├── .env.locla.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── images ├── Snipaste_2024-09-27_19-12-12.png ├── Snipaste_2024-09-27_19-14-53.png ├── Snipaste_2024-09-27_19-15-27.png └── Snipaste_2024-09-27_19-24-26.png ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public └── favicon.ico ├── src ├── app │ ├── [lang] │ │ ├── chat │ │ │ └── [id] │ │ │ │ ├── ChatContent.tsx │ │ │ │ └── page.tsx │ │ ├── i18n.ts │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── login │ │ │ ├── LoginContent.tsx │ │ │ ├── components │ │ │ │ ├── Login.tsx │ │ │ │ ├── Register.tsx │ │ │ │ └── ResetPassword.tsx │ │ │ └── page.tsx │ │ ├── new │ │ │ └── page.tsx │ │ ├── page.module.scss │ │ ├── page.tsx │ │ ├── recents │ │ │ ├── RecentsContent.tsx │ │ │ └── page.tsx │ │ └── typing.d.ts │ ├── api │ │ ├── chat-out-stream │ │ │ └── route.ts │ │ ├── chat │ │ │ └── route.ts │ │ └── file-server │ │ │ └── route.ts │ ├── components │ │ ├── ArtifactRenderer.tsx │ │ ├── AssistantMsg.tsx │ │ ├── Comfirm.tsx │ │ ├── DropDown.tsx │ │ ├── HintText.tsx │ │ ├── HtmlPreviewArtifact.tsx │ │ ├── IconProvider.tsx │ │ ├── Input.tsx │ │ ├── LoadingSpinner.tsx │ │ ├── MarkdownRenderer.tsx │ │ ├── Modal.tsx │ │ ├── Modify.tsx │ │ ├── New │ │ │ ├── NewContent.tsx │ │ │ └── NewServer.tsx │ │ ├── OutsideClickHandler.tsx │ │ ├── PrintWord.tsx │ │ ├── SelfMessage.tsx │ │ ├── SliderBar │ │ │ ├── SlideServer.tsx │ │ │ └── Slider.tsx │ │ ├── settings │ │ │ ├── Common.tsx │ │ │ ├── Deep.tsx │ │ │ ├── Setting.tsx │ │ │ └── User.tsx │ │ └── typing.d.ts │ ├── globals.css │ ├── lib │ │ ├── constant.ts │ │ ├── store.ts │ │ └── utils.ts │ ├── locales │ │ ├── en.json │ │ └── zh.json │ └── styles │ │ └── markdownTheme.ts ├── assets │ └── svgs │ │ ├── AI.svg │ │ ├── Chat.svg │ │ ├── ChatAdd.svg │ │ ├── Code.svg │ │ ├── Corner.svg │ │ ├── Drawer.svg │ │ ├── LoadingTag.svg │ │ ├── Pause.svg │ │ ├── Problem.svg │ │ └── VerticalAlignBottomOutlined.svg └── middleware.ts ├── tailwind.config.ts └── tsconfig.json /.env.locla.example: -------------------------------------------------------------------------------- 1 | # baseUrl 2 | OPENAI_API_BASE=https://api.openai.com 3 | # apiKey 4 | OPENAI_API_KEY=sk-xxx 5 | # 文件上传接口 6 | FILE_POST_URL=https://xxxx 7 | # 用户访问秘钥 8 | SECRET_KEY=114514 -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"], 3 | "rules": { 4 | "@typescript-eslint/no-explicit-any": ["off"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # dependencies 3 | /node_modules 4 | /.pnp 5 | .pnp.js 6 | .yarn/install-state.gz 7 | 8 | /.vscode 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | /.next/ 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 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 本项目是一个基于Next.js的聊天机器人项目,使用了OpenAI的API格式发送数据。 2 | 3 | 对于其他的api如 claude 可以使用 [one-api](https://github.com/songquanpeng/one-api) 进行转发 4 | 5 | 本项目模仿 claude 的 ui 和交互,代码块的功能暂时没有实现,后续会尝试补充 6 | 7 | 做了简单的移动端适配,但是可能存在一些问题,欢迎提issue 8 | 9 | 目前i18n仅做了中英文 10 | 11 | ### 项目运行 12 | 13 | 环境变量需要自己配置,配置文件在 .env.local.example 中,复制一份改名为 .env.local 即可 14 | 15 | ```bash 16 | pnpm install 17 | # 开发环境 18 | pnpm dev 19 | # 生产环境 20 | pnpm build 21 | pnpm start 22 | ``` 23 | 24 | 欢迎各位使用 25 | 26 | 27 | 28 | ### 文件上传接口描述 29 | **没有最单独的文件上传相关内容,仅仅是将文件URL发送给AI,需要你的AI模型支持文件才能使用** 30 | 上传文件续自备接口,接口格式满足 31 | 32 | ```javascript 33 | let formdata = new FormData(); 34 | formdata.append("file", fileInput.files[0], "200.txt"); 35 | 36 | let requestOptions = { 37 | method: 'POST', 38 | body: formdata, 39 | redirect: 'follow' 40 | }; 41 | 42 | fetch("https://xxxx", requestOptions) 43 | .then(response => response.text()) 44 | .then(result => console.log(result)) 45 | .catch(error => console.log('error', error)); 46 | ``` 47 | 48 | 返回接口 49 | 50 | ```json 51 | { 52 | "code": 0, 53 | "msg": "ok", 54 | "data": { 55 | "url": "https://xxx", 56 | "filename": "200.txt", 57 | "image": false 58 | } 59 | } 60 | ``` 61 | 62 | ### 效果演示 63 | 64 | ![首页](./images/Snipaste_2024-09-27_19-12-12.png) 65 | 66 | ![聊天页面](./images/Snipaste_2024-09-27_19-14-53.png) 67 | 68 | ![设置](./images/Snipaste_2024-09-27_19-15-27.png) 69 | 70 | ![历史记录](./images/Snipaste_2024-09-27_19-24-26.png) 71 | 72 | -------------------------------------------------------------------------------- /images/Snipaste_2024-09-27_19-12-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1653756334/claude-imitate/0d0cfa8da5cfb0212dbb5e9547cb87b5a9d52c27/images/Snipaste_2024-09-27_19-12-12.png -------------------------------------------------------------------------------- /images/Snipaste_2024-09-27_19-14-53.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1653756334/claude-imitate/0d0cfa8da5cfb0212dbb5e9547cb87b5a9d52c27/images/Snipaste_2024-09-27_19-14-53.png -------------------------------------------------------------------------------- /images/Snipaste_2024-09-27_19-15-27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1653756334/claude-imitate/0d0cfa8da5cfb0212dbb5e9547cb87b5a9d52c27/images/Snipaste_2024-09-27_19-15-27.png -------------------------------------------------------------------------------- /images/Snipaste_2024-09-27_19-24-26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1653756334/claude-imitate/0d0cfa8da5cfb0212dbb5e9547cb87b5a9d52c27/images/Snipaste_2024-09-27_19-24-26.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'raw.githubusercontent.com', 8 | }, 9 | { 10 | protocol: 'https', 11 | hostname: 'avatars.githubusercontent.com', 12 | }, 13 | { 14 | protocol: 'https', 15 | hostname: 'filesystem.site', 16 | }, 17 | ], 18 | }, 19 | webpack(config) { 20 | config.module.rules.push({ 21 | test: /\.svg$/, 22 | use: ["@svgr/webpack"], 23 | }); 24 | 25 | return config; 26 | }, 27 | async redirects() { 28 | return [ 29 | 30 | ]; 31 | }, 32 | }; 33 | 34 | export default nextConfig; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-2-sql-grapg", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev -H 0.0.0.0", 8 | "build": "next build", 9 | "start": "next start -H 0.0.0.0", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@ai-sdk/openai": "^0.0.60", 14 | "@ant-design/icons": "^5.4.0", 15 | "@ant-design/nextjs-registry": "^1.0.1", 16 | "@formatjs/intl-localematcher": "^0.5.4", 17 | "@svgr/webpack": "^8.1.0", 18 | "@types/negotiator": "^0.6.3", 19 | "antd": "^5.20.6", 20 | "framer-motion": "^11.5.4", 21 | "negotiator": "^0.6.3", 22 | "next": "14.2.9", 23 | "openai": "^4.62.0", 24 | "react": "^18", 25 | "react-dom": "^18", 26 | "react-markdown": "^9.0.1", 27 | "react-syntax-highlighter": "^15.5.0", 28 | "rehype-katex": "^7.0.1", 29 | "remark-gfm": "^4.0.0", 30 | "remark-math": "^6.0.0", 31 | "sass": "^1.78.0", 32 | "uuid": "^10.0.0", 33 | "zustand": "5.0.0-rc.2" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^20", 37 | "@types/react": "^18", 38 | "@types/react-dom": "^18", 39 | "@types/react-syntax-highlighter": "^15.5.13", 40 | "@types/uuid": "^10.0.0", 41 | "autoprefixer": "^10.4.20", 42 | "eslint": "^8", 43 | "eslint-config-next": "14.2.9", 44 | "postcss": "^8.4.45", 45 | "tailwindcss": "^3.4.10", 46 | "typescript": "^5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1653756334/claude-imitate/0d0cfa8da5cfb0212dbb5e9547cb87b5a9d52c27/public/favicon.ico -------------------------------------------------------------------------------- /src/app/[lang]/chat/[id]/ChatContent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | ArrowUpOutlined, 4 | CommentOutlined, 5 | DownOutlined, 6 | } from "@ant-design/icons"; 7 | import React, { useRef, useState, useEffect } from "react"; 8 | import { usePathname, useRouter } from "next/navigation"; 9 | import { v4 as uuid } from "uuid"; 10 | 11 | import OutsideClickHandler from "@/app/components/OutsideClickHandler"; 12 | import Confirm from "@/app/components/Comfirm"; 13 | import Modify from "@/app/components/Modify"; 14 | import DropdownMenu from "@/app/components/DropDown"; 15 | import SelfMessage from "@/app/components/SelfMessage"; 16 | import AssistantMsg from "@/app/components/AssistantMsg"; 17 | import { 18 | useSettingStore, 19 | useSessionStore, 20 | useUserStore, 21 | } from "@/app/lib/store"; 22 | import { throttle } from "@/app/lib/utils"; 23 | import { message as Message } from "antd"; 24 | import { IconProvider } from "@/app/components/IconProvider"; 25 | import HintText from "@/app/components/HintText"; 26 | export default function ChatContent({ t }: Chat.ChatContentProps) { 27 | const pathname = usePathname(); 28 | const session_id = pathname.split("/").slice(-1)[0]; 29 | const [showModify, setShowModify] = useState(false); 30 | const textareaRef = useRef(null); 31 | const [curChat, setCurChat] = useState(""); 32 | const [content, setContent] = useState(""); 33 | const [chatList, setChatList] = useState([]); 34 | const [loading, setLoading] = useState(false); 35 | const breakStreamRef = useRef(false); 36 | 37 | const chatListRef = useRef(null); 38 | const { settings, saveOneSettingToLocal } = useSettingStore(); 39 | const { user } = useUserStore(); 40 | 41 | const { 42 | setCurMsg, 43 | curMsg, 44 | getSessionById, 45 | addMessage, 46 | deleteSession, 47 | renameSession, 48 | deleteMessage, 49 | } = useSessionStore(); 50 | 51 | const router = useRouter(); 52 | 53 | const adjustTextareaHeight = () => { 54 | const textarea = textareaRef.current; 55 | if (textarea) { 56 | textarea.style.height = "auto"; 57 | textarea.style.height = `${Math.min(textarea.scrollHeight, 370)}px`; 58 | } 59 | }; 60 | 61 | const [confirmData, setConfirmData] = useState({ 62 | title: "", 63 | content: "", 64 | onCancel: () => {}, 65 | onConfirm: () => {}, 66 | visible: false, 67 | yesText: "", 68 | noText: "", 69 | }); 70 | const [modifyData, setModifyData] = useState({ 71 | title: "", 72 | content: "", 73 | onCancel: () => {}, 74 | onConfirm: () => {}, 75 | visible: false, 76 | yesText: "", 77 | noText: "", 78 | }); 79 | 80 | const hasSentMessage = useRef(true); 81 | 82 | // 先加载历史记录 83 | const session = getSessionById(session_id) || { messages: [], title: "" }; 84 | 85 | useEffect(() => { 86 | if (hasSentMessage.current) { 87 | setChatList(session.messages); 88 | downToBottom(); 89 | } 90 | // 说明从上个页面过来,需要发送 91 | if (curMsg !== "" && hasSentMessage.current) { 92 | streamChat(); 93 | // 标记消息已发送 94 | hasSentMessage.current = false; 95 | // 清除 curMsg,防止重复发送 96 | setCurMsg(""); 97 | } 98 | // 清理函数 99 | return () => { 100 | hasSentMessage.current = false; 101 | }; 102 | }, []); 103 | 104 | useEffect(() => { 105 | setChatList(session.messages); 106 | }, [session.messages.length]); 107 | 108 | useEffect(() => { 109 | adjustTextareaHeight(); 110 | }, [content]); 111 | 112 | const onDeleteChat = () => { 113 | const data = { 114 | title: t.chat.delete_title, 115 | content: t.chat.delete_content, 116 | yesText: t.confirm.delete, 117 | noText: t.confirm.no, 118 | visible: true, 119 | }; 120 | setConfirmData({ 121 | ...data, 122 | onConfirm: () => { 123 | setConfirmData({ ...data, visible: false }); 124 | deleteSession(session_id); 125 | router.push("/new"); 126 | }, 127 | onCancel: () => { 128 | setConfirmData({ ...data, visible: false }); 129 | }, 130 | }); 131 | }; 132 | 133 | const onRenameChat = () => { 134 | const data = { 135 | title: t.chat.rename_title, 136 | content: session.title, 137 | yesText: t.confirm.rename, 138 | noText: t.confirm.no, 139 | visible: true, 140 | }; 141 | setModifyData({ 142 | ...data, 143 | onConfirm: (value: string) => { 144 | renameSession(session_id, value); 145 | setModifyData({ ...data, visible: false, content: value }); 146 | }, 147 | onCancel: () => { 148 | setModifyData({ ...data, visible: false }); 149 | }, 150 | }); 151 | }; 152 | const handleEditChange = (e: React.ChangeEvent) => { 153 | setContent(e.target.value); 154 | }; 155 | const chooseModel = (item: Store.Model) => { 156 | saveOneSettingToLocal("currentModel", item.value); 157 | saveOneSettingToLocal("currentDisplayModel", item.label); 158 | }; 159 | 160 | const isScrolledToBottom = () => { 161 | if (chatListRef.current) { 162 | const { scrollTop, scrollHeight, clientHeight } = chatListRef.current; 163 | return scrollTop + clientHeight >= scrollHeight - 30; // 允许30px的误差 164 | } 165 | return false; 166 | }; 167 | 168 | const downToBottom = () => { 169 | if (chatListRef.current) { 170 | setTimeout(() => { 171 | chatListRef.current?.scrollTo({ 172 | top: chatListRef.current.scrollHeight + 100, 173 | behavior: "smooth", 174 | }); 175 | }, 100); 176 | } 177 | }; 178 | 179 | const throttleDownToBottom = throttle(downToBottom, 50); 180 | 181 | async function genTitle(historyMsgList: Global.ChatItem[]) { 182 | try { 183 | const res = await fetch("/api/chat-out-stream", { 184 | method: "POST", 185 | headers: { 186 | "Content-Type": "application/json", 187 | }, 188 | body: JSON.stringify({ 189 | model: "gpt-4o-mini", 190 | key: settings.APIKey, 191 | secret: settings.secret, 192 | historyMsgList: [ 193 | ...historyMsgList, 194 | { 195 | role: "user", 196 | content: t.chat.generate_title, 197 | }, 198 | ], 199 | systemPrompt: t.chat.generate_title, 200 | baseUrl: settings.baseUrl, 201 | }), 202 | }); 203 | if (res.ok) { 204 | const title = await res.json(); 205 | renameSession(session_id, title.msg.toString()); 206 | } else { 207 | Message.error(t.chat.generate_title_failed); 208 | } 209 | } catch (e) { 210 | console.log(e); 211 | Message.error(t.chat.generate_title_failed); 212 | } 213 | } 214 | 215 | async function streamChat(message?: Global.ChatItem) { 216 | setLoading(true); 217 | breakStreamRef.current = false; 218 | let historyMsgList: Global.ChatItem[] = []; 219 | const systemPrompt = settings.sysPrompt; 220 | if (chatList.length > 0) { 221 | const historyCnt = settings.historyNum; 222 | historyMsgList = chatList.slice(-historyCnt); 223 | } else { 224 | historyMsgList = getSessionById(session_id)?.messages || []; 225 | } 226 | const response = await fetch("/api/chat", { 227 | method: "POST", 228 | headers: { 229 | "Content-Type": "application/json", 230 | }, 231 | body: JSON.stringify({ 232 | model: settings.currentModel, 233 | historyMsgList, 234 | temperature: settings.random, 235 | systemPrompt, 236 | key: settings.APIKey, 237 | baseUrl: settings.baseUrl, 238 | secret: settings.secret, 239 | }), 240 | }); 241 | 242 | if (!response.ok) { 243 | const res = await response.json(); 244 | let errMsg = JSON.stringify(res.msg.error); 245 | 246 | if (res.code === 400) { 247 | errMsg += ", " + t.chat.error_hint; 248 | } 249 | addMessage(session_id, { 250 | role: "assistant", 251 | content: errMsg, 252 | id: uuid(), 253 | createdAt: Date.now(), 254 | }); 255 | // console.log(res); 256 | 257 | // downToBottom(); 258 | setLoading(false); 259 | return; 260 | } 261 | 262 | const reader = response.body?.getReader(); 263 | const decoder = new TextDecoder(); 264 | let messgae_slice = ""; 265 | 266 | while (true) { 267 | if (breakStreamRef.current) { 268 | reader?.cancel(); 269 | setLoading(false); 270 | breakStreamRef.current = false; 271 | setCurChat(""); 272 | // 停止的时候删除消息,只有一条消息就删除会话 273 | if (message) { 274 | deleteMessage(session_id, message.id); 275 | } else { 276 | deleteSession(session_id); 277 | router.push("/new"); 278 | } 279 | break; 280 | } 281 | const { done, value } = await reader!.read(); 282 | 283 | if (done) { 284 | setLoading(false); 285 | break; 286 | } 287 | const chunk = decoder.decode(value); 288 | const lines = chunk.split("\n"); 289 | 290 | for (const line of lines) { 291 | if (line.startsWith("data: ")) { 292 | const data = line.replace("data: ", ""); 293 | if (data === "[DONE]") { 294 | // 流结束 295 | const all_message = { 296 | role: "assistant" as const, 297 | content: messgae_slice, 298 | id: uuid(), 299 | createdAt: Date.now(), 300 | }; 301 | setCurChat(""); 302 | addMessage(session_id, all_message); 303 | downToBottom(); 304 | setLoading(false); 305 | // 第一次发送信息生成标题 306 | if (chatList.length <= 2) { 307 | genTitle(historyMsgList); 308 | } 309 | } else { 310 | try { 311 | const parsed = JSON.parse(data); 312 | const content = parsed.choices[0].delta.content; 313 | if (content) { 314 | messgae_slice += content; 315 | // 节流 316 | throttle(setCurChat, 1000 / 60)(messgae_slice); 317 | } 318 | // 如果内容滑到了底部,就一直往底下滚动 319 | if (isScrolledToBottom()) { 320 | throttleDownToBottom(); 321 | } 322 | } catch (error) { 323 | console.error("解析JSON时出错:", error); 324 | setCurChat(""); 325 | } 326 | } 327 | } 328 | } 329 | } 330 | } 331 | 332 | const sendMessage = async () => { 333 | if (content.trim() === "") { 334 | return; 335 | } 336 | const randonId = uuid(); 337 | downToBottom(); 338 | const message = { 339 | role: "user" as const, 340 | content, 341 | id: randonId, 342 | createdAt: Date.now(), 343 | }; 344 | addMessage(session_id, message); 345 | setContent(""); 346 | streamChat(message); 347 | }; 348 | 349 | return ( 350 |
351 |
352 |
353 | setShowModify(false)}> 354 |
setShowModify(!showModify)} 357 | > 358 |
359 | 360 |
361 |
362 | {session.title} 363 |
364 | 365 |
371 |
375 | {t.chat.rename} 376 |
377 |
381 | {t.chat.delete} 382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 | {/* 聊天记录 */} 390 |
394 |
395 | {chatList.map((item) => { 396 | if (item.role === "user") { 397 | return ( 398 | { 403 | setContent(item.content); 404 | }} 405 | /> 406 | ); 407 | } else { 408 | return ; 409 | } 410 | })} 411 | {curChat && curChat.trim() !== "" && ( 412 | 413 | )} 414 |
417 | 418 |
419 |
420 |
421 | {/* 底部输入框 */} 422 |
423 |
{ 426 | if (e.key === "Enter" && !e.shiftKey) { 427 | e.preventDefault(); 428 | if (loading) return; 429 | sendMessage(); 430 | } 431 | }} 432 | > 433 |
434 |