├── i18n ├── en-US │ ├── $.json │ └── _.json ├── zh-CN │ ├── $.json │ └── _.json └── README.md ├── vercel.json ├── src ├── components │ ├── ChakraUI │ │ ├── icons.ts │ │ ├── index.ts │ │ └── Provider.tsx │ ├── chatgpt │ │ ├── AiBlock.tsx │ │ ├── HumanBlock.tsx │ │ ├── ChatGPTApp.tsx │ │ ├── LoginPage.tsx │ │ └── ChatRoom.tsx │ ├── markdown │ │ ├── MermaidWrapper.tsx │ │ ├── Mermaid.tsx │ │ └── SimpleMarkdown.tsx │ ├── Highlight.tsx │ ├── CustomIcon.tsx │ ├── CopyComponent.tsx │ ├── LocaleSwitcher.tsx │ ├── SimpleColorPicker.tsx │ └── DeepDanbooru.tsx ├── assets │ ├── images │ │ ├── content.png │ │ └── chatgpt-logo.svg │ ├── icons │ │ ├── message.svg │ │ ├── new-chat.svg │ │ ├── send.svg │ │ ├── logout.svg │ │ ├── trashcan.svg │ │ ├── image-polaroid.svg │ │ ├── volume.svg │ │ └── gpt.svg │ ├── clickprompt-small.svg │ └── clickprompt-light.svg ├── i18n │ ├── pagePath.ts │ ├── en-US.ts │ ├── zh-CN.ts │ └── index.ts ├── app │ ├── [lang] │ │ ├── [...not_found] │ │ │ └── page.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ └── layout.tsx │ └── globals.css ├── storage │ └── webstorage.ts ├── utils │ ├── huggingface.txt2img.util.ts │ ├── openapi.util.ts │ ├── huggingface.space.util.ts │ ├── crypto.util.ts │ └── type.util.ts ├── configs │ └── constants.ts ├── types.d.ts ├── api │ ├── edge │ │ ├── user.ts │ │ ├── conversation.ts │ │ └── chat.ts │ ├── user.ts │ ├── chat.ts │ └── conversation.ts ├── middleware.ts └── layout │ └── NavBar.tsx ├── public ├── wechat.jpg ├── favicon │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-192x192.png │ └── favicon-512x512.png ├── sitemap.xml ├── robots.txt └── vite.svg ├── postcss.config.js ├── docs ├── TRANSLATING.md └── CONTRIBUTING.md ├── next-sitemap.config.js ├── scripts └── gen-enc.js ├── tailwind.config.js ├── .gitignore ├── tsconfig.json ├── jest.config.js ├── next.config.js ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── README.zh-CN.md ├── README.md └── package.json /i18n/en-US/$.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /i18n/zh-CN/$.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/ChakraUI/icons.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export * from "@chakra-ui/icons"; 4 | -------------------------------------------------------------------------------- /public/wechat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-diffusion/HEAD/public/wechat.jpg -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-diffusion/HEAD/public/favicon/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/assets/images/content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-diffusion/HEAD/src/assets/images/content.png -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-diffusion/HEAD/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-diffusion/HEAD/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-diffusion/HEAD/public/favicon/favicon-192x192.png -------------------------------------------------------------------------------- /public/favicon/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-engineering/chat-diffusion/HEAD/public/favicon/favicon-512x512.png -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/TRANSLATING.md: -------------------------------------------------------------------------------- 1 | # 🌐 i18n Guide 2 | 3 | Thanks for your interest in helping us translate ClickPrompt! 4 | 5 | TODO 6 | -------------------------------------------------------------------------------- /src/i18n/pagePath.ts: -------------------------------------------------------------------------------- 1 | export const hadChildRoutes = [] as string[]; 2 | 3 | export const pages = ["/"] as const; 4 | 5 | export type PagePath = (typeof pages)[number]; 6 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Allow: / 4 | 5 | # Host 6 | Host: https://www.chatvisualnovel.com/ 7 | 8 | # Sitemaps 9 | Sitemap: https://www.chatvisualnovel.com/sitemap.xml 10 | -------------------------------------------------------------------------------- /src/app/[lang]/[...not_found]/page.ts: -------------------------------------------------------------------------------- 1 | /// https://stackoverflow.com/a/75625136 2 | 3 | import { notFound } from "next/navigation"; 4 | 5 | export default function NotFoundCatchAll() { 6 | notFound(); 7 | return null; 8 | } 9 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: process.env.SITE_URL || "https://www.chatvisualnovel.com/", 4 | generateRobotsTxt: true, // (optional) 5 | // ...other options 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/chatgpt/AiBlock.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import styled from "@emotion/styled"; 4 | import { Flex } from "@/components/ChakraUI"; 5 | 6 | export const AiBlock = styled(Flex)` 7 | background-color: #fff; 8 | padding: 1rem; 9 | `; 10 | -------------------------------------------------------------------------------- /src/assets/icons/message.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/new-chat.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/chatgpt/HumanBlock.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import styled from "@emotion/styled"; 4 | import { Flex } from "@/components/ChakraUI"; 5 | 6 | export const HumanBlock = styled(Flex)` 7 | background-color: rgba(247, 247, 248); 8 | border-color: rgba(0, 0, 0, 0.1); 9 | padding: 1rem; 10 | `; 11 | -------------------------------------------------------------------------------- /src/assets/icons/send.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /scripts/gen-enc.js: -------------------------------------------------------------------------------- 1 | // node scripts/gen-enc.js 1234567890 2 | // read secret from command line 3 | let secret = process.argv[2]; 4 | // create key from secret 5 | let key = require("node:crypto") 6 | .createHash("sha256") 7 | .update(String(secret)) 8 | .digest("base64") 9 | .substr(0, 32); 10 | 11 | console.log(key); 12 | -------------------------------------------------------------------------------- /src/assets/icons/logout.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/markdown/MermaidWrapper.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | import React from "react"; 3 | 4 | export default function MermaidWrapper({ 5 | graphDefinition, 6 | }: { 7 | graphDefinition: string; 8 | }) { 9 | const MermaidDynamic = dynamic(() => import("./Mermaid"), { ssr: false }); 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/icons/trashcan.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | // generate by https://huemint.com/website-magazine/ 8 | white: "#ffffff", 9 | // Pohutukawa 10 | black: "#16245d", 11 | light: "#F8F4EA", 12 | blue: "#0A5CD6", 13 | }, 14 | }, 15 | }, 16 | plugins: [], 17 | }; 18 | -------------------------------------------------------------------------------- /src/i18n/en-US.ts: -------------------------------------------------------------------------------- 1 | import type { PagePath } from "./pagePath"; 2 | 3 | import _global from "@i18n/en-US/$.json"; 4 | import _index from "@i18n/en-US/_.json"; 5 | 6 | export type GlobalKey = keyof typeof _global; 7 | const pages = { 8 | "/": _index, 9 | } satisfies Record; 10 | export type PageKey

= keyof (typeof pages)[P]; 11 | 12 | const i18nDataEnUS = { 13 | "*": _global, 14 | ...pages, 15 | }; 16 | export default i18nDataEnUS; 17 | -------------------------------------------------------------------------------- /src/i18n/zh-CN.ts: -------------------------------------------------------------------------------- 1 | import type { PagePath } from "./pagePath"; 2 | 3 | import _global from "@i18n/zh-CN/$.json"; 4 | import _index from "@i18n/zh-CN/_.json"; 5 | 6 | export type GlobalKey = keyof typeof _global; 7 | const pages = { 8 | "/": _index, 9 | } satisfies Record; 10 | export type PageKey

= keyof (typeof pages)[P]; 11 | 12 | const i18nDataZhCN = { 13 | "*": _global, 14 | ...pages, 15 | }; 16 | export default i18nDataZhCN; 17 | -------------------------------------------------------------------------------- /src/assets/icons/image-polaroid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ChakraUI/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export { 4 | Avatar, 5 | Box, 6 | Flex, 7 | Heading, 8 | Spacer, 9 | Tooltip, 10 | Link, 11 | Breadcrumb, 12 | BreadcrumbItem, 13 | BreadcrumbLink, 14 | Button, 15 | Stack, 16 | Text, 17 | IconButton, 18 | Menu, 19 | MenuButton, 20 | MenuItem, 21 | MenuList, 22 | Input, 23 | Container, 24 | SimpleGrid, 25 | Card, 26 | CardBody, 27 | CardHeader, 28 | AlertIcon, 29 | AlertTitle, 30 | Alert, 31 | } from "@chakra-ui/react"; 32 | -------------------------------------------------------------------------------- /src/app/[lang]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /// https://stackoverflow.com/a/75625136 2 | 3 | import Link from "next/link"; 4 | 5 | export default function NotFound() { 6 | return ( 7 |

8 |

nOT foUnD – 404!

9 |
10 | 14 | Go back to Home 15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/ChakraUI/Provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { ChakraProvider, extendTheme } from "@chakra-ui/react"; 5 | 6 | export const Provider = ({ children }: { children: React.ReactNode }) => { 7 | const theme = extendTheme({ 8 | components: { 9 | Drawer: { 10 | sizes: { 11 | "2xl": { dialog: { maxW: "8xl" } }, 12 | }, 13 | }, 14 | }, 15 | }); 16 | 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /i18n/zh-CN/_.json: -------------------------------------------------------------------------------- 1 | { 2 | "select_api_type": "请选择你将如何访问 ChatGPT:", 3 | "select_api_type_note": "这个应用目前仅支持客户端模式,请确保你可以连接 OpenAI 服务器。在未经 OpenAI 许可的区域使用可能会造成封号。", 4 | "client": "客户端模式", 5 | "server": "服务器模式", 6 | "openai_api_key": "使用你的 OpenAI API key:", 7 | "huggingface_access_token": "使用你的 Hugging Face Access Token:", 8 | "sign_up": "注册", 9 | "create_new": "创建一个", 10 | "copy_paste": "输入", 11 | "go": "开始", 12 | "enter_openai_api_key": "请输入 OpenAI API key", 13 | "enter_huggingface_access_token": "请输入 Hugging Face Access Token", 14 | "select_all": "全选", 15 | "tag_prompt": "替换 prompt 并画图:" 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | pnpm-lock.yaml 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /.swc/ 15 | /out/ 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | .pnpm-debug.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | .idea 40 | 41 | /dist 42 | public/sitemap-0.xml 43 | src/assets/resources/**/*.json 44 | 45 | .vercel 46 | .env 47 | -------------------------------------------------------------------------------- /src/storage/webstorage.ts: -------------------------------------------------------------------------------- 1 | type WebStorageType = "localStorage" | "sessionStorage"; 2 | 3 | export class WebStorage { 4 | type: WebStorageType = "localStorage"; 5 | name: string; 6 | 7 | constructor(name: string, type?: WebStorageType) { 8 | this.name = name; 9 | type && (this.type = type); 10 | } 11 | 12 | get storage(): Storage { 13 | return window[this.type]; 14 | } 15 | 16 | get() { 17 | try { 18 | return JSON.parse(this.storage.getItem(this.name) ?? "") as T; 19 | } catch (e) { 20 | return null; 21 | } 22 | } 23 | 24 | set(value: T) { 25 | this.storage.setItem(this.name, JSON.stringify(value)); 26 | } 27 | 28 | remove() { 29 | this.storage.removeItem(this.name); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /i18n/en-US/_.json: -------------------------------------------------------------------------------- 1 | { 2 | "select_api_type": "Please select how would you like to access ChatGPT:", 3 | "select_api_type_note": "This application currently only support calling OpenAI directly from your browser.\nPlease make sure you can access OpenAI server.\nOpenAI may block users accessing outside of allowed areas.", 4 | "client": "Client-side", 5 | "server": "Server-side", 6 | "openai_api_key": "Use your OpenAI API key:", 7 | "huggingface_access_token": "Use your Hugging Face Access Token:", 8 | "sign_up": "Sign up for the", 9 | "create_new": "Create a new", 10 | "copy_paste": "Copy and paste", 11 | "go": "Go", 12 | "enter_openai_api_key": "Please enter your OpenAI API key.", 13 | "enter_huggingface_access_token": "Please enter your Hugging Face access token", 14 | "select_all": "Select all", 15 | "tag_prompt": "Draw with prompt: " 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/huggingface.txt2img.util.ts: -------------------------------------------------------------------------------- 1 | import { HUGGINGFACE_INFERENCE_URL } from "@/configs/constants"; 2 | 3 | export async function drawImage( 4 | token: string, 5 | model: string, 6 | prompt: string, 7 | negative_prompt?: string, 8 | wait_for_model?: boolean 9 | ) { 10 | const payload = { 11 | inputs: prompt, 12 | parameters: { 13 | negative_prompt: negative_prompt ? [negative_prompt] : undefined, 14 | num_images_per_prompt: 1, 15 | }, 16 | options: {}, 17 | }; 18 | if (wait_for_model) 19 | payload.options = { 20 | wait_for_model: true, 21 | }; 22 | return fetch(`${HUGGINGFACE_INFERENCE_URL}/models/${model}`, { 23 | method: "POST", 24 | cache: "no-cache", 25 | headers: { 26 | "Content-Type": "application/json", 27 | Authorization: "Bearer " + token, 28 | }, 29 | body: JSON.stringify(payload), 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": ".", // This has to be specified if "paths" is. 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "target": "ES2020", 6 | "lib": ["dom", "dom.iterable", "ES2021.String", "esnext"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": ["./src/*"], 26 | "@i18n/*": ["./i18n/*"] 27 | } 28 | }, 29 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Highlight.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | /** 4 | * Hight light keywords in paragraph 5 | */ 6 | export default function Highlight({ 7 | value, 8 | keyword, 9 | }: { 10 | value: string; 11 | keyword: string; 12 | }) { 13 | if ( 14 | !( 15 | value != undefined && 16 | keyword != undefined && 17 | value.length > 0 && 18 | keyword.length > 0 19 | ) 20 | ) { 21 | return value; 22 | } 23 | const regex = new RegExp(keyword, "gi"); 24 | 25 | return value 26 | .split(regex) 27 | .reduce((acc: any, part: string, i: number) => { 28 | if (i === 0) { 29 | return [part]; 30 | } 31 | return acc.concat( 32 | 33 | {keyword} 34 | , 35 | part 36 | ); 37 | }, []) 38 | .map((part: React.ReactNode, i: number) => ( 39 | {part} 40 | )); 41 | } 42 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | const nextJest = require("next/jest"); 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: "./", 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | /** @type {import('jest').Config} */ 11 | const customJestConfig = { 12 | // Add more setup options before each test is run 13 | // setupFilesAfterEnv: ['/jest.setup.js'], 14 | testEnvironment: "jest-environment-jsdom", 15 | moduleNameMapper: { 16 | "^jsonpath-plus": require.resolve("jsonpath-plus"), 17 | "^lodash-es$": "lodash", 18 | "^@/(.*)": "/src/$1", 19 | }, 20 | transformIgnorePatterns: [ 21 | "/node_modules/", 22 | "^.+\\.module\\.(css|sass|scss)$", 23 | ], 24 | }; 25 | 26 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 27 | module.exports = createJestConfig(customJestConfig); 28 | -------------------------------------------------------------------------------- /src/assets/icons/volume.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | appDir: true, 6 | // TODO https://beta.nextjs.org/docs/configuring/typescript#statically-typed-links 7 | // typedRoutes: true, 8 | }, 9 | trailingSlash: true, 10 | transpilePackages: ["react-syntax-highlighter"], 11 | images: { 12 | domains: ["prompt-engineering.github.io"], 13 | }, 14 | webpack: (config, options) => { 15 | config.module.rules.push({ 16 | test: /\.yml/, 17 | use: "yaml-loader", 18 | }); 19 | 20 | config.module.rules.push({ 21 | test: /\.svg$/i, 22 | type: "asset", 23 | resourceQuery: /url/, // *.svg?url 24 | }); 25 | 26 | config.module.rules.push({ 27 | test: /\.svg$/i, 28 | issuer: /\.[jt]sx?$/, 29 | resourceQuery: { not: [/url/] }, // exclude react component if *.svg?url 30 | use: ["@svgr/webpack"], 31 | }); 32 | 33 | return config; 34 | }, 35 | }; 36 | 37 | module.exports = nextConfig; 38 | -------------------------------------------------------------------------------- /src/components/CustomIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "next/image"; 3 | 4 | import chatgptLogo from "@/assets/images/chatgpt-logo.svg?url"; 5 | import clickPromptLogo from "@/assets/clickprompt-light.svg?url"; 6 | import clickPromptSmall from "@/assets/clickprompt-small.svg?url"; 7 | 8 | export function ChatGptIcon({ width = 32, height = 32 }) { 9 | return ( 10 | ChatGPT Logo 11 | ); 12 | } 13 | 14 | export function ClickPromptIcon({ width = 32, height = 32 }) { 15 | return ( 16 | ClickPrompt Logo 23 | ); 24 | } 25 | 26 | export function ClickPromptSmall({ width = 32, height = 32 }) { 27 | return ( 28 | ClickPrompt Logo 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/[lang]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { getAppData } from "@/i18n"; 3 | import { cookies } from "next/headers"; 4 | import { SITE_USER_COOKIE } from "@/configs/constants"; 5 | import * as UserAPI from "@/api/user"; 6 | import { ChatGPTApp } from "@/components/chatgpt/ChatGPTApp"; 7 | 8 | async function Page() { 9 | const { locale, pathname, i18n } = await getAppData(); 10 | const i18nProps: GeneralI18nProps = { 11 | locale, 12 | pathname, 13 | i18n: { 14 | dict: i18n.dict, 15 | }, 16 | }; 17 | 18 | const hashedKey = cookies().get(SITE_USER_COOKIE)?.value as string; 19 | 20 | let isLogin: boolean; 21 | try { 22 | isLogin = await UserAPI.isLoggedIn(hashedKey); 23 | } catch (e) { 24 | console.error(e); 25 | isLogin = false; 26 | } 27 | 28 | return ( 29 |
30 | 31 |
32 | ); 33 | } 34 | 35 | export default Page; 36 | -------------------------------------------------------------------------------- /src/configs/constants.ts: -------------------------------------------------------------------------------- 1 | export const SITE_TITLE = "ClickPrompt"; 2 | export const SITE_URL = "https://www.chatvisualnovel.com/"; 3 | export const SITE_LOCALE_COOKIE = "CLICKPROMPT_LOCALE"; 4 | export const SITE_USER_COOKIE = "CLICKPROMPT_USER"; 5 | export const GITHUB_URL = 6 | "https://github.com/prompt-engineering/chat-diffusion"; 7 | export const CP_GITHUB_ASSETS = `${GITHUB_URL}/tree/master/src/assets/`; 8 | export const SITE_INTERNAL_HEADER_URL = "$$$x-url"; 9 | export const SITE_INTERNAL_HEADER_PATHNAME = "$$$x-pathname"; 10 | export const SITE_INTERNAL_HEADER_LOCALE = "$$$x-locale"; 11 | export const CHAT_COMPLETION_URL = "https://api.openai.com/v1/chat/completions"; 12 | export const CHAT_COMPLETION_CONFIG = { 13 | model: "gpt-3.5-turbo", 14 | temperature: 0.5, 15 | max_tokens: 512, 16 | }; 17 | export const HUGGINGFACE_INFERENCE_URL = "https://api-inference.huggingface.co"; 18 | export const HUGGINGFACE_DEFAULT_STABLE_DIFFUSION_MODEL = 19 | "prompthero/openjourney"; 20 | export const HUGGINGFACE_DEEPDANBOORU_SPACE_URL = 21 | "wss://hysts-deepdanbooru.hf.space/queue/join"; 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [dev, master] 6 | pull_request: 7 | branches: [dev] 8 | 9 | jobs: 10 | build: 11 | name: Build & Test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: ["lts/gallium", "lts/hydrogen", "current"] 16 | steps: 17 | - name: Checkout 🛎️ 18 | uses: actions/checkout@v3 19 | with: 20 | persist-credentials: false 21 | 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: 16 25 | 26 | - run: npm ci 27 | 28 | - run: npm run test 29 | 30 | - run: npm run build --if-present 31 | lint: 32 | name: format and lint 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 🛎️ 36 | uses: actions/checkout@v3 37 | with: 38 | persist-credentials: false 39 | 40 | - uses: actions/setup-node@v3 41 | with: 42 | node-version: 16 43 | - run: npm ci 44 | 45 | - run: npm run format 46 | 47 | - run: npm run lint 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Prompt Engineering 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 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 | -------------------------------------------------------------------------------- /src/components/CopyComponent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CopyToClipboard } from "react-copy-to-clipboard"; 4 | import { CopyIcon } from "@chakra-ui/icons"; 5 | import React from "react"; 6 | import { Tooltip, useToast } from "@chakra-ui/react"; 7 | 8 | type CopyProps = { 9 | value: string; 10 | boxSize?: number; 11 | className?: string; 12 | children?: React.ReactNode; 13 | }; 14 | 15 | function CopyComponent({ 16 | value, 17 | className = "", 18 | children, 19 | boxSize = 8, 20 | }: CopyProps) { 21 | const toast = useToast(); 22 | return ( 23 |
24 | { 27 | toast({ 28 | title: "Copied to clipboard", 29 | position: "top", 30 | status: "success", 31 | }); 32 | }} 33 | > 34 |
35 | {children ? children : ""} 36 | 37 | 38 | 39 |
40 |
41 |
42 | ); 43 | } 44 | 45 | export default CopyComponent; 46 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "color-name-list" { 2 | interface ColorName { 3 | name: string; 4 | hex: string; 5 | } 6 | const colorNameList: ColorName[]; 7 | export = colorNameList; 8 | } 9 | 10 | declare module "nearest-color" { 11 | interface RGB { 12 | r: number; 13 | g: number; 14 | b: number; 15 | } 16 | interface ColorSpec { 17 | name: string; 18 | value: string; 19 | rgb: RGB; 20 | distance: number; 21 | } 22 | interface ColorMatcher extends NearestColor { 23 | (needle: RGB | string): ColorSpec; 24 | or: (alternateColors: string[] | Record) => ColorMatcher; 25 | } 26 | 27 | interface NearestColor { 28 | (needle: RGB | string, colors?: ColorSpec[]): string; 29 | from: (availableColors: string[] | Record) => ColorMatcher; 30 | } 31 | 32 | const nearestColor: NearestColor; 33 | 34 | export default nearestColor; 35 | } 36 | 37 | declare module "*.svg?url" { 38 | const content: string; 39 | export default content; 40 | } 41 | 42 | type GeneralI18nProps = { 43 | i18n: { 44 | dict: import("@/i18n/index").AppData["i18n"]["dict"]; 45 | }; 46 | locale: import("@/i18n").SupportedLocale; 47 | pathname: string; 48 | }; 49 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # ChatDiffusion - 集成了在线 AI 绘画功能的 ChatGPT UI 2 | 3 | [![ci](https://github.com/prompt-engineering/chat-diffusion/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-engineering/chat-diffusion/actions/workflows/ci.yml) 4 | ![GitHub](https://img.shields.io/github/license/prompt-engineering/chat-diffusion) 5 | 6 | [English](./README.md) | 简体中文 7 | 8 | ![截图](https://raw.githubusercontent.com/tianweiliu/.github/main/chatdiffusion.png) 9 | 10 | 演示:https://chat.fluoritestudio.com 11 | 12 | ## 目前仅支持客户端(浏览器)访问 OpenAI,服务器端调用正在开发中 13 | 14 | ## 集成的在线服务: 15 | 16 | - [x] Hugging Face [Inference API](https://huggingface.co/inference-api) 用于文字生成图像 17 | - [x] [prompthero/openjourney](https://huggingface.co/prompthero/openjourney) 作为默认的 Stable Diffusion 模型, 你可以让 ChatGPT 把 "model" 换成 Hugging Face 上任意开启了 Inference API 的模型。 18 | - [ ] Hugging Face Space 集成,用于图像转文字 19 | - [x] [DeepDanbooru](https://huggingface.co/spaces/hysts/DeepDanbooru) (开发中) 20 | 21 | ## 本地搭建 22 | 23 | 1. 从 GitHub 克隆 [ChatVisualNovel](https://github.com/prompt-engineering/chat-diffusion)。 24 | 2. 执行 `npm install`。 25 | 3. 直接运行 `npm run dev` 就可以使用了。 26 | 27 | ## LICENSE 28 | 29 | This code is distributed under the MIT license. See [LICENSE](./LICENSE) in this directory. 30 | -------------------------------------------------------------------------------- /src/utils/openapi.util.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, OpenAIApi, type ConfigurationParameters } from "openai"; 2 | async function getConfig(apiKey: string) { 3 | const baseConf: ConfigurationParameters = { 4 | apiKey, 5 | }; 6 | // FIXME now just for development 7 | if ( 8 | process.env.NODE_ENV === "development" && 9 | process.env.PROXY_HOST && 10 | process.env.PROXY_PORT 11 | ) { 12 | const { httpsOverHttp } = await import("tunnel"); 13 | const tunnel = httpsOverHttp({ 14 | proxy: { 15 | host: process.env.PROXY_HOST, 16 | port: process.env.PROXY_PORT as unknown as number, 17 | }, 18 | }); 19 | baseConf.baseOptions = { 20 | httpsAgent: tunnel, 21 | proxy: false, 22 | }; 23 | } 24 | return baseConf; 25 | } 26 | 27 | async function createNewOpenAIApi(apiKey: string) { 28 | const conf = await getConfig(apiKey); 29 | const configuration = new Configuration(conf); 30 | 31 | return new OpenAIApi(configuration); 32 | } 33 | 34 | const chatClients = new Map(); 35 | 36 | export async function getChatClient(keyHashed: string, apiKey: string) { 37 | const chatClient = 38 | chatClients.get(keyHashed) || (await createNewOpenAIApi(apiKey)); 39 | chatClients.set(keyHashed, chatClient); 40 | return chatClient; 41 | } 42 | -------------------------------------------------------------------------------- /src/api/edge/user.ts: -------------------------------------------------------------------------------- 1 | import { WebStorage } from "@/storage/webstorage"; 2 | 3 | export function isClientSideOpenAI() { 4 | if (typeof window !== "undefined" && typeof document !== "undefined") { 5 | // Client-side 6 | // TODO: Hardcode to true as server-side is not working yet. 7 | return true; 8 | // const _storage = new WebStorage("o:t", "sessionStorage"); 9 | // const _type = _storage.get(); 10 | // return _type && _type == "client" ? true : false; 11 | } 12 | return false; 13 | } 14 | 15 | export function getApiKey() { 16 | const _apiKeyRepo = new WebStorage("o:a", "sessionStorage"); 17 | const _apiKey = _apiKeyRepo.get(); 18 | return _apiKey; 19 | } 20 | 21 | export function getToken() { 22 | const _tokenRepo = new WebStorage("h:t", "sessionStorage"); 23 | const _token = _tokenRepo.get(); 24 | return _token; 25 | } 26 | 27 | export function saveApiKey(apiKey: string, token: string) { 28 | const _apiKeyRepo = new WebStorage("o:a", "sessionStorage"); 29 | _apiKeyRepo.set(apiKey); 30 | const _tokenRepo = new WebStorage("h:t", "sessionStorage"); 31 | _tokenRepo.set(token); 32 | return true; 33 | } 34 | 35 | export function logout() { 36 | window.sessionStorage.removeItem("o:a"); 37 | return { message: "Logged out" }; 38 | } 39 | -------------------------------------------------------------------------------- /src/assets/clickprompt-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/chatgpt/ChatGPTApp.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { isClientSideOpenAI, getApiKey, getToken } from "@/api/edge/user"; 4 | import { ChatRoom } from "@/components/chatgpt/ChatRoom"; 5 | import { LoginPage } from "@/components/chatgpt/LoginPage"; 6 | import React, { useEffect, useState } from "react"; 7 | 8 | type ChatGPTAppProps = { 9 | dict: Record; 10 | loggedIn?: boolean; 11 | updateLoginStatus?: (loggedIn: boolean) => void; 12 | initMessage?: string; 13 | }; 14 | export const ChatGPTApp = ({ 15 | dict, 16 | loggedIn, 17 | initMessage, 18 | updateLoginStatus, 19 | }: ChatGPTAppProps) => { 20 | const [isLoggedIn, setIsLoggedIn] = useState(loggedIn ?? false); 21 | 22 | useEffect(() => { 23 | if (isClientSideOpenAI()) { 24 | let _isLoggedin = getApiKey() && getToken() ? true : false; 25 | if (isLoggedIn != _isLoggedin) { 26 | setIsLoggedIn(_isLoggedin); 27 | if (updateLoginStatus) { 28 | updateLoginStatus(_isLoggedin); 29 | } 30 | return; 31 | } 32 | } 33 | if (updateLoginStatus) { 34 | updateLoginStatus(isLoggedIn); 35 | } 36 | }, [isLoggedIn]); 37 | 38 | return isLoggedIn ? ( 39 | 44 | ) : ( 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Manual 2 | 3 | We welcome contributions of any size and skill level. As an open source project, we believe in giving back to our contributors and are happy to help with guidance on PRs, technical writing, and turning any feature idea into a reality. 4 | 5 | > **Tip for new contributors:** 6 | > Take a look at [https://github.com/firstcontributions/first-contributions](https://github.com/firstcontributions/first-contributions) for helpful information on contributing 7 | 8 | ## Quick Guide 9 | 10 | ### Prerequisite 11 | 12 | ```shell 13 | node: ">=16.0.0" 14 | npm: "^8.11.0" 15 | # otherwise, your build will fail 16 | ``` 17 | 18 | ### Setting up your local repo 19 | 20 | ```shell 21 | git clone && cd ... 22 | npm install 23 | npm run build 24 | ``` 25 | 26 | ### Development 27 | 28 | ```shell 29 | # starts a file-watching, live-reloading dev script for active development 30 | npm run dev 31 | # build the entire project, one time. 32 | npm run build 33 | ``` 34 | 35 | ### Running tests 36 | 37 | ```shell 38 | # run this in the top-level project root to run all tests 39 | npm run test 40 | ``` 41 | 42 | ### Making a Pull Request 43 | 44 | You can run the following commands before making a Pull Request 45 | 46 | ```shell 47 | # format with fix 48 | npm run format:fix 49 | # lint with fix 50 | npm run lint:fix 51 | ``` 52 | 53 | ## Code Structure 54 | 55 | TODO 56 | 57 | ## Translation 58 | 59 | See [i18n guide](TRANSLATING.md) 60 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/huggingface.space.util.ts: -------------------------------------------------------------------------------- 1 | import { HUGGINGFACE_DEEPDANBOORU_SPACE_URL } from "@/configs/constants"; 2 | import { DeepDanbooruTag } from "./type.util"; 3 | 4 | function randomhash(length: number): string { 5 | const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; 6 | let result = ""; 7 | for (let i = 0; i < length; i++) { 8 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 9 | } 10 | return result; 11 | } 12 | 13 | export async function getTags(image: string): Promise { 14 | const hash = randomhash(12); 15 | const send_hash = { 16 | fn_index: 0, 17 | session_hash: hash, 18 | }; 19 | const img_data = { 20 | fn_index: 0, 21 | data: [image, 0.5], 22 | session_hash: hash, 23 | }; 24 | const socket = new WebSocket(HUGGINGFACE_DEEPDANBOORU_SPACE_URL); 25 | return new Promise((resolve, reject) => { 26 | socket.onerror = (event: Event) => { 27 | reject(new Error(`WebSocket error: ${event}`)); 28 | }; 29 | socket.onmessage = async (event: MessageEvent) => { 30 | const data = JSON.parse(event.data); 31 | if (data["msg"] === "send_hash") { 32 | socket.send(JSON.stringify(send_hash)); 33 | } else if (data["msg"] === "send_data") { 34 | socket.send(JSON.stringify(img_data)); 35 | } else if (data["msg"] === "process_completed") { 36 | const tags = data["output"]["data"][0]["confidences"]; 37 | resolve(tags); 38 | } 39 | }; 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/crypto.util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createCipheriv, 3 | createDecipheriv, 4 | randomBytes, 5 | createHash, 6 | } from "node:crypto"; 7 | 8 | if (!process.env["ENC_KEY"]) { 9 | // for skip CI 10 | // throw Error("No secret key env in the server."); 11 | console.log("No secret key env in the server."); 12 | } 13 | 14 | const hasher = createHash("sha256"); 15 | const secret = process.env["ENC_KEY"] || ""; 16 | function genIV() { 17 | return Buffer.from(randomBytes(16)); 18 | } 19 | 20 | function encrypt(data: string, secret: string, iv: Buffer) { 21 | const cipher = createCipheriv("aes-256-cbc", secret, iv); 22 | let encrypted = cipher.update(data, "utf8", "hex"); 23 | encrypted += cipher.final("hex"); 24 | return encrypted; 25 | } 26 | 27 | function decrypt(encrypted: string, secret: string, iv: string) { 28 | const ivBuffer = Buffer.from(iv, "hex"); 29 | const decipher = createDecipheriv("aes-256-cbc", secret, ivBuffer); 30 | let decrypted = decipher.update(encrypted, "hex", "utf8"); 31 | decrypted += decipher.final("utf8"); 32 | return decrypted; 33 | } 34 | 35 | export function hashedKey(key: string) { 36 | return hasher.copy().update(key).digest().toString("hex"); 37 | } 38 | 39 | export function encryptedKey(key: string) { 40 | const iv = genIV(); 41 | const key_encrypted = encrypt(key, secret, iv); 42 | return { 43 | iv, 44 | key_encrypted, 45 | }; 46 | } 47 | 48 | export function decryptKey(encryptedKey: string, iv: string) { 49 | return decrypt(encryptedKey, secret, iv); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | /* good looking scrollbar */ 7 | .overflow-container::-webkit-scrollbar { 8 | width: 8px; 9 | } 10 | 11 | .overflow-container::-webkit-scrollbar-track { 12 | background: #f1f1f1; 13 | } 14 | 15 | .overflow-container::-webkit-scrollbar-thumb { 16 | background: #888; 17 | } 18 | 19 | .overflow-container::-webkit-scrollbar-thumb:hover { 20 | background: #555; 21 | } 22 | } 23 | 24 | #root { 25 | margin: 0 auto; 26 | text-align: center; 27 | } 28 | 29 | code { 30 | text-shadow: none !important; 31 | } 32 | 33 | .logo { 34 | height: 6em; 35 | padding: 1.5em; 36 | will-change: filter; 37 | transition: filter 300ms; 38 | } 39 | .logo:hover { 40 | filter: drop-shadow(0 0 2em #646cffaa); 41 | } 42 | .logo.react:hover { 43 | filter: drop-shadow(0 0 2em #61dafbaa); 44 | } 45 | 46 | @keyframes logo-spin { 47 | from { 48 | transform: rotate(0deg); 49 | } 50 | to { 51 | transform: rotate(360deg); 52 | } 53 | } 54 | 55 | @media (prefers-reduced-motion: no-preference) { 56 | a:nth-of-type(2) .logo { 57 | animation: logo-spin infinite 20s linear; 58 | } 59 | } 60 | 61 | .card { 62 | padding: 2em; 63 | } 64 | 65 | .read-the-docs { 66 | color: #888; 67 | } 68 | 69 | ul, 70 | ul li, 71 | p { 72 | text-align: left; 73 | } 74 | /* custom grid-cols */ 75 | .grid-cols-\[1rem_1fr\] { 76 | grid-template-columns: 1rem 1fr; 77 | } 78 | .grid-cols-\[200px_1fr\] { 79 | grid-template-columns: 200px 1fr; 80 | } 81 | -------------------------------------------------------------------------------- /i18n/README.md: -------------------------------------------------------------------------------- 1 | # i18n files 2 | 3 | Inside this folder, the first folder level is locale code such as `en-US`, and in it has A LOT of json files the naming convention is: 4 | 5 | - Global data is in the `$.json` file. 6 | - For specific page data: 7 | - index page is corresponding to `_.json` file 8 | - other pages just use pathname without trailing slash and locale segment, and replace all `/` with `_`(cause in some filesystem `/` is illegal charactor in pathname). such as `_foo.json` for `/foo/`, `_foo_bar.json` for `/foo/bar/` . I think you get the idea. 9 | 10 | # HOW TO USE IN RSC(React server component) 11 | 12 | ```typescript jsx 13 | // page.server.tsx 14 | import { getAppData } from "@/i18n"; 15 | import CSC from "./component.client.tsx"; 16 | 17 | async function RscFoo() { 18 | // ... 19 | const { locale, pathname, i18n } = await getAppData(); 20 | const t = i18n.tFactory("/"); 21 | // t is a function takes key and give you value in the json file 22 | t("title"); // will be "Streamline your prompt design" 23 | 24 | // you can also access global data by 25 | const g = i18n.g; 26 | 27 | const i18nProps: GeneralI18nProps = { 28 | locale, 29 | pathname, 30 | i18n: { 31 | dict: i18n.dict, 32 | }, 33 | }; 34 | 35 | // use i18n in CSC (client side component) 36 | return ; 37 | // ... 38 | } 39 | ``` 40 | 41 | ```typescript jsx 42 | // component.client.tsx 43 | "use client"; 44 | 45 | export default function CSC({ i18n }: GeneralI18nProps) { 46 | const { dict } = i18n; 47 | 48 | // use dict like plain object here 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /src/app/[lang]/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/app/globals.css"; 2 | import React from "react"; 3 | import Image from "next/image"; 4 | import NavBar from "@/layout/NavBar"; 5 | import { Container } from "@/components/ChakraUI"; 6 | import { Provider } from "@/components/ChakraUI/Provider"; 7 | import { getAppData } from "@/i18n"; 8 | 9 | type RootLayoutProps = { 10 | params: { 11 | lang: string; 12 | }; 13 | children: React.ReactNode; 14 | }; 15 | export default async function RootLayout({ 16 | params, 17 | children, 18 | }: RootLayoutProps) { 19 | const { lang } = params; 20 | const { locale, pathname, i18n } = await getAppData(); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | ChatDiffusion 29 | 30 | 34 | 35 | 36 | 37 | {/* https://github.com/vercel/next.js/issues/42292 */} 38 |
39 | {/* @ts-expect-error Async Server Component */} 40 | 41 |
42 | 47 | {children} 48 | 49 |
50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatDiffusion - A ChatGPT web UI that integrates with variety of online Stable Diffusion services 2 | 3 | [![ci](https://github.com/prompt-engineering/chat-diffusion/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-engineering/chat-diffusion/actions/workflows/ci.yml) 4 | ![GitHub](https://img.shields.io/github/license/prompt-engineering/chat-diffusion) 5 | [![Discord](https://img.shields.io/discord/1082563233593966612)](https://discord.gg/FSWXq4DmEj) 6 | 7 | English | [简体中文](./README.zh-CN.md) 8 | 9 | ![Screenshot](https://raw.githubusercontent.com/tianweiliu/.github/main/chatdiffusion.png) 10 | 11 | Online Demo: [https://chat.fluoritestudio.com](https://chat.fluoritestudio.com) 12 | 13 | Join us: 14 | 15 | [![Chat Server](https://img.shields.io/badge/chat-discord-7289da.svg)](https://discord.gg/FSWXq4DmEj) 16 | 17 | ## Only support client-side (browser) call to OpenAI at this moment. Server-side WIP. 18 | 19 | ## Supported online services: 20 | 21 | - [x] Hugging Face [Inference API](https://huggingface.co/inference-api) for Text to Image 22 | - [x] Using [prompthero/openjourney](https://huggingface.co/prompthero/openjourney) as default Stable Diffusion model, you can ask ChatGPT to change the "model" value in JSON to any model hosted on Hugging Face that has public inference API enabled. 23 | - [ ] Hugging Face Space integration for Image to Text 24 | - [x] [DeepDanbooru](https://huggingface.co/spaces/hysts/DeepDanbooru) (WIP) 25 | 26 | ## Local Usage 27 | 28 | 1. Clone the [ChatDiffusion](https://github.com/prompt-engineering/chat-diffusion) from GitHub. 29 | 2. Run `npm install`. 30 | 3. You can now use the application by running `npm run dev`. 31 | 32 | ## LICENSE 33 | 34 | This code is distributed under the MIT license. See [LICENSE](./LICENSE) in this directory. 35 | -------------------------------------------------------------------------------- /src/assets/clickprompt-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/LocaleSwitcher.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SITE_LOCALE_COOKIE } from "@/configs/constants"; 4 | import { 5 | Box, 6 | Menu, 7 | MenuButton, 8 | MenuList, 9 | MenuItem, 10 | } from "@/components/ChakraUI"; 11 | import { ChevronDownIcon } from "@/components/ChakraUI/icons"; 12 | 13 | const options = [ 14 | { 15 | value: "zh-CN", 16 | label: "中文", 17 | }, 18 | { 19 | value: "en-US", 20 | label: "English", 21 | }, 22 | ]; 23 | export default function LocaleSwitcher({ locale }: { locale: string }) { 24 | const classZh = locale === "zh-CN" ? "text-blue-500" : "text-gray-500"; 25 | const classEn = locale === "en-US" ? "text-blue-500" : "text-gray-500"; 26 | function setEn() { 27 | document.cookie = `${SITE_LOCALE_COOKIE}=en-US;path=/;max-age=31536000;`; 28 | window.location.reload(); 29 | } 30 | 31 | function setZh() { 32 | document.cookie = `${SITE_LOCALE_COOKIE}=zh-CN;path=/;max-age=31536000;`; 33 | window.location.reload(); 34 | } 35 | 36 | return ( 37 |
38 | 39 | 40 | {locale === "zh-CN" ? "中文" : "English"} 41 | 42 | 43 | 44 | {options.map((child) => ( 45 | (child.value === "zh-CN" ? setZh() : setEn())} 49 | > 50 | 55 | {child.label} 56 | 57 | 58 | ))} 59 | 60 | 61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/type.util.ts: -------------------------------------------------------------------------------- 1 | import { ChatCompletionRequestMessage } from "openai"; 2 | 3 | export type RequestSend = { 4 | action: "send"; 5 | conversation_id: number; 6 | messages: ChatCompletionRequestMessage[]; 7 | }; 8 | export type ResponseSend = { 9 | id: number | undefined; 10 | conversation_id: number; 11 | role: string; 12 | content: string; 13 | name: string | undefined; 14 | created_at: string | undefined; 15 | }[]; 16 | 17 | export type RequestGetChats = { 18 | action: "get_chats"; 19 | conversation_id: number; 20 | }; 21 | 22 | export type ResponseGetChats = { 23 | id: number | undefined; 24 | conversation_id: number; 25 | role: string; 26 | content: string; 27 | name: string | undefined; 28 | created_at: string | undefined; 29 | }[]; 30 | 31 | export type RequestCreateConversation = { 32 | action: "create_conversation"; 33 | name: string; 34 | }; 35 | 36 | export type ResponseCreateConversation = 37 | | { 38 | id: number | undefined; 39 | name: string; 40 | created_at: string | undefined; 41 | user_id: number; 42 | deleted: number | undefined; 43 | } 44 | | null 45 | | undefined; 46 | 47 | export type RequestGetConversations = { 48 | action: "get_conversations"; 49 | }; 50 | 51 | export type ResponseGetConversations = { 52 | id: number | undefined; 53 | name: string; 54 | created_at: string | undefined; 55 | user_id: number; 56 | }[]; 57 | 58 | export type RequestChangeConversationName = { 59 | action: "change_conversation_name"; 60 | conversation_id: number; 61 | name: string; 62 | }; 63 | 64 | export type RequestDeleteConversation = { 65 | action: "delete_conversation"; 66 | conversation_id: number; 67 | }; 68 | 69 | export type RequestDeleteAllConversation = { 70 | action: "delete_all_conversations"; 71 | }; 72 | export type ResponseDeleteAllConversation = { 73 | message?: string; 74 | error?: string; 75 | }; 76 | 77 | export type DeepDanbooruTag = { 78 | label: string; 79 | confidence: number; 80 | }; 81 | -------------------------------------------------------------------------------- /src/api/user.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { SITE_INTERNAL_HEADER_URL } from "@/configs/constants"; 3 | import * as EdgeUser from "./edge/user"; 4 | 5 | export async function logout() { 6 | if (EdgeUser.isClientSideOpenAI()) return EdgeUser.logout(); 7 | const response = await fetch("/api/chatgpt/user", { 8 | method: "POST", 9 | body: JSON.stringify({ 10 | action: "logout", 11 | }), 12 | }); 13 | return response.json(); 14 | } 15 | 16 | export async function login(key: string, token: string) { 17 | if (EdgeUser.isClientSideOpenAI()) return EdgeUser.saveApiKey(key, token); 18 | const response = await fetch("/api/chatgpt/user", { 19 | method: "POST", 20 | body: JSON.stringify({ 21 | action: "login", 22 | key, 23 | }), 24 | }).then((it) => it.json()); 25 | 26 | if ((response as any).error) { 27 | alert("Error(login): " + JSON.stringify((response as any).error)); 28 | return; 29 | } 30 | 31 | return response; 32 | } 33 | 34 | export async function isLoggedIn(hashedKey?: string) { 35 | if (typeof window !== "undefined" && typeof document !== "undefined") { 36 | // Client-side 37 | if (EdgeUser.isClientSideOpenAI()) 38 | return EdgeUser.getApiKey() && EdgeUser.getToken() ? true : false; 39 | const response = await fetch("/api/chatgpt/verify", { 40 | method: "POST", 41 | body: hashedKey ?? "NOPE", 42 | }).then((it) => it.json()); 43 | 44 | return (response as any).loggedIn; 45 | } 46 | 47 | // const { headers } = await import("next/headers"); 48 | // const urlStr = headers().get(SITE_INTERNAL_HEADER_URL) as string; 49 | // // Propagate cookies to the API route 50 | // const headersPropagated = { cookie: headers().get("cookie") as string }; 51 | // const response = await fetch( 52 | // new URL("/api/chatgpt/verify", new URL(urlStr)), 53 | // { 54 | // method: "POST", 55 | // body: hashedKey ?? "NOPE", 56 | // headers: headersPropagated, 57 | // redirect: "follow", 58 | // } 59 | // ).then((it) => it.json()); 60 | // return (response as any).loggedIn; 61 | } 62 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextMiddleware, NextResponse } from "next/server"; 2 | import { 3 | SupportedLocales, 4 | getLocale, 5 | replaceRouteLocale, 6 | getLocaleFromPath, 7 | SupportedLocale, 8 | } from "@/i18n"; 9 | import { 10 | SITE_INTERNAL_HEADER_LOCALE, 11 | SITE_INTERNAL_HEADER_PATHNAME, 12 | SITE_INTERNAL_HEADER_URL, 13 | SITE_LOCALE_COOKIE, 14 | } from "@/configs/constants"; 15 | 16 | export const middleware: NextMiddleware = (request) => { 17 | // Check if there is any supported locale in the pathname 18 | const pathname = request.nextUrl.pathname; 19 | const pathnameIsMissingLocale = SupportedLocales.every( 20 | (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}` 21 | ); 22 | 23 | let locale = getLocale(request.headers); 24 | 25 | const cookie = request.cookies.get(SITE_LOCALE_COOKIE)?.value; 26 | // If there is a cookie, and it is a supported locale, use it 27 | if (SupportedLocales.includes(cookie as unknown as SupportedLocale)) { 28 | locale = cookie as unknown as SupportedLocale; 29 | } 30 | 31 | // Redirect if there is no locale 32 | if (pathnameIsMissingLocale) { 33 | // e.g. incoming request is /products 34 | // The new URL is now /en-US/products 35 | return NextResponse.redirect( 36 | new URL(`/${locale}/${pathname}`, request.url) 37 | ); 38 | } else if (getLocaleFromPath(pathname) !== locale) { 39 | return NextResponse.redirect( 40 | new URL(replaceRouteLocale(pathname, locale), request.url) 41 | ); 42 | } 43 | 44 | // ref: https://github.com/vercel/next.js/issues/43704#issuecomment-1411186664 45 | // for server component to access url and pathname 46 | // Store current request url in a custom header, which you can read later 47 | const requestHeaders = new Headers(request.headers); 48 | requestHeaders.set(SITE_INTERNAL_HEADER_URL, request.url); 49 | requestHeaders.set(SITE_INTERNAL_HEADER_PATHNAME, request.nextUrl.pathname); 50 | requestHeaders.set(SITE_INTERNAL_HEADER_LOCALE, locale); 51 | 52 | return NextResponse.next({ 53 | request: { 54 | // Apply new request headers 55 | headers: requestHeaders, 56 | }, 57 | }); 58 | }; 59 | 60 | export const config = { 61 | matcher: [ 62 | // Skip all internal paths (_next) 63 | "/((?!_next|favicon|api).*)", 64 | // Optional: only run on root (/) URL 65 | // '/' 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/SimpleColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { ChromePicker, ColorResult } from "react-color"; 3 | import styled from "@emotion/styled"; 4 | import nearestColor from "nearest-color"; 5 | import colorNameList from "color-name-list"; 6 | 7 | export enum ColorType { 8 | HumanSkin = "HumanSkin", 9 | Normal = "Normal", 10 | } 11 | 12 | type SimpleColorProps = { 13 | colorType?: ColorType; 14 | initColor?: string; 15 | updateColor?: (color: string) => void; 16 | }; 17 | 18 | const colorNameMap: Record = colorNameList.reduce( 19 | (o, { name, hex }) => Object.assign(o, { [name]: hex }), 20 | {} 21 | ); 22 | const nearest = nearestColor.from(colorNameMap); 23 | const hexToRgbString = (hex: string) => { 24 | const { rgb } = nearest(hex); 25 | return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; 26 | }; 27 | const defaultColor = "rgb(255, 255, 255)"; 28 | 29 | function SimpleColorPicker(props: SimpleColorProps) { 30 | const [color, setColor] = useState(defaultColor); 31 | const [displayColorPicker, setDisplayColorPicker] = useState(false); 32 | 33 | useEffect(() => { 34 | const initColor = 35 | props.initColor && colorNameMap[props.initColor.replace(/ color$/, "")]; 36 | setColor(initColor ? hexToRgbString(initColor) : defaultColor); 37 | }, [props.initColor]); 38 | 39 | const handleClick = () => { 40 | setDisplayColorPicker(!displayColorPicker); 41 | }; 42 | 43 | const handleClose = () => { 44 | setDisplayColorPicker(false); 45 | }; 46 | 47 | const handleChange = (color: ColorResult) => { 48 | const newColor = `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})`; 49 | setColor(newColor); 50 | if (props.updateColor) { 51 | const colorName = nearest(color.hex).name; 52 | // we should add color after the color name, so the StableDiffusion can parse it 53 | props.updateColor(colorName + " color"); 54 | } 55 | }; 56 | 57 | return ( 58 | <> 59 | 60 | 61 | 62 | {displayColorPicker && ( 63 | 64 | 65 | 66 | 67 | )} 68 | 69 | ); 70 | } 71 | 72 | const StyleColor = styled.div` 73 | width: 16px; 74 | height: 14px; 75 | border-radius: 2px; 76 | background: ${(props) => props.color}; 77 | `; 78 | 79 | const Swatch = styled.div` 80 | display: inline-block; 81 | padding: 1px; 82 | top: 4px; 83 | left: 4px; 84 | position: relative; 85 | background: #fff; 86 | border-radius: 1px; 87 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); 88 | cursor: pointer; 89 | `; 90 | 91 | const StylePopover = styled.div` 92 | position: absolute; 93 | z-index: 2; 94 | `; 95 | 96 | const StyleCover = styled.div` 97 | position: fixed; 98 | top: 0; 99 | right: 0; 100 | bottom: 0; 101 | left: 0; 102 | `; 103 | 104 | export default SimpleColorPicker; 105 | -------------------------------------------------------------------------------- /src/api/edge/conversation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ResponseCreateConversation, 3 | ResponseDeleteAllConversation, 4 | ResponseGetConversations, 5 | } from "@/utils/type.util"; 6 | import { WebStorage } from "@/storage/webstorage"; 7 | import { deleteAllChats, deleteChatsByConversationId } from "./chat"; 8 | 9 | function getConversationById( 10 | id: number, 11 | conversations: ResponseGetConversations 12 | ) { 13 | for (const _index in conversations) { 14 | const _conversation = conversations[_index]; 15 | if (_conversation.id == id) 16 | return { 17 | conversation: _conversation, 18 | index: parseInt(_index), 19 | }; 20 | } 21 | } 22 | 23 | export function getConversations() { 24 | const _conversationsRepo = new WebStorage( 25 | "o:convo" 26 | ); 27 | const _conversations = 28 | _conversationsRepo.get() ?? []; 29 | return _conversations as ResponseGetConversations; 30 | } 31 | 32 | export function createConversation(name?: string) { 33 | const _conversationsRepo = new WebStorage( 34 | "o:convo" 35 | ); 36 | const _conversations = 37 | _conversationsRepo.get() ?? []; 38 | let nextIndex = 1; 39 | for (const _index in _conversations) { 40 | if ((_conversations[_index].id ?? 0) >= nextIndex) 41 | nextIndex = (_conversations[_index].id ?? 0) + 1; 42 | } 43 | const _newConversation = { 44 | id: nextIndex, 45 | name: name ?? "Default name", 46 | created_at: Date.now().toString(), 47 | user_id: 0, 48 | deleted: 0, 49 | }; 50 | _conversations.push(_newConversation); 51 | _conversationsRepo.set(_conversations); 52 | 53 | return _newConversation as ResponseCreateConversation; 54 | } 55 | 56 | export function changeConversationName(conversationId: number, name: string) { 57 | const _conversationsRepo = new WebStorage( 58 | "o:convo" 59 | ); 60 | const _conversations = 61 | _conversationsRepo.get() ?? []; 62 | const _result = getConversationById(conversationId, _conversations); 63 | if (!_result) return; 64 | _result.conversation.name = name; 65 | _conversationsRepo.set(_conversations); 66 | 67 | return _result.conversation as ResponseCreateConversation; 68 | } 69 | 70 | export function deleteConversation(conversationId: number) { 71 | const _conversationsRepo = new WebStorage( 72 | "o:convo" 73 | ); 74 | const _conversations = 75 | _conversationsRepo.get() ?? []; 76 | const _result = getConversationById(conversationId, _conversations); 77 | if (!_result) return; 78 | deleteChatsByConversationId(conversationId); 79 | _conversations.splice(_result.index, 1); 80 | _conversationsRepo.set(_conversations); 81 | return _result.conversation as ResponseCreateConversation; 82 | } 83 | 84 | export async function deleteAllConversations() { 85 | const _conversationsRepo = new WebStorage( 86 | "o:convo" 87 | ); 88 | deleteAllChats(); 89 | _conversationsRepo.set([]); 90 | return {} as ResponseDeleteAllConversation; 91 | } 92 | -------------------------------------------------------------------------------- /src/api/chat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RequestGetChats, 3 | RequestSend, 4 | ResponseGetChats, 5 | ResponseSend, 6 | } from "@/utils/type.util"; 7 | import nodeFetch from "node-fetch"; 8 | import { isClientSideOpenAI } from "@/api/edge/user"; 9 | import * as EdgeChat from "@/api/edge/chat"; 10 | 11 | export async function getChatsByConversationId( 12 | conversationId: number, 13 | withSystem?: boolean 14 | ) { 15 | if (isClientSideOpenAI()) 16 | return EdgeChat.getChatsByConversationId( 17 | conversationId, 18 | withSystem 19 | ) as ResponseGetChats; 20 | const response = await nodeFetch("/api/chatgpt/chat", { 21 | method: "POST", 22 | body: JSON.stringify({ 23 | action: "get_chats", 24 | conversation_id: conversationId, 25 | } as RequestGetChats), 26 | }); 27 | const data = (await response.json()) as ResponseGetChats; 28 | if (!response.ok) { 29 | alert("Error: " + JSON.stringify((data as any).error)); 30 | return null; 31 | } 32 | 33 | if (!data) { 34 | alert("Error(getChatsByConversationId): sOmeTHiNg wEnT wRoNg"); 35 | return null; 36 | } 37 | 38 | return data; 39 | } 40 | 41 | export async function saveChat( 42 | conversationId: number, 43 | message: { 44 | role: string; 45 | content: string; 46 | } 47 | ) { 48 | if (isClientSideOpenAI()) 49 | return EdgeChat.saveChat(conversationId, message) as ResponseGetChats; 50 | const response = await nodeFetch("/api/chatgpt/chat", { 51 | method: "POST", 52 | body: JSON.stringify({ 53 | action: "save_chat", 54 | conversation_id: conversationId, 55 | message: message, 56 | }), 57 | }); 58 | const data = (await response.json()) as ResponseGetChats; 59 | if (!response.ok) { 60 | alert("Error: " + JSON.stringify((data as any).error)); 61 | return null; 62 | } 63 | 64 | if (!data) { 65 | alert("Error(getChatsByConversationId): sOmeTHiNg wEnT wRoNg"); 66 | return null; 67 | } 68 | 69 | return data; 70 | } 71 | 72 | export async function sendMessage( 73 | conversationId: number, 74 | message: string, 75 | name?: string 76 | ) { 77 | if (isClientSideOpenAI()) 78 | return (await EdgeChat.sendMessage( 79 | conversationId, 80 | message, 81 | name 82 | )) as ResponseSend; 83 | const response = await nodeFetch("/api/chatgpt/chat", { 84 | method: "POST", 85 | body: JSON.stringify({ 86 | action: "send", 87 | conversation_id: conversationId, 88 | messages: [ 89 | { 90 | role: "user", 91 | content: message, 92 | name: name ?? undefined, 93 | }, 94 | ], 95 | } as RequestSend), 96 | }); 97 | if (!response.ok) { 98 | throw new Error(await response.text()); 99 | } 100 | const data = (await response.json()) as ResponseSend; 101 | if (!data) { 102 | throw new Error("Empty response"); 103 | } 104 | return data; 105 | } 106 | 107 | export async function sendMsgWithStreamRes( 108 | conversationId: number, 109 | message: string, 110 | name?: string 111 | ) { 112 | const response = await fetch("/api/chatgpt/stream", { 113 | method: "POST", 114 | headers: { Accept: "text/event-stream" }, 115 | body: JSON.stringify({ 116 | action: "send_stream", 117 | conversation_id: conversationId, 118 | messages: [ 119 | { 120 | role: "user", 121 | content: message, 122 | name: name ?? undefined, 123 | }, 124 | ], 125 | }), 126 | }); 127 | 128 | if (!response.ok) { 129 | alert("Error: " + response.statusText); 130 | return; 131 | } 132 | if (response.body == null) { 133 | alert("Error: sOmeTHiNg wEnT wRoNg"); 134 | return; 135 | } 136 | return response.body; 137 | } 138 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { match } from "@formatjs/intl-localematcher"; 2 | import Negotiator from "negotiator"; 3 | 4 | const dictionaries = { 5 | "en-US": () => import("./en-US").then((module) => module.default), 6 | "zh-CN": () => import("./zh-CN").then((module) => module.default), 7 | }; 8 | 9 | export type SupportedLocale = keyof typeof dictionaries; 10 | export const SupportedLocales = Object.keys(dictionaries) as SupportedLocale[]; 11 | export const DefaultLocale: SupportedLocale = "zh-CN"; 12 | 13 | export function stripLocaleInPath(pathname: string): PagePath { 14 | const splits = pathname.split("/"); 15 | const locale = splits[1]; 16 | 17 | let striped: PagePath; 18 | if (SupportedLocales.includes(locale as SupportedLocale)) { 19 | striped = pathname.replace(`/${locale}`, "") as PagePath; 20 | } else { 21 | striped = pathname as PagePath; 22 | } 23 | 24 | // todo: we read to read routes from Next.js 25 | if (splits.length == 5 && hadChildRoutes.includes(splits[2])) { 26 | striped = `/${splits[2]}/$` as PagePath; 27 | } 28 | 29 | return striped; 30 | } 31 | 32 | export function getLocaleFromPath(pathname: string): SupportedLocale { 33 | const locale = pathname.split("/")[1]; 34 | if (SupportedLocales.includes(locale as SupportedLocale)) { 35 | return locale as SupportedLocale; 36 | } 37 | 38 | return DefaultLocale; 39 | } 40 | 41 | export function replaceRouteLocale( 42 | pathname: string, 43 | locale: SupportedLocale 44 | ): string { 45 | const currentLocale = pathname.split("/")[1]; 46 | if (SupportedLocales.includes(currentLocale as SupportedLocale)) { 47 | return pathname.replace(`/${currentLocale}`, `/${locale}`); 48 | } 49 | 50 | return `/${locale}${pathname}`; 51 | } 52 | 53 | export function getLocale(headers: Headers): SupportedLocale { 54 | const languages = new Negotiator({ 55 | headers: [...headers].reduce( 56 | (pre: Record, [key, value]) => { 57 | pre[key] = value; 58 | return pre; 59 | }, 60 | {} 61 | ), 62 | }).languages(); 63 | 64 | let locale: SupportedLocale; 65 | try { 66 | locale = match( 67 | languages, 68 | SupportedLocales, 69 | DefaultLocale 70 | ) as SupportedLocale; 71 | } catch (error) { 72 | locale = DefaultLocale; 73 | } 74 | 75 | return locale; 76 | } 77 | 78 | import type { 79 | GlobalKey as GlobalKeyEnUS, 80 | PageKey as PageKeyEnUS, 81 | } from "./en-US"; 82 | import type { 83 | GlobalKey as GlobalKeyZhCN, 84 | PageKey as PageKeyZhCN, 85 | } from "./zh-CN"; 86 | 87 | export type AppData = { 88 | i18n: { 89 | g: (key: GlobalKeyEnUS | GlobalKeyZhCN) => string; 90 | tFactory:

( 91 | path: P 92 | ) => (key: PageKeyEnUS

| PageKeyZhCN

) => string; 93 | dict: Record; 94 | }; 95 | pathname: string; 96 | locale: SupportedLocale; 97 | }; 98 | export type AppDataI18n = AppData["i18n"]; 99 | 100 | import { 101 | SITE_INTERNAL_HEADER_LOCALE, 102 | SITE_INTERNAL_HEADER_PATHNAME, 103 | } from "@/configs/constants"; 104 | import { hadChildRoutes, PagePath } from "./pagePath"; 105 | 106 | export async function getAppData(): Promise { 107 | let pathname: PagePath = "/"; 108 | let locale = DefaultLocale; 109 | 110 | try { 111 | const { headers } = await import("next/headers"); 112 | pathname = (headers().get(SITE_INTERNAL_HEADER_PATHNAME) || 113 | "/") as PagePath; 114 | locale = headers().get(SITE_INTERNAL_HEADER_LOCALE) as SupportedLocale; 115 | } catch (error) { 116 | console.log(error); 117 | } 118 | 119 | const dictionary = dictionaries[locale] ?? dictionaries[DefaultLocale]; 120 | const stripedPathname = stripLocaleInPath(pathname); 121 | return dictionary().then((module) => ({ 122 | i18n: { 123 | g: (key) => module["*"][key], 124 | tFactory: (_) => (key) => 125 | (module[stripedPathname] as any)[key as any] as any, 126 | dict: module[stripedPathname], 127 | }, 128 | pathname: stripedPathname, 129 | locale, 130 | })); 131 | } 132 | -------------------------------------------------------------------------------- /src/assets/icons/gpt.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/chatgpt/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button, Input } from "@/components/ChakraUI"; 4 | import React, { Dispatch, SetStateAction } from "react"; 5 | import * as UserApi from "@/api/user"; 6 | 7 | export const LoginPage = ({ 8 | dict, 9 | setIsLoggedIn, 10 | }: { 11 | dict: Record; 12 | setIsLoggedIn: Dispatch>; 13 | }) => { 14 | const [openAiKey, setOpenAiKey] = React.useState(""); 15 | const [huggingfaceToken, setHuggingFaceToken] = React.useState(""); 16 | 17 | async function login(key: string, token: string) { 18 | if (key.length === 0) { 19 | alert(dict["enter_openai_api_key"]); 20 | return; 21 | } 22 | 23 | if (token.length === 0) { 24 | alert(dict["enter_huggingface_access_token"]); 25 | return; 26 | } 27 | 28 | const data = await UserApi.login(key, token); 29 | if (data) { 30 | setIsLoggedIn(true); 31 | } else { 32 | alert("Login failed. Please check your API key."); 33 | setIsLoggedIn(false); 34 | } 35 | } 36 | 37 | return ( 38 |

39 |

ChatDiffusion

40 |

47 | {dict["select_api_type_note"]} 48 |

49 |

{dict["openai_api_key"]}

50 |
51 |
52 | 1. {dict["sign_up"]}   53 | 58 | OpenAI Platform. 59 | 60 |
61 |
62 | 2. {dict["create_new"]} secret key:   63 | 68 | Settings → API keys. 69 | 70 |
71 |
3. {dict["copy_paste"]} API key:
72 |
73 |
74 | setOpenAiKey(ev.target.value)} 78 | > 79 |
80 |

86 | {dict["huggingface_access_token"]} 87 |

88 |
89 |
90 | 1. {dict["sign_up"]}   91 | 96 | Hugging Face. 97 | 98 |
99 |
100 | 2. {dict["create_new"]} Access Token:   101 | 106 | Settings → Access Tokens. 107 | 108 |
109 |
3. {dict["copy_paste"]} Access Token:
110 |
111 |
112 | setHuggingFaceToken(ev.target.value)} 116 | > 117 |
118 |
119 | 127 |
128 |
129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-diffusion", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "husky install", 7 | "dev": "npm run prepare:data && cross-env NODE_ENV='development' next dev", 8 | "build": "next build", 9 | "postbuild": "next-sitemap", 10 | "start": "npm run dev", 11 | "lint": "next lint", 12 | "lint:fix": "next lint --fix", 13 | "format": "prettier --check . -u", 14 | "format:fix": "prettier --write . -u", 15 | "postinstall": "npm run prepare:data", 16 | "prepare:env": "npx vercel link && npx vercel env pull .env.local", 17 | "prepare:data": "", 18 | "test": "jest --passWithNoTests", 19 | "test:watch": "jest --watch" 20 | }, 21 | "dependencies": { 22 | "@chakra-ui/icons": "^2.0.17", 23 | "@chakra-ui/react": "^2.5.1", 24 | "@chakra-ui/spinner": "^2.0.13", 25 | "@chakra-ui/system": "^2.5.1", 26 | "@emotion/react": "^11.10.6", 27 | "@emotion/styled": "^11.10.6", 28 | "@formatjs/intl-localematcher": "^0.2.32", 29 | "@planetscale/database": "^1.6.0", 30 | "@prisma/client": "^4.11.0", 31 | "@remirror/pm": "^2.0.4", 32 | "@remirror/react": "^2.0.27", 33 | "@remirror/react-editors": "^1.0.27", 34 | "@tanstack/react-table": "^8.7.9", 35 | "@types/jsonpath-plus": "^5.0.2", 36 | "autosize": "^6.0.1", 37 | "chakra-ui-markdown-renderer": "^4.1.0", 38 | "client-only": "^0.0.1", 39 | "dagre": "^0.8.5", 40 | "dotparser": "^1.1.1", 41 | "encoding": "^0.1.13", 42 | "expr-eval": "^2.0.2", 43 | "formik": "^2.2.9", 44 | "framer-motion": "^10.0.1", 45 | "jsonpath-plus": "^7.2.0", 46 | "kysely": "^0.23.5", 47 | "kysely-planetscale": "^1.3.0", 48 | "lodash-es": "^4.17.21", 49 | "mermaid": "^10.0.2", 50 | "negotiator": "^0.6.3", 51 | "next": "13.2.3", 52 | "next-sitemap": "^4.0.2", 53 | "node-fetch": "^2", 54 | "openai": "^3.2.1", 55 | "react": "18.2.0", 56 | "react-color": "^2.19.3", 57 | "react-copy-to-clipboard": "^5.1.0", 58 | "react-dom": "18.2.0", 59 | "react-json-view": "^1.21.3", 60 | "react-markdown": "^8.0.5", 61 | "react-spinners": "^0.13.8", 62 | "react-syntax-highlighter": "^15.5.0", 63 | "reactflow": "^11.6.0", 64 | "remark-gfm": "^3.0.1", 65 | "remirror": "^2.0.26", 66 | "server-only": "^0.0.1", 67 | "sharp": "^0.31.3", 68 | "svg-pan-zoom": "^3.6.1", 69 | "typescript": "4.9.5", 70 | "use-debounce": "^9.0.3" 71 | }, 72 | "devDependencies": { 73 | "@svgr/webpack": "^6.5.1", 74 | "@testing-library/jest-dom": "^5.16.5", 75 | "@testing-library/react": "^14.0.0", 76 | "@types/autosize": "^4.0.1", 77 | "@types/dagre": "^0.7.48", 78 | "@types/jsonpath": "^0.2.0", 79 | "@types/lodash-es": "^4.17.6", 80 | "@types/negotiator": "^0.6.1", 81 | "@types/node": "18.14.5", 82 | "@types/node-fetch": "^2.6.2", 83 | "@types/papaparse": "^5.3.7", 84 | "@types/react": "18.0.28", 85 | "@types/react-color": "^3.0.6", 86 | "@types/react-copy-to-clipboard": "^5.0.4", 87 | "@types/react-dom": "18.0.11", 88 | "@types/react-syntax-highlighter": "^15.5.6", 89 | "@types/tunnel": "^0.0.3", 90 | "@typescript-eslint/eslint-plugin": "^5.54.1", 91 | "autoprefixer": "^10.4.13", 92 | "cross-env": "^7.0.3", 93 | "eslint": "8.35.0", 94 | "eslint-config-next": "13.2.3", 95 | "eslint-config-prettier": "^8.6.0", 96 | "eslint-plugin-prettier": "^4.2.1", 97 | "husky": "^8.0.3", 98 | "jest": "^29.4.3", 99 | "jest-environment-jsdom": "^29.4.3", 100 | "js-yaml": "^4.1.0", 101 | "lint-staged": "^13.1.2", 102 | "postcss": "^8.4.21", 103 | "prettier": "^2.8.4", 104 | "prisma": "^4.11.0", 105 | "tailwindcss": "^3.2.7", 106 | "tunnel": "^0.0.6", 107 | "walkdir": "^0.4.1", 108 | "yaml-loader": "^0.8.0" 109 | }, 110 | "overrides": { 111 | "react-json-view": { 112 | "react": "$react", 113 | "react-dom": "$react-dom" 114 | }, 115 | "flux": { 116 | "react": "$react", 117 | "react-dom": "$react-dom" 118 | } 119 | }, 120 | "engines": { 121 | "npm": ">=8.11.0", 122 | "node": ">=16.19.0" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/layout/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Box, 4 | Flex, 5 | Heading, 6 | IconButton, 7 | Link as NavLink, 8 | Menu, 9 | MenuButton, 10 | MenuItem, 11 | MenuList, 12 | Spacer, 13 | } from "@/components/ChakraUI"; 14 | import { 15 | ChevronDownIcon, 16 | ExternalLinkIcon, 17 | HamburgerIcon, 18 | } from "@/components/ChakraUI/icons"; 19 | import Link from "next/link"; 20 | import { GITHUB_URL } from "@/configs/constants"; 21 | import LocaleSwitcher from "@/components/LocaleSwitcher"; 22 | import { getAppData } from "@/i18n"; 23 | 24 | export default async function NavBar({ locale }: { locale: string }) { 25 | const { pathname } = await getAppData(); 26 | 27 | const NavList = [ 28 | { 29 | title: "Home", 30 | url: `/`, 31 | }, 32 | ]; 33 | 34 | return ( 35 | 42 | 43 | 44 | ChatDiffusion 45 | 46 | 47 | {NavList.map((nav: any) => { 48 | // 如果当前导航项有子菜单,则呈现为下拉菜单 49 | if (nav?.children) { 50 | return ( 51 | 52 | 53 | {nav.title} 54 | 55 | 56 | 57 | {nav.children.map((child: any) => ( 58 | 59 | 63 | {child.title} 64 | 65 | 66 | ))} 67 | 68 | 69 | ); 70 | } else { 71 | // 否则呈现为单独的链接 72 | return ( 73 | 74 | 78 | {nav.title} 79 | 80 | 81 | ); 82 | } 83 | })} 84 | 85 | 86 | 87 | 88 | 93 | GitHub 94 | 95 | 96 | } 100 | variant="outline" 101 | display={{ md: "none", base: "block" }} 102 | mr={4} 103 | /> 104 | 105 | {NavList.map((nav: any) => 106 | nav.children ? ( 107 | nav.children.map((child: any) => ( 108 | 109 | 113 | {child.title} 114 | 115 | 116 | )) 117 | ) : ( 118 | 119 | 120 | {nav.title} 121 | 122 | 123 | ) 124 | )} 125 | 126 | 127 | GitHub 128 | 129 | 130 | 131 | 132 | 133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /src/api/conversation.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { 3 | RequestChangeConversationName, 4 | RequestCreateConversation, 5 | RequestDeleteAllConversation, 6 | RequestDeleteConversation, 7 | RequestGetConversations, 8 | ResponseGetConversations, 9 | ResponseCreateConversation, 10 | ResponseDeleteAllConversation, 11 | } from "@/utils/type.util"; 12 | import { isClientSideOpenAI } from "@/api/edge/user"; 13 | import * as EdgeConversation from "@/api/edge/conversation"; 14 | 15 | export async function getConversations() { 16 | if (isClientSideOpenAI()) return EdgeConversation.getConversations(); 17 | const response = await fetch("/api/chatgpt/conversation", { 18 | method: "POST", 19 | body: JSON.stringify({ 20 | action: "get_conversations", 21 | } as RequestGetConversations), 22 | }); 23 | const data = (await response.json()) as ResponseGetConversations; 24 | if (!response.ok) { 25 | alert("Error: " + JSON.stringify((data as any).error)); 26 | return; 27 | } 28 | 29 | if (data == null) { 30 | alert("Error(createConversation): sOmeTHiNg wEnT wRoNg"); 31 | return; 32 | } 33 | 34 | return data; 35 | } 36 | 37 | export async function createConversation(name?: string) { 38 | if (isClientSideOpenAI()) return EdgeConversation.createConversation(name); 39 | const response = await fetch("/api/chatgpt/conversation", { 40 | method: "POST", 41 | body: JSON.stringify({ 42 | action: "create_conversation", 43 | name: name ?? "Default name", 44 | } as RequestCreateConversation), 45 | }); 46 | const data = (await response.json()) as ResponseCreateConversation; 47 | if (!response.ok) { 48 | alert("Error(createConversation): " + JSON.stringify((data as any).error)); 49 | return; 50 | } 51 | 52 | if (data == null) { 53 | alert("Error(createConversation): sOmeTHiNg wEnT wRoNg"); 54 | return; 55 | } 56 | 57 | return data; 58 | } 59 | 60 | export async function changeConversationName( 61 | conversationId: number, 62 | name: string 63 | ) { 64 | if (isClientSideOpenAI()) 65 | return EdgeConversation.changeConversationName(conversationId, name); 66 | const response = await fetch("/api/chatgpt/conversation", { 67 | method: "POST", 68 | body: JSON.stringify({ 69 | action: "change_conversation_name", 70 | conversation_id: conversationId, 71 | name: name ?? "Default name", 72 | } as RequestChangeConversationName), 73 | }); 74 | const data = (await response.json()) as ResponseCreateConversation; 75 | if (!response.ok) { 76 | alert("Error: " + JSON.stringify((data as any).error)); 77 | return; 78 | } 79 | 80 | if (!data) { 81 | alert("Error(changeConversationName): sOmeTHiNg wEnT wRoNg"); 82 | return; 83 | } 84 | 85 | return data; 86 | } 87 | 88 | export async function deleteConversation(conversationId: number) { 89 | if (isClientSideOpenAI()) 90 | return EdgeConversation.deleteConversation(conversationId); 91 | const response = await fetch("/api/chatgpt/conversation", { 92 | method: "POST", 93 | body: JSON.stringify({ 94 | action: "delete_conversation", 95 | conversation_id: conversationId, 96 | } as RequestDeleteConversation), 97 | }); 98 | const data = (await response.json()) as ResponseCreateConversation; 99 | if (!response.ok) { 100 | alert("Error: " + JSON.stringify((data as any).error)); 101 | return; 102 | } 103 | 104 | if (!data) { 105 | alert("Error(deleteConversation): sOmeTHiNg wEnT wRoNg"); 106 | return; 107 | } 108 | 109 | return data; 110 | } 111 | 112 | export async function deleteAllConversations() { 113 | if (isClientSideOpenAI()) return EdgeConversation.deleteAllConversations(); 114 | const response = await fetch("/api/chatgpt/conversation", { 115 | method: "POST", 116 | body: JSON.stringify({ 117 | action: "delete_all_conversations", 118 | } as RequestDeleteAllConversation), 119 | }); 120 | const data = (await response.json()) as ResponseDeleteAllConversation; 121 | if (!response.ok) { 122 | alert("Error: " + JSON.stringify((data as any).error)); 123 | return; 124 | } 125 | 126 | if (data.error) { 127 | alert("Error(deleteAllConversation): sOmeTHiNg wEnT wRoNg: " + data.error); 128 | return; 129 | } 130 | 131 | return data; 132 | } 133 | -------------------------------------------------------------------------------- /src/components/markdown/Mermaid.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import "client-only"; 4 | import React, { useCallback, useEffect, useRef, useState } from "react"; 5 | import svgPanZoom from "svg-pan-zoom"; 6 | import { Button, Flex } from "@chakra-ui/react"; 7 | import mermaid from "mermaid"; 8 | 9 | let currentId = 0; 10 | const uuid = () => `mermaid-${(currentId++).toString()}`; 11 | 12 | function downloadBlob(blob: Blob, filename: string) { 13 | const objectUrl = URL.createObjectURL(blob); 14 | 15 | const link = document.createElement("a"); 16 | link.href = objectUrl; 17 | link.download = filename; 18 | document.body.appendChild(link); 19 | link.click(); 20 | document.body.removeChild(link); 21 | 22 | setTimeout(() => URL.revokeObjectURL(objectUrl), 5000); 23 | } 24 | 25 | export default function Mermaid({ 26 | graphDefinition, 27 | }: { 28 | graphDefinition: string; 29 | }) { 30 | const [instance, setInstance] = useState(null); 31 | const enableZoom = useCallback(() => { 32 | instance?.enablePan(); 33 | instance?.enableZoom(); 34 | }, [instance]); 35 | 36 | const disableZoom = useCallback(() => { 37 | instance?.disablePan(); 38 | instance?.disableZoom(); 39 | }, [instance]); 40 | 41 | const resetZoom = useCallback(() => { 42 | instance?.fit(); 43 | instance?.center(); 44 | }, [instance]); 45 | 46 | const ref = useRef(null); 47 | const [hasError, setHasError] = React.useState(false); 48 | const currentId = uuid(); 49 | 50 | const downloadSVG = useCallback(() => { 51 | const svg = ref.current!.innerHTML; 52 | const blob = new Blob([svg], { type: "image/svg+xml" }); 53 | downloadBlob(blob, `myimage.svg`); 54 | }, []); 55 | 56 | useEffect(() => { 57 | if (!ref.current || !graphDefinition) return; 58 | mermaid.initialize({ 59 | startOnLoad: false, 60 | }); 61 | 62 | mermaid.mermaidAPI 63 | .render(currentId, graphDefinition) 64 | .then(({ svg, bindFunctions }) => { 65 | ref.current!.innerHTML = svg; 66 | bindFunctions?.(ref.current!); 67 | 68 | setInstance(() => { 69 | const instance = svgPanZoom(ref.current!.querySelector("svg")!); 70 | instance.fit(); 71 | instance.center(); 72 | instance.disablePan(); 73 | instance.disableZoom(); 74 | return instance; 75 | }); 76 | }) 77 | .catch((e) => { 78 | console.info(e); 79 | 80 | // NOTE(CGQAQ): there's a bug in mermaid will always throw an error: 81 | // Error: Diagram error not found. 82 | // we need to check if the svg is rendered. 83 | // if rendered, we can ignore the error. 84 | // ref: https://github.com/mermaid-js/mermaid/issues/4140 85 | if (ref.current?.querySelector("svg") == null) { 86 | setHasError(true); 87 | } 88 | }); 89 | }, [graphDefinition]); 90 | 91 | useEffect(() => { 92 | const handleSpaceDown = (e: KeyboardEvent) => { 93 | if (e.code === "Space" && !e.repeat) { 94 | e.preventDefault(); 95 | enableZoom(); 96 | } 97 | }; 98 | 99 | const handleSpaceUp = (e: KeyboardEvent) => { 100 | if (e.code === "Space" && !e.repeat) { 101 | disableZoom(); 102 | } 103 | }; 104 | document.addEventListener("keydown", handleSpaceDown); 105 | document.addEventListener("keyup", handleSpaceUp); 106 | 107 | return () => { 108 | document.removeEventListener("keydown", handleSpaceDown); 109 | document.removeEventListener("keyup", handleSpaceUp); 110 | }; 111 | }, [enableZoom, disableZoom]); 112 | 113 | if (hasError || !graphDefinition) 114 | return {graphDefinition}; 115 | return ( 116 | <> 117 | 118 | * hold space to pan & zoom 119 | 120 |
{ 123 | ref.current?.querySelector("svg")?.setPointerCapture(event.pointerId); 124 | }} 125 | >
126 | 127 | 128 | 129 | 130 | 131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /src/components/DeepDanbooru.tsx: -------------------------------------------------------------------------------- 1 | import { DeepDanbooruTag } from "@/utils/type.util"; 2 | import { ChangeEventHandler, useEffect, useState } from "react"; 3 | type DeepDanbooruProps = { 4 | dict: Record; 5 | tags: DeepDanbooruTag[]; 6 | handleTagSelectedChange?: (tags: string[]) => void; 7 | clearSelectedFlag: number; 8 | }; 9 | 10 | export function DeepDanbooru(props: DeepDanbooruProps) { 11 | const [tagSelected, setTagSelected] = useState<{ [key: string]: boolean }>( 12 | {} 13 | ); 14 | const [allSelected, setAllSelected] = useState(false); 15 | const handleTagSelectedChange: ChangeEventHandler = (e) => { 16 | tagSelected[e.target.name] = e.target.checked; 17 | setTagSelected(tagSelected); 18 | let selected = 0; 19 | for (const tag in tagSelected) if (tagSelected[tag]) selected++; 20 | console.log( 21 | selected, 22 | props.tags.filter((t) => t.label.indexOf("rating") == -1).length 23 | ); 24 | if ( 25 | selected == 26 | props.tags.filter((t) => t.label.indexOf("rating") == -1).length 27 | ) { 28 | setAllSelected(true); 29 | } else { 30 | setAllSelected(false); 31 | } 32 | if (props.handleTagSelectedChange) 33 | props.handleTagSelectedChange( 34 | Object.keys(tagSelected).filter((t) => tagSelected[t]) 35 | ); 36 | }; 37 | const handleTagSelectAllChange: ChangeEventHandler = ( 38 | e 39 | ) => { 40 | for (const i in props.tags) { 41 | if (props.tags[i].label.indexOf("rating") != -1) continue; 42 | tagSelected[props.tags[i].label] = e.target.checked; 43 | } 44 | setTagSelected(tagSelected); 45 | setAllSelected(e.target.checked); 46 | if (props.handleTagSelectedChange) 47 | props.handleTagSelectedChange( 48 | Object.keys(tagSelected).filter((t) => tagSelected[t]) 49 | ); 50 | }; 51 | useEffect(() => { 52 | if (props.clearSelectedFlag) { 53 | for (const i in props.tags) { 54 | if (props.tags[i].label.indexOf("rating") != -1) continue; 55 | tagSelected[props.tags[i].label] = false; 56 | } 57 | setTagSelected(tagSelected); 58 | setAllSelected(false); 59 | } 60 | }, [props.clearSelectedFlag]); 61 | return ( 62 | <> 63 | {props && 64 | props.tags.map((tag) => { 65 | if (tag.label.indexOf("rating") != -1) return; 66 | if (tagSelected[tag.label] == undefined) 67 | tagSelected[tag.label] = false; 68 | return ( 69 |
77 |
87 |
98 |
99 | 106 | 118 |
119 | ); 120 | })} 121 | {props && props.tags.length && ( 122 | <> 123 | 130 | 137 | 138 | )} 139 | 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /src/assets/images/chatgpt-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/edge/chat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CHAT_COMPLETION_CONFIG, 3 | CHAT_COMPLETION_URL, 4 | HUGGINGFACE_DEFAULT_STABLE_DIFFUSION_MODEL, 5 | } from "@/configs/constants"; 6 | import { ResponseGetChats, ResponseSend } from "@/utils/type.util"; 7 | import { WebStorage } from "@/storage/webstorage"; 8 | import { drawImage } from "@/utils/huggingface.txt2img.util"; 9 | import { 10 | ChatCompletionRequestMessage, 11 | CreateChatCompletionResponse, 12 | } from "openai"; 13 | import { getApiKey, getToken } from "./user"; 14 | 15 | export function getChatsByConversationId( 16 | conversationId: number, 17 | withExtra?: boolean 18 | ) { 19 | const _chatRepo = new WebStorage("o:c"); 20 | const _chats = _chatRepo.get() ?? []; 21 | const _filtered = _chats.filter( 22 | (e) => 23 | e.conversation_id == conversationId && 24 | e.role != "system" && 25 | (e.role == "user" || e.role == "assistant" || withExtra) 26 | ); 27 | if (withExtra) return _filtered; 28 | return [ 29 | { 30 | name: undefined, 31 | content: `Whever I ask you in any language to draw an image, respond with the following JSON: {"model":"${HUGGINGFACE_DEFAULT_STABLE_DIFFUSION_MODEL}","prompt":string,"negative_prompt":string}, and fill in prompt with very detailed tags used in Stable Diffusion, and fill in negative_prompt with common negative tags used in Stable Diffusion.\nOnly English should be used in prompt and negative_prompt. Reply other messages in my language.`, 32 | id: 0, 33 | role: "system", 34 | conversation_id: conversationId, 35 | created_at: undefined, 36 | }, 37 | ..._filtered, 38 | ]; 39 | } 40 | 41 | export function saveChat( 42 | conversationId: number, 43 | message: { 44 | role: string; 45 | content: string; 46 | } 47 | ) { 48 | const _chatRepo = new WebStorage("o:c"); 49 | const _chats = _chatRepo.get() ?? []; 50 | let nextIndex = 1; 51 | for (const _index in _chats) { 52 | if ((_chats[_index].id ?? 0) >= nextIndex) 53 | nextIndex = (_chats[_index].id ?? 0) + 1; 54 | } 55 | const _chat = { 56 | id: nextIndex, 57 | conversation_id: conversationId, 58 | role: message.role, 59 | content: message.content, 60 | name: undefined, 61 | created_at: Date.now().toString(), 62 | }; 63 | _chats.push(_chat); 64 | _chatRepo.set(_chats); 65 | return [_chat]; 66 | } 67 | 68 | async function taskDispatcher(conversationId: number, _message: string) { 69 | try { 70 | const jsonRegex = /{.*}/s; // s flag for dot to match newline characters 71 | const _match = _message.match(jsonRegex); 72 | if (_match) { 73 | const json = JSON.parse(_match[0]); 74 | if ( 75 | "model" in json && 76 | "prompt" in json && 77 | "negative_prompt" in json && 78 | json.prompt.length 79 | ) { 80 | const _token = getToken(); 81 | if (!_token) throw new Error("Access token not set."); 82 | let _response = await drawImage( 83 | _token, 84 | json.model, 85 | json.prompt, 86 | json.negative_prompt 87 | ); 88 | if (_response.status == 503) { 89 | _response = await drawImage( 90 | _token, 91 | json.model, 92 | json.prompt, 93 | json.negative_prompt, 94 | true 95 | ); 96 | } 97 | if (_response.status == 200) { 98 | const imgBlob = await _response.blob(); 99 | const data: string = await new Promise((resolve, _) => { 100 | const reader = new FileReader(); 101 | reader.onloadend = () => resolve(reader.result as string); 102 | reader.readAsDataURL(imgBlob); 103 | }); 104 | const message = { 105 | role: "image", 106 | content: data, 107 | }; 108 | return saveChat(conversationId, message); 109 | } else { 110 | throw new Error((await _response.json()).error); 111 | } 112 | } 113 | } 114 | } catch (e) { 115 | console.log(_message); 116 | console.log("taskDispatcher", e); 117 | } 118 | } 119 | 120 | export async function sendMessage( 121 | conversationId: number, 122 | message: string, 123 | name?: string 124 | ) { 125 | const messages = getChatsByConversationId(conversationId).map((it) => ({ 126 | role: it.role, 127 | content: it.content, 128 | name: it.name, 129 | })) as ChatCompletionRequestMessage[]; 130 | const _message: ChatCompletionRequestMessage = { 131 | role: "user", 132 | content: message, 133 | name: name ?? undefined, 134 | }; 135 | messages.push(_message); 136 | const apiKey = getApiKey(); 137 | if (!apiKey) throw new Error("API key not set."); 138 | try { 139 | const response = await fetch(CHAT_COMPLETION_URL, { 140 | method: "POST", 141 | headers: { 142 | "Content-Type": "application/json", 143 | Authorization: `Bearer ${apiKey}`, 144 | }, 145 | body: JSON.stringify({ 146 | ...CHAT_COMPLETION_CONFIG, 147 | messages: messages, 148 | }), 149 | }); 150 | const json = await response.json(); 151 | if (!response.ok) { 152 | throw new Error(json); 153 | } 154 | const { choices } = json as CreateChatCompletionResponse; 155 | if (choices.length === 0 || !choices[0].message) { 156 | throw new Error("No response from OpenAI"); 157 | } 158 | saveChat(conversationId, _message); 159 | return [ 160 | ...saveChat(conversationId, choices[0].message), 161 | ...((await taskDispatcher(conversationId, choices[0].message.content)) ?? 162 | []), 163 | ] as ResponseSend; 164 | } catch (e) { 165 | console.error(e); 166 | } 167 | } 168 | 169 | export function deleteChatsByConversationId(conversationId: number) { 170 | const _chatRepo = new WebStorage("o:c"); 171 | const _chats = _chatRepo.get() ?? []; 172 | const _filtered = _chats.filter((e) => e.conversation_id != conversationId); 173 | _chatRepo.set(_filtered); 174 | } 175 | 176 | export function deleteAllChats() { 177 | const _chatRepo = new WebStorage("o:c"); 178 | _chatRepo.set([]); 179 | } 180 | -------------------------------------------------------------------------------- /src/components/markdown/SimpleMarkdown.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import ReactMarkdown, { Components } from "react-markdown"; 5 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 6 | import { 7 | Code, 8 | Divider, 9 | Heading, 10 | Link, 11 | ListItem, 12 | OrderedList, 13 | Text, 14 | UnorderedList, 15 | } from "@chakra-ui/layout"; 16 | import { Image } from "@chakra-ui/image"; 17 | import { Checkbox } from "@chakra-ui/checkbox"; 18 | import { Table, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/table"; 19 | import { chakra } from "@chakra-ui/system"; 20 | import remarkGfm from "remark-gfm"; 21 | import MermaidWrapper from "./MermaidWrapper"; 22 | 23 | // MIT License 24 | // 25 | // Copyright (c) 2020 Mustafa Turhan 26 | // 27 | // Permission is hereby granted, free of charge, to any person obtaining a copy 28 | // of this software and associated documentation files (the "Software"), to deal 29 | // in the Software without restriction, including without limitation the rights 30 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 | // copies of the Software, and to permit persons to whom the Software is 32 | // furnished to do so, subject to the following conditions: 33 | // 34 | // The above copyright notice and this permission notice shall be included in all 35 | // copies or substantial portions of the Software. 36 | // 37 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 43 | // SOFTWARE. 44 | 45 | interface Defaults extends Components { 46 | /** 47 | * @deprecated Use `h1, h2, h3, h4, h5, h6` instead. 48 | */ 49 | heading?: Components["h1"]; 50 | } 51 | 52 | type GetCoreProps = { 53 | children?: React.ReactNode; 54 | "data-sourcepos"?: any; 55 | }; 56 | 57 | function getCoreProps(props: GetCoreProps): any { 58 | return props["data-sourcepos"] 59 | ? { "data-sourcepos": props["data-sourcepos"] } 60 | : {}; 61 | } 62 | 63 | export const defaults: Defaults = { 64 | p: (props) => { 65 | const { children } = props; 66 | return {children}; 67 | }, 68 | em: (props) => { 69 | const { children } = props; 70 | return {children}; 71 | }, 72 | blockquote: (props) => { 73 | const { children } = props; 74 | return ( 75 | 76 | {children} 77 | 78 | ); 79 | }, 80 | del: (props) => { 81 | const { children } = props; 82 | return {children}; 83 | }, 84 | hr: (props) => { 85 | return ; 86 | }, 87 | a: Link, 88 | img: Image, 89 | text: (props) => { 90 | const { children } = props; 91 | return {children}; 92 | }, 93 | ul: (props) => { 94 | const { ordered, children, depth } = props; 95 | const attrs = getCoreProps(props); 96 | let Element = UnorderedList; 97 | let styleType = "disc"; 98 | if (ordered) { 99 | Element = OrderedList; 100 | styleType = "decimal"; 101 | } 102 | if (depth === 1) styleType = "circle"; 103 | return ( 104 | 111 | {children} 112 | 113 | ); 114 | }, 115 | ol: (props) => { 116 | const { ordered, children, depth } = props; 117 | const attrs = getCoreProps(props); 118 | let Element = UnorderedList; 119 | let styleType = "disc"; 120 | if (ordered) { 121 | Element = OrderedList; 122 | styleType = "decimal"; 123 | } 124 | if (depth === 1) styleType = "circle"; 125 | return ( 126 | 133 | {children} 134 | 135 | ); 136 | }, 137 | li: (props) => { 138 | const { children, checked } = props; 139 | let checkbox = null; 140 | if (checked !== null && checked !== undefined) { 141 | checkbox = ( 142 | 143 | {children} 144 | 145 | ); 146 | } 147 | return ( 148 | 152 | {checkbox || children} 153 | 154 | ); 155 | }, 156 | heading: (props) => { 157 | const { level, children } = props; 158 | const sizes = ["2xl", "xl", "lg", "md", "sm", "xs"]; 159 | return ( 160 | 166 | {children} 167 | 168 | ); 169 | }, 170 | pre: (props) => { 171 | const { children } = props; 172 | return {children}; 173 | }, 174 | table: Table, 175 | thead: Thead, 176 | tbody: Tbody, 177 | tr: (props) => {props.children}, 178 | td: (props) => {props.children}, 179 | th: (props) => {props.children}, 180 | }; 181 | 182 | function SimpleMarkdown({ content }: any) { 183 | function getHighlighter(match: RegExpExecArray, props: any, children: any) { 184 | const language = match[1]; 185 | if (language == "mermaid") { 186 | return ; 187 | } 188 | 189 | return ( 190 | 191 | {children} 192 | 193 | ); 194 | } 195 | 196 | return ( 197 | <> 198 | 234 | {code} 235 | 236 | ); 237 | }, 238 | }} 239 | > 240 | {content} 241 | 242 | 243 | ); 244 | } 245 | 246 | export default SimpleMarkdown; 247 | -------------------------------------------------------------------------------- /src/components/chatgpt/ChatRoom.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import NewChat from "@/assets/icons/new-chat.svg"; 4 | import TrashcanIcon from "@/assets/icons/trashcan.svg"; 5 | import LogoutIcon from "@/assets/icons/logout.svg"; 6 | import Image from "next/image"; 7 | import content from "@/assets/images/content.png"; 8 | import send from "@/assets/icons/send.svg?url"; 9 | import image_polaroid from "@/assets/icons/image-polaroid.svg?url"; 10 | import React, { 11 | ChangeEventHandler, 12 | createRef, 13 | Dispatch, 14 | DragEventHandler, 15 | MouseEventHandler, 16 | SetStateAction, 17 | useEffect, 18 | useState, 19 | } from "react"; 20 | import styled from "@emotion/styled"; 21 | import type { 22 | DeepDanbooruTag, 23 | ResponseGetConversations, 24 | } from "@/utils/type.util"; 25 | import { ResponseGetChats, ResponseSend } from "@/utils/type.util"; 26 | import { BeatLoader } from "react-spinners"; 27 | import { useDebouncedCallback } from "use-debounce"; 28 | import { Input } from "@chakra-ui/react"; 29 | import * as ChatAPI from "@/api/chat"; 30 | import * as ConversationAPI from "@/api/conversation"; 31 | import * as UserAPI from "@/api/user"; 32 | import SimpleMarkdown from "@/components/markdown/SimpleMarkdown"; 33 | import { isClientSideOpenAI } from "@/api/edge/user"; 34 | import * as EdgeChatAPI from "@/api/edge/chat"; 35 | import { getTags } from "@/utils/huggingface.space.util"; 36 | import { DeepDanbooru } from "../DeepDanbooru"; 37 | 38 | const ChatInput = styled("input")` 39 | background: #ffffff; 40 | border-radius: 8px; 41 | border: none; 42 | padding: 0.5rem 1rem; 43 | width: 100%; 44 | height: 48px; 45 | font-size: 1rem; 46 | font-weight: 500; 47 | color: #1e1e1e; 48 | outline: none; 49 | transition: all 0.2s ease-in-out; 50 | 51 | &:focus { 52 | box-shadow: 0 0 0 2px #1e1e1e; 53 | } 54 | 55 | &:focus::placeholder { 56 | color: #1e1e1e; 57 | } 58 | `; 59 | const ChatInputWrapper = styled("div")` 60 | position: absolute; 61 | bottom: 8px; 62 | height: 48px; 63 | background-color: #fff; 64 | border-radius: 8px; 65 | max-width: 90%; 66 | `; 67 | const ChatsWrapper = styled("div")` 68 | // good looking scrollbar 69 | &::-webkit-scrollbar { 70 | width: 8px; 71 | } 72 | 73 | &::-webkit-scrollbar-track { 74 | background: #f1f1f1; 75 | } 76 | 77 | &::-webkit-scrollbar-thumb { 78 | background: #888; 79 | } 80 | 81 | &::-webkit-scrollbar-thumb:hover { 82 | background: #555; 83 | } 84 | `; 85 | const ButtonWrapper = styled("div")` 86 | position: absolute; 87 | top: 0; 88 | bottom: 0; 89 | right: 8px; 90 | `; 91 | const ChatSendButton = styled("button")` 92 | width: 48px; 93 | height: 48px; 94 | background-image: url(${send}); 95 | background-size: 24px; 96 | background-position: center; 97 | background-repeat: no-repeat; 98 | cursor: pointer; 99 | border: none; 100 | outline: none; 101 | `; 102 | 103 | const UploadFileButton = styled("button")` 104 | width: 48px; 105 | height: 48px; 106 | background-image: url(${image_polaroid}); 107 | background-size: 24px; 108 | background-position: center; 109 | background-repeat: no-repeat; 110 | cursor: pointer; 111 | border: none; 112 | outline: none; 113 | `; 114 | 115 | export const ChatRoom = ({ 116 | dict, 117 | setIsLoggedIn, 118 | initMessage, 119 | }: { 120 | dict: Record; 121 | setIsLoggedIn: Dispatch>; 122 | initMessage?: string; 123 | }) => { 124 | const chatsWrapper = React.useRef(null); 125 | const [disable, setDisable] = React.useState(false); 126 | const [chatHistory, setChatHistory] = React.useState([]); 127 | const [message, setMessage] = React.useState(initMessage ?? ""); 128 | 129 | const [conversations, setConversations] = useState( 130 | [] 131 | ); 132 | const [currentConversation, setCurrentConversation] = useState( 133 | null 134 | ); 135 | // editing conversation name 136 | const [editing, setEditing] = useState(null); 137 | const [editingName, setEditingName] = useState(""); 138 | const [file, setFile] = useState(new Blob()); 139 | const fileInputRef = createRef(); 140 | const [clearTagSelected, setClearTagSelected] = useState(0); 141 | 142 | // get conversations 143 | useEffect(() => { 144 | (async () => { 145 | try { 146 | const data = (await ConversationAPI.getConversations()) ?? []; 147 | setConversations(data); 148 | } catch (error) { 149 | setConversations([]); 150 | alert("Error: " + JSON.stringify(error)); 151 | } 152 | })(); 153 | }, []); 154 | 155 | // scroll to bottom 156 | useEffect(() => { 157 | setTimeout(() => { 158 | if (chatsWrapper.current) { 159 | chatsWrapper.current.scrollTop = chatsWrapper.current.scrollHeight; 160 | } 161 | }); 162 | }, [chatHistory]); 163 | 164 | const onEnterForSendMessage: React.KeyboardEventHandler = ( 165 | event 166 | ) => { 167 | setClearTagSelected(clearTagSelected + 1); 168 | if (event.code === "Enter" || event.code === "NumpadEnter") { 169 | event.preventDefault(); 170 | 171 | sendMessage(); 172 | } 173 | }; 174 | 175 | async function createConversation() { 176 | const data = await ConversationAPI.createConversation(); 177 | if (!data) { 178 | return; 179 | } 180 | 181 | setConversations([data, ...conversations]); 182 | if (data.id) setCurrentConversation(data.id); 183 | return data; 184 | } 185 | 186 | async function changeConversationName(conversationId: number, name: string) { 187 | await ConversationAPI.changeConversationName(conversationId, name); 188 | 189 | setConversations((c) => 190 | c.map((conversation) => { 191 | if (conversation.id === conversationId) { 192 | return { 193 | ...conversation, 194 | name, 195 | }; 196 | } 197 | return conversation; 198 | }) 199 | ); 200 | } 201 | 202 | const handleConversation = useDebouncedCallback( 203 | async ( 204 | conversationId: number | null, 205 | event: React.MouseEvent 206 | ) => { 207 | if (event.detail > 1) { 208 | // double click 209 | if (conversationId == null) { 210 | return; 211 | } 212 | setEditingName( 213 | conversations.find((c) => c.id === conversationId)?.name ?? "" 214 | ); 215 | setEditing(conversationId); 216 | return; 217 | } 218 | 219 | if (conversationId == null) { 220 | setCurrentConversation(null); 221 | setChatHistory([]); 222 | return; 223 | } 224 | setDisable(true); 225 | 226 | try { 227 | setCurrentConversation(conversationId); 228 | const data = await ChatAPI.getChatsByConversationId( 229 | conversationId, 230 | true 231 | ); 232 | if (!data) { 233 | return; 234 | } 235 | setChatHistory(data); 236 | } catch (e) { 237 | console.log("changeConversation: ", e); 238 | } finally { 239 | setDisable(false); 240 | } 241 | }, 242 | 200 243 | ); 244 | 245 | async function deleteConversation(conversationId: number) { 246 | if (conversationId == currentConversation) { 247 | setCurrentConversation(null); 248 | setChatHistory([]); 249 | } 250 | const data = await ConversationAPI.deleteConversation(conversationId); 251 | if (!data) { 252 | return; 253 | } 254 | setConversations( 255 | conversations.filter((conversation) => conversation.id !== conversationId) 256 | ); 257 | } 258 | 259 | async function deleteAllConversations() { 260 | const data = await ConversationAPI.deleteAllConversations(); 261 | if (!data) { 262 | return; 263 | } 264 | setConversations([]); 265 | setCurrentConversation(null); 266 | setChatHistory([]); 267 | } 268 | // FIXME anti-pattern, should use `useState` 269 | let codeMark = ""; 270 | async function sendMessage(prompt?: string) { 271 | const _message = message.length ? message : prompt; 272 | console.log(_message); 273 | if (!_message || _message.length === 0) { 274 | alert("Please enter your message first."); 275 | return; 276 | } 277 | 278 | try { 279 | setDisable(true); 280 | let _currentConversation = currentConversation; 281 | if (currentConversation == null) { 282 | const created = await createConversation(); 283 | _currentConversation = created?.id ?? null; 284 | setCurrentConversation(_currentConversation); 285 | if (!_currentConversation) return; 286 | } 287 | 288 | setMessage(""); 289 | let updatedHistory = [ 290 | ...chatHistory, 291 | { 292 | role: "user", 293 | content: _message, 294 | // TODO(CGQAQ): custom name of user 295 | // name: "User", 296 | }, 297 | ] as ResponseSend; 298 | 299 | setChatHistory([...updatedHistory]); 300 | 301 | if (isClientSideOpenAI()) { 302 | const _messages = await EdgeChatAPI.sendMessage( 303 | _currentConversation as number, 304 | _message 305 | ); 306 | setDisable(false); 307 | if (_messages && _messages.length) { 308 | setChatHistory([...updatedHistory, ..._messages]); 309 | } else { 310 | setDisable(false); 311 | setChatHistory([ 312 | ...updatedHistory.slice(0, updatedHistory.length - 1), 313 | ]); 314 | } 315 | return; 316 | } 317 | 318 | const data = await ChatAPI.sendMsgWithStreamRes( 319 | _currentConversation as number, 320 | _message 321 | ); 322 | if (!data) { 323 | setDisable(false); 324 | setChatHistory([...updatedHistory.slice(0, updatedHistory.length - 1)]); 325 | return; 326 | } 327 | const reader = data.getReader(); 328 | const decoder = new TextDecoder(); 329 | let isDone = false; 330 | while (!isDone) { 331 | const { value, done } = await reader.read(); 332 | isDone = done; 333 | const chunkValue = decoder.decode(value); 334 | const lines = chunkValue 335 | .split("\n") 336 | .filter((line) => line.trim() !== ""); 337 | for (const line of lines) { 338 | const message = line.replace(/^data: /, ""); 339 | if (message === "[DONE]") { 340 | setDisable(false); 341 | } else { 342 | const parsed = JSON.parse(message).choices[0].delta; 343 | if (parsed && Object.keys(parsed).length > 0) { 344 | if (!!parsed.role) { 345 | parsed.content = ""; 346 | updatedHistory = [...updatedHistory, parsed]; 347 | } else if (!!parsed.content) { 348 | if (parsed.content === "```") { 349 | // code block start 350 | if (!codeMark) { 351 | codeMark = parsed.content; 352 | } else { 353 | // code block end remove it 354 | codeMark = ""; 355 | } 356 | } 357 | updatedHistory[updatedHistory.length - 1].content += 358 | parsed.content; 359 | } 360 | setChatHistory([...updatedHistory]); 361 | } 362 | } 363 | } 364 | } 365 | } catch (err) { 366 | console.log(err); 367 | setDisable(false); 368 | } finally { 369 | // setDisable(false); 370 | } 371 | } 372 | 373 | async function logout() { 374 | await UserAPI.logout(); 375 | setIsLoggedIn(false); 376 | } 377 | 378 | const handleImageFileUpload: ChangeEventHandler = async ( 379 | e 380 | ) => { 381 | if (!e.target.files?.length) return; 382 | if (!currentConversation) { 383 | handleImageUploadReset(); 384 | return; 385 | } 386 | setDisable(true); 387 | const uploadedFile = e.target.files[0]; 388 | setFile(uploadedFile); 389 | const fileContent = await new Promise((resolve, _) => { 390 | const reader = new FileReader(); 391 | reader.onloadend = () => resolve(reader.result as string); 392 | reader.readAsDataURL(uploadedFile); 393 | }); 394 | const chats = chatHistory; 395 | let id = 1; 396 | chats.forEach((c) => { 397 | if (c.id && c.id >= id) id = c.id + 1; 398 | }); 399 | let chat = { 400 | id, 401 | conversation_id: currentConversation, 402 | role: "upload", 403 | content: fileContent as string, 404 | name: undefined, 405 | created_at: new Date().toISOString(), 406 | }; 407 | chats.push(chat); 408 | setChatHistory(chats); 409 | ChatAPI.saveChat(currentConversation, chat); 410 | const tags = await getTags(fileContent as string); 411 | chat = { 412 | id: id + 1, 413 | conversation_id: currentConversation, 414 | role: "info", 415 | content: JSON.stringify(tags), 416 | name: undefined, 417 | created_at: new Date().toISOString(), 418 | }; 419 | chats.push(chat); 420 | setChatHistory(chats); 421 | await ChatAPI.saveChat(currentConversation, chat); 422 | handleImageUploadReset(); 423 | setDisable(false); 424 | }; 425 | 426 | const handleDragOver: DragEventHandler = (e) => { 427 | e.preventDefault(); 428 | e.stopPropagation(); 429 | }; 430 | 431 | const handleDrop: DragEventHandler = (e) => { 432 | e.preventDefault(); 433 | e.stopPropagation(); 434 | const droppedFile = e.dataTransfer.files[0]; 435 | setFile(droppedFile); 436 | }; 437 | 438 | const handleImageUploadClick: MouseEventHandler = (e) => { 439 | if (fileInputRef && fileInputRef.current) { 440 | fileInputRef.current.click(); 441 | } 442 | }; 443 | 444 | const handleImageUploadReset = () => { 445 | if (fileInputRef && fileInputRef.current) { 446 | fileInputRef.current.value = ""; 447 | } 448 | setFile(new Blob()); 449 | }; 450 | 451 | const handleTagSelectedChange = (tags: string[]) => { 452 | setMessage(`${dict["tag_prompt"]}${tags.join(",")}`); 453 | }; 454 | 455 | return ( 456 |
457 | {/* left */} 458 |
459 |
463 | 464 | New chat 465 |
466 |
467 | {conversations.map((conversation) => ( 468 |
{ 476 | handleConversation(conversation.id!, event); 477 | }} 478 | > 479 | {editing === conversation.id ? ( 480 | { 484 | setEditingName(ev.currentTarget.value); 485 | }} 486 | onKeyDown={(ev) => { 487 | if (ev.key === "Enter" || ev.key === "NumpadEnter") { 488 | ev.preventDefault(); 489 | changeConversationName( 490 | conversation.id!, 491 | ev.currentTarget.value 492 | ).finally(() => { 493 | setEditing(null); 494 | }); 495 | } else if (ev.key === "Escape") { 496 | ev.preventDefault(); 497 | setEditing(null); 498 | } 499 | }} 500 | onBlur={async (ev) => { 501 | await changeConversationName( 502 | conversation.id!, 503 | ev.currentTarget.value 504 | ); 505 | setEditing(null); 506 | }} 507 | /> 508 | ) : ( 509 | <> 510 |
511 | {conversation.name} 512 |
513 | {/* delete button */} 514 |
{ 517 | e.stopPropagation(); 518 | if ( 519 | confirm("Are you sure to delete this conversation?") 520 | ) { 521 | deleteConversation(conversation.id!); 522 | } 523 | }} 524 | > 525 | 526 |
527 | 528 | )} 529 |
530 | ))} 531 |
532 |
533 |
{ 536 | e.stopPropagation(); 537 | if (confirm("Are you sure to delete ALL conversations?")) { 538 | deleteAllConversations(); 539 | } 540 | }} 541 | > 542 | 543 | Clear conversations 544 |
545 |
549 | 550 | Log out 551 |
552 |
553 |
554 | 555 | {/* right */} 556 |
557 | {/* {chatHistory.length === 0 && ( 558 | background image 559 | )} */} 560 | 561 | {/* chats */} 562 | 566 | {chatHistory.map((chat, index) => { 567 | return ( 568 |
569 | {chat && chat.role == "user" && ( 570 |
571 | {/* chat bubble badge */} 572 |
573 | 574 |
575 |
576 | )} 577 | {chat && chat.role == "assistant" && ( 578 |
579 |
580 | 583 |
584 |
585 | )} 586 | {chat && 587 | chat.role == "image" && 588 | chat.content.indexOf("data:image") != -1 && ( 589 |
590 |
591 | image 592 |
593 |
594 | )} 595 | {chat && 596 | chat.role == "upload" && 597 | chat.content.indexOf("data:image") != -1 ? ( 598 |
599 |
600 | image 601 |
602 |
603 | ) : ( 604 | <> 605 | )} 606 | {chat && 607 | chat.role == "info" && 608 | chat.content.indexOf("confidence") != -1 ? ( 609 |
610 |
611 | 617 |
618 |
619 | ) : ( 620 | <> 621 | )} 622 |
623 | ); 624 | })} 625 |
626 | 627 | 628 | setMessage(ev.target.value)} 633 | onKeyDown={onEnterForSendMessage} 634 | className="pr-10 md:w-9/12 border-0 md:pr-0 focus:ring-0" 635 | /> 636 | {disable ? ( 637 | 642 | ) : ( 643 | 644 | 649 | sendMessage()} 653 | /> 654 | 662 | 663 | )} 664 | 665 |
666 |
667 | ); 668 | }; 669 | --------------------------------------------------------------------------------