├── src ├── env.d.ts ├── types.ts ├── default.ts ├── components │ ├── Header.astro │ ├── Clipboard.tsx │ ├── MessageItem.tsx │ ├── PromptList.tsx │ ├── Setting.tsx │ └── Generator.tsx ├── pages │ ├── index.astro │ └── api │ │ ├── index.ts │ │ └── stream.ts ├── shims.d.ts ├── layouts │ └── Layout.astro ├── styles │ ├── message.css │ ├── global.css │ └── clipboard.css ├── markdown │ └── index.ts ├── hooks │ └── index.ts ├── utils │ └── index.ts └── prompts.ts ├── assets ├── preview.png └── environment.png ├── .gitignore ├── tsconfig.json ├── 来源 └── default.ts ├── astro.config.mjs ├── LICENSE ├── package.json ├── README.md └── public └── favicon.svg /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/product/chatgpt/main/assets/preview.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | yarn-error.log 4 | .idea/ 5 | .vercel 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /assets/environment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/product/chatgpt/main/assets/environment.png -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ChatMessage { 2 | role: 'system' | 'user' | 'assistant' 3 | content: string 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "jsxImportSource": "solid-js", 6 | "baseUrl": ".", 7 | "paths": { 8 | "~/*": [ 9 | "src/*" 10 | ] 11 | }, 12 | } 13 | } -------------------------------------------------------------------------------- /src/default.ts: -------------------------------------------------------------------------------- 1 | export const defaultSetting = { 2 | continuousDialogue: true, 3 | archiveSession: false, 4 | openaiAPIKey: "", 5 | openaiAPITemperature: 60, 6 | systemRule: "" 7 | } 8 | 9 | export const defaultMessage = ` 10 | - 本站仅用于演示,填入自己的 key 才可使用。 11 | - 本站功能强大,响应速度快,欢迎自部署。 12 | - Shift + Enter 换行。开头输入 / 或者 空格 搜索 Prompt 预设。点击输入框滚动到底部。` 13 | -------------------------------------------------------------------------------- /来源/default.ts: -------------------------------------------------------------------------------- 1 | export const defaultSetting = { 2 | continuousDialogue: true, 3 | archiveSession: false, 4 | openaiAPIKey: "", 5 | openaiAPITemperature: 60, 6 | systemRule: "" 7 | } 8 | 9 | export const defaultMessage = ` 10 | - 本站仅用于演示,填入自己的 key 才可使用555。 11 | - 由 [是枭](https://blog.evv1.com) 站点源码出售,功能强大响应快,欢迎自部署。 12 | - **Shift+Enter** 换行。开头输入 **/** 或者 **空格** 搜索 Prompt 预设。点击输入框滚动到底部。` 13 | -------------------------------------------------------------------------------- /src/components/Header.astro: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | ChatGPT 8 | Vercel 9 |
10 |
11 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "~/layouts/Layout.astro" 3 | import Header from "~/components/Header.astro" 4 | import Generator from "~/components/Generator" 5 | import "~/styles/global.css" 6 | import "@unocss/reset/tailwind.css" 7 | import "katex/dist/katex.min.css" 8 | import "highlight.js/styles/atom-one-dark.css" 9 | --- 10 | 11 | 12 |
13 |
14 | 15 |
16 |
17 | -------------------------------------------------------------------------------- /src/shims.d.ts: -------------------------------------------------------------------------------- 1 | import type { AttributifyAttributes } from '@unocss/preset-attributify' 2 | 3 | // declare module 'solid-js' { 4 | // namespace JSX { 5 | // interface HTMLAttributes extends AttributifyAttributes {} 6 | // } 7 | // } 8 | 9 | declare global { 10 | namespace astroHTML.JSX { 11 | interface HTMLAttributes extends AttributifyAttributes { } 12 | } 13 | namespace JSX { 14 | interface HTMLAttributes extends AttributifyAttributes {} 15 | } 16 | } -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | export interface Props { 3 | title: string 4 | } 5 | 6 | const { title } = Astro.props 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {title} 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/styles/message.css: -------------------------------------------------------------------------------- 1 | .message pre { 2 | background-color: #64748b10; 3 | font-size: 0.8rem; 4 | padding: 0.4rem 1rem; 5 | } 6 | 7 | .message .hljs { 8 | background-color: transparent; 9 | } 10 | 11 | .message table { 12 | font-size: 0.8em; 13 | } 14 | 15 | .message table thead tr { 16 | background-color: #64748b40; 17 | text-align: left; 18 | } 19 | 20 | .message table th, .message table td { 21 | padding: 0.6rem 1rem; 22 | } 23 | 24 | .message table tbody tr:last-of-type { 25 | border-bottom: 2px solid #64748b40; 26 | } -------------------------------------------------------------------------------- /src/components/Clipboard.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from "solid-js" 2 | import { copyToClipboard } from "../utils" 3 | import "../styles/clipboard.css" 4 | export default function Clipboard(props: { message: string }) { 5 | const [copied, setCopied] = createSignal(false) 6 | return ( 7 | ` 16 | ) 17 | } 18 | } 19 | 20 | export function extractTitle(info: string) { 21 | return info.match(/\[(.*)\]/)?.[1] || extractLang(info) || "txt" 22 | } 23 | 24 | const extractLang = (info: string) => { 25 | return info.trim().replace(/:(no-)?line-numbers({| |$).*/, "") 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { onCleanup, onMount } from "solid-js" 2 | import { copyToClipboard } from "../utils" 3 | 4 | export function useCopyCode() { 5 | const timeoutIdMap: Map = new Map() 6 | const listerner = (e: MouseEvent) => { 7 | const el = e.target as HTMLElement 8 | if (el.matches(".code-copy")) { 9 | const parent = el.parentElement 10 | const sibling = el.nextElementSibling as HTMLPreElement | null 11 | if (!parent || !sibling) { 12 | return 13 | } 14 | 15 | let text = sibling.innerText 16 | 17 | copyToClipboard(text).then(() => { 18 | el.classList.add("copied") 19 | clearTimeout(timeoutIdMap.get(el)) 20 | const timeoutId = setTimeout(() => { 21 | el.classList.remove("copied") 22 | el.blur() 23 | timeoutIdMap.delete(el) 24 | }, 2000) 25 | timeoutIdMap.set(el, timeoutId) 26 | }) 27 | } 28 | } 29 | onMount(() => { 30 | window.addEventListener("click", listerner) 31 | }) 32 | onCleanup(() => { 33 | window.removeEventListener("click", listerner) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 OuRongXing and Diu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vercel-chatgpt", 3 | "version": "0.0.1", 4 | "description": "Powered by OpenAI Chatgpt API and Vercel", 5 | "type": "module", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "astro dev", 9 | "start": "astro dev", 10 | "build": "astro build", 11 | "preview": "astro preview", 12 | "astro": "astro" 13 | }, 14 | "dependencies": { 15 | "@astrojs/solid-js": "^2.0.2", 16 | "@astrojs/vercel": "^3.1.3", 17 | "@solid-primitives/resize-observer": "^2.0.11", 18 | "@unocss/reset": "^0.50.3", 19 | "astro": "^2.0.15", 20 | "eventsource-parser": "^0.1.0", 21 | "fzf": "^0.5.1", 22 | "highlight.js": "^11.7.0", 23 | "just-throttle": "^4.2.0", 24 | "katex": "^0.6.0", 25 | "markdown-it": "^13.0.1", 26 | "markdown-it-highlightjs": "^4.0.1", 27 | "markdown-it-katex": "^2.0.3", 28 | "solid-js": "^1.6.11" 29 | }, 30 | "devDependencies": { 31 | "@iconify-json/carbon": "^1.1.16", 32 | "@types/markdown-it": "^12.2.3", 33 | "@vercel/node": "^2.8.0", 34 | "punycode": "^2.3.0", 35 | "typescript": "^4.8.3", 36 | "unocss": "^0.50.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/pages/api/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro" 2 | 3 | export const post: APIRoute = async ({ request }) => { 4 | const { message, key } = (await request.json()) ?? {} 5 | if (!message) { 6 | return { 7 | body: JSON.stringify({ 8 | success: false, 9 | message: "message is required" 10 | }) 11 | } 12 | } 13 | if (!key) { 14 | return { 15 | body: JSON.stringify({ 16 | success: false, 17 | message: "openapi key is required" 18 | }) 19 | } 20 | } 21 | 22 | const response = await fetch(`https://api.openai.com/v1/chat/completions`, { 23 | method: "POST", 24 | headers: { 25 | Authorization: `Bearer ${key}`, 26 | "Content-Type": `application/json` 27 | }, 28 | body: JSON.stringify({ 29 | model: "gpt-3.5-turbo", 30 | messages: [ 31 | { 32 | role: "user", 33 | content: message 34 | } 35 | ] 36 | }) 37 | }) 38 | let result = await response.json() 39 | if (result?.error) { 40 | return { 41 | body: JSON.stringify({ 42 | success: false, 43 | message: `${result.error?.message}` 44 | }) 45 | } 46 | } 47 | return { 48 | body: JSON.stringify({ 49 | success: true, 50 | message: "ok", 51 | data: result?.choices?.[0].message 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export async function copyToClipboard(text: string) { 2 | try { 3 | return navigator.clipboard.writeText(text) 4 | } catch { 5 | const element = document.createElement("textarea") 6 | const previouslyFocusedElement = document.activeElement 7 | 8 | element.value = text 9 | 10 | // Prevent keyboard from showing on mobile 11 | element.setAttribute("readonly", "") 12 | 13 | element.style.contain = "strict" 14 | element.style.position = "absolute" 15 | element.style.left = "-9999px" 16 | element.style.fontSize = "12pt" // Prevent zooming on iOS 17 | 18 | const selection = document.getSelection() 19 | const originalRange = selection 20 | ? selection.rangeCount > 0 && selection.getRangeAt(0) 21 | : null 22 | 23 | document.body.appendChild(element) 24 | element.select() 25 | 26 | // Explicit selection workaround for iOS 27 | element.selectionStart = 0 28 | element.selectionEnd = text.length 29 | 30 | document.execCommand("copy") 31 | document.body.removeChild(element) 32 | 33 | if (originalRange) { 34 | selection!.removeAllRanges() // originalRange can't be truthy when selection is falsy 35 | selection!.addRange(originalRange) 36 | } 37 | 38 | // Get the focus back on the previously focused element, if any 39 | if (previouslyFocusedElement) { 40 | ;(previouslyFocusedElement as HTMLElement).focus() 41 | } 42 | } 43 | } 44 | 45 | export function isMobile() { 46 | return /Mobi|Android|iPhone/i.test(navigator.userAgent) 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT-Vercel 2 | 3 | ![](assets/preview.png) 4 | 5 | 6 | 预览: [tg-bot.ml](https://www.tg-bot.ml/) 7 | 个人博客:[速龙博客](https://blog.ahayu.cn) 8 | 9 | ## 部署一个你自己的(免费) 10 | 11 | > 本项目主要面向中文用户,所以用中文,原版是英文的。 12 | 13 | 如果你只需要部署一个你自己用的网站,而不需要定制,那么你完全不需要在本地跑起来,你可以直接点击下面的按钮,然后按照提示操作即可。 vercel 域名已经被墙,但 vercel 本身没有被墙,所以你绑定自己的域名就可以了。 14 | 15 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/fastdragon18/chat) 16 | 17 | 如果你需要部署给更多人用,那么你可能需要将上面创建的你自己的仓库 `git clone` 到本地。 18 | 19 | 1. 将 `.env.example` 文件修改为 `.env`,然后在里面填入你的 [OpenAI API key](https://platform.openai.com/account/api-keys)。如果用户不填自己的 key,那么就会使用你的 key。 20 | 21 | ``` 22 | OPENAI_API_KEY=sk-xxx... 23 | // 你可以填写多个,用 | 分隔,随机调用。最好是多填几个,不太清楚有没有并发上的限制。 24 | OPENAI_API_KEY=sk-xxx|sk-yyy 25 | ``` 26 | 最新版本支持读取 Vercel 的环境变量,所以你也可以直接在 Vercel 上设置环境变量,如图所示。对于大部分人来说这个更方便。会在下次部署时生效。 27 | ![](assets/environment.png) 28 | 2. 默认设置在 `src/default.ts` 文件中,自行修改。默认的提示信息也在这里。 29 | ```ts 30 | const defaultSetting = { 31 | // 连续对话,每次都需要将上下文传给 API,比较费钱,而且同样有 4096 token 的限制 32 | continuousDialogue: true, 33 | // 记录对话内容,刷新后不会清空对话 34 | archiveSession: false, 35 | openaiAPIKey: "", 36 | // 0-100 越高 ChatGPT 思维就越发散,开始乱答 37 | openaiAPITemperature: 60, 38 | // 系统角色指令,会在每次提问时添加。主要用于对 ChatGPT 的语气,口头禅这些进行定制。 39 | systemRule: "" 40 | } 41 | ``` 42 | 3. 之前版本我设置了每次刷新重置 `开启连续对话` 选项,因为一般用不上这个,比较费钱。当前版本我已经移除了这个特性,如果你需要给更多人用,建议打开,只要将 [这行代码](https://github.com/ourongxing/chatgpt-vercel/blob/main/src/components/Generator.tsx#L46) 取消注释即可。 43 | 44 | 4. `git commit & push` 即可重新部署,vscode 上点几下就可以了。 45 | 46 | ## API 47 | 48 | ### POST /api 49 | ```ts 50 | await fetch("/api", { 51 | method: "POST", 52 | body: JSON.stringify({ 53 | message: "xxx", 54 | key: "xxxx" 55 | }) 56 | }) 57 | ``` 58 | ## License 59 | 60 | MIT 61 | -------------------------------------------------------------------------------- /src/components/MessageItem.tsx: -------------------------------------------------------------------------------- 1 | import type { Accessor } from "solid-js" 2 | import type { ChatMessage } from "../types" 3 | import MarkdownIt from "markdown-it" 4 | // @ts-ignore 5 | import mdKatex from "markdown-it-katex" 6 | import mdHighlight from "markdown-it-highlightjs" 7 | import Clipboard from "./Clipboard" 8 | import { preWrapperPlugin } from "../markdown" 9 | import "../styles/message.css" 10 | import { useCopyCode } from "../hooks" 11 | 12 | interface Props { 13 | role: ChatMessage["role"] 14 | message: Accessor | string 15 | } 16 | 17 | export default ({ role, message }: Props) => { 18 | useCopyCode() 19 | const roleClass = { 20 | system: "bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300", 21 | user: "bg-gradient-to-r from-sky-400 to-emerald-500", 22 | assistant: "bg-gradient-to-r from-yellow-300 to-red-700 " 23 | } 24 | 25 | const htmlString = () => { 26 | const md = MarkdownIt({ 27 | breaks: true, 28 | html: true 29 | }) 30 | .use(mdKatex) 31 | .use(mdHighlight) 32 | .use(preWrapperPlugin) 33 | 34 | if (typeof message === "function") { 35 | return md.render(message().trim()) 36 | } else if (typeof message === "string") { 37 | return md.render(message.trim()) 38 | } 39 | return "" 40 | } 41 | 42 | // createEffect(() => { 43 | // console.log(htmlString()) 44 | // }) 45 | 46 | return ( 47 |
51 |
54 |
58 | { 60 | if (typeof message === "function") { 61 | return message().trim() 62 | } else if (typeof message === "string") { 63 | return message.trim() 64 | } 65 | return "" 66 | })()} 67 | /> 68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/pages/api/stream.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro" 2 | import { 3 | createParser, 4 | ParsedEvent, 5 | ReconnectInterval 6 | } from "eventsource-parser" 7 | 8 | const localEnv = import.meta.env.OPENAI_API_KEY 9 | const vercelEnv = process.env.OPENAI_API_KEY 10 | 11 | const apiKeys = ((localEnv || vercelEnv)?.split(/\s*\|\s*/) ?? []).filter( 12 | Boolean 13 | ) 14 | 15 | export const post: APIRoute = async context => { 16 | const body = await context.request.json() 17 | const apiKey = apiKeys.length 18 | ? apiKeys[Math.floor(Math.random() * apiKeys.length)] 19 | : "" 20 | let { messages, key = apiKey, temperature = 0.6 } = body 21 | 22 | const encoder = new TextEncoder() 23 | const decoder = new TextDecoder() 24 | 25 | if (!key.startsWith("sk-")) key = apiKey 26 | if (!key) { 27 | return new Response("没有填写 OpenAI API key") 28 | } 29 | if (!messages) { 30 | return new Response("没有输入任何文字") 31 | } 32 | 33 | const completion = await fetch("https://api.openai.com/v1/chat/completions", { 34 | headers: { 35 | "Content-Type": "application/json", 36 | Authorization: `Bearer ${key}` 37 | }, 38 | method: "POST", 39 | body: JSON.stringify({ 40 | model: "gpt-3.5-turbo", 41 | messages, 42 | temperature, 43 | stream: true 44 | }) 45 | }) 46 | 47 | const stream = new ReadableStream({ 48 | async start(controller) { 49 | const streamParser = (event: ParsedEvent | ReconnectInterval) => { 50 | if (event.type === "event") { 51 | const data = event.data 52 | if (data === "[DONE]") { 53 | controller.close() 54 | return 55 | } 56 | try { 57 | // response = { 58 | // id: 'chatcmpl-6pULPSegWhFgi0XQ1DtgA3zTa1WR6', 59 | // object: 'chat.completion.chunk', 60 | // created: 1677729391, 61 | // model: 'gpt-3.5-turbo-0301', 62 | // choices: [ 63 | // { delta: { content: '你' }, index: 0, finish_reason: null } 64 | // ], 65 | // } 66 | const json = JSON.parse(data) 67 | const text = json.choices[0].delta?.content 68 | const queue = encoder.encode(text) 69 | controller.enqueue(queue) 70 | } catch (e) { 71 | controller.error(e) 72 | } 73 | } 74 | } 75 | 76 | const parser = createParser(streamParser) 77 | for await (const chunk of completion.body as any) { 78 | parser.feed(decoder.decode(chunk)) 79 | } 80 | } 81 | }) 82 | 83 | return new Response(stream) 84 | } 85 | -------------------------------------------------------------------------------- /src/components/PromptList.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js" 2 | import type { PromptItem } from "./Generator" 3 | 4 | export default function PromptList(props: { 5 | prompts: PromptItem[] 6 | select: (k: string) => void 7 | }) { 8 | let containerRef: HTMLUListElement 9 | const [hoverIndex, setHoverIndex] = createSignal(0) 10 | const [maxHeight, setMaxHeight] = createSignal("320px") 11 | function listener(e: KeyboardEvent) { 12 | if (e.key === "ArrowDown") { 13 | setHoverIndex(hoverIndex() + 1) 14 | } else if (e.key === "ArrowUp") { 15 | setHoverIndex(hoverIndex() - 1) 16 | } else if (e.key === "Enter") { 17 | props.select(props.prompts[hoverIndex()].prompt) 18 | } 19 | } 20 | 21 | createEffect(() => { 22 | if (hoverIndex() < 0) { 23 | setHoverIndex(0) 24 | } else if (hoverIndex() && hoverIndex() >= props.prompts.length) { 25 | setHoverIndex(props.prompts.length - 1) 26 | } 27 | }) 28 | 29 | createEffect(() => { 30 | if (containerRef && props.prompts.length) 31 | setMaxHeight( 32 | `${ 33 | window.innerHeight - containerRef.clientHeight > 112 34 | ? 320 35 | : window.innerHeight - 112 36 | }px` 37 | ) 38 | }) 39 | 40 | onMount(() => { 41 | window.addEventListener("keydown", listener) 42 | }) 43 | onCleanup(() => { 44 | window.removeEventListener("keydown", listener) 45 | }) 46 | 47 | return ( 48 |
    55 | 56 | {(prompt, i) => ( 57 | 62 | )} 63 | 64 |
65 | ) 66 | } 67 | 68 | function Item(props: { 69 | prompt: PromptItem 70 | select: (k: string) => void 71 | hover: boolean 72 | }) { 73 | let ref: HTMLLIElement 74 | createEffect(() => { 75 | if (props.hover) { 76 | ref.focus() 77 | ref.scrollIntoView({ block: "center" }) 78 | } 79 | }) 80 | return ( 81 |
  • { 89 | props.select(props.prompt.prompt) 90 | }} 91 | > 92 |

    {props.prompt.desc}

    93 |

    {props.prompt.prompt}

    94 |
  • 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/styles/clipboard.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --block-bg-light: #1e1e20; 3 | --copy-border-color: transparent; 4 | --copy-bg: #2a2d38; 5 | --copy-hover-border-color: rgba(60, 60, 67, 0.12); 6 | --copy-hover-bg: #303540; 7 | --copy-active-text: rgba(235, 235, 245, 0.6); 8 | --icon-copy: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' height='20' width='20' stroke='rgba(128,128,128,1)' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2'/%3E%3C/svg%3E"); 9 | --icon-copied: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' height='20' width='20' stroke='rgba(128,128,128,1)' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2m-6 9 2 2 4-4'/%3E%3C/svg%3E"); 10 | } 11 | 12 | button.copy { 13 | direction: ltr; 14 | position: absolute; 15 | top: 12px; 16 | right: 12px; 17 | z-index: 3; 18 | display: block; 19 | justify-content: center; 20 | align-items: center; 21 | border: 1px solid var(--copy-border-color); 22 | border-radius: 4px; 23 | width: 40px; 24 | height: 40px; 25 | background-color: var(--copy-bg); 26 | opacity: 0; 27 | cursor: pointer; 28 | background-image: var(--icon-copy); 29 | background-position: 50%; 30 | background-size: 20px; 31 | background-repeat: no-repeat; 32 | transition: border-color 0.25s, background-color 0.25s, opacity 0.25s; 33 | } 34 | 35 | .message-item:hover > .message-copy, 36 | pre:hover > .code-copy, 37 | button.copy:focus { 38 | opacity: 1; 39 | } 40 | 41 | button.copy:hover, 42 | button.copy.copied { 43 | border-color: var(--copy-hover-border-color); 44 | background-color: var(--copy-hover-bg); 45 | } 46 | 47 | button.copy.copied, 48 | button.copy:hover.copied { 49 | border-radius: 0 4px 4px 0; 50 | background-color: var(--copy-hover-bg); 51 | background-image: var(--icon-copied); 52 | } 53 | 54 | button.copy.copied::before, 55 | button.copy:hover.copied::before { 56 | position: relative; 57 | top: -1px; 58 | left: -65px; 59 | display: flex; 60 | justify-content: center; 61 | align-items: center; 62 | border: 1px solid var(--copy-hover-border-color); 63 | border-right: 0; 64 | border-radius: 4px 0 0 4px; 65 | width: 64px; 66 | height: 40px; 67 | text-align: center; 68 | font-size: 12px; 69 | font-weight: 500; 70 | color: var(--copy-active-text); 71 | background-color: var(--copy-hover-bg); 72 | white-space: nowrap; 73 | content: "复制成功"; 74 | } 75 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 93-robot-face-2 -------------------------------------------------------------------------------- /src/components/Setting.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accessor, 3 | children, 4 | createSignal, 5 | JSXElement, 6 | Setter, 7 | Show 8 | } from "solid-js" 9 | import type { Setting } from "./Generator" 10 | 11 | export default function Setting(props: { 12 | setting: Accessor 13 | setSetting: Setter 14 | clear: any 15 | reAnswer: any 16 | }) { 17 | const [shown, setShown] = createSignal(false) 18 | return ( 19 |
    20 | 21 | 22 | { 27 | props.setSetting({ 28 | ...props.setting(), 29 | openaiAPIKey: (e.target as HTMLInputElement).value 30 | }) 31 | }} 32 | /> 33 | 34 | 35 | { 40 | props.setSetting({ 41 | ...props.setting(), 42 | systemRule: (e.target as HTMLInputElement).value 43 | }) 44 | }} 45 | /> 46 | 47 | 48 | { 55 | props.setSetting({ 56 | ...props.setting(), 57 | openaiAPITemperature: Number( 58 | (e.target as HTMLInputElement).value 59 | ) 60 | }) 61 | }} 62 | /> 63 | 64 | 68 | 82 | 83 | 87 | 101 | 102 |
    103 |
    104 |
    105 |
    { 108 | setShown(!shown()) 109 | }} 110 | > 111 |
    114 |
    115 |
    119 |
    122 |
    126 |
    129 |
    130 |
    131 |
    132 | ) 133 | } 134 | 135 | function SettingItem(props: { 136 | children: JSXElement 137 | icon: string 138 | label: string 139 | }) { 140 | return ( 141 |
    142 |
    143 |
    146 | {props.children} 147 |
    148 | ) 149 | } 150 | -------------------------------------------------------------------------------- /src/components/Generator.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, createSignal, For, onMount, Show } from "solid-js" 2 | import { createResizeObserver } from "@solid-primitives/resize-observer" 3 | import MessageItem from "./MessageItem" 4 | import type { ChatMessage } from "~/types" 5 | import Setting from "./Setting" 6 | import PromptList from "./PromptList" 7 | import prompts from "~/prompts" 8 | import { Fzf } from "fzf" 9 | import { defaultMessage, defaultSetting } from "~/default" 10 | import throttle from "just-throttle" 11 | import { isMobile } from "~/utils" 12 | 13 | export interface PromptItem { 14 | desc: string 15 | prompt: string 16 | } 17 | 18 | export type Setting = typeof defaultSetting 19 | 20 | export default function () { 21 | let inputRef: HTMLTextAreaElement 22 | let containerRef: HTMLDivElement 23 | const [messageList, setMessageList] = createSignal([ 24 | // { 25 | // role: "assistant", 26 | // content: defaultMessage + defaultMessage + defaultMessage + defaultMessage 27 | // } 28 | ]) 29 | const [inputContent, setInputContent] = createSignal("") 30 | const [currentAssistantMessage, setCurrentAssistantMessage] = createSignal("") 31 | const [loading, setLoading] = createSignal(false) 32 | const [controller, setController] = createSignal() 33 | const [setting, setSetting] = createSignal(defaultSetting) 34 | const [compatiblePrompt, setCompatiblePrompt] = createSignal([]) 35 | const [containerWidth, setContainerWidth] = createSignal("init") 36 | const fzf = new Fzf(prompts, { selector: k => `${k.desc} (${k.prompt})` }) 37 | const [height, setHeight] = createSignal("48px") 38 | 39 | onMount(() => { 40 | createResizeObserver(containerRef, ({ width, height }, el) => { 41 | if (el === containerRef) setContainerWidth(`${width}px`) 42 | }) 43 | const storage = localStorage.getItem("setting") 44 | const session = localStorage.getItem("session") 45 | try { 46 | let archiveSession = false 47 | if (storage) { 48 | const parsed = JSON.parse(storage) 49 | archiveSession = parsed.archiveSession 50 | setSetting({ 51 | ...defaultSetting, 52 | ...parsed 53 | // continuousDialogue: false 54 | }) 55 | } 56 | if (session && archiveSession) { 57 | setMessageList(JSON.parse(session)) 58 | } 59 | } catch { 60 | console.log("Setting parse error") 61 | } 62 | }) 63 | 64 | createEffect(() => { 65 | if (messageList().length === 0) { 66 | setMessageList([ 67 | { 68 | role: "assistant", 69 | content: defaultMessage 70 | } 71 | ]) 72 | } else if ( 73 | messageList().length > 1 && 74 | messageList()[0].content === defaultMessage 75 | ) { 76 | setMessageList(messageList().slice(1)) 77 | } 78 | localStorage.setItem("setting", JSON.stringify(setting())) 79 | if (setting().archiveSession) 80 | localStorage.setItem("session", JSON.stringify(messageList())) 81 | }) 82 | 83 | createEffect(() => { 84 | if (messageList().length || currentAssistantMessage()) scrollToBottom() 85 | }) 86 | 87 | createEffect(() => { 88 | if (inputContent() === "") { 89 | setHeight("48px") 90 | setCompatiblePrompt([]) 91 | } 92 | }) 93 | 94 | const scrollToBottom = throttle( 95 | () => { 96 | window.scrollTo({ 97 | top: document.body.scrollHeight, 98 | behavior: "smooth" 99 | }) 100 | }, 101 | 250, 102 | { leading: true, trailing: false } 103 | ) 104 | 105 | function archiveCurrentMessage() { 106 | if (currentAssistantMessage()) { 107 | setMessageList([ 108 | ...messageList(), 109 | { 110 | role: "assistant", 111 | content: currentAssistantMessage() 112 | } 113 | ]) 114 | setCurrentAssistantMessage("") 115 | setLoading(false) 116 | setController() 117 | !isMobile() && inputRef.focus() 118 | } 119 | } 120 | 121 | async function handleButtonClick(value?: string) { 122 | const inputValue = value ?? inputContent() 123 | if (!inputValue) { 124 | return 125 | } 126 | // @ts-ignore 127 | if (window?.umami) umami.trackEvent("chat_generate") 128 | setInputContent("") 129 | if ( 130 | !value || 131 | value !== 132 | messageList() 133 | .filter(k => k.role === "user") 134 | .at(-1)?.content 135 | ) { 136 | setMessageList([ 137 | ...messageList(), 138 | { 139 | role: "user", 140 | content: inputValue 141 | } 142 | ]) 143 | } 144 | try { 145 | await fetchGPT(inputValue) 146 | } catch (error) { 147 | setLoading(false) 148 | setController() 149 | setCurrentAssistantMessage( 150 | String(error).includes("The user aborted a request") 151 | ? "" 152 | : String(error) 153 | ) 154 | } 155 | archiveCurrentMessage() 156 | } 157 | 158 | async function fetchGPT(inputValue: string) { 159 | setLoading(true) 160 | const controller = new AbortController() 161 | setController(controller) 162 | const systemRule = setting().systemRule.trim() 163 | const message = { 164 | role: "user", 165 | content: systemRule ? systemRule + "\n" + inputValue : inputValue 166 | } 167 | const response = await fetch("/api/stream", { 168 | method: "POST", 169 | body: JSON.stringify({ 170 | messages: setting().continuousDialogue 171 | ? [...messageList().slice(0, -1), message] 172 | : [message], 173 | key: setting().openaiAPIKey, 174 | temperature: setting().openaiAPITemperature / 100 175 | }), 176 | signal: controller.signal 177 | }) 178 | if (!response.ok) { 179 | throw new Error(response.statusText) 180 | } 181 | const data = response.body 182 | if (!data) { 183 | throw new Error("没有返回数据") 184 | } 185 | const reader = data.getReader() 186 | const decoder = new TextDecoder("utf-8") 187 | let done = false 188 | 189 | while (!done) { 190 | const { value, done: readerDone } = await reader.read() 191 | if (value) { 192 | let char = decoder.decode(value) 193 | if (char === "\n" && currentAssistantMessage().endsWith("\n")) { 194 | continue 195 | } 196 | if (char) { 197 | setCurrentAssistantMessage(currentAssistantMessage() + char) 198 | } 199 | } 200 | done = readerDone 201 | } 202 | } 203 | 204 | function clearSession() { 205 | // setInputContent("") 206 | setMessageList([]) 207 | setCurrentAssistantMessage("") 208 | } 209 | 210 | function stopStreamFetch() { 211 | if (controller()) { 212 | controller()?.abort() 213 | archiveCurrentMessage() 214 | } 215 | } 216 | 217 | function reAnswer() { 218 | handleButtonClick( 219 | messageList() 220 | .filter(k => k.role === "user") 221 | .at(-1)?.content 222 | ) 223 | } 224 | 225 | function selectPrompt(prompt: string) { 226 | setInputContent(prompt) 227 | setCompatiblePrompt([]) 228 | const { scrollHeight } = inputRef 229 | setHeight( 230 | `${ 231 | scrollHeight > window.innerHeight - 64 232 | ? window.innerHeight - 64 233 | : scrollHeight 234 | }px` 235 | ) 236 | inputRef.focus() 237 | } 238 | 239 | return ( 240 |
    241 | 242 | {message => ( 243 | 244 | )} 245 | 246 | {currentAssistantMessage() && ( 247 | 248 | )} 249 |
    262 | 263 | 269 | 270 | ( 273 |
    274 | AI 正在思考... 275 |
    279 | 不需要了 280 |
    281 |
    282 | )} 283 | > 284 | 285 | 289 | 290 |
    291 |