├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── README.zh-CN.md ├── docs ├── CONTRIBUTING.md └── TRANSLATING.md ├── i18n ├── README.md ├── en-US │ ├── $.json │ ├── _.json │ └── _chatgpt.json └── zh-CN │ ├── $.json │ ├── _.json │ └── _chatgpt.json ├── jest.config.js ├── next-sitemap.config.js ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── favicon │ ├── favicon-16x16.png │ ├── favicon-192x192.png │ ├── favicon-32x32.png │ ├── favicon-512x512.png │ └── favicon.ico ├── robots.txt ├── sitemap.xml ├── vite.svg └── wechat.jpg ├── scripts └── gen-enc.js ├── src ├── api │ ├── chat.ts │ ├── conversation.ts │ ├── edge │ │ ├── chat.ts │ │ ├── conversation.ts │ │ └── user.ts │ └── user.ts ├── app │ ├── [lang] │ │ ├── [...not_found] │ │ │ └── page.ts │ │ ├── chatgpt-visual-novel │ │ │ └── page.client.tsx │ │ ├── chatgpt │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ └── page.tsx │ ├── api │ │ └── chatgpt │ │ │ └── stream │ │ │ └── route.ts │ └── globals.css ├── assets │ ├── assets.json │ ├── clickprompt-light.svg │ ├── clickprompt-small.svg │ ├── icons │ │ ├── gpt.svg │ │ ├── logout.svg │ │ ├── message.svg │ │ ├── new-chat.svg │ │ ├── send.svg │ │ ├── trashcan.svg │ │ └── volume.svg │ └── images │ │ ├── chatgpt-logo.svg │ │ └── content.png ├── components │ ├── ChakraUI │ │ ├── Provider.tsx │ │ ├── icons.ts │ │ └── index.ts │ ├── ClickPrompt │ │ ├── Button.shared.tsx │ │ ├── ClickPromptButton.tsx │ │ ├── ExecutePromptButton.tsx │ │ └── LoggingDrawer.tsx │ ├── CopyComponent.tsx │ ├── CustomIcon.tsx │ ├── Engine │ │ ├── Background.tsx │ │ ├── DialogueCard.tsx │ │ ├── DialogueMenu.tsx │ │ ├── HistoryCard.tsx │ │ ├── InteractionCard.tsx │ │ ├── LoadStoryMenu.tsx │ │ ├── LoadingCard.tsx │ │ ├── MainMenu.tsx │ │ ├── NewStoryMenu.tsx │ │ └── SpeakerCard.tsx │ ├── Highlight.tsx │ ├── LocaleSwitcher.tsx │ ├── SimpleColorPicker.tsx │ ├── chatgpt │ │ ├── AiBlock.tsx │ │ ├── ChatGPTApp.tsx │ │ ├── ChatRoom.tsx │ │ ├── HumanBlock.tsx │ │ └── LoginPage.tsx │ └── markdown │ │ ├── Mermaid.tsx │ │ ├── MermaidWrapper.tsx │ │ └── SimpleMarkdown.tsx ├── configs │ └── constants.ts ├── i18n │ ├── en-US.ts │ ├── index.ts │ ├── pagePath.ts │ └── zh-CN.ts ├── layout │ └── NavBar.tsx ├── middleware.ts ├── pages │ └── api │ │ ├── action │ │ └── proxy.ts │ │ └── chatgpt │ │ ├── chat.ts │ │ ├── conversation.ts │ │ ├── user.ts │ │ └── verify.ts ├── storage │ ├── planetscale.ts │ └── webstorage.ts ├── types.d.ts └── utils │ ├── content.util.ts │ ├── crypto.util.ts │ ├── huggingface.space.util.ts │ ├── openapi.util.ts │ ├── types.d.ts │ ├── user.edge.util.ts │ └── user.util.ts ├── tailwind.config.js ├── tsconfig.json └── vercel.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [dev, master] 6 | pull_request: 7 | branches: [dev] 8 | 9 | jobs: 10 | build: 11 | name: Build & Test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: ["lts/gallium", "lts/hydrogen", "current"] 16 | steps: 17 | - name: Checkout 🛎️ 18 | uses: actions/checkout@v3 19 | with: 20 | persist-credentials: false 21 | 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: 16 25 | 26 | - run: npm ci 27 | 28 | - run: npm run test 29 | 30 | - run: npm run build --if-present 31 | lint: 32 | name: format and lint 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 🛎️ 36 | uses: actions/checkout@v3 37 | with: 38 | persist-credentials: false 39 | 40 | - uses: actions/setup-node@v3 41 | with: 42 | node-version: 16 43 | - run: npm ci 44 | 45 | - run: npm run format 46 | 47 | - run: npm run lint 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | pnpm-lock.yaml 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /.swc/ 15 | /out/ 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | .pnpm-debug.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | .idea 40 | 41 | /dist 42 | public/sitemap-0.xml 43 | src/assets/resources/**/*.json 44 | 45 | .vercel 46 | .env 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Prompt Engineering 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatVisualNovel - A fully customizable visual novel engine powered by ChatGPT 2 | 3 | [![ci](https://github.com/prompt-engineering/chat-visual-novel/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-engineering/chat-visual-novel/actions/workflows/ci.yml) 4 | ![GitHub](https://img.shields.io/github/license/prompt-engineering/chat-visual-novel) 5 | [![Discord](https://img.shields.io/discord/1082563233593966612)](https://discord.gg/FSWXq4DmEj) 6 | 7 | English | [简体中文](./README.zh-CN.md) 8 | 9 | Online Demo: https://chatvisualnovel.com/ 10 | 11 | Genshin Impact Doujin: https://genshin.chatvisualnovel.com/ 12 | 13 | Ace Attorney Doujin: https://ace.chatvisualnovel.com/ 14 | 15 | ![Screenshot](https://chatvisualnovelassets.s3.us-west-2.amazonaws.com/images/screenshots/Screenshot+2023-03-27+at+09.51.31.png) 16 | 17 | Join us: 18 | 19 | [![Chat Server](https://img.shields.io/badge/chat-discord-7289da.svg)](https://discord.gg/FSWXq4DmEj) 20 | 21 | # Deploy ChatVisualNovel on Vercel with Planetscale 22 | 23 | Follow these steps to deploy ChatVisualNovel on Vercel with a serverless MySQL database provided by Planetscale: 24 | 25 | 1. Clone [ChatVisualNovel](https://github.com/prompt-engineering/chat-visual-novel) from GitHub. 26 | 2. Create a Vercel account and connect it to your GitHub account. 27 | 3. Create a [Planetscale](https://app.planetscale.com) account. 28 | 4. Set up your Planetscale database: 29 | 1. Log in to your Planetscale account with `pscale auth login`. 30 | 2. Create a password with `pscale password create `. 31 | 3. Push your database to Planetscale with `npx prisma db push`. 32 | 5. Configure your Vercel environment: 33 | - Set `DATABASE_URL` to your Planetscale database URL. 34 | - Generate an encryption key with `node scripts/gen-enc.js` and set it as `ENC_KEY`. 35 | 36 | With these steps completed, your ChatVisualNovel will be deployed on Vercel with a Planetscale serverless MySQL database. 37 | 38 | ## Local Usage 39 | 40 | 1. Clone the [ChatVisualNovel repo](https://github.com/prompt-engineering/chat-visual-novel) from GitHub. 41 | 2. Dependencies on Planetscale services still exist temporarily. Please register as mentioned in the previous section and configure `DATABASE_URL` in the `.env` file. 42 | 3. Run `npm install`. 43 | 4. Generate an encryption key using `node scripts/gen-enc.js` and configure it in the `.env` file in the format `ENC_KEY=***`. (Note: You can copy the `.env` file from env.template) 44 | 5. You can now use the application by running `npm run dev`. 45 | 46 | # Customization 47 | 48 | [assets.json](src/assets/assets.json) 49 | 50 | - When an item is marked with (i18n). Either the key or value needs to be locale mapped in i18n configs. 51 | 52 | ```typescript 53 | { 54 | "genres": string[], //(Required)(i18n) Genres, used in Prompt. 55 | "player": { //(Optional) Player characters whose name will be generated by ChatGPT. Used only when there is no isPlayer: true in characters. 56 | "images": { 57 | [key: string]: string //(Required) Each key is a mood of the character. Can have any number of moods but there must be one named neutral. All possible moods of the first character will be used in Prompt for mood selection of all characters. Value is the URL to the image of corresponding mood. 58 | }, 59 | "imageSettings": { 60 | [key: string]: string //(Optional) CSS override to this character's image when displayed. Take highest priority. 61 | } 62 | }, 63 | "playerGender": string, //(Optional)(i18n) Gender of player, used in Prompt when there is no isPlayer: true in characters. 64 | "girls": [{ //(Optional) Girl characters whose names will be generated by ChatGPT. Used only when there is no isPlayer: false in characters. 65 | "images": { 66 | [key: string]: string //(Required) Each key is a mood of the character. Can have any number of moods but there must be one named neutral. All possible moods of the first character will be used in Prompt for mood selection of all characters. Value is the URL to the image of corresponding mood. 67 | }, 68 | "imageSettings": { 69 | [key: string]: string //(Optional) CSS override to this character's image when displayed. Take highest priority. 70 | } 71 | }], 72 | "characters": { //(Optional) Named characters. 73 | [key: string]: { //(Required)(i18n) Character name, used in Prompt. 74 | "isPlayer": boolean, //(Optional) When set to true, will be the player character. Please only set one character as isPlayer: true. 75 | "images": { 76 | [key: string]: string //(Required) Each key is a mood of the character. Can have any number of moods but there must be one named neutral. All possible moods of the first character will be used in Prompt for mood selection of all characters. Value is the URL to the image of corresponding mood. 77 | }, 78 | "imageSettings": { 79 | [key: string]: string //(Optional) CSS override to this character's image when displayed. Take highest priority. 80 | } 81 | } 82 | }, 83 | "places": { //(Required) Location (Background). 84 | [key: string]: { //(Required)(i18n) Each key is a location. There must be at least one location. All possible locations will be used in Prompt for location selection. 85 | "image": string, //(Required) URL to the image of the location. 86 | "bgm": string //(Optional) Background music of this location. 87 | }, 88 | "imageSettings": { //(Optional) Global Character image settings (CSS). 89 | [key: string]: string 90 | }, 91 | "tts": { //(Optional) Online Text-to-speach service integration. Only basic GET is supported for now. 92 | [key: string]: { //(Required) i18n locale, can set a default. 93 | "method": string, //(Optional) GET or POST(not supported yet) or HuggingFaceSpace, defaults to GET. 94 | "url": string, //(Required) API URL. 95 | "params": { //(Optional) URL query param map. Required when method is GET. 96 | "speaker": string, //(Required) Query param name for speaker. 97 | "text": string, //(Required) Query param name for text(dialogue). 98 | "additionalParams": string //(Optional) Additional parameters as a string. 99 | }, 100 | "ws": { //(Optional) When method is HuggingFaceSpace, this is required. 101 | url: string; //(Required) Hugging Face Space websocket URL. 102 | data: string[]; //(Optional) Data payload schema. Each key name will be replaced by the corresponding value. 103 | }, 104 | "voices": { 105 | male: string[]; //(Optional) Collection of allowed male voice names. 106 | female: string[]; //(Optional) Collection of allowed female voice names. 107 | } 108 | } 109 | } 110 | } 111 | ``` 112 | 113 | ## LICENSE 114 | 115 | This code is distributed under the MIT license. See [LICENSE](./LICENSE) in this directory. 116 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # ChatVisualNovel - 基于 ChatGPT 的定制化视觉小说引擎。 2 | 3 | [![ci](https://github.com/prompt-engineering/chat-visual-novel/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-engineering/chat-visual-novel/actions/workflows/ci.yml) 4 | ![GitHub](https://img.shields.io/github/license/prompt-engineering/chat-visual-novel) 5 | 6 | 演示: https://chatvisualnovel.com/ 7 | 8 | 原神同人(AI 语音): https://genshin.chatvisualnovel.com/ 9 | 10 | 逆转裁判同人: https://ace.chatvisualnovel.com/ 11 | 12 | ![截图](https://chatvisualnovelassets.s3.us-west-2.amazonaws.com/images/screenshots/Screenshot+2023-03-27+at+10.05.36.png) 13 | 14 | [English](./README.md) | 简体中文 15 | 16 | # 在 Vercel 上部署 ChatVisualNovel,使用 Planetscale 17 | 18 | 按照以下步骤,在 Vercel 上部署 ChatVisualNovel,使用由 Planetscale 提供的无服务器 MySQL 数据库: 19 | 20 | 1. 从 GitHub 克隆 [ChatVisualNovel](https://github.com/prompt-engineering/chat-visual-novel)。 21 | 2. 创建 Vercel 帐户,并将其连接到 GitHub 帐户。 22 | 3. 创建 [Planetscale](https://app.planetscale.com) 帐户。 23 | 4. 设置 Planetscale 数据库: 24 | 1. 使用 `pscale auth login` 登录 Planetscale 帐户。 25 | 2. 使用 `pscale password create ` 创建密码。 26 | 3. 使用 `npx prisma db push` 将数据库推送到 Planetscale。 27 | 5. 配置 Vercel 环境: 28 | - 将 `DATABASE_URL` 设置为 Planetscale 数据库的 URL。 29 | - 使用 `node scripts/gen-enc.js` 生成加密密钥,并将其设置为 `ENC_KEY`。 30 | 31 | 完成这些步骤后,您的 ChatVisualNovel 将在 Vercel 上部署,并使用 Planetscale 的无服务器 MySQL 数据库。 32 | 33 | ## 本地搭建 34 | 35 | 1. 从 GitHub 克隆 [ChatVisualNovel](https://github.com/prompt-engineering/chat-visual-novel)。 36 | 2. 暂时仍依赖 Planetscale 服务,按照上小节注册,并配置`DATABASE_URL`到.env 文件。 37 | 3. 执行 `npm install`。 38 | 4. 使用 `node scripts/gen-enc.js` 生成加密密钥,在 `.env` 文件中配置 `ENC_KEY=***` 的形式。(PS:`.env` 文件可以从 env.template 复制过去) 39 | 5. 直接运行 `npm run dev` 就可以使用了。 40 | 41 | # 自定义模版 42 | 43 | [assets.json](src/assets/assets.json) 44 | 45 | - 当出现(i18n)标注时,对应的 key 或者 value 需要在 i18n 中配置多语言对应。 46 | 47 | ```typescript 48 | { 49 | "genres": string[], //(Required)(i18n)故事类型,用于Prompt。 50 | "player": { // (Optional)让ChatGPT命名的玩家角色,当 characters 中不存在 isPlayer: true 的角色时使用。 51 | "images": { 52 | [key: string]: string, //(Required)每一个key对应这个角色的一个表情,可以是任意数量但必须存在一个 neutral,角色列表中第一位角色的所有可能表情将被使用在 Prompt 中作为可挑选的 mood。value 是这个表情对应的图片地址。 53 | }, 54 | "imagesSettings": { 55 | [key: string]: string, //(Optional)当显示这个角色的图片时加载的CSS,最高优先级。 56 | } 57 | }, 58 | "playerGender": string, //(Optional)(i18n)主人公性别,当 characters 中不存在 isPlayer: true 的角色时用于Prompt。 59 | "girls": [{ // (Optional)让ChatGPT命名的女性角色,当 characters 中不存在 isPlayer: false 的角色时使用。 60 | "images": { 61 | [key: string]: string, //(Required)每一个key对应这个角色的一个表情,可以是任意数量但必须存在一个 neutral,角色列表中第一位角色的所有可能表情将被使用在 Prompt 中作为可挑选的 mood。value 是这个表情对应的图片地址。 62 | }, 63 | "imagesSettings": { 64 | [key: string]: string, //(Optional)当显示这个角色的图片时加载的CSS,最高优先级。 65 | } 66 | }], 67 | "characters": { //(Optional)有名字的角色。 68 | [key: string]: { //(Required)(i18n)角色名字,用于Prompt。 69 | "isPlayer": boolean, //(Optional)设为 true 时将作为玩家角色,请只设置一个玩家角色。 70 | "images": { 71 | [key: string]: string, //(Required)每一个key对应这个角色的一个表情,可以是任意数量但必须存在一个 neutral,角色列表中第一位角色的所有可能表情将被使用在 Prompt 中作为可挑选的 mood。value 是这个表情对应的图片地址。 72 | }, 73 | "imagesSettings": { 74 | [key: string]: string, //(Optional)当显示这个角色的图片时加载的CSS,最高优先级。 75 | } 76 | } 77 | }, 78 | "places": { //(Required)地点(背景)。 79 | [key: string]: { //(Required)(i18n)每一个key对应一个地点,可以是任意数量但必须至少存在一个,所有可能的地点将被使用在 Prompt 中作为可挑选的 location。 80 | "image": string, //(Required)这个地点对应的图片地址。 81 | "bgm": string //(Optional)这个地点对应的背景音乐地址。 82 | } 83 | }, 84 | "imagesSettings": { //(Optional)角色图片显示配置(CSS)。 85 | [key: string]: string, 86 | }, 87 | "tts": { //(Optional)在线文字语音合成服务集成,目前仅支持GET。 88 | [key: string]: { //(Required) 对应 i18n 地区,可以设一个 default。 89 | "method": string, //(Optional)GET或POST(暂时不支持)或HuggingFaceSpace,默认为GET。 90 | "url": string, //(Required)调用API的URL。 91 | "params": { //(Optional)URL参数对应,如果 method 为 GET 则必填。 92 | "speaker": string, //(Required)speaker 对应的参数名。 93 | "text": string, //(Required)text 对应的参数名。 94 | "additionalParams": string //(Optional)附加参数值。 95 | }, 96 | "ws": { //(Optional)如果 method 是 HuggingFaceSpace,则为必填项。 97 | url: string; //(Required)Hugging Face Space websocket 地址。 98 | data: string[]; //(Optional)数组里的每一个标签将会被对应的值替换成为一个新数组作为最终数据传给 websocket。 99 | }, 100 | "voices": { 101 | male: string[]; //(Optional)所有男性声音名字的合集。 102 | female: string[]; //(Optional)所有女性声音名字的合集。 103 | } 104 | } 105 | } 106 | } 107 | ``` 108 | 109 | ## LICENSE 110 | 111 | This code is distributed under the MIT license. See [LICENSE](./LICENSE) in this directory. 112 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Manual 2 | 3 | We welcome contributions of any size and skill level. As an open source project, we believe in giving back to our contributors and are happy to help with guidance on PRs, technical writing, and turning any feature idea into a reality. 4 | 5 | > **Tip for new contributors:** 6 | > Take a look at [https://github.com/firstcontributions/first-contributions](https://github.com/firstcontributions/first-contributions) for helpful information on contributing 7 | 8 | ## Quick Guide 9 | 10 | ### Prerequisite 11 | 12 | ```shell 13 | node: ">=16.0.0" 14 | npm: "^8.11.0" 15 | # otherwise, your build will fail 16 | ``` 17 | 18 | ### Setting up your local repo 19 | 20 | ```shell 21 | git clone && cd ... 22 | npm install 23 | npm run build 24 | ``` 25 | 26 | ### Development 27 | 28 | ```shell 29 | # starts a file-watching, live-reloading dev script for active development 30 | npm run dev 31 | # build the entire project, one time. 32 | npm run build 33 | ``` 34 | 35 | ### Running tests 36 | 37 | ```shell 38 | # run this in the top-level project root to run all tests 39 | npm run test 40 | ``` 41 | 42 | ### Making a Pull Request 43 | 44 | You can run the following commands before making a Pull Request 45 | 46 | ```shell 47 | # format with fix 48 | npm run format:fix 49 | # lint with fix 50 | npm run lint:fix 51 | ``` 52 | 53 | ## Code Structure 54 | 55 | TODO 56 | 57 | ## Translation 58 | 59 | See [i18n guide](TRANSLATING.md) 60 | -------------------------------------------------------------------------------- /docs/TRANSLATING.md: -------------------------------------------------------------------------------- 1 | # 🌐 i18n Guide 2 | 3 | Thanks for your interest in helping us translate ClickPrompt! 4 | 5 | TODO 6 | -------------------------------------------------------------------------------- /i18n/README.md: -------------------------------------------------------------------------------- 1 | # i18n files 2 | 3 | Inside this folder, the first folder level is locale code such as `en-US`, and in it has A LOT of json files the naming convention is: 4 | 5 | - Global data is in the `$.json` file. 6 | - For specific page data: 7 | - index page is corresponding to `_.json` file 8 | - other pages just use pathname without trailing slash and locale segment, and replace all `/` with `_`(cause in some filesystem `/` is illegal charactor in pathname). such as `_foo.json` for `/foo/`, `_foo_bar.json` for `/foo/bar/` . I think you get the idea. 9 | 10 | # HOW TO USE IN RSC(React server component) 11 | 12 | ```typescript jsx 13 | // page.server.tsx 14 | import { getAppData } from "@/i18n"; 15 | import CSC from "./component.client.tsx"; 16 | 17 | async function RscFoo() { 18 | // ... 19 | const { locale, pathname, i18n } = await getAppData(); 20 | const t = i18n.tFactory("/"); 21 | // t is a function takes key and give you value in the json file 22 | t("title"); // will be "Streamline your prompt design" 23 | 24 | // you can also access global data by 25 | const g = i18n.g; 26 | 27 | const i18nProps: GeneralI18nProps = { 28 | locale, 29 | pathname, 30 | i18n: { 31 | dict: i18n.dict, 32 | }, 33 | }; 34 | 35 | // use i18n in CSC (client side component) 36 | return ; 37 | // ... 38 | } 39 | ``` 40 | 41 | ```typescript jsx 42 | // component.client.tsx 43 | "use client"; 44 | 45 | export default function CSC({ i18n }: GeneralI18nProps) { 46 | const { dict } = i18n; 47 | 48 | // use dict like plain object here 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /i18n/en-US/$.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /i18n/en-US/_.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Before we begin,", 3 | "male": "male", 4 | "female": "female", 5 | "prompt_main_character_default": "Firstly, generate a main character who is ", 6 | "prompt_main_character_gender_suffix": "", 7 | "prompt_main_character_named": "Firstly, the main character's name is ", 8 | "prompt_other_characters": ", and the other characters are ", 9 | "prompt_generate_girls_prefix": ", and generate ", 10 | "prompt_generate_girls_suffix": " girl characters", 11 | "prompt_characters_json_prefix": "\n\nFill their names in a JSON array and return only this array. You are not allowed to start the story now.", 12 | "prompt_characters_json_suffix": "", 13 | "prompt_story_start": "You are a writer and is writing a ", 14 | "prompt_after_story_genre": " novel. You must follow these rules:\n- Format is (location)/(speaker)/(mood): (dialogue)/(answers in JSON array)", 15 | "prompt_after_story_format": "- mood can only be one of ", 16 | "prompt_places": "- location can only be one of ", 17 | "prompt_end": "- If the dialogue contains a question to the main character, provide up to 3 answers.\n- If I provided answers, speaker should not be the main character.\n- If speaker is the main character, don't provide any answers.\n- You must accept my response.\n- All generated dialogues should follow the rules above.", 18 | "prompt_start_story": "You can start the story now. Generate one line of dialogue", 19 | "prompt_continue": "Continue with one line of dialogue.", 20 | "prompt_continue_with_answer": "Continue with one line of dialogue based on the answer: ", 21 | "select_genre": "Please select a genre: ", 22 | "crime": "crime", 23 | "sci-fi": "sci-fi", 24 | "fantasy": "fantasy", 25 | "horror": "horror", 26 | "romance": "romance", 27 | "sd_note_prefix": "Images pre-generated using ", 28 | "sd_note_model": " with ", 29 | "reset": "Reset", 30 | "loading": "Discussing storyline with ChatGPT", 31 | "loading_assets": "Loading assets", 32 | "start": "Start", 33 | "continue": "Continue", 34 | "prompt": "Prompt", 35 | "cast_prefix": "This is a story between ", 36 | "and": " and ", 37 | "cast_suffix": ".", 38 | "room": "Room", 39 | "lobby": "Lobby", 40 | "garden": "Garden", 41 | "restaurant": "Restaurant", 42 | "street": "Street", 43 | "school": "School", 44 | "copyright_note": "Images pre-generated using Stable Diffusion with AbyssOrangeMix3", 45 | "select_api_type": "Please select how would you like to access ChatGPT:", 46 | "select_api_type_note": "- If you choose to use client-side, please make sure you can access OpenAI server. OpenAI may block users accessing outside of allowed areas.\n- If you choose to access via our server, we cannot gurantee a smooth experience as OpenAI would rate limit our server.", 47 | "client": "Client-side", 48 | "server": "Server-side", 49 | "new_story": "New Story", 50 | "load_story": "Load", 51 | "delete_all_story": "Delete all", 52 | "delete_story": "Delete story", 53 | "action_confirmation": "Are you sure? You will not able to undo this action.", 54 | "confirm": "Confirm", 55 | "cancel": "Cancel", 56 | "return_to_main_menu": "Main Menu", 57 | "no_record": "No Record", 58 | "en-US": "English", 59 | "select_mode": "Please select interaction mode: ", 60 | "classic": "Classic", 61 | "free": "Free", 62 | "select_mode_note": "- Classic: You will be given choices.\n- Free: You will type your response.", 63 | "history": "History" 64 | } 65 | -------------------------------------------------------------------------------- /i18n/en-US/_chatgpt.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /i18n/zh-CN/$.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /i18n/zh-CN/_.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "在开始之前,", 3 | "male": "男", 4 | "female": "女", 5 | "prompt_main_character_default": "在开始故事之前,设计一个有虚构但好听名字的主角(", 6 | "prompt_main_character_gender_suffix": ")", 7 | "prompt_main_character_named": "在开始故事之前,主角必须是:", 8 | "prompt_other_characters": ",其他角色必须是:", 9 | "prompt_generate_girls_prefix": ",设计", 10 | "prompt_generate_girls_suffix": "位有虚构但好听的名字的女孩角色", 11 | "prompt_characters_json_prefix": ",把所有角色的名字生成一个字符串数组。只返回这个数组。你现在禁止开始这个故事。", 12 | "prompt_characters_json_suffix": "", 13 | "prompt_story_start": "你是一个作家,在写一个", 14 | "prompt_after_story_genre": "故事,你必须遵守以下规则:\n- 输出格式为:地点/说话者的名字/表情:说话者说的话/回答(字符串数组)", 15 | "prompt_after_story_format": "- 表情只能从这些中挑选:", 16 | "prompt_places": "- 地点只能从这些中挑选:", 17 | "prompt_end": "- 对话内容是中文\n- 如果对话是其他角色问主角的问题,回答中可以写入至多三个主角可能的回答\n- 如果主角在说话,回答必须为空\n- 如果我回复了,则这一句的说话者不能是主角\n- 必须认可任何我的回答\n- 生成的所有内容必须符合上述规则", 18 | "prompt_start_story": "现在开始这个故事,生成一行对话", 19 | "prompt_continue": "继续一行对话", 20 | "prompt_continue_with_answer": "基于下面的回答继续一行对话,", 21 | "select_genre": "请选择一个故事类型:", 22 | "crime": "犯罪", 23 | "sci-fi": "科幻", 24 | "fantasy": "奇幻", 25 | "horror": "惊悚", 26 | "romance": "言情", 27 | "sd_note_prefix": "图片由 ", 28 | "sd_note_model": " 生成,模型:", 29 | "reset": "重置", 30 | "loading": "正和 ChatGPT 讨论故事情节", 31 | "loading_assets": "正在加载素材", 32 | "start": "开始", 33 | "continue": "继续", 34 | "prompt": "咒语", 35 | "cast_prefix": "这是一个", 36 | "and": "和", 37 | "cast_suffix": "之间的故事。", 38 | "room": "房间", 39 | "lobby": "大厅", 40 | "garden": "花园", 41 | "restaurant": "餐厅", 42 | "street": "街头", 43 | "school": "学校", 44 | "copyright_note": "图片由 Stable Diffusion 生成。模型:AbyssOrangeMix3", 45 | "select_api_type": "请选择你将如何访问 ChatGPT:", 46 | "select_api_type_note": "- 如果你选择客户端模式,请确保你可以连接 OpenAI 服务器。在未经 OpenAI 许可的区域使用可能会造成封号。\n- 如果你选择服务器模式,在忙时 OpenAI 会对我们的服务器限流,我们将无法保证体验的稳定性。", 47 | "client": "客户端模式", 48 | "server": "服务器模式", 49 | "new_story": "新的开始", 50 | "load_story": "读取记录", 51 | "delete_all_story": "删除所有记录", 52 | "delete_story": "删除这个记录", 53 | "action_confirmation": "确认${action}?该操作将无法撤回。", 54 | "confirm": "确认", 55 | "cancel": "取消", 56 | "return_to_main_menu": "返回主菜单", 57 | "no_record": "没有记录", 58 | "zh-CN": "简体中文", 59 | "select_mode": "请选择交互模式:", 60 | "classic": "经典模式", 61 | "free": "自由模式", 62 | "select_mode_note": "- 经典模式:通过选项与角色交互。\n- 自由模式:自由输入和角色交互的内容。", 63 | "history": "历史" 64 | } 65 | -------------------------------------------------------------------------------- /i18n/zh-CN/_chatgpt.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | const nextJest = require("next/jest"); 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: "./", 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | /** @type {import('jest').Config} */ 11 | const customJestConfig = { 12 | // Add more setup options before each test is run 13 | // setupFilesAfterEnv: ['/jest.setup.js'], 14 | testEnvironment: "jest-environment-jsdom", 15 | moduleNameMapper: { 16 | "^jsonpath-plus": require.resolve("jsonpath-plus"), 17 | "^lodash-es$": "lodash", 18 | "^@/(.*)": "/src/$1", 19 | }, 20 | transformIgnorePatterns: [ 21 | "/node_modules/", 22 | "^.+\\.module\\.(css|sass|scss)$", 23 | ], 24 | }; 25 | 26 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 27 | module.exports = createJestConfig(customJestConfig); 28 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: process.env.SITE_URL || "https://www.chatvisualnovel.com/", 4 | generateRobotsTxt: true, // (optional) 5 | // ...other options 6 | }; 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | appDir: true, 6 | // TODO https://beta.nextjs.org/docs/configuring/typescript#statically-typed-links 7 | // typedRoutes: true, 8 | }, 9 | trailingSlash: true, 10 | transpilePackages: ["react-syntax-highlighter"], 11 | images: { 12 | domains: [ 13 | "prompt-engineering.github.io", 14 | "chatvisualnovelassets.s3.us-west-2.amazonaws.com", 15 | "assets.chatvisualnovel.com", 16 | ], 17 | }, 18 | webpack: (config, options) => { 19 | config.module.rules.push({ 20 | test: /\.yml/, 21 | use: "yaml-loader", 22 | }); 23 | 24 | config.module.rules.push({ 25 | test: /\.svg$/i, 26 | type: "asset", 27 | resourceQuery: /url/, // *.svg?url 28 | }); 29 | 30 | config.module.rules.push({ 31 | test: /\.svg$/i, 32 | issuer: /\.[jt]sx?$/, 33 | resourceQuery: { not: [/url/] }, // exclude react component if *.svg?url 34 | use: ["@svgr/webpack"], 35 | }); 36 | 37 | return config; 38 | }, 39 | }; 40 | 41 | module.exports = nextConfig; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-visual-novel", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "husky install", 7 | "dev": "npm run prepare:data && cross-env NODE_ENV='development' next dev", 8 | "build": "next build", 9 | "postbuild": "next-sitemap", 10 | "start": "npm run dev", 11 | "lint": "next lint", 12 | "lint:fix": "next lint --fix", 13 | "format": "prettier --check . -u", 14 | "format:fix": "prettier --write . -u", 15 | "postinstall": "npm run prepare:data", 16 | "prepare:env": "npx vercel link && npx vercel env pull .env.local", 17 | "prepare:data": "", 18 | "test": "jest --passWithNoTests", 19 | "test:watch": "jest --watch" 20 | }, 21 | "dependencies": { 22 | "@chakra-ui/icons": "^2.0.17", 23 | "@chakra-ui/react": "^2.5.1", 24 | "@chakra-ui/spinner": "^2.0.13", 25 | "@chakra-ui/system": "^2.5.1", 26 | "@emotion/react": "^11.10.6", 27 | "@emotion/styled": "^11.10.6", 28 | "@formatjs/intl-localematcher": "^0.2.32", 29 | "@planetscale/database": "^1.6.0", 30 | "@prisma/client": "^4.11.0", 31 | "@remirror/pm": "^2.0.4", 32 | "@remirror/react": "^2.0.27", 33 | "@remirror/react-editors": "^1.0.27", 34 | "@tanstack/react-table": "^8.7.9", 35 | "@types/jsonpath-plus": "^5.0.2", 36 | "autosize": "^6.0.1", 37 | "chakra-ui-markdown-renderer": "^4.1.0", 38 | "client-only": "^0.0.1", 39 | "dagre": "^0.8.5", 40 | "dotparser": "^1.1.1", 41 | "encoding": "^0.1.13", 42 | "expr-eval": "^2.0.2", 43 | "formik": "^2.2.9", 44 | "framer-motion": "^10.0.1", 45 | "jsonpath-plus": "^7.2.0", 46 | "kysely": "^0.23.5", 47 | "kysely-planetscale": "^1.3.0", 48 | "lodash-es": "^4.17.21", 49 | "mermaid": "^10.0.2", 50 | "negotiator": "^0.6.3", 51 | "next": "13.2.3", 52 | "next-sitemap": "^4.0.2", 53 | "node-fetch": "^2", 54 | "openai": "^3.2.1", 55 | "react": "18.2.0", 56 | "react-color": "^2.19.3", 57 | "react-copy-to-clipboard": "^5.1.0", 58 | "react-dom": "18.2.0", 59 | "react-json-view": "^1.21.3", 60 | "react-markdown": "^8.0.5", 61 | "react-spinners": "^0.13.8", 62 | "react-syntax-highlighter": "^15.5.0", 63 | "reactflow": "^11.6.0", 64 | "remark-gfm": "^3.0.1", 65 | "remirror": "^2.0.26", 66 | "server-only": "^0.0.1", 67 | "sharp": "^0.31.3", 68 | "svg-pan-zoom": "^3.6.1", 69 | "typescript": "4.9.5", 70 | "use-debounce": "^9.0.3" 71 | }, 72 | "devDependencies": { 73 | "@svgr/webpack": "^6.5.1", 74 | "@testing-library/jest-dom": "^5.16.5", 75 | "@testing-library/react": "^14.0.0", 76 | "@types/autosize": "^4.0.1", 77 | "@types/dagre": "^0.7.48", 78 | "@types/jsonpath": "^0.2.0", 79 | "@types/lodash-es": "^4.17.6", 80 | "@types/negotiator": "^0.6.1", 81 | "@types/node": "18.14.5", 82 | "@types/node-fetch": "^2.6.2", 83 | "@types/papaparse": "^5.3.7", 84 | "@types/react": "18.0.28", 85 | "@types/react-color": "^3.0.6", 86 | "@types/react-copy-to-clipboard": "^5.0.4", 87 | "@types/react-dom": "18.0.11", 88 | "@types/react-syntax-highlighter": "^15.5.6", 89 | "@types/tunnel": "^0.0.3", 90 | "@typescript-eslint/eslint-plugin": "^5.54.1", 91 | "autoprefixer": "^10.4.13", 92 | "cross-env": "^7.0.3", 93 | "eslint": "8.35.0", 94 | "eslint-config-next": "13.2.3", 95 | "eslint-config-prettier": "^8.6.0", 96 | "eslint-plugin-prettier": "^4.2.1", 97 | "husky": "^8.0.3", 98 | "jest": "^29.4.3", 99 | "jest-environment-jsdom": "^29.4.3", 100 | "js-yaml": "^4.1.0", 101 | "lint-staged": "^13.1.2", 102 | "postcss": "^8.4.21", 103 | "prettier": "^2.8.4", 104 | "prisma": "^4.11.0", 105 | "tailwindcss": "^3.2.7", 106 | "tunnel": "^0.0.6", 107 | "walkdir": "^0.4.1", 108 | "yaml-loader": "^0.8.0" 109 | }, 110 | "overrides": { 111 | "react-json-view": { 112 | "react": "$react", 113 | "react-dom": "$react-dom" 114 | }, 115 | "flux": { 116 | "react": "$react", 117 | "react-dom": "$react-dom" 118 | } 119 | }, 120 | "engines": { 121 | "npm": ">=8.11.0", 122 | "node": ">=16.19.0" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | referentialIntegrity = "prisma" 12 | } 13 | // create table chats 14 | // ( 15 | // id int auto_increment comment 'Chat ID' 16 | // primary key, 17 | // conversation_id bigint unsigned not null comment 'Conversation ID that the chat belongs to', 18 | // role varchar(10) not null comment 'The role of the author of this message. ChatCompletionRequestMessageRoleEnum', 19 | // content varchar(4096) charset utf8 not null comment 'The contents of the message', 20 | // name varchar(512) charset utf8 null comment 'The name of the user in a multi-user chat', 21 | // created_at datetime default CURRENT_TIMESTAMP not null, 22 | // constraint id 23 | // unique (id) 24 | // ); 25 | model chats { 26 | id Int @id @default(autoincrement()) 27 | conversation_id Int 28 | role String 29 | content String @db.VarChar(4096) 30 | name String? @db.VarChar(512) 31 | created_at DateTime @default(now()) 32 | } 33 | 34 | // create table conversations 35 | // ( 36 | // id bigint unsigned auto_increment comment 'Conversation ID' 37 | // primary key, 38 | // user_id bigint unsigned not null comment 'User ID that the conversation belongs to', 39 | // name varchar(255) charset utf8 default 'Default name' not null invisible comment 'conversation name CAN DULICATED', 40 | // deleted tinyint(1) default 0 not null comment 'is conversation has been deleted or not', 41 | // created_at datetime default CURRENT_TIMESTAMP not null 42 | // ); 43 | model conversations { 44 | id Int @id @default(autoincrement()) 45 | user_id Int 46 | name String @default("Default name") 47 | deleted Boolean @default(false) 48 | created_at DateTime @default(now()) 49 | } 50 | 51 | // -- for example, a user can save a custom field with the name "story:123" and the value "blablablabla" 52 | // -- story:123 is the type_name:id => type_value 53 | // create table custom_field 54 | // ( 55 | // -- type id is a unique id for each custom field 56 | // id bigint unsigned auto_increment comment 'Custom Field ID' 57 | // primary key, 58 | // user_id bigint unsigned not null comment 'User ID that the custom field belongs to', 59 | // type_id bigint unsigned not null comment 'custom type id', 60 | // type_name varchar(255) charset utf8 default 'Default name' not null invisible comment 'custom field name', 61 | // type_value varchar(32768) charset utf8 default 'Default value' not null invisible comment 'custom field value', 62 | // created_at datetime default CURRENT_TIMESTAMP not null 63 | // ); 64 | 65 | model custom_field { 66 | id Int @id @default(autoincrement()) 67 | user_id Int 68 | type_id Int 69 | type_name String @default("Default name") 70 | type_value String @default("Default value") 71 | created_at DateTime @default(now()) 72 | } 73 | 74 | // create table users 75 | // ( 76 | // id bigint unsigned auto_increment comment 'User ID', 77 | // key_hashed varchar(64) not null comment 'hash of openai key', 78 | // iv varchar(32) not null comment 'iv of openai key', 79 | // key_encrypted varchar(255) not null comment 'openai key, but it''s encrypted', 80 | // deleted tinyint default 0 not null comment 'is user has been deleted or not', 81 | // created_at datetime default CURRENT_TIMESTAMP not null, 82 | // primary key (id, key_hashed), 83 | // constraint id 84 | // unique (id) 85 | // ); 86 | model users { 87 | id Int @id @default(autoincrement()) 88 | key_hashed String 89 | iv String 90 | key_encrypted String 91 | deleted Boolean @default(false) 92 | created_at DateTime @default(now()) 93 | } 94 | -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-visual-novel/928589e0779cd36c03c5ae23287bac8097d7581d/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-visual-novel/928589e0779cd36c03c5ae23287bac8097d7581d/public/favicon/favicon-192x192.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-visual-novel/928589e0779cd36c03c5ae23287bac8097d7581d/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-visual-novel/928589e0779cd36c03c5ae23287bac8097d7581d/public/favicon/favicon-512x512.png -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-visual-novel/928589e0779cd36c03c5ae23287bac8097d7581d/public/favicon/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Allow: / 4 | 5 | # Host 6 | Host: https://www.chatvisualnovel.com/ 7 | 8 | # Sitemaps 9 | Sitemap: https://www.chatvisualnovel.com/sitemap.xml 10 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/wechat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-visual-novel/928589e0779cd36c03c5ae23287bac8097d7581d/public/wechat.jpg -------------------------------------------------------------------------------- /scripts/gen-enc.js: -------------------------------------------------------------------------------- 1 | // node scripts/gen-enc.js 1234567890 2 | // read secret from command line 3 | let secret = process.argv[2]; 4 | // create key from secret 5 | let key = require("node:crypto") 6 | .createHash("sha256") 7 | .update(String(secret)) 8 | .digest("base64") 9 | .substr(0, 32); 10 | 11 | console.log(key); 12 | -------------------------------------------------------------------------------- /src/api/chat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RequestGetChats, 3 | RequestSend, 4 | ResponseGetChats, 5 | ResponseSend, 6 | } from "@/pages/api/chatgpt/chat"; 7 | import nodeFetch from "node-fetch"; 8 | import { isClientSideOpenAI } from "@/api/edge/user"; 9 | import * as EdgeChat from "@/api/edge/chat"; 10 | 11 | export async function getChatsByConversationId(conversationId: number) { 12 | if (isClientSideOpenAI()) 13 | return EdgeChat.getChatsByConversationId( 14 | conversationId 15 | ) as ResponseGetChats; 16 | const response = await nodeFetch("/api/chatgpt/chat", { 17 | method: "POST", 18 | body: JSON.stringify({ 19 | action: "get_chats", 20 | conversation_id: conversationId, 21 | } as RequestGetChats), 22 | }); 23 | const data = (await response.json()) as ResponseGetChats; 24 | if (!response.ok) { 25 | alert("Error: " + JSON.stringify((data as any).error)); 26 | return null; 27 | } 28 | 29 | if (!data) { 30 | alert("Error(getChatsByConversationId): sOmeTHiNg wEnT wRoNg"); 31 | return null; 32 | } 33 | 34 | return data; 35 | } 36 | 37 | export async function sendMessage( 38 | conversationId: number, 39 | message: string, 40 | name?: string, 41 | handleDelta?: (value: string, delta: string) => void 42 | ) { 43 | if (isClientSideOpenAI()) 44 | return (await EdgeChat.sendMessage( 45 | conversationId, 46 | message, 47 | name, 48 | handleDelta 49 | )) as ResponseSend; 50 | const response = await nodeFetch("/api/chatgpt/chat", { 51 | method: "POST", 52 | body: JSON.stringify({ 53 | action: "send", 54 | conversation_id: conversationId, 55 | messages: [ 56 | { 57 | role: "user", 58 | content: message, 59 | name: name ?? undefined, 60 | }, 61 | ], 62 | } as RequestSend), 63 | }); 64 | if (!response.ok) { 65 | throw new Error(await response.text()); 66 | } 67 | const data = (await response.json()) as ResponseSend; 68 | if (!data) { 69 | throw new Error("Empty response"); 70 | } 71 | return data; 72 | } 73 | 74 | export async function sendMsgWithStreamRes( 75 | conversationId: number, 76 | message: string, 77 | name?: string 78 | ) { 79 | const response = await fetch("/api/chatgpt/stream", { 80 | method: "POST", 81 | headers: { Accept: "text/event-stream" }, 82 | body: JSON.stringify({ 83 | action: "send_stream", 84 | conversation_id: conversationId, 85 | messages: [ 86 | { 87 | role: "user", 88 | content: message, 89 | name: name ?? undefined, 90 | }, 91 | ], 92 | }), 93 | }); 94 | 95 | if (!response.ok) { 96 | alert("Error: " + response.statusText); 97 | return; 98 | } 99 | if (response.body == null) { 100 | alert("Error: sOmeTHiNg wEnT wRoNg"); 101 | return; 102 | } 103 | return response.body; 104 | } 105 | -------------------------------------------------------------------------------- /src/api/conversation.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { 3 | RequestChangeConversationName, 4 | RequestCreateConversation, 5 | RequestDeleteAllConversation, 6 | RequestDeleteConversation, 7 | RequestGetConversations, 8 | ResponseGetConversations, 9 | ResponseCreateConversation, 10 | ResponseDeleteAllConversation, 11 | } from "@/pages/api/chatgpt/conversation"; 12 | import { isClientSideOpenAI } from "@/api/edge/user"; 13 | import * as EdgeConversation from "@/api/edge/conversation"; 14 | 15 | export async function getConversations() { 16 | if (isClientSideOpenAI()) return EdgeConversation.getConversations(); 17 | const response = await fetch("/api/chatgpt/conversation", { 18 | method: "POST", 19 | body: JSON.stringify({ 20 | action: "get_conversations", 21 | } as RequestGetConversations), 22 | }); 23 | const data = (await response.json()) as ResponseGetConversations; 24 | if (!response.ok) { 25 | alert("Error: " + JSON.stringify((data as any).error)); 26 | return; 27 | } 28 | 29 | if (data == null) { 30 | alert("Error(createConversation): sOmeTHiNg wEnT wRoNg"); 31 | return; 32 | } 33 | 34 | return data; 35 | } 36 | 37 | export async function createConversation(name?: string) { 38 | if (isClientSideOpenAI()) return EdgeConversation.createConversation(name); 39 | const response = await fetch("/api/chatgpt/conversation", { 40 | method: "POST", 41 | body: JSON.stringify({ 42 | action: "create_conversation", 43 | name: name ?? "Default name", 44 | } as RequestCreateConversation), 45 | }); 46 | const data = (await response.json()) as ResponseCreateConversation; 47 | if (!response.ok) { 48 | alert("Error(createConversation): " + JSON.stringify((data as any).error)); 49 | return; 50 | } 51 | 52 | if (data == null) { 53 | alert("Error(createConversation): sOmeTHiNg wEnT wRoNg"); 54 | return; 55 | } 56 | 57 | return data; 58 | } 59 | 60 | export async function changeConversationName( 61 | conversationId: number, 62 | name: string 63 | ) { 64 | if (isClientSideOpenAI()) 65 | return EdgeConversation.changeConversationName(conversationId, name); 66 | const response = await fetch("/api/chatgpt/conversation", { 67 | method: "POST", 68 | body: JSON.stringify({ 69 | action: "change_conversation_name", 70 | conversation_id: conversationId, 71 | name: name ?? "Default name", 72 | } as RequestChangeConversationName), 73 | }); 74 | const data = (await response.json()) as ResponseCreateConversation; 75 | if (!response.ok) { 76 | alert("Error: " + JSON.stringify((data as any).error)); 77 | return; 78 | } 79 | 80 | if (!data) { 81 | alert("Error(changeConversationName): sOmeTHiNg wEnT wRoNg"); 82 | return; 83 | } 84 | 85 | return data; 86 | } 87 | 88 | export async function deleteConversation(conversationId: number) { 89 | if (isClientSideOpenAI()) 90 | return EdgeConversation.deleteConversation(conversationId); 91 | const response = await fetch("/api/chatgpt/conversation", { 92 | method: "POST", 93 | body: JSON.stringify({ 94 | action: "delete_conversation", 95 | conversation_id: conversationId, 96 | } as RequestDeleteConversation), 97 | }); 98 | const data = (await response.json()) as ResponseCreateConversation; 99 | if (!response.ok) { 100 | alert("Error: " + JSON.stringify((data as any).error)); 101 | return; 102 | } 103 | 104 | if (!data) { 105 | alert("Error(deleteConversation): sOmeTHiNg wEnT wRoNg"); 106 | return; 107 | } 108 | 109 | return data; 110 | } 111 | 112 | export async function deleteAllConversations() { 113 | if (isClientSideOpenAI()) return EdgeConversation.deleteAllConversations(); 114 | const response = await fetch("/api/chatgpt/conversation", { 115 | method: "POST", 116 | body: JSON.stringify({ 117 | action: "delete_all_conversations", 118 | } as RequestDeleteAllConversation), 119 | }); 120 | const data = (await response.json()) as ResponseDeleteAllConversation; 121 | if (!response.ok) { 122 | alert("Error: " + JSON.stringify((data as any).error)); 123 | return; 124 | } 125 | 126 | if (data.error) { 127 | alert("Error(deleteAllConversation): sOmeTHiNg wEnT wRoNg: " + data.error); 128 | return; 129 | } 130 | 131 | return data; 132 | } 133 | -------------------------------------------------------------------------------- /src/api/edge/chat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CHAT_COMPLETION_CONFIG, 3 | CHAT_COMPLETION_URL, 4 | } from "@/configs/constants"; 5 | import { ResponseGetChats, ResponseSend } from "@/pages/api/chatgpt/chat"; 6 | import { WebStorage } from "@/storage/webstorage"; 7 | import { CreateChatCompletionStreamResponse } from "@/utils/types"; 8 | import { 9 | ChatCompletionRequestMessage, 10 | ChatCompletionResponseMessage, 11 | CreateChatCompletionResponse, 12 | } from "openai"; 13 | import { getApiKey } from "./user"; 14 | 15 | export function getChatsByConversationId(conversationId: number) { 16 | const _chatRepo = new WebStorage("o:c"); 17 | const _chats = _chatRepo.get() ?? []; 18 | return _chats.filter((e) => e.conversation_id == conversationId); 19 | } 20 | 21 | export function saveChat( 22 | conversationId: number, 23 | message: ChatCompletionResponseMessage 24 | ) { 25 | const _chatRepo = new WebStorage("o:c"); 26 | const _chats = _chatRepo.get() ?? []; 27 | let nextIndex = 1; 28 | for (const _index in _chats) { 29 | if ((_chats[_index].id ?? 0) >= nextIndex) 30 | nextIndex = (_chats[_index].id ?? 0) + 1; 31 | } 32 | const _chat = { 33 | id: nextIndex, 34 | conversation_id: conversationId, 35 | role: message.role as string, 36 | content: message.content, 37 | name: undefined, 38 | created_at: new Date().toISOString(), 39 | }; 40 | _chats.push(_chat); 41 | _chatRepo.set(_chats); 42 | return _chat; 43 | } 44 | 45 | export async function sendMessage( 46 | conversationId: number, 47 | message: string, 48 | name?: string, 49 | handleDelta?: (value: string, delta: string) => void 50 | ) { 51 | const messages = getChatsByConversationId(conversationId).map((it) => ({ 52 | role: it.role, 53 | content: it.content, 54 | name: it.name, 55 | })) as ChatCompletionRequestMessage[]; 56 | const _message: ChatCompletionRequestMessage = { 57 | role: "user", 58 | content: message, 59 | name: name ?? undefined, 60 | }; 61 | messages.push(_message); 62 | const apiKey = getApiKey(); 63 | if (!apiKey) throw new Error("API key not set."); 64 | try { 65 | const response = await fetch(CHAT_COMPLETION_URL, { 66 | method: "POST", 67 | headers: { 68 | "Content-Type": "application/json", 69 | Authorization: `Bearer ${apiKey}`, 70 | }, 71 | body: JSON.stringify({ 72 | ...CHAT_COMPLETION_CONFIG, 73 | messages: messages, 74 | stream: true, 75 | }), 76 | }); 77 | if (!response.ok) { 78 | throw new Error(await response.text()); 79 | } 80 | const reader = response.body?.getReader(); 81 | const decoder = new TextDecoder(); 82 | const message: ChatCompletionResponseMessage = { 83 | role: "assistant", 84 | content: "", 85 | }; 86 | while (reader) { 87 | const { value, done } = await reader.read(); 88 | const data = decoder.decode(value).split("\n"); 89 | for (const lineIndex in data) { 90 | const jsonStr = data[lineIndex].replace(/^data: /g, "").trim(); 91 | if (!jsonStr) continue; 92 | if (jsonStr == "[DONE]") break; 93 | let json: CreateChatCompletionStreamResponse | undefined = undefined; 94 | try { 95 | json = JSON.parse(jsonStr) as CreateChatCompletionStreamResponse; 96 | if ( 97 | json && 98 | json.choices && 99 | json.choices.length && 100 | "delta" in json.choices[0] && 101 | json.choices[0].delta 102 | ) { 103 | if (json.choices[0].delta.role) { 104 | message.role = json.choices[0].delta.role; 105 | } 106 | if (json.choices[0].delta.content) { 107 | message.content += json.choices[0].delta.content; 108 | if (handleDelta) { 109 | handleDelta(message.content, json.choices[0].delta.content); 110 | } 111 | } 112 | } 113 | } catch (e) { 114 | console.error(e); 115 | } 116 | } 117 | if (done) break; 118 | } 119 | saveChat(conversationId, _message); 120 | return [saveChat(conversationId, message)] as ResponseSend; 121 | } catch (e) { 122 | console.error(e); 123 | } 124 | } 125 | 126 | export function deleteChatsByConversationId(conversationId: number) { 127 | const _chatRepo = new WebStorage("o:c"); 128 | const _chats = _chatRepo.get() ?? []; 129 | const _filtered = _chats.filter((e) => e.conversation_id != conversationId); 130 | _chatRepo.set(_filtered); 131 | } 132 | 133 | export function deleteAllChats() { 134 | const _chatRepo = new WebStorage("o:c"); 135 | _chatRepo.set([]); 136 | } 137 | -------------------------------------------------------------------------------- /src/api/edge/conversation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ResponseCreateConversation, 3 | ResponseDeleteAllConversation, 4 | ResponseGetConversations, 5 | } from "@/pages/api/chatgpt/conversation"; 6 | import { WebStorage } from "@/storage/webstorage"; 7 | import { deleteAllChats, deleteChatsByConversationId } from "./chat"; 8 | 9 | function getConversationById( 10 | id: number, 11 | conversations: ResponseGetConversations 12 | ) { 13 | for (const _index in conversations) { 14 | const _conversation = conversations[_index]; 15 | if (_conversation.id == id) 16 | return { 17 | conversation: _conversation, 18 | index: parseInt(_index), 19 | }; 20 | } 21 | } 22 | 23 | export function getConversations() { 24 | const _conversationsRepo = new WebStorage( 25 | "o:convo" 26 | ); 27 | const _conversations = 28 | _conversationsRepo.get() ?? []; 29 | return _conversations as ResponseGetConversations; 30 | } 31 | 32 | export function createConversation(name?: string) { 33 | const _conversationsRepo = new WebStorage( 34 | "o:convo" 35 | ); 36 | const _conversations = 37 | _conversationsRepo.get() ?? []; 38 | let nextIndex = 1; 39 | for (const _index in _conversations) { 40 | if ((_conversations[_index].id ?? 0) >= nextIndex) 41 | nextIndex = (_conversations[_index].id ?? 0) + 1; 42 | } 43 | const _newConversation = { 44 | id: nextIndex, 45 | name: name ?? "Default name", 46 | created_at: new Date().toISOString(), 47 | user_id: 0, 48 | deleted: 0, 49 | }; 50 | _conversations.push(_newConversation); 51 | _conversationsRepo.set(_conversations); 52 | 53 | return _newConversation as ResponseCreateConversation; 54 | } 55 | 56 | export function changeConversationName(conversationId: number, name: string) { 57 | const _conversationsRepo = new WebStorage( 58 | "o:convo" 59 | ); 60 | const _conversations = 61 | _conversationsRepo.get() ?? []; 62 | const _result = getConversationById(conversationId, _conversations); 63 | if (!_result) return; 64 | _result.conversation.name = name; 65 | _conversationsRepo.set(_conversations); 66 | 67 | return _result.conversation as ResponseCreateConversation; 68 | } 69 | 70 | export function deleteConversation(conversationId: number) { 71 | const _conversationsRepo = new WebStorage( 72 | "o:convo" 73 | ); 74 | const _conversations = 75 | _conversationsRepo.get() ?? []; 76 | const _result = getConversationById(conversationId, _conversations); 77 | if (!_result) return; 78 | deleteChatsByConversationId(conversationId); 79 | _conversations.splice(_result.index, 1); 80 | _conversationsRepo.set(_conversations); 81 | return _result.conversation as ResponseCreateConversation; 82 | } 83 | 84 | export async function deleteAllConversations() { 85 | const _conversationsRepo = new WebStorage( 86 | "o:convo" 87 | ); 88 | deleteAllChats(); 89 | _conversationsRepo.set([]); 90 | return {} as ResponseDeleteAllConversation; 91 | } 92 | -------------------------------------------------------------------------------- /src/api/edge/user.ts: -------------------------------------------------------------------------------- 1 | import { WebStorage } from "@/storage/webstorage"; 2 | 3 | export function isClientSideOpenAI() { 4 | if (typeof window !== "undefined" && typeof document !== "undefined") { 5 | // Client-side 6 | const _storage = new WebStorage("o:t", "sessionStorage"); 7 | const _type = _storage.get(); 8 | return _type && _type == "client" ? true : false; 9 | } 10 | return false; 11 | } 12 | 13 | export function getApiKey() { 14 | const _apiKeyRepo = new WebStorage("o:a", "sessionStorage"); 15 | const _apiKey = _apiKeyRepo.get(); 16 | return _apiKey; 17 | } 18 | 19 | export function saveApiKey(apiKey: string) { 20 | const _apiKeyRepo = new WebStorage("o:a", "sessionStorage"); 21 | _apiKeyRepo.set(apiKey); 22 | return apiKey; 23 | } 24 | 25 | export function logout() { 26 | window.sessionStorage.removeItem("o:a"); 27 | return { message: "Logged out" }; 28 | } 29 | -------------------------------------------------------------------------------- /src/api/user.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { SITE_INTERNAL_HEADER_URL } from "@/configs/constants"; 3 | import { WebStorage } from "@/storage/webstorage"; 4 | import * as EdgeUser from "./edge/user"; 5 | 6 | export async function logout() { 7 | if (EdgeUser.isClientSideOpenAI()) return EdgeUser.logout(); 8 | const response = await fetch("/api/chatgpt/user", { 9 | method: "POST", 10 | body: JSON.stringify({ 11 | action: "logout", 12 | }), 13 | }); 14 | return response.json(); 15 | } 16 | 17 | export async function login(key: string) { 18 | if (EdgeUser.isClientSideOpenAI()) return EdgeUser.saveApiKey(key); 19 | const response = await fetch("/api/chatgpt/user", { 20 | method: "POST", 21 | body: JSON.stringify({ 22 | action: "login", 23 | key, 24 | }), 25 | }).then((it) => it.json()); 26 | 27 | if ((response as any).error) { 28 | alert("Error(login): " + JSON.stringify((response as any).error)); 29 | return; 30 | } 31 | 32 | return response; 33 | } 34 | 35 | export async function isLoggedIn(hashedKey?: string) { 36 | if (typeof window !== "undefined" && typeof document !== "undefined") { 37 | // Client-side 38 | if (EdgeUser.isClientSideOpenAI()) 39 | return EdgeUser.getApiKey() ? true : false; 40 | const response = await fetch("/api/chatgpt/verify", { 41 | method: "POST", 42 | body: hashedKey ?? "NOPE", 43 | }).then((it) => it.json()); 44 | 45 | return (response as any).loggedIn; 46 | } 47 | 48 | const { headers } = await import("next/headers"); 49 | const urlStr = headers().get(SITE_INTERNAL_HEADER_URL) as string; 50 | // Propagate cookies to the API route 51 | const headersPropagated = { cookie: headers().get("cookie") as string }; 52 | const response = await fetch( 53 | new URL("/api/chatgpt/verify", new URL(urlStr)), 54 | { 55 | method: "POST", 56 | body: hashedKey ?? "NOPE", 57 | headers: headersPropagated, 58 | redirect: "follow", 59 | } 60 | ).then((it) => it.json()); 61 | return (response as any).loggedIn; 62 | } 63 | -------------------------------------------------------------------------------- /src/app/[lang]/[...not_found]/page.ts: -------------------------------------------------------------------------------- 1 | /// https://stackoverflow.com/a/75625136 2 | 3 | import { notFound } from "next/navigation"; 4 | 5 | export default function NotFoundCatchAll() { 6 | notFound(); 7 | return null; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/[lang]/chatgpt/page.tsx: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import React from "react"; 4 | import { cookies } from "next/headers"; 5 | import { SITE_USER_COOKIE } from "@/configs/constants"; 6 | import { ChatGPTApp } from "@/components/chatgpt/ChatGPTApp"; 7 | import * as UserAPI from "@/api/user"; 8 | 9 | export default async function ChatGPTPage() { 10 | const hashedKey = cookies().get(SITE_USER_COOKIE)?.value as string; 11 | 12 | let isLogin: boolean; 13 | try { 14 | isLogin = await UserAPI.isLoggedIn(hashedKey); 15 | } catch (e) { 16 | console.error(e); 17 | isLogin = false; 18 | } 19 | 20 | return ( 21 |
22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/[lang]/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/app/globals.css"; 2 | import React from "react"; 3 | import Image from "next/image"; 4 | import NavBar from "@/layout/NavBar"; 5 | import { Container } from "@/components/ChakraUI"; 6 | import { Provider } from "@/components/ChakraUI/Provider"; 7 | 8 | type RootLayoutProps = { 9 | params: { 10 | lang: string; 11 | }; 12 | children: React.ReactNode; 13 | }; 14 | export default function RootLayout({ params, children }: RootLayoutProps) { 15 | const { lang } = params; 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | ChatVisualNovel - An endless visual novel powered by ChatGPT. 25 | 26 | 30 | 34 | 35 | 36 | 37 | {/* https://github.com/vercel/next.js/issues/42292 */} 38 |
39 | {/* @ts-expect-error Async Server Component */} 40 | 41 |
42 | 47 | {children} 48 | 49 |
50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/[lang]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /// https://stackoverflow.com/a/75625136 2 | 3 | import Link from "next/link"; 4 | 5 | export default function NotFound() { 6 | return ( 7 |
8 |

nOT foUnD – 404!

9 |
10 | 14 | Go back to Home 15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/[lang]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { getAppData } from "@/i18n"; 3 | import ChatGptVisualNovel from "@/app/[lang]/chatgpt-visual-novel/page.client"; 4 | 5 | async function Page() { 6 | const { locale, pathname, i18n } = await getAppData(); 7 | const i18nProps: GeneralI18nProps = { 8 | locale, 9 | pathname, 10 | i18n: { 11 | dict: i18n.dict, 12 | }, 13 | }; 14 | 15 | return ; 16 | } 17 | 18 | export default Page; 19 | -------------------------------------------------------------------------------- /src/app/api/chatgpt/stream/route.ts: -------------------------------------------------------------------------------- 1 | import { CHAT_COMPLETION_CONFIG } from "@/configs/constants"; 2 | import { 3 | createChat, 4 | createConversation, 5 | getAllChatsInsideConversation, 6 | } from "@/storage/planetscale"; 7 | import { decryptKey } from "@/utils/crypto.util"; 8 | import { getChatClient } from "@/utils/openapi.util"; 9 | import { getUser, User } from "@/utils/user.edge.util"; 10 | import { 11 | ChatCompletionRequestMessage, 12 | ChatCompletionRequestMessageRoleEnum, 13 | } from "openai"; 14 | 15 | export async function POST(request: Request, response: Response) { 16 | // TODO mixin? 17 | const user = await getUser(); 18 | if (!user || !(user as User)?.id) { 19 | return user; 20 | } 21 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 22 | // @ts-ignore 23 | const { id: user_id, key_hashed, key_encrypted, iv } = user as User; 24 | const body = await request.json(); 25 | let conversation_id: number | undefined | null = body.conversation_id; 26 | // if no conversation.ts exists, create new one as default, elsewise `create Chat` will throw error 27 | if (!conversation_id) { 28 | const defaultConvesation = await createConversation({ 29 | user_id, 30 | name: "Default Conversation name", 31 | }); 32 | conversation_id = defaultConvesation?.id; 33 | } 34 | if (conversation_id == null) { 35 | return new Response(JSON.stringify({ error: "No conversation_id found" }), { 36 | status: 400, 37 | }); 38 | } 39 | const chatClient = await getChatClient( 40 | key_hashed, 41 | decryptKey(key_encrypted, iv) 42 | ); 43 | const chats = await getAllChatsInsideConversation(conversation_id); 44 | const msgs = chats.map( 45 | (it) => 46 | ({ 47 | role: it.role, 48 | content: it.content, 49 | name: it.name, 50 | } as ChatCompletionRequestMessage) 51 | ); 52 | const newMsgs = body.messages; 53 | try { 54 | const messages = [...msgs, ...newMsgs].map((it) => ({ 55 | ...it, 56 | name: it.name ?? undefined, 57 | })); 58 | const response = await chatClient.createChatCompletion( 59 | { 60 | ...CHAT_COMPLETION_CONFIG, 61 | messages, 62 | stream: true, 63 | }, 64 | { responseType: "stream" } 65 | ); 66 | if (response.status !== 200) { 67 | return new Response(JSON.stringify({ error: response.statusText }), { 68 | status: response.status, 69 | }); 70 | } 71 | let controller: any; 72 | const encoder = new TextEncoder(); 73 | const stream = new ReadableStream({ 74 | async start(_) { 75 | controller = _; 76 | }, 77 | }); 78 | let msg = "", 79 | role: ChatCompletionRequestMessageRoleEnum; 80 | // FIXME add typescript type for res 81 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 82 | // @ts-ignore 83 | response.data.on("data", async (data: BufferSource | undefined) => { 84 | if (data) { 85 | const dataStr = data.toString(); 86 | controller.enqueue(encoder.encode(dataStr)); 87 | // for save chat history 88 | const lines = dataStr.split("\n").filter((line) => line.trim() !== ""); 89 | for (const line of lines) { 90 | const message = line.replace(/^data: /, ""); 91 | if (message === "[DONE]") { 92 | controller.close(); 93 | // add response to newMsgs 94 | const _newMsg = { content: msg, role }; 95 | messages.push({ ..._newMsg, name: undefined }); 96 | const needToSave = newMsgs 97 | .concat(_newMsg) 98 | .map((it: any) => ({ ...it, conversation_id })); 99 | try { 100 | // save to database 101 | const result = await createChat(needToSave); 102 | if (!result) { 103 | // TODO logging 104 | } 105 | } catch (error) { 106 | console.error("save to database error", error); 107 | } 108 | } else { 109 | try { 110 | const parsed = JSON.parse(message).choices[0].delta; 111 | if (parsed.role) { 112 | role = parsed.role; 113 | } 114 | if (parsed.content) { 115 | msg += parsed.content; 116 | } 117 | } catch (error) { 118 | console.error( 119 | "Could not JSON parse stream message", 120 | message, 121 | error 122 | ); 123 | } 124 | } 125 | } 126 | } 127 | }); 128 | 129 | return new Response(stream, { 130 | headers: { 131 | "Content-Type": "text/event-stream;", 132 | "Cache-Control": "no-cache", 133 | Connection: "keep-alive", 134 | }, 135 | }); 136 | } catch (e: any) { 137 | if (e.response?.status) { 138 | e.response.data.on("data", (data: BufferSource | undefined) => { 139 | return new Response(JSON.stringify({ error: data?.toString() }), { 140 | status: 500, 141 | }); 142 | }); 143 | } else { 144 | let msg = e.message; 145 | if (e.code === "ETIMEDOUT") { 146 | msg = "Request api was timeout, pls confirm your network worked"; 147 | } 148 | return new Response(JSON.stringify({ error: msg }), { 149 | status: 500, 150 | }); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | /* good looking scrollbar */ 7 | .overflow-container::-webkit-scrollbar { 8 | width: 8px; 9 | } 10 | 11 | .overflow-container::-webkit-scrollbar-track { 12 | background: #f1f1f1; 13 | } 14 | 15 | .overflow-container::-webkit-scrollbar-thumb { 16 | background: #888; 17 | } 18 | 19 | .overflow-container::-webkit-scrollbar-thumb:hover { 20 | background: #555; 21 | } 22 | } 23 | 24 | #root { 25 | margin: 0 auto; 26 | text-align: center; 27 | } 28 | 29 | code { 30 | text-shadow: none !important; 31 | } 32 | 33 | .logo { 34 | height: 6em; 35 | padding: 1.5em; 36 | will-change: filter; 37 | transition: filter 300ms; 38 | } 39 | .logo:hover { 40 | filter: drop-shadow(0 0 2em #646cffaa); 41 | } 42 | .logo.react:hover { 43 | filter: drop-shadow(0 0 2em #61dafbaa); 44 | } 45 | 46 | @keyframes logo-spin { 47 | from { 48 | transform: rotate(0deg); 49 | } 50 | to { 51 | transform: rotate(360deg); 52 | } 53 | } 54 | 55 | @media (prefers-reduced-motion: no-preference) { 56 | a:nth-of-type(2) .logo { 57 | animation: logo-spin infinite 20s linear; 58 | } 59 | } 60 | 61 | .card { 62 | padding: 2em; 63 | } 64 | 65 | .read-the-docs { 66 | color: #888; 67 | } 68 | 69 | ul, 70 | ul li, 71 | p { 72 | text-align: left; 73 | } 74 | /* custom grid-cols */ 75 | .grid-cols-\[1rem_1fr\] { 76 | grid-template-columns: 1rem 1fr; 77 | } 78 | .grid-cols-\[200px_1fr\] { 79 | grid-template-columns: 200px 1fr; 80 | } 81 | -------------------------------------------------------------------------------- /src/assets/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "genres": ["romance", "fantasy", "horror", "sci-fi", "crime"], 3 | "player": { 4 | "images": { 5 | "neutral": "https://assets.chatvisualnovel.com/00144-1619487309.png", 6 | "happy": "https://assets.chatvisualnovel.com/00089-1619487309.png", 7 | "sad": "https://assets.chatvisualnovel.com/00091-1619487309.png", 8 | "surprised": "https://assets.chatvisualnovel.com/00093-1619487309.png", 9 | "closed_eyes": "https://assets.chatvisualnovel.com/00095-1619487309.png" 10 | } 11 | }, 12 | "playerGender": "male", 13 | "girls": [ 14 | { 15 | "images": { 16 | "neutral": "https://assets.chatvisualnovel.com/00055-3317647877.png", 17 | "happy": "https://assets.chatvisualnovel.com/00057-3317647877.png", 18 | "sad": "https://assets.chatvisualnovel.com/00059-3317647877.png", 19 | "closed_eyes": "https://assets.chatvisualnovel.com/00061-3317647877.png", 20 | "surprised": "https://assets.chatvisualnovel.com/00063-3317647877.png" 21 | } 22 | }, 23 | { 24 | "images": { 25 | "neutral": "https://assets.chatvisualnovel.com/00063-3415190727.png", 26 | "happy": "https://assets.chatvisualnovel.com/00065-3415190727.png", 27 | "sad": "https://assets.chatvisualnovel.com/00067-3415190727.png", 28 | "surprised": "https://assets.chatvisualnovel.com/00071-3415190727.png", 29 | "closed_eyes": "https://assets.chatvisualnovel.com/00073-3415190727.png" 30 | } 31 | }, 32 | { 33 | "images": { 34 | "neutral": "https://assets.chatvisualnovel.com/00073-645522131.png", 35 | "happy": "https://assets.chatvisualnovel.com/00075-645522131.png", 36 | "sad": "https://assets.chatvisualnovel.com/00077-645522131.png", 37 | "surprised": "https://assets.chatvisualnovel.com/00079-645522131.png", 38 | "closed_eyes": "https://assets.chatvisualnovel.com/00081-645522131.png" 39 | } 40 | } 41 | ], 42 | "places": { 43 | "street": { 44 | "image": "https://assets.chatvisualnovel.com/00024-3109134467.png" 45 | }, 46 | "room": { 47 | "image": "https://assets.chatvisualnovel.com/00115-1693129910.png" 48 | }, 49 | "lobby": { 50 | "image": "https://assets.chatvisualnovel.com/00007-2952547515.png" 51 | }, 52 | "garden": { 53 | "image": "https://assets.chatvisualnovel.com/00117-1865753899.png" 54 | }, 55 | "restaurant": { 56 | "image": "https://assets.chatvisualnovel.com/00146-2156326714.png" 57 | }, 58 | "school": { 59 | "image": "https://assets.chatvisualnovel.com/00068-792831435.png" 60 | } 61 | }, 62 | "imageSettings": { 63 | "left": "50%", 64 | "transform": "translate(-50%, 0)", 65 | "bottom": "100%", 66 | "maxWidth": "70vw", 67 | "maxHeight": "70vh" 68 | }, 69 | "tts": { 70 | "default": { 71 | "method": "HuggingFaceSpace", 72 | "url": "https://plachta-vits-umamusume-voice-synthesizer.hf.space", 73 | "ws": { 74 | "url": "wss://plachta-vits-umamusume-voice-synthesizer.hf.space/queue/join", 75 | "data": ["text", "voice", "localeLanguage", "speed", "phonemeInput"] 76 | }, 77 | "voices": { 78 | "male": ["重云 Chongyun (Genshin Impact)"], 79 | "female": [ 80 | "甘雨 Ganyu (Genshin Impact)", 81 | "宵宫 Yoimiya (Genshin Impact)", 82 | "珊瑚宫心海 Sangonomiya Kokomi (Genshin Impact)" 83 | ] 84 | } 85 | } 86 | }, 87 | "modes": ["classic", "free"] 88 | } 89 | -------------------------------------------------------------------------------- /src/assets/clickprompt-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/assets/clickprompt-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/gpt.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/logout.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/message.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/new-chat.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/send.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/trashcan.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/volume.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/chatgpt-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-visual-novel/928589e0779cd36c03c5ae23287bac8097d7581d/src/assets/images/content.png -------------------------------------------------------------------------------- /src/components/ChakraUI/Provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { ChakraProvider, extendTheme } from "@chakra-ui/react"; 5 | 6 | export const Provider = ({ children }: { children: React.ReactNode }) => { 7 | const theme = extendTheme({ 8 | components: { 9 | Drawer: { 10 | sizes: { 11 | "2xl": { dialog: { maxW: "8xl" } }, 12 | }, 13 | }, 14 | }, 15 | }); 16 | 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/ChakraUI/icons.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "@chakra-ui/icons"; 4 | -------------------------------------------------------------------------------- /src/components/ChakraUI/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export { 4 | Avatar, 5 | Box, 6 | Flex, 7 | Heading, 8 | Spacer, 9 | Tooltip, 10 | Link, 11 | Breadcrumb, 12 | BreadcrumbItem, 13 | BreadcrumbLink, 14 | Button, 15 | Stack, 16 | Text, 17 | IconButton, 18 | Menu, 19 | MenuButton, 20 | MenuItem, 21 | MenuList, 22 | Input, 23 | Container, 24 | SimpleGrid, 25 | Card, 26 | CardBody, 27 | CardHeader, 28 | AlertIcon, 29 | AlertTitle, 30 | Alert, 31 | } from "@chakra-ui/react"; 32 | -------------------------------------------------------------------------------- /src/components/ClickPrompt/Button.shared.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import React, { MouseEventHandler } from "react"; 5 | import styled from "@emotion/styled"; 6 | 7 | export type ButtonSize = "sm" | "md" | "lg"; 8 | 9 | export const StyledBird = styled(Image)` 10 | position: absolute; 11 | top: -20px; 12 | right: -20px; 13 | `; 14 | 15 | export const StyledPromptButton = styled.div` 16 | position: relative; 17 | width: auto; 18 | `; 19 | 20 | export type CPButtonProps = { 21 | loading?: boolean; 22 | onClick?: MouseEventHandler; 23 | size?: ButtonSize; 24 | text: string; 25 | children?: React.ReactNode; 26 | [key: string]: any; 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/ClickPrompt/ClickPromptButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import { Box, Text, Tooltip, useDisclosure } from "@chakra-ui/react"; 5 | import { Button } from "@/components/ChakraUI"; 6 | import { BeatLoader } from "react-spinners"; 7 | import { ClickPromptSmall } from "@/components/CustomIcon"; 8 | import clickPromptLogo from "@/assets/clickprompt-light.svg?url"; 9 | import { 10 | CPButtonProps, 11 | StyledBird, 12 | StyledPromptButton, 13 | } from "@/components/ClickPrompt/Button.shared"; 14 | import { LoggingDrawer } from "@/components/ClickPrompt/LoggingDrawer"; 15 | import * as UserAPI from "@/api/user"; 16 | 17 | export type ClickPromptBirdParams = { width?: number; height?: number }; 18 | 19 | export function ClickPromptBird(props: ClickPromptBirdParams) { 20 | const width = props.width || 38; 21 | const height = props.height || 32; 22 | 23 | return ( 24 | 30 | ); 31 | } 32 | 33 | export function ClickPromptButton(props: CPButtonProps) { 34 | const [isLoading, setIsLoading] = useState(props.loading); 35 | const [isLoggedIn, setIsLoggedIn] = useState(false); 36 | const { isOpen, onOpen, onClose } = useDisclosure(); 37 | 38 | const handleClick = async (event: any) => { 39 | setIsLoading(true); 40 | const isLoggedIn = await UserAPI.isLoggedIn(); 41 | setIsLoggedIn(isLoggedIn); 42 | onOpen(); 43 | props.onClick && props.onClick(event); 44 | }; 45 | 46 | const handleClose = () => { 47 | setIsLoading(false); 48 | onClose(); 49 | }; 50 | 51 | function NormalSize() { 52 | return ( 53 | 54 | 64 | 65 | 66 | ); 67 | } 68 | 69 | function SmallSize() { 70 | return ( 71 | 77 | ); 78 | } 79 | 80 | return ( 81 | 82 | {props.size !== "sm" && } 83 | {props.size === "sm" && } 84 | 85 | {LoggingDrawer(isOpen, handleClose, isLoggedIn, props)} 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/components/ClickPrompt/ExecutePromptButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { 4 | createRef, 5 | MouseEventHandler, 6 | MutableRefObject, 7 | ReactEventHandler, 8 | RefObject, 9 | useEffect, 10 | useRef, 11 | useState, 12 | } from "react"; 13 | import { Text, useDisclosure } from "@chakra-ui/react"; 14 | import * as UserAPI from "@/api/user"; 15 | import { ResponseCreateConversation } from "@/pages/api/chatgpt/conversation"; 16 | import { createConversation } from "@/api/conversation"; 17 | import { sendMessage } from "@/api/chat"; 18 | import { ResponseSend } from "@/pages/api/chatgpt/chat"; 19 | import { Button } from "@/components/ChakraUI"; 20 | import { BeatLoader } from "react-spinners"; 21 | import { ClickPromptBird } from "@/components/ClickPrompt/ClickPromptButton"; 22 | import { ButtonSize, StyledPromptButton } from "./Button.shared"; 23 | import { LoggingDrawer } from "@/components/ClickPrompt/LoggingDrawer"; 24 | 25 | export type ExecButtonProps = { 26 | loading?: boolean; 27 | disabled?: boolean; 28 | onClick?: MouseEventHandler; 29 | name: string; 30 | text: string; 31 | size?: ButtonSize; 32 | btnText?: string; 33 | conversationName?: string; 34 | children?: React.ReactNode; 35 | handleResponse?: (response: ResponseSend) => void; 36 | conversationId?: number; 37 | updateConversationId?: (conversationId: number) => void; 38 | handleLoadingStateChange?: (isLoading: boolean) => void; 39 | handleButtonRefChange?: (e: RefObject) => void; 40 | handleDelta?: (value: string, delta: string) => void; 41 | }; 42 | 43 | function ExecutePromptButton(props: ExecButtonProps) { 44 | const [isLoading, setIsLoading] = useState(props.loading); 45 | const { isOpen, onOpen, onClose } = useDisclosure(); 46 | const [hasLogin, setHasLogin] = useState(false); 47 | const btnRef = createRef(); 48 | 49 | const handleClick: MouseEventHandler = async (e) => { 50 | if (props.onClick) { 51 | props.onClick(e); 52 | } 53 | setIsLoading(true); 54 | if (props.handleLoadingStateChange) props.handleLoadingStateChange(true); 55 | try { 56 | const isLoggedIn = await UserAPI.isLoggedIn(); 57 | if (!isLoggedIn) { 58 | setHasLogin(false); 59 | onOpen(); 60 | setIsLoading(false); 61 | if (props.handleLoadingStateChange) 62 | props.handleLoadingStateChange(false); 63 | return; 64 | } else { 65 | setHasLogin(true); 66 | } 67 | } catch (e) { 68 | console.log(e); 69 | setHasLogin(false); 70 | setIsLoading(false); 71 | if (props.handleLoadingStateChange) props.handleLoadingStateChange(false); 72 | return; 73 | } 74 | 75 | let conversationId = props.conversationId; 76 | if (!props.conversationId) { 77 | const conversation: ResponseCreateConversation = await createConversation( 78 | props.conversationName 79 | ); 80 | if (!conversation) { 81 | return; 82 | } 83 | 84 | conversationId = conversation.id as number; 85 | props.updateConversationId 86 | ? props.updateConversationId(conversationId) 87 | : null; 88 | } 89 | 90 | if (conversationId) { 91 | try { 92 | const response: any = await sendMessage( 93 | conversationId, 94 | props.text, 95 | undefined, 96 | props.handleDelta 97 | ); 98 | if (response && props.handleResponse) { 99 | props.handleResponse(response as ResponseSend); 100 | } 101 | } catch (e) { 102 | console.error(e); 103 | } 104 | } 105 | 106 | setIsLoading(false); 107 | if (props.handleLoadingStateChange) props.handleLoadingStateChange(false); 108 | }; 109 | 110 | useEffect(() => { 111 | console.log(`hasLogin: ${hasLogin}`); 112 | if (hasLogin) { 113 | onClose(); 114 | } 115 | }, [hasLogin]); 116 | 117 | useEffect(() => { 118 | if (props.loading != undefined) setIsLoading(props.loading); 119 | }, [props.loading]); 120 | 121 | const handleClose = () => { 122 | onClose(); 123 | }; 124 | 125 | const updateLoginStatus = (status: boolean) => { 126 | if (status) { 127 | setHasLogin(true); 128 | onClose(); 129 | } 130 | }; 131 | 132 | useEffect(() => { 133 | if (props.handleButtonRefChange) props.handleButtonRefChange(btnRef); 134 | }, [btnRef.current]); 135 | 136 | return ( 137 | <> 138 | 139 | 157 | 158 | 159 | {!hasLogin && 160 | LoggingDrawer(isOpen, handleClose, hasLogin, props, updateLoginStatus)} 161 | 162 | ); 163 | } 164 | 165 | export default ExecutePromptButton; 166 | -------------------------------------------------------------------------------- /src/components/ClickPrompt/LoggingDrawer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Drawer, 5 | DrawerBody, 6 | DrawerCloseButton, 7 | DrawerContent, 8 | DrawerOverlay, 9 | } from "@chakra-ui/react"; 10 | import { ChatGPTApp } from "@/components/chatgpt/ChatGPTApp"; 11 | import React from "react"; 12 | import { CPButtonProps } from "@/components/ClickPrompt/Button.shared"; 13 | 14 | export function LoggingDrawer( 15 | isOpen: boolean, 16 | handleClose: () => void, 17 | isLoggedIn: boolean, 18 | props: CPButtonProps, 19 | updateStatus?: (loggedIn: boolean) => void 20 | ) { 21 | return ( 22 | 28 | 29 | 30 | 31 | 32 |
33 | 38 |
39 |
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/CopyComponent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CopyToClipboard } from "react-copy-to-clipboard"; 4 | import { CopyIcon } from "@chakra-ui/icons"; 5 | import React from "react"; 6 | import { Tooltip, useToast } from "@chakra-ui/react"; 7 | 8 | type CopyProps = { 9 | value: string; 10 | boxSize?: number; 11 | className?: string; 12 | children?: React.ReactNode; 13 | }; 14 | 15 | function CopyComponent({ 16 | value, 17 | className = "", 18 | children, 19 | boxSize = 8, 20 | }: CopyProps) { 21 | const toast = useToast(); 22 | return ( 23 |
24 | { 27 | toast({ 28 | title: "Copied to clipboard", 29 | position: "top", 30 | status: "success", 31 | }); 32 | }} 33 | > 34 |
35 | {children ? children : ""} 36 | 37 | 38 | 39 |
40 |
41 |
42 | ); 43 | } 44 | 45 | export default CopyComponent; 46 | -------------------------------------------------------------------------------- /src/components/CustomIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "next/image"; 3 | 4 | import chatgptLogo from "@/assets/images/chatgpt-logo.svg?url"; 5 | import clickPromptLogo from "@/assets/clickprompt-light.svg?url"; 6 | import clickPromptSmall from "@/assets/clickprompt-small.svg?url"; 7 | 8 | export function ChatGptIcon({ width = 32, height = 32 }) { 9 | return ( 10 | ChatGPT Logo 11 | ); 12 | } 13 | 14 | export function ClickPromptIcon({ width = 32, height = 32 }) { 15 | return ( 16 | ClickPrompt Logo 23 | ); 24 | } 25 | 26 | export function ClickPromptSmall({ width = 32, height = 32 }) { 27 | return ( 28 | ClickPrompt Logo 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/Engine/Background.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { KV, Location, Scene } from "@/utils/types"; 4 | import { RefObject } from "react"; 5 | import Image from "next/image"; 6 | 7 | export type BackgroundProps = { 8 | scene: Scene; 9 | locationMap: KV; 10 | places: KV; 11 | bgmMap: KV; 12 | bgmRef: RefObject; 13 | onLoaded?: () => {}; 14 | }; 15 | 16 | export function Background(props: BackgroundProps) { 17 | return ( 18 | <> 19 | {props.scene.location 39 |