├── .env.example
├── .gitignore
├── README.md
├── README_zh.md
├── components
├── DropDown.tsx
├── Footer.tsx
├── Header.tsx
├── LoadingDots.tsx
├── Recommend.tsx
└── ResizablePanel.tsx
├── messages
├── en.json
└── zh.json
├── next.config.js
├── package-lock.json
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
├── api
│ └── generate.ts
└── index.tsx
├── postcss.config.js
├── public
├── 1-black.png
├── 2-black.png
├── favicon.ico
├── icon.svg
├── og-image.png
├── screenshot.png
├── screenshot_zh.png
├── vercel.svg
└── vercelLogo.png
├── styles
├── globals.css
├── loading-dots.module.css
└── markdown.module.css
├── tailwind.config.js
├── test
└── utils.test.ts
├── tsconfig.json
└── utils
├── OpenAIStream.ts
├── auth.ts
├── fetchWithTimeout.ts
└── utils.ts
/.env.example:
--------------------------------------------------------------------------------
1 | OPENAI_API_KEY=
2 | NEXT_PUBLIC_USE_USER_KEY=false
3 | NEXT_PUBLIC_SECRET=
4 |
--------------------------------------------------------------------------------
/.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 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 | .env
38 |
39 | # idea
40 | .idea
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Chat Simplifier
2 |
3 | [](https://chat.imzbb.cc)
4 |
5 | English | [中文](https://github.com/zhengbangbo/chat-simplifier/blob/main/README_zh.md)
6 |
7 | This project simplify chat content for you using AI.
8 |
9 | [](https://chat-simplifier.vercel.app/)
10 |
11 | ## How it works
12 |
13 | This project uses the [Chat GPT API](https://platform.openai.com/docs/api-reference/chat) (gpt-3.5-turbo) and [Vercel Edge functions](https://vercel.com/features/edge-functions) with streaming. It constructs a prompt based on the form and user input, sends it to the GPT-3 API via a Vercel Edge function, then streams the response back to the application.
14 |
15 | ## Running Locally
16 |
17 | After cloning the repository, go to [OpenAI](https://beta.openai.com/account/api-keys) to create an account and refer to the [environment variable instructions](#environment-variable-description) to put your API key into a file named `.env`.
18 |
19 | Then, run the application in the command line and it will be available at `http://localhost:3000`.
20 |
21 | ```bash
22 | npm run dev
23 | ```
24 |
25 | ## Environment variable description
26 |
27 | | Environment variable | Description | Required | Optional value |
28 | |---------|------|-----|--|
29 | |OPENAI_API_KEY| OpenAI API Key,separate with `,` when there are multiple | No | ([Get](https://beta.openai.com/account/api-keys)) |
30 | |NEXT_PUBLIC_USE_USER_KEY|Whether to use the API key entered by the user| No, default value is `true` |`true` or `false` |
31 | |NEXT_PUBLIC_SECRET|Secret string for the project. Use for generating signatures for API calls | No |`null`|
32 |
33 | ## One-Click Deploy
34 |
35 | Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples):
36 |
37 | [](https://vercel.com/new/clone?repository-url=https://github.com/zhengbangbo/chat-simplifier&env=OPENAI_API_KEY,NEXT_PUBLIC_USE_USER_KEY&envDescription=%E7%82%B9%E5%87%BB%E5%8F%B3%E4%BE%A7%E3%80%8CLearn%20More%E3%80%8D%E6%9F%A5%E7%9C%8B%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E8%AF%B4%E6%98%8E&envLink=https://github.com/zhengbangbo/chat-simplifier/wiki/Deploy&project-name=chat-simplifier&repository-name=chat-simplifier)
38 |
39 | ## Credits
40 |
41 | Inspired by [TwtterBio](https://github.com/Nutlope/twitterbio) and [Jimmy Lv](https://www.bilibili.com/video/BV17M411i7B6).
42 |
--------------------------------------------------------------------------------
/README_zh.md:
--------------------------------------------------------------------------------
1 | # 聊天简化器
2 |
3 | [](https://chat.imzbb.cc)
4 |
5 | [English](https://github.com/zhengbangbo/chat-simplifier/blob/main/README.md) | 中文
6 |
7 | 此项目使用 AI 为您简化聊天内容
8 |
9 | [](https://chat-simplifier.vercel.app/)
10 |
11 | ## 它是怎么工作的
12 |
13 | 该项目使用 [Chat GPT API](https://platform.openai.com/docs/api-reference/chat) (gpt-3.5-turbo)和 [Vercel Edge functions](https://vercel.com/features/edge-functions) 流式传输。它根据表单和用户输入构建 Prompts,通过 Vercel Edge 函数发送到 GPT-3 API,然后将响应流回到应用程序。
14 |
15 | ## 本地运行
16 |
17 | 克隆存储库后,前往 [OpenAI](https://beta.openai.com/account/api-keys) 创建帐户,并参考[环境变量说明](#环境变量说明)将 API 密钥放入名为 `.env` 的文件中。
18 |
19 | 然后,在命令行中运行应用程序,它将在 `http://localhost:3000` 处可用。
20 |
21 | > **Note**
22 | > 本地运行时,服务端使用本地网络请求 OpenAI,因此需要配置好网络代理。
23 |
24 | ```bash
25 | npm run dev
26 | ```
27 |
28 | ## 环境变量说明
29 |
30 | | 环境变量 | 说明 | 是否必须 |可选值 |
31 | |---------|------|---|----|
32 | |OPENAI_API_KEY| OpenAI API Key,当有多个时用`,`分隔 | 非必需 |([获取](https://beta.openai.com/account/api-keys)) |
33 | |NEXT_PUBLIC_USE_USER_KEY|是否使用用户自己输入的 API 密钥| 必须,默认为 true |`true` or `false` |
34 | |NEXT_PUBLIC_SECRET|项目的秘密字符串。用于生成 API 调用的签名| 非必需 | `123abc` |
35 |
36 | ## 一键部署
37 |
38 | 使用 [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples) 部署示例:
39 |
40 | [](https://vercel.com/new/clone?repository-url=https://github.com/zhengbangbo/chat-simplifier&env=OPENAI_API_KEY,NEXT_PUBLIC_USE_USER_KEY&envDescription=%E7%82%B9%E5%87%BB%E5%8F%B3%E4%BE%A7%E3%80%8CLearn%20More%E3%80%8D%E6%9F%A5%E7%9C%8B%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E8%AF%B4%E6%98%8E&envLink=https://github.com/zhengbangbo/chat-simplifier/wiki/Deploy&project-name=chat-simplifier&repository-name=chat-simplifier)
41 |
42 | ## 致谢
43 |
44 | 灵感来自 [TwtterBio](https://github.com/Nutlope/twitterbio) 和 [Jimmy Lv](https://www.bilibili.com/video/BV17M411i7B6)。
45 |
--------------------------------------------------------------------------------
/components/DropDown.tsx:
--------------------------------------------------------------------------------
1 | import { Menu, Transition } from "@headlessui/react";
2 | import {
3 | CheckIcon,
4 | ChevronDownIcon,
5 | ChevronUpIcon,
6 | } from "@heroicons/react/20/solid";
7 | import { Fragment } from "react";
8 | import { useTranslations } from 'next-intl'
9 |
10 | function classNames(...classes: string[]) {
11 | return classes.filter(Boolean).join(" ");
12 | }
13 |
14 | export type FormType = "paragraphForm" | "outlineForm";
15 |
16 | interface DropDownProps {
17 | form: FormType;
18 | setForm: (form: FormType) => void;
19 | }
20 |
21 | let forms: FormType[] = ["paragraphForm", "outlineForm"]
22 |
23 | export default function DropDown({ form, setForm}: DropDownProps) {
24 | const t = useTranslations('Index')
25 | return (
26 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export default function Footer() {
4 | return (
5 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { useRouter } from 'next/router'
3 | import { useTranslations } from "next-intl";
4 |
5 | export default function Header() {
6 | const t = useTranslations('Index')
7 | const { locale, locales, route } = useRouter()
8 | const otherLocale = locales?.find((cur) => cur !== locale)
9 |
10 | return (
11 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/components/LoadingDots.tsx:
--------------------------------------------------------------------------------
1 | import styles from "../styles/loading-dots.module.css";
2 |
3 | const LoadingDots = ({
4 | color = "#000",
5 | style = "small",
6 | }: {
7 | color: string;
8 | style: string;
9 | }) => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | export default LoadingDots;
20 |
21 | LoadingDots.defaultProps = {
22 | style: "small",
23 | };
24 |
--------------------------------------------------------------------------------
/components/Recommend.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'next-intl'
2 |
3 | export default function Recommend() {
4 | const t = useTranslations('Index')
5 | return (
6 |
7 |
{t('recommend')}
8 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/components/ResizablePanel.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import useMeasure from "react-use-measure";
3 |
4 | export default function ResizablePanel({
5 | children,
6 | }: {
7 | children: React.ReactNode;
8 | }) {
9 | let [ref, { height }] = useMeasure();
10 |
11 | return (
12 |
18 |
19 | {children}
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/messages/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "Index": {
3 | "title": "Chat Simplifier",
4 | "switchLocale": "{locale, select,zh {中文} en {English} other {}}",
5 | "deployWiki": "Deploy your own website?",
6 | "slogan": "Too many group messages? It's too long to look!",
7 | "step0": "Paste your OpenAI API Key",
8 | "openaiApiKeyPlaceholder": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
9 | "step1": "Paste your chat content",
10 | "helpPageLink": "(Click here to view tutorials)",
11 | "placeholder": "Choose more chat content and copy it. It usually contains nicknames and content.",
12 | "step2": "Select the form of the output.",
13 | "simplifierButton": "Simplify the chat content",
14 | "privacyPolicy1": "It is not recommended to upload chat that is too private. For more information, please see",
15 | "privacyPolicy2": "Privacy Statement",
16 | "simplifiedContent": "Simplified content",
17 | "paragraphForm": "Paragraph",
18 | "paragraphFormPrompt": "You will receive a chat record and we hope you can summarize it. Please provide a brief summary in one paragraph.",
19 | "outlineForm": "Outline",
20 | "outlineFormPrompt": "You will receive a chat record and we hope you can summarize it. Please provide a concise outline format that includes a list.",
21 | "pasteButton": "Paste",
22 | "clearButton": "Clear",
23 | "copySuccess": "Chat copied to clipboard",
24 | "copyError": "Copy failed, please use HTTPS",
25 | "emptyChatError": "Please paste the chat content",
26 | "timeoutError": "Request timed out, please try again later",
27 | "internalServerError": "Internal server error, please try again later",
28 | "emptyAPIKeyError": "Please paste the OpenAI API key",
29 | "invalidAPIKeyError": "The OpenAI API key is invalid",
30 | "recommend": "Recommend Other AI Application"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/messages/zh.json:
--------------------------------------------------------------------------------
1 | {
2 | "Index": {
3 | "title": "聊天简化器",
4 | "switchLocale": "{locale, select,zh {中文} en {English} other {}}",
5 | "deployWiki": "部署自己的网站?",
6 | "slogan": "群消息太多?太长不看!",
7 | "step0": "粘贴你的 OpenAI API Key",
8 | "openaiApiKeyPlaceholder": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
9 | "step1": "粘贴你的聊天内容",
10 | "helpPageLink": "(点击这里查看教程)",
11 | "placeholder": "多选聊天内容,复制。通常包含昵称和内容。",
12 | "step2": "选择输出结果的形式。",
13 | "simplifierButton": "简化聊天内容",
14 | "simplifiedContent": "简化后的内容",
15 | "privacyPolicy1": "不建议上传过于隐私的聊天内容,详情查看",
16 | "privacyPolicy2": "《隐私声明》",
17 | "paragraphForm": "段落",
18 | "paragraphFormPrompt": "你将得到一串聊天记录,希望你能够对这些记录进行摘要。要求简明扼要,以一段话的形式输出。",
19 | "outlineForm": "大纲",
20 | "outlineFormPrompt": "你将得到一串聊天记录,希望你能够对这些记录进行摘要。要求简明扼要,以包含列表的大纲形式输出。",
21 | "pasteButton": "粘贴",
22 | "clearButton": "清空",
23 | "copySuccess": "摘要已复制到剪贴板",
24 | "emptyChatError": "聊天内容不能为空",
25 | "copyError": "复制失败,请使用HTTPS",
26 | "timeoutError": "请求超时,请稍后再试",
27 | "internalServerError": "服务器内部错误,请稍后再试",
28 | "emptyAPIKeyError": "OpenAI API Key 不能为空",
29 | "invalidAPIKeyError": "OpenAI API Key 无效",
30 | "recommend": "推荐其他 AI 应用"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | module.exports = {
3 | reactStrictMode: true,
4 | i18n: {
5 | locales: ['en', 'zh'],
6 | defaultLocale: 'en',
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "dev": "next dev",
5 | "build": "next build",
6 | "start": "next start",
7 | "test": "vitest"
8 | },
9 | "dependencies": {
10 | "@headlessui/react": "^1.7.14",
11 | "@headlessui/tailwindcss": "^0.1.3",
12 | "@heroicons/react": "^2.0.17",
13 | "@tailwindcss/forms": "^0.5.3",
14 | "@vercel/analytics": "^0.1.11",
15 | "dotenv": "^16.0.3",
16 | "eventsource-parser": "^1.0.0",
17 | "framer-motion": "^10.12.2",
18 | "js-sha256": "^0.9.0",
19 | "marked": "^4.3.0",
20 | "next": "latest",
21 | "next-intl": "^2.13.1",
22 | "react": "18.2.0",
23 | "react-dom": "18.2.0",
24 | "react-hook-form": "^7.43.9",
25 | "react-hot-toast": "^2.4.0",
26 | "react-use-measure": "^2.1.1"
27 | },
28 | "devDependencies": {
29 | "@types/marked": "^4.0.8",
30 | "@types/node": "18.15.11",
31 | "@types/react": "18.0.35",
32 | "@types/react-dom": "18.0.11",
33 | "autoprefixer": "^10.4.14",
34 | "postcss": "^8.4.22",
35 | "tailwindcss": "^3.3.1",
36 | "typescript": "5.0.4",
37 | "vitest": "^0.30.1"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { Analytics } from "@vercel/analytics/react";
2 | import type { AppProps } from "next/app";
3 | import { NextIntlProvider } from 'next-intl'
4 | import "../styles/globals.css";
5 |
6 | function MyApp({ Component, pageProps }: AppProps) {
7 | return (
8 |
11 |
12 |
13 |
14 | );
15 | }
16 |
17 | export default MyApp;
18 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from "next/document";
2 |
3 | class MyDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
8 |
9 |
13 |
14 |
18 |
19 |
20 |
21 |
25 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
43 | export default MyDocument;
44 |
--------------------------------------------------------------------------------
/pages/api/generate.ts:
--------------------------------------------------------------------------------
1 | import { OpenAIStream, OpenAIStreamPayload } from "../../utils/OpenAIStream";
2 | import type { ChatGPTMessage } from "../../utils/OpenAIStream";
3 | import { checkOpenAIKey } from "../../utils/utils";
4 | import { verifySignature } from "../../utils/auth";
5 |
6 | export const config = {
7 | runtime: "edge",
8 | unstable_allowDynamic: [
9 | "/node_modules/js-sha256/src/sha256.js",
10 | ]
11 | };
12 |
13 | const handler = async (req: Request): Promise => {
14 |
15 | if (!process.env.NEXT_PUBLIC_USE_USER_KEY) {
16 | return new Response("NEXT_PUBLIC_USE_USER_KEY", {
17 | status: 501,
18 | statusText: "No environment variable set: NEXT_PUBLIC_USE_USER_KEY"
19 | });
20 | };
21 |
22 | if (process.env.NEXT_PUBLIC_USE_USER_KEY === 'false') {
23 | if (!process.env.OPENAI_API_KEY) {
24 | return new Response("OPENAI_API_KEY", {
25 | status: 501,
26 | statusText: "No environment variable set: OPENAI_API_KEY"
27 | });
28 | }
29 | }
30 |
31 |
32 | const { prompt, time, sign, api_key } = (await req.json()) as {
33 | prompt: ChatGPTMessage[];
34 | time: number;
35 | sign: string;
36 | api_key?: string;
37 | };
38 |
39 | if (!await verifySignature({
40 | t: time, m: prompt?.[prompt.length - 1]?.content || ''
41 | }, sign)) {
42 | return new Response("Invalid signature", { status: 400 });
43 | }
44 |
45 | const payload: OpenAIStreamPayload = {
46 | model: "gpt-3.5-turbo",
47 | messages: prompt,
48 | temperature: 0.8,
49 | top_p: 1,
50 | frequency_penalty: 0,
51 | presence_penalty: 0,
52 | max_tokens: 300,
53 | stream: true,
54 | n: 1,
55 | api_key,
56 | }
57 |
58 | const stream = await OpenAIStream(payload);
59 | return new Response(stream);
60 | };
61 |
62 | export default handler;
63 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { AnimatePresence, motion } from "framer-motion";
2 | import type { NextPage } from "next";
3 | import Head from "next/head";
4 | import Image from "next/image";
5 | import { use, useEffect, useState } from "react";
6 | import { useTranslations } from 'next-intl'
7 | import { Toaster, toast } from "react-hot-toast";
8 | import DropDown, { FormType } from "../components/DropDown";
9 | import Footer from "../components/Footer";
10 |
11 | import Header from "../components/Header";
12 | import LoadingDots from "../components/LoadingDots";
13 | import ResizablePanel from "../components/ResizablePanel";
14 | import Recommend from "../components/Recommend";
15 | import { fetchWithTimeout } from '../utils/fetchWithTimeout'
16 | import { generateSignature } from '../utils/auth'
17 | import { checkOpenAIKey } from "../utils/utils";
18 | import { marked } from "marked";
19 | import styles from '../styles/markdown.module.css'
20 |
21 | const useUserKey = process.env.NEXT_PUBLIC_USE_USER_KEY === "false" ? false : true;
22 |
23 | const REQUEST_TIMEOUT = 15 * 1000 // 15s timeout
24 |
25 | const Home: NextPage = () => {
26 | const t = useTranslations('Index')
27 |
28 | const [loading, setLoading] = useState(false);
29 | const [chat, setChat] = useState("");
30 | const [form, setForm] = useState("paragraphForm");
31 | const [api_key, setAPIKey] = useState("")
32 | const [isSecureContext, setIsSecureContext] = useState(false)
33 | const [generatedChat, setGeneratedChat] = useState("");
34 |
35 | console.log("Streamed response: ", generatedChat);
36 |
37 | const system_prompt =
38 | form === 'paragraphForm' ?
39 | `${t('paragraphFormPrompt')}`
40 | : `${t('outlineFormPrompt')}`;
41 |
42 | const user_prompt = chat;
43 | const prompt = [
44 | {
45 | "role": "system",
46 | "content": system_prompt
47 | },
48 | {
49 | "role": "user",
50 | "content": user_prompt
51 | }
52 | ]
53 |
54 | useEffect(() => {
55 | if (typeof window !== "undefined") {
56 | setIsSecureContext(window.isSecureContext)
57 | }
58 | })
59 |
60 | const generateChat = async (e: any) => {
61 | e.preventDefault();
62 | setGeneratedChat("");
63 | setLoading(true);
64 |
65 | if (!chat) {
66 | toast.error(t('emptyChatError'))
67 | setLoading(false)
68 | return
69 | }
70 |
71 | if (useUserKey) {
72 | if (!api_key) {
73 | toast.error(t('emptyAPIKeyError'))
74 | setLoading(false)
75 | return
76 | }
77 | if (!checkOpenAIKey(api_key)) {
78 | toast.error(t('invalidAPIKeyError'))
79 | setLoading(false)
80 | return
81 | }
82 | }
83 | console.log("Sending request to Edge function.");
84 |
85 | const timestamp = Date.now()
86 | try {
87 | const response = useUserKey ?
88 | await fetchWithTimeout("/api/generate", {
89 | method: "POST",
90 | headers: {
91 | "Content-Type": "application/json",
92 | },
93 | timeout: REQUEST_TIMEOUT,
94 | body: JSON.stringify({
95 | prompt,
96 | time: timestamp,
97 | sign: await generateSignature({
98 | t: timestamp,
99 | m: prompt?.[prompt.length - 1]?.content || "",
100 | }),
101 | api_key,
102 | }),
103 | })
104 | :
105 | await fetchWithTimeout("/api/generate", {
106 | method: "POST",
107 | headers: {
108 | "Content-Type": "application/json",
109 | },
110 | timeout: REQUEST_TIMEOUT,
111 | body: JSON.stringify({
112 | prompt,
113 | time: timestamp,
114 | sign: await generateSignature({
115 | t: timestamp,
116 | m: prompt?.[prompt.length - 1]?.content || "",
117 | }),
118 | }),
119 | })
120 | console.log("Edge function returned.");
121 |
122 | if (!response.ok) {
123 | throw new Error(response.statusText);
124 | }
125 |
126 | // This data is a ReadableStream
127 | const data = response.body;
128 | if (!data) {
129 | return;
130 | }
131 |
132 | const reader = data.getReader();
133 | const decoder = new TextDecoder();
134 | let done = false;
135 |
136 | while (!done) {
137 | const { value, done: doneReading } = await reader.read();
138 | done = doneReading;
139 | const chunkValue = decoder.decode(value).replace("<|im_end|>", "");
140 | setGeneratedChat((prev) => prev + chunkValue);
141 | }
142 |
143 | setLoading(false);
144 | } catch (e: any) {
145 | console.error('[fetch ERROR]', e)
146 | if (e instanceof Error && e?.name === 'AbortError') {
147 | setLoading(false)
148 | toast.error(t('timeoutError'))
149 | } else {
150 | setLoading(false)
151 | toast.error(e?.message || t('unknownError'))
152 | }
153 | }
154 | };
155 |
156 | return (
157 |
158 |
159 |
{t('title')}
160 |
161 |
162 |
163 |
164 |
165 | {t('title')}
166 |
167 | {t('slogan')}
168 |
169 | {useUserKey && (
170 | <>
171 |
184 |
setAPIKey(e.target.value)}
187 | className="w-full rounded-md border-2 border-gray-300 shadow-sm focus:border-black focus:ring-black p-2"
188 | placeholder={
189 | t('openaiApiKeyPlaceholder')
190 | }
191 | />
192 | >)
193 | }
194 |
195 |
201 |
202 | {t('step1')}{" "}
203 | {
204 | !useUserKey && (
205 |
206 | {t('helpPageLink')}
211 |
212 | )
213 | }
214 |
215 |
216 |
217 | {
218 | isSecureContext && (
219 | navigator.clipboard.readText().then((clipText) => setChat(clipText))}>
221 | {t('pasteButton')}
222 |
223 | )
224 | }
225 | setChat("")}>
227 | {t('clearButton')}
228 |
229 |
230 |
275 |
280 |
281 |
282 |
283 |
284 | {generatedChat && (
285 | <>
286 |
287 |
288 | {t('simplifiedContent')}
289 |
290 |
291 |
292 |
{
295 | if (!isSecureContext) {
296 | toast(t('copyError'), {
297 | icon: "❌",
298 | });
299 | return;
300 | }
301 | navigator.clipboard.writeText(generatedChat.trim());
302 | toast(t('copySuccess'), {
303 | icon: "✂️",
304 | });
305 | }}
306 | >
307 |
313 |
314 |
315 | >
316 | )}
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 | );
325 | };
326 |
327 | export default Home;
328 |
329 | export function getStaticProps({ locale }: { locale: string }) {
330 | return {
331 | props: {
332 | messages: {
333 | ...require(`../messages/${locale}.json`),
334 | },
335 | },
336 | }
337 | }
338 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/1-black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengbangbo/chat-simplifier/afc4ea40dddc95c64bff4683bd01318849acbbe2/public/1-black.png
--------------------------------------------------------------------------------
/public/2-black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengbangbo/chat-simplifier/afc4ea40dddc95c64bff4683bd01318849acbbe2/public/2-black.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengbangbo/chat-simplifier/afc4ea40dddc95c64bff4683bd01318849acbbe2/public/favicon.ico
--------------------------------------------------------------------------------
/public/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengbangbo/chat-simplifier/afc4ea40dddc95c64bff4683bd01318849acbbe2/public/og-image.png
--------------------------------------------------------------------------------
/public/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengbangbo/chat-simplifier/afc4ea40dddc95c64bff4683bd01318849acbbe2/public/screenshot.png
--------------------------------------------------------------------------------
/public/screenshot_zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengbangbo/chat-simplifier/afc4ea40dddc95c64bff4683bd01318849acbbe2/public/screenshot_zh.png
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
--------------------------------------------------------------------------------
/public/vercelLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zhengbangbo/chat-simplifier/afc4ea40dddc95c64bff4683bd01318849acbbe2/public/vercelLogo.png
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer components {
6 | .recommend-container {
7 | @apply flex px-2 items-center justify-center h-14 hover:bg-slate-200 rounded-lg hover:transition-all border border-slate-100 bg-slate-50;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/styles/loading-dots.module.css:
--------------------------------------------------------------------------------
1 | .loading {
2 | display: inline-flex;
3 | align-items: center;
4 | }
5 |
6 | .loading .spacer {
7 | margin-right: 2px;
8 | }
9 |
10 | .loading span {
11 | animation-name: blink;
12 | animation-duration: 1.4s;
13 | animation-iteration-count: infinite;
14 | animation-fill-mode: both;
15 | width: 5px;
16 | height: 5px;
17 | border-radius: 50%;
18 | display: inline-block;
19 | margin: 0 1px;
20 | }
21 |
22 | .loading span:nth-of-type(2) {
23 | animation-delay: 0.2s;
24 | }
25 |
26 | .loading span:nth-of-type(3) {
27 | animation-delay: 0.4s;
28 | }
29 |
30 | .loading2 {
31 | display: inline-flex;
32 | align-items: center;
33 | }
34 |
35 | .loading2 .spacer {
36 | margin-right: 2px;
37 | }
38 |
39 | .loading2 span {
40 | animation-name: blink;
41 | animation-duration: 1.4s;
42 | animation-iteration-count: infinite;
43 | animation-fill-mode: both;
44 | width: 4px;
45 | height: 4px;
46 | border-radius: 50%;
47 | display: inline-block;
48 | margin: 0 1px;
49 | }
50 |
51 | .loading2 span:nth-of-type(2) {
52 | animation-delay: 0.2s;
53 | }
54 |
55 | .loading2 span:nth-of-type(3) {
56 | animation-delay: 0.4s;
57 | }
58 |
59 | @keyframes blink {
60 | 0% {
61 | opacity: 0.2;
62 | }
63 | 20% {
64 | opacity: 1;
65 | }
66 | 100% {
67 | opacity: 0.2;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/styles/markdown.module.css:
--------------------------------------------------------------------------------
1 | .markdown {
2 | text-align: left;
3 | }
4 |
5 | .markdown li {
6 | list-style-position: outside;
7 | list-style-type: disc;
8 | margin: 0 1rem;
9 | }
10 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | future: {
4 | hoverOnlyWhenSupported: true,
5 | },
6 | content: [
7 | "./pages/**/*.{js,ts,jsx,tsx}",
8 | "./components/**/*.{js,ts,jsx,tsx}",
9 | "./app/**/*.{js,ts,jsx,tsx}",
10 | ],
11 | theme: {
12 | recommendContainer: {
13 |
14 | },
15 | extend: {},
16 | },
17 | plugins: [require("@tailwindcss/forms"), require("@headlessui/tailwindcss")],
18 | };
19 |
--------------------------------------------------------------------------------
/test/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { checkOpenAIKey } from "../utils/utils";
2 | import { describe, it, expect } from 'vitest'
3 |
4 |
5 | describe('checkOpenAIKey', () => {
6 | it('should return false when OpenAI Api Key is incorrect', () => {
7 | expect(checkOpenAIKey('sk-c0VR')).toBe(false)
8 | })
9 | it('should return true when OpenAI APi Key is correct', () => {
10 | expect(checkOpenAIKey('sk-cU0xvexxxxxxxxxxxxxxxxxxxxxxxxxxxx00tPk7H2iue0VR')).toBe(true)
11 | })
12 | })
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/utils/OpenAIStream.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createParser,
3 | ParsedEvent,
4 | ReconnectInterval,
5 | } from "eventsource-parser";
6 |
7 | export type ChatGPTAgent = "user" | "system";
8 |
9 | export interface ChatGPTMessage {
10 | role: ChatGPTAgent;
11 | content: string;
12 | }
13 |
14 | export interface OpenAIStreamPayload {
15 | model: string;
16 | messages: ChatGPTMessage[];
17 | temperature: number;
18 | top_p: number;
19 | frequency_penalty: number;
20 | presence_penalty: number;
21 | max_tokens: number;
22 | stream: boolean;
23 | n: number;
24 | api_key?: string;
25 | }
26 |
27 | export async function OpenAIStream(payload: OpenAIStreamPayload) {
28 | const encoder = new TextEncoder();
29 | const decoder = new TextDecoder();
30 |
31 | let counter = 0;
32 |
33 | const useUserKey = process.env.NEXT_PUBLIC_USE_USER_KEY === "true" ? true : false;
34 |
35 | const openai_api_key_list = (useUserKey ? payload.api_key : process.env.OPENAI_API_KEY) || ""
36 |
37 | const openai_api_key = openai_api_key_list.split(",").sort(() => Math.random() - 0.5)[0]
38 |
39 | delete payload.api_key
40 |
41 | const res = await fetch("https://api.openai.com/v1/chat/completions", {
42 | headers: {
43 | "Content-Type": "application/json",
44 | Authorization: `Bearer ${openai_api_key ?? ""}`,
45 | },
46 | method: "POST",
47 | body: JSON.stringify(payload),
48 | });
49 |
50 | const stream = new ReadableStream({
51 | async start(controller) {
52 | // callback
53 | function onParse(event: ParsedEvent | ReconnectInterval) {
54 | if (event.type === "event") {
55 | const data = event.data;
56 | // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
57 | if (data === "[DONE]") {
58 | controller.close();
59 | return;
60 | }
61 | try {
62 | const json = JSON.parse(data);
63 | const text = json.choices[0].delta?.content || "";
64 | if (counter < 2 && (text.match(/\n/) || []).length) {
65 | // this is a prefix character (i.e., "\n\n"), do nothing
66 | return;
67 | }
68 | const queue = encoder.encode(text);
69 | controller.enqueue(queue);
70 | counter++;
71 | } catch (e) {
72 | // maybe parse error
73 | controller.error(e);
74 | }
75 | }
76 | }
77 |
78 | // stream response (SSE) from OpenAI may be fragmented into multiple chunks
79 | // this ensures we properly read chunks and invoke an event for each SSE event stream
80 | const parser = createParser(onParse);
81 | // https://web.dev/streams/#asynchronous-iteration
82 | for await (const chunk of res.body as any) {
83 | parser.feed(decoder.decode(chunk));
84 | }
85 | },
86 | });
87 |
88 | return stream;
89 | }
90 |
--------------------------------------------------------------------------------
/utils/auth.ts:
--------------------------------------------------------------------------------
1 | import { sha256 } from 'js-sha256'
2 | interface AuthPayload {
3 | t: number
4 | m: string
5 | }
6 |
7 | async function digestMessage(message: string) {
8 | if (typeof crypto !== 'undefined' && crypto?.subtle?.digest) {
9 | const msgUint8 = new TextEncoder().encode(message)
10 | const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
11 | const hashArray = Array.from(new Uint8Array(hashBuffer))
12 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
13 | } else {
14 | return sha256(message).toString()
15 | }
16 | }
17 |
18 | export const generateSignature = async(payload: AuthPayload) => {
19 | const { t: timestamp, m: lastMessage } = payload
20 | const secretKey = process.env.NEXT_PUBLIC_SECRET as string
21 | const signText = `${timestamp}:${lastMessage}:${secretKey}`
22 | // eslint-disable-next-line no-return-await
23 | return await digestMessage(signText)
24 | }
25 |
26 | export const verifySignature = async(payload: AuthPayload, sign: string) => {
27 | const payloadSign = await generateSignature(payload)
28 | return payloadSign === sign
29 | }
30 |
--------------------------------------------------------------------------------
/utils/fetchWithTimeout.ts:
--------------------------------------------------------------------------------
1 | interface Options extends RequestInit {
2 | /** timeout, default: 8000ms */
3 | timeout?: number
4 | }
5 |
6 | export async function fetchWithTimeout(
7 | resource: RequestInfo | URL,
8 | options: Options = {}
9 | ) {
10 | const { timeout } = options
11 |
12 | const controller = new AbortController()
13 | const id = setTimeout(() => controller.abort(), timeout)
14 | const response = await fetch(resource, {
15 | ...options,
16 | signal: controller.signal
17 | })
18 | clearTimeout(id)
19 | return response
20 | }
21 |
--------------------------------------------------------------------------------
/utils/utils.ts:
--------------------------------------------------------------------------------
1 |
2 | export function checkOpenAIKey(str: string) {
3 | var pattern = /^sk-[A-Za-z0-9]{48}$/;
4 | return pattern.test(str);
5 | }
6 |
--------------------------------------------------------------------------------