mirror of
https://github.com/Yidadaa/ChatGPT-Next-Web.git
synced 2025-08-31 03:09:04 +08:00
Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
d1d8b1f393 |
@@ -1,97 +0,0 @@
|
||||
# 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
|
||||
|
||||
# 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
|
@@ -1,22 +1,13 @@
|
||||
|
||||
# Your openai api key. (required)
|
||||
OPENAI_API_KEY=sk-xxxx
|
||||
|
||||
# Access password, separated by comma. (optional)
|
||||
# Access passsword, separated by comma. (optional)
|
||||
CODE=your-password
|
||||
|
||||
# You can start service behind a proxy. (optional)
|
||||
# You can start service behind a proxy
|
||||
PROXY_URL=http://localhost:7890
|
||||
|
||||
# (optional)
|
||||
# Default: Empty
|
||||
# Google Gemini Pro API key, set if you want to use Google Gemini Pro API.
|
||||
GOOGLE_API_KEY=
|
||||
|
||||
# (optional)
|
||||
# Default: https://generativelanguage.googleapis.com/
|
||||
# Google Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url.
|
||||
GOOGLE_URL=
|
||||
|
||||
# Override openai api request base url. (optional)
|
||||
# Default: https://api.openai.com
|
||||
# Examples: http://your-openai-proxy.com
|
||||
@@ -45,25 +36,3 @@ ENABLE_BALANCE_QUERY=
|
||||
# Default: Empty
|
||||
# If you want to disable parse settings from url, set this value to 1.
|
||||
DISABLE_FAST_LINK=
|
||||
|
||||
# (optional)
|
||||
# Default: Empty
|
||||
# To control custom models, use + to add a custom model, use - to hide a model, use name=displayName to customize model name, separated by comma.
|
||||
CUSTOM_MODELS=
|
||||
|
||||
# (optional)
|
||||
# Default: Empty
|
||||
# Change default model
|
||||
DEFAULT_MODEL=
|
||||
|
||||
# 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=
|
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"plugins": ["prettier", "unused-imports"],
|
||||
"rules": {
|
||||
"unused-imports/no-unused-imports": "warn"
|
||||
}
|
||||
"plugins": ["prettier"]
|
||||
}
|
||||
|
80
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
80
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
@@ -1,80 +0,0 @@
|
||||
name: '🐛 Bug Report'
|
||||
description: 'Report an bug'
|
||||
title: '[Bug] '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 Deployment Method'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Official installation package'
|
||||
- 'Vercel'
|
||||
- 'Zeabur'
|
||||
- 'Sealos'
|
||||
- 'Netlify'
|
||||
- 'Docker'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 Version'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '💻 Operating System'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Windows'
|
||||
- 'macOS'
|
||||
- 'Ubuntu'
|
||||
- 'Other Linux'
|
||||
- 'iOS'
|
||||
- 'iPad OS'
|
||||
- 'Android'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 System Version'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🌐 Browser'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Chrome'
|
||||
- 'Edge'
|
||||
- 'Safari'
|
||||
- 'Firefox'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 Browser Version'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🐛 Bug Description'
|
||||
description: A clear and concise description of the bug, if the above option is `Other`, please also explain in detail.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📷 Recurrence Steps'
|
||||
description: A clear and concise description of how to recurrence.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🚦 Expected Behavior'
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📝 Additional Information'
|
||||
description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here.
|
80
.github/ISSUE_TEMPLATE/1_bug_report_cn.yml
vendored
80
.github/ISSUE_TEMPLATE/1_bug_report_cn.yml
vendored
@@ -1,80 +0,0 @@
|
||||
name: '🐛 反馈缺陷'
|
||||
description: '反馈一个问题/缺陷'
|
||||
title: '[Bug] '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 部署方式'
|
||||
multiple: true
|
||||
options:
|
||||
- '官方安装包'
|
||||
- 'Vercel'
|
||||
- 'Zeabur'
|
||||
- 'Sealos'
|
||||
- 'Netlify'
|
||||
- 'Docker'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 软件版本'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '💻 系统环境'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Windows'
|
||||
- 'macOS'
|
||||
- 'Ubuntu'
|
||||
- 'Other Linux'
|
||||
- 'iOS'
|
||||
- 'iPad OS'
|
||||
- 'Android'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 系统版本'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🌐 浏览器'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Chrome'
|
||||
- 'Edge'
|
||||
- 'Safari'
|
||||
- 'Firefox'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 浏览器版本'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🐛 问题描述'
|
||||
description: 请提供一个清晰且简洁的问题描述,若上述选项为`Other`,也请详细说明。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📷 复现步骤'
|
||||
description: 请提供一个清晰且简洁的描述,说明如何复现问题。
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🚦 期望结果'
|
||||
description: 请提供一个清晰且简洁的描述,说明您期望发生什么。
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📝 补充信息'
|
||||
description: 如果您的问题需要进一步说明,或者您遇到的问题无法在一个简单的示例中复现,请在这里添加更多信息。
|
21
.github/ISSUE_TEMPLATE/2_feature_request.yml
vendored
21
.github/ISSUE_TEMPLATE/2_feature_request.yml
vendored
@@ -1,21 +0,0 @@
|
||||
name: '🌠 Feature Request'
|
||||
description: 'Suggest an idea'
|
||||
title: '[Feature Request] '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🥰 Feature Description'
|
||||
description: Please add a clear and concise description of the problem you are seeking to solve with this feature request.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🧐 Proposed Solution'
|
||||
description: Describe the solution you'd like in a clear and concise manner.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📝 Additional Information'
|
||||
description: Add any other context about the problem here.
|
21
.github/ISSUE_TEMPLATE/2_feature_request_cn.yml
vendored
21
.github/ISSUE_TEMPLATE/2_feature_request_cn.yml
vendored
@@ -1,21 +0,0 @@
|
||||
name: '🌠 功能需求'
|
||||
description: '提出需求或建议'
|
||||
title: '[Feature Request] '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🥰 需求描述'
|
||||
description: 请添加一个清晰且简洁的问题描述,阐述您希望通过这个功能需求解决的问题。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🧐 解决方案'
|
||||
description: 请清晰且简洁地描述您想要的解决方案。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📝 补充信息'
|
||||
description: 在这里添加关于问题的任何其他背景信息。
|
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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.
|
24
.github/ISSUE_TEMPLATE/功能建议.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/功能建议.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 功能建议
|
||||
about: 请告诉我们你的灵光一闪
|
||||
title: "[Feature] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
|
||||
|
||||
> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
|
||||
|
||||
**这个功能与现有的问题有关吗?**
|
||||
如果有关,请在此列出链接或者描述问题。
|
||||
|
||||
**你想要什么功能或者有什么建议?**
|
||||
尽管告诉我们。
|
||||
|
||||
**有没有可以参考的同类竞品?**
|
||||
可以给出参考产品的链接或者截图。
|
||||
|
||||
**其他信息**
|
||||
可以说说你的其他考虑。
|
36
.github/ISSUE_TEMPLATE/反馈问题.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/反馈问题.md
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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 或者服务器部署]
|
28
.github/PULL_REQUEST_TEMPLATE.md
vendored
28
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,28 +0,0 @@
|
||||
#### 💻 变更类型 | Change Type
|
||||
|
||||
<!-- For change type, change [ ] to [x]. -->
|
||||
|
||||
- [ ] feat <!-- 引入新功能 | Introduce new features -->
|
||||
- [ ] fix <!-- 修复 Bug | Fix a bug -->
|
||||
- [ ] refactor <!-- 重构代码(既不修复 Bug 也不添加新功能) | Refactor code that neither fixes a bug nor adds a feature -->
|
||||
- [ ] perf <!-- 提升性能的代码变更 | A code change that improves performance -->
|
||||
- [ ] style <!-- 添加或更新不影响代码含义的样式文件 | Add or update style files that do not affect the meaning of the code -->
|
||||
- [ ] test <!-- 添加缺失的测试或纠正现有的测试 | Adding missing tests or correcting existing tests -->
|
||||
- [ ] docs <!-- 仅文档更新 | Documentation only changes -->
|
||||
- [ ] ci <!-- 修改持续集成配置文件和脚本 | Changes to our CI configuration files and scripts -->
|
||||
- [ ] chore <!-- 其他不修改 src 或 test 文件的变更 | Other changes that don’t modify src or test files -->
|
||||
- [ ] build <!-- 进行架构变更 | Make architectural changes -->
|
||||
|
||||
#### 🔀 变更说明 | Description of Change
|
||||
|
||||
<!--
|
||||
感谢您的 Pull Request ,请提供此 Pull Request 的变更说明
|
||||
Thank you for your Pull Request. Please provide a description above.
|
||||
-->
|
||||
|
||||
#### 📝 补充信息 | Additional Information
|
||||
|
||||
<!--
|
||||
请添加与此 Pull Request 相关的补充信息
|
||||
Add any other context about the Pull Request here.
|
||||
-->
|
19
.github/workflows/app.yml
vendored
19
.github/workflows/app.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 16
|
||||
- name: get version
|
||||
run: echo "PACKAGE_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').package.version")" >> $GITHUB_ENV
|
||||
- name: create release
|
||||
@@ -43,9 +43,12 @@ 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: x86_64-apple-darwin,aarch64-apple-darwin
|
||||
rust_target: aarch64-apple-darwin
|
||||
- os: windows-latest
|
||||
arch: x86_64
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
@@ -56,15 +59,14 @@ jobs:
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
node-version: 16
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.config.rust_target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.config.os }}
|
||||
key: ${{ matrix.config.rust_target }}
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.config.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -77,15 +79,8 @@ 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:
|
||||
|
84
.github/workflows/deploy_preview.yml
vendored
84
.github/workflows/deploy_preview.yml
vendored
@@ -1,84 +0,0 @@
|
||||
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@v4
|
||||
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 }})
|
40
.github/workflows/remove_deploy_preview.yml
vendored
40
.github/workflows/remove_deploy_preview.yml
vendored
@@ -1,40 +0,0 @@
|
||||
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
|
2
.github/workflows/sync.yml
vendored
2
.github/workflows/sync.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: ChatGPTNextWeb/ChatGPT-Next-Web
|
||||
upstream_sync_repo: Yidadaa/ChatGPT-Next-Web
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -43,6 +43,4 @@ dev
|
||||
.env
|
||||
|
||||
*.key
|
||||
*.key.pub
|
||||
|
||||
masks.json
|
||||
*.key.pub
|
36
Dockerfile
36
Dockerfile
@@ -16,7 +16,6 @@ FROM base AS builder
|
||||
RUN apk update && apk add --no-cache git
|
||||
|
||||
ENV OPENAI_API_KEY=""
|
||||
ENV GOOGLE_API_KEY=""
|
||||
ENV CODE=""
|
||||
|
||||
WORKDIR /app
|
||||
@@ -32,7 +31,6 @@ RUN apk add proxychains-ng
|
||||
|
||||
ENV PROXY_URL=""
|
||||
ENV OPENAI_API_KEY=""
|
||||
ENV GOOGLE_API_KEY=""
|
||||
ENV CODE=""
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
@@ -43,22 +41,22 @@ COPY --from=builder /app/.next/server ./.next/server
|
||||
EXPOSE 3000
|
||||
|
||||
CMD if [ -n "$PROXY_URL" ]; then \
|
||||
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); \
|
||||
conf=/etc/proxychains.conf; \
|
||||
echo "strict_chain" > $conf; \
|
||||
echo "proxy_dns" >> $conf; \
|
||||
echo "remote_dns_subnet 224" >> $conf; \
|
||||
echo "tcp_read_time_out 15000" >> $conf; \
|
||||
echo "tcp_connect_time_out 8000" >> $conf; \
|
||||
echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
|
||||
echo "localnet ::1/128" >> $conf; \
|
||||
echo "[ProxyList]" >> $conf; \
|
||||
echo "$protocol $host $port" >> $conf; \
|
||||
cat /etc/proxychains.conf; \
|
||||
proxychains -f $conf node server.js; \
|
||||
export HOSTNAME="127.0.0.1"; \
|
||||
protocol=$(echo $PROXY_URL | cut -d: -f1); \
|
||||
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
|
||||
port=$(echo $PROXY_URL | cut -d: -f3); \
|
||||
conf=/etc/proxychains.conf; \
|
||||
echo "strict_chain" > $conf; \
|
||||
echo "proxy_dns" >> $conf; \
|
||||
echo "remote_dns_subnet 224" >> $conf; \
|
||||
echo "tcp_read_time_out 15000" >> $conf; \
|
||||
echo "tcp_connect_time_out 8000" >> $conf; \
|
||||
echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \
|
||||
echo "localnet ::1/128" >> $conf; \
|
||||
echo "[ProxyList]" >> $conf; \
|
||||
echo "$protocol $host $port" >> $conf; \
|
||||
cat /etc/proxychains.conf; \
|
||||
proxychains -f $conf node server.js; \
|
||||
else \
|
||||
node server.js; \
|
||||
node server.js; \
|
||||
fi
|
||||
|
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-2024 Zhang Yifei
|
||||
Copyright (c) 2023 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
|
||||
|
202
README.md
202
README.md
@@ -1,73 +1,44 @@
|
||||
<div align="center">
|
||||
<img src="./docs/images/icon.svg" alt="icon"/>
|
||||
|
||||
<a href='#企业版'>
|
||||
<img src="./docs/images/ent.svg" alt="icon"/>
|
||||
</a>
|
||||
|
||||
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
|
||||
<h1 align="center">ChatGPT Next Web</h1>
|
||||
|
||||
English / [简体中文](./README_CN.md)
|
||||
|
||||
One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support.
|
||||
One-Click to get well-designed cross-platform ChatGPT web UI.
|
||||
|
||||
一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型。
|
||||
一键免费部署你的跨平台私人 ChatGPT 应用。
|
||||
|
||||
[![Web][Web-image]][web-url]
|
||||
[![Windows][Windows-image]][download-url]
|
||||
[![MacOS][MacOS-image]][download-url]
|
||||
[![Linux][Linux-image]][download-url]
|
||||
|
||||
[Web App](https://app.nextchat.dev/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Enterprise Edition](#enterprise-edition) / [Twitter](https://twitter.com/NextChatDev)
|
||||
[Web App](https://chatgpt.nextweb.fun/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Twitter](https://twitter.com/mortiest_ricky) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
|
||||
|
||||
[网页版](https://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues)
|
||||
[网页版](https://chatgpt.nextweb.fun/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg)
|
||||
|
||||
[web-url]: https://app.nextchat.dev/
|
||||
[web-url]: https://chatgpt.nextweb.fun
|
||||
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
|
||||
[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge
|
||||
[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows
|
||||
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
|
||||
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
|
||||
|
||||
[<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](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) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
|
||||
|
||||
[<img src="https://github.com/user-attachments/assets/903482d4-3e87-4134-9af1-f2588fa90659" height="60" width="288" >](https://monica.im/?utm=nxcrp)
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## Enterprise Edition
|
||||
|
||||
Meeting Your Company's Privatization and Customization Deployment Requirements:
|
||||
- **Brand Customization**: Tailored VI/UI to seamlessly align with your corporate brand image.
|
||||
- **Resource Integration**: Unified configuration and management of dozens of AI resources by company administrators, ready for use by team members.
|
||||
- **Permission Control**: Clearly defined member permissions, resource permissions, and knowledge base permissions, all controlled via a corporate-grade Admin Panel.
|
||||
- **Knowledge Integration**: Combining your internal knowledge base with AI capabilities, making it more relevant to your company's specific business needs compared to general AI.
|
||||
- **Security Auditing**: Automatically intercept sensitive inquiries and trace all historical conversation records, ensuring AI adherence to corporate information security standards.
|
||||
- **Private Deployment**: Enterprise-level private deployment supporting various mainstream private cloud solutions, ensuring data security and privacy protection.
|
||||
- **Continuous Updates**: Ongoing updates and upgrades in cutting-edge capabilities like multimodal AI, ensuring consistent innovation and advancement.
|
||||
|
||||
For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
|
||||
## 企业版
|
||||
|
||||
满足企业用户私有化部署和个性化定制需求:
|
||||
- **品牌定制**:企业量身定制 VI/UI,与企业品牌形象无缝契合
|
||||
- **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用
|
||||
- **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制
|
||||
- **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求
|
||||
- **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范
|
||||
- **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护
|
||||
- **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进
|
||||
|
||||
企业版咨询: **business@nextchat.dev**
|
||||
|
||||
<img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601">
|
||||
|
||||
## Features
|
||||
|
||||
- **Deploy for free with one-click** on Vercel in under 1 minute
|
||||
- Compact client (~5MB) on Linux/Windows/MacOS, [download it now](https://github.com/Yidadaa/ChatGPT-Next-Web/releases)
|
||||
- Fully compatible with self-deployed LLMs, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI)
|
||||
- Privacy first, all data is stored locally in the browser
|
||||
- Fully compatible with self-deployed llms, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI)
|
||||
- Privacy first, all data stored locally in the browser
|
||||
- Markdown support: LaTex, mermaid, code highlight, etc.
|
||||
- Responsive design, dark mode and PWA
|
||||
- Fast first screen loading speed (~100kb), support streaming response
|
||||
@@ -76,12 +47,6 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
- Automatically compresses chat history to support long conversations while also saving your tokens
|
||||
- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
||||
@@ -90,20 +55,14 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||
- [x] Desktop App with tauri
|
||||
- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
|
||||
- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
|
||||
- [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||
- [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||
- [ ] local knowledge base
|
||||
- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||
|
||||
## What's New
|
||||
|
||||
- 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
|
||||
- 🚀 v2.14.0 Now supports Artifacts & SD
|
||||
- 🚀 v2.10.1 support Google Gemini Pro model.
|
||||
- 🚀 v2.9.11 you can use azure endpoint now.
|
||||
- 🚀 v2.8 now we have a client that runs across all platforms!
|
||||
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
|
||||
- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/).
|
||||
- 🚀 v2.7 let's share conversations as image, or share to ShareGPT!
|
||||
- 🚀 v2.8 now we have a client that runs across all platforms!
|
||||
- 🚀 v2.9.11 you can use azure endpoint now.
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -116,7 +75,7 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话
|
||||
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
|
||||
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
|
||||
- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
|
||||
- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština
|
||||
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
|
||||
|
||||
## 开发计划
|
||||
@@ -127,21 +86,15 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
||||
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||
- [x] 使用 tauri 打包桌面应用
|
||||
- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
|
||||
- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
|
||||
- [x] 插件机制,支持`联网搜索`、`计算器`、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||
- [x] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
|
||||
- [ ] 本地知识库
|
||||
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||
|
||||
## 最新动态
|
||||
|
||||
- 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
|
||||
- 🚀 v2.14.0 现在支持 Artifacts & SD 了。
|
||||
- 🚀 v2.10.1 现在支持 Gemini Pro 模型。
|
||||
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
||||
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
||||
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
||||
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
||||
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
||||
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
||||
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
||||
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
||||
|
||||
## Get Started
|
||||
|
||||
@@ -208,7 +161,7 @@ Access password, separated by comma.
|
||||
|
||||
### `OPENAI_API_KEY` (required)
|
||||
|
||||
Your openai api key, join multiple api keys with comma.
|
||||
Your openai api key.
|
||||
|
||||
### `BASE_URL` (optional)
|
||||
|
||||
@@ -224,7 +177,7 @@ Specify OpenAI organization ID.
|
||||
|
||||
### `AZURE_URL` (optional)
|
||||
|
||||
> Example: https://{azure-resource-url}/openai
|
||||
> Example: https://{azure-resource-url}/openai/deployments/{deploy-name}
|
||||
|
||||
Azure deploy url.
|
||||
|
||||
@@ -236,66 +189,6 @@ Azure Api Key.
|
||||
|
||||
Azure Api Version, find it at [Azure Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions).
|
||||
|
||||
### `GOOGLE_API_KEY` (optional)
|
||||
|
||||
Google Gemini Pro Api Key.
|
||||
|
||||
### `GOOGLE_URL` (optional)
|
||||
|
||||
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.
|
||||
|
||||
### `BAIDU_API_KEY` (optional)
|
||||
|
||||
Baidu Api Key.
|
||||
|
||||
### `BAIDU_SECRET_KEY` (optional)
|
||||
|
||||
Baidu Secret Key.
|
||||
|
||||
### `BAIDU_URL` (optional)
|
||||
|
||||
Baidu Api Url.
|
||||
|
||||
### `BYTEDANCE_API_KEY` (optional)
|
||||
|
||||
ByteDance Api Key.
|
||||
|
||||
### `BYTEDANCE_URL` (optional)
|
||||
|
||||
ByteDance Api Url.
|
||||
|
||||
### `ALIBABA_API_KEY` (optional)
|
||||
|
||||
Alibaba Cloud Api Key.
|
||||
|
||||
### `ALIBABA_URL` (optional)
|
||||
|
||||
Alibaba Cloud Api Url.
|
||||
|
||||
### `IFLYTEK_URL` (Optional)
|
||||
|
||||
iflytek Api Url.
|
||||
|
||||
### `IFLYTEK_API_KEY` (Optional)
|
||||
|
||||
iflytek Api Key.
|
||||
|
||||
### `IFLYTEK_API_SECRET` (Optional)
|
||||
|
||||
iflytek Api Secret.
|
||||
|
||||
### `HIDE_USER_API_KEY` (optional)
|
||||
|
||||
> Default: Empty
|
||||
@@ -312,7 +205,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.
|
||||
If you do want users to query balance, set this value to 1, or you should set it to 0.
|
||||
|
||||
### `DISABLE_FAST_LINK` (optional)
|
||||
|
||||
@@ -323,41 +216,9 @@ If you want to disable parse settings from url, set this to 1.
|
||||
### `CUSTOM_MODELS` (optional)
|
||||
|
||||
> Default: Empty
|
||||
> Example: `+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` means add `llama, claude-2` to model list, and remove `gpt-3.5-turbo` from list, and display `gpt-4-1106-preview` as `gpt-4-turbo`.
|
||||
> Example: `+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview:gpt-4-turbo` means add `llama, claude-2` to model list, and remove `gpt-3.5-turbo` from list, and display `gpt-4-1106-preview` as `gpt-4-turbo`.
|
||||
|
||||
To control custom models, use `+` to add a custom model, use `-` to hide a model, use `name=displayName` to customize model name, separated by comma.
|
||||
|
||||
User `-all` to disable all default models, `+all` to enable all default models.
|
||||
|
||||
For Azure: use `modelName@azure=deploymentName` to customize model name and deployment name.
|
||||
> Example: `+gpt-3.5-turbo@azure=gpt35` will show option `gpt35(Azure)` in model list.
|
||||
> If you only can use Azure model, `-all,+gpt-3.5-turbo@azure=gpt35` will `gpt35(Azure)` the only option in model list.
|
||||
|
||||
For ByteDance: use `modelName@bytedance=deploymentName` to customize model name and deployment name.
|
||||
> Example: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` will show option `Doubao-lite-4k(ByteDance)` in model list.
|
||||
|
||||
### `DEFAULT_MODEL` (optional)
|
||||
|
||||
Change default model
|
||||
|
||||
### `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.
|
||||
|
||||
### `STABILITY_API_KEY` (optional)
|
||||
|
||||
Stability API key.
|
||||
|
||||
### `STABILITY_URL` (optional)
|
||||
|
||||
Customize Stability API url.
|
||||
To control custom models, use `+` to add a custom model, use `-` to hide a model, use `name:displayName` to customize model name, separated by comma.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -483,15 +344,10 @@ If you want to add a new translation, read this [document](./docs/translation.md
|
||||
[@piksonGit](https://github.com/piksonGit)
|
||||
[@ouyangzhiping](https://github.com/ouyangzhiping)
|
||||
[@wenjiavv](https://github.com/wenjiavv)
|
||||
[@LeXwDeX](https://github.com/LeXwDeX)
|
||||
[@Licoy](https://github.com/Licoy)
|
||||
[@shangmin2009](https://github.com/shangmin2009)
|
||||
|
||||
### Contributors
|
||||
### Contributor
|
||||
|
||||
<a href="https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=ChatGPTNextWeb/ChatGPT-Next-Web" />
|
||||
</a>
|
||||
[Contributors](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
||||
|
||||
## LICENSE
|
||||
|
||||
|
143
README_CN.md
143
README_CN.md
@@ -1,48 +1,28 @@
|
||||
<div align="center">
|
||||
<img src="./docs/images/icon.svg" alt="预览"/>
|
||||
|
||||
<a href='#企业版'>
|
||||
<img src="./docs/images/ent.svg" alt="icon"/>
|
||||
</a>
|
||||
<h1 align="center">ChatGPT Next Web</h1>
|
||||
|
||||
<h1 align="center">NextChat</h1>
|
||||
一键免费部署你的私人 ChatGPT 网页应用。
|
||||
|
||||
一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
|
||||
[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) / [QQ 群](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Donate](#捐赠-donate-usdt)
|
||||
|
||||
[企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) /[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
|
||||
|
||||
[<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](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) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## 企业版
|
||||
|
||||
满足您公司私有化部署和定制需求
|
||||
- **品牌定制**:企业量身定制 VI/UI,与企业品牌形象无缝契合
|
||||
- **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用
|
||||
- **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制
|
||||
- **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求
|
||||
- **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范
|
||||
- **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护
|
||||
- **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进
|
||||
|
||||
企业版咨询: **business@nextchat.dev**
|
||||
|
||||
<img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601">
|
||||
|
||||
## 开始使用
|
||||
|
||||
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
||||
2. 点击右侧按钮开始部署:
|
||||
[](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),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE;
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE;
|
||||
3. 部署完毕后,即可开始使用;
|
||||
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## 保持更新
|
||||
|
||||
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
|
||||
@@ -88,7 +68,7 @@ code1,code2,code3
|
||||
|
||||
### `OPENAI_API_KEY` (必填项)
|
||||
|
||||
OpanAI 密钥,你在 openai 账户页面申请的 api key,使用英文逗号隔开多个 key,这样可以随机轮询这些 key。
|
||||
OpanAI 密钥,你在 openai 账户页面申请的 api key。
|
||||
|
||||
### `CODE` (可选)
|
||||
|
||||
@@ -112,7 +92,7 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
|
||||
|
||||
### `AZURE_URL` (可选)
|
||||
|
||||
> 形如:https://{azure-resource-url}/openai
|
||||
> 形如:https://{azure-resource-url}/openai/deployments/{deploy-name}
|
||||
|
||||
Azure 部署地址。
|
||||
|
||||
@@ -124,68 +104,6 @@ Azure 密钥。
|
||||
|
||||
Azure Api 版本,你可以在这里找到:[Azure 文档](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)。
|
||||
|
||||
### `GOOGLE_API_KEY` (可选)
|
||||
|
||||
Google Gemini Pro 密钥.
|
||||
|
||||
### `GOOGLE_URL` (可选)
|
||||
|
||||
Google Gemini Pro Api Url.
|
||||
|
||||
### `ANTHROPIC_API_KEY` (可选)
|
||||
|
||||
anthropic claude Api Key.
|
||||
|
||||
### `ANTHROPIC_API_VERSION` (可选)
|
||||
|
||||
anthropic claude Api version.
|
||||
|
||||
### `ANTHROPIC_URL` (可选)
|
||||
|
||||
anthropic claude Api Url.
|
||||
|
||||
### `BAIDU_API_KEY` (可选)
|
||||
|
||||
Baidu Api Key.
|
||||
|
||||
### `BAIDU_SECRET_KEY` (可选)
|
||||
|
||||
Baidu Secret Key.
|
||||
|
||||
### `BAIDU_URL` (可选)
|
||||
|
||||
Baidu Api Url.
|
||||
|
||||
### `BYTEDANCE_API_KEY` (可选)
|
||||
|
||||
ByteDance Api Key.
|
||||
|
||||
### `BYTEDANCE_URL` (可选)
|
||||
|
||||
ByteDance Api Url.
|
||||
|
||||
### `ALIBABA_API_KEY` (可选)
|
||||
|
||||
阿里云(千问)Api Key.
|
||||
|
||||
### `ALIBABA_URL` (可选)
|
||||
|
||||
阿里云(千问)Api Url.
|
||||
|
||||
### `IFLYTEK_URL` (可选)
|
||||
|
||||
讯飞星火Api Url.
|
||||
|
||||
### `IFLYTEK_API_KEY` (可选)
|
||||
|
||||
讯飞星火Api Key.
|
||||
|
||||
### `IFLYTEK_API_SECRET` (可选)
|
||||
|
||||
讯飞星火Api Secret.
|
||||
|
||||
|
||||
|
||||
### `HIDE_USER_API_KEY` (可选)
|
||||
|
||||
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
||||
@@ -202,44 +120,11 @@ ByteDance 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`。
|
||||
> 如果你想先禁用所有模型,再启用指定模型,可以使用 `-all,+gpt-3.5-turbo`,则表示仅启用 `gpt-3.5-turbo`
|
||||
|
||||
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
||||
|
||||
在Azure的模式下,支持使用`modelName@azure=deploymentName`的方式配置模型名称和部署名称(deploy-name)
|
||||
> 示例:`+gpt-3.5-turbo@azure=gpt35`这个配置会在模型列表显示一个`gpt35(Azure)`的选项。
|
||||
> 如果你只能使用Azure模式,那么设置 `-all,+gpt-3.5-turbo@azure=gpt35` 则可以让对话的默认使用 `gpt35(Azure)`
|
||||
|
||||
在ByteDance的模式下,支持使用`modelName@bytedance=deploymentName`的方式配置模型名称和部署名称(deploy-name)
|
||||
> 示例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx`这个配置会在模型列表显示一个`Doubao-lite-4k(ByteDance)`的选项
|
||||
|
||||
|
||||
### `DEFAULT_MODEL` (可选)
|
||||
|
||||
更改默认模型
|
||||
|
||||
### `DEFAULT_INPUT_TEMPLATE` (可选)
|
||||
|
||||
自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
|
||||
|
||||
### `STABILITY_API_KEY` (optional)
|
||||
|
||||
Stability API密钥
|
||||
|
||||
### `STABILITY_URL` (optional)
|
||||
|
||||
自定义的Stability API请求地址
|
||||
> 示例:`+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`。
|
||||
|
||||
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名:展示名` 来自定义模型的展示名,用英文逗号隔开。
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -253,7 +138,7 @@ Stability API密钥
|
||||
OPENAI_API_KEY=<your api key here>
|
||||
|
||||
# 中国大陆用户,可以使用本项目自带的代理进行开发,你也可以自由选择其他代理地址
|
||||
BASE_URL=https://b.nextweb.fun/api/proxy
|
||||
BASE_URL=https://a.nextweb.fun/api/proxy
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
|
310
README_JA.md
310
README_JA.md
@@ -1,310 +0,0 @@
|
||||
<div align="center">
|
||||
<img src="./docs/images/ent.svg" alt="プレビュー"/>
|
||||
|
||||
<h1 align="center">NextChat</h1>
|
||||
|
||||
ワンクリックで無料であなた専用の ChatGPT ウェブアプリをデプロイ。GPT3、GPT4 & Gemini Pro モデルをサポート。
|
||||
|
||||
[企業版](#企業版) / [デモ](https://chat-gpt-next-web.vercel.app/) / [フィードバック](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Discordに参加](https://discord.gg/zrhvHCr79N)
|
||||
|
||||
[<img src="https://vercel.com/button" alt="Zeaburでデプロイ" height="30">](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) [<img src="https://zeabur.com/button.svg" alt="Zeaburでデプロイ" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Gitpodで開く" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 企業版
|
||||
|
||||
あなたの会社のプライベートデプロイとカスタマイズのニーズに応える
|
||||
- **ブランドカスタマイズ**:企業向けに特別に設計された VI/UI、企業ブランドイメージとシームレスにマッチ
|
||||
- **リソース統合**:企業管理者が数十種類のAIリソースを統一管理、チームメンバーはすぐに使用可能
|
||||
- **権限管理**:メンバーの権限、リソースの権限、ナレッジベースの権限を明確にし、企業レベルのAdmin Panelで統一管理
|
||||
- **知識の統合**:企業内部のナレッジベースとAI機能を結びつけ、汎用AIよりも企業自身の業務ニーズに近づける
|
||||
- **セキュリティ監査**:機密質問を自動的にブロックし、すべての履歴対話を追跡可能にし、AIも企業の情報セキュリティ基準に従わせる
|
||||
- **プライベートデプロイ**:企業レベルのプライベートデプロイ、主要なプライベートクラウドデプロイをサポートし、データのセキュリティとプライバシーを保護
|
||||
- **継続的な更新**:マルチモーダル、エージェントなどの最先端機能を継続的に更新し、常に最新であり続ける
|
||||
|
||||
企業版のお問い合わせ: **business@nextchat.dev**
|
||||
|
||||
|
||||
## 始めに
|
||||
|
||||
1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する;
|
||||
2. 右側のボタンをクリックしてデプロイを開始:
|
||||
[](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) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください;
|
||||
3. デプロイが完了したら、すぐに使用を開始できます;
|
||||
4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
## 更新を維持する
|
||||
|
||||
もし上記の手順に従ってワンクリックでプロジェクトをデプロイした場合、「更新があります」というメッセージが常に表示されることがあります。これは、Vercel がデフォルトで新しいプロジェクトを作成するためで、本プロジェクトを fork していないことが原因です。そのため、正しく更新を検出できません。
|
||||
|
||||
以下の手順に従って再デプロイすることをお勧めします:
|
||||
|
||||
- 元のリポジトリを削除する
|
||||
- ページ右上の fork ボタンを使って、本プロジェクトを fork する
|
||||
- Vercel で再度選択してデプロイする、[詳細な手順はこちらを参照してください](./docs/vercel-ja.md)。
|
||||
|
||||
|
||||
### 自動更新を開く
|
||||
|
||||
> Upstream Sync の実行エラーが発生した場合は、手動で Sync Fork してください!
|
||||
|
||||
プロジェクトを fork した後、GitHub の制限により、fork 後のプロジェクトの Actions ページで Workflows を手動で有効にし、Upstream Sync Action を有効にする必要があります。有効化後、毎時の定期自動更新が可能になります:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
### 手動でコードを更新する
|
||||
|
||||
手動で即座に更新したい場合は、[GitHub のドキュメント](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork)を参照して、fork したプロジェクトを上流のコードと同期する方法を確認してください。
|
||||
|
||||
このプロジェクトをスターまたはウォッチしたり、作者をフォローすることで、新機能の更新通知をすぐに受け取ることができます。
|
||||
|
||||
|
||||
|
||||
## ページアクセスパスワードを設定する
|
||||
|
||||
> パスワードを設定すると、ユーザーは設定ページでアクセスコードを手動で入力しない限り、通常のチャットができず、未承認の状態であることを示すメッセージが表示されます。
|
||||
|
||||
> **警告**:パスワードの桁数は十分に長く設定してください。7桁以上が望ましいです。さもないと、[ブルートフォース攻撃を受ける可能性があります](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。
|
||||
|
||||
このプロジェクトは限られた権限管理機能を提供しています。Vercel プロジェクトのコントロールパネルで、環境変数ページに `CODE` という名前の環境変数を追加し、値をカンマで区切ったカスタムパスワードに設定してください:
|
||||
|
||||
```
|
||||
code1,code2,code3
|
||||
```
|
||||
|
||||
この環境変数を追加または変更した後、**プロジェクトを再デプロイ**して変更を有効にしてください。
|
||||
|
||||
|
||||
## 環境変数
|
||||
|
||||
> 本プロジェクトのほとんどの設定は環境変数で行います。チュートリアル:[Vercel の環境変数を変更する方法](./docs/vercel-ja.md)。
|
||||
|
||||
### `OPENAI_API_KEY` (必須)
|
||||
|
||||
OpenAI の API キー。OpenAI アカウントページで申請したキーをカンマで区切って複数設定できます。これにより、ランダムにキーが選択されます。
|
||||
|
||||
### `CODE` (オプション)
|
||||
|
||||
アクセスパスワード。カンマで区切って複数設定可能。
|
||||
|
||||
**警告**:この項目を設定しないと、誰でもデプロイしたウェブサイトを利用でき、トークンが急速に消耗する可能性があるため、設定をお勧めします。
|
||||
|
||||
### `BASE_URL` (オプション)
|
||||
|
||||
> デフォルト: `https://api.openai.com`
|
||||
|
||||
> 例: `http://your-openai-proxy.com`
|
||||
|
||||
OpenAI API のプロキシ URL。手動で OpenAI API のプロキシを設定している場合はこのオプションを設定してください。
|
||||
|
||||
> SSL 証明書の問題がある場合は、`BASE_URL` のプロトコルを http に設定してください。
|
||||
|
||||
### `OPENAI_ORG_ID` (オプション)
|
||||
|
||||
OpenAI の組織 ID を指定します。
|
||||
|
||||
### `AZURE_URL` (オプション)
|
||||
|
||||
> 形式: https://{azure-resource-url}/openai/deployments/{deploy-name}
|
||||
> `CUSTOM_MODELS` で `displayName` 形式で {deploy-name} を設定した場合、`AZURE_URL` から {deploy-name} を省略できます。
|
||||
|
||||
Azure のデプロイ URL。
|
||||
|
||||
### `AZURE_API_KEY` (オプション)
|
||||
|
||||
Azure の API キー。
|
||||
|
||||
### `AZURE_API_VERSION` (オプション)
|
||||
|
||||
Azure API バージョン。[Azure ドキュメント](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)で確認できます。
|
||||
|
||||
### `GOOGLE_API_KEY` (オプション)
|
||||
|
||||
Google Gemini Pro API キー。
|
||||
|
||||
### `GOOGLE_URL` (オプション)
|
||||
|
||||
Google Gemini Pro API の URL。
|
||||
|
||||
### `ANTHROPIC_API_KEY` (オプション)
|
||||
|
||||
Anthropic Claude API キー。
|
||||
|
||||
### `ANTHROPIC_API_VERSION` (オプション)
|
||||
|
||||
Anthropic Claude API バージョン。
|
||||
|
||||
### `ANTHROPIC_URL` (オプション)
|
||||
|
||||
Anthropic Claude API の URL。
|
||||
|
||||
### `BAIDU_API_KEY` (オプション)
|
||||
|
||||
Baidu API キー。
|
||||
|
||||
### `BAIDU_SECRET_KEY` (オプション)
|
||||
|
||||
Baidu シークレットキー。
|
||||
|
||||
### `BAIDU_URL` (オプション)
|
||||
|
||||
Baidu API の URL。
|
||||
|
||||
### `BYTEDANCE_API_KEY` (オプション)
|
||||
|
||||
ByteDance API キー。
|
||||
|
||||
### `BYTEDANCE_URL` (オプション)
|
||||
|
||||
ByteDance API の URL。
|
||||
|
||||
### `ALIBABA_API_KEY` (オプション)
|
||||
|
||||
アリババ(千问)API キー。
|
||||
|
||||
### `ALIBABA_URL` (オプション)
|
||||
|
||||
アリババ(千问)API の URL。
|
||||
|
||||
### `HIDE_USER_API_KEY` (オプション)
|
||||
|
||||
ユーザーが API キーを入力できないようにしたい場合は、この環境変数を 1 に設定します。
|
||||
|
||||
### `DISABLE_GPT4` (オプション)
|
||||
|
||||
ユーザーが GPT-4 を使用できないようにしたい場合は、この環境変数を 1 に設定します。
|
||||
|
||||
### `ENABLE_BALANCE_QUERY` (オプション)
|
||||
|
||||
バランスクエリ機能を有効にしたい場合は、この環境変数を 1 に設定します。
|
||||
|
||||
### `DISABLE_FAST_LINK` (オプション)
|
||||
|
||||
リンクからのプリセット設定解析を無効にしたい場合は、この環境変数を 1 に設定します。
|
||||
|
||||
### `WHITE_WEBDEV_ENDPOINTS` (オプション)
|
||||
|
||||
アクセス許可を与える WebDAV サービスのアドレスを追加したい場合、このオプションを使用します。フォーマット要件:
|
||||
- 各アドレスは完全なエンドポイントでなければなりません。
|
||||
> `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` として表示します。
|
||||
> すべてのモデルを無効にし、特定のモデルを有効にしたい場合は、`-all,+gpt-3.5-turbo` を使用します。これは `gpt-3.5-turbo` のみを有効にすることを意味します。
|
||||
|
||||
モデルリストを管理します。`+` でモデルを追加し、`-` でモデルを非表示にし、`モデル名=表示名` でモデルの表示名をカスタマイズし、カンマで区切ります。
|
||||
|
||||
Azure モードでは、`modelName@azure=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。
|
||||
> 例:`+gpt-3.5-turbo@azure=gpt35` この設定でモデルリストに `gpt35(Azure)` のオプションが表示されます。
|
||||
|
||||
ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。
|
||||
> 例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` この設定でモデルリストに `Doubao-lite-4k(ByteDance)` のオプションが表示されます。
|
||||
|
||||
### `DEFAULT_MODEL` (オプション)
|
||||
|
||||
デフォルトのモデルを変更します。
|
||||
|
||||
### `DEFAULT_INPUT_TEMPLATE` (オプション)
|
||||
|
||||
『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。
|
||||
|
||||
|
||||
## 開発
|
||||
|
||||
下のボタンをクリックして二次開発を開始してください:
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||
|
||||
コードを書く前に、プロジェクトのルートディレクトリに `.env.local` ファイルを新規作成し、環境変数を記入します:
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=<your api key here>
|
||||
```
|
||||
|
||||
|
||||
### ローカル開発
|
||||
|
||||
1. Node.js 18 と Yarn をインストールします。具体的な方法は ChatGPT にお尋ねください。
|
||||
2. `yarn install && yarn dev` を実行します。⚠️ 注意:このコマンドはローカル開発用であり、デプロイには使用しないでください。
|
||||
3. ローカルでデプロイしたい場合は、`yarn install && yarn build && yarn start` コマンドを使用してください。プロセスを守るために pm2 を使用することもできます。詳細は ChatGPT にお尋ねください。
|
||||
|
||||
|
||||
## デプロイ
|
||||
|
||||
### コンテナデプロイ(推奨)
|
||||
|
||||
> Docker バージョンは 20 以上が必要です。それ以下だとイメージが見つからないというエラーが出ます。
|
||||
|
||||
> ⚠️ 注意:Docker バージョンは最新バージョンより 1~2 日遅れることが多いため、デプロイ後に「更新があります」の通知が出続けることがありますが、正常です。
|
||||
|
||||
```shell
|
||||
docker pull yidadaa/chatgpt-next-web
|
||||
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY=sk-xxxx \
|
||||
-e CODE=ページアクセスパスワード \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
プロキシを指定することもできます:
|
||||
|
||||
```shell
|
||||
docker run -d -p 3000:3000 \
|
||||
-e OPENAI_API_KEY=sk-xxxx \
|
||||
-e CODE=ページアクセスパスワード \
|
||||
--net=host \
|
||||
-e PROXY_URL=http://127.0.0.1:7890 \
|
||||
yidadaa/chatgpt-next-web
|
||||
```
|
||||
|
||||
ローカルプロキシがアカウントとパスワードを必要とする場合は、以下を使用できます:
|
||||
|
||||
```shell
|
||||
-e PROXY_URL="http://127.0.0.1:7890 user password"
|
||||
```
|
||||
|
||||
他の環境変数を指定する必要がある場合は、上記のコマンドに `-e 環境変数=環境変数値` を追加して指定してください。
|
||||
|
||||
|
||||
### ローカルデプロイ
|
||||
|
||||
コンソールで以下のコマンドを実行します:
|
||||
|
||||
```shell
|
||||
bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh)
|
||||
```
|
||||
|
||||
⚠️ 注意:インストール中に問題が発生した場合は、Docker を使用してデプロイしてください。
|
||||
|
||||
|
||||
## 謝辞
|
||||
|
||||
### 寄付者
|
||||
|
||||
> 英語版をご覧ください。
|
||||
|
||||
### 貢献者
|
||||
|
||||
[プロジェクトの貢献者リストはこちら](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
|
||||
|
||||
### 関連プロジェクト
|
||||
|
||||
- [one-api](https://github.com/songquanpeng/one-api): 一つのプラットフォームで大規模モデルのクォータ管理を提供し、市場に出回っているすべての主要な大規模言語モデルをサポートします。
|
||||
|
||||
|
||||
## オープンソースライセンス
|
||||
|
||||
[MIT](https://opensource.org/license/mit/)
|
@@ -1,70 +0,0 @@
|
||||
import { ApiPath } from "@/app/constant";
|
||||
import { NextRequest } from "next/server";
|
||||
import { handle as openaiHandler } from "../../openai";
|
||||
import { handle as azureHandler } from "../../azure";
|
||||
import { handle as googleHandler } from "../../google";
|
||||
import { handle as anthropicHandler } from "../../anthropic";
|
||||
import { handle as baiduHandler } from "../../baidu";
|
||||
import { handle as bytedanceHandler } from "../../bytedance";
|
||||
import { handle as alibabaHandler } from "../../alibaba";
|
||||
import { handle as moonshotHandler } from "../../moonshot";
|
||||
import { handle as stabilityHandler } from "../../stability";
|
||||
import { handle as iflytekHandler } from "../../iflytek";
|
||||
import { handle as proxyHandler } from "../../proxy";
|
||||
|
||||
async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { provider: string; path: string[] } },
|
||||
) {
|
||||
const apiPath = `/api/${params.provider}`;
|
||||
console.log(`[${params.provider} Route] params `, params);
|
||||
switch (apiPath) {
|
||||
case ApiPath.Azure:
|
||||
return azureHandler(req, { params });
|
||||
case ApiPath.Google:
|
||||
return googleHandler(req, { params });
|
||||
case ApiPath.Anthropic:
|
||||
return anthropicHandler(req, { params });
|
||||
case ApiPath.Baidu:
|
||||
return baiduHandler(req, { params });
|
||||
case ApiPath.ByteDance:
|
||||
return bytedanceHandler(req, { params });
|
||||
case ApiPath.Alibaba:
|
||||
return alibabaHandler(req, { params });
|
||||
// case ApiPath.Tencent: using "/api/tencent"
|
||||
case ApiPath.Moonshot:
|
||||
return moonshotHandler(req, { params });
|
||||
case ApiPath.Stability:
|
||||
return stabilityHandler(req, { params });
|
||||
case ApiPath.Iflytek:
|
||||
return iflytekHandler(req, { params });
|
||||
case ApiPath.OpenAI:
|
||||
return openaiHandler(req, { params });
|
||||
default:
|
||||
return proxyHandler(req, { params });
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
];
|
@@ -1,129 +0,0 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
ALIBABA_BASE_URL,
|
||||
ApiPath,
|
||||
ModelProvider,
|
||||
ServiceProvider,
|
||||
} from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/api/auth";
|
||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Alibaba Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Qwen);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Alibaba] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
// alibaba use base url or just remove the path
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Alibaba, "");
|
||||
|
||||
let baseUrl = serverConfig.alibabaUrl || ALIBABA_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",
|
||||
Authorization: req.headers.get("Authorization") ?? "",
|
||||
"X-DashScope-SSE": req.headers.get("X-DashScope-SSE") ?? "disable",
|
||||
},
|
||||
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 clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Alibaba as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Alibaba] filter`, e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
@@ -1,170 +0,0 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
ANTHROPIC_BASE_URL,
|
||||
Anthropic,
|
||||
ApiPath,
|
||||
ServiceProvider,
|
||||
ModelProvider,
|
||||
} from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
|
||||
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
||||
|
||||
export 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));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
// try rebuild url, when using cloudflare ai gateway in server
|
||||
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}${path}`);
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
[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 clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Anthropic as string,
|
||||
)
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
@@ -1,73 +0,0 @@
|
||||
import md5 from "spark-md5";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
|
||||
async function handle(req: NextRequest, res: NextResponse) {
|
||||
const serverConfig = getServerSideConfig();
|
||||
const storeUrl = () =>
|
||||
`https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`;
|
||||
const storeHeaders = () => ({
|
||||
Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
|
||||
});
|
||||
if (req.method === "POST") {
|
||||
const clonedBody = await req.text();
|
||||
const hashedCode = md5.hash(clonedBody).trim();
|
||||
const body: {
|
||||
key: string;
|
||||
value: string;
|
||||
expiration_ttl?: number;
|
||||
} = {
|
||||
key: hashedCode,
|
||||
value: clonedBody,
|
||||
};
|
||||
try {
|
||||
const ttl = parseInt(serverConfig.cloudflareKVTTL as string);
|
||||
if (ttl > 60) {
|
||||
body["expiration_ttl"] = ttl;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const res = await fetch(`${storeUrl()}/bulk`, {
|
||||
headers: {
|
||||
...storeHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
body: JSON.stringify([body]),
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log("save data", result);
|
||||
if (result?.success) {
|
||||
return NextResponse.json(
|
||||
{ code: 0, id: hashedCode, result },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: true, msg: "Save data error" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (req.method === "GET") {
|
||||
const id = req?.nextUrl?.searchParams?.get("id");
|
||||
const res = await fetch(`${storeUrl()}/values/${id}`, {
|
||||
headers: storeHeaders(),
|
||||
method: "GET",
|
||||
});
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: true, msg: "Invalid request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = handle;
|
||||
export const GET = handle;
|
||||
|
||||
export const runtime = "edge";
|
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSideConfig } from "../config/server";
|
||||
import md5 from "spark-md5";
|
||||
import { ACCESS_CODE_PREFIX, ModelProvider } from "../constant";
|
||||
import { ACCESS_CODE_PREFIX } from "../constant";
|
||||
|
||||
function getIP(req: NextRequest) {
|
||||
let ip = req.ip ?? req.headers.get("x-real-ip");
|
||||
@@ -16,15 +16,15 @@ function getIP(req: NextRequest) {
|
||||
|
||||
function parseApiKey(bearToken: string) {
|
||||
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||
const isApiKey = !token.startsWith(ACCESS_CODE_PREFIX);
|
||||
const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX);
|
||||
|
||||
return {
|
||||
accessCode: isApiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
|
||||
apiKey: isApiKey ? token : "",
|
||||
accessCode: isOpenAiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
|
||||
apiKey: isOpenAiKey ? token : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
||||
export function auth(req: NextRequest) {
|
||||
const authToken = req.headers.get("Authorization") ?? "";
|
||||
|
||||
// check if it is openai api key or user token
|
||||
@@ -46,64 +46,18 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
||||
};
|
||||
}
|
||||
|
||||
if (serverConfig.hideUserApiKey && !!apiKey) {
|
||||
return {
|
||||
error: true,
|
||||
msg: "you are not allowed to access with your own api key",
|
||||
};
|
||||
}
|
||||
|
||||
// if user does not provide an api key, inject system api key
|
||||
if (!apiKey) {
|
||||
const serverConfig = getServerSideConfig();
|
||||
const serverApiKey = 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.Stability:
|
||||
systemApiKey = serverConfig.stabilityApiKey;
|
||||
break;
|
||||
case ModelProvider.GeminiPro:
|
||||
systemApiKey = serverConfig.googleApiKey;
|
||||
break;
|
||||
case ModelProvider.Claude:
|
||||
systemApiKey = serverConfig.anthropicApiKey;
|
||||
break;
|
||||
case ModelProvider.Doubao:
|
||||
systemApiKey = serverConfig.bytedanceApiKey;
|
||||
break;
|
||||
case ModelProvider.Ernie:
|
||||
systemApiKey = serverConfig.baiduApiKey;
|
||||
break;
|
||||
case ModelProvider.Qwen:
|
||||
systemApiKey = serverConfig.alibabaApiKey;
|
||||
break;
|
||||
case ModelProvider.Moonshot:
|
||||
systemApiKey = serverConfig.moonshotApiKey;
|
||||
break;
|
||||
case ModelProvider.Iflytek:
|
||||
systemApiKey =
|
||||
serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
|
||||
break;
|
||||
case ModelProvider.GPT:
|
||||
default:
|
||||
if (req.nextUrl.pathname.includes("azure/deployments")) {
|
||||
systemApiKey = serverConfig.azureApiKey;
|
||||
} else {
|
||||
systemApiKey = serverConfig.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (systemApiKey) {
|
||||
if (serverApiKey) {
|
||||
console.log("[Auth] use system api key");
|
||||
req.headers.set("Authorization", `Bearer ${systemApiKey}`);
|
||||
req.headers.set(
|
||||
"Authorization",
|
||||
`${serverConfig.isAzure ? "" : "Bearer "}${serverApiKey}`,
|
||||
);
|
||||
} else {
|
||||
console.log("[Auth] admin did not provide an api key");
|
||||
}
|
||||
|
@@ -1,32 +0,0 @@
|
||||
import { ModelProvider } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
import { requestOpenai } from "./common";
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Azure Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const subpath = params.path.join("/");
|
||||
|
||||
const authResult = auth(req, ModelProvider.GPT);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestOpenai(req);
|
||||
} catch (e) {
|
||||
console.error("[Azure] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
144
app/api/baidu.ts
144
app/api/baidu.ts
@@ -1,144 +0,0 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
BAIDU_BASE_URL,
|
||||
ApiPath,
|
||||
ModelProvider,
|
||||
ServiceProvider,
|
||||
} from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/api/auth";
|
||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||
import { getAccessToken } from "@/app/utils/baidu";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Baidu Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Ernie);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (!serverConfig.baiduApiKey || !serverConfig.baiduSecretKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `missing BAIDU_API_KEY or BAIDU_SECRET_KEY in server env vars`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Baidu] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Baidu, "");
|
||||
|
||||
let baseUrl = serverConfig.baiduUrl || BAIDU_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 { access_token } = await getAccessToken(
|
||||
serverConfig.baiduApiKey as string,
|
||||
serverConfig.baiduSecretKey as string,
|
||||
);
|
||||
const fetchUrl = `${baseUrl}${path}?access_token=${access_token}`;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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 clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Baidu as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Baidu] filter`, e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
@@ -1,129 +0,0 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
BYTEDANCE_BASE_URL,
|
||||
ApiPath,
|
||||
ModelProvider,
|
||||
ServiceProvider,
|
||||
} from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/api/auth";
|
||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[ByteDance Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Doubao);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[ByteDance] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.ByteDance, "");
|
||||
|
||||
let baseUrl = serverConfig.bytedanceUrl || BYTEDANCE_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",
|
||||
Authorization: req.headers.get("Authorization") ?? "",
|
||||
},
|
||||
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 clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.ByteDance as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ByteDance] filter`, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
@@ -1,36 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSideConfig } from "../config/server";
|
||||
import { OPENAI_BASE_URL, ServiceProvider } from "../constant";
|
||||
import { isModelAvailableInServer } from "../utils/model";
|
||||
import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
|
||||
import { DEFAULT_MODELS, OPENAI_BASE_URL } from "../constant";
|
||||
import { collectModelTable } from "../utils/model";
|
||||
import { makeAzurePath } from "../azure";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function requestOpenai(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
const isAzure = req.nextUrl.pathname.includes("azure/deployments");
|
||||
const authValue = req.headers.get("Authorization") ?? "";
|
||||
const authHeaderName = serverConfig.isAzure ? "api-key" : "Authorization";
|
||||
|
||||
var authValue,
|
||||
authHeaderName = "";
|
||||
if (isAzure) {
|
||||
authValue =
|
||||
req.headers
|
||||
.get("Authorization")
|
||||
?.trim()
|
||||
.replaceAll("Bearer ", "")
|
||||
.trim() ?? "";
|
||||
|
||||
authHeaderName = "api-key";
|
||||
} else {
|
||||
authValue = req.headers.get("Authorization") ?? "";
|
||||
authHeaderName = "Authorization";
|
||||
}
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll("/api/openai/", "");
|
||||
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
||||
"/api/openai/",
|
||||
"",
|
||||
);
|
||||
|
||||
let baseUrl =
|
||||
(isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL;
|
||||
serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL;
|
||||
|
||||
if (!baseUrl.startsWith("http")) {
|
||||
baseUrl = `https://${baseUrl}`;
|
||||
@@ -42,6 +30,7 @@ export async function requestOpenai(req: NextRequest) {
|
||||
|
||||
console.log("[Proxy] ", path);
|
||||
console.log("[Base Url]", baseUrl);
|
||||
console.log("[Org ID]", serverConfig.openaiOrgId);
|
||||
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
@@ -50,46 +39,17 @@ export async function requestOpenai(req: NextRequest) {
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
if (isAzure) {
|
||||
const azureApiVersion =
|
||||
req?.nextUrl?.searchParams?.get("api-version") ||
|
||||
serverConfig.azureApiVersion;
|
||||
baseUrl = baseUrl.split("/deployments").shift() as string;
|
||||
path = `${req.nextUrl.pathname.replaceAll(
|
||||
"/api/azure/",
|
||||
"",
|
||||
)}?api-version=${azureApiVersion}`;
|
||||
|
||||
// Forward compatibility:
|
||||
// if display_name(deployment_name) not set, and '{deploy-id}' in AZURE_URL
|
||||
// then using default '{deploy-id}'
|
||||
if (serverConfig.customModels && serverConfig.azureUrl) {
|
||||
const modelName = path.split("/")[1];
|
||||
let realDeployName = "";
|
||||
serverConfig.customModels
|
||||
.split(",")
|
||||
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
|
||||
.forEach((m) => {
|
||||
const [fullName, displayName] = m.split("=");
|
||||
const [_, providerName] = fullName.split("@");
|
||||
if (providerName === "azure" && !displayName) {
|
||||
const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
|
||||
"deployments/",
|
||||
);
|
||||
if (deployId) {
|
||||
realDeployName = deployId;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (realDeployName) {
|
||||
console.log("[Replace with DeployId", realDeployName);
|
||||
path = path.replaceAll(modelName, realDeployName);
|
||||
}
|
||||
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 = cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
|
||||
console.log("fetchUrl", fetchUrl);
|
||||
const fetchUrl = `${baseUrl}/${path}`;
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -111,24 +71,17 @@ export async function requestOpenai(req: NextRequest) {
|
||||
// #1815 try to refuse gpt4 request
|
||||
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 (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.OpenAI as string,
|
||||
) ||
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Azure as string,
|
||||
)
|
||||
) {
|
||||
if (modelTable[jsonBody?.model ?? ""].available === false) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -147,35 +100,12 @@ 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,
|
||||
|
@@ -13,7 +13,6 @@ const DANGER_CONFIG = {
|
||||
hideBalanceQuery: serverConfig.hideBalanceQuery,
|
||||
disableFastLink: serverConfig.disableFastLink,
|
||||
customModels: serverConfig.customModels,
|
||||
defaultModel: serverConfig.defaultModel,
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
43
app/api/cors/[...path]/route.ts
Normal file
43
app/api/cors/[...path]/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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";
|
@@ -1,129 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import { ApiPath, GEMINI_BASE_URL, ModelProvider } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { provider: string; path: string[] } },
|
||||
) {
|
||||
console.log("[Google Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.GeminiPro);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const bearToken = req.headers.get("Authorization") ?? "";
|
||||
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||
|
||||
const apiKey = token ? token : serverConfig.googleApiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `missing GOOGLE_API_KEY in server env vars`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await request(req, apiKey);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Google] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
|
||||
export const runtime = "edge";
|
||||
export const preferredRegion = [
|
||||
"bom1",
|
||||
"cle1",
|
||||
"cpt1",
|
||||
"gru1",
|
||||
"hnd1",
|
||||
"iad1",
|
||||
"icn1",
|
||||
"kix1",
|
||||
"pdx1",
|
||||
"sfo1",
|
||||
"sin1",
|
||||
"syd1",
|
||||
];
|
||||
|
||||
async function request(req: NextRequest, apiKey: string) {
|
||||
const controller = new AbortController();
|
||||
|
||||
let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL;
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Google, "");
|
||||
|
||||
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}?key=${apiKey}${
|
||||
req?.nextUrl?.searchParams?.get("alt") === "sse" ? "&alt=sse" : ""
|
||||
}`;
|
||||
|
||||
console.log("[Fetch Url] ", fetchUrl);
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||
redirect: "manual",
|
||||
// @ts-ignore
|
||||
duplex: "half",
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
// 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);
|
||||
}
|
||||
}
|
@@ -1,129 +0,0 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
IFLYTEK_BASE_URL,
|
||||
ApiPath,
|
||||
ModelProvider,
|
||||
ServiceProvider,
|
||||
} from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/api/auth";
|
||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||
// iflytek
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Iflytek Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Iflytek);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Iflytek] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
// iflytek use base url or just remove the path
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Iflytek, "");
|
||||
|
||||
let baseUrl = serverConfig.iflytekUrl || IFLYTEK_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",
|
||||
Authorization: req.headers.get("Authorization") ?? "",
|
||||
},
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
redirect: "manual",
|
||||
// @ts-ignore
|
||||
duplex: "half",
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
// try to refuse some request to some models
|
||||
if (serverConfig.customModels && req.body) {
|
||||
try {
|
||||
const clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Iflytek as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Iflytek] filter`, e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
@@ -1,128 +0,0 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
MOONSHOT_BASE_URL,
|
||||
ApiPath,
|
||||
ModelProvider,
|
||||
ServiceProvider,
|
||||
} from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/api/auth";
|
||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Moonshot Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Moonshot);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Moonshot] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
// alibaba use base url or just remove the path
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Moonshot, "");
|
||||
|
||||
let baseUrl = serverConfig.moonshotUrl || MOONSHOT_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",
|
||||
Authorization: req.headers.get("Authorization") ?? "",
|
||||
},
|
||||
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 clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||
|
||||
// not undefined and is false
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Moonshot as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Moonshot] filter`, e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
@@ -1,10 +1,10 @@
|
||||
import { type OpenAIListModelResponse } from "@/app/client/platforms/openai";
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import { ModelProvider, OpenaiPath } from "@/app/constant";
|
||||
import { OpenaiPath } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
import { requestOpenai } from "./common";
|
||||
import { auth } from "../../auth";
|
||||
import { requestOpenai } from "../../common";
|
||||
|
||||
const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
|
||||
|
||||
@@ -13,16 +13,14 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
|
||||
|
||||
if (config.disableGPT4) {
|
||||
remoteModelRes.data = remoteModelRes.data.filter(
|
||||
(m) =>
|
||||
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) ||
|
||||
m.id.startsWith("gpt-4o-mini"),
|
||||
(m) => !m.id.startsWith("gpt-4"),
|
||||
);
|
||||
}
|
||||
|
||||
return remoteModelRes;
|
||||
}
|
||||
|
||||
export async function handle(
|
||||
async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
@@ -47,7 +45,7 @@ export async function handle(
|
||||
);
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.GPT);
|
||||
const authResult = auth(req);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
@@ -72,3 +70,8 @@ export async function handle(
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
|
||||
export const runtime = "edge";
|
@@ -1,75 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Proxy Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
// remove path params from searchParams
|
||||
req.nextUrl.searchParams.delete("path");
|
||||
req.nextUrl.searchParams.delete("provider");
|
||||
|
||||
const subpath = params.path.join("/");
|
||||
const fetchUrl = `${req.headers.get(
|
||||
"x-base-url",
|
||||
)}/${subpath}?${req.nextUrl.searchParams.toString()}`;
|
||||
const skipHeaders = ["connection", "host", "origin", "referer", "cookie"];
|
||||
const headers = new Headers(
|
||||
Array.from(req.headers.entries()).filter((item) => {
|
||||
if (
|
||||
item[0].indexOf("x-") > -1 ||
|
||||
item[0].indexOf("sec-") > -1 ||
|
||||
skipHeaders.includes(item[0])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const fetchOptions: RequestInit = {
|
||||
headers,
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||
redirect: "manual",
|
||||
// @ts-ignore
|
||||
duplex: "half",
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
// 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");
|
||||
|
||||
// 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,
|
||||
headers: newHeaders,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
@@ -1,99 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import { ModelProvider, STABILITY_BASE_URL } from "@/app/constant";
|
||||
import { auth } from "@/app/api/auth";
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Stability] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
let baseUrl = serverConfig.stabilityUrl || STABILITY_BASE_URL;
|
||||
|
||||
if (!baseUrl.startsWith("http")) {
|
||||
baseUrl = `https://${baseUrl}`;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll("/api/stability/", "");
|
||||
|
||||
console.log("[Stability Proxy] ", path);
|
||||
console.log("[Stability Base Url]", baseUrl);
|
||||
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
const authResult = auth(req, ModelProvider.Stability);
|
||||
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const bearToken = req.headers.get("Authorization") ?? "";
|
||||
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||
|
||||
const key = token ? token : serverConfig.stabilityApiKey;
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `missing STABILITY_API_KEY in server env vars`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const fetchUrl = `${baseUrl}/${path}`;
|
||||
console.log("[Stability Url] ", fetchUrl);
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": req.headers.get("Content-Type") || "multipart/form-data",
|
||||
Accept: req.headers.get("Accept") || "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
|
||||
redirect: "manual",
|
||||
// @ts-ignore
|
||||
duplex: "half",
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
// 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);
|
||||
}
|
||||
}
|
@@ -1,117 +0,0 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import { TENCENT_BASE_URL, ModelProvider } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/api/auth";
|
||||
import { getHeader } from "@/app/utils/tencent";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Tencent Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Hunyuan);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Tencent] ", 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",
|
||||
];
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL;
|
||||
|
||||
if (!baseUrl.startsWith("http")) {
|
||||
baseUrl = `https://${baseUrl}`;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
|
||||
console.log("[Base Url]", baseUrl);
|
||||
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
const fetchUrl = baseUrl;
|
||||
|
||||
const body = await req.text();
|
||||
const headers = await getHeader(
|
||||
body,
|
||||
serverConfig.tencentSecretId as string,
|
||||
serverConfig.tencentSecretKey as string,
|
||||
);
|
||||
const fetchOptions: RequestInit = {
|
||||
headers,
|
||||
method: req.method,
|
||||
body,
|
||||
redirect: "manual",
|
||||
// @ts-ignore
|
||||
duplex: "half",
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
@@ -1,73 +0,0 @@
|
||||
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";
|
@@ -1,167 +0,0 @@
|
||||
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");
|
||||
let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
|
||||
|
||||
// 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 (
|
||||
proxy_method !== "MKCOL" &&
|
||||
proxy_method !== "GET" &&
|
||||
proxy_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 (proxy_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 (proxy_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 (proxy_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 = proxy_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: 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";
|
9
app/azure.ts
Normal file
9
app/azure.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function makeAzurePath(path: string, apiVersion: string) {
|
||||
// should omit /v1 prefix
|
||||
path = path.replaceAll("v1/", "");
|
||||
|
||||
// should add api-key to query string
|
||||
path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`;
|
||||
|
||||
return path;
|
||||
}
|
@@ -1,66 +1,26 @@
|
||||
import { getClientConfig } from "../config/client";
|
||||
import {
|
||||
ACCESS_CODE_PREFIX,
|
||||
ModelProvider,
|
||||
ServiceProvider,
|
||||
} from "../constant";
|
||||
import {
|
||||
ChatMessageTool,
|
||||
ChatMessage,
|
||||
ModelType,
|
||||
useAccessStore,
|
||||
useChatStore,
|
||||
} from "../store";
|
||||
import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
|
||||
import { GeminiProApi } from "./platforms/google";
|
||||
import { ClaudeApi } from "./platforms/anthropic";
|
||||
import { ErnieApi } from "./platforms/baidu";
|
||||
import { DoubaoApi } from "./platforms/bytedance";
|
||||
import { QwenApi } from "./platforms/alibaba";
|
||||
import { HunyuanApi } from "./platforms/tencent";
|
||||
import { MoonshotApi } from "./platforms/moonshot";
|
||||
import { SparkApi } from "./platforms/iflytek";
|
||||
import { ACCESS_CODE_PREFIX, Azure, 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 TTSModels = ["tts-1", "tts-1-hd"] 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 | MultimodalContent[];
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LLMConfig {
|
||||
model: string;
|
||||
providerName?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stream?: boolean;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
size?: DalleRequestPayload["size"];
|
||||
quality?: DalleRequestPayload["quality"];
|
||||
style?: DalleRequestPayload["style"];
|
||||
}
|
||||
|
||||
export interface SpeechOptions {
|
||||
model: string;
|
||||
input: string;
|
||||
voice: string;
|
||||
response_format?: string;
|
||||
speed?: number;
|
||||
onController?: (controller: AbortController) => void;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
@@ -71,8 +31,6 @@ export interface ChatOptions {
|
||||
onFinish: (message: string) => void;
|
||||
onError?: (err: Error) => void;
|
||||
onController?: (controller: AbortController) => void;
|
||||
onBeforeTool?: (tool: ChatMessageTool) => void;
|
||||
onAfterTool?: (tool: ChatMessageTool) => void;
|
||||
}
|
||||
|
||||
export interface LLMUsage {
|
||||
@@ -82,24 +40,14 @@ export interface LLMUsage {
|
||||
|
||||
export interface LLMModel {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
available: boolean;
|
||||
provider: LLMModelProvider;
|
||||
sorted: number;
|
||||
}
|
||||
|
||||
export interface LLMModelProvider {
|
||||
id: string;
|
||||
providerName: string;
|
||||
providerType: string;
|
||||
sorted: number;
|
||||
}
|
||||
|
||||
export abstract class LLMApi {
|
||||
abstract chat(options: ChatOptions): Promise<void>;
|
||||
abstract speech(options: SpeechOptions): Promise<ArrayBuffer>;
|
||||
abstract usage(): Promise<LLMUsage>;
|
||||
abstract models(): Promise<LLMModel[]>;
|
||||
abstract speech(input: string): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
type ProviderName = "openai" | "azure" | "claude" | "palm";
|
||||
@@ -126,35 +74,8 @@ interface ChatProvider {
|
||||
export class ClientApi {
|
||||
public llm: LLMApi;
|
||||
|
||||
constructor(provider: ModelProvider = ModelProvider.GPT) {
|
||||
switch (provider) {
|
||||
case ModelProvider.GeminiPro:
|
||||
this.llm = new GeminiProApi();
|
||||
break;
|
||||
case ModelProvider.Claude:
|
||||
this.llm = new ClaudeApi();
|
||||
break;
|
||||
case ModelProvider.Ernie:
|
||||
this.llm = new ErnieApi();
|
||||
break;
|
||||
case ModelProvider.Doubao:
|
||||
this.llm = new DoubaoApi();
|
||||
break;
|
||||
case ModelProvider.Qwen:
|
||||
this.llm = new QwenApi();
|
||||
break;
|
||||
case ModelProvider.Hunyuan:
|
||||
this.llm = new HunyuanApi();
|
||||
break;
|
||||
case ModelProvider.Moonshot:
|
||||
this.llm = new MoonshotApi();
|
||||
break;
|
||||
case ModelProvider.Iflytek:
|
||||
this.llm = new SparkApi();
|
||||
break;
|
||||
default:
|
||||
this.llm = new ChatGPTApi();
|
||||
}
|
||||
constructor() {
|
||||
this.llm = new ChatGPTApi();
|
||||
}
|
||||
|
||||
config() {}
|
||||
@@ -173,7 +94,7 @@ export class ClientApi {
|
||||
{
|
||||
from: "human",
|
||||
value:
|
||||
"Share from [NextChat]: https://github.com/Yidadaa/ChatGPT-Next-Web",
|
||||
"Share from [ChatGPT Next Web]: https://github.com/Yidadaa/ChatGPT-Next-Web",
|
||||
},
|
||||
]);
|
||||
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
|
||||
@@ -203,125 +124,33 @@ export class ClientApi {
|
||||
}
|
||||
}
|
||||
|
||||
export function getBearerToken(
|
||||
apiKey: string,
|
||||
noBearer: boolean = false,
|
||||
): string {
|
||||
return validString(apiKey)
|
||||
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
|
||||
: "";
|
||||
}
|
||||
export const api = new ClientApi();
|
||||
|
||||
export function validString(x: string): boolean {
|
||||
return x?.length > 0;
|
||||
}
|
||||
|
||||
export function getHeaders(ignoreHeaders: boolean = false) {
|
||||
export function getHeaders() {
|
||||
const accessStore = useAccessStore.getState();
|
||||
const chatStore = useChatStore.getState();
|
||||
let headers: Record<string, string> = {};
|
||||
if (!ignoreHeaders) {
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
};
|
||||
|
||||
const clientConfig = getClientConfig();
|
||||
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||
const authHeader = isAzure ? "api-key" : "Authorization";
|
||||
const apiKey = isAzure ? accessStore.azureApiKey : accessStore.openaiApiKey;
|
||||
|
||||
function getConfig() {
|
||||
const modelConfig = chatStore.currentSession().mask.modelConfig;
|
||||
const isGoogle = modelConfig.providerName == ServiceProvider.Google;
|
||||
const isAzure = modelConfig.providerName === ServiceProvider.Azure;
|
||||
const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic;
|
||||
const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
|
||||
const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
|
||||
const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
|
||||
const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot;
|
||||
const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
|
||||
const isEnabledAccessControl = accessStore.enabledAccessControl();
|
||||
const apiKey = isGoogle
|
||||
? accessStore.googleApiKey
|
||||
: isAzure
|
||||
? accessStore.azureApiKey
|
||||
: isAnthropic
|
||||
? accessStore.anthropicApiKey
|
||||
: isByteDance
|
||||
? accessStore.bytedanceApiKey
|
||||
: isAlibaba
|
||||
? accessStore.alibabaApiKey
|
||||
: isMoonshot
|
||||
? accessStore.moonshotApiKey
|
||||
: isIflytek
|
||||
? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
|
||||
? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
|
||||
: ""
|
||||
: accessStore.openaiApiKey;
|
||||
return {
|
||||
isGoogle,
|
||||
isAzure,
|
||||
isAnthropic,
|
||||
isBaidu,
|
||||
isByteDance,
|
||||
isAlibaba,
|
||||
isMoonshot,
|
||||
isIflytek,
|
||||
apiKey,
|
||||
isEnabledAccessControl,
|
||||
};
|
||||
}
|
||||
const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`;
|
||||
const validString = (x: string) => x && x.length > 0;
|
||||
|
||||
function getAuthHeader(): string {
|
||||
return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization";
|
||||
}
|
||||
|
||||
const {
|
||||
isGoogle,
|
||||
isAzure,
|
||||
isAnthropic,
|
||||
isBaidu,
|
||||
apiKey,
|
||||
isEnabledAccessControl,
|
||||
} = getConfig();
|
||||
// when using google api in app, not set auth header
|
||||
if (isGoogle && clientConfig?.isApp) return headers;
|
||||
// when using baidu api in app, not set auth header
|
||||
if (isBaidu && clientConfig?.isApp) return headers;
|
||||
|
||||
const authHeader = getAuthHeader();
|
||||
|
||||
const bearerToken = getBearerToken(apiKey, isAzure || isAnthropic);
|
||||
|
||||
if (bearerToken) {
|
||||
headers[authHeader] = bearerToken;
|
||||
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) {
|
||||
headers["Authorization"] = getBearerToken(
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function getClientApi(provider: ServiceProvider): ClientApi {
|
||||
switch (provider) {
|
||||
case ServiceProvider.Google:
|
||||
return new ClientApi(ModelProvider.GeminiPro);
|
||||
case ServiceProvider.Anthropic:
|
||||
return new ClientApi(ModelProvider.Claude);
|
||||
case ServiceProvider.Baidu:
|
||||
return new ClientApi(ModelProvider.Ernie);
|
||||
case ServiceProvider.ByteDance:
|
||||
return new ClientApi(ModelProvider.Doubao);
|
||||
case ServiceProvider.Alibaba:
|
||||
return new ClientApi(ModelProvider.Qwen);
|
||||
case ServiceProvider.Tencent:
|
||||
return new ClientApi(ModelProvider.Hunyuan);
|
||||
case ServiceProvider.Moonshot:
|
||||
return new ClientApi(ModelProvider.Moonshot);
|
||||
case ServiceProvider.Iflytek:
|
||||
return new ClientApi(ModelProvider.Iflytek);
|
||||
default:
|
||||
return new ClientApi(ModelProvider.GPT);
|
||||
}
|
||||
}
|
||||
|
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
Alibaba,
|
||||
ALIBABA_BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
MultimodalContent,
|
||||
} from "../api";
|
||||
import Locale from "../../locales";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RequestInput {
|
||||
messages: {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string | MultimodalContent[];
|
||||
}[];
|
||||
}
|
||||
interface RequestParam {
|
||||
result_format: string;
|
||||
incremental_output?: boolean;
|
||||
temperature: number;
|
||||
repetition_penalty?: number;
|
||||
top_p: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
interface RequestPayload {
|
||||
model: string;
|
||||
input: RequestInput;
|
||||
parameters: RequestParam;
|
||||
}
|
||||
|
||||
export class QwenApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.alibabaUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
baseUrl = isApp ? ALIBABA_BASE_URL : ApiPath.Alibaba;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Alibaba)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res?.output?.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: getMessageTextContent(v),
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const requestPayload: RequestPayload = {
|
||||
model: modelConfig.model,
|
||||
input: {
|
||||
messages,
|
||||
},
|
||||
parameters: {
|
||||
result_format: "message",
|
||||
incremental_output: shouldStream,
|
||||
temperature: modelConfig.temperature,
|
||||
// max_tokens: modelConfig.max_tokens,
|
||||
top_p: modelConfig.top_p === 1 ? 0.99 : modelConfig.top_p, // qwen top_p is should be < 1
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(Alibaba.ChatPath);
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
"X-DashScope-SSE": shouldStream ? "enable" : "disable",
|
||||
},
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (remainText.length > 0) {
|
||||
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||
const fetchText = remainText.slice(0, fetchCount);
|
||||
responseText += fetchText;
|
||||
remainText = remainText.slice(fetchCount);
|
||||
options.onUpdate?.(responseText, fetchText);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateResponseText);
|
||||
}
|
||||
|
||||
// start animaion
|
||||
animateResponseText();
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[Alibaba] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [responseText];
|
||||
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);
|
||||
}
|
||||
|
||||
responseText = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.output.choices as Array<{
|
||||
message: { content: string };
|
||||
}>;
|
||||
const delta = choices[0]?.message?.content;
|
||||
if (delta) {
|
||||
remainText += delta;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text, msg);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
async usage() {
|
||||
return {
|
||||
used: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async models(): Promise<LLMModel[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export { Alibaba };
|
@@ -1,415 +0,0 @@
|
||||
import { Anthropic, ApiPath } from "@/app/constant";
|
||||
import { ChatOptions, getHeaders, LLMApi, SpeechOptions } from "../api";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
usePluginStore,
|
||||
ChatMessageTool,
|
||||
} from "@/app/store";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { DEFAULT_API_HOST } from "@/app/constant";
|
||||
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||
import { preProcessImageContent, stream } from "@/app/utils/chat";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
import { RequestPayload } from "./openai";
|
||||
|
||||
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 {
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
console.log("[Response] claude response: ", res);
|
||||
|
||||
return res?.content?.[0]?.text;
|
||||
}
|
||||
async chat(options: ChatOptions): Promise<void> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
// try get base64image from local cache image_url
|
||||
const messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = await preProcessImageContent(v.content);
|
||||
messages.push({ role: v.role, content });
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (shouldStream) {
|
||||
let index = -1;
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
path,
|
||||
requestBody,
|
||||
{
|
||||
...getHeaders(),
|
||||
"anthropic-version": accessStore.anthropicApiVersion,
|
||||
},
|
||||
// @ts-ignore
|
||||
tools.map((tool) => ({
|
||||
name: tool?.function?.name,
|
||||
description: tool?.function?.description,
|
||||
input_schema: tool?.function?.parameters,
|
||||
})),
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
let chunkJson:
|
||||
| undefined
|
||||
| {
|
||||
type: "content_block_delta" | "content_block_stop";
|
||||
content_block?: {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
delta?: {
|
||||
type: "text_delta" | "input_json_delta";
|
||||
text?: string;
|
||||
partial_json?: string;
|
||||
};
|
||||
index: number;
|
||||
};
|
||||
chunkJson = JSON.parse(text);
|
||||
|
||||
if (chunkJson?.content_block?.type == "tool_use") {
|
||||
index += 1;
|
||||
const id = chunkJson?.content_block.id;
|
||||
const name = chunkJson?.content_block.name;
|
||||
runTools.push({
|
||||
id,
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
arguments: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
chunkJson?.delta?.type == "input_json_delta" &&
|
||||
chunkJson?.delta?.partial_json
|
||||
) {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] +=
|
||||
chunkJson?.delta?.partial_json;
|
||||
}
|
||||
return chunkJson?.delta?.text;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// reset index value
|
||||
index = -1;
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
{
|
||||
role: "assistant",
|
||||
content: toolCallMessage.tool_calls.map(
|
||||
(tool: ChatMessageTool) => ({
|
||||
type: "tool_use",
|
||||
id: tool.id,
|
||||
name: tool?.function?.name,
|
||||
input: tool?.function?.arguments
|
||||
? JSON.parse(tool?.function?.arguments)
|
||||
: {},
|
||||
}),
|
||||
),
|
||||
},
|
||||
// @ts-ignore
|
||||
...toolCallResult.map((result) => ({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: result.tool_call_id,
|
||||
content: result.content,
|
||||
},
|
||||
],
|
||||
})),
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const payload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...getHeaders(), // get common headers
|
||||
"anthropic-version": accessStore.anthropicApiVersion,
|
||||
// do not send `anthropicApiKey` in browser!!!
|
||||
// Authorization: getAuthKey(accessStore.anthropicApiKey),
|
||||
},
|
||||
};
|
||||
|
||||
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, "/");
|
||||
|
||||
// try rebuild url, when using cloudflare ai gateway in client
|
||||
return cloudflareAIGatewayUrl(`${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;
|
||||
}
|
@@ -1,286 +0,0 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
Baidu,
|
||||
BAIDU_BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
import { getAccessToken } from "@/app/utils/baidu";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import Locale from "../../locales";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
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 ErnieApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.baiduUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
// do not use proxy for baidubce api
|
||||
baseUrl = isApp ? BAIDU_BASE_URL : ApiPath.Baidu;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Baidu)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
// "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function",
|
||||
role: v.role === "system" ? "user" : v.role,
|
||||
content: getMessageTextContent(v),
|
||||
}));
|
||||
|
||||
// "error_code": 336006, "error_msg": "the length of messages must be an odd number",
|
||||
if (messages.length % 2 === 0) {
|
||||
if (messages.at(0)?.role === "user") {
|
||||
messages.splice(1, 0, {
|
||||
role: "assistant",
|
||||
content: " ",
|
||||
});
|
||||
} else {
|
||||
messages.unshift({
|
||||
role: "user",
|
||||
content: " ",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const requestPayload: RequestPayload = {
|
||||
messages,
|
||||
stream: shouldStream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
frequency_penalty: modelConfig.frequency_penalty,
|
||||
top_p: modelConfig.top_p,
|
||||
};
|
||||
|
||||
console.log("[Request] Baidu payload: ", requestPayload);
|
||||
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
let chatPath = this.path(Baidu.ChatPath(modelConfig.model));
|
||||
|
||||
// getAccessToken can not run in browser, because cors error
|
||||
if (!!getClientConfig()?.isApp) {
|
||||
const accessStore = useAccessStore.getState();
|
||||
if (accessStore.useCustomConfig) {
|
||||
if (accessStore.isValidBaidu()) {
|
||||
const { access_token } = await getAccessToken(
|
||||
accessStore.baiduApiKey,
|
||||
accessStore.baiduSecretKey,
|
||||
);
|
||||
chatPath = `${chatPath}${
|
||||
chatPath.includes("?") ? "&" : "?"
|
||||
}access_token=${access_token}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (remainText.length > 0) {
|
||||
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||
const fetchText = remainText.slice(0, fetchCount);
|
||||
responseText += fetchText;
|
||||
remainText = remainText.slice(fetchCount);
|
||||
options.onUpdate?.(responseText, fetchText);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateResponseText);
|
||||
}
|
||||
|
||||
// start animaion
|
||||
animateResponseText();
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log("[Baidu] request response content type: ", contentType);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [responseText];
|
||||
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);
|
||||
}
|
||||
|
||||
responseText = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const delta = json?.result;
|
||||
if (delta) {
|
||||
remainText += delta;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text, msg);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = resJson?.result;
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
async usage() {
|
||||
return {
|
||||
used: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async models(): Promise<LLMModel[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export { Baidu };
|
@@ -1,260 +0,0 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
ByteDance,
|
||||
BYTEDANCE_BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import Locale from "../../locales";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
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 DoubaoApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.bytedanceUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
baseUrl = isApp ? BYTEDANCE_BASE_URL : ApiPath.ByteDance;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ByteDance)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: getMessageTextContent(v),
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const requestPayload: RequestPayload = {
|
||||
messages,
|
||||
stream: shouldStream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
frequency_penalty: modelConfig.frequency_penalty,
|
||||
top_p: modelConfig.top_p,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(ByteDance.ChatPath);
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (remainText.length > 0) {
|
||||
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||
const fetchText = remainText.slice(0, fetchCount);
|
||||
responseText += fetchText;
|
||||
remainText = remainText.slice(fetchCount);
|
||||
options.onUpdate?.(responseText, fetchText);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateResponseText);
|
||||
}
|
||||
|
||||
// start animaion
|
||||
animateResponseText();
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[ByteDance] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [responseText];
|
||||
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);
|
||||
}
|
||||
|
||||
responseText = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: { content: string };
|
||||
}>;
|
||||
const delta = choices[0]?.delta?.content;
|
||||
if (delta) {
|
||||
remainText += delta;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text, msg);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
async usage() {
|
||||
return {
|
||||
used: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async models(): Promise<LLMModel[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export { ByteDance };
|
@@ -1,319 +0,0 @@
|
||||
import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
LLMUsage,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { DEFAULT_API_HOST } from "@/app/constant";
|
||||
import Locale from "../../locales";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import {
|
||||
getMessageTextContent,
|
||||
getMessageImages,
|
||||
isVisionModel,
|
||||
} from "@/app/utils";
|
||||
import { preProcessImageContent } from "@/app/utils/chat";
|
||||
|
||||
export class GeminiProApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.googleUrl;
|
||||
}
|
||||
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
if (baseUrl.length === 0) {
|
||||
baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google;
|
||||
}
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Google)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
let chatPath = [baseUrl, path].join("/");
|
||||
|
||||
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
||||
// if chatPath.startsWith('http') then add key in query string
|
||||
if (chatPath.startsWith("http") && accessStore.googleApiKey) {
|
||||
chatPath += `&key=${accessStore.googleApiKey}`;
|
||||
}
|
||||
return chatPath;
|
||||
}
|
||||
extractMessage(res: any) {
|
||||
console.log("[Response] gemini-pro response: ", res);
|
||||
|
||||
return (
|
||||
res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
|
||||
res?.error?.message ||
|
||||
""
|
||||
);
|
||||
}
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions): Promise<void> {
|
||||
const apiClient = this;
|
||||
let multimodal = false;
|
||||
|
||||
// try get base64image from local cache image_url
|
||||
const _messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = await preProcessImageContent(v.content);
|
||||
_messages.push({ role: v.role, content });
|
||||
}
|
||||
const messages = _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; ) {
|
||||
// Check if current and next item both have the role "model"
|
||||
if (messages[i].role === messages[i + 1].role) {
|
||||
// Concatenate the 'parts' of the current and next item
|
||||
messages[i].parts = messages[i].parts.concat(messages[i + 1].parts);
|
||||
// Remove the next item
|
||||
messages.splice(i + 1, 1);
|
||||
} else {
|
||||
// Move to the next item
|
||||
i++;
|
||||
}
|
||||
}
|
||||
// if (visionModel && messages.length > 1) {
|
||||
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
|
||||
// }
|
||||
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
const requestPayload = {
|
||||
contents: messages,
|
||||
generationConfig: {
|
||||
// stopSequences: [
|
||||
// "Title"
|
||||
// ],
|
||||
temperature: modelConfig.temperature,
|
||||
maxOutputTokens: modelConfig.max_tokens,
|
||||
topP: modelConfig.top_p,
|
||||
// "topK": modelConfig.top_k,
|
||||
},
|
||||
safetySettings: [
|
||||
{
|
||||
category: "HARM_CATEGORY_HARASSMENT",
|
||||
threshold: accessStore.googleSafetySettings,
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||
threshold: accessStore.googleSafetySettings,
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
threshold: accessStore.googleSafetySettings,
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
threshold: accessStore.googleSafetySettings,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
try {
|
||||
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
|
||||
const chatPath = this.path(Google.ChatPath(modelConfig.model));
|
||||
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
}
|
||||
};
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
if (finished || controller.signal.aborted) {
|
||||
responseText += remainText;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainText.length > 0) {
|
||||
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||
const fetchText = remainText.slice(0, fetchCount);
|
||||
responseText += fetchText;
|
||||
remainText = remainText.slice(fetchCount);
|
||||
options.onUpdate?.(responseText, fetchText);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateResponseText);
|
||||
}
|
||||
|
||||
// start animaion
|
||||
animateResponseText();
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[Gemini] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [responseText];
|
||||
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);
|
||||
}
|
||||
|
||||
responseText = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const delta = apiClient.extractMessage(json);
|
||||
|
||||
if (delta) {
|
||||
remainText += delta;
|
||||
}
|
||||
|
||||
const blockReason = json?.promptFeedback?.blockReason;
|
||||
if (blockReason) {
|
||||
// being blocked
|
||||
console.log(`[Google] [Safety Ratings] result:`, blockReason);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text, msg);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
const resJson = await res.json();
|
||||
if (resJson?.promptFeedback?.blockReason) {
|
||||
// being blocked
|
||||
options.onError?.(
|
||||
new Error(
|
||||
"Message is being blocked for reason: " +
|
||||
resJson.promptFeedback.blockReason,
|
||||
),
|
||||
);
|
||||
}
|
||||
const message = apiClient.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
usage(): Promise<LLMUsage> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async models(): Promise<LLMModel[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
@@ -1,250 +0,0 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
DEFAULT_API_HOST,
|
||||
Iflytek,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import Locale from "../../locales";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
|
||||
import { RequestPayload } from "./openai";
|
||||
|
||||
export class SparkApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.iflytekUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
const apiPath = ApiPath.Iflytek;
|
||||
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Iflytek)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = getMessageTextContent(v);
|
||||
messages.push({ role: v.role, content });
|
||||
}
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
providerName: options.config.providerName,
|
||||
},
|
||||
};
|
||||
|
||||
const requestPayload: RequestPayload = {
|
||||
messages,
|
||||
stream: options.config.stream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
frequency_penalty: modelConfig.frequency_penalty,
|
||||
top_p: modelConfig.top_p,
|
||||
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
|
||||
// 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] Spark payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(Iflytek.ChatPath);
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
// Make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
|
||||
// Animate response text to make it look smooth
|
||||
function animateResponseText() {
|
||||
if (finished || controller.signal.aborted) {
|
||||
responseText += remainText;
|
||||
console.log("[Response Animation] finished");
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainText.length > 0) {
|
||||
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||
const fetchText = remainText.slice(0, fetchCount);
|
||||
responseText += fetchText;
|
||||
remainText = remainText.slice(fetchCount);
|
||||
options.onUpdate?.(responseText, fetchText);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateResponseText);
|
||||
}
|
||||
|
||||
// Start animation
|
||||
animateResponseText();
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log("[Spark] request response content type: ", contentType);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
// Handle different error scenarios
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
let extraInfo = await res.clone().text();
|
||||
try {
|
||||
const resJson = await res.clone().json();
|
||||
extraInfo = prettyObject(resJson);
|
||||
} catch {}
|
||||
|
||||
if (res.status === 401) {
|
||||
extraInfo = Locale.Error.Unauthorized;
|
||||
}
|
||||
|
||||
options.onError?.(
|
||||
new Error(
|
||||
`Request failed with status ${res.status}: ${extraInfo}`,
|
||||
),
|
||||
);
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: { content: string };
|
||||
}>;
|
||||
const delta = choices[0]?.delta?.content;
|
||||
|
||||
if (delta) {
|
||||
remainText += delta;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text);
|
||||
options.onError?.(new Error(`Failed to parse response: ${text}`));
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
options.onError?.(
|
||||
new Error(`Request failed with status ${res.status}: ${errorText}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async usage() {
|
||||
return {
|
||||
used: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async models(): Promise<LLMModel[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
@@ -1,199 +0,0 @@
|
||||
"use client";
|
||||
// azure and openai, using same models. so using same LLMApi.
|
||||
import {
|
||||
ApiPath,
|
||||
DEFAULT_API_HOST,
|
||||
Moonshot,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
ChatMessageTool,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { stream } from "@/app/utils/chat";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
import { RequestPayload } from "./openai";
|
||||
|
||||
export class MoonshotApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.moonshotUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
const apiPath = ApiPath.Moonshot;
|
||||
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Moonshot)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = getMessageTextContent(v);
|
||||
messages.push({ role: v.role, content });
|
||||
}
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
providerName: options.config.providerName,
|
||||
},
|
||||
};
|
||||
|
||||
const requestPayload: RequestPayload = {
|
||||
messages,
|
||||
stream: options.config.stream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
frequency_penalty: modelConfig.frequency_penalty,
|
||||
top_p: modelConfig.top_p,
|
||||
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
|
||||
// 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);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(Moonshot.ChatPath);
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const index = tool_calls[0]?.index;
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
async usage() {
|
||||
return {
|
||||
used: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async models(): Promise<LLMModel[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
@@ -1,47 +1,22 @@
|
||||
"use client";
|
||||
// azure and openai, using same models. so using same LLMApi.
|
||||
import {
|
||||
ApiPath,
|
||||
DEFAULT_API_HOST,
|
||||
DEFAULT_MODELS,
|
||||
OpenaiPath,
|
||||
Azure,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
ServiceProvider,
|
||||
} from "@/app/constant";
|
||||
import {
|
||||
ChatMessageTool,
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { collectModelsWithDefaultModel } from "@/app/utils/model";
|
||||
import {
|
||||
preProcessImageContent,
|
||||
uploadImage,
|
||||
base64Image2Blob,
|
||||
stream,
|
||||
} from "@/app/utils/chat";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
LLMUsage,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
|
||||
import Locale from "../../locales";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import {
|
||||
getMessageTextContent,
|
||||
isVisionModel,
|
||||
isDalle3 as _isDalle3,
|
||||
} from "@/app/utils";
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { makeAzurePath } from "@/app/azure";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
@@ -52,113 +27,80 @@ export interface OpenAIListModelResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
export 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 interface DalleRequestPayload {
|
||||
model: string;
|
||||
prompt: string;
|
||||
response_format: "url" | "b64_json";
|
||||
n: number;
|
||||
size: DalleSize;
|
||||
quality: DalleQuality;
|
||||
style: DalleStyle;
|
||||
}
|
||||
|
||||
export class ChatGPTApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||
|
||||
const isAzure = path.includes("deployments");
|
||||
if (accessStore.useCustomConfig) {
|
||||
if (isAzure && !accessStore.isValidAzure()) {
|
||||
throw Error(
|
||||
"incomplete azure config, please check it in your settings page",
|
||||
);
|
||||
}
|
||||
|
||||
baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
||||
if (isAzure && !accessStore.isValidAzure()) {
|
||||
throw Error(
|
||||
"incomplete azure config, please check it in your settings page",
|
||||
);
|
||||
}
|
||||
|
||||
let baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI;
|
||||
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
|
||||
baseUrl = isApp ? DEFAULT_API_HOST : ApiPath.OpenAI;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (
|
||||
!baseUrl.startsWith("http") &&
|
||||
!isAzure &&
|
||||
!baseUrl.startsWith(ApiPath.OpenAI)
|
||||
) {
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
if (isAzure) {
|
||||
path = makeAzurePath(path, accessStore.azureApiVersion);
|
||||
}
|
||||
|
||||
// try rebuild url, when using cloudflare ai gateway in client
|
||||
return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
async extractMessage(res: any) {
|
||||
if (res.error) {
|
||||
return "```\n" + JSON.stringify(res, null, 4) + "\n```";
|
||||
}
|
||||
// dalle3 model return url, using url create image message
|
||||
if (res.data) {
|
||||
let url = res.data?.at(0)?.url ?? "";
|
||||
const b64_json = res.data?.at(0)?.b64_json ?? "";
|
||||
if (!url && b64_json) {
|
||||
// uploadImage
|
||||
url = await uploadImage(base64Image2Blob(b64_json, "image/png"));
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return res.choices?.at(0)?.message?.content ?? res;
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
async speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
const requestPayload = {
|
||||
model: options.model,
|
||||
input: options.input,
|
||||
voice: options.voice,
|
||||
response_format: options.response_format,
|
||||
speed: options.speed,
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: v.content,
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("[Request] openai speech payload: ", requestPayload);
|
||||
const requestPayload = {
|
||||
messages,
|
||||
stream: options.config.stream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
frequency_penalty: modelConfig.frequency_penalty,
|
||||
top_p: modelConfig.top_p,
|
||||
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
|
||||
// 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);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const speechPath = this.path(OpenaiPath.SpeechPath);
|
||||
const speechPayload = {
|
||||
const chatPath = this.path(OpenaiPath.ChatPath);
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
@@ -171,192 +113,98 @@ export class ChatGPTApi implements LLMApi {
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const res = await fetch(speechPath, speechPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
return await res.arrayBuffer();
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a speech request", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
providerName: options.config.providerName,
|
||||
},
|
||||
};
|
||||
|
||||
let requestPayload: RequestPayload | DalleRequestPayload;
|
||||
|
||||
const isDalle3 = _isDalle3(options.config.model);
|
||||
const isO1 = options.config.model.startsWith("o1");
|
||||
if (isDalle3) {
|
||||
const prompt = getMessageTextContent(
|
||||
options.messages.slice(-1)?.pop() as any,
|
||||
);
|
||||
requestPayload = {
|
||||
model: options.config.model,
|
||||
prompt,
|
||||
// URLs are only valid for 60 minutes after the image has been generated.
|
||||
response_format: "b64_json", // using b64_json, and save image in CacheStorage
|
||||
n: 1,
|
||||
size: options.config?.size ?? "1024x1024",
|
||||
quality: options.config?.quality ?? "standard",
|
||||
style: options.config?.style ?? "vivid",
|
||||
};
|
||||
} else {
|
||||
const visionModel = isVisionModel(options.config.model);
|
||||
const messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = visionModel
|
||||
? await preProcessImageContent(v.content)
|
||||
: getMessageTextContent(v);
|
||||
if (!(isO1 && v.role === "system"))
|
||||
messages.push({ role: v.role, content });
|
||||
}
|
||||
|
||||
// O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet.
|
||||
requestPayload = {
|
||||
messages,
|
||||
stream: !isO1 ? options.config.stream : false,
|
||||
model: modelConfig.model,
|
||||
temperature: !isO1 ? modelConfig.temperature : 1,
|
||||
presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
|
||||
frequency_penalty: !isO1 ? modelConfig.frequency_penalty : 0,
|
||||
top_p: !isO1 ? modelConfig.top_p : 1,
|
||||
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
|
||||
// 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) {
|
||||
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Request] openai payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !isDalle3 && !!options.config.stream && !isO1;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
let chatPath = "";
|
||||
if (modelConfig.providerName === ServiceProvider.Azure) {
|
||||
// find model, and get displayName as deployName
|
||||
const { models: configModels, customModels: configCustomModels } =
|
||||
useAppConfig.getState();
|
||||
const {
|
||||
defaultModel,
|
||||
customModels: accessCustomModels,
|
||||
useCustomConfig,
|
||||
} = useAccessStore.getState();
|
||||
const models = collectModelsWithDefaultModel(
|
||||
configModels,
|
||||
[configCustomModels, accessCustomModels].join(","),
|
||||
defaultModel,
|
||||
);
|
||||
const model = models.find(
|
||||
(model) =>
|
||||
model.name === modelConfig.model &&
|
||||
model?.provider?.providerName === ServiceProvider.Azure,
|
||||
);
|
||||
chatPath = this.path(
|
||||
(isDalle3 ? Azure.ImagePath : Azure.ChatPath)(
|
||||
(model?.displayName ?? model?.name) as string,
|
||||
useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
chatPath = this.path(
|
||||
isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
|
||||
);
|
||||
}
|
||||
if (shouldStream) {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
// console.log("getAsTools", tools, funcs);
|
||||
stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const index = tool_calls[0]?.index;
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
let responseText = "";
|
||||
let finished = false;
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
options.onFinish(responseText);
|
||||
finished = true;
|
||||
}
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
|
||||
);
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[OpenAI] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [responseText];
|
||||
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);
|
||||
}
|
||||
|
||||
responseText = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text) as {
|
||||
choices: Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const delta = json.choices[0]?.delta?.content;
|
||||
if (delta) {
|
||||
responseText += delta;
|
||||
options.onUpdate?.(responseText, delta);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = await this.extractMessage(resJson);
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -443,28 +291,40 @@ export class ChatGPTApi implements LLMApi {
|
||||
});
|
||||
|
||||
const resJson = (await res.json()) as OpenAIListModelResponse;
|
||||
const chatModels = resJson.data?.filter(
|
||||
(m) => m.id.startsWith("gpt-") || m.id.startsWith("chatgpt-"),
|
||||
);
|
||||
const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
|
||||
console.log("[Models]", chatModels);
|
||||
|
||||
if (!chatModels) {
|
||||
return [];
|
||||
}
|
||||
|
||||
//由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场
|
||||
let seq = 1000; //同 Constant.ts 中的排序保持一致
|
||||
return chatModels.map((m) => ({
|
||||
name: m.id,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
sorted: 1,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
public cache: Record<string, ArrayBuffer> = {};
|
||||
|
||||
async speech(input: string): Promise<ArrayBuffer> {
|
||||
if (this.cache[input]) return this.cache[input].slice(0);
|
||||
|
||||
const res = await fetch(this.path(OpenaiPath.Speech), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "tts-1",
|
||||
input: input,
|
||||
voice: "onyx",
|
||||
}),
|
||||
});
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
this.cache[input] = arrayBuffer.slice(0);
|
||||
return arrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
export { OpenaiPath };
|
||||
|
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
import { ApiPath, DEFAULT_API_HOST, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import Locale from "../../locales";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||
import mapKeys from "lodash-es/mapKeys";
|
||||
import mapValues from "lodash-es/mapValues";
|
||||
import isArray from "lodash-es/isArray";
|
||||
import isObject from "lodash-es/isObject";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RequestPayload {
|
||||
Messages: {
|
||||
Role: "system" | "user" | "assistant";
|
||||
Content: string | MultimodalContent[];
|
||||
}[];
|
||||
Stream?: boolean;
|
||||
Model: string;
|
||||
Temperature: number;
|
||||
TopP: number;
|
||||
}
|
||||
|
||||
function capitalizeKeys(obj: any): any {
|
||||
if (isArray(obj)) {
|
||||
return obj.map(capitalizeKeys);
|
||||
} else if (isObject(obj)) {
|
||||
return mapValues(
|
||||
mapKeys(obj, (value: any, key: string) =>
|
||||
key.replace(/(^|_)(\w)/g, (m, $1, $2) => $2.toUpperCase()),
|
||||
),
|
||||
capitalizeKeys,
|
||||
);
|
||||
} else {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
export class HunyuanApi implements LLMApi {
|
||||
path(): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.tencentUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
baseUrl = isApp
|
||||
? DEFAULT_API_HOST + "/api/proxy/tencent"
|
||||
: ApiPath.Tencent;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Tencent)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.Choices?.at(0)?.Message?.Content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const visionModel = isVisionModel(options.config.model);
|
||||
const messages = options.messages.map((v, index) => ({
|
||||
// "Messages 中 system 角色必须位于列表的最开始"
|
||||
role: index !== 0 && v.role === "system" ? "user" : v.role,
|
||||
content: visionModel ? v.content : getMessageTextContent(v),
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const requestPayload: RequestPayload = capitalizeKeys({
|
||||
model: modelConfig.model,
|
||||
messages,
|
||||
temperature: modelConfig.temperature,
|
||||
top_p: modelConfig.top_p,
|
||||
stream: options.config.stream,
|
||||
});
|
||||
|
||||
console.log("[Request] Tencent payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path();
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (remainText.length > 0) {
|
||||
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
|
||||
const fetchText = remainText.slice(0, fetchCount);
|
||||
responseText += fetchText;
|
||||
remainText = remainText.slice(fetchCount);
|
||||
options.onUpdate?.(responseText, fetchText);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateResponseText);
|
||||
}
|
||||
|
||||
// start animaion
|
||||
animateResponseText();
|
||||
|
||||
const finish = () => {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
options.onFinish(responseText + remainText);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[Tencent] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
responseText = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [responseText];
|
||||
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);
|
||||
}
|
||||
|
||||
responseText = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
if (msg.data === "[DONE]" || finished) {
|
||||
return finish();
|
||||
}
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.Choices as Array<{
|
||||
Delta: { Content: string };
|
||||
}>;
|
||||
const delta = choices[0]?.Delta?.Content;
|
||||
if (delta) {
|
||||
remainText += delta;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text, msg);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
async usage() {
|
||||
return {
|
||||
used: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async models(): Promise<LLMModel[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
@@ -38,20 +38,16 @@ interface ChatCommands {
|
||||
next?: Command;
|
||||
prev?: Command;
|
||||
clear?: Command;
|
||||
fork?: Command;
|
||||
del?: Command;
|
||||
}
|
||||
|
||||
// Compatible with Chinese colon character ":"
|
||||
export const ChatCommandPrefix = /^[::]/;
|
||||
export const ChatCommandPrefix = ":";
|
||||
|
||||
export function useChatCommand(commands: ChatCommands = {}) {
|
||||
function extract(userInput: string) {
|
||||
const match = userInput.match(ChatCommandPrefix);
|
||||
if (match) {
|
||||
return userInput.slice(1) as keyof ChatCommands;
|
||||
}
|
||||
return userInput as keyof ChatCommands;
|
||||
return (
|
||||
userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput
|
||||
) as keyof ChatCommands;
|
||||
}
|
||||
|
||||
function search(userInput: string) {
|
||||
@@ -61,7 +57,7 @@ export function useChatCommand(commands: ChatCommands = {}) {
|
||||
.filter((c) => c.startsWith(input))
|
||||
.map((c) => ({
|
||||
title: desc[c as keyof ChatCommands],
|
||||
content: ":" + c,
|
||||
content: ChatCommandPrefix + c,
|
||||
}));
|
||||
}
|
||||
|
||||
|
@@ -1,31 +0,0 @@
|
||||
.artifacts {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 20px;
|
||||
background: var(--second);
|
||||
}
|
||||
&-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
}
|
||||
&-content {
|
||||
flex-grow: 1;
|
||||
padding: 0 20px 20px 20px;
|
||||
background-color: var(--second);
|
||||
}
|
||||
}
|
||||
|
||||
.artifacts-iframe {
|
||||
width: 100%;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 6px;
|
||||
background-color: var(--gray);
|
||||
}
|
@@ -1,266 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { IconButton } from "./button";
|
||||
import { nanoid } from "nanoid";
|
||||
import ExportIcon from "../icons/share.svg";
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
import DownloadIcon from "../icons/download.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import LoadingButtonIcon from "../icons/loading.svg";
|
||||
import ReloadButtonIcon from "../icons/reload.svg";
|
||||
import Locale from "../locales";
|
||||
import { Modal, showToast } from "./ui-lib";
|
||||
import { copyToClipboard, downloadAs } from "../utils";
|
||||
import { Path, ApiPath, REPO_URL } from "@/app/constant";
|
||||
import { Loading } from "./home";
|
||||
import styles from "./artifacts.module.scss";
|
||||
|
||||
type HTMLPreviewProps = {
|
||||
code: string;
|
||||
autoHeight?: boolean;
|
||||
height?: number | string;
|
||||
onLoad?: (title?: string) => void;
|
||||
};
|
||||
|
||||
export type HTMLPreviewHander = {
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
export const HTMLPreview = forwardRef<HTMLPreviewHander, HTMLPreviewProps>(
|
||||
function HTMLPreview(props, ref) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [frameId, setFrameId] = useState<string>(nanoid());
|
||||
const [iframeHeight, setIframeHeight] = useState(600);
|
||||
const [title, setTitle] = useState("");
|
||||
/*
|
||||
* https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
|
||||
* 1. using srcdoc
|
||||
* 2. using src with dataurl:
|
||||
* easy to share
|
||||
* length limit (Data URIs cannot be larger than 32,768 characters.)
|
||||
*/
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (e: any) => {
|
||||
const { id, height, title } = e.data;
|
||||
setTitle(title);
|
||||
if (id == frameId) {
|
||||
setIframeHeight(height);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [frameId]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reload: () => {
|
||||
setFrameId(nanoid());
|
||||
},
|
||||
}));
|
||||
|
||||
const height = useMemo(() => {
|
||||
if (!props.autoHeight) return props.height || 600;
|
||||
if (typeof props.height === "string") {
|
||||
return props.height;
|
||||
}
|
||||
const parentHeight = props.height || 600;
|
||||
return iframeHeight + 40 > parentHeight
|
||||
? parentHeight
|
||||
: iframeHeight + 40;
|
||||
}, [props.autoHeight, props.height, iframeHeight]);
|
||||
|
||||
const srcDoc = useMemo(() => {
|
||||
const script = `<script>window.addEventListener("DOMContentLoaded", () => new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body))</script>`;
|
||||
if (props.code.includes("<!DOCTYPE html>")) {
|
||||
props.code.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + script);
|
||||
}
|
||||
return script + props.code;
|
||||
}, [props.code, frameId]);
|
||||
|
||||
const handleOnLoad = () => {
|
||||
if (props?.onLoad) {
|
||||
props.onLoad(title);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className={styles["artifacts-iframe"]}
|
||||
key={frameId}
|
||||
ref={iframeRef}
|
||||
sandbox="allow-forms allow-modals allow-scripts"
|
||||
style={{ height }}
|
||||
srcDoc={srcDoc}
|
||||
onLoad={handleOnLoad}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function ArtifactsShareButton({
|
||||
getCode,
|
||||
id,
|
||||
style,
|
||||
fileName,
|
||||
}: {
|
||||
getCode: () => string;
|
||||
id?: string;
|
||||
style?: any;
|
||||
fileName?: string;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [name, setName] = useState(id);
|
||||
const [show, setShow] = useState(false);
|
||||
const shareUrl = useMemo(
|
||||
() => [location.origin, "#", Path.Artifacts, "/", name].join(""),
|
||||
[name],
|
||||
);
|
||||
const upload = (code: string) =>
|
||||
id
|
||||
? Promise.resolve({ id })
|
||||
: fetch(ApiPath.Artifacts, {
|
||||
method: "POST",
|
||||
body: code,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ id }) => {
|
||||
if (id) {
|
||||
return { id };
|
||||
}
|
||||
throw Error();
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast(Locale.Export.Artifacts.Error);
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="window-action-button" style={style}>
|
||||
<IconButton
|
||||
icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
|
||||
bordered
|
||||
title={Locale.Export.Artifacts.Title}
|
||||
onClick={() => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
upload(getCode())
|
||||
.then((res) => {
|
||||
if (res?.id) {
|
||||
setShow(true);
|
||||
setName(res?.id);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{show && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Export.Artifacts.Title}
|
||||
onClose={() => setShow(false)}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="download"
|
||||
icon={<DownloadIcon />}
|
||||
bordered
|
||||
text={Locale.Export.Download}
|
||||
onClick={() => {
|
||||
downloadAs(getCode(), `${fileName || name}.html`).then(() =>
|
||||
setShow(false),
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
icon={<CopyIcon />}
|
||||
bordered
|
||||
text={Locale.Chat.Actions.Copy}
|
||||
onClick={() => {
|
||||
copyToClipboard(shareUrl).then(() => setShow(false));
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
<a target="_blank" href={shareUrl}>
|
||||
{shareUrl}
|
||||
</a>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Artifacts() {
|
||||
const { id } = useParams();
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fileName, setFileName] = useState("");
|
||||
const previewRef = useRef<HTMLPreviewHander>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetch(`${ApiPath.Artifacts}?id=${id}`)
|
||||
.then((res) => {
|
||||
if (res.status > 300) {
|
||||
throw Error("can not get content");
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.then((res) => res.text())
|
||||
.then(setCode)
|
||||
.catch((e) => {
|
||||
showToast(Locale.Export.Artifacts.Error);
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className={styles["artifacts"]}>
|
||||
<div className={styles["artifacts-header"]}>
|
||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconButton bordered icon={<GithubIcon />} shadow />
|
||||
</a>
|
||||
<IconButton
|
||||
bordered
|
||||
style={{ marginLeft: 20 }}
|
||||
icon={<ReloadButtonIcon />}
|
||||
shadow
|
||||
onClick={() => previewRef.current?.reload()}
|
||||
/>
|
||||
<div className={styles["artifacts-title"]}>NextChat Artifacts</div>
|
||||
<ArtifactsShareButton
|
||||
id={id}
|
||||
getCode={() => code}
|
||||
fileName={fileName}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["artifacts-content"]}>
|
||||
{loading && <Loading />}
|
||||
{code && (
|
||||
<HTMLPreview
|
||||
code={code}
|
||||
ref={previewRef}
|
||||
autoHeight={false}
|
||||
height={"100%"}
|
||||
onLoad={(title) => {
|
||||
setFileName(title as string);
|
||||
setLoading(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,70 +1,12 @@
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
.top-banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 64px;
|
||||
box-sizing: border-box;
|
||||
background: var(--second);
|
||||
.top-banner-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
line-height: 150%;
|
||||
span {
|
||||
gap: 8px;
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
margin-left: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
.top-banner-close {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 48px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.top-banner {
|
||||
padding: 12px 24px 12px 12px;
|
||||
.top-banner-close {
|
||||
right: 10px;
|
||||
}
|
||||
.top-banner-inner {
|
||||
.top-banner-logo {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
animation: slide-in-from-top ease 0.3s;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
margin-top: 10vh;
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
@@ -72,7 +14,6 @@
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
line-height: 2;
|
||||
margin-bottom: 1vh;
|
||||
}
|
||||
|
||||
.auth-tips {
|
||||
@@ -83,10 +24,6 @@
|
||||
margin: 3vh 0;
|
||||
}
|
||||
|
||||
.auth-input-second {
|
||||
margin: 0 0 3vh 0;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
@@ -1,34 +1,21 @@
|
||||
import styles from "./auth.module.scss";
|
||||
import { IconButton } from "./button";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Path, SAAS_CHAT_URL } from "../constant";
|
||||
import { Path } from "../constant";
|
||||
import { useAccessStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
import Delete from "../icons/close.svg";
|
||||
import Arrow from "../icons/arrow.svg";
|
||||
import Logo from "../icons/logo.svg";
|
||||
import { useMobileScreen } from "@/app/utils";
|
||||
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
import { useEffect } from "react";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import LeftIcon from "@/app/icons/left.svg";
|
||||
import { safeLocalStorage } from "@/app/utils";
|
||||
import {
|
||||
trackSettingsPageGuideToCPaymentClick,
|
||||
trackAuthorizationPageButtonToCPaymentClick,
|
||||
} from "../utils/auth-settings-events";
|
||||
const storage = safeLocalStorage();
|
||||
|
||||
export function AuthPage() {
|
||||
const navigate = useNavigate();
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
const goHome = () => navigate(Path.Home);
|
||||
const goChat = () => navigate(Path.Chat);
|
||||
const goSaas = () => {
|
||||
trackAuthorizationPageButtonToCPaymentClick();
|
||||
window.location.href = SAAS_CHAT_URL;
|
||||
};
|
||||
|
||||
const resetAccessCode = () => {
|
||||
accessStore.update((access) => {
|
||||
access.openaiApiKey = "";
|
||||
@@ -45,14 +32,6 @@ export function AuthPage() {
|
||||
|
||||
return (
|
||||
<div className={styles["auth-page"]}>
|
||||
<TopBanner></TopBanner>
|
||||
<div className={styles["auth-header"]}>
|
||||
<IconButton
|
||||
icon={<LeftIcon />}
|
||||
text={Locale.Auth.Return}
|
||||
onClick={() => navigate(Path.Home)}
|
||||
></IconButton>
|
||||
</div>
|
||||
<div className={`no-dark ${styles["auth-logo"]}`}>
|
||||
<BotIcon />
|
||||
</div>
|
||||
@@ -85,17 +64,6 @@ export function AuthPage() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className={styles["auth-input-second"]}
|
||||
type="password"
|
||||
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||
value={accessStore.googleApiKey}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.googleApiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -106,74 +74,13 @@ export function AuthPage() {
|
||||
onClick={goChat}
|
||||
/>
|
||||
<IconButton
|
||||
text={Locale.Auth.SaasTips}
|
||||
text={Locale.Auth.Later}
|
||||
onClick={() => {
|
||||
goSaas();
|
||||
resetAccessCode();
|
||||
goHome();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBanner() {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const isMobile = useMobileScreen();
|
||||
useEffect(() => {
|
||||
// 检查 localStorage 中是否有标记
|
||||
const bannerDismissed = storage.getItem("bannerDismissed");
|
||||
// 如果标记不存在,存储默认值并显示横幅
|
||||
if (!bannerDismissed) {
|
||||
storage.setItem("bannerDismissed", "false");
|
||||
setIsVisible(true); // 显示横幅
|
||||
} else if (bannerDismissed === "true") {
|
||||
// 如果标记为 "true",则隐藏横幅
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
storage.setItem("bannerDismissed", "true");
|
||||
};
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles["top-banner"]}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className={`${styles["top-banner-inner"]} no-dark`}>
|
||||
<Logo className={styles["top-banner-logo"]}></Logo>
|
||||
<span>
|
||||
{Locale.Auth.TopTips}
|
||||
<a
|
||||
href={SAAS_CHAT_URL}
|
||||
rel="stylesheet"
|
||||
onClick={() => {
|
||||
trackSettingsPageGuideToCPaymentClick();
|
||||
}}
|
||||
>
|
||||
{Locale.Settings.Access.SaasStart.ChatNow}
|
||||
<Arrow style={{ marginLeft: "4px" }} />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{(isHovered || isMobile) && (
|
||||
<Delete className={styles["top-banner-close"]} onClick={handleClose} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import styles from "./button.module.scss";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
export type ButtonType = "primary" | "danger" | null;
|
||||
|
||||
@@ -17,8 +16,6 @@ export function IconButton(props: {
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
autoFocus?: boolean;
|
||||
style?: CSSProperties;
|
||||
aria?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
@@ -34,12 +31,9 @@ export function IconButton(props: {
|
||||
role="button"
|
||||
tabIndex={props.tabIndex}
|
||||
autoFocus={props.autoFocus}
|
||||
style={props.style}
|
||||
aria-label={props.aria}
|
||||
>
|
||||
{props.icon && (
|
||||
<div
|
||||
aria-label={props.text || props.title}
|
||||
className={
|
||||
styles["icon-button-icon"] +
|
||||
` ${props.type === "primary" && "no-dark"}`
|
||||
@@ -50,12 +44,7 @@ export function IconButton(props: {
|
||||
)}
|
||||
|
||||
{props.text && (
|
||||
<div
|
||||
aria-label={props.text || props.title}
|
||||
className={styles["icon-button-text"]}
|
||||
>
|
||||
{props.text}
|
||||
</div>
|
||||
<div className={styles["icon-button-text"]}>{props.text}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
import {
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
import { useChatStore } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { MaskAvatar } from "./mask";
|
||||
import { Mask } from "../store/mask";
|
||||
@@ -39,16 +40,12 @@ export function ChatItem(props: {
|
||||
});
|
||||
}
|
||||
}, [props.selected]);
|
||||
|
||||
const { pathname: currentPath } = useLocation();
|
||||
return (
|
||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={`${styles["chat-item"]} ${
|
||||
props.selected &&
|
||||
(currentPath === Path.Chat || currentPath === Path.Home) &&
|
||||
styles["chat-item-selected"]
|
||||
props.selected && styles["chat-item-selected"]
|
||||
}`}
|
||||
onClick={props.onClick}
|
||||
ref={(ele) => {
|
||||
|
@@ -1,47 +1,5 @@
|
||||
@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;
|
||||
@@ -231,10 +189,12 @@
|
||||
|
||||
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 {
|
||||
@@ -346,12 +306,6 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-model-name {
|
||||
font-size: 12px;
|
||||
color: var(--black);
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-container {
|
||||
@@ -373,7 +327,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container {
|
||||
.chat-message-user > .chat-message-container {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@@ -395,7 +349,6 @@
|
||||
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) {
|
||||
@@ -413,21 +366,6 @@
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chat-message-tools {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
margin-top: 5px;
|
||||
.chat-message-tool {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
svg {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-item {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
@@ -443,64 +381,6 @@
|
||||
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;
|
||||
@@ -515,7 +395,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 {
|
||||
@@ -580,7 +460,6 @@
|
||||
|
||||
@include single-line();
|
||||
}
|
||||
|
||||
.hint-content {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -595,26 +474,15 @@
|
||||
}
|
||||
|
||||
.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: none;
|
||||
border: var(--border-in-light);
|
||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
@@ -626,7 +494,9 @@
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.chat-input:focus {}
|
||||
.chat-input:focus {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.chat-input-send {
|
||||
background-color: var(--primary);
|
||||
@@ -646,51 +516,3 @@
|
||||
bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-key-container {
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shortcut-key-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shortcut-key-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.shortcut-key-title {
|
||||
font-size: 14px;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.shortcut-key-keys {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
background-color: var(--gray);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.shortcut-key span {
|
||||
font-size: 12px;
|
||||
color: var(--black);
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,7 @@ import BotIcon from "../icons/bot.svg";
|
||||
import BlackBotIcon from "../icons/black-bot.svg";
|
||||
|
||||
export function getEmojiUrl(unified: string, style: EmojiStyle) {
|
||||
// Whoever owns this Content Delivery Network (CDN), I am using your CDN to serve emojis
|
||||
// Old CDN broken, so I had to switch to this one
|
||||
// Author: https://github.com/H0llyW00dzZ
|
||||
return `https://fastly.jsdelivr.net/npm/emoji-datasource-apple/img/${style}/64/${unified}.png`;
|
||||
return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
|
||||
}
|
||||
|
||||
export function AvatarPicker(props: {
|
||||
@@ -21,7 +18,6 @@ export function AvatarPicker(props: {
|
||||
}) {
|
||||
return (
|
||||
<EmojiPicker
|
||||
width={"100%"}
|
||||
lazyLoadEmojis
|
||||
theme={EmojiTheme.AUTO}
|
||||
getEmojiUrl={getEmojiUrl}
|
||||
@@ -36,8 +32,7 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) {
|
||||
if (props.model) {
|
||||
return (
|
||||
<div className="no-dark">
|
||||
{props.model?.startsWith("gpt-4") ||
|
||||
props.model?.startsWith("chatgpt-4o") ? (
|
||||
{props.model?.startsWith("gpt-4") ? (
|
||||
<BlackBotIcon className="user-avatar" />
|
||||
) : (
|
||||
<BotIcon className="user-avatar" />
|
||||
|
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { IconButton } from "./button";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
@@ -8,7 +6,6 @@ import { ISSUE_URL } from "../constant";
|
||||
import Locale from "../locales";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { useSyncStore } from "../store/sync";
|
||||
import { useChatStore } from "../store/chat";
|
||||
|
||||
interface IErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
@@ -31,7 +28,8 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
||||
try {
|
||||
useSyncStore.getState().export();
|
||||
} finally {
|
||||
useChatStore.getState().clearAllData();
|
||||
localStorage.clear();
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -94,7 +94,6 @@
|
||||
|
||||
button {
|
||||
flex-grow: 1;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
@@ -191,59 +190,6 @@
|
||||
pre {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.message-images {
|
||||
display: grid;
|
||||
justify-content: left;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: repeat(var(--image-count), auto);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
$image-width: calc(calc(100vw/2)/var(--image-count));
|
||||
|
||||
.message-image-multi {
|
||||
width: $image-width;
|
||||
height: $image-width;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
max-width: calc(100vw/3*2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
$max-image-width: calc(900px/3*2/var(--image-count));
|
||||
$image-width: calc(80vw/3*2/var(--image-count));
|
||||
|
||||
.message-image-multi {
|
||||
width: $image-width;
|
||||
height: $image-width;
|
||||
max-width: $max-image-width;
|
||||
max-height: $max-image-width;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
max-width: calc(100vw/3*2);
|
||||
}
|
||||
}
|
||||
|
||||
.message-image-multi {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-image,
|
||||
.message-image-multi {
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||
}
|
||||
}
|
||||
|
||||
&-assistant {
|
||||
@@ -267,5 +213,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.default-theme {}
|
||||
}
|
||||
.default-theme {
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
||||
import { ChatMessage, ModelType, useAppConfig, useChatStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
import styles from "./exporter.module.scss";
|
||||
import {
|
||||
@@ -12,12 +12,7 @@ import {
|
||||
showToast,
|
||||
} from "./ui-lib";
|
||||
import { IconButton } from "./button";
|
||||
import {
|
||||
copyToClipboard,
|
||||
downloadAs,
|
||||
getMessageImages,
|
||||
useMobileScreen,
|
||||
} from "../utils";
|
||||
import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
|
||||
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
@@ -34,12 +29,10 @@ import NextImage from "next/image";
|
||||
|
||||
import { toBlob, toPng } from "html-to-image";
|
||||
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
||||
|
||||
import { api } from "../client/api";
|
||||
import { prettyObject } from "../utils/format";
|
||||
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { type ClientApi, getClientApi } from "../client/api";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
@@ -293,7 +286,7 @@ export function RenderExport(props: {
|
||||
id={`${m.role}:${i}`}
|
||||
className={EXPORT_MESSAGE_CLASS_NAME}
|
||||
>
|
||||
<Markdown content={getMessageTextContent(m)} defaultShow />
|
||||
<Markdown content={m.content} defaultShow />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -308,12 +301,10 @@ export function PreviewActions(props: {
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shouldExport, setShouldExport] = useState(false);
|
||||
const config = useAppConfig();
|
||||
|
||||
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
||||
setShouldExport(false);
|
||||
|
||||
const api: ClientApi = getClientApi(config.modelConfig.providerName);
|
||||
|
||||
api
|
||||
.share(msgs)
|
||||
.then((res) => {
|
||||
@@ -539,9 +530,9 @@ export function ImagePreviewer(props: {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles["main-title"]}>NextChat</div>
|
||||
<div className={styles["main-title"]}>ChatGPT Next Web</div>
|
||||
<div className={styles["sub-title"]}>
|
||||
github.com/ChatGPTNextWeb/ChatGPT-Next-Web
|
||||
github.com/Yidadaa/ChatGPT-Next-Web
|
||||
</div>
|
||||
<div className={styles["icons"]}>
|
||||
<ExportAvatar avatar={config.avatar} />
|
||||
@@ -581,38 +572,10 @@ export function ImagePreviewer(props: {
|
||||
|
||||
<div className={styles["body"]}>
|
||||
<Markdown
|
||||
content={getMessageTextContent(m)}
|
||||
content={m.content}
|
||||
fontSize={config.fontSize}
|
||||
fontFamily={config.fontFamily}
|
||||
defaultShow
|
||||
/>
|
||||
{getMessageImages(m).length == 1 && (
|
||||
<img
|
||||
key={i}
|
||||
src={getMessageImages(m)[0]}
|
||||
alt="message"
|
||||
className={styles["message-image"]}
|
||||
/>
|
||||
)}
|
||||
{getMessageImages(m).length > 1 && (
|
||||
<div
|
||||
className={styles["message-images"]}
|
||||
style={
|
||||
{
|
||||
"--image-count": getMessageImages(m).length,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{getMessageImages(m).map((src, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={src}
|
||||
alt="message"
|
||||
className={styles["message-image-multi"]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -631,10 +594,8 @@ export function MarkdownPreviewer(props: {
|
||||
props.messages
|
||||
.map((m) => {
|
||||
return m.role === "user"
|
||||
? `## ${Locale.Export.MessageFromYou}:\n${getMessageTextContent(m)}`
|
||||
: `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent(
|
||||
m,
|
||||
).trim()}`;
|
||||
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
|
||||
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
|
@@ -137,18 +137,12 @@
|
||||
position: relative;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sidebar-title-container {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 18px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
|
@@ -2,6 +2,8 @@
|
||||
|
||||
require("../polyfill");
|
||||
|
||||
import "regenerator-runtime/runtime";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
@@ -27,7 +29,7 @@ import { SideBar } from "./sidebar";
|
||||
import { useAppConfig } from "../store/config";
|
||||
import { AuthPage } from "./auth";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { type ClientApi, getClientApi } from "../client/api";
|
||||
import { api } from "../client/api";
|
||||
import { useAccessStore } from "../store";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
@@ -39,10 +41,6 @@ export function Loading(props: { noLogo?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
@@ -59,21 +57,6 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const PluginPage = dynamic(async () => (await import("./plugin")).PluginPage, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const SearchChat = dynamic(
|
||||
async () => (await import("./search-chat")).SearchChatPage,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
const Sd = dynamic(async () => (await import("./sd")).Sd, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
export function useSwitchTheme() {
|
||||
const config = useAppConfig();
|
||||
|
||||
@@ -141,23 +124,11 @@ const loadAsyncGoogleFont = () => {
|
||||
document.head.appendChild(linkEl);
|
||||
};
|
||||
|
||||
export function WindowContent(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={styles["window-content"]} id={SlotID.AppBody}>
|
||||
{props?.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Screen() {
|
||||
const config = useAppConfig();
|
||||
const location = useLocation();
|
||||
const isArtifact = location.pathname.includes(Path.Artifacts);
|
||||
const isHome = location.pathname === Path.Home;
|
||||
const isAuth = location.pathname === Path.Auth;
|
||||
const isSd = location.pathname === Path.Sd;
|
||||
const isSdNew = location.pathname === Path.SdNew;
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const shouldTightBorder =
|
||||
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
||||
@@ -166,42 +137,34 @@ function Screen() {
|
||||
loadAsyncGoogleFont();
|
||||
}, []);
|
||||
|
||||
if (isArtifact) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/artifacts/:id" element={<Artifacts />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const renderContent = () => {
|
||||
if (isAuth) return <AuthPage />;
|
||||
if (isSd) return <Sd />;
|
||||
if (isSdNew) return <Sd />;
|
||||
return (
|
||||
<>
|
||||
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
||||
<WindowContent>
|
||||
<Routes>
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
<Route path={Path.NewChat} element={<NewChat />} />
|
||||
<Route path={Path.Masks} element={<MaskPage />} />
|
||||
<Route path={Path.Plugins} element={<PluginPage />} />
|
||||
<Route path={Path.SearchChat} element={<SearchChat />} />
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
</Routes>
|
||||
</WindowContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.container} ${
|
||||
shouldTightBorder ? styles["tight-container"] : styles.container
|
||||
} ${getLang() === "ar" ? styles["rtl-screen"] : ""}`}
|
||||
className={
|
||||
styles.container +
|
||||
` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${
|
||||
getLang() === "ar" ? styles["rtl-screen"] : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
{isAuth ? (
|
||||
<>
|
||||
<AuthPage />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
||||
|
||||
<div className={styles["window-content"]} id={SlotID.AppBody}>
|
||||
<Routes>
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
<Route path={Path.NewChat} element={<NewChat />} />
|
||||
<Route path={Path.Masks} element={<MaskPage />} />
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -209,8 +172,6 @@ function Screen() {
|
||||
export function useLoadData() {
|
||||
const config = useAppConfig();
|
||||
|
||||
const api: ClientApi = getClientApi(config.modelConfig.providerName);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const models = await api.llm.models();
|
||||
|
@@ -9,7 +9,6 @@ interface InputRangeProps {
|
||||
min: string;
|
||||
max: string;
|
||||
step: string;
|
||||
aria: string;
|
||||
}
|
||||
|
||||
export function InputRange({
|
||||
@@ -20,13 +19,11 @@ export function InputRange({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
aria,
|
||||
}: InputRangeProps) {
|
||||
return (
|
||||
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
||||
{title || value}
|
||||
<input
|
||||
aria-label={aria}
|
||||
type="range"
|
||||
title={title}
|
||||
value={value}
|
||||
|
@@ -6,21 +6,14 @@ import RehypeKatex from "rehype-katex";
|
||||
import RemarkGfm from "remark-gfm";
|
||||
import RehypeHighlight from "rehype-highlight";
|
||||
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
|
||||
import { copyToClipboard, useWindowSize } from "../utils";
|
||||
import { copyToClipboard } from "../utils";
|
||||
import mermaid from "mermaid";
|
||||
import Locale from "../locales";
|
||||
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import ReloadButtonIcon from "../icons/reload.svg";
|
||||
import React from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { showImageModal, FullScreen } from "./ui-lib";
|
||||
import {
|
||||
ArtifactsShareButton,
|
||||
HTMLPreview,
|
||||
HTMLPreviewHander,
|
||||
} from "./artifacts";
|
||||
import { useChatStore } from "../store";
|
||||
import { IconButton } from "./button";
|
||||
import { showImageModal } from "./ui-lib";
|
||||
|
||||
export function Mermaid(props: { code: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
@@ -69,59 +62,27 @@ export function Mermaid(props: { code: string }) {
|
||||
|
||||
export function PreCode(props: { children: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const previewRef = useRef<HTMLPreviewHander>(null);
|
||||
const refText = ref.current?.innerText;
|
||||
const [mermaidCode, setMermaidCode] = useState("");
|
||||
const [htmlCode, setHtmlCode] = useState("");
|
||||
const { height } = useWindowSize();
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
|
||||
const renderArtifacts = useDebouncedCallback(() => {
|
||||
const renderMermaid = useDebouncedCallback(() => {
|
||||
if (!ref.current) return;
|
||||
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
||||
if (mermaidDom) {
|
||||
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
||||
}
|
||||
const htmlDom = ref.current.querySelector("code.language-html");
|
||||
const refText = ref.current.querySelector("code")?.innerText;
|
||||
if (htmlDom) {
|
||||
setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||
} else if (refText?.startsWith("<!DOCTYPE")) {
|
||||
setHtmlCode(refText);
|
||||
}
|
||||
}, 600);
|
||||
|
||||
const enableArtifacts = session.mask?.enableArtifacts !== false;
|
||||
|
||||
//Wrap the paragraph for plain-text
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const codeElements = ref.current.querySelectorAll(
|
||||
"code",
|
||||
) as NodeListOf<HTMLElement>;
|
||||
const wrapLanguages = [
|
||||
"",
|
||||
"md",
|
||||
"markdown",
|
||||
"text",
|
||||
"txt",
|
||||
"plaintext",
|
||||
"tex",
|
||||
"latex",
|
||||
];
|
||||
codeElements.forEach((codeElement) => {
|
||||
let languageClass = codeElement.className.match(/language-(\w+)/);
|
||||
let name = languageClass ? languageClass[1] : "";
|
||||
if (wrapLanguages.includes(name)) {
|
||||
codeElement.style.whiteSpace = "pre-wrap";
|
||||
}
|
||||
});
|
||||
setTimeout(renderArtifacts, 1);
|
||||
}
|
||||
}, []);
|
||||
setTimeout(renderMermaid, 1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refText]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mermaidCode.length > 0 && (
|
||||
<Mermaid code={mermaidCode} key={mermaidCode} />
|
||||
)}
|
||||
<pre ref={ref}>
|
||||
<span
|
||||
className="copy-code-button"
|
||||
@@ -134,69 +95,6 @@ export function PreCode(props: { children: any }) {
|
||||
></span>
|
||||
{props.children}
|
||||
</pre>
|
||||
{mermaidCode.length > 0 && (
|
||||
<Mermaid code={mermaidCode} key={mermaidCode} />
|
||||
)}
|
||||
{htmlCode.length > 0 && enableArtifacts && (
|
||||
<FullScreen className="no-dark html" right={70}>
|
||||
<ArtifactsShareButton
|
||||
style={{ position: "absolute", right: 20, top: 10 }}
|
||||
getCode={() => htmlCode}
|
||||
/>
|
||||
<IconButton
|
||||
style={{ position: "absolute", right: 120, top: 10 }}
|
||||
bordered
|
||||
icon={<ReloadButtonIcon />}
|
||||
shadow
|
||||
onClick={() => previewRef.current?.reload()}
|
||||
/>
|
||||
<HTMLPreview
|
||||
ref={previewRef}
|
||||
code={htmlCode}
|
||||
autoHeight={!document.fullscreenElement}
|
||||
height={!document.fullscreenElement ? 600 : height}
|
||||
/>
|
||||
</FullScreen>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomCode(props: { children: any; className?: string }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [showToggle, setShowToggle] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const codeHeight = ref.current.scrollHeight;
|
||||
setShowToggle(codeHeight > 400);
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}
|
||||
}, [props.children]);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((collapsed) => !collapsed);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<code
|
||||
className={props?.className}
|
||||
ref={ref}
|
||||
style={{
|
||||
maxHeight: collapsed ? "400px" : "none",
|
||||
overflowY: "hidden",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</code>
|
||||
{showToggle && collapsed && (
|
||||
<div
|
||||
className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}
|
||||
>
|
||||
<button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -218,45 +116,11 @@ function escapeDollarNumber(text: string) {
|
||||
return escapedText;
|
||||
}
|
||||
|
||||
function escapeBrackets(text: string) {
|
||||
const pattern =
|
||||
/(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
|
||||
return text.replace(
|
||||
pattern,
|
||||
(match, codeBlock, squareBracket, roundBracket) => {
|
||||
if (codeBlock) {
|
||||
return codeBlock;
|
||||
} else if (squareBracket) {
|
||||
return `$$${squareBracket}$$`;
|
||||
} else if (roundBracket) {
|
||||
return `$${roundBracket}$`;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function tryWrapHtmlCode(text: string) {
|
||||
// try add wrap html code (fixed: html codeblock include 2 newline)
|
||||
return text
|
||||
.replace(
|
||||
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
|
||||
(match, quoteStart, lang, newLine, doctype) => {
|
||||
return !quoteStart ? "\n```html\n" + doctype : match;
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
/(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*?)([`]*?)([\n\r]*?)/g,
|
||||
(match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
|
||||
return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function _MarkDownContent(props: { content: string }) {
|
||||
const escapedContent = useMemo(() => {
|
||||
return tryWrapHtmlCode(escapeBrackets(escapeDollarNumber(props.content)));
|
||||
}, [props.content]);
|
||||
const escapedContent = useMemo(
|
||||
() => escapeDollarNumber(props.content),
|
||||
[props.content],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
@@ -273,7 +137,6 @@ function _MarkDownContent(props: { content: string }) {
|
||||
]}
|
||||
components={{
|
||||
pre: PreCode,
|
||||
code: CustomCode,
|
||||
p: (pProps) => <p {...pProps} dir="auto" />,
|
||||
a: (aProps) => {
|
||||
const href = aProps.href || "";
|
||||
@@ -295,7 +158,6 @@ export function Markdown(
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
parentRef?: RefObject<HTMLDivElement>;
|
||||
defaultShow?: boolean;
|
||||
} & React.DOMAttributes<HTMLDivElement>,
|
||||
@@ -307,7 +169,6 @@ export function Markdown(
|
||||
className="markdown-body"
|
||||
style={{
|
||||
fontSize: `${props.fontSize ?? 14}px`,
|
||||
fontFamily: props.fontFamily || "inherit",
|
||||
}}
|
||||
ref={mdRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
|
@@ -22,7 +22,7 @@ import {
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
} from "../store";
|
||||
import { MultimodalContent, ROLES } from "../client/api";
|
||||
import { ROLES } from "../client/api";
|
||||
import {
|
||||
Input,
|
||||
List,
|
||||
@@ -37,24 +37,19 @@ import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import chatStyle from "./chat.module.scss";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
copyToClipboard,
|
||||
downloadAs,
|
||||
getMessageImages,
|
||||
readFromFile,
|
||||
} from "../utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { copyToClipboard, downloadAs, readFromFile } from "../utils";
|
||||
import { Updater } from "../typing";
|
||||
import { ModelConfigList } from "./model-config";
|
||||
import { FileName, Path } from "../constant";
|
||||
import { BUILTIN_MASK_STORE } from "../masks";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
|
||||
// drag and drop helper function
|
||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||
@@ -126,8 +121,6 @@ export function MaskConfig(props: {
|
||||
onClose={() => setShowPicker(false)}
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
aria-label={Locale.Mask.Config.Avatar}
|
||||
onClick={() => setShowPicker(true)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
@@ -140,7 +133,6 @@ export function MaskConfig(props: {
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Mask.Config.Name}>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.Name}
|
||||
type="text"
|
||||
value={props.mask.name}
|
||||
onInput={(e) =>
|
||||
@@ -155,7 +147,6 @@ export function MaskConfig(props: {
|
||||
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.HideContext.Title}
|
||||
type="checkbox"
|
||||
checked={props.mask.hideContext}
|
||||
onChange={(e) => {
|
||||
@@ -166,29 +157,12 @@ export function MaskConfig(props: {
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Artifacts.Title}
|
||||
subTitle={Locale.Mask.Config.Artifacts.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.Artifacts.Title}
|
||||
type="checkbox"
|
||||
checked={props.mask.enableArtifacts !== false}
|
||||
onChange={(e) => {
|
||||
props.updateMask((mask) => {
|
||||
mask.enableArtifacts = e.currentTarget.checked;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
{!props.shouldSyncFromGlobal ? (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Share.Title}
|
||||
subTitle={Locale.Mask.Config.Share.SubTitle}
|
||||
>
|
||||
<IconButton
|
||||
aria={Locale.Mask.Config.Share.Title}
|
||||
icon={<CopyIcon />}
|
||||
text={Locale.Mask.Config.Share.Action}
|
||||
onClick={copyMaskLink}
|
||||
@@ -202,7 +176,6 @@ export function MaskConfig(props: {
|
||||
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.Sync.Title}
|
||||
type="checkbox"
|
||||
checked={props.mask.syncGlobalConfig}
|
||||
onChange={async (e) => {
|
||||
@@ -271,7 +244,7 @@ function ContextPromptItem(props: {
|
||||
</>
|
||||
)}
|
||||
<Input
|
||||
value={getMessageTextContent(props.prompt)}
|
||||
value={props.prompt.content}
|
||||
type="text"
|
||||
className={chatStyle["context-content"]}
|
||||
rows={focusingInput ? 5 : 1}
|
||||
@@ -316,18 +289,7 @@ export function ContextPrompts(props: {
|
||||
};
|
||||
|
||||
const updateContextPrompt = (i: number, prompt: ChatMessage) => {
|
||||
props.updateContext((context) => {
|
||||
const images = getMessageImages(context[i]);
|
||||
context[i] = prompt;
|
||||
if (images.length > 0) {
|
||||
const text = getMessageTextContent(context[i]);
|
||||
const newContext: MultimodalContent[] = [{ type: "text", text }];
|
||||
for (const img of images) {
|
||||
newContext.push({ type: "image_url", image_url: { url: img } });
|
||||
}
|
||||
context[i].content = newContext;
|
||||
}
|
||||
});
|
||||
props.updateContext((context) => (context[i] = prompt));
|
||||
};
|
||||
|
||||
const onDragEnd: OnDragEndResponder = (result) => {
|
||||
@@ -425,7 +387,7 @@ export function MaskPage() {
|
||||
const maskStore = useMaskStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const filterLang = maskStore.language;
|
||||
const [filterLang, setFilterLang] = useState<Lang>();
|
||||
|
||||
const allMasks = maskStore
|
||||
.getAll()
|
||||
@@ -532,9 +494,9 @@ export function MaskPage() {
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
if (value === Locale.Settings.Lang.All) {
|
||||
maskStore.setLanguage(undefined);
|
||||
setFilterLang(undefined);
|
||||
} else {
|
||||
maskStore.setLanguage(value as Lang);
|
||||
setFilterLang(value as Lang);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@@ -7,7 +7,6 @@ import { MaskAvatar } from "./mask";
|
||||
import Locale from "../locales";
|
||||
|
||||
import styles from "./message-selector.module.scss";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
|
||||
function useShiftRange() {
|
||||
const [startIndex, setStartIndex] = useState<number>();
|
||||
@@ -104,9 +103,7 @@ export function MessageSelector(props: {
|
||||
const searchResults = new Set<string>();
|
||||
if (text.length > 0) {
|
||||
messages.forEach((m) =>
|
||||
getMessageTextContent(m).includes(text)
|
||||
? searchResults.add(m.id!)
|
||||
: null,
|
||||
m.content.includes(text) ? searchResults.add(m.id!) : null,
|
||||
);
|
||||
}
|
||||
setSearchIds(searchResults);
|
||||
@@ -222,12 +219,12 @@ export function MessageSelector(props: {
|
||||
{new Date(m.date).toLocaleString()}
|
||||
</div>
|
||||
<div className={`${styles["content"]} one-line`}>
|
||||
{getMessageTextContent(m)}
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["checkbox"]}>
|
||||
<input type="checkbox" checked={isSelected} readOnly></input>
|
||||
<input type="checkbox" checked={isSelected}></input>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,7 +0,0 @@
|
||||
.select-compress-model {
|
||||
width: 60%;
|
||||
select {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
@@ -1,49 +1,37 @@
|
||||
import { ServiceProvider } from "@/app/constant";
|
||||
import { ModalConfigValidator, ModelConfig } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { InputRange } from "./input-range";
|
||||
import { ListItem, Select } from "./ui-lib";
|
||||
import { useAllModels } from "../utils/hooks";
|
||||
import { groupBy } from "lodash-es";
|
||||
import styles from "./model-config.module.scss";
|
||||
|
||||
export function ModelConfigList(props: {
|
||||
modelConfig: ModelConfig;
|
||||
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||
}) {
|
||||
const allModels = useAllModels();
|
||||
const groupModels = groupBy(
|
||||
allModels.filter((v) => v.available),
|
||||
"provider.providerName",
|
||||
);
|
||||
const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`;
|
||||
const compressModelValue = `${props.modelConfig.compressModel}@${props.modelConfig?.compressProviderName}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Model}>
|
||||
<Select
|
||||
aria-label={Locale.Settings.Model}
|
||||
value={value}
|
||||
align="left"
|
||||
value={props.modelConfig.model}
|
||||
onChange={(e) => {
|
||||
const [model, providerName] = e.currentTarget.value.split("@");
|
||||
props.updateConfig((config) => {
|
||||
config.model = ModalConfigValidator.model(model);
|
||||
config.providerName = providerName as ServiceProvider;
|
||||
});
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.model = ModalConfigValidator.model(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{Object.keys(groupModels).map((providerName, index) => (
|
||||
<optgroup label={providerName} key={index}>
|
||||
{groupModels[providerName].map((v, i) => (
|
||||
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
||||
{v.displayName}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
{allModels
|
||||
.filter((v) => v.available)
|
||||
.map((v, i) => (
|
||||
<option value={v.name} key={i}>
|
||||
{v.displayName}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
@@ -51,7 +39,6 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.Temperature.Title}
|
||||
value={props.modelConfig.temperature?.toFixed(1)}
|
||||
min="0"
|
||||
max="1" // lets limit it to 0-1
|
||||
@@ -71,7 +58,6 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.TopP.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.TopP.Title}
|
||||
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
|
||||
min="0"
|
||||
max="1"
|
||||
@@ -91,7 +77,6 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.MaxTokens.Title}
|
||||
type="number"
|
||||
min={1024}
|
||||
max={512000}
|
||||
@@ -106,94 +91,84 @@ export function ModelConfigList(props: {
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.PresencePenalty.Title}
|
||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.presence_penalty =
|
||||
ModalConfigValidator.presence_penalty(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
|
||||
{props.modelConfig?.providerName == ServiceProvider.Google ? null : (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.PresencePenalty.Title}
|
||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.PresencePenalty.Title}
|
||||
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.presence_penalty =
|
||||
ModalConfigValidator.presence_penalty(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.FrequencyPenalty.Title}
|
||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.frequency_penalty =
|
||||
ModalConfigValidator.frequency_penalty(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.FrequencyPenalty.Title}
|
||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.FrequencyPenalty.Title}
|
||||
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.frequency_penalty =
|
||||
ModalConfigValidator.frequency_penalty(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.InjectSystemPrompts.Title}
|
||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.modelConfig.enableInjectSystemPrompts}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.enableInjectSystemPrompts = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.InjectSystemPrompts.Title}
|
||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.InjectSystemPrompts.Title}
|
||||
type="checkbox"
|
||||
checked={props.modelConfig.enableInjectSystemPrompts}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.enableInjectSystemPrompts =
|
||||
e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.InputTemplate.Title}
|
||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={props.modelConfig.template}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.template = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.InputTemplate.Title}
|
||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.InputTemplate.Title}
|
||||
type="text"
|
||||
value={props.modelConfig.template}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.template = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
<ListItem
|
||||
title={Locale.Settings.HistoryCount.Title}
|
||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.HistoryCount.Title}
|
||||
title={props.modelConfig.historyMessageCount.toString()}
|
||||
value={props.modelConfig.historyMessageCount}
|
||||
min="0"
|
||||
@@ -212,7 +187,6 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.CompressThreshold.Title}
|
||||
type="number"
|
||||
min={500}
|
||||
max={4000}
|
||||
@@ -228,7 +202,6 @@ export function ModelConfigList(props: {
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||
<input
|
||||
aria-label={Locale.Memory.Title}
|
||||
type="checkbox"
|
||||
checked={props.modelConfig.sendMemory}
|
||||
onChange={(e) =>
|
||||
@@ -238,31 +211,6 @@ export function ModelConfigList(props: {
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.CompressModel.Title}
|
||||
subTitle={Locale.Settings.CompressModel.SubTitle}
|
||||
>
|
||||
<Select
|
||||
className={styles["select-compress-model"]}
|
||||
aria-label={Locale.Settings.CompressModel.Title}
|
||||
value={compressModelValue}
|
||||
onChange={(e) => {
|
||||
const [model, providerName] = e.currentTarget.value.split("@");
|
||||
props.updateConfig((config) => {
|
||||
config.compressModel = ModalConfigValidator.model(model);
|
||||
config.compressProviderName = providerName as ServiceProvider;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{allModels
|
||||
.filter((v) => v.available)
|
||||
.map((v, i) => (
|
||||
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
||||
{v.displayName}({v.provider?.providerName})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -1,16 +0,0 @@
|
||||
.plugin-title {
|
||||
font-weight: bolder;
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.plugin-content {
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
pre code {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
@@ -1,393 +0,0 @@
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import OpenAPIClientAxios from "openapi-client-axios";
|
||||
import yaml from "js-yaml";
|
||||
import { PLUGINS_REPO_URL } from "../constant";
|
||||
import { IconButton } from "./button";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
import styles from "./mask.module.scss";
|
||||
import pluginStyles from "./plugin.module.scss";
|
||||
|
||||
import EditIcon from "../icons/edit.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import ConfirmIcon from "../icons/confirm.svg";
|
||||
import ReloadIcon from "../icons/reload.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
|
||||
import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin";
|
||||
import {
|
||||
PasswordInput,
|
||||
List,
|
||||
ListItem,
|
||||
Modal,
|
||||
showConfirm,
|
||||
showToast,
|
||||
} from "./ui-lib";
|
||||
import Locale from "../locales";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { getClientConfig } from "../config/client";
|
||||
|
||||
export function PluginPage() {
|
||||
const navigate = useNavigate();
|
||||
const pluginStore = usePluginStore();
|
||||
|
||||
const allPlugins = pluginStore.getAll();
|
||||
const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const plugins = searchText.length > 0 ? searchPlugins : allPlugins;
|
||||
|
||||
// refactored already, now it accurate
|
||||
const onSearch = (text: string) => {
|
||||
setSearchText(text);
|
||||
if (text.length > 0) {
|
||||
const result = allPlugins.filter(
|
||||
(m) => m?.title.toLowerCase().includes(text.toLowerCase()),
|
||||
);
|
||||
setSearchPlugins(result);
|
||||
} else {
|
||||
setSearchPlugins(allPlugins);
|
||||
}
|
||||
};
|
||||
|
||||
const [editingPluginId, setEditingPluginId] = useState<string | undefined>();
|
||||
const editingPlugin = pluginStore.get(editingPluginId);
|
||||
const editingPluginTool = FunctionToolService.get(editingPlugin?.id);
|
||||
const closePluginModal = () => setEditingPluginId(undefined);
|
||||
|
||||
const onChangePlugin = useDebouncedCallback((editingPlugin, e) => {
|
||||
const content = e.target.innerText;
|
||||
try {
|
||||
const api = new OpenAPIClientAxios({
|
||||
definition: yaml.load(content) as any,
|
||||
});
|
||||
api
|
||||
.init()
|
||||
.then(() => {
|
||||
if (content != editingPlugin.content) {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.content = content;
|
||||
const tool = FunctionToolService.add(plugin, true);
|
||||
plugin.title = tool.api.definition.info.title;
|
||||
plugin.version = tool.api.definition.info.version;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
}
|
||||
}, 100).bind(null, editingPlugin);
|
||||
|
||||
const [loadUrl, setLoadUrl] = useState<string>("");
|
||||
const loadFromUrl = (loadUrl: string) =>
|
||||
fetch(loadUrl)
|
||||
.catch((e) => {
|
||||
const p = new URL(loadUrl);
|
||||
return fetch(`/api/proxy/${p.pathname}?${p.search}`, {
|
||||
headers: {
|
||||
"X-Base-URL": p.origin,
|
||||
},
|
||||
});
|
||||
})
|
||||
.then((res) => res.text())
|
||||
.then((content) => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, " ");
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
})
|
||||
.then((content) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.content = content;
|
||||
const tool = FunctionToolService.add(plugin, true);
|
||||
plugin.title = tool.api.definition.info.title;
|
||||
plugin.version = tool.api.definition.info.version;
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
});
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
{Locale.Plugin.Page.Title}
|
||||
</div>
|
||||
<div className="window-header-submai-title">
|
||||
{Locale.Plugin.Page.SubTitle(plugins.length)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<a
|
||||
href={PLUGINS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconButton icon={<GithubIcon />} bordered />
|
||||
</a>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mask-page-body"]}>
|
||||
<div className={styles["mask-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={Locale.Plugin.Page.Search}
|
||||
autoFocus
|
||||
onInput={(e) => onSearch(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles["mask-create"]}
|
||||
icon={<AddIcon />}
|
||||
text={Locale.Plugin.Page.Create}
|
||||
bordered
|
||||
onClick={() => {
|
||||
const createdPlugin = pluginStore.create();
|
||||
setEditingPluginId(createdPlugin.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{plugins.length == 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
margin: "60px auto",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Locale.Plugin.Page.Find}
|
||||
<a
|
||||
href={PLUGINS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: 16 }}
|
||||
>
|
||||
<IconButton icon={<GithubIcon />} bordered />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{plugins.map((m) => (
|
||||
<div className={styles["mask-item"]} key={m.id}>
|
||||
<div className={styles["mask-header"]}>
|
||||
<div className={styles["mask-icon"]}></div>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>
|
||||
{m.title}@<small>{m.version}</small>
|
||||
</div>
|
||||
<div className={styles["mask-info"] + " one-line"}>
|
||||
{Locale.Plugin.Item.Info(
|
||||
FunctionToolService.add(m).length,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["mask-actions"]}>
|
||||
{m.builtin ? (
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
text={Locale.Plugin.Item.View}
|
||||
onClick={() => setEditingPluginId(m.id)}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
text={Locale.Plugin.Item.Edit}
|
||||
onClick={() => setEditingPluginId(m.id)}
|
||||
/>
|
||||
)}
|
||||
{!m.builtin && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
text={Locale.Plugin.Item.Delete}
|
||||
onClick={async () => {
|
||||
if (
|
||||
await showConfirm(Locale.Plugin.Item.DeleteConfirm)
|
||||
) {
|
||||
pluginStore.delete(m.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingPlugin && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)}
|
||||
onClose={closePluginModal}
|
||||
actions={[
|
||||
<IconButton
|
||||
icon={<ConfirmIcon />}
|
||||
text={Locale.UI.Confirm}
|
||||
key="export"
|
||||
bordered
|
||||
onClick={() => setEditingPluginId("")}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<List>
|
||||
<ListItem title={Locale.Plugin.EditModal.Auth}>
|
||||
<select
|
||||
value={editingPlugin?.authType}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authType = e.target.value;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{Locale.Plugin.Auth.None}</option>
|
||||
<option value="bearer">{Locale.Plugin.Auth.Bearer}</option>
|
||||
<option value="basic">{Locale.Plugin.Auth.Basic}</option>
|
||||
<option value="custom">{Locale.Plugin.Auth.Custom}</option>
|
||||
</select>
|
||||
</ListItem>
|
||||
{["bearer", "basic", "custom"].includes(
|
||||
editingPlugin.authType as string,
|
||||
) && (
|
||||
<ListItem title={Locale.Plugin.Auth.Location}>
|
||||
<select
|
||||
value={editingPlugin?.authLocation}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authLocation = e.target.value;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="header">
|
||||
{Locale.Plugin.Auth.LocationHeader}
|
||||
</option>
|
||||
<option value="query">
|
||||
{Locale.Plugin.Auth.LocationQuery}
|
||||
</option>
|
||||
<option value="body">
|
||||
{Locale.Plugin.Auth.LocationBody}
|
||||
</option>
|
||||
</select>
|
||||
</ListItem>
|
||||
)}
|
||||
{editingPlugin.authType == "custom" && (
|
||||
<ListItem title={Locale.Plugin.Auth.CustomHeader}>
|
||||
<input
|
||||
type="text"
|
||||
value={editingPlugin?.authHeader}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authHeader = e.target.value;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
)}
|
||||
{["bearer", "basic", "custom"].includes(
|
||||
editingPlugin.authType as string,
|
||||
) && (
|
||||
<ListItem title={Locale.Plugin.Auth.Token}>
|
||||
<PasswordInput
|
||||
type="text"
|
||||
value={editingPlugin?.authToken}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authToken = e.currentTarget.value;
|
||||
});
|
||||
}}
|
||||
></PasswordInput>
|
||||
</ListItem>
|
||||
)}
|
||||
{!getClientConfig()?.isApp && (
|
||||
<ListItem
|
||||
title={Locale.Plugin.Auth.Proxy}
|
||||
subTitle={Locale.Plugin.Auth.ProxyDescription}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingPlugin?.usingProxy}
|
||||
style={{ minWidth: 16 }}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.usingProxy = e.currentTarget.checked;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
<List>
|
||||
<ListItem title={Locale.Plugin.EditModal.Content}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<input
|
||||
type="text"
|
||||
style={{ minWidth: 200, marginRight: 20 }}
|
||||
onInput={(e) => setLoadUrl(e.currentTarget.value)}
|
||||
></input>
|
||||
<IconButton
|
||||
icon={<ReloadIcon />}
|
||||
text={Locale.Plugin.EditModal.Load}
|
||||
bordered
|
||||
onClick={() => loadFromUrl(loadUrl)}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
subTitle={
|
||||
<div
|
||||
className={`markdown-body ${pluginStyles["plugin-content"]}`}
|
||||
dir="auto"
|
||||
>
|
||||
<pre>
|
||||
<code
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: editingPlugin.content,
|
||||
}}
|
||||
onBlur={onChangePlugin}
|
||||
></code>
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
></ListItem>
|
||||
{editingPluginTool?.tools.map((tool, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
title={tool?.function?.name}
|
||||
subTitle={tool?.function?.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
@@ -1,2 +0,0 @@
|
||||
export * from "./sd";
|
||||
export * from "./sd-panel";
|
@@ -1,45 +0,0 @@
|
||||
.ctrl-param-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
padding: 10px 0;
|
||||
animation: slide-in ease 0.6s;
|
||||
flex-direction: column;
|
||||
|
||||
.ctrl-param-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ctrl-param-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: bolder;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ctrl-param-item-sub-title {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
margin-top: 3px;
|
||||
}
|
||||
textarea {
|
||||
appearance: none;
|
||||
border-radius: 10px;
|
||||
border: var(--border-in-light);
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
background: var(--white);
|
||||
color: var(--black);
|
||||
padding: 0 10px;
|
||||
max-width: 50%;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-models {
|
||||
button {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
@@ -1,320 +0,0 @@
|
||||
import styles from "./sd-panel.module.scss";
|
||||
import React from "react";
|
||||
import { Select } from "@/app/components/ui-lib";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import Locale from "@/app/locales";
|
||||
import { useSdStore } from "@/app/store/sd";
|
||||
|
||||
export const params = [
|
||||
{
|
||||
name: Locale.SdPanel.Prompt,
|
||||
value: "prompt",
|
||||
type: "textarea",
|
||||
placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.Prompt),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: Locale.SdPanel.ModelVersion,
|
||||
value: "model",
|
||||
type: "select",
|
||||
default: "sd3-medium",
|
||||
support: ["sd3"],
|
||||
options: [
|
||||
{ name: "SD3 Medium", value: "sd3-medium" },
|
||||
{ name: "SD3 Large", value: "sd3-large" },
|
||||
{ name: "SD3 Large Turbo", value: "sd3-large-turbo" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: Locale.SdPanel.NegativePrompt,
|
||||
value: "negative_prompt",
|
||||
type: "textarea",
|
||||
placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.NegativePrompt),
|
||||
},
|
||||
{
|
||||
name: Locale.SdPanel.AspectRatio,
|
||||
value: "aspect_ratio",
|
||||
type: "select",
|
||||
default: "1:1",
|
||||
options: [
|
||||
{ name: "1:1", value: "1:1" },
|
||||
{ name: "16:9", value: "16:9" },
|
||||
{ name: "21:9", value: "21:9" },
|
||||
{ name: "2:3", value: "2:3" },
|
||||
{ name: "3:2", value: "3:2" },
|
||||
{ name: "4:5", value: "4:5" },
|
||||
{ name: "5:4", value: "5:4" },
|
||||
{ name: "9:16", value: "9:16" },
|
||||
{ name: "9:21", value: "9:21" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: Locale.SdPanel.ImageStyle,
|
||||
value: "style",
|
||||
type: "select",
|
||||
default: "3d-model",
|
||||
support: ["core"],
|
||||
options: [
|
||||
{ name: Locale.SdPanel.Styles.D3Model, value: "3d-model" },
|
||||
{ name: Locale.SdPanel.Styles.AnalogFilm, value: "analog-film" },
|
||||
{ name: Locale.SdPanel.Styles.Anime, value: "anime" },
|
||||
{ name: Locale.SdPanel.Styles.Cinematic, value: "cinematic" },
|
||||
{ name: Locale.SdPanel.Styles.ComicBook, value: "comic-book" },
|
||||
{ name: Locale.SdPanel.Styles.DigitalArt, value: "digital-art" },
|
||||
{ name: Locale.SdPanel.Styles.Enhance, value: "enhance" },
|
||||
{ name: Locale.SdPanel.Styles.FantasyArt, value: "fantasy-art" },
|
||||
{ name: Locale.SdPanel.Styles.Isometric, value: "isometric" },
|
||||
{ name: Locale.SdPanel.Styles.LineArt, value: "line-art" },
|
||||
{ name: Locale.SdPanel.Styles.LowPoly, value: "low-poly" },
|
||||
{
|
||||
name: Locale.SdPanel.Styles.ModelingCompound,
|
||||
value: "modeling-compound",
|
||||
},
|
||||
{ name: Locale.SdPanel.Styles.NeonPunk, value: "neon-punk" },
|
||||
{ name: Locale.SdPanel.Styles.Origami, value: "origami" },
|
||||
{ name: Locale.SdPanel.Styles.Photographic, value: "photographic" },
|
||||
{ name: Locale.SdPanel.Styles.PixelArt, value: "pixel-art" },
|
||||
{ name: Locale.SdPanel.Styles.TileTexture, value: "tile-texture" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Seed",
|
||||
value: "seed",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 4294967294,
|
||||
},
|
||||
{
|
||||
name: Locale.SdPanel.OutFormat,
|
||||
value: "output_format",
|
||||
type: "select",
|
||||
default: "png",
|
||||
options: [
|
||||
{ name: "PNG", value: "png" },
|
||||
{ name: "JPEG", value: "jpeg" },
|
||||
{ name: "WebP", value: "webp" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const sdCommonParams = (model: string, data: any) => {
|
||||
return params.filter((item) => {
|
||||
return !(item.support && !item.support.includes(model));
|
||||
});
|
||||
};
|
||||
|
||||
export const models = [
|
||||
{
|
||||
name: "Stable Image Ultra",
|
||||
value: "ultra",
|
||||
params: (data: any) => sdCommonParams("ultra", data),
|
||||
},
|
||||
{
|
||||
name: "Stable Image Core",
|
||||
value: "core",
|
||||
params: (data: any) => sdCommonParams("core", data),
|
||||
},
|
||||
{
|
||||
name: "Stable Diffusion 3",
|
||||
value: "sd3",
|
||||
params: (data: any) => {
|
||||
return sdCommonParams("sd3", data).filter((item) => {
|
||||
return !(
|
||||
data.model === "sd3-large-turbo" && item.value == "negative_prompt"
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function ControlParamItem(props: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
required?: boolean;
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}>
|
||||
<div className={styles["ctrl-param-item-header"]}>
|
||||
<div className={styles["ctrl-param-item-title"]}>
|
||||
<div>
|
||||
{props.title}
|
||||
{props.required && <span style={{ color: "red" }}>*</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{props.children}
|
||||
{props.subTitle && (
|
||||
<div className={styles["ctrl-param-item-sub-title"]}>
|
||||
{props.subTitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ControlParam(props: {
|
||||
columns: any[];
|
||||
data: any;
|
||||
onChange: (field: string, val: any) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{props.columns?.map((item) => {
|
||||
let element: null | JSX.Element;
|
||||
switch (item.type) {
|
||||
case "textarea":
|
||||
element = (
|
||||
<ControlParamItem
|
||||
title={item.name}
|
||||
subTitle={item.sub}
|
||||
required={item.required}
|
||||
>
|
||||
<textarea
|
||||
rows={item.rows || 3}
|
||||
style={{ maxWidth: "100%", width: "100%", padding: "10px" }}
|
||||
placeholder={item.placeholder}
|
||||
onChange={(e) => {
|
||||
props.onChange(item.value, e.currentTarget.value);
|
||||
}}
|
||||
value={props.data[item.value]}
|
||||
></textarea>
|
||||
</ControlParamItem>
|
||||
);
|
||||
break;
|
||||
case "select":
|
||||
element = (
|
||||
<ControlParamItem
|
||||
title={item.name}
|
||||
subTitle={item.sub}
|
||||
required={item.required}
|
||||
>
|
||||
<Select
|
||||
aria-label={item.name}
|
||||
value={props.data[item.value]}
|
||||
onChange={(e) => {
|
||||
props.onChange(item.value, e.currentTarget.value);
|
||||
}}
|
||||
>
|
||||
{item.options.map((opt: any) => {
|
||||
return (
|
||||
<option value={opt.value} key={opt.value}>
|
||||
{opt.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</ControlParamItem>
|
||||
);
|
||||
break;
|
||||
case "number":
|
||||
element = (
|
||||
<ControlParamItem
|
||||
title={item.name}
|
||||
subTitle={item.sub}
|
||||
required={item.required}
|
||||
>
|
||||
<input
|
||||
aria-label={item.name}
|
||||
type="number"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
value={props.data[item.value] || 0}
|
||||
onChange={(e) => {
|
||||
props.onChange(item.value, parseInt(e.currentTarget.value));
|
||||
}}
|
||||
/>
|
||||
</ControlParamItem>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
element = (
|
||||
<ControlParamItem
|
||||
title={item.name}
|
||||
subTitle={item.sub}
|
||||
required={item.required}
|
||||
>
|
||||
<input
|
||||
aria-label={item.name}
|
||||
type="text"
|
||||
value={props.data[item.value]}
|
||||
style={{ maxWidth: "100%", width: "100%" }}
|
||||
onChange={(e) => {
|
||||
props.onChange(item.value, e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</ControlParamItem>
|
||||
);
|
||||
}
|
||||
return <div key={item.value}>{element}</div>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getModelParamBasicData = (
|
||||
columns: any[],
|
||||
data: any,
|
||||
clearText?: boolean,
|
||||
) => {
|
||||
const newParams: any = {};
|
||||
columns.forEach((item: any) => {
|
||||
if (clearText && ["text", "textarea", "number"].includes(item.type)) {
|
||||
newParams[item.value] = item.default || "";
|
||||
} else {
|
||||
// @ts-ignore
|
||||
newParams[item.value] = data[item.value] || item.default || "";
|
||||
}
|
||||
});
|
||||
return newParams;
|
||||
};
|
||||
|
||||
export const getParams = (model: any, params: any) => {
|
||||
return models.find((m) => m.value === model.value)?.params(params) || [];
|
||||
};
|
||||
|
||||
export function SdPanel() {
|
||||
const sdStore = useSdStore();
|
||||
const currentModel = sdStore.currentModel;
|
||||
const setCurrentModel = sdStore.setCurrentModel;
|
||||
const params = sdStore.currentParams;
|
||||
const setParams = sdStore.setCurrentParams;
|
||||
|
||||
const handleValueChange = (field: string, val: any) => {
|
||||
setParams({
|
||||
...params,
|
||||
[field]: val,
|
||||
});
|
||||
};
|
||||
const handleModelChange = (model: any) => {
|
||||
setCurrentModel(model);
|
||||
setParams(getModelParamBasicData(model.params({}), params));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlParamItem title={Locale.SdPanel.AIModel}>
|
||||
<div className={styles["ai-models"]}>
|
||||
{models.map((item) => {
|
||||
return (
|
||||
<IconButton
|
||||
text={item.name}
|
||||
key={item.value}
|
||||
type={currentModel.value == item.value ? "primary" : null}
|
||||
shadow
|
||||
onClick={() => handleModelChange(item)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ControlParamItem>
|
||||
<ControlParam
|
||||
columns={getParams?.(currentModel, params) as any[]}
|
||||
data={params}
|
||||
onChange={handleValueChange}
|
||||
></ControlParam>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,140 +0,0 @@
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import GithubIcon from "@/app/icons/github.svg";
|
||||
import SDIcon from "@/app/icons/sd.svg";
|
||||
import ReturnIcon from "@/app/icons/return.svg";
|
||||
import HistoryIcon from "@/app/icons/history.svg";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import { Path, REPO_URL } from "@/app/constant";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
SideBarContainer,
|
||||
SideBarBody,
|
||||
SideBarHeader,
|
||||
SideBarTail,
|
||||
useDragSideBar,
|
||||
useHotKey,
|
||||
} from "@/app/components/sidebar";
|
||||
|
||||
import { getParams, getModelParamBasicData } from "./sd-panel";
|
||||
import { useSdStore } from "@/app/store/sd";
|
||||
import { showToast } from "@/app/components/ui-lib";
|
||||
import { useMobileScreen } from "@/app/utils";
|
||||
|
||||
const SdPanel = dynamic(
|
||||
async () => (await import("@/app/components/sd")).SdPanel,
|
||||
{
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export function SideBar(props: { className?: string }) {
|
||||
useHotKey();
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||
const navigate = useNavigate();
|
||||
const sdStore = useSdStore();
|
||||
const currentModel = sdStore.currentModel;
|
||||
const params = sdStore.currentParams;
|
||||
const setParams = sdStore.setCurrentParams;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const columns = getParams?.(currentModel, params);
|
||||
const reqParams: any = {};
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const item = columns[i];
|
||||
reqParams[item.value] = params[item.value] ?? null;
|
||||
if (item.required) {
|
||||
if (!reqParams[item.value]) {
|
||||
showToast(Locale.SdPanel.ParamIsRequired(item.name));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let data: any = {
|
||||
model: currentModel.value,
|
||||
model_name: currentModel.name,
|
||||
status: "wait",
|
||||
params: reqParams,
|
||||
created_at: new Date().toLocaleString(),
|
||||
img_data: "",
|
||||
};
|
||||
sdStore.sendTask(data, () => {
|
||||
setParams(getModelParamBasicData(columns, params, true));
|
||||
navigate(Path.SdNew);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SideBarContainer
|
||||
onDragStart={onDragStart}
|
||||
shouldNarrow={shouldNarrow}
|
||||
{...props}
|
||||
>
|
||||
{isMobileScreen ? (
|
||||
<div
|
||||
className="window-header"
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
>
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<ReturnIcon />}
|
||||
bordered
|
||||
title={Locale.Sd.Actions.ReturnHome}
|
||||
onClick={() => navigate(Path.Home)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SDIcon width={50} height={50} />
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<HistoryIcon />}
|
||||
bordered
|
||||
title={Locale.Sd.Actions.History}
|
||||
onClick={() => navigate(Path.SdNew)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SideBarHeader
|
||||
title={
|
||||
<IconButton
|
||||
icon={<ReturnIcon />}
|
||||
bordered
|
||||
title={Locale.Sd.Actions.ReturnHome}
|
||||
onClick={() => navigate(Path.Home)}
|
||||
/>
|
||||
}
|
||||
logo={<SDIcon width={38} height={"100%"} />}
|
||||
></SideBarHeader>
|
||||
)}
|
||||
<SideBarBody>
|
||||
<SdPanel />
|
||||
</SideBarBody>
|
||||
<SideBarTail
|
||||
primaryAction={
|
||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconButton icon={<GithubIcon />} shadow />
|
||||
</a>
|
||||
}
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
text={Locale.SdPanel.Submit}
|
||||
type="primary"
|
||||
shadow
|
||||
onClick={handleSubmit}
|
||||
></IconButton>
|
||||
}
|
||||
/>
|
||||
</SideBarContainer>
|
||||
);
|
||||
}
|
@@ -1,53 +0,0 @@
|
||||
.sd-img-list{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
.sd-img-item{
|
||||
width: 48%;
|
||||
.sd-img-item-info{
|
||||
flex:1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
user-select: text;
|
||||
p{
|
||||
margin: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.line-1{
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
.pre-img{
|
||||
display: flex;
|
||||
width: 130px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--second);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.img{
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all .3s;
|
||||
&:hover{
|
||||
opacity: .7;
|
||||
}
|
||||
}
|
||||
&:not(:last-child){
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.sd-img-list{
|
||||
.sd-img-item{
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,336 +0,0 @@
|
||||
import chatStyles from "@/app/components/chat.module.scss";
|
||||
import styles from "@/app/components/sd/sd.module.scss";
|
||||
import homeStyles from "@/app/components/home.module.scss";
|
||||
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import ReturnIcon from "@/app/icons/return.svg";
|
||||
import Locale from "@/app/locales";
|
||||
import { Path } from "@/app/constant";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
copyToClipboard,
|
||||
getMessageTextContent,
|
||||
useMobileScreen,
|
||||
} from "@/app/utils";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
import MinIcon from "@/app/icons/min.svg";
|
||||
import MaxIcon from "@/app/icons/max.svg";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { ChatAction } from "@/app/components/chat";
|
||||
import DeleteIcon from "@/app/icons/clear.svg";
|
||||
import CopyIcon from "@/app/icons/copy.svg";
|
||||
import PromptIcon from "@/app/icons/prompt.svg";
|
||||
import ResetIcon from "@/app/icons/reload.svg";
|
||||
import { useSdStore } from "@/app/store/sd";
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
import ErrorIcon from "@/app/icons/delete.svg";
|
||||
import SDIcon from "@/app/icons/sd.svg";
|
||||
import { Property } from "csstype";
|
||||
import {
|
||||
showConfirm,
|
||||
showImageModal,
|
||||
showModal,
|
||||
} from "@/app/components/ui-lib";
|
||||
import { removeImage } from "@/app/utils/chat";
|
||||
import { SideBar } from "./sd-sidebar";
|
||||
import { WindowContent } from "@/app/components/home";
|
||||
import { params } from "./sd-panel";
|
||||
|
||||
function getSdTaskStatus(item: any) {
|
||||
let s: string;
|
||||
let color: Property.Color | undefined = undefined;
|
||||
switch (item.status) {
|
||||
case "success":
|
||||
s = Locale.Sd.Status.Success;
|
||||
color = "green";
|
||||
break;
|
||||
case "error":
|
||||
s = Locale.Sd.Status.Error;
|
||||
color = "red";
|
||||
break;
|
||||
case "wait":
|
||||
s = Locale.Sd.Status.Wait;
|
||||
color = "yellow";
|
||||
break;
|
||||
case "running":
|
||||
s = Locale.Sd.Status.Running;
|
||||
color = "blue";
|
||||
break;
|
||||
default:
|
||||
s = item.status.toUpperCase();
|
||||
}
|
||||
return (
|
||||
<p className={styles["line-1"]} title={item.error} style={{ color: color }}>
|
||||
<span>
|
||||
{Locale.Sd.Status.Name}: {s}
|
||||
</span>
|
||||
{item.status === "error" && (
|
||||
<span
|
||||
className="clickable"
|
||||
onClick={() => {
|
||||
showModal({
|
||||
title: Locale.Sd.Detail,
|
||||
children: (
|
||||
<div style={{ color: color, userSelect: "text" }}>
|
||||
{item.error}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
- {item.error}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sd() {
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||
const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
|
||||
const config = useAppConfig();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sdStore = useSdStore();
|
||||
const [sdImages, setSdImages] = useState(sdStore.draw);
|
||||
const isSd = location.pathname === Path.Sd;
|
||||
|
||||
useEffect(() => {
|
||||
setSdImages(sdStore.draw);
|
||||
}, [sdStore.currentId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideBar className={isSd ? homeStyles["sidebar-show"] : ""} />
|
||||
<WindowContent>
|
||||
<div className={chatStyles.chat} key={"1"}>
|
||||
<div className="window-header" data-tauri-drag-region>
|
||||
{isMobileScreen && (
|
||||
<div className="window-actions">
|
||||
<div className={"window-action-button"}>
|
||||
<IconButton
|
||||
icon={<ReturnIcon />}
|
||||
bordered
|
||||
title={Locale.Chat.Actions.ChatList}
|
||||
onClick={() => navigate(Path.Sd)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`window-header-title ${chatStyles["chat-body-title"]}`}
|
||||
>
|
||||
<div className={`window-header-main-title`}>Stability AI</div>
|
||||
<div className="window-header-sub-title">
|
||||
{Locale.Sd.SubTitle(sdImages.length || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
{showMaxIcon && (
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
aria={Locale.Chat.Actions.FullScreen}
|
||||
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
|
||||
bordered
|
||||
onClick={() => {
|
||||
config.update(
|
||||
(config) => (config.tightBorder = !config.tightBorder),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isMobileScreen && <SDIcon width={50} height={50} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className={chatStyles["chat-body"]} ref={scrollRef}>
|
||||
<div className={styles["sd-img-list"]}>
|
||||
{sdImages.length > 0 ? (
|
||||
sdImages.map((item: any) => {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{ display: "flex" }}
|
||||
className={styles["sd-img-item"]}
|
||||
>
|
||||
{item.status === "success" ? (
|
||||
<img
|
||||
className={styles["img"]}
|
||||
src={item.img_data}
|
||||
alt={item.id}
|
||||
onClick={(e) =>
|
||||
showImageModal(
|
||||
item.img_data,
|
||||
true,
|
||||
isMobileScreen
|
||||
? { width: "100%", height: "fit-content" }
|
||||
: { maxWidth: "100%", maxHeight: "100%" },
|
||||
isMobileScreen
|
||||
? { width: "100%", height: "fit-content" }
|
||||
: { width: "100%", height: "100%" },
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : item.status === "error" ? (
|
||||
<div className={styles["pre-img"]}>
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles["pre-img"]}>
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{ marginLeft: "10px" }}
|
||||
className={styles["sd-img-item-info"]}
|
||||
>
|
||||
<p className={styles["line-1"]}>
|
||||
{Locale.SdPanel.Prompt}:{" "}
|
||||
<span
|
||||
className="clickable"
|
||||
title={item.params.prompt}
|
||||
onClick={() => {
|
||||
showModal({
|
||||
title: Locale.Sd.Detail,
|
||||
children: (
|
||||
<div style={{ userSelect: "text" }}>
|
||||
{item.params.prompt}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{item.params.prompt}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
{Locale.SdPanel.AIModel}: {item.model_name}
|
||||
</p>
|
||||
{getSdTaskStatus(item)}
|
||||
<p>{item.created_at}</p>
|
||||
<div className={chatStyles["chat-message-actions"]}>
|
||||
<div className={chatStyles["chat-input-actions"]}>
|
||||
<ChatAction
|
||||
text={Locale.Sd.Actions.Params}
|
||||
icon={<PromptIcon />}
|
||||
onClick={() => {
|
||||
showModal({
|
||||
title: Locale.Sd.GenerateParams,
|
||||
children: (
|
||||
<div style={{ userSelect: "text" }}>
|
||||
{Object.keys(item.params).map((key) => {
|
||||
let label = key;
|
||||
let value = item.params[key];
|
||||
switch (label) {
|
||||
case "prompt":
|
||||
label = Locale.SdPanel.Prompt;
|
||||
break;
|
||||
case "negative_prompt":
|
||||
label =
|
||||
Locale.SdPanel.NegativePrompt;
|
||||
break;
|
||||
case "aspect_ratio":
|
||||
label = Locale.SdPanel.AspectRatio;
|
||||
break;
|
||||
case "seed":
|
||||
label = "Seed";
|
||||
value = value || 0;
|
||||
break;
|
||||
case "output_format":
|
||||
label = Locale.SdPanel.OutFormat;
|
||||
value = value?.toUpperCase();
|
||||
break;
|
||||
case "style":
|
||||
label = Locale.SdPanel.ImageStyle;
|
||||
value = params
|
||||
.find(
|
||||
(item) =>
|
||||
item.value === "style",
|
||||
)
|
||||
?.options?.find(
|
||||
(item) => item.value === value,
|
||||
)?.name;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{ margin: "10px" }}
|
||||
>
|
||||
<strong>{label}: </strong>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChatAction
|
||||
text={Locale.Sd.Actions.Copy}
|
||||
icon={<CopyIcon />}
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
getMessageTextContent({
|
||||
role: "user",
|
||||
content: item.params.prompt,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ChatAction
|
||||
text={Locale.Sd.Actions.Retry}
|
||||
icon={<ResetIcon />}
|
||||
onClick={() => {
|
||||
const reqData = {
|
||||
model: item.model,
|
||||
model_name: item.model_name,
|
||||
status: "wait",
|
||||
params: { ...item.params },
|
||||
created_at: new Date().toLocaleString(),
|
||||
img_data: "",
|
||||
};
|
||||
sdStore.sendTask(reqData);
|
||||
}}
|
||||
/>
|
||||
<ChatAction
|
||||
text={Locale.Sd.Actions.Delete}
|
||||
icon={<DeleteIcon />}
|
||||
onClick={async () => {
|
||||
if (
|
||||
await showConfirm(Locale.Sd.Danger.Delete)
|
||||
) {
|
||||
// remove img_data + remove item in list
|
||||
removeImage(item.img_data).finally(() => {
|
||||
sdStore.draw = sdImages.filter(
|
||||
(i: any) => i.id !== item.id,
|
||||
);
|
||||
sdStore.getNextId();
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div>{Locale.Sd.EmptyRecord}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WindowContent>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,167 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { ErrorBoundary } from "./error";
|
||||
import styles from "./mask.module.scss";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { IconButton } from "./button";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import Locale from "../locales";
|
||||
import { Path } from "../constant";
|
||||
|
||||
import { useChatStore } from "../store";
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
export function SearchChatPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const sessions = chatStore.sessions;
|
||||
const selectSession = chatStore.selectSession;
|
||||
|
||||
const [searchResults, setSearchResults] = useState<Item[]>([]);
|
||||
|
||||
const previousValueRef = useRef<string>("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const doSearch = useCallback((text: string) => {
|
||||
const lowerCaseText = text.toLowerCase();
|
||||
const results: Item[] = [];
|
||||
|
||||
sessions.forEach((session, index) => {
|
||||
const fullTextContents: string[] = [];
|
||||
|
||||
session.messages.forEach((message) => {
|
||||
const content = message.content as string;
|
||||
if (!content.toLowerCase || content === "") return;
|
||||
const lowerCaseContent = content.toLowerCase();
|
||||
|
||||
// full text search
|
||||
let pos = lowerCaseContent.indexOf(lowerCaseText);
|
||||
while (pos !== -1) {
|
||||
const start = Math.max(0, pos - 35);
|
||||
const end = Math.min(content.length, pos + lowerCaseText.length + 35);
|
||||
fullTextContents.push(content.substring(start, end));
|
||||
pos = lowerCaseContent.indexOf(
|
||||
lowerCaseText,
|
||||
pos + lowerCaseText.length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (fullTextContents.length > 0) {
|
||||
results.push({
|
||||
id: index,
|
||||
name: session.topic,
|
||||
content: fullTextContents.join("... "), // concat content with...
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// sort by length of matching content
|
||||
results.sort((a, b) => b.content.length - a.content.length);
|
||||
|
||||
return results;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
if (searchInputRef.current) {
|
||||
const currentValue = searchInputRef.current.value;
|
||||
if (currentValue !== previousValueRef.current) {
|
||||
if (currentValue.length > 0) {
|
||||
const result = doSearch(currentValue);
|
||||
setSearchResults(result);
|
||||
}
|
||||
previousValueRef.current = currentValue;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Cleanup the interval on component unmount
|
||||
return () => clearInterval(intervalId);
|
||||
}, [doSearch]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
{/* header */}
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
{Locale.SearchChat.Page.Title}
|
||||
</div>
|
||||
<div className="window-header-submai-title">
|
||||
{Locale.SearchChat.Page.SubTitle(searchResults.length)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mask-page-body"]}>
|
||||
<div className={styles["mask-filter"]}>
|
||||
{/**搜索输入框 */}
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={Locale.SearchChat.Page.Search}
|
||||
autoFocus
|
||||
ref={searchInputRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const searchText = e.currentTarget.value;
|
||||
if (searchText.length > 0) {
|
||||
const result = doSearch(searchText);
|
||||
setSearchResults(result);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{searchResults.map((item) => (
|
||||
<div
|
||||
className={styles["mask-item"]}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
navigate(Path.Chat);
|
||||
selectSession(item.id);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{/** 搜索匹配的文本 */}
|
||||
<div className={styles["mask-header"]}>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>{item.name}</div>
|
||||
{item.content.slice(0, 70)}
|
||||
</div>
|
||||
</div>
|
||||
{/** 操作按钮 */}
|
||||
<div className={styles["mask-actions"]}>
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
text={Locale.SearchChat.Item.View}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
@@ -5,8 +5,6 @@
|
||||
|
||||
.avatar {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.edit-prompt-modal {
|
||||
@@ -72,9 +70,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle-button {
|
||||
button {
|
||||
overflow:visible ;
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
|
||||
import { useEffect, useRef, useMemo } from "react";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
@@ -7,10 +7,11 @@ import SettingsIcon from "../icons/settings.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import ChatGptIcon from "../icons/chatgpt.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import MaskIcon from "../icons/mask.svg";
|
||||
import PluginIcon from "../icons/plugin.svg";
|
||||
import DragIcon from "../icons/drag.svg";
|
||||
import DiscoveryIcon from "../icons/discovery.svg";
|
||||
|
||||
import Locale from "../locales";
|
||||
|
||||
@@ -22,20 +23,19 @@ import {
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
NARROW_SIDEBAR_WIDTH,
|
||||
Path,
|
||||
PLUGINS,
|
||||
REPO_URL,
|
||||
} from "../constant";
|
||||
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { isIOS, useMobileScreen } from "../utils";
|
||||
import dynamic from "next/dynamic";
|
||||
import { showConfirm, Selector } from "./ui-lib";
|
||||
import { showConfirm, showToast } from "./ui-lib";
|
||||
|
||||
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
||||
loading: () => null,
|
||||
});
|
||||
|
||||
export function useHotKey() {
|
||||
function useHotKey() {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,7 +54,7 @@ export function useHotKey() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDragSideBar() {
|
||||
function useDragSideBar() {
|
||||
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
|
||||
|
||||
const config = useAppConfig();
|
||||
@@ -127,21 +127,25 @@ export function useDragSideBar() {
|
||||
shouldNarrow,
|
||||
};
|
||||
}
|
||||
export function SideBarContainer(props: {
|
||||
children: React.ReactNode;
|
||||
onDragStart: (e: MouseEvent) => void;
|
||||
shouldNarrow: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
|
||||
export function SideBar(props: { className?: string }) {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// drag side bar
|
||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const isIOSMobile = useMemo(
|
||||
() => isIOS() && isMobileScreen,
|
||||
[isMobileScreen],
|
||||
);
|
||||
const { children, className, onDragStart, shouldNarrow } = props;
|
||||
|
||||
useHotKey();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sidebar} ${className} ${
|
||||
className={`${styles.sidebar} ${props.className} ${
|
||||
shouldNarrow && styles["narrow-sidebar"]
|
||||
}`}
|
||||
style={{
|
||||
@@ -149,125 +153,43 @@ export function SideBarContainer(props: {
|
||||
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className={styles["sidebar-drag"]}
|
||||
onPointerDown={(e) => onDragStart(e as any)}
|
||||
>
|
||||
<DragIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideBarHeader(props: {
|
||||
title?: string | React.ReactNode;
|
||||
subTitle?: string | React.ReactNode;
|
||||
logo?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { title, subTitle, logo, children } = props;
|
||||
return (
|
||||
<Fragment>
|
||||
<div className={styles["sidebar-header"]} data-tauri-drag-region>
|
||||
<div className={styles["sidebar-title-container"]}>
|
||||
<div className={styles["sidebar-title"]} data-tauri-drag-region>
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles["sidebar-sub-title"]}>{subTitle}</div>
|
||||
<div className={styles["sidebar-title"]} data-tauri-drag-region>
|
||||
ChatGPT Next
|
||||
</div>
|
||||
<div className={styles["sidebar-sub-title"]}>
|
||||
Build your own AI assistant.
|
||||
</div>
|
||||
<div className={styles["sidebar-logo"] + " no-dark"}>
|
||||
<ChatGptIcon />
|
||||
</div>
|
||||
<div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
|
||||
</div>
|
||||
{children}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideBarBody(props: {
|
||||
children: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
}) {
|
||||
const { onClick, children } = props;
|
||||
return (
|
||||
<div className={styles["sidebar-body"]} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className={styles["sidebar-header-bar"]}>
|
||||
<IconButton
|
||||
icon={<MaskIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Mask.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
onClick={() => {
|
||||
if (config.dontShowMaskSplashScreen !== true) {
|
||||
navigate(Path.NewChat, { state: { fromHome: true } });
|
||||
} else {
|
||||
navigate(Path.Masks, { state: { fromHome: true } });
|
||||
}
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
<IconButton
|
||||
icon={<PluginIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Plugin.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
onClick={() => showToast(Locale.WIP)}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
|
||||
export function SideBarTail(props: {
|
||||
primaryAction?: React.ReactNode;
|
||||
secondaryAction?: React.ReactNode;
|
||||
}) {
|
||||
const { primaryAction, secondaryAction } = props;
|
||||
|
||||
return (
|
||||
<div className={styles["sidebar-tail"]}>
|
||||
<div className={styles["sidebar-actions"]}>{primaryAction}</div>
|
||||
<div className={styles["sidebar-actions"]}>{secondaryAction}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideBar(props: { className?: string }) {
|
||||
useHotKey();
|
||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||
const [showPluginSelector, setShowPluginSelector] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
return (
|
||||
<SideBarContainer
|
||||
onDragStart={onDragStart}
|
||||
shouldNarrow={shouldNarrow}
|
||||
{...props}
|
||||
>
|
||||
<SideBarHeader
|
||||
title="NextChat"
|
||||
subTitle="Build your own AI assistant."
|
||||
logo={<ChatGptIcon />}
|
||||
>
|
||||
<div className={styles["sidebar-header-bar"]}>
|
||||
<IconButton
|
||||
icon={<MaskIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Mask.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
onClick={() => {
|
||||
if (config.dontShowMaskSplashScreen !== true) {
|
||||
navigate(Path.NewChat, { state: { fromHome: true } });
|
||||
} else {
|
||||
navigate(Path.Masks, { state: { fromHome: true } });
|
||||
}
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DiscoveryIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Discovery.Name}
|
||||
className={styles["sidebar-bar-button"]}
|
||||
onClick={() => setShowPluginSelector(true)}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
{showPluginSelector && (
|
||||
<Selector
|
||||
items={[
|
||||
...PLUGINS.map((item) => {
|
||||
return {
|
||||
title: item.name,
|
||||
value: item.path,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
onClose={() => setShowPluginSelector(false)}
|
||||
onSelection={(s) => {
|
||||
navigate(s[0], { state: { fromHome: true } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SideBarHeader>
|
||||
<SideBarBody
|
||||
<div
|
||||
className={styles["sidebar-body"]}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
navigate(Path.Home);
|
||||
@@ -275,41 +197,32 @@ export function SideBar(props: { className?: string }) {
|
||||
}}
|
||||
>
|
||||
<ChatList narrow={shouldNarrow} />
|
||||
</SideBarBody>
|
||||
<SideBarTail
|
||||
primaryAction={
|
||||
<>
|
||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Home.DeleteChat)) {
|
||||
chatStore.deleteSession(chatStore.currentSessionIndex);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<Link to={Path.Settings}>
|
||||
<IconButton
|
||||
aria={Locale.Settings.Title}
|
||||
icon={<SettingsIcon />}
|
||||
shadow
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconButton
|
||||
aria={Locale.Export.MessageFromChatGPT}
|
||||
icon={<GithubIcon />}
|
||||
shadow
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
secondaryAction={
|
||||
</div>
|
||||
|
||||
<div className={styles["sidebar-tail"]}>
|
||||
<div className={styles["sidebar-actions"]}>
|
||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Home.DeleteChat)) {
|
||||
chatStore.deleteSession(chatStore.currentSessionIndex);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<Link to={Path.Settings}>
|
||||
<IconButton icon={<SettingsIcon />} shadow />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconButton icon={<GithubIcon />} shadow />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
||||
@@ -323,8 +236,15 @@ export function SideBar(props: { className?: string }) {
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SideBarContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles["sidebar-drag"]}
|
||||
onPointerDown={(e) => onDragStart(e as any)}
|
||||
>
|
||||
<DragIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,133 +0,0 @@
|
||||
import { TTSConfig, TTSConfigValidator } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { ListItem, Select } from "./ui-lib";
|
||||
import {
|
||||
DEFAULT_TTS_ENGINE,
|
||||
DEFAULT_TTS_ENGINES,
|
||||
DEFAULT_TTS_MODELS,
|
||||
DEFAULT_TTS_VOICES,
|
||||
} from "../constant";
|
||||
import { InputRange } from "./input-range";
|
||||
|
||||
export function TTSConfigList(props: {
|
||||
ttsConfig: TTSConfig;
|
||||
updateConfig: (updater: (config: TTSConfig) => void) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.TTS.Enable.Title}
|
||||
subTitle={Locale.Settings.TTS.Enable.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.ttsConfig.enable}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.enable = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
{/* <ListItem
|
||||
title={Locale.Settings.TTS.Autoplay.Title}
|
||||
subTitle={Locale.Settings.TTS.Autoplay.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.ttsConfig.autoplay}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.autoplay = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem> */}
|
||||
<ListItem title={Locale.Settings.TTS.Engine}>
|
||||
<Select
|
||||
value={props.ttsConfig.engine}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.engine = TTSConfigValidator.engine(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{DEFAULT_TTS_ENGINES.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
{props.ttsConfig.engine === DEFAULT_TTS_ENGINE && (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.TTS.Model}>
|
||||
<Select
|
||||
value={props.ttsConfig.model}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.model = TTSConfigValidator.model(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{DEFAULT_TTS_MODELS.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.TTS.Voice.Title}
|
||||
subTitle={Locale.Settings.TTS.Voice.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={props.ttsConfig.voice}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.voice = TTSConfigValidator.voice(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{DEFAULT_TTS_VOICES.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.TTS.Speed.Title}
|
||||
subTitle={Locale.Settings.TTS.Speed.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.TTS.Speed.Title}
|
||||
value={props.ttsConfig.speed?.toFixed(1)}
|
||||
min="0.3"
|
||||
max="4.0"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.speed = TTSConfigValidator.speed(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,119 +0,0 @@
|
||||
@import "../styles/animation.scss";
|
||||
.plugin-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.plugin-page-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.plugin-filter {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
animation: slide-in ease 0.3s;
|
||||
height: 40px;
|
||||
|
||||
display: flex;
|
||||
|
||||
.search-bar {
|
||||
flex-grow: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-bar:focus {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.plugin-filter-lang {
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.plugin-create {
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
box-sizing: border-box;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
border: var(--border-in-light);
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
.plugin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.plugin-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.plugin-title {
|
||||
.plugin-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.plugin-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
.plugin-runtime-warning {
|
||||
font-size: 12px;
|
||||
color: #f86c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
transition: all ease 0.3s;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--card-shadow);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: var(--border-in-light);
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -14,24 +14,17 @@
|
||||
|
||||
.popover-content {
|
||||
position: absolute;
|
||||
width: 350px;
|
||||
animation: slide-in 0.3s ease;
|
||||
right: 0;
|
||||
top: calc(100% + 10px);
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
.popover-content {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.popover-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
@@ -61,19 +54,6 @@
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
.list-header {
|
||||
.list-item-title {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.list-item-sub-title {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
@@ -252,12 +232,6 @@
|
||||
position: relative;
|
||||
max-width: fit-content;
|
||||
|
||||
&.left-align-option {
|
||||
option {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.select-with-icon-select {
|
||||
height: 100%;
|
||||
border: var(--border-in-light);
|
||||
@@ -310,12 +284,7 @@
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
|
||||
.selector-item-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&-content {
|
||||
min-width: 300px;
|
||||
.list {
|
||||
max-height: 90vh;
|
||||
overflow-x: hidden;
|
||||
@@ -336,4 +305,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -13,15 +13,7 @@ import MinIcon from "../icons/min.svg";
|
||||
import Locale from "../locales";
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import React, {
|
||||
CSSProperties,
|
||||
HTMLProps,
|
||||
MouseEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import React, { HTMLProps, useEffect, useState } from "react";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
export function Popover(props: {
|
||||
@@ -34,10 +26,10 @@ export function Popover(props: {
|
||||
<div className={styles.popover}>
|
||||
{props.children}
|
||||
{props.open && (
|
||||
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
|
||||
)}
|
||||
{props.open && (
|
||||
<div className={styles["popover-content"]}>{props.content}</div>
|
||||
<div className={styles["popover-content"]}>
|
||||
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
|
||||
{props.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -50,21 +42,16 @@ export function Card(props: { children: JSX.Element[]; className?: string }) {
|
||||
}
|
||||
|
||||
export function ListItem(props: {
|
||||
title?: string;
|
||||
subTitle?: string | JSX.Element;
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
icon?: JSX.Element;
|
||||
className?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
vertical?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
styles["list-item"] +
|
||||
` ${props.vertical ? styles["vertical"] : ""} ` +
|
||||
` ${props.className || ""}`
|
||||
}
|
||||
className={styles["list-item"] + ` ${props.className || ""}`}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<div className={styles["list-header"]}>
|
||||
@@ -265,10 +252,9 @@ export function Input(props: InputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordInput(
|
||||
props: HTMLProps<HTMLInputElement> & { aria?: string },
|
||||
) {
|
||||
export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
function changeVisibility() {
|
||||
setVisible(!visible);
|
||||
}
|
||||
@@ -276,7 +262,6 @@ export function PasswordInput(
|
||||
return (
|
||||
<div className={"password-input-container"}>
|
||||
<IconButton
|
||||
aria={props.aria}
|
||||
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
||||
onClick={changeVisibility}
|
||||
className={"password-eye"}
|
||||
@@ -292,19 +277,13 @@ export function PasswordInput(
|
||||
|
||||
export function Select(
|
||||
props: React.DetailedHTMLProps<
|
||||
React.SelectHTMLAttributes<HTMLSelectElement> & {
|
||||
align?: "left" | "center";
|
||||
},
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||
HTMLSelectElement
|
||||
>,
|
||||
) {
|
||||
const { className, children, align, ...otherProps } = props;
|
||||
const { className, children, ...otherProps } = props;
|
||||
return (
|
||||
<div
|
||||
className={`${styles["select-with-icon"]} ${
|
||||
align === "left" ? styles["left-align-option"] : ""
|
||||
} ${className}`}
|
||||
>
|
||||
<div className={`${styles["select-with-icon"]} ${className}`}>
|
||||
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
||||
{children}
|
||||
</select>
|
||||
@@ -441,25 +420,17 @@ export function showPrompt(content: any, value = "", rows = 3) {
|
||||
});
|
||||
}
|
||||
|
||||
export function showImageModal(
|
||||
img: string,
|
||||
defaultMax?: boolean,
|
||||
style?: CSSProperties,
|
||||
boxStyle?: CSSProperties,
|
||||
) {
|
||||
export function showImageModal(img: string) {
|
||||
showModal({
|
||||
title: Locale.Export.Image.Modal,
|
||||
defaultMax: defaultMax,
|
||||
children: (
|
||||
<div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
|
||||
<div>
|
||||
<img
|
||||
src={img}
|
||||
alt="preview"
|
||||
style={
|
||||
style ?? {
|
||||
maxWidth: "100%",
|
||||
}
|
||||
}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
></img>
|
||||
</div>
|
||||
),
|
||||
@@ -471,56 +442,27 @@ export function Selector<T>(props: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
value: T;
|
||||
disable?: boolean;
|
||||
}>;
|
||||
defaultSelectedValue?: T[] | T;
|
||||
defaultSelectedValue?: T;
|
||||
onSelection?: (selection: T[]) => void;
|
||||
onClose?: () => void;
|
||||
multiple?: boolean;
|
||||
}) {
|
||||
const [selectedValues, setSelectedValues] = useState<T[]>(
|
||||
Array.isArray(props.defaultSelectedValue)
|
||||
? props.defaultSelectedValue
|
||||
: props.defaultSelectedValue !== undefined
|
||||
? [props.defaultSelectedValue]
|
||||
: [],
|
||||
);
|
||||
|
||||
const handleSelection = (e: MouseEvent, value: T) => {
|
||||
if (props.multiple) {
|
||||
e.stopPropagation();
|
||||
const newSelectedValues = selectedValues.includes(value)
|
||||
? selectedValues.filter((v) => v !== value)
|
||||
: [...selectedValues, value];
|
||||
setSelectedValues(newSelectedValues);
|
||||
props.onSelection?.(newSelectedValues);
|
||||
} else {
|
||||
setSelectedValues([value]);
|
||||
props.onSelection?.([value]);
|
||||
props.onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
||||
<div className={styles["selector-content"]}>
|
||||
<List>
|
||||
{props.items.map((item, i) => {
|
||||
const selected = selectedValues.includes(item.value);
|
||||
const selected = props.defaultSelectedValue === item.value;
|
||||
return (
|
||||
<ListItem
|
||||
className={`${styles["selector-item"]} ${
|
||||
item.disable && styles["selector-item-disabled"]
|
||||
}`}
|
||||
className={styles["selector-item"]}
|
||||
key={i}
|
||||
title={item.title}
|
||||
subTitle={item.subTitle}
|
||||
onClick={(e) => {
|
||||
if (item.disable) {
|
||||
e.stopPropagation();
|
||||
} else {
|
||||
handleSelection(e, item.value);
|
||||
}
|
||||
onClick={() => {
|
||||
props.onSelection?.([item.value]);
|
||||
props.onClose?.();
|
||||
}}
|
||||
>
|
||||
{selected ? (
|
||||
@@ -543,38 +485,3 @@ export function Selector<T>(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function FullScreen(props: any) {
|
||||
const { children, right = 10, top = 10, ...rest } = props;
|
||||
const ref = useRef<HTMLDivElement>();
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
ref.current?.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const handleScreenChange = (e: any) => {
|
||||
if (e.target === ref.current) {
|
||||
setFullScreen(!!document.fullscreenElement);
|
||||
}
|
||||
};
|
||||
document.addEventListener("fullscreenchange", handleScreenChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", handleScreenChange);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative" }} {...rest}>
|
||||
<div style={{ position: "absolute", right, top }}>
|
||||
<IconButton
|
||||
icon={fullScreen ? <MinIcon /> : <MaxIcon />}
|
||||
onClick={toggleFullscreen}
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
55
app/components/voice/voice.module.scss
Normal file
55
app/components/voice/voice.module.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
.voice-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba($color: #000000, $alpha: 0.9);
|
||||
color: white;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.top,
|
||||
.bottom {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
font-size: 1.5em;
|
||||
color: rgba($color: #fff, $alpha: 0.6);
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: rgba($color: #00ff00, $alpha: 0.2);
|
||||
}
|
||||
|
||||
.top.active {
|
||||
background-color: white;
|
||||
|
||||
&::after {
|
||||
content: "☁️";
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.top:hover {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.top {
|
||||
}
|
||||
|
||||
.center {
|
||||
height: 2px;
|
||||
background-color: white;
|
||||
opacity: 0.2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
}
|
||||
}
|
117
app/components/voice/voice.tsx
Normal file
117
app/components/voice/voice.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useChatStore } from "@/app/store";
|
||||
import style from "./voice.module.scss";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import SpeechRecognition, {
|
||||
useSpeechRecognition,
|
||||
} from "react-speech-recognition";
|
||||
import { IconButton } from "../button";
|
||||
import { api } from "@/app/client/api";
|
||||
|
||||
function findLast<T>(array: T[], predictor: (_: T) => boolean) {
|
||||
for (let i = array.length - 1; i >= 0; i -= 1) {
|
||||
if (predictor(array[i])) {
|
||||
return array[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function VoicePage() {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const lastAssistantMessage = useMemo(
|
||||
() => findLast(session.messages, (m) => m.role === "assistant"),
|
||||
[session.messages],
|
||||
);
|
||||
const lastUserMessage = useMemo(
|
||||
() => findLast(session.messages, (m) => m.role === "user"),
|
||||
[session.messages],
|
||||
);
|
||||
const speech = useSpeechRecognition({
|
||||
clearTranscriptOnListen: true,
|
||||
});
|
||||
|
||||
if (!speech.browserSupportsSpeechRecognition) {
|
||||
throw Error("your browser does not support speech recognition api");
|
||||
}
|
||||
|
||||
function startVoice() {
|
||||
SpeechRecognition.startListening({
|
||||
language: "zh-CN",
|
||||
});
|
||||
sourceNodeRef.current?.stop();
|
||||
}
|
||||
|
||||
function stopVoice() {
|
||||
SpeechRecognition.stopListening();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!speech.listening) {
|
||||
if (
|
||||
speech.finalTranscript.length > 0 &&
|
||||
speech.finalTranscript !== lastUserMessage?.content
|
||||
) {
|
||||
chatStore.onUserInput(speech.finalTranscript);
|
||||
}
|
||||
}
|
||||
}, [speech.listening]);
|
||||
|
||||
const [loadingTTS, setLoadingTTS] = useState(false);
|
||||
const sourceNodeRef = useRef<AudioBufferSourceNode>();
|
||||
|
||||
function speak() {
|
||||
const content = lastAssistantMessage?.content;
|
||||
if (!content) return;
|
||||
setLoadingTTS(true);
|
||||
api.llm.speech(content).then(async (arrayBuffer) => {
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
const source = audioContext.createBufferSource();
|
||||
try {
|
||||
sourceNodeRef.current?.stop();
|
||||
} catch {}
|
||||
sourceNodeRef.current = source;
|
||||
// 设置音频源的 buffer 属性
|
||||
source.buffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
// 连接到默认的输出设备(通常是扬声器)
|
||||
source.connect(audioContext.destination);
|
||||
// 开始播放
|
||||
setLoadingTTS(false);
|
||||
source.start(0);
|
||||
});
|
||||
}
|
||||
|
||||
const lastStream = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
lastAssistantMessage?.streaming !== lastStream.current &&
|
||||
lastStream.current
|
||||
) {
|
||||
speak();
|
||||
}
|
||||
lastStream.current = !!lastAssistantMessage?.streaming;
|
||||
}, [lastAssistantMessage?.streaming]);
|
||||
|
||||
return (
|
||||
<div className={style["voice-page"]}>
|
||||
<div className={style["top"] + ` ${style["active"]}`} onClick={speak}>
|
||||
{lastAssistantMessage?.content}
|
||||
</div>
|
||||
<div className={style["center"]}></div>
|
||||
<div
|
||||
className={style["bottom"] + ` ${speech.listening && style["active"]}`}
|
||||
onClick={() => {
|
||||
if (speech.listening) {
|
||||
stopVoice();
|
||||
} else {
|
||||
startVoice();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{speech.transcript || lastUserMessage?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,5 +1,4 @@
|
||||
import tauriConfig from "../../src-tauri/tauri.conf.json";
|
||||
import { DEFAULT_INPUT_TEMPLATE } from "../constant";
|
||||
|
||||
export const getBuildConfig = () => {
|
||||
if (typeof process === "undefined") {
|
||||
@@ -39,7 +38,6 @@ export const getBuildConfig = () => {
|
||||
...commitInfo,
|
||||
buildMode,
|
||||
isApp,
|
||||
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build";
|
||||
export function getClientConfig() {
|
||||
if (typeof document !== "undefined") {
|
||||
// client side
|
||||
return JSON.parse(queryMeta("config") || "{}") as BuildConfig;
|
||||
return JSON.parse(queryMeta("config")) as BuildConfig;
|
||||
}
|
||||
|
||||
if (typeof process !== "undefined") {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import md5 from "spark-md5";
|
||||
import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant";
|
||||
import { DEFAULT_MODELS } from "../constant";
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
@@ -21,58 +21,11 @@ declare global {
|
||||
ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
|
||||
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
|
||||
CUSTOM_MODELS?: string; // to control custom models
|
||||
DEFAULT_MODEL?: string; // to control default model in every new chat window
|
||||
|
||||
// stability only
|
||||
STABILITY_URL?: string;
|
||||
STABILITY_API_KEY?: string;
|
||||
|
||||
// azure only
|
||||
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
|
||||
AZURE_API_KEY?: string;
|
||||
AZURE_API_VERSION?: string;
|
||||
|
||||
// google only
|
||||
GOOGLE_API_KEY?: string;
|
||||
GOOGLE_URL?: string;
|
||||
|
||||
// google tag manager
|
||||
GTM_ID?: string;
|
||||
|
||||
// anthropic only
|
||||
ANTHROPIC_URL?: string;
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
ANTHROPIC_API_VERSION?: string;
|
||||
|
||||
// baidu only
|
||||
BAIDU_URL?: string;
|
||||
BAIDU_API_KEY?: string;
|
||||
BAIDU_SECRET_KEY?: string;
|
||||
|
||||
// bytedance only
|
||||
BYTEDANCE_URL?: string;
|
||||
BYTEDANCE_API_KEY?: string;
|
||||
|
||||
// alibaba only
|
||||
ALIBABA_URL?: string;
|
||||
ALIBABA_API_KEY?: string;
|
||||
|
||||
// tencent only
|
||||
TENCENT_URL?: string;
|
||||
TENCENT_SECRET_KEY?: string;
|
||||
TENCENT_SECRET_ID?: string;
|
||||
|
||||
// moonshot only
|
||||
MOONSHOT_URL?: string;
|
||||
MOONSHOT_API_KEY?: string;
|
||||
|
||||
// iflytek only
|
||||
IFLYTEK_URL?: string;
|
||||
IFLYTEK_API_KEY?: string;
|
||||
IFLYTEK_API_SECRET?: string;
|
||||
|
||||
// custom template for preprocessing user input
|
||||
DEFAULT_INPUT_TEMPLATE?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,22 +43,6 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
|
||||
}
|
||||
})();
|
||||
|
||||
function getApiKey(keys?: string) {
|
||||
const apiKeyEnvVar = keys ?? "";
|
||||
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||
const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||
const apiKey = apiKeys[randomIndex];
|
||||
if (apiKey) {
|
||||
console.log(
|
||||
`[Server Config] using ${randomIndex + 1} of ${
|
||||
apiKeys.length
|
||||
} api key - ${apiKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export const getServerSideConfig = () => {
|
||||
if (typeof process === "undefined") {
|
||||
throw Error(
|
||||
@@ -115,107 +52,26 @@ export const getServerSideConfig = () => {
|
||||
|
||||
const disableGPT4 = !!process.env.DISABLE_GPT4;
|
||||
let customModels = process.env.CUSTOM_MODELS ?? "";
|
||||
let defaultModel = process.env.DEFAULT_MODEL ?? "";
|
||||
|
||||
if (disableGPT4) {
|
||||
if (customModels) customModels += ",";
|
||||
customModels += DEFAULT_MODELS.filter(
|
||||
(m) =>
|
||||
(m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o")) &&
|
||||
!m.name.startsWith("gpt-4o-mini"),
|
||||
)
|
||||
customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4"))
|
||||
.map((m) => "-" + m.name)
|
||||
.join(",");
|
||||
if (
|
||||
(defaultModel.startsWith("gpt-4") ||
|
||||
defaultModel.startsWith("chatgpt-4o")) &&
|
||||
!defaultModel.startsWith("gpt-4o-mini")
|
||||
)
|
||||
defaultModel = "";
|
||||
}
|
||||
|
||||
const isStability = !!process.env.STABILITY_API_KEY;
|
||||
|
||||
const isAzure = !!process.env.AZURE_URL;
|
||||
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
||||
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
||||
const isTencent = !!process.env.TENCENT_API_KEY;
|
||||
|
||||
const isBaidu = !!process.env.BAIDU_API_KEY;
|
||||
const isBytedance = !!process.env.BYTEDANCE_API_KEY;
|
||||
const isAlibaba = !!process.env.ALIBABA_API_KEY;
|
||||
const isMoonshot = !!process.env.MOONSHOT_API_KEY;
|
||||
const isIflytek = !!process.env.IFLYTEK_API_KEY;
|
||||
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
||||
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||
// const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||
// const apiKey = apiKeys[randomIndex];
|
||||
// console.log(
|
||||
// `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
|
||||
// );
|
||||
|
||||
const allowedWebDevEndpoints = (
|
||||
process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
|
||||
).split(",");
|
||||
|
||||
return {
|
||||
baseUrl: process.env.BASE_URL,
|
||||
apiKey: getApiKey(process.env.OPENAI_API_KEY),
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
openaiOrgId: process.env.OPENAI_ORG_ID,
|
||||
|
||||
isStability,
|
||||
stabilityUrl: process.env.STABILITY_URL,
|
||||
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
|
||||
|
||||
isAzure,
|
||||
azureUrl: process.env.AZURE_URL,
|
||||
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
|
||||
azureApiKey: process.env.AZURE_API_KEY,
|
||||
azureApiVersion: process.env.AZURE_API_VERSION,
|
||||
|
||||
isGoogle,
|
||||
googleApiKey: getApiKey(process.env.GOOGLE_API_KEY),
|
||||
googleUrl: process.env.GOOGLE_URL,
|
||||
|
||||
isAnthropic,
|
||||
anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY),
|
||||
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
||||
anthropicUrl: process.env.ANTHROPIC_URL,
|
||||
|
||||
isBaidu,
|
||||
baiduUrl: process.env.BAIDU_URL,
|
||||
baiduApiKey: getApiKey(process.env.BAIDU_API_KEY),
|
||||
baiduSecretKey: process.env.BAIDU_SECRET_KEY,
|
||||
|
||||
isBytedance,
|
||||
bytedanceApiKey: getApiKey(process.env.BYTEDANCE_API_KEY),
|
||||
bytedanceUrl: process.env.BYTEDANCE_URL,
|
||||
|
||||
isAlibaba,
|
||||
alibabaUrl: process.env.ALIBABA_URL,
|
||||
alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
|
||||
|
||||
isTencent,
|
||||
tencentUrl: process.env.TENCENT_URL,
|
||||
tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY),
|
||||
tencentSecretId: process.env.TENCENT_SECRET_ID,
|
||||
|
||||
isMoonshot,
|
||||
moonshotUrl: process.env.MOONSHOT_URL,
|
||||
moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY),
|
||||
|
||||
isIflytek,
|
||||
iflytekUrl: process.env.IFLYTEK_URL,
|
||||
iflytekApiKey: process.env.IFLYTEK_API_KEY,
|
||||
iflytekApiSecret: process.env.IFLYTEK_API_SECRET,
|
||||
|
||||
cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||
cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
|
||||
cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
|
||||
cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL,
|
||||
|
||||
gtmId: process.env.GTM_ID,
|
||||
gaId: process.env.GA_ID || DEFAULT_GA_ID,
|
||||
|
||||
needCode: ACCESS_CODES.size > 0,
|
||||
code: process.env.CODE,
|
||||
codes: ACCESS_CODES,
|
||||
@@ -228,7 +84,5 @@ export const getServerSideConfig = () => {
|
||||
hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY,
|
||||
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
||||
customModels,
|
||||
defaultModel,
|
||||
allowedWebDevEndpoints,
|
||||
};
|
||||
};
|
||||
|
466
app/constant.ts
466
app/constant.ts
@@ -1,7 +1,9 @@
|
||||
export const OWNER = "ChatGPTNextWeb";
|
||||
import { GPTText } from "./utils/prompts/gpt-text";
|
||||
import { GPTVoice } from "./utils/prompts/gpt-voice";
|
||||
|
||||
export const OWNER = "Yidadaa";
|
||||
export const REPO = "ChatGPT-Next-Web";
|
||||
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
||||
export const PLUGINS_REPO_URL = `https://github.com/${OWNER}/NextChat-Awesome-Plugins`;
|
||||
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
|
||||
export const UPDATE_URL = `${REPO_URL}#keep-updated`;
|
||||
export const RELEASE_URL = `${REPO_URL}/releases`;
|
||||
@@ -9,28 +11,9 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c
|
||||
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
|
||||
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
|
||||
|
||||
export const STABILITY_BASE_URL = "https://api.stability.ai";
|
||||
|
||||
export const DEFAULT_API_HOST = "https://api.nextchat.dev";
|
||||
export const DEFAULT_CORS_HOST = "https://a.nextweb.fun";
|
||||
export const DEFAULT_API_HOST = `${DEFAULT_CORS_HOST}/api/proxy`;
|
||||
export const OPENAI_BASE_URL = "https://api.openai.com";
|
||||
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
|
||||
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/";
|
||||
|
||||
export const BAIDU_BASE_URL = "https://aip.baidubce.com";
|
||||
export const BAIDU_OATUH_URL = `${BAIDU_BASE_URL}/oauth/2.0/token`;
|
||||
|
||||
export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com";
|
||||
|
||||
export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/";
|
||||
|
||||
export const TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
|
||||
|
||||
export const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
|
||||
export const IFLYTEK_BASE_URL = "https://spark-api-open.xf-yun.com";
|
||||
|
||||
export const CACHE_URL_PREFIX = "/api/cache";
|
||||
export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
|
||||
|
||||
export enum Path {
|
||||
Home = "/",
|
||||
@@ -38,28 +21,12 @@ export enum Path {
|
||||
Settings = "/settings",
|
||||
NewChat = "/new-chat",
|
||||
Masks = "/masks",
|
||||
Plugins = "/plugins",
|
||||
Auth = "/auth",
|
||||
Sd = "/sd",
|
||||
SdNew = "/sd-new",
|
||||
Artifacts = "/artifacts",
|
||||
SearchChat = "/search-chat",
|
||||
}
|
||||
|
||||
export enum ApiPath {
|
||||
Cors = "",
|
||||
Azure = "/api/azure",
|
||||
Cors = "/api/cors",
|
||||
OpenAI = "/api/openai",
|
||||
Anthropic = "/api/anthropic",
|
||||
Google = "/api/google",
|
||||
Baidu = "/api/baidu",
|
||||
ByteDance = "/api/bytedance",
|
||||
Alibaba = "/api/alibaba",
|
||||
Tencent = "/api/tencent",
|
||||
Moonshot = "/api/moonshot",
|
||||
Iflytek = "/api/iflytek",
|
||||
Stability = "/api/stability",
|
||||
Artifacts = "/api/artifacts",
|
||||
}
|
||||
|
||||
export enum SlotID {
|
||||
@@ -74,14 +41,12 @@ export enum FileName {
|
||||
|
||||
export enum StoreKey {
|
||||
Chat = "chat-next-web-store",
|
||||
Plugin = "chat-next-web-plugin",
|
||||
Access = "access-control",
|
||||
Config = "app-config",
|
||||
Mask = "mask-store",
|
||||
Prompt = "prompt-store",
|
||||
Update = "chat-update",
|
||||
Sync = "sync",
|
||||
SdList = "sd-list",
|
||||
}
|
||||
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
||||
@@ -103,403 +68,90 @@ export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown";
|
||||
export enum ServiceProvider {
|
||||
OpenAI = "OpenAI",
|
||||
Azure = "Azure",
|
||||
Google = "Google",
|
||||
Anthropic = "Anthropic",
|
||||
Baidu = "Baidu",
|
||||
ByteDance = "ByteDance",
|
||||
Alibaba = "Alibaba",
|
||||
Tencent = "Tencent",
|
||||
Moonshot = "Moonshot",
|
||||
Stability = "Stability",
|
||||
Iflytek = "Iflytek",
|
||||
}
|
||||
|
||||
// Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
|
||||
// BLOCK_NONE will not block any content, and BLOCK_ONLY_HIGH will block only high-risk content.
|
||||
export enum GoogleSafetySettingsThreshold {
|
||||
BLOCK_NONE = "BLOCK_NONE",
|
||||
BLOCK_ONLY_HIGH = "BLOCK_ONLY_HIGH",
|
||||
BLOCK_MEDIUM_AND_ABOVE = "BLOCK_MEDIUM_AND_ABOVE",
|
||||
BLOCK_LOW_AND_ABOVE = "BLOCK_LOW_AND_ABOVE",
|
||||
}
|
||||
|
||||
export enum ModelProvider {
|
||||
Stability = "Stability",
|
||||
GPT = "GPT",
|
||||
GeminiPro = "GeminiPro",
|
||||
Claude = "Claude",
|
||||
Ernie = "Ernie",
|
||||
Doubao = "Doubao",
|
||||
Qwen = "Qwen",
|
||||
Hunyuan = "Hunyuan",
|
||||
Moonshot = "Moonshot",
|
||||
Iflytek = "Iflytek",
|
||||
}
|
||||
|
||||
export const Stability = {
|
||||
GeneratePath: "v2beta/stable-image/generate",
|
||||
ExampleEndpoint: "https://api.stability.ai",
|
||||
};
|
||||
|
||||
export const Anthropic = {
|
||||
ChatPath: "v1/messages",
|
||||
ChatPath1: "v1/complete",
|
||||
ExampleEndpoint: "https://api.anthropic.com",
|
||||
Vision: "2023-06-01",
|
||||
};
|
||||
|
||||
export const OpenaiPath = {
|
||||
ChatPath: "v1/chat/completions",
|
||||
SpeechPath: "v1/audio/speech",
|
||||
ImagePath: "v1/images/generations",
|
||||
UsagePath: "dashboard/billing/usage",
|
||||
SubsPath: "dashboard/billing/subscription",
|
||||
ListModelPath: "v1/models",
|
||||
Speech: "v1/audio/speech",
|
||||
};
|
||||
|
||||
export const Azure = {
|
||||
ChatPath: (deployName: string, apiVersion: string) =>
|
||||
`deployments/${deployName}/chat/completions?api-version=${apiVersion}`,
|
||||
// https://<your_resource_name>.openai.azure.com/openai/deployments/<your_deployment_name>/images/generations?api-version=<api_version>
|
||||
ImagePath: (deployName: string, apiVersion: string) =>
|
||||
`deployments/${deployName}/images/generations?api-version=${apiVersion}`,
|
||||
ExampleEndpoint: "https://{resource-url}/openai",
|
||||
};
|
||||
|
||||
export const Google = {
|
||||
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
|
||||
ChatPath: (modelName: string) =>
|
||||
`v1beta/models/${modelName}:streamGenerateContent`,
|
||||
};
|
||||
|
||||
export const Baidu = {
|
||||
ExampleEndpoint: BAIDU_BASE_URL,
|
||||
ChatPath: (modelName: string) => {
|
||||
let endpoint = modelName;
|
||||
if (modelName === "ernie-4.0-8k") {
|
||||
endpoint = "completions_pro";
|
||||
}
|
||||
if (modelName === "ernie-4.0-8k-preview-0518") {
|
||||
endpoint = "completions_adv_pro";
|
||||
}
|
||||
if (modelName === "ernie-3.5-8k") {
|
||||
endpoint = "completions";
|
||||
}
|
||||
if (modelName === "ernie-speed-8k") {
|
||||
endpoint = "ernie_speed";
|
||||
}
|
||||
return `rpc/2.0/ai_custom/v1/wenxinworkshop/chat/${endpoint}`;
|
||||
},
|
||||
};
|
||||
|
||||
export const ByteDance = {
|
||||
ExampleEndpoint: "https://ark.cn-beijing.volces.com/api/",
|
||||
ChatPath: "api/v3/chat/completions",
|
||||
};
|
||||
|
||||
export const Alibaba = {
|
||||
ExampleEndpoint: ALIBABA_BASE_URL,
|
||||
ChatPath: "v1/services/aigc/text-generation/generation",
|
||||
};
|
||||
|
||||
export const Tencent = {
|
||||
ExampleEndpoint: TENCENT_BASE_URL,
|
||||
};
|
||||
|
||||
export const Moonshot = {
|
||||
ExampleEndpoint: MOONSHOT_BASE_URL,
|
||||
ChatPath: "v1/chat/completions",
|
||||
};
|
||||
|
||||
export const Iflytek = {
|
||||
ExampleEndpoint: IFLYTEK_BASE_URL,
|
||||
ChatPath: "v1/chat/completions",
|
||||
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
|
||||
};
|
||||
|
||||
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
||||
// export const DEFAULT_SYSTEM_TEMPLATE = `
|
||||
// You are ChatGPT, a large language model trained by {{ServiceProvider}}.
|
||||
// Knowledge cutoff: {{cutoff}}
|
||||
// Current model: {{model}}
|
||||
// Current time: {{time}}
|
||||
// Latex inline: $x^2$
|
||||
// Latex block: $$e=mc^2$$
|
||||
// `;
|
||||
export const DEFAULT_SYSTEM_TEMPLATE = `
|
||||
You are ChatGPT, a large language model trained by {{ServiceProvider}}.
|
||||
Knowledge cutoff: {{cutoff}}
|
||||
Current model: {{model}}
|
||||
Current time: {{time}}
|
||||
Latex inline: \\(x^2\\)
|
||||
Latex block: $$e=mc^2$$
|
||||
`;
|
||||
// export const DEFAULT_SYSTEM_TEMPLATE = GPTText;
|
||||
export const DEFAULT_SYSTEM_TEMPLATE = GPTVoice;
|
||||
|
||||
export const SUMMARIZE_MODEL = "gpt-4o-mini";
|
||||
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
|
||||
export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
|
||||
|
||||
export const KnowledgeCutOffDate: Record<string, string> = {
|
||||
default: "2021-09",
|
||||
"gpt-4-turbo": "2023-12",
|
||||
"gpt-4-turbo-2024-04-09": "2023-12",
|
||||
"gpt-4-turbo-preview": "2023-12",
|
||||
"gpt-4o": "2023-10",
|
||||
"gpt-4o-2024-05-13": "2023-10",
|
||||
"gpt-4o-2024-08-06": "2023-10",
|
||||
"chatgpt-4o-latest": "2023-10",
|
||||
"gpt-4o-mini": "2023-10",
|
||||
"gpt-4o-mini-2024-07-18": "2023-10",
|
||||
"gpt-4-1106-preview": "2023-04",
|
||||
"gpt-4-vision-preview": "2023-04",
|
||||
"o1-mini": "2023-10",
|
||||
"o1-preview": "2023-10",
|
||||
// After improvements,
|
||||
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
||||
"gemini-pro": "2023-12",
|
||||
"gemini-pro-vision": "2023-12",
|
||||
};
|
||||
|
||||
export const DEFAULT_TTS_ENGINE = "OpenAI-TTS";
|
||||
export const DEFAULT_TTS_ENGINES = ["OpenAI-TTS", "Edge-TTS"];
|
||||
export const DEFAULT_TTS_MODEL = "tts-1";
|
||||
export const DEFAULT_TTS_VOICE = "alloy";
|
||||
export const DEFAULT_TTS_MODELS = ["tts-1", "tts-1-hd"];
|
||||
export const DEFAULT_TTS_VOICES = [
|
||||
"alloy",
|
||||
"echo",
|
||||
"fable",
|
||||
"onyx",
|
||||
"nova",
|
||||
"shimmer",
|
||||
];
|
||||
|
||||
const openaiModels = [
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo-0125",
|
||||
"gpt-4",
|
||||
"gpt-4-0613",
|
||||
"gpt-4-32k",
|
||||
"gpt-4-32k-0613",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-2024-08-06",
|
||||
"chatgpt-4o-latest",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-4-vision-preview",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-1106-preview",
|
||||
"dall-e-3",
|
||||
"o1-mini",
|
||||
"o1-preview",
|
||||
];
|
||||
|
||||
const googleModels = [
|
||||
"gemini-1.0-pro",
|
||||
"gemini-1.5-pro-latest",
|
||||
"gemini-1.5-flash-latest",
|
||||
"gemini-pro-vision",
|
||||
];
|
||||
|
||||
const anthropicModels = [
|
||||
"claude-instant-1.2",
|
||||
"claude-2.0",
|
||||
"claude-2.1",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
];
|
||||
|
||||
const baiduModels = [
|
||||
"ernie-4.0-turbo-8k",
|
||||
"ernie-4.0-8k",
|
||||
"ernie-4.0-8k-preview",
|
||||
"ernie-4.0-8k-preview-0518",
|
||||
"ernie-4.0-8k-latest",
|
||||
"ernie-3.5-8k",
|
||||
"ernie-3.5-8k-0205",
|
||||
"ernie-speed-128k",
|
||||
"ernie-speed-8k",
|
||||
"ernie-lite-8k",
|
||||
"ernie-tiny-8k",
|
||||
];
|
||||
|
||||
const bytedanceModels = [
|
||||
"Doubao-lite-4k",
|
||||
"Doubao-lite-32k",
|
||||
"Doubao-lite-128k",
|
||||
"Doubao-pro-4k",
|
||||
"Doubao-pro-32k",
|
||||
"Doubao-pro-128k",
|
||||
];
|
||||
|
||||
const alibabaModes = [
|
||||
"qwen-turbo",
|
||||
"qwen-plus",
|
||||
"qwen-max",
|
||||
"qwen-max-0428",
|
||||
"qwen-max-0403",
|
||||
"qwen-max-0107",
|
||||
"qwen-max-longcontext",
|
||||
];
|
||||
|
||||
const tencentModels = [
|
||||
"hunyuan-pro",
|
||||
"hunyuan-standard",
|
||||
"hunyuan-lite",
|
||||
"hunyuan-role",
|
||||
"hunyuan-functioncall",
|
||||
"hunyuan-code",
|
||||
"hunyuan-vision",
|
||||
];
|
||||
|
||||
const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"];
|
||||
|
||||
const iflytekModels = [
|
||||
"general",
|
||||
"generalv3",
|
||||
"pro-128k",
|
||||
"generalv3.5",
|
||||
"4.0Ultra",
|
||||
];
|
||||
|
||||
let seq = 1000; // 内置的模型序号生成器从1000开始
|
||||
export const DEFAULT_MODELS = [
|
||||
...openaiModels.map((name) => ({
|
||||
name,
|
||||
{
|
||||
name: "gpt-4",
|
||||
available: true,
|
||||
sorted: seq++, // Global sequence sort(index)
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致
|
||||
},
|
||||
})),
|
||||
...openaiModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-4-0314",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "azure",
|
||||
providerName: "Azure",
|
||||
providerType: "azure",
|
||||
sorted: 2,
|
||||
},
|
||||
})),
|
||||
...googleModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-4-0613",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "google",
|
||||
providerName: "Google",
|
||||
providerType: "google",
|
||||
sorted: 3,
|
||||
},
|
||||
})),
|
||||
...anthropicModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-4-32k",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "anthropic",
|
||||
sorted: 4,
|
||||
},
|
||||
})),
|
||||
...baiduModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-4-32k-0314",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "baidu",
|
||||
providerName: "Baidu",
|
||||
providerType: "baidu",
|
||||
sorted: 5,
|
||||
},
|
||||
})),
|
||||
...bytedanceModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-4-32k-0613",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "bytedance",
|
||||
providerName: "ByteDance",
|
||||
providerType: "bytedance",
|
||||
sorted: 6,
|
||||
},
|
||||
})),
|
||||
...alibabaModes.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-4-1106-preview",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "alibaba",
|
||||
providerName: "Alibaba",
|
||||
providerType: "alibaba",
|
||||
sorted: 7,
|
||||
},
|
||||
})),
|
||||
...tencentModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-4-vision-preview",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "tencent",
|
||||
providerName: "Tencent",
|
||||
providerType: "tencent",
|
||||
sorted: 8,
|
||||
},
|
||||
})),
|
||||
...moonshotModes.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "moonshot",
|
||||
providerName: "Moonshot",
|
||||
providerType: "moonshot",
|
||||
sorted: 9,
|
||||
},
|
||||
})),
|
||||
...iflytekModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-0301",
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "iflytek",
|
||||
providerName: "Iflytek",
|
||||
providerType: "iflytek",
|
||||
sorted: 10,
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-0613",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-1106",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-16k",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-16k-0613",
|
||||
available: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const CHAT_PAGE_SIZE = 15;
|
||||
export const MAX_RENDER_MSG_COUNT = 45;
|
||||
|
||||
// some famous webdav endpoints
|
||||
export const internalAllowedWebDavEndpoints = [
|
||||
"https://dav.jianguoyun.com/dav/",
|
||||
"https://dav.dropdav.com/",
|
||||
"https://dav.box.com/dav",
|
||||
"https://nanao.teracloud.jp/dav/",
|
||||
"https://bora.teracloud.jp/dav/",
|
||||
"https://webdav.4shared.com/",
|
||||
"https://dav.idrivesync.com",
|
||||
"https://webdav.yandex.com",
|
||||
"https://app.koofr.net/dav/Koofr",
|
||||
];
|
||||
|
||||
export const DEFAULT_GA_ID = "G-89WN60ZK2E";
|
||||
export const PLUGINS = [
|
||||
{ name: "Plugins", path: Path.Plugins },
|
||||
{ name: "Stable Diffusion", path: Path.Sd },
|
||||
{ name: "Search Chat", path: Path.SearchChat },
|
||||
];
|
||||
|
||||
export const SAAS_CHAT_URL = "https://nextchat.dev/chat";
|
||||
|
9
app/global.d.ts
vendored
9
app/global.d.ts
vendored
@@ -19,18 +19,11 @@ declare interface Window {
|
||||
};
|
||||
fs: {
|
||||
writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
|
||||
writeTextFile(path: string, data: string): Promise<void>;
|
||||
};
|
||||
notification: {
|
||||
notification:{
|
||||
requestPermission(): Promise<Permission>;
|
||||
isPermissionGranted(): Promise<boolean>;
|
||||
sendNotification(options: string | Options): void;
|
||||
};
|
||||
http: {
|
||||
fetch<T>(
|
||||
url: string,
|
||||
options?: Record<string, unknown>,
|
||||
): Promise<Response<T>>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@@ -1 +0,0 @@
|
||||
<svg class="icon--SJP_d" width="16" height="16" fill="none" viewBox="0 0 16 16" style="min-width: 16px; min-height: 16px;"><g><path data-follow-fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M5.248 14.444a.625.625 0 0 1-.005-.884l5.068-5.12a.625.625 0 0 0 0-.88L5.243 2.44a.625.625 0 1 1 .889-.88l5.067 5.121c.723.73.723 1.907 0 2.638l-5.067 5.12a.625.625 0 0 1-.884.005Z" fill="currentColor"></path></g></svg>
|
Before Width: | Height: | Size: 426 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="分组 1" style="stroke:#333;stroke-width:1;stroke-opacity:1;stroke-dasharray:0 0" d="M0 5.33667L0.73 3.66667 M4.6675 5.33667L3.9375 3.66667 M0.729167 3.67L2.32917 0L3.93917 3.67 M0.729167 3.66667L3.93917 3.66667" transform="translate(5.666666666666666 5.333333333333333) rotate(0 2.333750009536743 2.6666666666666665)"/><path id="路径 5" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M13.07,5.33C12.45,2.29 9.76,0 6.53,0C3.31,0 0.62,2.29 0,5.33L2,4.67" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.533316666666666 2.6666666666666665)"/><path id="路径 6" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0C0.62,3.04 3.31,5.33 6.53,5.33C9.76,5.33 12.45,3.04 13.07,0L11.33,0.67" transform="translate(1.3333333333333333 9.333333333333332) rotate(0 6.533316666666666 2.6666666666666665)"/></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="分组 1" style="stroke:#333333; stroke-width:1; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.333333333333333) rotate(0 2.333750009536743 2.6666666666666665)" d="M0 5.33667L0.73 3.66667 M4.6675 5.33667L3.9375 3.66667 M0.729167 3.67L2.32917 0L3.93917 3.67 M0.729167 3.66667L3.93917 3.66667 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.533316666666666 2.6666666666666665)" d="M13.07,5.33C12.45,2.29 9.76,0 6.53,0C3.31,0 0.62,2.29 0,5.33L2,4.67 " /><path id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 9.333333333333332) rotate(0 6.533316666666666 2.6666666666666665)" d="M0,0C0.62,3.04 3.31,5.33 6.53,5.33C9.76,5.33 12.45,3.04 13.07,0L11.33,0.67 " /></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user