diff --git a/.dockerignore b/.dockerignore index 60da41dd8..95ed9e268 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,97 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Node.js dependencies +/node_modules +/jspm_packages + +# TypeScript v1 declaration files +typings + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.test + # local env files .env*.local -# docker-compose env files -.env +# Next.js build output +.next +out +# Nuxt.js build output +.nuxt +dist + +# Gatsby files +.cache/ + + +# Vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Temporary folders +tmp +temp + +# IDE and editor directories +.idea +.vscode +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +Thumbs.db + +# secret key *.key -*.key.pub \ No newline at end of file +*.key.pub diff --git a/.env.template b/.env.template index 166cc4ef4..b2a0438d9 100644 --- a/.env.template +++ b/.env.template @@ -2,7 +2,7 @@ # Your openai api key. (required) OPENAI_API_KEY=sk-xxxx -# Access passsword, separated by comma. (optional) +# Access password, separated by comma. (optional) CODE=your-password # You can start service behind a proxy @@ -47,3 +47,17 @@ ENABLE_BALANCE_QUERY= # If you want to disable parse settings from url, set this value to 1. DISABLE_FAST_LINK= + +# anthropic claude Api Key.(optional) +ANTHROPIC_API_KEY= + +### anthropic claude Api version. (optional) +ANTHROPIC_API_VERSION= + + + +### anthropic claude Api url (optional) +ANTHROPIC_URL= + +### (optional) +WHITE_WEBDEV_ENDPOINTS= \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 01fa35e82..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "[Bug] " -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Deployment** -- [ ] Docker -- [ ] Vercel -- [ ] Server - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional Logs** -Add any logs about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..bdba257d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,146 @@ +name: Bug report +description: Create a report to help us improve +title: "[Bug] " +labels: ["bug"] + +body: + - type: markdown + attributes: + value: "## Describe the bug" + - type: textarea + id: bug-description + attributes: + label: "Bug Description" + description: "A clear and concise description of what the bug is." + placeholder: "Explain the bug..." + validations: + required: true + + - type: markdown + attributes: + value: "## To Reproduce" + - type: textarea + id: steps-to-reproduce + attributes: + label: "Steps to Reproduce" + description: "Steps to reproduce the behavior:" + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: markdown + attributes: + value: "## Expected behavior" + - type: textarea + id: expected-behavior + attributes: + label: "Expected Behavior" + description: "A clear and concise description of what you expected to happen." + placeholder: "Describe what you expected to happen..." + validations: + required: true + + - type: markdown + attributes: + value: "## Screenshots" + - type: textarea + id: screenshots + attributes: + label: "Screenshots" + description: "If applicable, add screenshots to help explain your problem." + placeholder: "Paste your screenshots here or write 'N/A' if not applicable..." + validations: + required: false + + - type: markdown + attributes: + value: "## Deployment" + - type: checkboxes + id: deployment + attributes: + label: "Deployment Method" + description: "Please select the deployment method you are using." + options: + - label: "Docker" + - label: "Vercel" + - label: "Server" + + - type: markdown + attributes: + value: "## Desktop (please complete the following information):" + - type: input + id: desktop-os + attributes: + label: "Desktop OS" + description: "Your desktop operating system." + placeholder: "e.g., Windows 10" + validations: + required: false + - type: input + id: desktop-browser + attributes: + label: "Desktop Browser" + description: "Your desktop browser." + placeholder: "e.g., Chrome, Safari" + validations: + required: false + - type: input + id: desktop-version + attributes: + label: "Desktop Browser Version" + description: "Version of your desktop browser." + placeholder: "e.g., 89.0" + validations: + required: false + + - type: markdown + attributes: + value: "## Smartphone (please complete the following information):" + - type: input + id: smartphone-device + attributes: + label: "Smartphone Device" + description: "Your smartphone device." + placeholder: "e.g., iPhone X" + validations: + required: false + - type: input + id: smartphone-os + attributes: + label: "Smartphone OS" + description: "Your smartphone operating system." + placeholder: "e.g., iOS 14.4" + validations: + required: false + - type: input + id: smartphone-browser + attributes: + label: "Smartphone Browser" + description: "Your smartphone browser." + placeholder: "e.g., Safari" + validations: + required: false + - type: input + id: smartphone-version + attributes: + label: "Smartphone Browser Version" + description: "Version of your smartphone browser." + placeholder: "e.g., 14" + validations: + required: false + + - type: markdown + attributes: + value: "## Additional Logs" + - type: textarea + id: additional-logs + attributes: + label: "Additional Logs" + description: "Add any logs about the problem here." + placeholder: "Paste any relevant logs here..." + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 25c36ab67..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: "[Feature] " -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..499781330 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,53 @@ +name: Feature request +description: Suggest an idea for this project +title: "[Feature Request]: " +labels: ["enhancement"] + +body: + - type: markdown + attributes: + value: "## Is your feature request related to a problem? Please describe." + - type: textarea + id: problem-description + attributes: + label: Problem Description + description: "A clear and concise description of what the problem is. Example: I'm always frustrated when [...]" + placeholder: "Explain the problem you are facing..." + validations: + required: true + + - type: markdown + attributes: + value: "## Describe the solution you'd like" + - type: textarea + id: desired-solution + attributes: + label: Solution Description + description: A clear and concise description of what you want to happen. + placeholder: "Describe the solution you'd like..." + validations: + required: true + + - type: markdown + attributes: + value: "## Describe alternatives you've considered" + - type: textarea + id: alternatives-considered + attributes: + label: Alternatives Considered + description: A clear and concise description of any alternative solutions or features you've considered. + placeholder: "Describe any alternative solutions or features you've considered..." + validations: + required: false + + - type: markdown + attributes: + value: "## Additional context" + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context or screenshots about the feature request here. + placeholder: "Add any other context or screenshots about the feature request here..." + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/功能建议.md b/.github/ISSUE_TEMPLATE/功能建议.md deleted file mode 100644 index 3fc3d0769..000000000 --- a/.github/ISSUE_TEMPLATE/功能建议.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: 功能建议 -about: 请告诉我们你的灵光一闪 -title: "[Feature] " -labels: '' -assignees: '' - ---- - -> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。 - -> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724) - -**这个功能与现有的问题有关吗?** -如果有关,请在此列出链接或者描述问题。 - -**你想要什么功能或者有什么建议?** -尽管告诉我们。 - -**有没有可以参考的同类竞品?** -可以给出参考产品的链接或者截图。 - -**其他信息** -可以说说你的其他考虑。 diff --git a/.github/ISSUE_TEMPLATE/反馈问题.md b/.github/ISSUE_TEMPLATE/反馈问题.md deleted file mode 100644 index 270263f06..000000000 --- a/.github/ISSUE_TEMPLATE/反馈问题.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: 反馈问题 -about: 请告诉我们你遇到的问题 -title: "[Bug] " -labels: '' -assignees: '' - ---- - -> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。 - -> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724) - -**反馈须知** - -⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭,如果没有提供下方的信息,我们无法定位你的问题。 - -请在下方中括号内输入 x 来表示你已经知晓相关内容。 -- [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答; -- [ ] 我确认已经在 [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) 列表(包括已经 Close 的)中搜索了此次反馈的问题,没有找到解答。 -- [ ] 我确认已经在 [Vercel 使用教程](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/vercel-cn.md) 中搜索了此次反馈的问题,没有找到解答。 - -**描述问题** -请在此描述你遇到了什么问题。 - -**如何复现** -请告诉我们你是通过什么操作触发的该问题。 - -**截图** -请在此提供控制台截图、屏幕截图或者服务端的 log 截图。 - -**一些必要的信息** - - 系统:[比如 windows 10/ macos 12/ linux / android 11 / ios 16] - - 浏览器: [比如 chrome, safari] - - 版本: [填写设置页面的版本号] - - 部署方式:[比如 vercel、docker 或者服务器部署] diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index aebba28f7..7e74cf045 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -43,12 +43,9 @@ jobs: - os: ubuntu-latest arch: x86_64 rust_target: x86_64-unknown-linux-gnu - - os: macos-latest - arch: x86_64 - rust_target: x86_64-apple-darwin - os: macos-latest arch: aarch64 - rust_target: aarch64-apple-darwin + rust_target: x86_64-apple-darwin,aarch64-apple-darwin - os: windows-latest arch: x86_64 rust_target: x86_64-pc-windows-msvc @@ -60,13 +57,14 @@ jobs: uses: actions/setup-node@v3 with: node-version: 18 + cache: 'yarn' - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.config.rust_target }} - uses: Swatinem/rust-cache@v2 with: - key: ${{ matrix.config.rust_target }} + key: ${{ matrix.config.os }} - name: install dependencies (ubuntu only) if: matrix.config.os == 'ubuntu-latest' run: | @@ -79,8 +77,15 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: releaseId: ${{ needs.create-release.outputs.release_id }} + args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }} publish-release: permissions: diff --git a/.github/workflows/deploy_preview.yml b/.github/workflows/deploy_preview.yml new file mode 100644 index 000000000..bdbb78c27 --- /dev/null +++ b/.github/workflows/deploy_preview.yml @@ -0,0 +1,84 @@ +name: VercelPreviewDeployment + +on: + pull_request_target: + types: + - opened + - synchronize + - reopened + +env: + VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_PR_DOMAIN_SUFFIX: ${{ secrets.VERCEL_PR_DOMAIN_SUFFIX }} + +permissions: + contents: read + statuses: write + pull-requests: write + +jobs: + deploy-preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> "$GITHUB_OUTPUT" + id: extract_branch + + - name: Hash branch name + uses: pplanel/hash-calculator-action@v1.3.1 + id: hash_branch + with: + input: ${{ steps.extract_branch.outputs.branch }} + method: MD5 + + - name: Set Environment Variables + id: set_env + if: github.event_name == 'pull_request_target' + run: | + echo "VERCEL_ALIAS_DOMAIN=${{ github.event.pull_request.number }}-${{ github.workflow }}.${VERCEL_PR_DOMAIN_SUFFIX}" >> $GITHUB_OUTPUT + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Cache dependencies + uses: actions/cache@v2 + id: cache-npm + with: + path: ~/.npm + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=preview --token=${VERCEL_TOKEN} + + - name: Deploy Project Artifacts to Vercel + id: vercel + env: + META_TAG: ${{ steps.hash_branch.outputs.digest }}-${{ github.run_number }}-${{ github.run_attempt}} + run: | + set -e + vercel pull --yes --environment=preview --token=${VERCEL_TOKEN} + vercel build --token=${VERCEL_TOKEN} + vercel deploy --prebuilt --archive=tgz --token=${VERCEL_TOKEN} --meta base_hash=${{ env.META_TAG }} + + DEFAULT_URL=$(vercel ls --token=${VERCEL_TOKEN} --meta base_hash=${{ env.META_TAG }}) + ALIAS_URL=$(vercel alias set ${DEFAULT_URL} ${{ steps.set_env.outputs.VERCEL_ALIAS_DOMAIN }} --token=${VERCEL_TOKEN} --scope ${VERCEL_TEAM}| awk '{print $3}') + + echo "New preview URL: ${DEFAULT_URL}" + echo "New alias URL: ${ALIAS_URL}" + echo "VERCEL_URL=${ALIAS_URL}" >> "$GITHUB_OUTPUT" + + - uses: mshick/add-pr-comment@v2 + with: + message: | + Your build has completed! + + [Preview deployment](${{ steps.vercel.outputs.VERCEL_URL }}) diff --git a/.github/workflows/remove_deploy_preview.yml b/.github/workflows/remove_deploy_preview.yml new file mode 100644 index 000000000..4846cda2d --- /dev/null +++ b/.github/workflows/remove_deploy_preview.yml @@ -0,0 +1,40 @@ +name: Removedeploypreview + +permissions: + contents: read + statuses: write + pull-requests: write + +env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + +on: + pull_request_target: + types: + - closed + +jobs: + delete-deployments: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT + id: extract_branch + + - name: Hash branch name + uses: pplanel/hash-calculator-action@v1.3.1 + id: hash_branch + with: + input: ${{ steps.extract_branch.outputs.branch }} + method: MD5 + + - name: Call the delete-deployment-preview.sh script + env: + META_TAG: ${{ steps.hash_branch.outputs.digest }} + run: | + bash ./scripts/delete-deployment-preview.sh diff --git a/Dockerfile b/Dockerfile index 436d39d82..ae9a17cdd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ COPY --from=builder /app/.next/server ./.next/server EXPOSE 3000 CMD if [ -n "$PROXY_URL" ]; then \ - export HOSTNAME="127.0.0.1"; \ + export HOSTNAME="0.0.0.0"; \ protocol=$(echo $PROXY_URL | cut -d: -f1); \ host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \ port=$(echo $PROXY_URL | cut -d: -f3); \ diff --git a/LICENSE b/LICENSE index 542e91f4e..047f9431e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Zhang Yifei +Copyright (c) 2023-2024 Zhang Yifei Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3ac537abc..d496d68ed 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 [MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple [Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/ZBUEFA) @@ -200,6 +200,18 @@ Google Gemini Pro Api Key. Google Gemini Pro Api Url. +### `ANTHROPIC_API_KEY` (optional) + +anthropic claude Api Key. + +### `ANTHROPIC_API_VERSION` (optional) + +anthropic claude Api version. + +### `ANTHROPIC_URL` (optional) + +anthropic claude Api Url. + ### `HIDE_USER_API_KEY` (optional) > Default: Empty @@ -216,7 +228,7 @@ If you do not want users to use GPT-4, set this value to 1. > Default: Empty -If you do want users to query balance, set this value to 1, or you should set it to 0. +If you do want users to query balance, set this value to 1. ### `DISABLE_FAST_LINK` (optional) @@ -233,6 +245,17 @@ To control custom models, use `+` to add a custom model, use `-` to hide a model User `-all` to disable all default models, `+all` to enable all default models. +### `WHITE_WEBDEV_ENDPOINTS` (optional) + +You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format: +- Each address must be a complete endpoint +> `https://xxxx/yyy` +- Multiple addresses are connected by ', ' + +### `DEFAULT_INPUT_TEMPLATE` (optional) + +Customize the default template used to initialize the User Input Preprocessing configuration item in Settings. + ## Requirements NodeJS >= 18, Docker >= 20 diff --git a/README_CN.md b/README_CN.md index 4acefefa5..6811102b6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -114,6 +114,18 @@ Google Gemini Pro 密钥. Google Gemini Pro Api Url. +### `ANTHROPIC_API_KEY` (optional) + +anthropic claude Api Key. + +### `ANTHROPIC_API_VERSION` (optional) + +anthropic claude Api version. + +### `ANTHROPIC_URL` (optional) + +anthropic claude Api Url. + ### `HIDE_USER_API_KEY` (可选) 如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。 @@ -130,6 +142,13 @@ Google Gemini Pro Api Url. 如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。 +### `WHITE_WEBDEV_ENDPOINTS` (可选) + +如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求: +- 每一个地址必须是一个完整的 endpoint +> `https://xxxx/xxx` +- 多个地址以`,`相连 + ### `CUSTOM_MODELS` (可选) > 示例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` 表示增加 `qwen-7b-chat` 和 `glm-6b` 到模型列表,而从列表中删除 `gpt-3.5-turbo`,并将 `gpt-4-1106-preview` 模型名字展示为 `gpt-4-turbo`。 @@ -137,6 +156,9 @@ Google Gemini Pro Api Url. 用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。 +### `DEFAULT_INPUT_TEMPLATE` (可选) +自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项 + ## 开发 点击下方按钮,开始二次开发: diff --git a/app/api/anthropic/[...path]/route.ts b/app/api/anthropic/[...path]/route.ts new file mode 100644 index 000000000..4264893d9 --- /dev/null +++ b/app/api/anthropic/[...path]/route.ts @@ -0,0 +1,189 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { + ANTHROPIC_BASE_URL, + Anthropic, + ApiPath, + DEFAULT_MODELS, + ModelProvider, +} from "@/app/constant"; +import { prettyObject } from "@/app/utils/format"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "../../auth"; +import { collectModelTable } from "@/app/utils/model"; + +const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]); + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[Anthropic Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const subpath = params.path.join("/"); + + if (!ALLOWD_PATH.has(subpath)) { + console.log("[Anthropic Route] forbidden path ", subpath); + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + subpath, + }, + { + status: 403, + }, + ); + } + + const authResult = auth(req, ModelProvider.Claude); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const response = await request(req); + return response; + } catch (e) { + console.error("[Anthropic] ", e); + return NextResponse.json(prettyObject(e)); + } +} + +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", +]; + +const serverConfig = getServerSideConfig(); + +async function request(req: NextRequest) { + const controller = new AbortController(); + + let authHeaderName = "x-api-key"; + let authValue = + req.headers.get(authHeaderName) || + req.headers.get("Authorization")?.replaceAll("Bearer ", "").trim() || + serverConfig.anthropicApiKey || + ""; + + let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Anthropic, ""); + + let baseUrl = + serverConfig.anthropicUrl || serverConfig.baseUrl || ANTHROPIC_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + console.log("[Proxy] ", path); + console.log("[Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const fetchUrl = `${baseUrl}${path}`; + + const fetchOptions: RequestInit = { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + [authHeaderName]: authValue, + "anthropic-version": + req.headers.get("anthropic-version") || + serverConfig.anthropicApiVersion || + Anthropic.Vision, + }, + method: req.method, + body: req.body, + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + // #1815 try to refuse some request to some models + if (serverConfig.customModels && req.body) { + try { + const modelTable = collectModelTable( + DEFAULT_MODELS, + serverConfig.customModels, + ); + const clonedBody = await req.text(); + fetchOptions.body = clonedBody; + + const jsonBody = JSON.parse(clonedBody) as { model?: string }; + + // not undefined and is false + if (modelTable[jsonBody?.model ?? ""].available === false) { + return NextResponse.json( + { + error: true, + message: `you are not allowed to use ${jsonBody?.model} model`, + }, + { + status: 403, + }, + ); + } + } catch (e) { + console.error(`[Anthropic] filter`, e); + } + } + console.log("[Anthropic request]", fetchOptions.headers, req.method); + try { + const res = await fetch(fetchUrl, fetchOptions); + + console.log( + "[Anthropic response]", + res.status, + " ", + res.headers, + res.url, + ); + // to prevent browser prompt for credentials + const newHeaders = new Headers(res.headers); + newHeaders.delete("www-authenticate"); + // to disable nginx buffering + newHeaders.set("X-Accel-Buffering", "no"); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/api/auth.ts b/app/api/auth.ts index 16c8034eb..b750f2d17 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -57,12 +57,31 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { if (!apiKey) { const serverConfig = getServerSideConfig(); - const systemApiKey = - modelProvider === ModelProvider.GeminiPro - ? serverConfig.googleApiKey - : serverConfig.isAzure - ? serverConfig.azureApiKey - : serverConfig.apiKey; + // const systemApiKey = + // modelProvider === ModelProvider.GeminiPro + // ? serverConfig.googleApiKey + // : serverConfig.isAzure + // ? serverConfig.azureApiKey + // : serverConfig.apiKey; + + let systemApiKey: string | undefined; + + switch (modelProvider) { + case ModelProvider.GeminiPro: + systemApiKey = serverConfig.googleApiKey; + break; + case ModelProvider.Claude: + systemApiKey = serverConfig.anthropicApiKey; + break; + case ModelProvider.GPT: + default: + if (serverConfig.isAzure) { + systemApiKey = serverConfig.azureApiKey; + } else { + systemApiKey = serverConfig.apiKey; + } + } + if (systemApiKey) { console.log("[Auth] use system api key"); req.headers.set("Authorization", `Bearer ${systemApiKey}`); diff --git a/app/api/common.ts b/app/api/common.ts index ca8406bb3..a75f2de5c 100644 --- a/app/api/common.ts +++ b/app/api/common.ts @@ -43,10 +43,6 @@ export async function requestOpenai(req: NextRequest) { 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( () => { @@ -116,18 +112,37 @@ export async function requestOpenai(req: NextRequest) { try { const res = await fetch(fetchUrl, fetchOptions); + // Extract the OpenAI-Organization header from the response + const openaiOrganizationHeader = res.headers.get("OpenAI-Organization"); + + // Check if serverConfig.openaiOrgId is defined and not an empty string + if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") { + // If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present + console.log("[Org ID]", openaiOrganizationHeader); + } else { + console.log("[Org ID] is not set up."); + } + // to prevent browser prompt for credentials const newHeaders = new Headers(res.headers); newHeaders.delete("www-authenticate"); // to disable nginx buffering newHeaders.set("X-Accel-Buffering", "no"); + + // Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV) + // Also, this is to prevent the header from being sent to the client + if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") { + newHeaders.delete("OpenAI-Organization"); + } + // The latest version of the OpenAI API forced the content-encoding to be "br" in json response // So if the streaming is disabled, we need to remove the content-encoding header // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header // The browser will try to decode the response with brotli and fail newHeaders.delete("content-encoding"); + return new Response(res.body, { status: res.status, statusText: res.statusText, diff --git a/app/api/config/route.ts b/app/api/config/route.ts index db84fba17..b0d9da031 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -13,6 +13,7 @@ const DANGER_CONFIG = { hideBalanceQuery: serverConfig.hideBalanceQuery, disableFastLink: serverConfig.disableFastLink, customModels: serverConfig.customModels, + defaultModel: serverConfig.defaultModel, }; declare global { diff --git a/app/api/cors/[...path]/route.ts b/app/api/cors/[...path]/route.ts deleted file mode 100644 index 0217b12b0..000000000 --- a/app/api/cors/[...path]/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -async function handle( - req: NextRequest, - { params }: { params: { path: string[] } }, -) { - if (req.method === "OPTIONS") { - return NextResponse.json({ body: "OK" }, { status: 200 }); - } - - const [protocol, ...subpath] = params.path; - const targetUrl = `${protocol}://${subpath.join("/")}`; - - const method = req.headers.get("method") ?? undefined; - const shouldNotHaveBody = ["get", "head"].includes( - method?.toLowerCase() ?? "", - ); - - const fetchOptions: RequestInit = { - headers: { - authorization: req.headers.get("authorization") ?? "", - }, - body: shouldNotHaveBody ? null : req.body, - method, - // @ts-ignore - duplex: "half", - }; - - const fetchResult = await fetch(targetUrl, fetchOptions); - - console.log("[Any Proxy]", targetUrl, { - status: fetchResult.status, - statusText: fetchResult.statusText, - }); - - return fetchResult; -} - -export const POST = handle; -export const GET = handle; -export const OPTIONS = handle; - -export const runtime = "nodejs"; diff --git a/app/api/google/[...path]/route.ts b/app/api/google/[...path]/route.ts index 869bd5076..ebd192891 100644 --- a/app/api/google/[...path]/route.ts +++ b/app/api/google/[...path]/route.ts @@ -101,19 +101,14 @@ 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", diff --git a/app/api/upstash/[action]/[...key]/route.ts b/app/api/upstash/[action]/[...key]/route.ts new file mode 100644 index 000000000..fcfef4718 --- /dev/null +++ b/app/api/upstash/[action]/[...key]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; + +async function handle( + req: NextRequest, + { params }: { params: { action: string; key: string[] } }, +) { + const requestUrl = new URL(req.url); + const endpoint = requestUrl.searchParams.get("endpoint"); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + const [...key] = params.key; + // only allow to request to *.upstash.io + if (!endpoint || !new URL(endpoint).hostname.endsWith(".upstash.io")) { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + params.key.join("/"), + }, + { + status: 403, + }, + ); + } + + // only allow upstash get and set method + if (params.action !== "get" && params.action !== "set") { + console.log("[Upstash Route] forbidden action ", params.action); + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + params.action, + }, + { + status: 403, + }, + ); + } + + const targetUrl = `${endpoint}/${params.action}/${params.key.join("/")}`; + + const method = req.method; + const shouldNotHaveBody = ["get", "head"].includes( + method?.toLowerCase() ?? "", + ); + + const fetchOptions: RequestInit = { + headers: { + authorization: req.headers.get("authorization") ?? "", + }, + body: shouldNotHaveBody ? null : req.body, + method, + // @ts-ignore + duplex: "half", + }; + + console.log("[Upstash Proxy]", targetUrl, fetchOptions); + const fetchResult = await fetch(targetUrl, fetchOptions); + + console.log("[Any Proxy]", targetUrl, { + status: fetchResult.status, + statusText: fetchResult.statusText, + }); + + return fetchResult; +} + +export const POST = handle; +export const GET = handle; +export const OPTIONS = handle; + +export const runtime = "edge"; diff --git a/app/api/webdav/[...path]/route.ts b/app/api/webdav/[...path]/route.ts new file mode 100644 index 000000000..01286fc1b --- /dev/null +++ b/app/api/webdav/[...path]/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from "next/server"; +import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant"; +import { getServerSideConfig } from "@/app/config/server"; + +const config = getServerSideConfig(); + +const mergedAllowedWebDavEndpoints = [ + ...internalAllowedWebDavEndpoints, + ...config.allowedWebDevEndpoints, +].filter((domain) => Boolean(domain.trim())); + +const normalizeUrl = (url: string) => { + try { + return new URL(url); + } catch (err) { + return null; + } +}; + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + const folder = STORAGE_KEY; + const fileName = `${folder}/backup.json`; + + const requestUrl = new URL(req.url); + let endpoint = requestUrl.searchParams.get("endpoint"); + + // Validate the endpoint to prevent potential SSRF attacks + if ( + !endpoint || + !mergedAllowedWebDavEndpoints.some((allowedEndpoint) => { + const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint); + const normalizedEndpoint = normalizeUrl(endpoint as string); + + return normalizedEndpoint && + normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname && + normalizedEndpoint.pathname.startsWith(normalizedAllowedEndpoint.pathname); + }) + ) { + return NextResponse.json( + { + error: true, + msg: "Invalid endpoint", + }, + { + status: 400, + }, + ); + } + + if (!endpoint?.endsWith("/")) { + endpoint += "/"; + } + + const endpointPath = params.path.join("/"); + const targetPath = `${endpoint}${endpointPath}`; + + // only allow MKCOL, GET, PUT + if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + targetPath, + }, + { + status: 403, + }, + ); + } + + // for MKCOL request, only allow request ${folder} + if (req.method === "MKCOL" && !targetPath.endsWith(folder)) { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + targetPath, + }, + { + status: 403, + }, + ); + } + + // for GET request, only allow request ending with fileName + if (req.method === "GET" && !targetPath.endsWith(fileName)) { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + targetPath, + }, + { + status: 403, + }, + ); + } + + // for PUT request, only allow request ending with fileName + if (req.method === "PUT" && !targetPath.endsWith(fileName)) { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + targetPath, + }, + { + status: 403, + }, + ); + } + + const targetUrl = targetPath; + + const method = req.method; + const shouldNotHaveBody = ["get", "head"].includes( + method?.toLowerCase() ?? "", + ); + + const fetchOptions: RequestInit = { + headers: { + authorization: req.headers.get("authorization") ?? "", + }, + body: shouldNotHaveBody ? null : req.body, + redirect: "manual", + method, + // @ts-ignore + duplex: "half", + }; + + let fetchResult; + + try { + fetchResult = await fetch(targetUrl, fetchOptions); + } finally { + console.log( + "[Any Proxy]", + targetUrl, + { + method: req.method, + }, + { + status: fetchResult?.status, + statusText: fetchResult?.statusText, + }, + ); + } + + return fetchResult; +} + +export const PUT = handle; +export const GET = handle; +export const OPTIONS = handle; + +export const runtime = "edge"; diff --git a/app/client/api.ts b/app/client/api.ts index 56fa32996..7bee546b4 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -8,15 +8,24 @@ import { import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; import { ChatGPTApi } from "./platforms/openai"; import { GeminiProApi } from "./platforms/google"; +import { ClaudeApi } from "./platforms/anthropic"; 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 type ChatModel = ModelType; +export interface MultimodalContent { + type: "text" | "image_url"; + text?: string; + image_url?: { + url: string; + }; +} + export interface RequestMessage { role: MessageRole; - content: string; + content: string | MultimodalContent[]; } export interface LLMConfig { @@ -86,11 +95,16 @@ export class ClientApi { public llm: LLMApi; constructor(provider: ModelProvider = ModelProvider.GPT) { - if (provider === ModelProvider.GeminiPro) { - this.llm = new GeminiProApi(); - return; + switch (provider) { + case ModelProvider.GeminiPro: + this.llm = new GeminiProApi(); + break; + case ModelProvider.Claude: + this.llm = new ClaudeApi(); + break; + default: + this.llm = new ChatGPTApi(); } - this.llm = new ChatGPTApi(); } config() {} @@ -143,11 +157,10 @@ export function getHeaders() { const accessStore = useAccessStore.getState(); const headers: Record = { "Content-Type": "application/json", - "x-requested-with": "XMLHttpRequest", - "Accept": "application/json", + Accept: "application/json", }; const modelConfig = useChatStore.getState().currentSession().mask.modelConfig; - const isGoogle = modelConfig.model === "gemini-pro"; + const isGoogle = modelConfig.model.startsWith("gemini"); const isAzure = accessStore.provider === ServiceProvider.Azure; const authHeader = isAzure ? "api-key" : "Authorization"; const apiKey = isGoogle @@ -155,20 +168,23 @@ export function getHeaders() { : isAzure ? accessStore.azureApiKey : accessStore.openaiApiKey; - + const clientConfig = getClientConfig(); const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`; const validString = (x: string) => x && x.length > 0; - // use user's api key first - if (validString(apiKey)) { - headers[authHeader] = makeBearer(apiKey); - } else if ( - accessStore.enabledAccessControl() && - validString(accessStore.accessCode) - ) { - headers[authHeader] = makeBearer( - ACCESS_CODE_PREFIX + accessStore.accessCode, - ); + // when using google api in app, not set auth header + if (!(isGoogle && clientConfig?.isApp)) { + // use user's api key first + if (validString(apiKey)) { + headers[authHeader] = makeBearer(apiKey); + } else if ( + accessStore.enabledAccessControl() && + validString(accessStore.accessCode) + ) { + headers[authHeader] = makeBearer( + ACCESS_CODE_PREFIX + accessStore.accessCode, + ); + } } return headers; diff --git a/app/client/platforms/anthropic.ts b/app/client/platforms/anthropic.ts new file mode 100644 index 000000000..e90c8f057 --- /dev/null +++ b/app/client/platforms/anthropic.ts @@ -0,0 +1,415 @@ +import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant"; +import { ChatOptions, LLMApi, MultimodalContent } from "../api"; +import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; +import { getClientConfig } from "@/app/config/client"; +import { DEFAULT_API_HOST } from "@/app/constant"; +import { RequestMessage } from "@/app/typing"; +import { + EventStreamContentType, + fetchEventSource, +} from "@fortaine/fetch-event-source"; + +import Locale from "../../locales"; +import { prettyObject } from "@/app/utils/format"; +import { getMessageTextContent, isVisionModel } from "@/app/utils"; + +export type MultiBlockContent = { + type: "image" | "text"; + source?: { + type: string; + media_type: string; + data: string; + }; + text?: string; +}; + +export type AnthropicMessage = { + role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper]; + content: string | MultiBlockContent[]; +}; + +export interface AnthropicChatRequest { + model: string; // The model that will complete your prompt. + messages: AnthropicMessage[]; // The prompt that you want Claude to complete. + max_tokens: number; // The maximum number of tokens to generate before stopping. + stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text. + temperature?: number; // Amount of randomness injected into the response. + top_p?: number; // Use nucleus sampling. + top_k?: number; // Only sample from the top K options for each subsequent token. + metadata?: object; // An object describing metadata about the request. + stream?: boolean; // Whether to incrementally stream the response using server-sent events. +} + +export interface ChatRequest { + model: string; // The model that will complete your prompt. + prompt: string; // The prompt that you want Claude to complete. + max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping. + stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text. + temperature?: number; // Amount of randomness injected into the response. + top_p?: number; // Use nucleus sampling. + top_k?: number; // Only sample from the top K options for each subsequent token. + metadata?: object; // An object describing metadata about the request. + stream?: boolean; // Whether to incrementally stream the response using server-sent events. +} + +export interface ChatResponse { + completion: string; + stop_reason: "stop_sequence" | "max_tokens"; + model: string; +} + +export type ChatStreamResponse = ChatResponse & { + stop?: string; + log_id: string; +}; + +const ClaudeMapper = { + assistant: "assistant", + user: "user", + system: "user", +} as const; + +const keys = ["claude-2, claude-instant-1"]; + +export class ClaudeApi implements LLMApi { + extractMessage(res: any) { + console.log("[Response] claude response: ", res); + + return res?.content?.[0]?.text; + } + async chat(options: ChatOptions): Promise { + const visionModel = isVisionModel(options.config.model); + + const accessStore = useAccessStore.getState(); + + const shouldStream = !!options.config.stream; + + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + ...{ + model: options.config.model, + }, + }; + + const messages = [...options.messages]; + + const keys = ["system", "user"]; + + // roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages + for (let i = 0; i < messages.length - 1; i++) { + const message = messages[i]; + const nextMessage = messages[i + 1]; + + if (keys.includes(message.role) && keys.includes(nextMessage.role)) { + messages[i] = [ + message, + { + role: "assistant", + content: ";", + }, + ] as any; + } + } + + const prompt = messages + .flat() + .filter((v) => { + if (!v.content) return false; + if (typeof v.content === "string" && !v.content.trim()) return false; + return true; + }) + .map((v) => { + const { role, content } = v; + const insideRole = ClaudeMapper[role] ?? "user"; + + if (!visionModel || typeof content === "string") { + return { + role: insideRole, + content: getMessageTextContent(v), + }; + } + return { + role: insideRole, + content: content + .filter((v) => v.image_url || v.text) + .map(({ type, text, image_url }) => { + if (type === "text") { + return { + type, + text: text!, + }; + } + const { url = "" } = image_url || {}; + const colonIndex = url.indexOf(":"); + const semicolonIndex = url.indexOf(";"); + const comma = url.indexOf(","); + + const mimeType = url.slice(colonIndex + 1, semicolonIndex); + const encodeType = url.slice(semicolonIndex + 1, comma); + const data = url.slice(comma + 1); + + return { + type: "image" as const, + source: { + type: encodeType, + media_type: mimeType, + data, + }, + }; + }), + }; + }); + + if (prompt[0]?.role === "assistant") { + prompt.unshift({ + role: "user", + content: ";", + }); + } + + const requestBody: AnthropicChatRequest = { + messages: prompt, + stream: shouldStream, + + model: modelConfig.model, + max_tokens: modelConfig.max_tokens, + temperature: modelConfig.temperature, + top_p: modelConfig.top_p, + // top_k: modelConfig.top_k, + top_k: 5, + }; + + const path = this.path(Anthropic.ChatPath); + + const controller = new AbortController(); + options.onController?.(controller); + + const payload = { + method: "POST", + body: JSON.stringify(requestBody), + signal: controller.signal, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": accessStore.anthropicApiKey, + "anthropic-version": accessStore.anthropicApiVersion, + Authorization: getAuthKey(accessStore.anthropicApiKey), + }, + }; + + if (shouldStream) { + try { + const context = { + text: "", + finished: false, + }; + + const finish = () => { + if (!context.finished) { + options.onFinish(context.text); + context.finished = true; + } + }; + + controller.signal.onabort = finish; + fetchEventSource(path, { + ...payload, + async onopen(res) { + const contentType = res.headers.get("content-type"); + console.log("response content type: ", contentType); + + if (contentType?.startsWith("text/plain")) { + context.text = await res.clone().text(); + return finish(); + } + + if ( + !res.ok || + !res.headers + .get("content-type") + ?.startsWith(EventStreamContentType) || + res.status !== 200 + ) { + const responseTexts = [context.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); + } + + if (extraInfo) { + responseTexts.push(extraInfo); + } + + context.text = responseTexts.join("\n\n"); + + return finish(); + } + }, + onmessage(msg) { + let chunkJson: + | undefined + | { + type: "content_block_delta" | "content_block_stop"; + delta?: { + type: "text_delta"; + text: string; + }; + index: number; + }; + try { + chunkJson = JSON.parse(msg.data); + } catch (e) { + console.error("[Response] parse error", msg.data); + } + + if (!chunkJson || chunkJson.type === "content_block_stop") { + return finish(); + } + + const { delta } = chunkJson; + if (delta?.text) { + context.text += delta.text; + options.onUpdate?.(context.text, delta.text); + } + }, + onclose() { + finish(); + }, + onerror(e) { + options.onError?.(e); + throw e; + }, + openWhenHidden: true, + }); + } catch (e) { + console.error("failed to chat", e); + options.onError?.(e as Error); + } + } else { + try { + controller.signal.onabort = () => options.onFinish(""); + + const res = await fetch(path, payload); + const resJson = await res.json(); + + const message = this.extractMessage(resJson); + options.onFinish(message); + } catch (e) { + console.error("failed to chat", e); + options.onError?.(e as Error); + } + } + } + async usage() { + return { + used: 0, + total: 0, + }; + } + async models() { + // const provider = { + // id: "anthropic", + // providerName: "Anthropic", + // providerType: "anthropic", + // }; + + return [ + // { + // name: "claude-instant-1.2", + // available: true, + // provider, + // }, + // { + // name: "claude-2.0", + // available: true, + // provider, + // }, + // { + // name: "claude-2.1", + // available: true, + // provider, + // }, + // { + // name: "claude-3-opus-20240229", + // available: true, + // provider, + // }, + // { + // name: "claude-3-sonnet-20240229", + // available: true, + // provider, + // }, + // { + // name: "claude-3-haiku-20240307", + // available: true, + // provider, + // }, + ]; + } + path(path: string): string { + const accessStore = useAccessStore.getState(); + + let baseUrl: string = ""; + + if (accessStore.useCustomConfig) { + baseUrl = accessStore.anthropicUrl; + } + + // if endpoint is empty, use default endpoint + if (baseUrl.trim().length === 0) { + const isApp = !!getClientConfig()?.isApp; + + baseUrl = isApp + ? DEFAULT_API_HOST + "/api/proxy/anthropic" + : ApiPath.Anthropic; + } + + if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) { + baseUrl = "https://" + baseUrl; + } + + baseUrl = trimEnd(baseUrl, "/"); + + return `${baseUrl}/${path}`; + } +} + +function trimEnd(s: string, end = " ") { + if (end.length === 0) return s; + + while (s.endsWith(end)) { + s = s.slice(0, -end.length); + } + + return s; +} + +function bearer(value: string) { + return `Bearer ${value.trim()}`; +} + +function getAuthKey(apiKey = "") { + const accessStore = useAccessStore.getState(); + const isApp = !!getClientConfig()?.isApp; + let authKey = ""; + + if (apiKey) { + // use user's api key first + authKey = bearer(apiKey); + } else if ( + accessStore.enabledAccessControl() && + !isApp && + !!accessStore.accessCode + ) { + // or use access code + authKey = bearer(ACCESS_CODE_PREFIX + accessStore.accessCode); + } + + return authKey; +} diff --git a/app/client/platforms/google.ts b/app/client/platforms/google.ts index f0f63659f..a786f5275 100644 --- a/app/client/platforms/google.ts +++ b/app/client/platforms/google.ts @@ -1,15 +1,14 @@ import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant"; import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api"; import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; -import { - EventStreamContentType, - fetchEventSource, -} from "@fortaine/fetch-event-source"; -import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; -import Locale from "../../locales"; -import { getServerSideConfig } from "@/app/config/server"; -import de from "@/app/locales/de"; +import { DEFAULT_API_HOST } from "@/app/constant"; +import { + getMessageTextContent, + getMessageImages, + isVisionModel, +} from "@/app/utils"; + export class GeminiProApi implements LLMApi { extractMessage(res: any) { console.log("[Response] gemini-pro response: ", res); @@ -21,11 +20,33 @@ export class GeminiProApi implements LLMApi { ); } async chat(options: ChatOptions): Promise { - const apiClient = this; - const messages = options.messages.map((v) => ({ - role: v.role.replace("assistant", "model").replace("system", "user"), - parts: [{ text: v.content }], - })); + // const apiClient = this; + let multimodal = false; + const messages = options.messages.map((v) => { + let parts: any[] = [{ text: getMessageTextContent(v) }]; + if (isVisionModel(options.config.model)) { + const images = getMessageImages(v); + if (images.length > 0) { + multimodal = true; + parts = parts.concat( + images.map((image) => { + const imageType = image.split(";")[0].split(":")[1]; + const imageData = image.split(",")[1]; + return { + inline_data: { + mime_type: imageType, + data: imageData, + }, + }; + }), + ); + } + } + return { + role: v.role.replace("assistant", "model").replace("system", "user"), + parts: parts, + }; + }); // google requires that role in neighboring messages must not be the same for (let i = 0; i < messages.length - 1; ) { @@ -40,7 +61,9 @@ export class GeminiProApi implements LLMApi { i++; } } - + // if (visionModel && messages.length > 1) { + // options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision")); + // } const modelConfig = { ...useAppConfig.getState().modelConfig, ...useChatStore.getState().currentSession().mask.modelConfig, @@ -79,13 +102,31 @@ export class GeminiProApi implements LLMApi { ], }; - console.log("[Request] google payload: ", requestPayload); + const accessStore = useAccessStore.getState(); - const shouldStream = !!options.config.stream; + let baseUrl = ""; + + if (accessStore.useCustomConfig) { + baseUrl = accessStore.googleUrl; + } + + const isApp = !!getClientConfig()?.isApp; + + let shouldStream = !!options.config.stream; const controller = new AbortController(); options.onController?.(controller); try { - const chatPath = this.path(Google.ChatPath); + // let baseUrl = accessStore.googleUrl; + + if (!baseUrl) { + baseUrl = isApp + ? DEFAULT_API_HOST + "/api/proxy/google/" + Google.ChatPath(modelConfig.model) + : this.path(Google.ChatPath(modelConfig.model)); + } + + if (isApp) { + baseUrl += `?key=${accessStore.googleApiKey}`; + } const chatPayload = { method: "POST", body: JSON.stringify(requestPayload), @@ -98,13 +139,10 @@ export class GeminiProApi implements LLMApi { () => controller.abort(), REQUEST_TIMEOUT_MS, ); + if (shouldStream) { let responseText = ""; let remainText = ""; - let streamChatPath = chatPath.replace( - "generateContent", - "streamGenerateContent", - ); let finished = false; let existingTexts: string[] = []; @@ -134,7 +172,11 @@ export class GeminiProApi implements LLMApi { // start animaion animateResponseText(); - fetch(streamChatPath, chatPayload) + + fetch( + baseUrl.replace("generateContent", "streamGenerateContent"), + chatPayload, + ) .then((response) => { const reader = response?.body?.getReader(); const decoder = new TextDecoder(); @@ -145,6 +187,19 @@ export class GeminiProApi implements LLMApi { value, }): Promise { if (done) { + if (response.status !== 200) { + try { + let data = JSON.parse(ensureProperEnding(partialData)); + if (data && data[0].error) { + options.onError?.(new Error(data[0].error.message)); + } else { + options.onError?.(new Error("Request failed")); + } + } catch (_) { + options.onError?.(new Error("Request failed")); + } + } + console.log("Stream complete"); // options.onFinish(responseText + remainText); finished = true; @@ -185,11 +240,9 @@ export class GeminiProApi implements LLMApi { console.error("Error:", error); }); } else { - const res = await fetch(chatPath, chatPayload); + const res = await fetch(baseUrl, chatPayload); clearTimeout(requestTimeoutId); - const resJson = await res.json(); - if (resJson?.promptFeedback?.blockReason) { // being blocked options.onError?.( diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index 68a0fda75..f35992630 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -1,3 +1,4 @@ +"use client"; import { ApiPath, DEFAULT_API_HOST, @@ -8,7 +9,14 @@ import { } from "@/app/constant"; import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; -import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api"; +import { + ChatOptions, + getHeaders, + LLMApi, + LLMModel, + LLMUsage, + MultimodalContent, +} from "../api"; import Locale from "../../locales"; import { EventStreamContentType, @@ -17,6 +25,11 @@ import { import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; import { makeAzurePath } from "@/app/azure"; +import { + getMessageTextContent, + getMessageImages, + isVisionModel, +} from "@/app/utils"; export interface OpenAIListModelResponse { object: string; @@ -27,25 +40,49 @@ export interface OpenAIListModelResponse { }>; } +interface RequestPayload { + messages: { + role: "system" | "user" | "assistant"; + content: string | MultimodalContent[]; + }[]; + stream?: boolean; + model: string; + temperature: number; + presence_penalty: number; + frequency_penalty: number; + top_p: number; + max_tokens?: number; +} + export class ChatGPTApi implements LLMApi { private disableListModels = true; path(path: string): string { const accessStore = useAccessStore.getState(); - const isAzure = accessStore.provider === ServiceProvider.Azure; + let baseUrl = ""; - if (isAzure && !accessStore.isValidAzure()) { - throw Error( - "incomplete azure config, please check it in your settings page", - ); + if (accessStore.useCustomConfig) { + const isAzure = accessStore.provider === ServiceProvider.Azure; + + if (isAzure && !accessStore.isValidAzure()) { + throw Error( + "incomplete azure config, please check it in your settings page", + ); + } + + if (isAzure) { + path = makeAzurePath(path, accessStore.azureApiVersion); + } + + baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl; } - let baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl; - if (baseUrl.length === 0) { const isApp = !!getClientConfig()?.isApp; - baseUrl = isApp ? DEFAULT_API_HOST : ApiPath.OpenAI; + baseUrl = isApp + ? DEFAULT_API_HOST + "/proxy" + ApiPath.OpenAI + : ApiPath.OpenAI; } if (baseUrl.endsWith("/")) { @@ -55,9 +92,7 @@ export class ChatGPTApi implements LLMApi { baseUrl = "https://" + baseUrl; } - if (isAzure) { - path = makeAzurePath(path, accessStore.azureApiVersion); - } + console.log("[Proxy Endpoint] ", baseUrl, path); return [baseUrl, path].join("/"); } @@ -67,9 +102,10 @@ export class ChatGPTApi implements LLMApi { } async chat(options: ChatOptions) { + const visionModel = isVisionModel(options.config.model); const messages = options.messages.map((v) => ({ role: v.role, - content: v.content, + content: visionModel ? v.content : getMessageTextContent(v), })); const modelConfig = { @@ -80,7 +116,7 @@ export class ChatGPTApi implements LLMApi { }, }; - const requestPayload = { + const requestPayload: RequestPayload = { messages, stream: options.config.stream, model: modelConfig.model, @@ -92,6 +128,11 @@ 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. }; + // add max_tokens to vision model + if (visionModel && modelConfig.model.includes("preview")) { + requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); + } + console.log("[Request] openai payload: ", requestPayload); const shouldStream = !!options.config.stream; @@ -123,6 +164,9 @@ export class ChatGPTApi implements LLMApi { if (finished || controller.signal.aborted) { responseText += remainText; console.log("[Response Animation] finished"); + if (responseText?.length === 0) { + options.onError?.(new Error("empty response from server")); + } return; } @@ -197,19 +241,31 @@ export class ChatGPTApi implements LLMApi { } const text = msg.data; try { - const json = JSON.parse(text) as { - choices: Array<{ - delta: { - content: string; - }; - }>; - }; - const delta = json.choices[0]?.delta?.content; + const json = JSON.parse(text); + const choices = json.choices as Array<{ + delta: { content: string }; + }>; + const delta = choices[0]?.delta?.content; + const textmoderation = json?.prompt_filter_results; + if (delta) { remainText += delta; } + + if ( + textmoderation && + textmoderation.length > 0 && + ServiceProvider.Azure + ) { + const contentFilterResults = + textmoderation[0]?.content_filter_results; + console.log( + `[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`, + contentFilterResults, + ); + } } catch (e) { - console.error("[Request] parse error", text); + console.error("[Request] parse error", text, msg); } }, onclose() { diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx index 33967717d..7ef6e7b83 100644 --- a/app/components/chat-list.tsx +++ b/app/components/chat-list.tsx @@ -12,7 +12,7 @@ import { import { useChatStore } from "../store"; import Locale from "../locales"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { Path } from "../constant"; import { MaskAvatar } from "./mask"; import { Mask } from "../store/mask"; @@ -40,12 +40,16 @@ export function ChatItem(props: { }); } }, [props.selected]); + + const { pathname: currentPath } = useLocation(); return ( {(provided) => (
{ diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index 16790ccb1..e7619e92b 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -1,5 +1,47 @@ @import "../styles/animation.scss"; +.attach-images { + position: absolute; + left: 30px; + bottom: 32px; + display: flex; +} + +.attach-image { + cursor: default; + width: 64px; + height: 64px; + border: rgba($color: #888, $alpha: 0.2) 1px solid; + border-radius: 5px; + margin-right: 10px; + background-size: cover; + background-position: center; + background-color: var(--white); + + .attach-image-mask { + width: 100%; + height: 100%; + opacity: 0; + transition: all ease 0.2s; + } + + .attach-image-mask:hover { + opacity: 1; + } + + .delete-image { + width: 24px; + height: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + float: right; + background-color: var(--white); + } +} + .chat-input-actions { display: flex; flex-wrap: wrap; @@ -189,12 +231,10 @@ animation: slide-in ease 0.3s; - $linear: linear-gradient( - to right, - rgba(0, 0, 0, 0), - rgba(0, 0, 0, 1), - rgba(0, 0, 0, 0) - ); + $linear: linear-gradient(to right, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 1), + rgba(0, 0, 0, 0)); mask-image: $linear; @mixin show { @@ -327,7 +367,7 @@ } } -.chat-message-user > .chat-message-container { +.chat-message-user>.chat-message-container { align-items: flex-end; } @@ -349,6 +389,7 @@ padding: 7px; } } + /* Specific styles for iOS devices */ @media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) { @supports (-webkit-touch-callout: none) { @@ -381,6 +422,64 @@ transition: all ease 0.3s; } +.chat-message-item-image { + width: 100%; + margin-top: 10px; +} + +.chat-message-item-images { + width: 100%; + display: grid; + justify-content: left; + grid-gap: 10px; + grid-template-columns: repeat(var(--image-count), auto); + margin-top: 10px; +} + +.chat-message-item-image-multi { + object-fit: cover; + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +.chat-message-item-image, +.chat-message-item-image-multi { + box-sizing: border-box; + border-radius: 10px; + border: rgba($color: #888, $alpha: 0.2) 1px solid; +} + + +@media only screen and (max-width: 600px) { + $calc-image-width: calc(100vw/3*2/var(--image-count)); + + .chat-message-item-image-multi { + width: $calc-image-width; + height: $calc-image-width; + } + + .chat-message-item-image { + max-width: calc(100vw/3*2); + } +} + +@media screen and (min-width: 600px) { + $max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count)); + $image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count)); + + .chat-message-item-image-multi { + width: $image-width; + height: $image-width; + max-width: $max-image-width; + max-height: $max-image-width; + } + + .chat-message-item-image { + max-width: calc(calc(1200px - var(--sidebar-width))/3*2); + } +} + .chat-message-action-date { font-size: 12px; opacity: 0.2; @@ -395,7 +494,7 @@ z-index: 1; } -.chat-message-user > .chat-message-container > .chat-message-item { +.chat-message-user>.chat-message-container>.chat-message-item { background-color: var(--second); &:hover { @@ -460,6 +559,7 @@ @include single-line(); } + .hint-content { font-size: 12px; @@ -474,15 +574,26 @@ } .chat-input-panel-inner { + cursor: text; display: flex; flex: 1; + border-radius: 10px; + border: var(--border-in-light); +} + +.chat-input-panel-inner-attach { + padding-bottom: 80px; +} + +.chat-input-panel-inner:has(.chat-input:focus) { + border: 1px solid var(--primary); } .chat-input { height: 100%; width: 100%; border-radius: 10px; - border: var(--border-in-light); + border: none; box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03); background-color: var(--white); color: var(--black); @@ -494,9 +605,7 @@ min-height: 68px; } -.chat-input:focus { - border: 1px solid var(--primary); -} +.chat-input:focus {} .chat-input-send { background-color: var(--primary); @@ -515,4 +624,4 @@ .chat-input-send { bottom: 30px; } -} +} \ No newline at end of file diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 39abdd97b..061192504 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useCallback, Fragment, + RefObject, } from "react"; import SendWhiteIcon from "../icons/send-white.svg"; @@ -15,6 +16,7 @@ import ExportIcon from "../icons/share.svg"; import ReturnIcon from "../icons/return.svg"; import CopyIcon from "../icons/copy.svg"; import LoadingIcon from "../icons/three-dots.svg"; +import LoadingButtonIcon from "../icons/loading.svg"; import PromptIcon from "../icons/prompt.svg"; import MaskIcon from "../icons/mask.svg"; import MaxIcon from "../icons/max.svg"; @@ -27,6 +29,7 @@ import PinIcon from "../icons/pin.svg"; import EditIcon from "../icons/rename.svg"; import ConfirmIcon from "../icons/confirm.svg"; import CancelIcon from "../icons/cancel.svg"; +import ImageIcon from "../icons/image.svg"; import LightIcon from "../icons/light.svg"; import DarkIcon from "../icons/dark.svg"; @@ -53,8 +56,13 @@ import { selectOrCopy, autoGrowTextArea, useMobileScreen, + getMessageTextContent, + getMessageImages, + isVisionModel, } from "../utils"; +import { compressImage } from "@/app/utils/chat"; + import dynamic from "next/dynamic"; import { ChatControllerPool } from "../client/controller"; @@ -89,6 +97,7 @@ import { prettyObject } from "../utils/format"; import { ExportMessageModal } from "./exporter"; import { getClientConfig } from "../config/client"; import { useAllModels } from "../utils/hooks"; +import { MultimodalContent } from "../client/api"; const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { loading: () => , @@ -211,6 +220,8 @@ function useSubmitHandler() { }, []); const shouldSubmit = (e: React.KeyboardEvent) => { + // Fix Chinese input method "Enter" on Safari + if (e.keyCode == 229) return false; if (e.key !== "Enter") return false; if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current)) return false; @@ -375,11 +386,13 @@ function ChatAction(props: { ); } -function useScrollToBottom() { +function useScrollToBottom( + scrollRef: RefObject, + detach: boolean = false, +) { // for auto-scroll - const scrollRef = useRef(null); - const [autoScroll, setAutoScroll] = useState(true); + const [autoScroll, setAutoScroll] = useState(true); function scrollDomToBottom() { const dom = scrollRef.current; if (dom) { @@ -392,7 +405,7 @@ function useScrollToBottom() { // auto scroll useEffect(() => { - if (autoScroll) { + if (autoScroll && !detach) { scrollDomToBottom(); } }); @@ -406,10 +419,14 @@ function useScrollToBottom() { } export function ChatActions(props: { + uploadImage: () => void; + setAttachImages: (images: string[]) => void; + setUploading: (uploading: boolean) => void; showPromptModal: () => void; scrollToBottom: () => void; showPromptHints: () => void; hitBottom: boolean; + uploading: boolean; }) { const config = useAppConfig(); const navigate = useNavigate(); @@ -432,18 +449,39 @@ export function ChatActions(props: { // switch model const currentModel = chatStore.currentSession().mask.modelConfig.model; const allModels = useAllModels(); - const models = useMemo( - () => allModels.filter((m) => m.available), - [allModels], - ); + const models = useMemo(() => { + const filteredModels = allModels.filter((m) => m.available); + const defaultModel = filteredModels.find((m) => m.isDefault); + + if (defaultModel) { + const arr = [ + defaultModel, + ...filteredModels.filter((m) => m !== defaultModel), + ]; + return arr; + } else { + return filteredModels; + } + }, [allModels]); const [showModelSelector, setShowModelSelector] = useState(false); + const [showUploadImage, setShowUploadImage] = useState(false); useEffect(() => { + const show = isVisionModel(currentModel); + setShowUploadImage(show); + if (!show) { + props.setAttachImages([]); + props.setUploading(false); + } + // if current model is not available // switch to first available model const isUnavaliableModel = !models.some((m) => m.name === currentModel); if (isUnavaliableModel && models.length > 0) { - const nextModel = models[0].name as ModelType; + // show next model to default model if exist + let nextModel: ModelType = ( + models.find((model) => model.isDefault) || models[0] + ).name; chatStore.updateCurrentSession( (session) => (session.mask.modelConfig.model = nextModel), ); @@ -475,6 +513,13 @@ export function ChatActions(props: { /> )} + {showUploadImage && ( + : } + /> + )} void }) { ); } +export function DeleteImageButton(props: { deleteImage: () => void }) { + return ( +
+ +
+ ); +} + function _Chat() { type RenderMessage = ChatMessage & { preview?: boolean }; @@ -624,10 +677,22 @@ function _Chat() { const [userInput, setUserInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const { submitKey, shouldSubmit } = useSubmitHandler(); - const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom(); + const scrollRef = useRef(null); + const isScrolledToBottom = scrollRef?.current + ? Math.abs( + scrollRef.current.scrollHeight - + (scrollRef.current.scrollTop + scrollRef.current.clientHeight), + ) <= 1 + : false; + const { setAutoScroll, scrollDomToBottom } = useScrollToBottom( + scrollRef, + isScrolledToBottom, + ); const [hitBottom, setHitBottom] = useState(true); const isMobileScreen = useMobileScreen(); const navigate = useNavigate(); + const [attachImages, setAttachImages] = useState([]); + const [uploading, setUploading] = useState(false); // prompt hints const promptStore = usePromptStore(); @@ -705,7 +770,10 @@ function _Chat() { return; } setIsLoading(true); - chatStore.onUserInput(userInput).then(() => setIsLoading(false)); + chatStore + .onUserInput(userInput, attachImages) + .then(() => setIsLoading(false)); + setAttachImages([]); localStorage.setItem(LAST_INPUT_KEY, userInput); setUserInput(""); setPromptHints([]); @@ -783,9 +851,9 @@ function _Chat() { }; const onRightClick = (e: any, message: ChatMessage) => { // copy to clipboard - if (selectOrCopy(e.currentTarget, message.content)) { + if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) { if (userInput.length === 0) { - setUserInput(message.content); + setUserInput(getMessageTextContent(message)); } e.preventDefault(); @@ -853,7 +921,9 @@ function _Chat() { // resend the message setIsLoading(true); - chatStore.onUserInput(userMessage.content).then(() => setIsLoading(false)); + const textContent = getMessageTextContent(userMessage); + const images = getMessageImages(userMessage); + chatStore.onUserInput(textContent, images).then(() => setIsLoading(false)); inputRef.current?.focus(); }; @@ -962,7 +1032,6 @@ function _Chat() { setHitBottom(isHitBottom); setAutoScroll(isHitBottom); }; - function scrollToBottom() { setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE); scrollDomToBottom(); @@ -1020,6 +1089,7 @@ function _Chat() { if (payload.url) { accessStore.update((access) => (access.openaiUrl = payload.url!)); } + accessStore.update((access) => (access.useCustomConfig = true)); }); } } catch { @@ -1048,6 +1118,94 @@ function _Chat() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const handlePaste = useCallback( + async (event: React.ClipboardEvent) => { + const currentModel = chatStore.currentSession().mask.modelConfig.model; + if (!isVisionModel(currentModel)) { + return; + } + const items = (event.clipboardData || window.clipboardData).items; + for (const item of items) { + if (item.kind === "file" && item.type.startsWith("image/")) { + event.preventDefault(); + const file = item.getAsFile(); + if (file) { + const images: string[] = []; + images.push(...attachImages); + images.push( + ...(await new Promise((res, rej) => { + setUploading(true); + const imagesData: string[] = []; + compressImage(file, 256 * 1024) + .then((dataUrl) => { + imagesData.push(dataUrl); + setUploading(false); + res(imagesData); + }) + .catch((e) => { + setUploading(false); + rej(e); + }); + })), + ); + const imagesLength = images.length; + + if (imagesLength > 3) { + images.splice(3, imagesLength - 3); + } + setAttachImages(images); + } + } + } + }, + [attachImages, chatStore], + ); + + async function uploadImage() { + const images: string[] = []; + images.push(...attachImages); + + images.push( + ...(await new Promise((res, rej) => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = + "image/png, image/jpeg, image/webp, image/heic, image/heif"; + fileInput.multiple = true; + fileInput.onchange = (event: any) => { + setUploading(true); + const files = event.target.files; + const imagesData: string[] = []; + for (let i = 0; i < files.length; i++) { + const file = event.target.files[i]; + compressImage(file, 256 * 1024) + .then((dataUrl) => { + imagesData.push(dataUrl); + if ( + imagesData.length === 3 || + imagesData.length === files.length + ) { + setUploading(false); + res(imagesData); + } + }) + .catch((e) => { + setUploading(false); + rej(e); + }); + } + }; + fileInput.click(); + })), + ); + + const imagesLength = images.length; + if (imagesLength > 3) { + images.splice(3, imagesLength - 3); + } + setAttachImages(images); + } + return (
@@ -1154,15 +1312,29 @@ function _Chat() { onClick={async () => { const newMessage = await showPrompt( Locale.Chat.Actions.Edit, - message.content, + getMessageTextContent(message), 10, ); + let newContent: string | MultimodalContent[] = + newMessage; + const images = getMessageImages(message); + if (images.length > 0) { + newContent = [{ type: "text", text: newMessage }]; + for (let i = 0; i < images.length; i++) { + newContent.push({ + type: "image_url", + image_url: { + url: images[i], + }, + }); + } + } chatStore.updateCurrentSession((session) => { const m = session.mask.context .concat(session.messages) .find((m) => m.id === message.id); if (m) { - m.content = newMessage; + m.content = newContent; } }); }} @@ -1217,7 +1389,11 @@ function _Chat() { } - onClick={() => copyToClipboard(message.content)} + onClick={() => + copyToClipboard( + getMessageTextContent(message), + ) + } /> )} @@ -1232,7 +1408,7 @@ function _Chat() { )}
onRightClick(e, message)} onDoubleClickCapture={() => { if (!isMobileScreen) return; - setUserInput(message.content); + setUserInput(getMessageTextContent(message)); }} fontSize={fontSize} parentRef={scrollRef} defaultShow={i >= messages.length - 6} /> + {getMessageImages(message).length == 1 && ( + + )} + {getMessageImages(message).length > 1 && ( +
+ {getMessageImages(message).map((image, index) => { + return ( + + ); + })} +
+ )}
@@ -1266,9 +1472,13 @@ function _Chat() { setShowPromptModal(true)} scrollToBottom={scrollToBottom} hitBottom={hitBottom} + uploading={uploading} showPromptHints={() => { // Click again to close if (promptHints.length > 0) { @@ -1281,8 +1491,16 @@ function _Chat() { onSearch(""); }} /> -
+