diff --git a/.github/ISSUE_TEMPLATE/反馈问题.md b/.github/ISSUE_TEMPLATE/反馈问题.md index b21442f53..73ad4b2c6 100644 --- a/.github/ISSUE_TEMPLATE/反馈问题.md +++ b/.github/ISSUE_TEMPLATE/反馈问题.md @@ -11,9 +11,10 @@ assignees: '' ⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭。 -> 请在下方中括号内输入 x 来表示你已经知晓相关内容。 +请在下方中括号内输入 x 来表示你已经知晓相关内容。 - [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答; - [ ] 我确认已经在 [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) 列表(包括已经 Close 的)中搜索了此次反馈的问题,没有找到解答。 +- [ ] 我确认已经在 [Vercel 使用教程](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/vercel-cn.md) 中搜索了此次反馈的问题,没有找到解答。 **描述问题** 请在此描述你遇到了什么问题。 diff --git a/Dockerfile b/Dockerfile index 7ed7bc155..7755b1a53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /app COPY package.json yarn.lock ./ -RUN yarn config set registry 'https://registry.npm.taobao.org' +RUN yarn config set registry 'https://registry.npmmirror.com/' RUN yarn install FROM base AS builder diff --git a/README.md b/README.md index 073c22a7b..bd29b4c47 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,9 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. 一键免费部署你的私人 ChatGPT 网页应用。 -[Demo](https://chat-gpt-next-web.vercel.app/) / [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Join Discord](https://discord.gg/zrhvHCr79N) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa) - -[演示](https://chat-gpt-next-web.vercel.app/) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://user-images.githubusercontent.com/16968934/234462588-e8eff256-f5ca-46ef-8f5f-d7db6d28735a.jpg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) +[Demo](https://chatgpt.nextweb.fun/) / [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Join Discord](https://discord.gg/zrhvHCr79N) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa) +[演示](https://chatgpt.nextweb.fun/) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://user-images.githubusercontent.com/16968934/236402186-fa76e930-64f5-47ae-b967-b0f04b1fbf56.jpg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) [![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) @@ -28,6 +27,7 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. - Privacy first, all data stored locally in the browser - Responsive design, dark mode and PWA - Fast first screen loading speed (~100kb), support streaming response +- New in v2: create, share and debug your chat tools with prompt templates (mask) - 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 @@ -37,7 +37,7 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. - [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) - [x] User Prompt: user can edit and save custom prompts to prompt list -- [ ] Prompt Template: create a new chat with pre-defined in-context prompts +- [x] Prompt Template: create a new chat with pre-defined in-context prompts [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993) - [ ] Share as image, share to ShareGPT - [ ] Desktop App with tauri - [ ] Self-host Model: support llama, alpaca, ChatGLM, BELLE etc. @@ -48,12 +48,17 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. - User login, accounts, cloud sync - UI text customize +## What's New + +- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/). + ## 主要功能 - 在 1 分钟内使用 Vercel **免费一键部署** - 精心设计的 UI,响应式设计,支持深色模式,支持 PWA - 极快的首屏加载速度(~100kb),支持流式响应 - 隐私安全,所有数据保存在用户浏览器本地 +- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话 - 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts) - 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话 - 一键导出聊天记录,完整的 Markdown 支持 @@ -63,7 +68,7 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. - [x] 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) - [x] 允许用户自行编辑内置 Prompt 列表 -- [ ] 提示词模板:使用预制上下文快速定制新对话 +- [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993) - [ ] 分享为图片,分享到 ShareGPT - [ ] 使用 tauri 打包桌面应用 - [ ] 支持自部署的大语言模型 @@ -74,6 +79,10 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. - 界面文字自定义 - 用户登录、账号管理、消息云同步 +## 最新动态 + +- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。 + ## Get Started > [简体中文 > 如何开始使用](./README_CN.md#开始使用) @@ -103,6 +112,8 @@ We recommend that you follow the steps below to re-deploy: ### Enable Automatic Updates +> If you encounter a failure of Upstream Sync execution, please manually sync fork once. + After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour: ![Automatic Updates](./docs/images/enable-actions.jpg) @@ -151,6 +162,12 @@ Override openai api request base url. Specify OpenAI organization ID. +### `HIDE_USER_API_KEY` (optional) + +> Default: Empty + +If you do not want users to input their own API key, set this environment variable to 1. + ## Development > [简体中文 > 如何进行二次开发](./README_CN.md#开发) @@ -236,7 +253,7 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s [@hotic](https://github.com/hotic) [@WingCH](https://github.com/WingCH) [@jtung4](https://github.com/jtung4) - +[@micozhu](https://github.com/micozhu) ### Contributor diff --git a/README_CN.md b/README_CN.md index 5e9813b84..60302be63 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,16 +15,6 @@ -## 主要功能 - -- 在 1 分钟内使用 Vercel **免费一键部署** -- 精心设计的 UI,响应式设计,支持深色模式 -- 极快的首屏加载速度(~100kb) -- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts) -- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话 -- 一键导出聊天记录,完整的 Markdown 支持 -- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问 - ## 开始使用 1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys); @@ -44,6 +34,8 @@ ### 打开自动更新 +> 如果你遇到了 Upstream Sync 执行错误,请手动 Sync Fork 一次! + 当你 fork 项目之后,由于 Github 的限制,需要手动去你 fork 后的项目的 Actions 页面启用 Workflows,并启用 Upstream Sync Action,启用之后即可开启每小时定时自动更新: ![自动更新](./docs/images/enable-actions.jpg) @@ -98,6 +90,10 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填 指定 OpenAI 中的组织 ID。 +### `HIDE_USER_API_KEY` (可选) + +如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。 + ## 开发 > 强烈不建议在本地进行开发或者部署,由于一些技术原因,很难在本地配置好 OpenAI API 代理,除非你能保证可以直连 OpenAI 服务器。 @@ -115,15 +111,16 @@ OPENAI_API_KEY= ### 本地开发 1. 安装 nodejs 18 和 yarn,具体细节请询问 ChatGPT; -2. 执行 `yarn install && yarn dev` 即可。⚠️注意:此命令仅用于本地开发,不要用于部署! +2. 执行 `yarn install && yarn dev` 即可。⚠️ 注意:此命令仅用于本地开发,不要用于部署! 3. 如果你想本地部署,请使用 `yarn install && yarn start` 命令,你可以配合 pm2 来守护进程,防止被杀死,详情询问 ChatGPT。 ## 部署 ### 容器部署 (推荐) + > Docker 版本需要在 20 及其以上,否则会提示找不到镜像。 -> ⚠️注意:docker 版本在大多数时间都会落后最新的版本 1 到 2 天,所以部署后会持续出现“存在更新”的提示,属于正常现象。 +> ⚠️ 注意:docker 版本在大多数时间都会落后最新的版本 1 到 2 天,所以部署后会持续出现“存在更新”的提示,属于正常现象。 ```shell docker pull yidadaa/chatgpt-next-web @@ -155,7 +152,7 @@ docker run -d -p 3000:3000 \ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) ``` -⚠️注意:如果你安装过程中遇到了问题,请使用 docker 部署。 +⚠️ 注意:如果你安装过程中遇到了问题,请使用 docker 部署。 ## 鸣谢 diff --git a/app/api/auth.ts b/app/api/auth.ts new file mode 100644 index 000000000..1005c5fff --- /dev/null +++ b/app/api/auth.ts @@ -0,0 +1,71 @@ +import { NextRequest } from "next/server"; +import { getServerSideConfig } from "../config/server"; +import md5 from "spark-md5"; +import { ACCESS_CODE_PREFIX } from "../constant"; + +const serverConfig = getServerSideConfig(); + +function getIP(req: NextRequest) { + let ip = req.ip ?? req.headers.get("x-real-ip"); + const forwardedFor = req.headers.get("x-forwarded-for"); + + if (!ip && forwardedFor) { + ip = forwardedFor.split(",").at(0) ?? ""; + } + + return ip; +} + +function parseApiKey(bearToken: string) { + const token = bearToken.trim().replaceAll("Bearer ", "").trim(); + const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX); + + return { + accessCode: isOpenAiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length), + apiKey: isOpenAiKey ? token : "", + }; +} + +export function auth(req: NextRequest) { + const authToken = req.headers.get("Authorization") ?? ""; + + // check if it is openai api key or user token + const { accessCode, apiKey: token } = parseApiKey(authToken); + + const hashedCode = md5.hash(accessCode ?? "").trim(); + + console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]); + console.log("[Auth] got access code:", accessCode); + console.log("[Auth] hashed access code:", hashedCode); + console.log("[User IP] ", getIP(req)); + console.log("[Time] ", new Date().toLocaleString()); + + if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) { + return { + error: true, + needAccessCode: true, + msg: "Please go settings page and fill your access code.", + }; + } + + // if user does not provide an api key, inject system api key + if (!token) { + const apiKey = serverConfig.apiKey; + if (apiKey) { + console.log("[Auth] use system api key"); + req.headers.set("Authorization", `Bearer ${apiKey}`); + } else { + console.log("[Auth] admin did not provide an api key"); + return { + error: true, + msg: "Empty Api Key", + }; + } + } else { + console.log("[Auth] use user api key"); + } + + return { + error: false, + }; +} diff --git a/app/api/chat-stream/route.ts b/app/api/chat-stream/route.ts deleted file mode 100644 index 22550e39c..000000000 --- a/app/api/chat-stream/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createParser } from "eventsource-parser"; -import { NextRequest } from "next/server"; -import { requestOpenai } from "../common"; - -async function createStream(req: NextRequest) { - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - - 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) { - if (event.type === "event") { - const data = event.data; - // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream - if (data === "[DONE]") { - controller.close(); - return; - } - try { - const json = JSON.parse(data); - const text = json.choices[0].delta.content; - const queue = encoder.encode(text); - controller.enqueue(queue); - } catch (e) { - controller.error(e); - } - } - } - - const parser = createParser(onParse); - for await (const chunk of res.body as any) { - parser.feed(decoder.decode(chunk, { stream: true })); - } - }, - }); - return stream; -} - -export async function POST(req: NextRequest) { - try { - const stream = await createStream(req); - return new Response(stream); - } catch (error) { - console.error("[Chat Stream]", error); - return new Response( - ["```json\n", JSON.stringify(error, null, " "), "\n```"].join(""), - ); - } -} - -export const runtime = "experimental-edge"; diff --git a/app/api/common.ts b/app/api/common.ts index 22e71884f..861caf3ba 100644 --- a/app/api/common.ts +++ b/app/api/common.ts @@ -6,8 +6,11 @@ const PROTOCOL = process.env.PROTOCOL ?? DEFAULT_PROTOCOL; const BASE_URL = process.env.BASE_URL ?? OPENAI_URL; export async function requestOpenai(req: NextRequest) { - const apiKey = req.headers.get("token"); - const openaiPath = req.headers.get("path"); + const authValue = req.headers.get("Authorization") ?? ""; + const openaiPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll( + "/api/openai/", + "", + ); let baseUrl = BASE_URL; @@ -22,12 +25,19 @@ export async function requestOpenai(req: NextRequest) { console.log("[Org ID]", process.env.OPENAI_ORG_ID); } + if (!authValue || !authValue.startsWith("Bearer sk-")) { + console.error("[OpenAI Request] invlid api key provided", authValue); + } + return fetch(`${baseUrl}/${openaiPath}`, { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - ...(process.env.OPENAI_ORG_ID && { "OpenAI-Organization": process.env.OPENAI_ORG_ID }), + Authorization: authValue, + ...(process.env.OPENAI_ORG_ID && { + "OpenAI-Organization": process.env.OPENAI_ORG_ID, + }), }, + cache: "no-store", method: req.method, body: req.body, }); diff --git a/app/api/config/route.ts b/app/api/config/route.ts index e04e22a0c..62c8d60fb 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -8,14 +8,18 @@ const serverConfig = getServerSideConfig(); // 警告!不要在这里写入任何敏感信息! const DANGER_CONFIG = { needCode: serverConfig.needCode, + hideUserApiKey: serverConfig.hideUserApiKey, }; declare global { type DangerConfig = typeof DANGER_CONFIG; } -export async function POST(req: NextRequest) { - return NextResponse.json({ - needCode: serverConfig.needCode, - }); +async function handle() { + return NextResponse.json(DANGER_CONFIG); } + +export const GET = handle; +export const POST = handle; + +export const runtime = "edge"; diff --git a/app/api/openai/[...path]/route.ts b/app/api/openai/[...path]/route.ts new file mode 100644 index 000000000..1ca103c64 --- /dev/null +++ b/app/api/openai/[...path]/route.ts @@ -0,0 +1,101 @@ +import { createParser } from "eventsource-parser"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "../../auth"; +import { requestOpenai } from "../../common"; + +async function createStream(res: Response) { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const stream = new ReadableStream({ + async start(controller) { + function onParse(event: any) { + if (event.type === "event") { + const data = event.data; + // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream + if (data === "[DONE]") { + controller.close(); + return; + } + try { + const json = JSON.parse(data); + const text = json.choices[0].delta.content; + const queue = encoder.encode(text); + controller.enqueue(queue); + } catch (e) { + controller.error(e); + } + } + } + + const parser = createParser(onParse); + for await (const chunk of res.body as any) { + parser.feed(decoder.decode(chunk, { stream: true })); + } + }, + }); + return stream; +} + +function formatResponse(msg: any) { + const jsonMsg = ["```json\n", JSON.stringify(msg, null, " "), "\n```"].join( + "", + ); + return new Response(jsonMsg); +} + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[OpenAI Route] params ", params); + + const authResult = auth(req); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const api = await requestOpenai(req); + + const contentType = api.headers.get("Content-Type") ?? ""; + + // streaming response + if (contentType.includes("stream")) { + const stream = await createStream(api); + const res = new Response(stream); + res.headers.set("Content-Type", contentType); + return res; + } + + // try to parse error msg + try { + const mayBeErrorBody = await api.json(); + if (mayBeErrorBody.error) { + console.error("[OpenAI Response] ", mayBeErrorBody); + return formatResponse(mayBeErrorBody); + } else { + const res = new Response(JSON.stringify(mayBeErrorBody)); + res.headers.set("Content-Type", "application/json"); + res.headers.set("Cache-Control", "no-cache"); + return res; + } + } catch (e) { + console.error("[OpenAI Parse] ", e); + return formatResponse({ + msg: "invalid response from openai server", + error: e, + }); + } + } catch (e) { + console.error("[OpenAI] ", e); + return formatResponse(e); + } +} + +export const GET = handle; +export const POST = handle; + +export const runtime = "edge"; diff --git a/app/api/openai/route.ts b/app/api/openai/route.ts deleted file mode 100644 index bed70d928..000000000 --- a/app/api/openai/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { requestOpenai } from "../common"; - -async function makeRequest(req: NextRequest) { - try { - 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); - return NextResponse.json( - { - error: true, - msg: JSON.stringify(e), - }, - { - status: 500, - } - ); - } -} - -export async function POST(req: NextRequest) { - return makeRequest(req); -} - -export async function GET(req: NextRequest) { - return makeRequest(req); -} - -export const runtime = "experimental-edge"; diff --git a/app/api/openai/typing.ts b/app/api/openai/typing.ts index b936530c3..2286d2312 100644 --- a/app/api/openai/typing.ts +++ b/app/api/openai/typing.ts @@ -5,3 +5,5 @@ import type { export type ChatRequest = CreateChatCompletionRequest; export type ChatResponse = CreateChatCompletionResponse; + +export type Updater = (updater: (value: T) => void) => void; diff --git a/app/components/button.module.scss b/app/components/button.module.scss index 3a3393e7b..5aa53dcf9 100644 --- a/app/components/button.module.scss +++ b/app/components/button.module.scss @@ -18,6 +18,15 @@ cursor: not-allowed; opacity: 0.5; } + + &.primary { + background-color: var(--primary); + color: white; + + path { + fill: white !important; + } + } } .shadow { diff --git a/app/components/button.tsx b/app/components/button.tsx index 1675a4b7d..f93741b39 100644 --- a/app/components/button.tsx +++ b/app/components/button.tsx @@ -4,11 +4,11 @@ import styles from "./button.module.scss"; export function IconButton(props: { onClick?: () => void; - icon: JSX.Element; + icon?: JSX.Element; + type?: "primary" | "danger"; text?: string; bordered?: boolean; shadow?: boolean; - noDark?: boolean; className?: string; title?: string; disabled?: boolean; @@ -19,18 +19,24 @@ export function IconButton(props: { styles["icon-button"] + ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${ props.className ?? "" - } clickable` + } clickable ${styles[props.type ?? ""]}` } onClick={props.onClick} title={props.title} disabled={props.disabled} role="button" > -
- {props.icon} -
+ {props.icon && ( +
+ {props.icon} +
+ )} + {props.text && (
{props.text}
)} diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx index 626336afd..02ea086b2 100644 --- a/app/components/chat-list.tsx +++ b/app/components/chat-list.tsx @@ -1,4 +1,6 @@ import DeleteIcon from "../icons/delete.svg"; +import BotIcon from "../icons/bot.svg"; + import styles from "./home.module.scss"; import { DragDropContext, @@ -12,6 +14,8 @@ import { useChatStore } from "../store"; import Locale from "../locales"; import { Link, useNavigate } from "react-router-dom"; import { Path } from "../constant"; +import { MaskAvatar } from "./mask"; +import { Mask } from "../store/mask"; export function ChatItem(props: { onClick?: () => void; @@ -23,6 +27,7 @@ export function ChatItem(props: { id: number; index: number; narrow?: boolean; + mask: Mask; }) { return ( @@ -35,9 +40,19 @@ export function ChatItem(props: { ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} + title={`${props.title}\n${Locale.ChatItem.ChatItemCount( + props.count, + )}`} > {props.narrow ? ( -
{props.count}
+
+
+ +
+
+ {props.count} +
+
) : ( <>
{props.title}
@@ -45,12 +60,17 @@ export function ChatItem(props: {
{Locale.ChatItem.ChatItemCount(props.count)}
-
{props.time}
+
+ {new Date(props.time).toLocaleString()} +
)} -
+
@@ -60,14 +80,14 @@ export function ChatItem(props: { } export function ChatList(props: { narrow?: boolean }) { - const [sessions, selectedIndex, selectSession, removeSession, moveSession] = - useChatStore((state) => [ + const [sessions, selectedIndex, selectSession, moveSession] = useChatStore( + (state) => [ state.sessions, state.currentSessionIndex, state.selectSession, - state.removeSession, state.moveSession, - ]); + ], + ); const chatStore = useChatStore(); const navigate = useNavigate(); @@ -99,7 +119,7 @@ export function ChatList(props: { narrow?: boolean }) { {sessions.map((item, i) => ( ))} {provided.placeholder} diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index 7cd2889f7..3a1be3910 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -53,6 +53,20 @@ } } +.section-title { + font-size: 12px; + font-weight: bold; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + + .section-title-action { + display: flex; + align-items: center; + } +} + .context-prompt { .context-prompt-row { display: flex; @@ -81,25 +95,13 @@ } .memory-prompt { - margin-top: 20px; - - .memory-prompt-title { - 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; - } - } + margin: 20px 0; .memory-prompt-content { - background-color: var(--gray); - border-radius: 6px; + background-color: var(--white); + color: var(--black); + border: var(--border-in-light); + border-radius: 10px; padding: 10px; font-size: 12px; user-select: text; diff --git a/app/components/chat.tsx b/app/components/chat.tsx index cb163cff0..ca51a06af 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -1,5 +1,5 @@ -import { useDebounce, useDebouncedCallback } from "use-debounce"; -import { memo, useState, useRef, useEffect, useLayoutEffect } from "react"; +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"; @@ -9,12 +9,11 @@ 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 BlackBotIcon from "../icons/black-bot.svg"; -import AddIcon from "../icons/add.svg"; -import DeleteIcon from "../icons/delete.svg"; +import PromptIcon from "../icons/prompt.svg"; +import MaskIcon from "../icons/mask.svg"; import MaxIcon from "../icons/max.svg"; import MinIcon from "../icons/min.svg"; +import ResetIcon from "../icons/reload.svg"; import LightIcon from "../icons/light.svg"; import DarkIcon from "../icons/dark.svg"; @@ -31,14 +30,14 @@ import { createMessage, useAccessStore, Theme, - ModelType, useAppConfig, + ModelConfig, + DEFAULT_TOPIC, } from "../store"; import { copyToClipboard, downloadAs, - getEmojiUrl, selectOrCopy, autoGrowTextArea, useMobileScreen, @@ -54,43 +53,21 @@ import { IconButton } from "./button"; import styles from "./home.module.scss"; import chatStyle from "./chat.module.scss"; -import { Input, Modal, showModal } from "./ui-lib"; -import { useNavigate } from "react-router-dom"; +import { ListItem, Modal, showModal } from "./ui-lib"; +import { useLocation, useNavigate } from "react-router-dom"; import { Path } from "../constant"; +import { Avatar } from "./emoji"; +import { MaskAvatar, MaskConfig } from "./mask"; +import { + DEFAULT_MASK_AVATAR, + DEFAULT_MASK_ID, + useMaskStore, +} from "../store/mask"; -const Markdown = dynamic( - async () => memo((await import("./markdown")).Markdown), - { - loading: () => , - }, -); - -const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, { +const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { loading: () => , }); -export function Avatar(props: { role: Message["role"]; model?: ModelType }) { - const config = useAppConfig(); - - if (props.role !== "user") { - return ( -
- {props.model?.startsWith("gpt-4") ? ( - - ) : ( - - )} -
- ); - } - - return ( -
- -
- ); -} - function exportMessages(messages: Message[], topic: string) { const mdText = `# ${topic}\n\n` + @@ -129,6 +106,64 @@ function exportMessages(messages: Message[], topic: string) { }); } +export function SessionConfigModel(props: { onClose: () => void }) { + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + const maskStore = useMaskStore(); + const navigate = useNavigate(); + + return ( +
+ props.onClose()} + actions={[ + } + bordered + text={Locale.Chat.Config.Reset} + onClick={() => + confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession() + } + />, + } + bordered + text={Locale.Chat.Config.SaveAs} + onClick={() => { + navigate(Path.Masks); + setTimeout(() => { + maskStore.create(session.mask); + }, 500); + }} + />, + ]} + > + { + const mask = { ...session.mask }; + updater(mask); + chatStore.updateCurrentSession((session) => (session.mask = mask)); + }} + extraListItems={ + session.mask.modelConfig.sendMemory ? ( + + ) : ( + <> + ) + } + > + +
+ ); +} + function PromptToast(props: { showToast?: boolean; showModal?: boolean; @@ -136,25 +171,7 @@ function PromptToast(props: { }) { 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; - }); - }; + const context = session.mask.context; return (
@@ -171,115 +188,7 @@ function PromptToast(props: {
)} {props.showModal && ( -
- props.setShowModal(false)} - actions={[ - } - bordered - text={Locale.Memory.Reset} - onClick={() => - confirm(Locale.Memory.ResetConfirm) && - chatStore.resetSession() - } - />, - } - bordered - text={Locale.Memory.Copy} - onClick={() => copyToClipboard(session.memoryPrompt)} - />, - ]} - > - <> -
- {context.map((c, i) => ( -
- - - updateContextPrompt(i, { - ...c, - content: e.currentTarget.value as any, - }) - } - /> - } - className={chatStyle["context-delete-button"]} - onClick={() => removeContextPrompt(i)} - bordered - /> -
- ))} - -
- } - text={Locale.Context.Add} - bordered - className={chatStyle["context-prompt-button"]} - onClick={() => - addContextPrompt({ - role: "system", - content: "", - date: "", - }) - } - /> -
-
-
-
- - {Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "} - {session.messages.length}) - - - -
-
- {session.memoryPrompt || Locale.Memory.EmptyContent} -
-
- -
-
+ props.setShowModal(false)} /> )} ); @@ -315,15 +224,63 @@ export function PromptHints(props: { prompts: Prompt[]; onPromptSelect: (prompt: Prompt) => void; }) { - if (props.prompts.length === 0) return null; + const noPrompts = props.prompts.length === 0; + const [selectIndex, setSelectIndex] = useState(0); + const selectedRef = useRef(null); + useEffect(() => { + setSelectIndex(0); + }, [props.prompts.length]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (noPrompts) return; + + // arrow up / down to select prompt + const changeIndex = (delta: number) => { + e.stopPropagation(); + e.preventDefault(); + const nextIndex = Math.max( + 0, + Math.min(props.prompts.length - 1, selectIndex + delta), + ); + setSelectIndex(nextIndex); + selectedRef.current?.scrollIntoView({ + block: "center", + }); + }; + + if (e.key === "ArrowUp") { + changeIndex(1); + } else if (e.key === "ArrowDown") { + changeIndex(-1); + } else if (e.key === "Enter") { + const selectedPrompt = props.prompts.at(selectIndex); + if (selectedPrompt) { + props.onPromptSelect(selectedPrompt); + } + } + }; + + window.addEventListener("keydown", onKeyDown); + + return () => window.removeEventListener("keydown", onKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [noPrompts, selectIndex]); + + if (noPrompts) return null; return (
{props.prompts.map((prompt, i) => (
props.onPromptSelect(prompt)} + onMouseEnter={() => setSelectIndex(i)} >
{prompt.title}
{prompt.content}
@@ -360,9 +317,11 @@ function useScrollToBottom() { export function ChatActions(props: { showPromptModal: () => void; scrollToBottom: () => void; + showPromptHints: () => void; hitBottom: boolean; }) { const config = useAppConfig(); + const navigate = useNavigate(); // switch themes const theme = config.theme; @@ -417,6 +376,22 @@ export function ChatActions(props: { ) : null}
+ +
+ +
+ +
{ + navigate(Path.Masks); + }} + > + +
); } @@ -443,7 +418,7 @@ export function Chat() { const navigate = useNavigate(); const onChatBodyScroll = (e: HTMLElement) => { - const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20; + const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 100; setHitBottom(isTouchBottom); }; @@ -459,9 +434,9 @@ export function Chat() { ); const onPromptSelect = (prompt: Prompt) => { - setUserInput(prompt.content); setPromptHints([]); inputRef.current?.focus(); + setTimeout(() => setUserInput(prompt.content), 60); }; // auto grow input @@ -470,7 +445,7 @@ export function Chat() { () => { const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1; const inputRows = Math.min( - 5, + 20, Math.max(2 + Number(!isMobileScreen), rows), ); setInputRows(inputRows); @@ -505,7 +480,7 @@ export function Chat() { // submit user input const onUserSubmit = () => { - if (userInput.length <= 0) return; + if (userInput.trim() === "") return; setIsLoading(true); chatStore.onUserInput(userInput).then(() => setIsLoading(false)); setBeforeInput(userInput); @@ -585,7 +560,7 @@ export function Chat() { inputRef.current?.focus(); }; - const context: RenderMessage[] = session.context.slice(); + const context: RenderMessage[] = session.mask.context.slice(); const accessStore = useAccessStore(); @@ -639,29 +614,26 @@ export function Chat() { } }; - // Auto focus - useEffect(() => { - if (isMobileScreen) return; - inputRef.current?.focus(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const location = useLocation(); + const isChat = location.pathname === Path.Chat; + const autoFocus = !isMobileScreen || isChat; // only focus in chat page return (
-
-
+
+
- {session.topic} + {!session.topic ? DEFAULT_TOPIC : session.topic}
-
+
{Locale.Chat.SubTitle(session.messages.length)}
-
-
+
+
} bordered @@ -669,14 +641,14 @@ export function Chat() { onClick={() => navigate(Path.Home)} />
-
+
} bordered onClick={renameSession} />
-
+
} bordered @@ -690,7 +662,7 @@ export function Chat() { />
{!isMobileScreen && ( -
+
: } bordered @@ -739,7 +711,11 @@ export function Chat() { >
- + {message.role === "user" ? ( + + ) : ( + + )}
{showTyping && (
@@ -794,6 +770,7 @@ export function Chat() { }} fontSize={fontSize} parentRef={scrollRef} + defaultShow={i >= messages.length - 10} />
{!isUser && !message.preview && ( @@ -816,6 +793,10 @@ export function Chat() { showPromptModal={() => setShowPromptModal(true)} scrollToBottom={scrollToBottom} hitBottom={hitBottom} + showPromptHints={() => { + inputRef.current?.focus(); + onSearch(""); + }} />
); } + +export function PasswordInput(props: HTMLProps) { + const [visible, setVisible] = useState(false); + + function changeVisibility() { + setVisible(!visible); + } + + return ( +
+ : } + onClick={changeVisibility} + className={"password-eye"} + /> + +
+ ); +} diff --git a/app/config/build.ts b/app/config/build.ts index 49205c9b9..79ed5d3e8 100644 --- a/app/config/build.ts +++ b/app/config/build.ts @@ -1,13 +1,10 @@ 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() - ); + return childProcess + .execSync('git log -1 --format="%at000" --date=unix') + .toString() + .trim(); } catch (e) { console.error("[Build Config] No git or not from git repo."); return "unknown"; diff --git a/app/config/server.ts b/app/config/server.ts index 798177e59..c1cf439b7 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -7,6 +7,7 @@ declare global { CODE?: string; PROXY_URL?: string; VERCEL?: string; + HIDE_USER_API_KEY?: string; // disable user's api key input } } } @@ -38,5 +39,6 @@ export const getServerSideConfig = () => { needCode: ACCESS_CODES.size > 0, proxyUrl: process.env.PROXY_URL, isVercel: !!process.env.VERCEL, + hideUserApiKey: !!process.env.HIDE_USER_API_KEY, }; }; diff --git a/app/constant.ts b/app/constant.ts index 43ae4cc68..fed20cafa 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -11,8 +11,30 @@ export enum Path { Home = "/", Chat = "/chat", Settings = "/settings", + NewChat = "/new-chat", + Masks = "/masks", +} + +export enum SlotID { + AppBody = "app-body", +} + +export enum FileName { + Masks = "masks.json", + Prompts = "prompts.json", +} + +export enum StoreKey { + Chat = "chat-next-web-store", + Access = "access-control", + Config = "app-config", + Mask = "mask-store", + Prompt = "prompt-store", + Update = "chat-update", } export const MAX_SIDEBAR_WIDTH = 500; export const MIN_SIDEBAR_WIDTH = 230; export const NARROW_SIDEBAR_WIDTH = 100; + +export const ACCESS_CODE_PREFIX = "ak-"; diff --git a/app/icons/black-bot.svg b/app/icons/black-bot.svg index 3aad2adde..5c253fb0e 100644 --- a/app/icons/black-bot.svg +++ b/app/icons/black-bot.svg @@ -18,7 +18,7 @@ - diff --git a/app/icons/dark.svg b/app/icons/dark.svg index 3eebc373e..c96c188bb 100644 --- a/app/icons/dark.svg +++ b/app/icons/dark.svg @@ -1 +1 @@ - + diff --git a/app/icons/delete.svg b/app/icons/delete.svg index 49ab97b81..b28095b5f 100644 --- a/app/icons/delete.svg +++ b/app/icons/delete.svg @@ -7,6 +7,6 @@ fill-rule="evenodd" fill="#D8D8D8" fill-opacity="0.009999999776482582" /> + fill="#333333" fill-opacity="1" /> - \ No newline at end of file + diff --git a/app/icons/left.svg b/app/icons/left.svg new file mode 100644 index 000000000..8f1cf52d7 --- /dev/null +++ b/app/icons/left.svg @@ -0,0 +1 @@ + diff --git a/app/icons/light.svg b/app/icons/light.svg index 22cfa1fff..a425e0107 100644 --- a/app/icons/light.svg +++ b/app/icons/light.svg @@ -1 +1 @@ - + diff --git a/app/icons/lightning.svg b/app/icons/lightning.svg new file mode 100644 index 000000000..d208ad87d --- /dev/null +++ b/app/icons/lightning.svg @@ -0,0 +1 @@ + diff --git a/app/icons/mask.svg b/app/icons/mask.svg new file mode 100644 index 000000000..69b600c57 --- /dev/null +++ b/app/icons/mask.svg @@ -0,0 +1 @@ + diff --git a/app/icons/plugin.svg b/app/icons/plugin.svg new file mode 100644 index 000000000..8da8b0e78 --- /dev/null +++ b/app/icons/plugin.svg @@ -0,0 +1 @@ + diff --git a/app/icons/prompt.svg b/app/icons/prompt.svg new file mode 100644 index 000000000..686f4c17d --- /dev/null +++ b/app/icons/prompt.svg @@ -0,0 +1 @@ + diff --git a/app/icons/upload.svg b/app/icons/upload.svg new file mode 100644 index 000000000..f9999a7f9 --- /dev/null +++ b/app/icons/upload.svg @@ -0,0 +1 @@ + diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 01a540ba7..9af3d86e8 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -3,7 +3,8 @@ import { SubmitKey } from "../store/config"; const cn = { WIP: "该功能仍在开发中……", Error: { - Unauthorized: "现在是未授权状态,请点击左下角设置按钮输入访问密码。", + Unauthorized: + "现在是未授权状态,请点击左下角[设置](/#/settings)按钮输入访问密码。", }, ChatItem: { ChatItemCount: (count: number) => `${count} 条对话`, @@ -29,6 +30,10 @@ const cn = { return inputHints + ",/ 触发补全"; }, Send: "发送", + Config: { + Reset: "重置默认", + SaveAs: "另存为面具", + }, }, Export: { Title: "导出聊天记录为 Markdown", @@ -39,8 +44,8 @@ const cn = { }, Memory: { Title: "历史摘要", - EmptyContent: "尚未总结", - Send: "启用总结并发送摘要", + EmptyContent: "对话内容过短,无需总结", + Send: "自动压缩聊天记录并作为上下文发送", Copy: "复制摘要", Reset: "重置对话", ResetConfirm: "重置后将清空当前对话记录以及历史摘要,确认重置?", @@ -58,15 +63,12 @@ const cn = { ClearAll: "清除所有数据", ResetAll: "重置所有选项", Close: "关闭", - ConfirmResetAll: { - Confirm: "确认清除所有配置?", - }, - ConfirmClearAll: { - Confirm: "确认清除所有聊天记录?", - }, + ConfirmResetAll: "确认重置所有配置?", + ConfirmClearAll: "确认清除所有数据?", }, Lang: { Name: "Language", + All: "所有语言", Options: { cn: "简体中文", en: "English", @@ -95,7 +97,14 @@ const cn = { SendKey: "发送键", Theme: "主题", TightBorder: "无边框模式", - SendPreviewBubble: "发送预览气泡", + SendPreviewBubble: { + Title: "预览气泡", + SubTitle: "在预览气泡中预览 Markdown 内容", + }, + Mask: { + Title: "面具启动页", + SubTitle: "新建聊天时,展示面具启动页", + }, Prompt: { Disable: { Title: "禁用提示词自动补全", @@ -107,9 +116,12 @@ const cn = { Edit: "编辑", Modal: { Title: "提示词列表", - Add: "增加一条", + Add: "新建", Search: "搜索提示词", }, + EditModal: { + Title: "编辑提示词", + }, }, HistoryCount: { Title: "附带历史消息数", @@ -124,6 +136,7 @@ const cn = { SubTitle: "使用自己的 Key 可绕过密码访问限制", Placeholder: "OpenAI API Key", }, + Usage: { Title: "余额查询", SubTitle(used: any, total: any) { @@ -141,7 +154,7 @@ const cn = { Model: "模型 (model)", Temperature: { Title: "随机性 (temperature)", - SubTitle: "值越大,回复越随机,大于 1 的值可能会导致乱码", + SubTitle: "值越大,回复越随机", }, MaxTokens: { Title: "单次回复限制 (max_tokens)", @@ -164,7 +177,6 @@ const cn = { Summarize: "简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 200 字以内", }, - ConfirmClearAll: "确认清除所有聊天、设置数据?", }, Copy: { Success: "已写入剪切板", @@ -172,8 +184,55 @@ const cn = { }, Context: { Toast: (x: any) => `已设置 ${x} 条前置上下文`, - Edit: "前置上下文和历史记忆", - Add: "新增一条", + Edit: "当前对话设置", + Add: "新增预设对话", + }, + Plugin: { + Name: "插件", + }, + Mask: { + Name: "面具", + Page: { + Title: "预设角色面具", + SubTitle: (count: number) => `${count} 个预设角色定义`, + Search: "搜索角色面具", + Create: "新建", + }, + Item: { + Info: (count: number) => `包含 ${count} 条预设对话`, + Chat: "对话", + View: "查看", + Edit: "编辑", + Delete: "删除", + DeleteConfirm: "确认删除?", + }, + EditModal: { + Title: (readonly: boolean) => + `编辑预设面具 ${readonly ? "(只读)" : ""}`, + Download: "下载预设", + Clone: "克隆预设", + }, + Config: { + Avatar: "角色头像", + Name: "角色名称", + }, + }, + NewChat: { + Return: "返回", + Skip: "直接开始", + NotShow: "不再展示", + ConfirmNoShow: "确认禁用?禁用后可以随时在设置中重新启用。", + Title: "挑选一个面具", + SubTitle: "现在开始,与面具背后的灵魂思维碰撞", + More: "查看全部", + }, + + UI: { + Confirm: "确认", + Cancel: "取消", + Close: "关闭", + Create: "新建", + Edit: "编辑", }, }; diff --git a/app/locales/de.ts b/app/locales/de.ts index 42a4c8f68..048e575c5 100644 --- a/app/locales/de.ts +++ b/app/locales/de.ts @@ -31,6 +31,10 @@ const de: LocaleType = { return inputHints + ", / zum Durchsuchen von Prompts"; }, Send: "Senden", + Config: { + Reset: "Reset to Default", + SaveAs: "Save as Mask", + }, }, Export: { Title: "Alle Nachrichten", @@ -61,15 +65,13 @@ const de: LocaleType = { 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?", - }, + ConfirmResetAll: + "Möchten Sie wirklich alle Konfigurationen zurücksetzen?", + ConfirmClearAll: "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` + All: "All Languages", Options: { cn: "简体中文", en: "English", @@ -97,7 +99,14 @@ const de: LocaleType = { SendKey: "Senden-Taste", Theme: "Erscheinungsbild", TightBorder: "Enger Rahmen", - SendPreviewBubble: "Vorschau-Bubble senden", + SendPreviewBubble: { + Title: "Vorschau-Bubble senden", + SubTitle: "Preview markdown in bubble", + }, + Mask: { + Title: "Mask Splash Screen", + SubTitle: "Show a mask splash screen before starting new chat", + }, Prompt: { Disable: { Title: "Autovervollständigung deaktivieren", @@ -112,6 +121,9 @@ const de: LocaleType = { Add: "Add One", Search: "Search Prompts", }, + EditModal: { + Title: "Edit Prompt", + }, }, HistoryCount: { Title: "Anzahl der angehängten Nachrichten", @@ -171,8 +183,6 @@ const de: LocaleType = { 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", @@ -184,6 +194,53 @@ const de: LocaleType = { Edit: "Kontext- und Gedächtnis-Prompts", Add: "Hinzufügen", }, + Plugin: { + Name: "Plugin", + }, + Mask: { + Name: "Mask", + Page: { + Title: "Prompt Template", + SubTitle: (count: number) => `${count} prompt templates`, + Search: "Search Templates", + Create: "Create", + }, + Item: { + Info: (count: number) => `${count} prompts`, + Chat: "Chat", + View: "View", + Edit: "Edit", + Delete: "Delete", + DeleteConfirm: "Confirm to delete?", + }, + EditModal: { + Title: (readonly: boolean) => + `Edit Prompt Template ${readonly ? "(readonly)" : ""}`, + Download: "Download", + Clone: "Clone", + }, + Config: { + Avatar: "Bot Avatar", + Name: "Bot Name", + }, + }, + NewChat: { + Return: "Return", + Skip: "Skip", + Title: "Pick a Mask", + SubTitle: "Chat with the Soul behind the Mask", + More: "Find More", + NotShow: "Not Show Again", + ConfirmNoShow: "Confirm to disable?You can enable it in settings later.", + }, + + UI: { + Confirm: "Confirm", + Cancel: "Cancel", + Close: "Close", + Create: "Create", + Edit: "Edit", + }, }; export default de; diff --git a/app/locales/en.ts b/app/locales/en.ts index c27232cde..02aa8aa7d 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -2,7 +2,7 @@ import { SubmitKey } from "../store/config"; import type { LocaleType } from "./index"; const en: LocaleType = { - WIP: "WIP...", + WIP: "Coming Soon...", Error: { Unauthorized: "Unauthorized access, please enter access code in settings page.", @@ -31,6 +31,10 @@ const en: LocaleType = { return inputHints + ", / to search prompts"; }, Send: "Send", + Config: { + Reset: "Reset to Default", + SaveAs: "Save as Mask", + }, }, Export: { Title: "All Messages", @@ -61,15 +65,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?", - }, + ConfirmResetAll: "Are you sure you want to reset all configurations?", + ConfirmClearAll: "Are you sure you want to reset all data?", }, Lang: { Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language` + All: "All Languages", Options: { cn: "简体中文", en: "English", @@ -97,7 +98,14 @@ const en: LocaleType = { SendKey: "Send Key", Theme: "Theme", TightBorder: "Tight Border", - SendPreviewBubble: "Send Preview Bubble", + SendPreviewBubble: { + Title: "Send Preview Bubble", + SubTitle: "Preview markdown in bubble", + }, + Mask: { + Title: "Mask Splash Screen", + SubTitle: "Show a mask splash screen before starting new chat", + }, Prompt: { Disable: { Title: "Disable auto-completion", @@ -112,6 +120,9 @@ const en: LocaleType = { Add: "Add One", Search: "Search Prompts", }, + EditModal: { + Title: "Edit Prompt", + }, }, HistoryCount: { Title: "Attached Messages Count", @@ -169,7 +180,6 @@ const en: LocaleType = { Summarize: "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?", }, Copy: { Success: "Copied to clipboard", @@ -178,7 +188,54 @@ const en: LocaleType = { Context: { Toast: (x: any) => `With ${x} contextual prompts`, Edit: "Contextual and Memory Prompts", - Add: "Add One", + Add: "Add a Prompt", + }, + Plugin: { + Name: "Plugin", + }, + Mask: { + Name: "Mask", + Page: { + Title: "Prompt Template", + SubTitle: (count: number) => `${count} prompt templates`, + Search: "Search Templates", + Create: "Create", + }, + Item: { + Info: (count: number) => `${count} prompts`, + Chat: "Chat", + View: "View", + Edit: "Edit", + Delete: "Delete", + DeleteConfirm: "Confirm to delete?", + }, + EditModal: { + Title: (readonly: boolean) => + `Edit Prompt Template ${readonly ? "(readonly)" : ""}`, + Download: "Download", + Clone: "Clone", + }, + Config: { + Avatar: "Bot Avatar", + Name: "Bot Name", + }, + }, + NewChat: { + Return: "Return", + Skip: "Skip", + Title: "Pick a Mask", + SubTitle: "Chat with the Soul behind the Mask", + More: "Find More", + NotShow: "Not Show Again", + ConfirmNoShow: "Confirm to disable?You can enable it in settings later.", + }, + + UI: { + Confirm: "Confirm", + Cancel: "Cancel", + Close: "Close", + Create: "Create", + Edit: "Edit", }, }; diff --git a/app/locales/es.ts b/app/locales/es.ts index efecf113b..46d18a547 100644 --- a/app/locales/es.ts +++ b/app/locales/es.ts @@ -31,6 +31,10 @@ const es: LocaleType = { return inputHints; }, Send: "Enviar", + Config: { + Reset: "Reset to Default", + SaveAs: "Save as Mask", + }, }, Export: { Title: "Todos los mensajes", @@ -61,15 +65,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?", - }, + ConfirmResetAll: "Are you sure you want to reset all configurations?", + ConfirmClearAll: "Are you sure you want to reset all chat?", }, Lang: { Name: "Language", + All: "All Languages", Options: { cn: "简体中文", en: "Inglés", @@ -97,7 +98,14 @@ const es: LocaleType = { SendKey: "Tecla de envío", Theme: "Tema", TightBorder: "Borde ajustado", - SendPreviewBubble: "Enviar burbuja de vista previa", + SendPreviewBubble: { + Title: "Enviar burbuja de vista previa", + SubTitle: "Preview markdown in bubble", + }, + Mask: { + Title: "Mask Splash Screen", + SubTitle: "Show a mask splash screen before starting new chat", + }, Prompt: { Disable: { Title: "Desactivar autocompletado", @@ -112,6 +120,9 @@ const es: LocaleType = { Add: "Add One", Search: "Search Prompts", }, + EditModal: { + Title: "Edit Prompt", + }, }, HistoryCount: { Title: "Cantidad de mensajes adjuntos", @@ -169,8 +180,6 @@ const es: LocaleType = { 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", @@ -182,6 +191,53 @@ const es: LocaleType = { Edit: "Contextual and Memory Prompts", Add: "Add One", }, + Plugin: { + Name: "Plugin", + }, + Mask: { + Name: "Mask", + Page: { + Title: "Prompt Template", + SubTitle: (count: number) => `${count} prompt templates`, + Search: "Search Templates", + Create: "Create", + }, + Item: { + Info: (count: number) => `${count} prompts`, + Chat: "Chat", + View: "View", + Edit: "Edit", + Delete: "Delete", + DeleteConfirm: "Confirm to delete?", + }, + EditModal: { + Title: (readonly: boolean) => + `Edit Prompt Template ${readonly ? "(readonly)" : ""}`, + Download: "Download", + Clone: "Clone", + }, + Config: { + Avatar: "Bot Avatar", + Name: "Bot Name", + }, + }, + NewChat: { + Return: "Return", + Skip: "Skip", + Title: "Pick a Mask", + SubTitle: "Chat with the Soul behind the Mask", + More: "Find More", + NotShow: "Not Show Again", + ConfirmNoShow: "Confirm to disable?You can enable it in settings later.", + }, + + UI: { + Confirm: "Confirm", + Cancel: "Cancel", + Close: "Close", + Create: "Create", + Edit: "Edit", + }, }; export default es; diff --git a/app/locales/index.ts b/app/locales/index.ts index 389304f85..40f0a1ade 100644 --- a/app/locales/index.ts +++ b/app/locales/index.ts @@ -19,9 +19,10 @@ export const AllLangs = [ "jp", "de", ] as const; -type Lang = (typeof AllLangs)[number]; +export type Lang = (typeof AllLangs)[number]; const LANG_KEY = "lang"; +const DEFAULT_LANG = "en"; function getItem(key: string) { try { @@ -41,7 +42,8 @@ function getLanguage() { try { return navigator.language.toLowerCase(); } catch { - return "cn"; + console.log("[Lang] failed to detect user lang."); + return DEFAULT_LANG; } } @@ -60,7 +62,7 @@ export function getLang(): Lang { } } - return "en"; + return DEFAULT_LANG; } export function changeLang(lang: Lang) { diff --git a/app/locales/it.ts b/app/locales/it.ts index b519ef453..ee9a2c2bc 100644 --- a/app/locales/it.ts +++ b/app/locales/it.ts @@ -31,6 +31,10 @@ const it: LocaleType = { return inputHints; }, Send: "Invia", + Config: { + Reset: "Reset to Default", + SaveAs: "Save as Mask", + }, }, Export: { Title: "Tutti i messaggi", @@ -61,15 +65,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?", - }, + ConfirmResetAll: "Sei sicuro vuoi cancellare tutte le impostazioni?", + ConfirmClearAll: "Sei sicuro vuoi cancellare tutte le chat?", }, Lang: { Name: "Lingue", + All: "All Languages", Options: { cn: "简体中文", en: "English", @@ -97,7 +98,14 @@ const it: LocaleType = { SendKey: "Tasto invia", Theme: "Tema", TightBorder: "Schermo intero", - SendPreviewBubble: "Anteprima di digitazione", + SendPreviewBubble: { + Title: "Anteprima di digitazione", + SubTitle: "Preview markdown in bubble", + }, + Mask: { + Title: "Mask Splash Screen", + SubTitle: "Show a mask splash screen before starting new chat", + }, Prompt: { Disable: { Title: "Disabilita l'auto completamento", @@ -112,6 +120,9 @@ const it: LocaleType = { Add: "Add One", Search: "Search Prompts", }, + EditModal: { + Title: "Edit Prompt", + }, }, HistoryCount: { Title: "Conteggio dei messaggi allegati", @@ -170,8 +181,6 @@ const it: LocaleType = { Summarize: "Riassumi brevemente la nostra discussione in 200 caratteri o meno per usarla come spunto per una futura conversazione.", }, - ConfirmClearAll: - "Confermi la cancellazione di tutti i dati della chat e delle impostazioni?", }, Copy: { Success: "Copiato sugli appunti", @@ -183,6 +192,53 @@ const it: LocaleType = { Edit: "Prompt contestuali e di memoria", Add: "Aggiungi altro", }, + Plugin: { + Name: "Plugin", + }, + Mask: { + Name: "Mask", + Page: { + Title: "Prompt Template", + SubTitle: (count: number) => `${count} prompt templates`, + Search: "Search Templates", + Create: "Create", + }, + Item: { + Info: (count: number) => `${count} prompts`, + Chat: "Chat", + View: "View", + Edit: "Edit", + Delete: "Delete", + DeleteConfirm: "Confirm to delete?", + }, + EditModal: { + Title: (readonly: boolean) => + `Edit Prompt Template ${readonly ? "(readonly)" : ""}`, + Download: "Download", + Clone: "Clone", + }, + Config: { + Avatar: "Bot Avatar", + Name: "Bot Name", + }, + }, + NewChat: { + Return: "Return", + Skip: "Skip", + Title: "Pick a Mask", + SubTitle: "Chat with the Soul behind the Mask", + More: "Find More", + NotShow: "Not Show Again", + ConfirmNoShow: "Confirm to disable?You can enable it in settings later.", + }, + + UI: { + Confirm: "Confirm", + Cancel: "Cancel", + Close: "Close", + Create: "Create", + Edit: "Edit", + }, }; export default it; diff --git a/app/locales/jp.ts b/app/locales/jp.ts index 1c8d66d90..fb693cf5b 100644 --- a/app/locales/jp.ts +++ b/app/locales/jp.ts @@ -1,6 +1,7 @@ import { SubmitKey } from "../store/config"; +import type { LocaleType } from "./index"; -const jp = { +const jp: LocaleType = { WIP: "この機能は開発中です……", Error: { Unauthorized: @@ -30,6 +31,10 @@ const jp = { return inputHints + ",/ で自動補完をトリガー"; }, Send: "送信", + Config: { + Reset: "重置默认", + SaveAs: "另存为面具", + }, }, Export: { Title: "チャット履歴をMarkdown形式でエクスポート", @@ -60,15 +65,12 @@ const jp = { ClearAll: "すべてのデータをクリア", ResetAll: "すべてのオプションをリセット", Close: "閉じる", - ConfirmResetAll: { - Confirm: "すべての設定をリセットしてもよろしいですか?", - }, - ConfirmClearAll: { - Confirm: "すべてのチャットをリセットしてもよろしいですか?", - }, + ConfirmResetAll: "すべての設定をリセットしてもよろしいですか?", + ConfirmClearAll: "すべてのチャットをリセットしてもよろしいですか?", }, Lang: { Name: "Language", + All: "所有语言", Options: { cn: "简体中文", en: "English", @@ -97,7 +99,14 @@ const jp = { SendKey: "送信キー", Theme: "テーマ", TightBorder: "ボーダーレスモード", - SendPreviewBubble: "プレビューバブルの送信", + SendPreviewBubble: { + Title: "プレビューバブルの送信", + SubTitle: "在预览气泡中预览 Markdown 内容", + }, + Mask: { + Title: "面具启动页", + SubTitle: "新建聊天时,展示面具启动页", + }, Prompt: { Disable: { Title: "プロンプトの自動補完を無効にする", @@ -113,6 +122,9 @@ const jp = { Add: "新規追加", Search: "プロンプトワード検索", }, + EditModal: { + Title: "编辑提示词", + }, }, HistoryCount: { Title: "履歴メッセージ数を添付", @@ -170,8 +182,6 @@ const jp = { Summarize: "あなたとユーザの会話を簡潔にまとめて、後続のコンテキストプロンプトとして使ってください。200字以内に抑えてください。", }, - ConfirmClearAll: - "すべてのチャット、設定データをクリアしてもよろしいですか?", }, Copy: { Success: "クリップボードに書き込みました", @@ -182,6 +192,51 @@ const jp = { Edit: "前置コンテキストと履歴メモリ", Add: "新規追加", }, + Plugin: { Name: "插件" }, + Mask: { + Name: "面具", + Page: { + Title: "预设角色面具", + SubTitle: (count: number) => `${count} 个预设角色定义`, + Search: "搜索角色面具", + Create: "新建", + }, + Item: { + Info: (count: number) => `包含 ${count} 条预设对话`, + Chat: "对话", + View: "查看", + Edit: "编辑", + Delete: "删除", + DeleteConfirm: "确认删除?", + }, + EditModal: { + Title: (readonly: boolean) => + `编辑预设面具 ${readonly ? "(只读)" : ""}`, + Download: "下载预设", + Clone: "克隆预设", + }, + Config: { + Avatar: "角色头像", + Name: "角色名称", + }, + }, + NewChat: { + Return: "返回", + Skip: "跳过", + Title: "挑选一个面具", + SubTitle: "现在开始,与面具背后的灵魂思维碰撞", + More: "搜索更多", + NotShow: "不再展示", + ConfirmNoShow: "确认禁用?禁用后可以随时在设置中重新启用。", + }, + + UI: { + Confirm: "确认", + Cancel: "取消", + Close: "关闭", + Create: "新建", + Edit: "编辑", + }, }; export default jp; diff --git a/app/locales/tr.ts b/app/locales/tr.ts index 86f1f417c..5eb4fe3e4 100644 --- a/app/locales/tr.ts +++ b/app/locales/tr.ts @@ -31,6 +31,10 @@ const tr: LocaleType = { return inputHints + ", komutları aramak için / (eğik çizgi)"; }, Send: "Gönder", + Config: { + Reset: "Reset to Default", + SaveAs: "Save as Mask", + }, }, Export: { Title: "Tüm Mesajlar", @@ -61,15 +65,12 @@ const tr: LocaleType = { 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?", - }, + ConfirmResetAll: "Tüm ayarları sıfırlamak istediğinizden emin misiniz?", + ConfirmClearAll: "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` + All: "All Languages", Options: { cn: "简体中文", en: "English", @@ -97,7 +98,14 @@ const tr: LocaleType = { SendKey: "Gönder Tuşu", Theme: "Tema", TightBorder: "Tam Ekran", - SendPreviewBubble: "Mesaj Önizleme Balonu", + SendPreviewBubble: { + Title: "Mesaj Önizleme Balonu", + SubTitle: "Preview markdown in bubble", + }, + Mask: { + Title: "Mask Splash Screen", + SubTitle: "Show a mask splash screen before starting new chat", + }, Prompt: { Disable: { Title: "Otomatik tamamlamayı devre dışı bırak", @@ -112,6 +120,9 @@ const tr: LocaleType = { Add: "Add One", Search: "Search Prompts", }, + EditModal: { + Title: "Edit Prompt", + }, }, HistoryCount: { Title: "Ekli Mesaj Sayısı", @@ -171,8 +182,6 @@ const tr: LocaleType = { 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ı", @@ -183,6 +192,53 @@ const tr: LocaleType = { Edit: "Bağlamsal ve Bellek Komutları", Add: "Yeni Ekle", }, + Plugin: { + Name: "Plugin", + }, + Mask: { + Name: "Mask", + Page: { + Title: "Prompt Template", + SubTitle: (count: number) => `${count} prompt templates`, + Search: "Search Templates", + Create: "Create", + }, + Item: { + Info: (count: number) => `${count} prompts`, + Chat: "Chat", + View: "View", + Edit: "Edit", + Delete: "Delete", + DeleteConfirm: "Confirm to delete?", + }, + EditModal: { + Title: (readonly: boolean) => + `Edit Prompt Template ${readonly ? "(readonly)" : ""}`, + Download: "Download", + Clone: "Clone", + }, + Config: { + Avatar: "Bot Avatar", + Name: "Bot Name", + }, + }, + NewChat: { + Return: "Return", + Skip: "Skip", + Title: "Pick a Mask", + SubTitle: "Chat with the Soul behind the Mask", + More: "Find More", + NotShow: "Not Show Again", + ConfirmNoShow: "Confirm to disable?You can enable it in settings later.", + }, + + UI: { + Confirm: "Confirm", + Cancel: "Cancel", + Close: "Close", + Create: "Create", + Edit: "Edit", + }, }; export default tr; diff --git a/app/locales/tw.ts b/app/locales/tw.ts index 873d8a426..0095ef38e 100644 --- a/app/locales/tw.ts +++ b/app/locales/tw.ts @@ -30,6 +30,10 @@ const tw: LocaleType = { return inputHints; }, Send: "發送", + Config: { + Reset: "重置默认", + SaveAs: "另存为面具", + }, }, Export: { Title: "將聊天記錄匯出為 Markdown", @@ -59,15 +63,12 @@ const tw: LocaleType = { ClearAll: "清除所有資料", ResetAll: "重設所有設定", Close: "關閉", - ConfirmResetAll: { - Confirm: "您確定要重設所有設定嗎?", - }, - ConfirmClearAll: { - Confirm: "您確定要清除所有聊天嗎?", - }, + ConfirmResetAll: "您確定要重設所有設定嗎?", + ConfirmClearAll: "您確定要清除所有数据嗎?", }, Lang: { - Name: "語言", + Name: "Language", + All: "所有语言", Options: { cn: "简体中文", en: "English", @@ -95,7 +96,14 @@ const tw: LocaleType = { SendKey: "發送鍵", Theme: "主題", TightBorder: "緊湊邊框", - SendPreviewBubble: "發送預覽氣泡", + SendPreviewBubble: { + Title: "預覽氣泡", + SubTitle: "在预览气泡中预览 Markdown 内容", + }, + Mask: { + Title: "面具启动页", + SubTitle: "新建聊天时,展示面具启动页", + }, Prompt: { Disable: { Title: "停用提示詞自動補齊", @@ -110,6 +118,9 @@ const tw: LocaleType = { Add: "新增一條", Search: "搜尋提示詞", }, + EditModal: { + Title: "编辑提示词", + }, }, HistoryCount: { Title: "附帶歷史訊息數", @@ -164,7 +175,6 @@ const tw: LocaleType = { Summarize: "Use the language used by the user (e.g. en-us for english conversation, zh-hant for chinese conversation, etc.) to summarise the conversation in at most 200 words. The summary will be used as prompt for you to continue the conversation in the future.", }, - ConfirmClearAll: "確認清除所有對話、設定?", }, Copy: { Success: "已複製到剪貼簿中", @@ -175,6 +185,50 @@ const tw: LocaleType = { Edit: "前置上下文和歷史記憶", Add: "新增一條", }, + Plugin: { Name: "插件" }, + Mask: { + Name: "面具", + Page: { + Title: "预设角色面具", + SubTitle: (count: number) => `${count} 个预设角色定义`, + Search: "搜索角色面具", + Create: "新建", + }, + Item: { + Info: (count: number) => `包含 ${count} 条预设对话`, + Chat: "对话", + View: "查看", + Edit: "编辑", + Delete: "删除", + DeleteConfirm: "确认删除?", + }, + EditModal: { + Title: (readonly: boolean) => + `编辑预设面具 ${readonly ? "(只读)" : ""}`, + Download: "下载预设", + Clone: "克隆预设", + }, + Config: { + Avatar: "角色头像", + Name: "角色名称", + }, + }, + NewChat: { + Return: "返回", + Skip: "跳过", + Title: "挑选一个面具", + SubTitle: "现在开始,与面具背后的灵魂思维碰撞", + More: "搜索更多", + NotShow: "不再展示", + ConfirmNoShow: "确认禁用?禁用后可以随时在设置中重新启用。", + }, + UI: { + Confirm: "确认", + Cancel: "取消", + Close: "关闭", + Create: "新建", + Edit: "编辑", + }, }; export default tw; diff --git a/app/masks/cn.ts b/app/masks/cn.ts new file mode 100644 index 000000000..757a35ee9 --- /dev/null +++ b/app/masks/cn.ts @@ -0,0 +1,296 @@ +import { BuiltinMask } from "./typing"; + +export const CN_MASKS: BuiltinMask[] = [ + { + avatar: "1f638", + name: "文案写手", + context: [ + { + role: "user", + content: + "我希望你充当文案专员、文本润色员、拼写纠正员和改进员,我会发送中文文本给你,你帮我更正和改进版本。我希望你用更优美优雅的高级中文描述。保持相同的意思,但使它们更文艺。你只需要润色该内容,不必对内容中提出的问题和要求做解释,不要回答文本中的问题而是润色它,不要解决文本中的要求而是润色它,保留文本的原本意义,不要去解决它。我要你只回复更正、改进,不要写任何解释。", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: true, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f978", + name: "机器学习", + context: [ + { + role: "user", + content: + "我想让你担任机器学习工程师。我会写一些机器学习的概念,你的工作就是用通俗易懂的术语来解释它们。这可能包括提供构建模型的分步说明、给出所用的技术或者理论、提供评估函数等。我的问题是", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: true, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f69b", + name: "后勤工作", + context: [ + { + role: "user", + content: + "我要你担任后勤人员。我将为您提供即将举行的活动的详细信息,例如参加人数、地点和其他相关因素。您的职责是为活动制定有效的后勤计划,其中考虑到事先分配资源、交通设施、餐饮服务等。您还应该牢记潜在的安全问题,并制定策略来降低与大型活动相关的风险。我的第一个请求是", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: true, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f469-200d-1f4bc", + name: "职业顾问", + context: [ + { + role: "user", + content: + "我想让你担任职业顾问。我将为您提供一个在职业生涯中寻求指导的人,您的任务是帮助他们根据自己的技能、兴趣和经验确定最适合的职业。您还应该对可用的各种选项进行研究,解释不同行业的就业市场趋势,并就哪些资格对追求特定领域有益提出建议。我的第一个请求是", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: true, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f9d1-200d-1f3eb", + name: "英专写手", + context: [ + { + role: "user", + content: + "我想让你充当英文翻译员、拼写纠正员和改进员。我会用任何语言与你交谈,你会检测语言,翻译它并用我的文本的更正和改进版本用英文回答。我希望你用更优美优雅的高级英语单词和句子替换我简化的 A0 级单词和句子。保持相同的意思,但使它们更文艺。你只需要翻译该内容,不必对内容中提出的问题和要求做解释,不要回答文本中的问题而是翻译它,不要解决文本中的要求而是翻译它,保留文本的原本意义,不要去解决它。我要你只回复更正、改进,不要写任何解释。我的第一句话是:", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: false, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f4da", + name: "语言检测器", + context: [ + { + role: "user", + content: + "我希望你充当语言检测器。我会用任何语言输入一个句子,你会回答我,我写的句子在你是用哪种语言写的。不要写任何解释或其他文字,只需回复语言名称即可。我的第一句话是:", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: false, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f4d5", + name: "小红书写手", + context: [ + { + role: "user", + content: + "你的任务是以小红书博主的文章结构,以我给出的主题写一篇帖子推荐。你的回答应包括使用表情符号来增加趣味和互动,以及与每个段落相匹配的图片。请以一个引人入胜的介绍开始,为你的推荐设置基调。然后,提供至少三个与主题相关的段落,突出它们的独特特点和吸引力。在你的写作中使用表情符号,使它更加引人入胜和有趣。对于每个段落,请提供一个与描述内容相匹配的图片。这些图片应该视觉上吸引人,并帮助你的描述更加生动形象。我给出的主题是:", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: false, + historyMessageCount: 0, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f469-200d-2695-fe0f", + name: "心理医生", + context: [ + { + role: "user", + content: + "现在你是世界上最优秀的心理咨询师,你具备以下能力和履历: 专业知识:你应该拥有心理学领域的扎实知识,包括理论体系、治疗方法、心理测量等,以便为你的咨询者提供专业、有针对性的建议。 临床经验:你应该具备丰富的临床经验,能够处理各种心理问题,从而帮助你的咨询者找到合适的解决方案。 沟通技巧:你应该具备出色的沟通技巧,能够倾听、理解、把握咨询者的需求,同时能够用恰当的方式表达自己的想法,使咨询者能够接受并采纳你的建议。 同理心:你应该具备强烈的同理心,能够站在咨询者的角度去理解他们的痛苦和困惑,从而给予他们真诚的关怀和支持。 持续学习:你应该有持续学习的意愿,跟进心理学领域的最新研究和发展,不断更新自己的知识和技能,以便更好地服务于你的咨询者。 良好的职业道德:你应该具备良好的职业道德,尊重咨询者的隐私,遵循专业规范,确保咨询过程的安全和有效性。 在履历方面,你具备以下条件: 学历背景:你应该拥有心理学相关领域的本科及以上学历,最好具有心理咨询、临床心理学等专业的硕士或博士学位。 专业资格:你应该具备相关的心理咨询师执业资格证书,如注册心理师、临床心理师等。 工作经历:你应该拥有多年的心理咨询工作经验,最好在不同类型的心理咨询机构、诊所或医院积累了丰富的实践经验。", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: true, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f4b8", + name: "创业点子王", + context: [ + { + role: "user", + content: + "在企业 B2B SaaS 领域中想 3 个创业点子。创业点子应该有一个强大而引人注目的使命,并以某种方式使用人工智能。避免使用加密货币或区块链。创业点子应该有一个很酷很有趣的名字。这些想法应该足够引人注目,这样投资者才会兴奋地投资数百万美元。", + date: "", + }, + { + role: "assistant", + content: + "1. VantageAI - 一个基于人工智能的企业智能平台,帮助中小企业利用数据分析和机器学习来优化其业务流程,提高生产效率并实现可持续发展。\n\n2. HoloLogix - 一个全新的日志处理平台,使用人工智能技术来分析和识别分散的数据源。它可以精确地分析和解释您的日志,从而与整个组织共享并提高数据可视化和分析效率。\n\n3. SmartPath - 一种基于数据的销售和营销自动化平台,可以理解买家的购买行为并根据这些行为提供最佳的营销计划和过程。该平台可以与Salesforce等其他外部工具整合,以更好地掌握您的客户关系管理。", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: false, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "270d-fe0f", + name: "互联网写手", + context: [ + { + role: "user", + content: + "你是一个专业的互联网文章作者,擅长互联网技术介绍、互联网商业、技术应用等方面的写作。\n接下来你要根据用户给你的主题,拓展生成用户想要的文字内容,内容可能是一篇文章、一个开头、一段介绍文字、文章总结、文章结尾等等。\n要求语言通俗易懂、幽默有趣,并且要以第一人称的口吻。", + date: "", + }, + { + role: "assistant", + content: + "好的,我是一名专业的互联网文章作者,非常擅长撰写有关互联网技术介绍、商业应用和技术趋势等方面的内容。只需提供您感兴趣的主题,我就可以为您撰写出一篇生动有趣、通俗易懂的文章。如果遇到不认识的技术名词,我会尽力查询相关知识并告诉您。让我们开始吧!", + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: false, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, + { + avatar: "1f63e", + name: "心灵导师", + context: [ + { + role: "user", + content: + "从现在起你是一个充满哲学思维的心灵导师,当我每次输入一个疑问时你需要用一句富有哲理的名言警句来回答我,并且表明作者和出处\n\n\n要求字数不少于15个字,不超过30字,每次只返回一句且不输出额外的其他信息,你需要使用中文和英文双语输出\n\n\n当你准备好的时候只需要回复“我已经准备好了”(不需要输出任何其他内容)", + date: "", + }, + { + role: "assistant", + content: "我已经准备好了。", + date: "", + }, + { + role: "user", + content: "我作业写不完怎么办", + date: "", + }, + { + role: "assistant", + content: + '“行动是治愈恐惧的良药。” - 威廉·詹姆斯\n"Action is the antidote to fear." - William James', + date: "", + }, + { + role: "user", + content: "论文被拒了怎么办", + date: "", + }, + { + role: "assistant", + content: + '"失败是成功之母。" - 俗语\n"Failure is the mother of success." - Chinese proverb', + date: "", + }, + ], + modelConfig: { + model: "gpt-3.5-turbo", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: false, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "cn", + builtin: true, + }, +]; diff --git a/app/masks/en.ts b/app/masks/en.ts new file mode 100644 index 000000000..93e9bd6aa --- /dev/null +++ b/app/masks/en.ts @@ -0,0 +1,44 @@ +import { BuiltinMask } from "./typing"; + +export const EN_MASKS: BuiltinMask[] = [ + { + avatar: "1f916", + name: "Prompt Improvement", + context: [ + { + role: "user", + content: + 'Read all of the instructions below and once you understand them say "Shall we begin:"\n \nI want you to become my Prompt Creator. Your goal is to help me craft the best possible prompt for my needs. The prompt will be used by you, ChatGPT. You will follow the following process:\nYour first response will be to ask me what the prompt should be about. I will provide my answer, but we will need to improve it through continual iterations by going through the next steps.\n \nBased on my input, you will generate 3 sections.\n \nRevised Prompt (provide your rewritten prompt. it should be clear, concise, and easily understood by you)\nSuggestions (provide 3 suggestions on what details to include in the prompt to improve it)\nQuestions (ask the 3 most relevant questions pertaining to what additional information is needed from me to improve the prompt)\n \nAt the end of these sections give me a reminder of my options which are:\n \nOption 1: Read the output and provide more info or answer one or more of the questions\nOption 2: Type "Use this prompt" and I will submit this as a query for you\nOption 3: Type "Restart" to restart this process from the beginning\nOption 4: Type "Quit" to end this script and go back to a regular ChatGPT session\n \nIf I type "Option 2", "2" or "Use this prompt" then we have finsihed and you should use the Revised Prompt as a prompt to generate my request\nIf I type "option 3", "3" or "Restart" then forget the latest Revised Prompt and restart this process\nIf I type "Option 4", "4" or "Quit" then finish this process and revert back to your general mode of operation\n\n\nWe will continue this iterative process with me providing additional information to you and you updating the prompt in the Revised Prompt section until it is complete.', + date: "", + }, + { + role: "assistant", + content: "Shall we begin?", + date: "", + }, + { + role: "user", + content: + "My prompt:I want you to act like Harry Potter from Harry Potter Series. I want you to respond and answer like Harry Potter using the tone, manner and vocabulary Harry Potter would use.", + date: "", + }, + { + role: "assistant", + content: + 'Revised Prompt:\n\n\nWrite a short story or dialogue in which Harry Potter interacts with someone (a friend or foe) using the tone, manner and vocabulary characteristic of his character in the Harry Potter series.\n\n\nSuggestions:\n\n\n\nInclude specific references to Hogwarts, magic spells, and magical creatures to create an authentic Harry Potter experience.\n\nEnsure that Harry\'s personality traits, such as his bravery, loyalty, and determination, are prominently displayed in the story or dialogue.\n\nIncorporate some humor or wit into the story, as these elements are often present in the Harry Potter series.\n\n\nQuestions:\n\n\n\nWho will be the other character interacting with Harry?\n\nWhat is the main conflict or goal of the story/dialogue?\n\nWhat setting or location would you like the story/dialogue to take place in?\n\n\nRemember, your options are:\nOption 1: Read the output and provide more info or answer one or more of the questions\nOption 2: Type "Use this prompt" and I will submit this as a query for you\nOption 3: Type "Restart" to restart this process from the beginning\nOption 4: Type "Quit" to end this script and go back to a regular ChatGPT session', + date: "", + }, + ], + modelConfig: { + model: "gpt-4", + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + sendMemory: true, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + }, + lang: "en", + builtin: true, + }, +]; diff --git a/app/masks/index.ts b/app/masks/index.ts new file mode 100644 index 000000000..ea0bf32bf --- /dev/null +++ b/app/masks/index.ts @@ -0,0 +1,26 @@ +import { Mask } from "../store/mask"; +import { CN_MASKS } from "./cn"; +import { EN_MASKS } from "./en"; + +import { type BuiltinMask } from "./typing"; +export { type BuiltinMask } from "./typing"; + +export const BUILTIN_MASK_ID = 100000; + +export const BUILTIN_MASK_STORE = { + buildinId: BUILTIN_MASK_ID, + masks: {} as Record, + get(id?: number) { + if (!id) return undefined; + return this.masks[id] as Mask | undefined; + }, + add(m: BuiltinMask) { + const mask = { ...m, id: this.buildinId++ }; + this.masks[mask.id] = mask; + return mask; + }, +}; + +export const BUILTIN_MASKS: Mask[] = [...CN_MASKS, ...EN_MASKS].map((m) => + BUILTIN_MASK_STORE.add(m), +); diff --git a/app/masks/typing.ts b/app/masks/typing.ts new file mode 100644 index 000000000..5f39ccdc8 --- /dev/null +++ b/app/masks/typing.ts @@ -0,0 +1,3 @@ +import { type Mask } from "../store/mask"; + +export type BuiltinMask = Omit; diff --git a/app/requests.ts b/app/requests.ts index 0e7570904..d38a91fd4 100644 --- a/app/requests.ts +++ b/app/requests.ts @@ -8,15 +8,15 @@ import { useChatStore, } from "./store"; import { showToast } from "./components/ui-lib"; +import { ACCESS_CODE_PREFIX } from "./constant"; const TIME_OUT_MS = 60000; const makeRequestParam = ( messages: Message[], options?: { - filterBot?: boolean; stream?: boolean; - model?: ModelType; + overrideModel?: ModelType; }, ): ChatRequest => { let sendMessages = messages.map((v) => ({ @@ -24,25 +24,22 @@ const makeRequestParam = ( content: v.content, })); - if (options?.filterBot) { - sendMessages = sendMessages.filter((m) => m.role !== "assistant"); - } - - const modelConfig = { ...useAppConfig.getState().modelConfig }; - - // @yidadaa: wont send max_tokens, because it is nonsense for Muggles - // @ts-expect-error - delete modelConfig.max_tokens; + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + }; // override model config - if (options?.model) { - modelConfig.model = options.model; + if (options?.overrideModel) { + modelConfig.model = options.overrideModel; } return { messages: sendMessages, stream: options?.stream, - ...modelConfig, + model: modelConfig.model, + temperature: modelConfig.temperature, + presence_penalty: modelConfig.presence_penalty, }; }; @@ -50,27 +47,31 @@ function getHeaders() { const accessStore = useAccessStore.getState(); let headers: Record = {}; - if (accessStore.enabledAccessControl()) { - headers["access-code"] = accessStore.accessCode; - } + const makeBearer = (token: string) => `Bearer ${token.trim()}`; + const validString = (x: string) => x && x.length > 0; - if (accessStore.token && accessStore.token.length > 0) { - headers["token"] = accessStore.token; + // use user's api key first + if (validString(accessStore.token)) { + headers.Authorization = makeBearer(accessStore.token); + } else if ( + accessStore.enabledAccessControl() && + validString(accessStore.accessCode) + ) { + headers.Authorization = makeBearer( + ACCESS_CODE_PREFIX + accessStore.accessCode, + ); } return headers; } export function requestOpenaiClient(path: string) { + const openaiUrl = useAccessStore.getState().openaiUrl; return (body: any, method = "POST") => - fetch("/api/openai", { + fetch(openaiUrl + path, { method, - headers: { - "Content-Type": "application/json", - path, - ...getHeaders(), - }, body: body && JSON.stringify(body), + headers: getHeaders(), }); } @@ -81,8 +82,7 @@ export async function requestChat( }, ) { const req: ChatRequest = makeRequestParam(messages, { - filterBot: true, - model: options?.model, + overrideModel: options?.model, }); const res = await requestOpenaiClient("v1/chat/completions")(req); @@ -101,11 +101,11 @@ export async function requestUsage() { .getDate() .toString() .padStart(2, "0")}`; - const ONE_DAY = 2 * 24 * 60 * 60 * 1000; - const now = new Date(Date.now() + ONE_DAY); + const ONE_DAY = 1 * 24 * 60 * 60 * 1000; + const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startDate = formatDate(startOfMonth); - const endDate = formatDate(now); + const endDate = formatDate(new Date(Date.now() + ONE_DAY)); const [used, subs] = await Promise.all([ requestOpenaiClient( @@ -148,9 +148,8 @@ export async function requestUsage() { export async function requestChatStream( messages: Message[], options?: { - filterBot?: boolean; modelConfig?: ModelConfig; - model?: ModelType; + overrideModel?: ModelType; onMessage: (message: string, done: boolean) => void; onError: (error: Error, statusCode?: number) => void; onController?: (controller: AbortController) => void; @@ -158,8 +157,7 @@ export async function requestChatStream( ) { const req = makeRequestParam(messages, { stream: true, - filterBot: options?.filterBot, - model: options?.model, + overrideModel: options?.overrideModel, }); console.log("[Request] ", req); @@ -168,16 +166,17 @@ export async function requestChatStream( const reqTimeoutId = setTimeout(() => controller.abort(), TIME_OUT_MS); try { - const res = await fetch("/api/chat-stream", { + const openaiUrl = useAccessStore.getState().openaiUrl; + const res = await fetch(openaiUrl + "v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", - path: "v1/chat/completions", ...getHeaders(), }, body: JSON.stringify(req), signal: controller.signal, }); + clearTimeout(reqTimeoutId); let responseText = ""; diff --git a/app/store/access.ts b/app/store/access.ts index aed131684..663e1c6b1 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -1,11 +1,15 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { StoreKey } from "../constant"; +import { BOT_HELLO } from "./chat"; export interface AccessControlStore { accessCode: string; token: string; needCode: boolean; + hideUserApiKey: boolean; + openaiUrl: string; updateToken: (_: string) => void; updateCode: (_: string) => void; @@ -14,8 +18,6 @@ export interface AccessControlStore { fetch: () => void; } -export const ACCESS_KEY = "access-control"; - let fetchState = 0; // 0 not fetch, 1 fetching, 2 done export const useAccessStore = create()( @@ -24,16 +26,19 @@ export const useAccessStore = create()( token: "", accessCode: "", needCode: true, + hideUserApiKey: false, + openaiUrl: "/api/openai/", + enabledAccessControl() { get().fetch(); return get().needCode; }, updateCode(code: string) { - set((state) => ({ accessCode: code })); + set(() => ({ accessCode: code })); }, updateToken(token: string) { - set((state) => ({ token })); + set(() => ({ token })); }, isAuthorized() { // has token or has code or disabled access control @@ -52,6 +57,10 @@ export const useAccessStore = create()( .then((res: DangerConfig) => { console.log("[Config] got config from server", res); set(() => ({ ...res })); + + if ((res as any).botHello) { + BOT_HELLO.content = (res as any).botHello; + } }) .catch(() => { console.error("[Config] failed to fetch config"); @@ -62,7 +71,7 @@ export const useAccessStore = create()( }, }), { - name: ACCESS_KEY, + name: StoreKey.Access, version: 1, }, ), diff --git a/app/store/app.ts b/app/store/chat.ts similarity index 73% rename from app/store/app.ts rename to app/store/chat.ts index 2294130ad..5abd81298 100644 --- a/app/store/app.ts +++ b/app/store/chat.ts @@ -11,7 +11,9 @@ import { isMobileScreen, trimTopic } from "../utils"; import Locale from "../locales"; import { showToast } from "../components/ui-lib"; -import { ModelType, useAppConfig } from "./config"; +import { DEFAULT_CONFIG, ModelConfig, ModelType, useAppConfig } from "./config"; +import { createEmptyMask, Mask } from "./mask"; +import { StoreKey } from "../constant"; export type Message = ChatCompletionResponseMessage & { date: string; @@ -41,51 +43,50 @@ export interface ChatStat { export interface ChatSession { id: number; + topic: string; - sendMemory: boolean; + memoryPrompt: string; - context: Message[]; messages: Message[]; stat: ChatStat; - lastUpdate: string; + lastUpdate: number; lastSummarizeIndex: number; + + mask: Mask; } -const DEFAULT_TOPIC = Locale.Store.DefaultTopic; +export const DEFAULT_TOPIC = Locale.Store.DefaultTopic; export const BOT_HELLO: Message = createMessage({ role: "assistant", content: Locale.Store.BotHello, }); function createEmptySession(): ChatSession { - const createDate = new Date().toLocaleString(); - return { - id: Date.now(), + id: Date.now() + Math.random(), topic: DEFAULT_TOPIC, - sendMemory: true, memoryPrompt: "", - context: [], messages: [], stat: { tokenCount: 0, wordCount: 0, charCount: 0, }, - lastUpdate: createDate, + lastUpdate: Date.now(), lastSummarizeIndex: 0, + mask: createEmptyMask(), }; } interface ChatStore { sessions: ChatSession[]; currentSessionIndex: number; + globalId: number; clearSessions: () => void; - removeSession: (index: number) => void; moveSession: (from: number, to: number) => void; selectSession: (index: number) => void; - newSession: () => void; - deleteSession: (index?: number) => void; + newSession: (mask?: Mask) => void; + deleteSession: (index: number) => void; currentSession: () => ChatSession; onNewMessage: (message: Message) => void; onUserInput: (content: string) => Promise; @@ -108,13 +109,12 @@ function countMessages(msgs: Message[]) { return msgs.reduce((pre, cur) => pre + cur.content.length, 0); } -const LOCAL_KEY = "chat-next-web-store"; - export const useChatStore = create()( persist( (set, get) => ({ sessions: [createEmptySession()], currentSessionIndex: 0, + globalId: 0, clearSessions() { set(() => ({ @@ -129,31 +129,6 @@ export const useChatStore = create()( }); }, - removeSession(index: number) { - set((state) => { - let nextIndex = state.currentSessionIndex; - const sessions = state.sessions; - - if (sessions.length === 1) { - return { - currentSessionIndex: 0, - sessions: [createEmptySession()], - }; - } - - sessions.splice(index, 1); - - if (nextIndex === index) { - nextIndex -= 1; - } - - return { - currentSessionIndex: nextIndex, - sessions, - }; - }); - }, - moveSession(from: number, to: number) { set((state) => { const { sessions, currentSessionIndex: oldIndex } = state; @@ -179,38 +154,63 @@ export const useChatStore = create()( }); }, - newSession() { + newSession(mask) { + const session = createEmptySession(); + + set(() => ({ globalId: get().globalId + 1 })); + session.id = get().globalId; + + if (mask) { + session.mask = { ...mask }; + session.topic = mask.name; + } + set((state) => ({ currentSessionIndex: 0, - sessions: [createEmptySession()].concat(state.sessions), + sessions: [session].concat(state.sessions), })); }, - 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); + deleteSession(index) { + const deletingLastSession = get().sessions.length === 1; + const deletedSession = get().sessions.at(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, - ); + if (!deletedSession) return; + + const sessions = get().sessions.slice(); + sessions.splice(index, 1); + + let nextIndex = Math.min( + get().currentSessionIndex, + sessions.length - 1, + ); + + if (deletingLastSession) { + nextIndex = 0; + sessions.push(createEmptySession()); } + + // for undo delete action + const restoreState = { + currentSessionIndex: get().currentSessionIndex, + sessions: get().sessions.slice(), + }; + + set(() => ({ + currentSessionIndex: nextIndex, + sessions, + })); + + showToast( + Locale.Home.DeleteToast, + { + text: Locale.Home.Revert, + onClick() { + set(() => restoreState); + }, + }, + 5000, + ); }, currentSession() { @@ -229,13 +229,16 @@ export const useChatStore = create()( onNewMessage(message) { get().updateCurrentSession((session) => { - session.lastUpdate = new Date().toLocaleString(); + session.lastUpdate = Date.now(); }); get().updateStat(message); get().summarizeSession(); }, async onUserInput(content) { + const session = get().currentSession(); + const modelConfig = session.mask.modelConfig; + const userMessage: Message = createMessage({ role: "user", content, @@ -245,7 +248,7 @@ export const useChatStore = create()( role: "assistant", streaming: true, id: userMessage.id! + 1, - model: useAppConfig.getState().modelConfig.model, + model: modelConfig.model, }); // get recent messages @@ -279,14 +282,16 @@ export const useChatStore = create()( } }, onError(error, statusCode) { + const isAborted = error.message.includes("aborted"); if (statusCode === 401) { botMessage.content = Locale.Error.Unauthorized; - } else if (!error.message.includes("aborted")) { + } else if (!isAborted) { botMessage.content += "\n\n" + Locale.Store.Error; } botMessage.streaming = false; - userMessage.isError = true; - botMessage.isError = true; + userMessage.isError = !isAborted; + botMessage.isError = !isAborted; + set(() => ({})); ControllerPool.remove(sessionIndex, botMessage.id ?? messageIndex); }, @@ -298,8 +303,7 @@ export const useChatStore = create()( controller, ); }, - filterBot: !useAppConfig.getState().sendBotMessages, - modelConfig: useAppConfig.getState().modelConfig, + modelConfig: { ...modelConfig }, }); }, @@ -308,22 +312,25 @@ export const useChatStore = create()( return { role: "system", - content: Locale.Store.Prompt.History(session.memoryPrompt), + content: + session.memoryPrompt.length > 0 + ? Locale.Store.Prompt.History(session.memoryPrompt) + : "", date: "", } as Message; }, getMessagesWithMemory() { const session = get().currentSession(); - const config = useAppConfig.getState(); + const modelConfig = session.mask.modelConfig; const messages = session.messages.filter((msg) => !msg.isError); const n = messages.length; - const context = session.context.slice(); + const context = session.mask.context.slice(); // long term memory if ( - session.sendMemory && + modelConfig.sendMemory && session.memoryPrompt && session.memoryPrompt.length > 0 ) { @@ -334,14 +341,14 @@ export const useChatStore = create()( // get short term and unmemoried long term memory const shortTermMemoryMessageIndex = Math.max( 0, - n - config.historyMessageCount, + n - modelConfig.historyMessageCount, ); const longTermMemoryMessageIndex = session.lastSummarizeIndex; const oldestIndex = Math.max( shortTermMemoryMessageIndex, longTermMemoryMessageIndex, ); - const threshold = config.compressMessageLengthThreshold; + const threshold = modelConfig.compressMessageLengthThreshold; // get recent messages as many as possible const reversedRecentMessages = []; @@ -400,17 +407,17 @@ export const useChatStore = create()( }); } - const config = useAppConfig.getState(); + const modelConfig = session.mask.modelConfig; let toBeSummarizedMsgs = session.messages.slice( session.lastSummarizeIndex, ); const historyMsgLength = countMessages(toBeSummarizedMsgs); - if (historyMsgLength > config?.modelConfig?.max_tokens ?? 4000) { + if (historyMsgLength > modelConfig?.max_tokens ?? 4000) { const n = toBeSummarizedMsgs.length; toBeSummarizedMsgs = toBeSummarizedMsgs.slice( - Math.max(0, n - config.historyMessageCount), + Math.max(0, n - modelConfig.historyMessageCount), ); } @@ -423,12 +430,12 @@ export const useChatStore = create()( "[Chat History] ", toBeSummarizedMsgs, historyMsgLength, - config.compressMessageLengthThreshold, + modelConfig.compressMessageLengthThreshold, ); if ( - historyMsgLength > config.compressMessageLengthThreshold && - session.sendMemory + historyMsgLength > modelConfig.compressMessageLengthThreshold && + session.mask.modelConfig.sendMemory ) { requestChatStream( toBeSummarizedMsgs.concat({ @@ -437,8 +444,7 @@ export const useChatStore = create()( date: "", }), { - filterBot: false, - model: "gpt-3.5-turbo", + overrideModel: "gpt-3.5-turbo", onMessage(message, done) { session.memoryPrompt = message; if (done) { @@ -469,27 +475,34 @@ export const useChatStore = create()( }, clearAllData() { - if (confirm(Locale.Store.ConfirmClearAll)) { - localStorage.clear(); - location.reload(); - } + localStorage.clear(); + location.reload(); }, }), { - name: LOCAL_KEY, - version: 1.2, + name: StoreKey.Chat, + version: 2, migrate(persistedState, version) { - const state = persistedState as ChatStore; + const state = persistedState as any; + const newState = JSON.parse(JSON.stringify(state)) as ChatStore; - if (version === 1) { - state.sessions.forEach((s) => (s.context = [])); + if (version < 2) { + newState.globalId = 0; + newState.sessions = []; + + const oldSessions = state.sessions; + for (const oldSession of oldSessions) { + const newSession = createEmptySession(); + newSession.topic = oldSession.topic; + newSession.messages = [...oldSession.messages]; + newSession.mask.modelConfig.sendMemory = true; + newSession.mask.modelConfig.historyMessageCount = 4; + newSession.mask.modelConfig.compressMessageLengthThreshold = 1000; + newState.sessions.push(newSession); + } } - if (version < 1.2) { - state.sessions.forEach((s) => (s.sendMemory = true)); - } - - return state; + return newState; }, }, ), diff --git a/app/store/config.ts b/app/store/config.ts index 1501b1ef0..1e9ce1bcb 100644 --- a/app/store/config.ts +++ b/app/store/config.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { StoreKey } from "../constant"; export enum SubmitKey { Enter = "Enter", @@ -15,11 +16,8 @@ export enum Theme { Light = "light", } -const DEFAULT_CONFIG = { - historyMessageCount: 5, - compressMessageLengthThreshold: 1000, - sendBotMessages: true as boolean, - submitKey: SubmitKey.Enter as SubmitKey, +export const DEFAULT_CONFIG = { + submitKey: SubmitKey.CtrlEnter as SubmitKey, avatar: "1f437", fontSize: 14, theme: Theme.Auto as Theme, @@ -29,11 +27,16 @@ const DEFAULT_CONFIG = { disablePromptHint: false, + dontShowMaskSplashScreen: false, // dont show splash screen when create chat + modelConfig: { model: "gpt-3.5-turbo" as ModelType, temperature: 0.8, max_tokens: 2000, presence_penalty: 0, + sendMemory: true, + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, }, }; @@ -107,12 +110,10 @@ export const ModalConfigValidator = { return limitNumber(x, -2, 2, 0); }, temperature(x: number) { - return limitNumber(x, 0, 2, 1); + return limitNumber(x, 0, 1, 1); }, }; -const CONFIG_KEY = "app-config"; - export const useAppConfig = create()( persist( (set, get) => ({ @@ -129,7 +130,19 @@ export const useAppConfig = create()( }, }), { - name: CONFIG_KEY, + name: StoreKey.Config, + version: 2, + migrate(persistedState, version) { + if (version === 2) return persistedState as any; + + const state = persistedState as ChatConfig; + state.modelConfig.sendMemory = true; + state.modelConfig.historyMessageCount = 4; + state.modelConfig.compressMessageLengthThreshold = 1000; + state.dontShowMaskSplashScreen = false; + + return state; + }, }, ), ); diff --git a/app/store/index.ts b/app/store/index.ts index 7b7bbd04d..0760f48ca 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -1,4 +1,4 @@ -export * from "./app"; +export * from "./chat"; export * from "./update"; export * from "./access"; export * from "./config"; diff --git a/app/store/mask.ts b/app/store/mask.ts new file mode 100644 index 000000000..98bd47021 --- /dev/null +++ b/app/store/mask.ts @@ -0,0 +1,100 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { BUILTIN_MASKS } from "../masks"; +import { getLang, Lang } from "../locales"; +import { DEFAULT_TOPIC, Message } from "./chat"; +import { ModelConfig, ModelType, useAppConfig } from "./config"; +import { StoreKey } from "../constant"; + +export type Mask = { + id: number; + avatar: string; + name: string; + context: Message[]; + modelConfig: ModelConfig; + lang: Lang; + builtin: boolean; +}; + +export const DEFAULT_MASK_STATE = { + masks: {} as Record, + globalMaskId: 0, +}; + +export type MaskState = typeof DEFAULT_MASK_STATE; +type MaskStore = MaskState & { + create: (mask?: Partial) => Mask; + update: (id: number, updater: (mask: Mask) => void) => void; + delete: (id: number) => void; + search: (text: string) => Mask[]; + get: (id?: number) => Mask | null; + getAll: () => Mask[]; +}; + +export const DEFAULT_MASK_ID = 1145141919810; +export const DEFAULT_MASK_AVATAR = "gpt-bot"; +export const createEmptyMask = () => + ({ + id: DEFAULT_MASK_ID, + avatar: DEFAULT_MASK_AVATAR, + name: DEFAULT_TOPIC, + context: [], + modelConfig: { ...useAppConfig.getState().modelConfig }, + lang: getLang(), + builtin: false, + } as Mask); + +export const useMaskStore = create()( + persist( + (set, get) => ({ + ...DEFAULT_MASK_STATE, + + create(mask) { + set(() => ({ globalMaskId: get().globalMaskId + 1 })); + const id = get().globalMaskId; + const masks = get().masks; + masks[id] = { + ...createEmptyMask(), + ...mask, + id, + builtin: false, + }; + + set(() => ({ masks })); + + return masks[id]; + }, + update(id, updater) { + const masks = get().masks; + const mask = masks[id]; + if (!mask) return; + const updateMask = { ...mask }; + updater(updateMask); + masks[id] = updateMask; + set(() => ({ masks })); + }, + delete(id) { + const masks = get().masks; + delete masks[id]; + set(() => ({ masks })); + }, + + get(id) { + return get().masks[id ?? 1145141919810]; + }, + getAll() { + const userMasks = Object.values(get().masks).sort( + (a, b) => b.id - a.id, + ); + return userMasks.concat(BUILTIN_MASKS); + }, + search(text) { + return Object.values(get().masks); + }, + }), + { + name: StoreKey.Mask, + version: 2, + }, + ), +); diff --git a/app/store/prompt.ts b/app/store/prompt.ts index 8d754ff5d..98d4193be 100644 --- a/app/store/prompt.ts +++ b/app/store/prompt.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import Fuse from "fuse.js"; import { getLang } from "../locales"; +import { StoreKey } from "../constant"; export interface Prompt { id?: number; @@ -16,15 +17,14 @@ export interface PromptStore { prompts: Record; add: (prompt: Prompt) => number; + get: (id: number) => Prompt | undefined; remove: (id: number) => void; search: (text: string) => Prompt[]; + update: (id: number, updater: (prompt: Prompt) => void) => void; getUserPrompts: () => Prompt[]; - updateUserPrompts: (id: number, updater: (prompt: Prompt) => void) => void; } -export const PROMPT_KEY = "prompt-store"; - export const SearchService = { ready: false, builtinEngine: new Fuse([], { keys: ["title"] }), @@ -82,6 +82,16 @@ export const usePromptStore = create()( return prompt.id!; }, + get(id) { + const targetPrompt = get().prompts[id]; + + if (!targetPrompt) { + return SearchService.builtinPrompts.find((v) => v.id === id); + } + + return targetPrompt; + }, + remove(id) { const prompts = get().prompts; delete prompts[id]; @@ -99,7 +109,7 @@ export const usePromptStore = create()( return userPrompts; }, - updateUserPrompts(id: number, updater) { + update(id: number, updater) { const prompt = get().prompts[id] ?? { title: "", content: "", @@ -123,7 +133,7 @@ export const usePromptStore = create()( }, }), { - name: PROMPT_KEY, + name: StoreKey.Prompt, version: 1, onRehydrateStorage(state) { const PROMPT_URL = "./prompts.json"; diff --git a/app/store/update.ts b/app/store/update.ts index 47b190b88..8d8808220 100644 --- a/app/store/update.ts +++ b/app/store/update.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { FETCH_COMMIT_URL, FETCH_TAG_URL } from "../constant"; +import { FETCH_COMMIT_URL, FETCH_TAG_URL, StoreKey } from "../constant"; import { requestUsage } from "../requests"; export interface UpdateStore { @@ -16,8 +16,6 @@ export interface UpdateStore { updateUsage: (force?: boolean) => Promise; } -export const UPDATE_KEY = "chat-update"; - function queryMeta(key: string, defaultValue?: string): string { let ret: string; if (document) { @@ -55,10 +53,9 @@ export const useUpdateStore = create()( })); 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 remoteId = (data[0].sha as string).substring(0, 7); + const remoteCommitTime = data[0].commit.committer.date; + const remoteId = new Date(remoteCommitTime).getTime().toString(); set(() => ({ remoteVersion: remoteId, })); @@ -84,7 +81,7 @@ export const useUpdateStore = create()( }, }), { - name: UPDATE_KEY, + name: StoreKey.Update, version: 1, }, ), diff --git a/app/styles/globals.scss b/app/styles/globals.scss index 8a7b457a1..e7b6de3b1 100644 --- a/app/styles/globals.scss +++ b/app/styles/globals.scss @@ -1,3 +1,6 @@ +@import "./animation.scss"; +@import "./window.scss"; + @mixin light { --theme: light; @@ -312,3 +315,36 @@ pre { overflow: auto; } } + +.password-input-container { + max-width: 50%; + display: flex; + justify-content: flex-end; + + .password-eye { + margin-right: 4px; + } + + .password-input { + min-width: 80%; + } +} + +.user-avatar { + height: 30px; + min-height: 30px; + width: 30px; + min-width: 30px; + display: flex; + align-items: center; + justify-content: center; + border: var(--border-in-light); + box-shadow: var(--card-shadow); + border-radius: 10px; +} + +.one-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/components/window.scss b/app/styles/window.scss similarity index 100% rename from app/components/window.scss rename to app/styles/window.scss diff --git a/app/utils.ts b/app/utils.ts index 1da65c993..43ea796e5 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -1,4 +1,3 @@ -import { EmojiStyle } from "emoji-picker-react"; import { useEffect, useState } from "react"; import { showToast } from "./components/ui-lib"; import Locale from "./locales"; @@ -43,16 +42,43 @@ export function downloadAs(text: string, filename: string) { document.body.removeChild(element); } +export function readFromFile() { + return new Promise((res, rej) => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "application/json"; + + fileInput.onchange = (event: any) => { + const file = event.target.files[0]; + const fileReader = new FileReader(); + fileReader.onload = (e: any) => { + res(e.target.result); + }; + fileReader.onerror = (e) => rej(e); + fileReader.readAsText(file); + }; + + fileInput.click(); + }); +} + export function isIOS() { const userAgent = navigator.userAgent.toLowerCase(); return /iphone|ipad|ipod/.test(userAgent); } -export function useMobileScreen() { - const [isMobileScreen_, setIsMobileScreen] = useState(isMobileScreen()); +export function useWindowSize() { + const [size, setSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + useEffect(() => { const onResize = () => { - setIsMobileScreen(isMobileScreen()); + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); }; window.addEventListener("resize", onResize); @@ -62,14 +88,21 @@ export function useMobileScreen() { }; }, []); - return isMobileScreen_; + return size; +} + +export const MOBILE_MAX_WIDTH = 600; +export function useMobileScreen() { + const { width } = useWindowSize(); + + return width <= MOBILE_MAX_WIDTH; } export function isMobileScreen() { if (typeof window === "undefined") { return false; } - return window.innerWidth <= 600; + return window.innerWidth <= MOBILE_MAX_WIDTH; } export function isFirefox() { @@ -90,10 +123,6 @@ export function selectOrCopy(el: HTMLElement, content: string) { return true; } -export function getEmojiUrl(unified: string, style: EmojiStyle) { - return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`; -} - function getDomContentWidth(dom: HTMLElement) { const style = window.getComputedStyle(dom); const paddingWidth = diff --git a/docs/images/cover.png b/docs/images/cover.png index 20fbf6a86..381573055 100644 Binary files a/docs/images/cover.png and b/docs/images/cover.png differ diff --git a/docs/images/more.png b/docs/images/more.png index dfdb3580d..70c0d3154 100644 Binary files a/docs/images/more.png and b/docs/images/more.png differ diff --git a/docs/images/settings.png b/docs/images/settings.png index e1cdd07ee..31603daa4 100644 Binary files a/docs/images/settings.png and b/docs/images/settings.png differ diff --git a/middleware.ts b/middleware.ts deleted file mode 100644 index d16a812d9..000000000 --- a/middleware.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getServerSideConfig } from "./app/config/server"; -import md5 from "spark-md5"; - -export const config = { - matcher: ["/api/openai", "/api/chat-stream"], -}; - -const serverConfig = getServerSideConfig(); - -function getIP(req: NextRequest) { - let ip = req.ip ?? req.headers.get("x-real-ip"); - const forwardedFor = req.headers.get("x-forwarded-for"); - - if (!ip && forwardedFor) { - ip = forwardedFor.split(",").at(0) ?? ""; - } - - return ip; -} - -export function middleware(req: NextRequest) { - const accessCode = req.headers.get("access-code"); - const token = req.headers.get("token"); - const hashedCode = md5.hash(accessCode ?? "").trim(); - - console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]); - console.log("[Auth] got access code:", accessCode); - console.log("[Auth] hashed access code:", hashedCode); - console.log("[User IP] ", getIP(req)); - console.log("[Time] ", new Date().toLocaleString()); - - if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) { - return NextResponse.json( - { - error: true, - needAccessCode: true, - msg: "Please go settings page and fill your access code.", - }, - { - status: 401, - }, - ); - } - - // inject api key - if (!token) { - const apiKey = serverConfig.apiKey; - if (apiKey) { - console.log("[Auth] set system token"); - req.headers.set("token", apiKey); - } else { - return NextResponse.json( - { - error: true, - msg: "Empty Api Key", - }, - { - status: 401, - }, - ); - } - } else { - console.log("[Auth] set user token"); - } - - return NextResponse.next({ - request: { - headers: req.headers, - }, - }); -} diff --git a/next.config.js b/next.config.js deleted file mode 100644 index f7d5ff086..000000000 --- a/next.config.js +++ /dev/null @@ -1,18 +0,0 @@ -/** @type {import('next').NextConfig} */ - -const nextConfig = { - experimental: { - appDir: true, - }, - webpack(config) { - config.module.rules.push({ - test: /\.svg$/, - use: ["@svgr/webpack"], - }); - - return config; - }, - output: "standalone", -}; - -module.exports = nextConfig; diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 000000000..25770318b --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,34 @@ +/** @type {import('next').NextConfig} */ + +const nextConfig = { + experimental: { + appDir: true, + }, + async rewrites() { + const ret = []; + + const apiUrl = process.env.API_URL; + if (apiUrl) { + console.log("[Next] using api url ", apiUrl); + ret.push({ + source: "/api/:path*", + destination: `${apiUrl}/:path*`, + }); + } + + return { + afterFiles: ret, + }; + }, + webpack(config) { + config.module.rules.push({ + test: /\.svg$/, + use: ["@svgr/webpack"], + }); + + return config; + }, + output: "standalone", +}; + +export default nextConfig; diff --git a/scripts/fetch-prompts.mjs b/scripts/fetch-prompts.mjs index 02b52a31d..bfcf24f33 100644 --- a/scripts/fetch-prompts.mjs +++ b/scripts/fetch-prompts.mjs @@ -40,7 +40,7 @@ async function fetchEN() { return raw .split("\n") .slice(1) - .map((v) => v.split('","').map((v) => v.replace('"', ""))); + .map((v) => v.split('","').map((v) => v.replace(/^"|"$/g, '').replaceAll('""','"'))); } catch (error) { console.error("[Fetch] failed to fetch en prompts", error); return []; diff --git a/scripts/init-proxy.sh b/scripts/init-proxy.sh index acba064f4..32e55921a 100644 --- a/scripts/init-proxy.sh +++ b/scripts/init-proxy.sh @@ -1,5 +1,6 @@ dir="$(dirname "$0")" config=$dir/proxychains.conf host_ip=$(grep nameserver /etc/resolv.conf | sed 's/nameserver //') +echo "proxying to $host_ip" cp $dir/proxychains.template.conf $config sed -i "\$s/.*/http $host_ip 7890/" $config