From b43c0b01099506fd928c0fd571453fa8d42db110 Mon Sep 17 00:00:00 2001 From: sijinhui Date: Sat, 16 Dec 2023 23:05:14 +0800 Subject: [PATCH 001/384] init --- .github/workflows/docker.yml | 52 - .github/workflows/dockerToHub-dev.yml | 69 + .github/workflows/dockerToHub.yml | 68 + .gitignore | 1 + Dockerfile | 24 +- README_CN.md | 1 + app/api/auth.ts | 25 +- app/api/auth/[...nextauth]/route.ts | 6 + app/api/common.ts | 50 +- app/api/logs/[...path]/route.ts | 28 + app/api/midjourney/[...path]/route.ts | 93 + app/api/openai/[...path]/route.ts | 90 +- app/app/(auth)/layout.tsx | 32 + app/app/(auth)/login/login-button.tsx | 54 + app/app/(auth)/login/page.tsx | 41 + app/app/(auth)/login/user-login-button.tsx | 121 + app/azure.ts | 13 +- app/client/api.ts | 29 +- app/client/platforms/openai.ts | 55 +- app/components/chat.module.scss | 46 +- app/components/chat.tsx | 283 +- app/components/emoji.tsx | 2 +- app/components/error.tsx | 2 + app/components/home.tsx | 38 +- app/components/icons/loading-circle.tsx | 22 + app/components/icons/loading-dots.module.scss | 40 + app/components/icons/loading-dots.tsx | 17 + app/components/icons/magic.tsx | 30 + app/components/markdown.tsx | 2 +- app/components/reward.tsx | 39 + app/components/settings.tsx | 847 +-- app/components/sidebar.tsx | 32 +- app/config/build.ts | 42 +- app/config/server.ts | 15 +- app/constant.ts | 94 +- app/global.d.ts | 2 +- app/icons/coffee.svg | 1 + app/layout.tsx | 30 +- app/locales/ar.ts | 289 - app/locales/bn.ts | 334 - app/locales/cn.ts | 52 +- app/locales/cs.ts | 238 - app/locales/de.ts | 240 - app/locales/en.ts | 50 +- app/locales/es.ts | 240 - app/locales/fr.ts | 309 - app/locales/id.ts | 385 -- app/locales/index.ts | 96 +- app/locales/it.ts | 240 - app/locales/jp.ts | 267 - app/locales/ko.ts | 237 - app/locales/no.ts | 161 - app/locales/pt.ts | 466 -- app/locales/ru.ts | 244 - app/locales/tr.ts | 241 - app/locales/tw.ts | 227 - app/locales/vi.ts | 236 - app/masks/cn.ts | 51 +- app/masks/en.ts | 2 +- app/page.tsx | 8 + app/providers.tsx | 6 + app/store/access.ts | 36 +- app/store/chat.ts | 419 +- app/store/config.ts | 30 +- app/store/mask.ts | 4 +- app/store/prompt.ts | 5 +- app/store/sync.ts | 6 +- app/store/update.ts | 63 +- app/styles/globals.scss | 29 + app/styles/login.scss | 59 + app/utils.ts | 24 +- app/utils/custom.ts | 16 + app/utils/hooks.ts | 9 +- app/utils/merge.ts | 10 +- app/utils/model.ts | 3 +- docker-compose.yml | 56 +- lib/auth.ts | 297 + lib/hooks/use-window-size.ts | 38 + lib/prisma.ts | 11 + lib/utils.ts | 59 + middleware.ts | 60 + next.config.mjs | 38 +- package.json | 56 +- postcss.config.js | 8 + prisma/schema.prisma | 134 + public/logo.png | Bin 0 -> 15013 bytes public/prompts.json | 659 +- public/site.webmanifest | 4 +- src-tauri/tauri.conf.json | 2 +- tailwind.config.js | 182 + yarn.lock | 6137 ----------------- 91 files changed, 3406 insertions(+), 12103 deletions(-) delete mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/dockerToHub-dev.yml create mode 100644 .github/workflows/dockerToHub.yml create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/logs/[...path]/route.ts create mode 100644 app/api/midjourney/[...path]/route.ts create mode 100644 app/app/(auth)/layout.tsx create mode 100644 app/app/(auth)/login/login-button.tsx create mode 100644 app/app/(auth)/login/page.tsx create mode 100644 app/app/(auth)/login/user-login-button.tsx create mode 100644 app/components/icons/loading-circle.tsx create mode 100644 app/components/icons/loading-dots.module.scss create mode 100644 app/components/icons/loading-dots.tsx create mode 100644 app/components/icons/magic.tsx create mode 100644 app/components/reward.tsx create mode 100644 app/icons/coffee.svg delete mode 100644 app/locales/ar.ts delete mode 100644 app/locales/bn.ts delete mode 100644 app/locales/cs.ts delete mode 100644 app/locales/de.ts delete mode 100644 app/locales/es.ts delete mode 100644 app/locales/fr.ts delete mode 100644 app/locales/id.ts delete mode 100644 app/locales/it.ts delete mode 100644 app/locales/jp.ts delete mode 100644 app/locales/ko.ts delete mode 100644 app/locales/no.ts delete mode 100644 app/locales/ru.ts delete mode 100644 app/locales/tr.ts delete mode 100644 app/locales/tw.ts delete mode 100644 app/locales/vi.ts create mode 100644 app/providers.tsx create mode 100644 app/styles/login.scss create mode 100644 app/utils/custom.ts create mode 100644 lib/auth.ts create mode 100644 lib/hooks/use-window-size.ts create mode 100644 lib/prisma.ts create mode 100644 lib/utils.ts create mode 100644 middleware.ts create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 public/logo.png create mode 100644 tailwind.config.js delete mode 100644 yarn.lock diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 8ac96f19356..00000000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Publish Docker image - -on: - workflow_dispatch: - release: - types: [published] - -jobs: - push_to_registry: - name: Push Docker image to Docker Hub - runs-on: ubuntu-latest - steps: - - - name: Check out the repo - uses: actions/checkout@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v4 - with: - images: yidadaa/chatgpt-next-web - tags: | - type=raw,value=latest - type=ref,event=tag - - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - - name: Build and push Docker image - uses: docker/build-push-action@v4 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - diff --git a/.github/workflows/dockerToHub-dev.yml b/.github/workflows/dockerToHub-dev.yml new file mode 100644 index 00000000000..2a60b719d53 --- /dev/null +++ b/.github/workflows/dockerToHub-dev.yml @@ -0,0 +1,69 @@ +name: DEV DEPLOY TO TX +on: + push: + branches: + - dev +# paths: +# - 'app/**' +# - 'public/**' +# - '.github/**' +# - 'docker-compose.yml' +# - 'Dockerfile' +# - 'package.json' + +jobs: + build: + name: build image to aly + # runs-on: "103.200" + runs-on: thinkpad + # runs-on: ubuntu-latest + # runs-on: self-hosted + steps: + - name: Check out the repo + uses: actions/checkout@v3 + with: + clean: true + ref: 'dev' + - name: build and deploy to Docker Hub + run: | + echo ${{ secrets.ALY_DOCKER_PASSWORD }} | docker login registry.cn-hangzhou.aliyuncs.com -u ${{ secrets.ALY_DOCKER_USERNAME }} --password-stdin + echo "${{ secrets.DOCKER_ENV }}" > .env + docker-compose build + docker-compose push + yes | docker system prune --filter "until=168h" + deploy: + name: 部署到dev服务器 + needs: build + runs-on: z4 + steps: + - name: Check out the repo + uses: actions/checkout@v3 + with: + clean: true + ref: 'dev' + - name: Set up SSH key + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + - name: Sync repository to ty + run: | + yes | docker image prune + rsync -az -e 'ssh -o StrictHostKeyChecking=no' --delete $GITHUB_WORKSPACE/ root@tx.xiaosi.cc:/data/ChatGPT-Next-Web + - name: deploy-to-ty + uses: appleboy/ssh-action@master + env: + SERVER_WORKDIR: ${{ secrets.SERVER_WORKDIR }} #传递工作目录变量 + with: + host: tx.xiaosi.cc #服务器地址 + username: root #用户名 + password: ${{ secrets.SERVER_PASSWORD }} #私钥 安全问题一定都以变量的方式传递!!! + envs: SERVER_WORKDIR,ALY_DOCKER_PASSWORD,ALY_DOCKER_USERNAME,DOCKER_ENV #使用工作目录变量 + script: | + cd $SERVER_WORKDIR #进入到工作目录 + echo "${{ secrets.DOCKER_ENV }}" > .env + echo ${{ secrets.ALY_DOCKER_PASSWORD }} | docker login registry.cn-hangzhou.aliyuncs.com -u ${{ secrets.ALY_DOCKER_USERNAME }} --password-stdin + docker-compose pull && docker-compose up -d + yes | docker image prune + rm -rf /www/server/nginx/proxy_cache_dir/* + rm -rf /www/server/nginx/proxy_temp_dir/* + sleep 2 diff --git a/.github/workflows/dockerToHub.yml b/.github/workflows/dockerToHub.yml new file mode 100644 index 00000000000..d9ac8a70e20 --- /dev/null +++ b/.github/workflows/dockerToHub.yml @@ -0,0 +1,68 @@ +name: PRO DEPLOY TO TY +on: + push: + branches: + - main +# paths: +# - 'app/**' +# - 'public/**' +# - '.github/**' +# - 'docker-compose.yml' +# - 'Dockerfile' +# - 'package.json' + +jobs: + build: + name: build image to aly + # runs-on: "103.200" + runs-on: thinkpad + # runs-on: ubuntu-latest + # runs-on: self-hosted + steps: + - name: Check out the repo + uses: actions/checkout@v3 + with: + clean: true + - name: build and deploy to Docker Hub + run: | + echo ${{ secrets.ALY_DOCKER_PASSWORD }} | docker login registry.cn-hangzhou.aliyuncs.com -u ${{ secrets.ALY_DOCKER_USERNAME }} --password-stdin + echo "${{ secrets.DOCKER_ENV }}" > .env + docker-compose build + docker-compose push + yes | docker system prune --filter "until=168h" + deploy: + name: 部署到服务器 + needs: build + runs-on: z4 + steps: + - name: Check out the repo + uses: actions/checkout@v3 + with: + clean: true + - name: Set up SSH key + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + - name: Sync repository to ty + run: | + yes | docker image prune + rsync -az -e 'ssh -o StrictHostKeyChecking=no' --delete $GITHUB_WORKSPACE/ root@ty.xiaosi.cc:/data/ChatGPT-Next-Web + - name: deploy-to-ty + uses: appleboy/ssh-action@master + env: + SERVER_WORKDIR: ${{ secrets.SERVER_WORKDIR }} #传递工作目录变量 + with: + host: ty.xiaosi.cc #服务器地址 + username: root #用户名 + password: ${{ secrets.SERVER_PASSWORD }} #私钥 安全问题一定都以变量的方式传递!!! + envs: SERVER_WORKDIR,ALY_DOCKER_PASSWORD,ALY_DOCKER_USERNAME,DOCKER_ENV #使用工作目录变量 + script: | + cd $SERVER_WORKDIR #进入到工作目录 + echo "${{ secrets.DOCKER_ENV }}" > .env + echo ${{ secrets.ALY_DOCKER_PASSWORD }} | docker login registry.cn-hangzhou.aliyuncs.com -u ${{ secrets.ALY_DOCKER_USERNAME }} --password-stdin + docker-compose pull && docker-compose up -d + yes | docker image prune + rm -rf /www/server/nginx/proxy_cache_dir/* + rm -rf /www/server/nginx/proxy_temp_dir/* + sleep 2 + tccli cdn PurgePathCache --cli-unfold-argument --Paths 'https://chat.xiaosi.cc/' --FlushType delete diff --git a/.gitignore b/.gitignore index b00b0e325a4..92a551faef4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +#yarn.lock # local env files .env*.local diff --git a/Dockerfile b/Dockerfile index 720a0cfe959..929faa12c50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,35 @@ +#FROM registry.cn-hangzhou.aliyuncs.com/sijinhui/node:18-alpine AS base FROM node:18-alpine AS base +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories +RUN apk update && apk add --no-cache git tzdata +# 设置时区环境变量 +ENV TZ=Asia/Chongqing +# 更新并安装时区工具 +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone FROM base AS deps - -RUN apk add --no-cache libc6-compat +RUN apk add --no-cache libc6-compat g++ make WORKDIR /app -COPY package.json yarn.lock ./ +COPY package.json ./ RUN yarn config set registry 'https://registry.npmmirror.com/' +RUN yarn config set sharp_binary_host "https://npm.taobao.org/mirrors/sharp" +RUN yarn config set sharp_libvips_binary_host "https://npm.taobao.org/mirrors/sharp-libvips" +RUN # 清理遗留的缓存 +RUN yarn cache clean RUN yarn install FROM base AS builder -RUN apk update && apk add --no-cache git - ENV OPENAI_API_KEY="" ENV CODE="" WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules COPY . . +RUN rm -rf ./node_modules +COPY --from=deps /app/node_modules ./node_modules RUN yarn build @@ -38,7 +47,10 @@ COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/server ./.next/server +RUN rm -f .env + EXPOSE 3000 +ENV KEEP_ALIVE_TIMEOUT=30 CMD if [ -n "$PROXY_URL" ]; then \ export HOSTNAME="127.0.0.1"; \ diff --git a/README_CN.md b/README_CN.md index 08b38557389..d734796581b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -205,6 +205,7 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s [见项目贡献者列表](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors) ### 相关项目 + - [one-api](https://github.com/songquanpeng/one-api): 一站式大模型额度管理平台,支持市面上所有主流大语言模型 ## 开源协议 diff --git a/app/api/auth.ts b/app/api/auth.ts index b41e34e059b..2f06a31d91c 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -3,12 +3,13 @@ import { getServerSideConfig } from "../config/server"; import md5 from "spark-md5"; import { ACCESS_CODE_PREFIX } from "../constant"; -function getIP(req: NextRequest) { - let ip = req.ip ?? req.headers.get("x-real-ip"); +export function getIP(req: NextRequest) { + let ip = req.headers.get("x-real-ip") ?? req.ip; + const forwardedFor = req.headers.get("x-forwarded-for"); - if (!ip && forwardedFor) { - ip = forwardedFor.split(",").at(0) ?? ""; + if (forwardedFor) { + ip = forwardedFor.split(",").at(0) ?? ip; } return ip; @@ -24,7 +25,7 @@ function parseApiKey(bearToken: string) { }; } -export function auth(req: NextRequest) { +export function auth(req: NextRequest, isAzure?: boolean) { const authToken = req.headers.get("Authorization") ?? ""; // check if it is openai api key or user token @@ -33,11 +34,11 @@ export function auth(req: NextRequest) { const hashedCode = md5.hash(accessCode ?? "").trim(); const serverConfig = getServerSideConfig(); - console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]); - console.log("[Auth] got access code:", accessCode); - console.log("[Auth] hashed access code:", hashedCode); - console.log("[User IP] ", getIP(req)); - console.log("[Time] ", new Date().toLocaleString()); + // console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]); + // console.log("[Auth] got access code:", accessCode); + // console.log("[Auth] hashed access code:", hashedCode); + // console.log("[User IP] ", getIP(req)); + // console.log("[Time]", new Date().toLocaleString()); if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !apiKey) { return { @@ -55,7 +56,7 @@ export function auth(req: NextRequest) { // if user does not provide an api key, inject system api key if (!apiKey) { - const serverApiKey = serverConfig.isAzure + const serverApiKey = isAzure ? serverConfig.azureApiKey : serverConfig.apiKey; @@ -63,7 +64,7 @@ export function auth(req: NextRequest) { console.log("[Auth] use system api key"); req.headers.set( "Authorization", - `${serverConfig.isAzure ? "" : "Bearer "}${serverApiKey}`, + `${isAzure ? "" : "Bearer "}${serverApiKey}`, ); } else { console.log("[Auth] admin did not provide an api key"); diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000000..ca0b5b4a861 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import { authOptions } from "@/lib/auth"; +import NextAuth from "next-auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/app/api/common.ts b/app/api/common.ts index 6b0d619df1d..445c7af0c01 100644 --- a/app/api/common.ts +++ b/app/api/common.ts @@ -6,19 +6,23 @@ import { makeAzurePath } from "../azure"; const serverConfig = getServerSideConfig(); -export async function requestOpenai(req: NextRequest) { +export async function requestOpenai( + req: NextRequest, + cloneBody: any, + isAzure: boolean, +) { const controller = new AbortController(); const authValue = req.headers.get("Authorization") ?? ""; - const authHeaderName = serverConfig.isAzure ? "api-key" : "Authorization"; + const authHeaderName = isAzure ? "api-key" : "Authorization"; let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll( "/api/openai/", "", ); - - let baseUrl = - serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL; + let baseUrl = isAzure + ? serverConfig.azureUrl + : serverConfig.baseUrl || OPENAI_BASE_URL; if (!baseUrl.startsWith("http")) { baseUrl = `https://${baseUrl}`; @@ -28,12 +32,12 @@ export async function requestOpenai(req: NextRequest) { baseUrl = baseUrl.slice(0, -1); } - console.log("[Proxy] ", path); - console.log("[Base Url]", baseUrl); - // this fix [Org ID] undefined in server side if not using custom point - if (serverConfig.openaiOrgId !== undefined) { - console.log("[Org ID]", serverConfig.openaiOrgId); - } + // console.log("[Proxy] ", path); + // console.log("[Base Url]", baseUrl); + // // this fix [Org ID] undefined in server side if not using custom point + // if (serverConfig.openaiOrgId !== undefined) { + // console.log("[Org ID]", serverConfig.openaiOrgId); + // } const timeoutId = setTimeout( () => { @@ -42,16 +46,6 @@ export async function requestOpenai(req: NextRequest) { 10 * 60 * 1000, ); - if (serverConfig.isAzure) { - if (!serverConfig.azureApiVersion) { - return NextResponse.json({ - error: true, - message: `missing AZURE_API_VERSION in server env vars`, - }); - } - path = makeAzurePath(path, serverConfig.azureApiVersion); - } - const fetchUrl = `${baseUrl}/${path}`; const fetchOptions: RequestInit = { headers: { @@ -63,7 +57,7 @@ export async function requestOpenai(req: NextRequest) { }), }, method: req.method, - body: req.body, + body: cloneBody, // to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body redirect: "manual", // @ts-ignore @@ -72,19 +66,21 @@ export async function requestOpenai(req: NextRequest) { }; // #1815 try to refuse gpt4 request - if (serverConfig.customModels && req.body) { + if (serverConfig.customModels && cloneBody) { try { const modelTable = collectModelTable( DEFAULT_MODELS, serverConfig.customModels, ); - const clonedBody = await req.text(); - fetchOptions.body = clonedBody; + // const clonedBody = await req.text(); + fetchOptions.body = cloneBody; - const jsonBody = JSON.parse(clonedBody) as { model?: string }; + const jsonBody = JSON.parse(cloneBody) as { + model?: string; + }; // not undefined and is false - if (modelTable[jsonBody?.model ?? ""].available === false) { + if (!modelTable[jsonBody?.model ?? ""].available) { return NextResponse.json( { error: true, diff --git a/app/api/logs/[...path]/route.ts b/app/api/logs/[...path]/route.ts new file mode 100644 index 00000000000..47b6951da95 --- /dev/null +++ b/app/api/logs/[...path]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { insertUser } from "@/lib/auth"; + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + try { + const request_data = await req.json(); + if (request_data?.userName) { + await insertUser({ name: request_data?.userName }); + } + // console.log("===========4", request_data); + await prisma.logEntry.create({ + data: request_data, + }); + } catch (e) { + return NextResponse.json({ status: 0 }); + // console.log("[LOG]", e); + } + + return NextResponse.json({ status: 1 }); +} +export const GET = handle; +export const POST = handle; + +// export const runtime = "edge"; diff --git a/app/api/midjourney/[...path]/route.ts b/app/api/midjourney/[...path]/route.ts new file mode 100644 index 00000000000..e0a4fa74393 --- /dev/null +++ b/app/api/midjourney/[...path]/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/app/api/auth"; + +const BASE_URL = process.env.MIDJOURNEY_PROXY_URL ?? null; +const MIDJOURNEY_PROXY_KEY = process.env.MIDJOURNEY_PROXY_KEY ?? null; + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Midjourney Route] params ", params); + + const customMjProxyUrl = req.headers.get("midjourney-proxy-url"); + let mjProxyUrl = BASE_URL; + if ( + customMjProxyUrl && + (customMjProxyUrl.startsWith("http://") || + customMjProxyUrl.startsWith("https://")) + ) { + mjProxyUrl = customMjProxyUrl; + } + + if (!mjProxyUrl) { + return NextResponse.json( + { + error: true, + msg: "please set MIDJOURNEY_PROXY_URL in .env or set midjourney-proxy-url in config", + }, + { + status: 500, + }, + ); + } + let cloneBody, jsonBody; + + try { + cloneBody = (await req.text()) as any; + jsonBody = JSON.parse(cloneBody) as { model?: string }; + } catch (e) { + jsonBody = {}; + } + + const authResult = auth(req); + // if (authResult.error) { + // return NextResponse.json(authResult, { + // status: 401, + // }); + // } + + const reqPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll( + "/api/midjourney/", + "", + ); + + let fetchUrl = `${mjProxyUrl}/${reqPath}`; + + console.log("[MJ Proxy] ", fetchUrl); + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 15 * 1000); + + const fetchOptions: RequestInit = { + //@ts-ignore + headers: { + "Content-Type": "application/json", + Authorization: MIDJOURNEY_PROXY_KEY, + // "mj-api-secret": API_SECRET, + }, + cache: "no-store", + method: req.method, + body: cloneBody, + signal: controller.signal, + //@ts-ignore + // duplex: "half", + }; + try { + const res = await fetch(fetchUrl, fetchOptions); + if (res.status !== 200) { + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + }); + } + + return res; + } finally { + clearTimeout(timeoutId); + } +} + +export const GET = handle; +export const POST = handle; diff --git a/app/api/openai/[...path]/route.ts b/app/api/openai/[...path]/route.ts index 2addd53a52d..171d5364bd5 100644 --- a/app/api/openai/[...path]/route.ts +++ b/app/api/openai/[...path]/route.ts @@ -1,12 +1,14 @@ import { type OpenAIListModelResponse } from "@/app/client/platforms/openai"; import { getServerSideConfig } from "@/app/config/server"; -import { OpenaiPath } from "@/app/constant"; +import { OpenaiPath, AZURE_PATH, AZURE_MODELS } from "@/app/constant"; import { prettyObject } from "@/app/utils/format"; import { NextRequest, NextResponse } from "next/server"; -import { auth } from "../../auth"; +import { auth, getIP } from "../../auth"; +import { getToken } from "next-auth/jwt"; import { requestOpenai } from "../../common"; +import { headers } from "next/headers"; -const ALLOWD_PATH = new Set(Object.values(OpenaiPath)); +const ALLOWD_PATH = new Set(Object.values({ ...OpenaiPath, ...AZURE_PATH })); function getModels(remoteModelRes: OpenAIListModelResponse) { const config = getServerSideConfig(); @@ -17,6 +19,15 @@ function getModels(remoteModelRes: OpenAIListModelResponse) { ); } + console.log(remoteModelRes.data); + // 过滤不需要的模型 + remoteModelRes.data = remoteModelRes.data.filter( + (m) => + m.id === "gpt-4-0613" || + m.id === "gpt-3.5-turbo-16k" || + m.id === "gpt-4-32k", + ); + return remoteModelRes; } @@ -24,7 +35,7 @@ async function handle( req: NextRequest, { params }: { params: { path: string[] } }, ) { - console.log("[OpenAI Route] params ", params); + // console.log("[OpenAI Route] params ", params); if (req.method === "OPTIONS") { return NextResponse.json({ body: "OK" }, { status: 200 }); @@ -44,16 +55,57 @@ async function handle( }, ); } + let cloneBody, jsonBody; + + try { + cloneBody = (await req.text()) as any; + jsonBody = JSON.parse(cloneBody) as { model?: string }; + } catch (e) { + jsonBody = {}; + } - const authResult = auth(req); - if (authResult.error) { - return NextResponse.json(authResult, { - status: 401, + try { + const protocol = req.headers.get("x-forwarded-proto") || "http"; + const baseUrl = process.env.NEXTAUTH_URL ?? "http://localhost:3000"; + const ip = getIP(req); + // 对其进行 Base64 解码 + let h_userName = req.headers.get("x-request-name"); + if (h_userName) { + const buffer = Buffer.from(h_userName, "base64"); + h_userName = decodeURIComponent(buffer.toString("utf-8")); + } + console.log("[中文]", h_userName, baseUrl); + const logData = { + ip: ip, + path: subpath, + logEntry: JSON.stringify(jsonBody), + model: jsonBody?.model, + userName: h_userName, + }; + + await fetch(`${baseUrl}/api/logs/openai`, { + method: "POST", + headers: { + "Content-Type": "application/json", + // ...req.headers, + }, + body: JSON.stringify(logData), }); + } catch (e) { + console.log("[LOG]", e, "=========="); } + const isAzure = AZURE_MODELS.includes(jsonBody?.model as string); + // console.log("[Models]", jsonBody?.model); + const authResult = auth(req, isAzure); + // if (authResult.error) { + // return NextResponse.json(authResult, { + // status: 401, + // }); + // } + try { - const response = await requestOpenai(req); + const response = await requestOpenai(req, cloneBody, isAzure); // list models if (subpath === OpenaiPath.ListModelPath && response.status === 200) { @@ -75,4 +127,22 @@ export const GET = handle; export const POST = handle; export const runtime = "edge"; -export const preferredRegion = ['arn1', 'bom1', 'cdg1', 'cle1', 'cpt1', 'dub1', 'fra1', 'gru1', 'hnd1', 'iad1', 'icn1', 'kix1', 'lhr1', 'pdx1', 'sfo1', 'sin1', 'syd1']; +export const preferredRegion = [ + "arn1", + "bom1", + "cdg1", + "cle1", + "cpt1", + "dub1", + "fra1", + "gru1", + "hnd1", + "iad1", + "icn1", + "kix1", + "lhr1", + "pdx1", + "sfo1", + "sin1", + "syd1", +]; diff --git a/app/app/(auth)/layout.tsx b/app/app/(auth)/layout.tsx new file mode 100644 index 00000000000..57458206ef1 --- /dev/null +++ b/app/app/(auth)/layout.tsx @@ -0,0 +1,32 @@ +import "@/app/styles/login.scss"; +import { Metadata } from "next"; +import { ReactNode } from "react"; +// import { useEffect } from "react"; +// import {useSession} from "next-auth/react"; +import { getSession, isName } from "@/lib/auth"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Login | 实人认证", +}; + +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + const session = await getSession(); + // If the user is already authenticated, redirect them to home + if (session?.user?.name && isName(session.user.name)) { + // Replace '/dashboard' with the desired redirect path + redirect("/"); + } + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/app/app/(auth)/login/login-button.tsx b/app/app/(auth)/login/login-button.tsx new file mode 100644 index 00000000000..309774f9d1e --- /dev/null +++ b/app/app/(auth)/login/login-button.tsx @@ -0,0 +1,54 @@ +"use client"; + +import LoadingDots from "@/app/components/icons/loading-dots"; +import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { useState, useEffect } from "react"; +import { toast } from "sonner"; + +export default function LoginButton() { + const [loading, setLoading] = useState(false); + + // Get error message added by next/auth in URL. + const searchParams = useSearchParams(); + const error = searchParams?.get("error"); + + useEffect(() => { + const errorMessage = Array.isArray(error) ? error.pop() : error; + errorMessage && toast.error(errorMessage); + }, [error]); + + return ( + + ); +} diff --git a/app/app/(auth)/login/page.tsx b/app/app/(auth)/login/page.tsx new file mode 100644 index 00000000000..69b60a6e3e5 --- /dev/null +++ b/app/app/(auth)/login/page.tsx @@ -0,0 +1,41 @@ +import Image from "next/image"; +import LoginButton from "./login-button"; +import UserLoginButton from "./user-login-button"; +import { Suspense } from "react"; + +export default function LoginPage() { + return ( +
+ Platforms Starter Kit +

+ Sign in to your account +

+ +
+ + } + > + + +
+
+
+ + } + > + + +
+
+ ); +} diff --git a/app/app/(auth)/login/user-login-button.tsx b/app/app/(auth)/login/user-login-button.tsx new file mode 100644 index 00000000000..1c4d80e0a7f --- /dev/null +++ b/app/app/(auth)/login/user-login-button.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import React, { useState, useEffect, useRef } from "react"; +import { isName } from "@/lib/auth"; + +export default function UserLoginButton() { + const [loading, setLoading] = useState(false); + + const nameInput = useRef(null); + const [username, setUsername] = useState(""); + const [error, setError] = useState(false); + + const handleComposition = (e: React.CompositionEvent) => { + if (e.type === "compositionend") { + setUsername(e.currentTarget.value); + } + }; + const onNameChange = (e: React.ChangeEvent) => { + if ((e.nativeEvent as InputEvent).isComposing) { + return; + } + setUsername(e.target.value); + }; + const onSubmitHandler = async (e: React.FormEvent) => { + // handle yow submition + setLoading(true); + e.preventDefault(); + + console.log("current,username2", username); + const result = await signIn("credentials", { + username: username, + redirect: false, + }); + setLoading(false); + if (!result?.error) { + window.location.href = "/"; + } else setError(true); + }; + + useEffect(() => { + if (nameInput.current) { + if (!isName(username)) { + setError(true); + nameInput.current.setCustomValidity("用户名校验失败"); + } else { + setError(false); + nameInput.current.setCustomValidity(""); + } + } + // console.log("username:", username); + }, [username]); + + return ( + <> + {/* + This example requires updating your template: + + ``` + + + ``` + */} + +
+
+
+
+ e.preventDefault()} + onCompositionEnd={handleComposition} + onChange={onNameChange} + required + placeholder="输入姓名、拼音或邮箱" + className={`${ + loading + ? "cursor-not-allowed bg-stone-50 dark:bg-stone-800" + : "bg-white hover:bg-stone-50 active:bg-stone-100 dark:bg-black dark:hover:border-white dark:hover:bg-black" + } group my-2 flex h-10 w-full items-center justify-center space-x-2 rounded-md border border-stone-200 transition-colors duration-75 focus:outline-none dark:border-stone-700 + ${ + error + ? "focus:invalid:border-red-500 focus:invalid:ring-red-500" + : "" + } + `} + /> + {/*{error &&

{error}

}*/} +
+
+ +
+ +
+
+
+ {/**/} + + ); +} diff --git a/app/azure.ts b/app/azure.ts index 48406c55ba5..05d686dd720 100644 --- a/app/azure.ts +++ b/app/azure.ts @@ -1,7 +1,14 @@ -export function makeAzurePath(path: string, apiVersion: string) { +export function makeAzurePath( + path: string, + apiVersion: string, + azureModel?: string, +) { // should omit /v1 prefix - path = path.replaceAll("v1/", ""); - + // path = path.replaceAll("v1/", ""); + path = path.replaceAll( + "v1/chat/completions", + `openai/deployments/${azureModel}/chat/completions`, + ); // should add api-key to query string path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`; diff --git a/app/client/api.ts b/app/client/api.ts index eedd2c9ab48..e5c8e628409 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -1,12 +1,22 @@ import { getClientConfig } from "../config/client"; -import { ACCESS_CODE_PREFIX, Azure, ServiceProvider } from "../constant"; +import { + ACCESS_CODE_PREFIX, + Azure, + AZURE_MODELS, + ServiceProvider, +} from "../constant"; import { ChatMessage, ModelType, useAccessStore } from "../store"; import { ChatGPTApi } from "./platforms/openai"; export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; -export const Models = ["gpt-3.5-turbo", "gpt-4"] as const; +export const Models = [ + "gpt-3.5-turbo-16k", + "gpt-4-0613", + "gpt-4-32k", + "midjourney", +] as const; export type ChatModel = ModelType; export interface RequestMessage { @@ -40,6 +50,7 @@ export interface LLMUsage { export interface LLMModel { name: string; + describe: string; available: boolean; } @@ -125,14 +136,15 @@ export class ClientApi { export const api = new ClientApi(); -export function getHeaders() { +export function getHeaders(isAzure?: boolean) { const accessStore = useAccessStore.getState(); const headers: Record = { "Content-Type": "application/json", "x-requested-with": "XMLHttpRequest", }; + // const isAzure = AZURE_MODELS.includes(jsonBody?.model as string) + // const isAzure = accessStore.provider === ServiceProvider.Azure; - const isAzure = accessStore.provider === ServiceProvider.Azure; const authHeader = isAzure ? "api-key" : "Authorization"; const apiKey = isAzure ? accessStore.azureApiKey : accessStore.openaiApiKey; @@ -151,5 +163,14 @@ export function getHeaders() { ); } + if (validString(accessStore.midjourneyProxyUrl)) { + headers["midjourney-proxy-url"] = accessStore.midjourneyProxyUrl; + } return headers; } + +export function useGetMidjourneySelfProxyUrl(url: string) { + const accessStore = useAccessStore.getState(); + console.log("useMjImgSelfProxy", accessStore.useMjImgSelfProxy); + return url; +} diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index 8ea864692d5..f1ce369a889 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -1,5 +1,6 @@ import { ApiPath, + AZURE_MODELS, DEFAULT_API_HOST, DEFAULT_MODELS, OpenaiPath, @@ -30,10 +31,10 @@ export interface OpenAIListModelResponse { export class ChatGPTApi implements LLMApi { private disableListModels = true; - path(path: string): string { + path(path: string, isAzure?: boolean, azureModel?: string): string { const accessStore = useAccessStore.getState(); - const isAzure = accessStore.provider === ServiceProvider.Azure; + // const isAzure = accessStore.provider === ServiceProvider.Azure; if (isAzure && !accessStore.isValidAzure()) { throw Error( @@ -56,7 +57,7 @@ export class ChatGPTApi implements LLMApi { } if (isAzure) { - path = makeAzurePath(path, accessStore.azureApiVersion); + path = makeAzurePath(path, accessStore.azureApiVersion, azureModel); } return [baseUrl, path].join("/"); @@ -79,7 +80,7 @@ export class ChatGPTApi implements LLMApi { model: options.config.model, }, }; - + const is_azure = AZURE_MODELS.includes(modelConfig.model); const requestPayload = { messages, stream: options.config.stream, @@ -92,21 +93,24 @@ export class ChatGPTApi implements LLMApi { // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. }; - console.log("[Request] openai payload: ", requestPayload); + // console.log("[Request] openai payload: ", requestPayload); const shouldStream = !!options.config.stream; const controller = new AbortController(); options.onController?.(controller); try { - const chatPath = this.path(OpenaiPath.ChatPath); + let chatPath = this.path( + OpenaiPath.ChatPath, + is_azure, + modelConfig.model, + ); const chatPayload = { method: "POST", body: JSON.stringify(requestPayload), signal: controller.signal, - headers: getHeaders(), + headers: getHeaders(is_azure), }; - // make a fetch request const requestTimeoutId = setTimeout( () => controller.abort(), @@ -154,10 +158,10 @@ export class ChatGPTApi implements LLMApi { async onopen(res) { clearTimeout(requestTimeoutId); const contentType = res.headers.get("content-type"); - console.log( - "[OpenAI] request response content type: ", - contentType, - ); + // console.log( + // "[OpenAI] request response content type: ", + // contentType, + // ); if (contentType?.startsWith("text/plain")) { responseText = await res.clone().text(); @@ -172,19 +176,25 @@ export class ChatGPTApi implements LLMApi { res.status !== 200 ) { const responseTexts = [responseText]; - let extraInfo = await res.clone().text(); + // let extraInfo = await res.clone().text(); try { const resJson = await res.clone().json(); - extraInfo = prettyObject(resJson); - } catch {} - - if (res.status === 401) { - responseTexts.push(Locale.Error.Unauthorized); + responseTexts.push(prettyObject(resJson)); + } catch { + responseTexts.push(Locale.Error.BACKEND_ERR); } - if (extraInfo) { - responseTexts.push(extraInfo); - } + // if (res.status === 401) { + // responseTexts.push(Locale.Error.Unauthorized); + // } else if (res.status === 404) { + // responseTexts.push(Locale.Error.NOT_FOUND_ERR); + // } + // if (res.status > 400) { + // responseTexts.push(Locale.Error.BACKEND_ERR); + // } + // else if (extraInfo) { + // responseTexts.push(extraInfo); + // } responseText = responseTexts.join("\n\n"); @@ -314,7 +324,7 @@ export class ChatGPTApi implements LLMApi { const resJson = (await res.json()) as OpenAIListModelResponse; const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-")); - console.log("[Models]", chatModels); + // console.log("[Models]", chatModels); if (!chatModels) { return []; @@ -323,6 +333,7 @@ export class ChatGPTApi implements LLMApi { return chatModels.map((m) => ({ name: m.id, available: true, + describe: "", })); } } diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index 16790ccb1db..32300a9c4cd 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -34,7 +34,7 @@ } &:hover { - --delay: 0.5s; + --delay: 0.3s; width: var(--full-width); transition-delay: var(--delay); @@ -52,6 +52,17 @@ justify-content: center; } } + + .chat-input-action-long-weight { + width: var(--full-width); + .text { + white-space: nowrap; + padding-left: 5px; + opacity: 1; + transform: translate(0); + pointer-events: none; + } + } } .prompt-toast { @@ -381,6 +392,39 @@ transition: all ease 0.3s; } +.chat-model-mj{ + img{ + width: 280px; + } +} + +.chat-message-action-btn{ + font-size: 12px; + background-color: var(--white); + color: var(--black); + + border: var(--border-in-light); + box-shadow: var(--card-shadow); + padding: 8px 16px; + border-radius: 16px; + + animation: slide-in-from-top ease 0.3s; + transition: all .3s; + cursor: pointer; + margin: 2px 2px; +} + +.chat-select-images{ + margin-bottom: 10px; + img{ + width:80px; + height: 80px; + margin: 0 5px; + border-radius: 10px; + border:1px dashed var(--color-border-muted); + } +} + .chat-message-action-date { font-size: 12px; opacity: 0.2; diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 39abdd97b24..4f1c6c764cc 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -34,6 +34,7 @@ import AutoIcon from "../icons/auto.svg"; import BottomIcon from "../icons/bottom.svg"; import StopIcon from "../icons/pause.svg"; import RobotIcon from "../icons/robot.svg"; +import UploadIcon from "../icons/upload.svg"; import { ChatMessage, @@ -50,6 +51,7 @@ import { import { copyToClipboard, + downloadAs, selectOrCopy, autoGrowTextArea, useMobileScreen, @@ -88,6 +90,8 @@ import { ChatCommandPrefix, useChatCommand, useCommand } from "../command"; import { prettyObject } from "../utils/format"; import { ExportMessageModal } from "./exporter"; import { getClientConfig } from "../config/client"; +import { Button } from "emoji-picker-react/src/components/atoms/Button"; +import Image from "next/image"; import { useAllModels } from "../utils/hooks"; const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { @@ -337,6 +341,10 @@ function ChatAction(props: { full: 16, icon: 16, }); + const allModels = useAllModels().map((item) => item.displayName); + const customModelClassName = allModels.includes(props.text) + ? "chat-input-action-long-weight" + : ""; function updateWidth() { if (!iconRef.current || !textRef.current) return; @@ -349,9 +357,15 @@ function ChatAction(props: { }); } + useEffect(() => { + if (customModelClassName !== "") { + updateWidth(); + } + }, [props.text, customModelClassName]); + return (
{ props.onClick(); setTimeout(updateWidth, 1); @@ -409,6 +423,7 @@ export function ChatActions(props: { showPromptModal: () => void; scrollToBottom: () => void; showPromptHints: () => void; + imageSelected: (img: any) => void; hitBottom: boolean; }) { const config = useAppConfig(); @@ -429,6 +444,25 @@ export function ChatActions(props: { const couldStop = ChatControllerPool.hasPending(); const stopAll = () => ChatControllerPool.stopAll(); + function selectImage() { + document.getElementById("chat-image-file-select-upload")?.click(); + } + + const onImageSelected = (e: any) => { + const file = e.target.files[0]; + const filename = file.name; + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + const base64 = reader.result; + props.imageSelected({ + filename, + base64, + }); + }; + e.target.value = null; + }; + // switch model const currentModel = chatStore.currentSession().mask.modelConfig.model; const allModels = useAllModels(); @@ -467,13 +501,13 @@ export function ChatActions(props: { icon={} /> )} - {props.hitBottom && ( - } - /> - )} + {/*{props.hitBottom && (*/} + {/* }*/} + {/* />*/} + {/*)}*/} - } - /> + {/*}*/} + {/*/>*/} - { - navigate(Path.Masks); - }} - text={Locale.Chat.InputActions.Masks} - icon={} - /> + {/* {*/} + {/* navigate(Path.Masks);*/} + {/* }}*/} + {/* text={Locale.Chat.InputActions.Masks}*/} + {/* icon={}*/} + {/*/>*/} } /> + } + /> + + {showModelSelector && ( ({ title: m.displayName, + subTitle: m.describe, value: m.name, }))} onClose={() => setShowModelSelector(false)} @@ -622,6 +670,8 @@ function _Chat() { const inputRef = useRef(null); const [userInput, setUserInput] = useState(""); + const [useImages, setUseImages] = useState([]); + const [mjImageMode, setMjImageMode] = useState("IMAGINE"); const [isLoading, setIsLoading] = useState(false); const { submitKey, shouldSubmit } = useSubmitHandler(); const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom(); @@ -697,17 +747,33 @@ function _Chat() { const doSubmit = (userInput: string) => { if (userInput.trim() === "") return; - const matchCommand = chatCommands.match(userInput); - if (matchCommand.matched) { - setUserInput(""); - setPromptHints([]); - matchCommand.invoke(); - return; + if (useImages.length > 0) { + if (mjImageMode === "IMAGINE" && userInput == "") { + alert(Locale.Midjourney.NeedInputUseImgPrompt); + return; + } + } else { + const matchCommand = chatCommands.match(userInput); + if (matchCommand.matched) { + setUserInput(""); + setPromptHints([]); + matchCommand.invoke(); + return; + } } setIsLoading(true); - chatStore.onUserInput(userInput).then(() => setIsLoading(false)); + + chatStore + .onUserInput(userInput, { + useImages, + mjImageMode, + setAutoScroll, + }) + .then(() => setIsLoading(false)); localStorage.setItem(LAST_INPUT_KEY, userInput); setUserInput(""); + setUseImages([]); + setMjImageMode("IMAGINE"); setPromptHints([]); if (!isMobileScreen) inputRef.current?.focus(); setAutoScroll(true); @@ -1031,6 +1097,12 @@ function _Chat() { // edit / insert message modal const [isEditingMessage, setIsEditingMessage] = useState(false); + messages?.forEach((msg) => { + if (msg.model === "midjourney" && msg.attr.taskId) { + chatStore.fetchMidjourneyStatus(msg); + } + }); + // remember unfinished input useEffect(() => { // try to load from local storage @@ -1238,17 +1310,109 @@ function _Chat() { message.content.length === 0 && !isUser } - onContextMenu={(e) => onRightClick(e, message)} - onDoubleClickCapture={() => { - if (!isMobileScreen) return; - setUserInput(message.content); - }} + // onContextMenu={(e) => onRightClick(e, message)} + // onDoubleClickCapture={() => { + // if (!isMobileScreen) return; + // setUserInput(message.content); + // }} fontSize={fontSize} parentRef={scrollRef} defaultShow={i >= messages.length - 6} />
+ {!isUser && + message.model == "midjourney" && + message.attr?.finished && + ["VARIATION", "IMAGINE", "BLEND"].includes( + message.attr?.action, + ) && ( +
+
+ + + + + {/**/} +
+
+ + + + +
+
+ )} +
{isContext ? Locale.Chat.IsContext @@ -1280,12 +1444,65 @@ function _Chat() { setUserInput("/"); onSearch(""); }} + imageSelected={(img: any) => { + if (useImages.length >= 5) { + alert(Locale.Midjourney.SelectImgMax(5)); + return; + } + setUseImages([...useImages, img]); + }} /> + {useImages.length > 0 && ( +
+ {useImages.map((img: any, i) => ( + { + setUseImages(useImages.filter((_, ii) => ii != i)); + }} + title={img.filename} + alt={img.filename} + width={20} + height={20} + /> + ))} +
+ {[ + { name: Locale.Midjourney.ModeImagineUseImg, value: "IMAGINE" }, + // { name: Locale.Midjourney.ModeBlend, value: "BLEND" }, + // { name: Locale.Midjourney.ModeDescribe, value: "DESCRIBE" }, + ].map((item, i) => ( + + ))} +
+
+ {Locale.Midjourney.HasImgTip} +
+
+ )} +