;
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 |
11 | );
12 | }
13 |
14 | export function ClickPromptIcon({ width = 32, height = 32 }) {
15 | return (
16 |
23 | );
24 | }
25 |
26 | export function ClickPromptSmall({ width = 32, height = 32 }) {
27 | return (
28 |
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 | [](https://github.com/prompt-engineering/chat-diffusion/actions/workflows/ci.yml)
4 | 
5 |
6 | [English](./README.md) | 简体中文
7 |
8 | 
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 |
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 | [](https://github.com/prompt-engineering/chat-diffusion/actions/workflows/ci.yml)
4 | 
5 | [](https://discord.gg/FSWXq4DmEj)
6 |
7 | English | [简体中文](./README.zh-CN.md)
8 |
9 | 
10 |
11 | Online Demo: [https://chat.fluoritestudio.com](https://chat.fluoritestudio.com)
12 |
13 | Join us:
14 |
15 | [](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 |
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 |
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 |
61 |
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 |
99 |
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 |
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 |
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 |
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 |
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 |
585 | )}
586 | {chat &&
587 | chat.role == "image" &&
588 | chat.content.indexOf("data:image") != -1 && (
589 |
590 |
591 |

592 |
593 |
594 | )}
595 | {chat &&
596 | chat.role == "upload" &&
597 | chat.content.indexOf("data:image") != -1 ? (
598 |
599 |
600 |

601 |
602 |
603 | ) : (
604 | <>>
605 | )}
606 | {chat &&
607 | chat.role == "info" &&
608 | chat.content.indexOf("confidence") != -1 ? (
609 |
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 |
--------------------------------------------------------------------------------