mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-25 19:33:42 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00b1a9781d | ||
|
|
240d330001 | ||
|
|
4e4431339f | ||
|
|
fa2f8c66d1 | ||
|
|
32f62d70af | ||
|
|
68f0fa917f | ||
|
|
8a14cb19a9 | ||
|
|
3d99965a8f | ||
|
|
4d5a9476b6 | ||
|
|
15d6ed252f | ||
|
|
ecf6cc27d6 | ||
|
|
cadd2558fd | ||
|
|
c3d91bf0cd | ||
|
|
996537d262 | ||
|
|
5ea6206319 | ||
|
|
8c28c408d8 | ||
|
|
c34b8ab919 | ||
|
|
9f4813326c | ||
|
|
9569888b0e | ||
|
|
1a636b0f50 | ||
|
|
48e8c0a194 | ||
|
|
59583e53bd | ||
|
|
bb7422c526 | ||
|
|
c99086447e | ||
|
|
f7074bba8c | ||
|
|
4400392c0c | ||
|
|
4a5465f884 | ||
|
|
37cc87531c | ||
|
|
1074fffe79 | ||
|
|
3d0a98d5d2 | ||
|
|
b3559f99a2 | ||
|
|
51a1d9f92a | ||
|
|
3fc9b91bf1 | ||
|
|
0a8e5d6734 |
@@ -1,20 +1,21 @@
|
|||||||
|
|
||||||
# Your openai api key. (required)
|
# Your openai api key. (required)
|
||||||
OPENAI_API_KEY=sk-xxxx
|
OPENAI_API_KEY=sk-xxxx
|
||||||
|
|
||||||
# Access password, separated by comma. (optional)
|
# Access password, separated by comma. (optional)
|
||||||
CODE=your-password
|
CODE=your-password
|
||||||
|
|
||||||
# You can start service behind a proxy. (optional)
|
# You can start service behind a proxy
|
||||||
PROXY_URL=http://localhost:7890
|
PROXY_URL=http://localhost:7890
|
||||||
|
|
||||||
# (optional)
|
# (optional)
|
||||||
# Default: Empty
|
# Default: Empty
|
||||||
# Google Gemini Pro API key, set if you want to use Google Gemini Pro API.
|
# Googel Gemini Pro API key, set if you want to use Google Gemini Pro API.
|
||||||
GOOGLE_API_KEY=
|
GOOGLE_API_KEY=
|
||||||
|
|
||||||
# (optional)
|
# (optional)
|
||||||
# Default: https://generativelanguage.googleapis.com/
|
# Default: https://generativelanguage.googleapis.com/
|
||||||
# Google Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url.
|
# Googel Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url.
|
||||||
GOOGLE_URL=
|
GOOGLE_URL=
|
||||||
|
|
||||||
# Override openai api request base url. (optional)
|
# Override openai api request base url. (optional)
|
||||||
@@ -46,15 +47,6 @@ ENABLE_BALANCE_QUERY=
|
|||||||
# If you want to disable parse settings from url, set this value to 1.
|
# If you want to disable parse settings from url, set this value to 1.
|
||||||
DISABLE_FAST_LINK=
|
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 claude Api Key.(optional)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
@@ -62,6 +54,8 @@ ANTHROPIC_API_KEY=
|
|||||||
### anthropic claude Api version. (optional)
|
### anthropic claude Api version. (optional)
|
||||||
ANTHROPIC_API_VERSION=
|
ANTHROPIC_API_VERSION=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### anthropic claude Api url (optional)
|
### anthropic claude Api url (optional)
|
||||||
ANTHROPIC_URL=
|
ANTHROPIC_URL=
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"plugins": ["prettier"]
|
"plugins": [
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"legacyDecorators": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["globals.css"]
|
||||||
}
|
}
|
||||||
|
|||||||
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: 在这里添加关于问题的任何其他背景信息。
|
|
||||||
146
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
146
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Describe the bug"
|
||||||
|
- type: textarea
|
||||||
|
id: bug-description
|
||||||
|
attributes:
|
||||||
|
label: "Bug Description"
|
||||||
|
description: "A clear and concise description of what the bug is."
|
||||||
|
placeholder: "Explain the bug..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## To Reproduce"
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: "Steps to Reproduce"
|
||||||
|
description: "Steps to reproduce the behavior:"
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Expected behavior"
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: "Expected Behavior"
|
||||||
|
description: "A clear and concise description of what you expected to happen."
|
||||||
|
placeholder: "Describe what you expected to happen..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Screenshots"
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: "Screenshots"
|
||||||
|
description: "If applicable, add screenshots to help explain your problem."
|
||||||
|
placeholder: "Paste your screenshots here or write 'N/A' if not applicable..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Deployment"
|
||||||
|
- type: checkboxes
|
||||||
|
id: deployment
|
||||||
|
attributes:
|
||||||
|
label: "Deployment Method"
|
||||||
|
description: "Please select the deployment method you are using."
|
||||||
|
options:
|
||||||
|
- label: "Docker"
|
||||||
|
- label: "Vercel"
|
||||||
|
- label: "Server"
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Desktop (please complete the following information):"
|
||||||
|
- type: input
|
||||||
|
id: desktop-os
|
||||||
|
attributes:
|
||||||
|
label: "Desktop OS"
|
||||||
|
description: "Your desktop operating system."
|
||||||
|
placeholder: "e.g., Windows 10"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: desktop-browser
|
||||||
|
attributes:
|
||||||
|
label: "Desktop Browser"
|
||||||
|
description: "Your desktop browser."
|
||||||
|
placeholder: "e.g., Chrome, Safari"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: desktop-version
|
||||||
|
attributes:
|
||||||
|
label: "Desktop Browser Version"
|
||||||
|
description: "Version of your desktop browser."
|
||||||
|
placeholder: "e.g., 89.0"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Smartphone (please complete the following information):"
|
||||||
|
- type: input
|
||||||
|
id: smartphone-device
|
||||||
|
attributes:
|
||||||
|
label: "Smartphone Device"
|
||||||
|
description: "Your smartphone device."
|
||||||
|
placeholder: "e.g., iPhone X"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: smartphone-os
|
||||||
|
attributes:
|
||||||
|
label: "Smartphone OS"
|
||||||
|
description: "Your smartphone operating system."
|
||||||
|
placeholder: "e.g., iOS 14.4"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: smartphone-browser
|
||||||
|
attributes:
|
||||||
|
label: "Smartphone Browser"
|
||||||
|
description: "Your smartphone browser."
|
||||||
|
placeholder: "e.g., Safari"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: smartphone-version
|
||||||
|
attributes:
|
||||||
|
label: "Smartphone Browser Version"
|
||||||
|
description: "Version of your smartphone browser."
|
||||||
|
placeholder: "e.g., 14"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Additional Logs"
|
||||||
|
- type: textarea
|
||||||
|
id: additional-logs
|
||||||
|
attributes:
|
||||||
|
label: "Additional Logs"
|
||||||
|
description: "Add any logs about the problem here."
|
||||||
|
placeholder: "Paste any relevant logs here..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
53
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: "[Feature Request]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Is your feature request related to a problem? Please describe."
|
||||||
|
- type: textarea
|
||||||
|
id: problem-description
|
||||||
|
attributes:
|
||||||
|
label: Problem Description
|
||||||
|
description: "A clear and concise description of what the problem is. Example: I'm always frustrated when [...]"
|
||||||
|
placeholder: "Explain the problem you are facing..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Describe the solution you'd like"
|
||||||
|
- type: textarea
|
||||||
|
id: desired-solution
|
||||||
|
attributes:
|
||||||
|
label: Solution Description
|
||||||
|
description: A clear and concise description of what you want to happen.
|
||||||
|
placeholder: "Describe the solution you'd like..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Describe alternatives you've considered"
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives-considered
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
placeholder: "Describe any alternative solutions or features you've considered..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Additional context"
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
|
placeholder: "Add any other context or screenshots about the feature request here..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
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.
|
|
||||||
-->
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,5 +44,3 @@ dev
|
|||||||
|
|
||||||
*.key
|
*.key
|
||||||
*.key.pub
|
*.key.pub
|
||||||
|
|
||||||
masks.json
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ COPY --from=builder /app/.next/server ./.next/server
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD if [ -n "$PROXY_URL" ]; then \
|
CMD if [ -n "$PROXY_URL" ]; then \
|
||||||
export HOSTNAME="0.0.0.0"; \
|
export HOSTNAME="127.0.0.1"; \
|
||||||
protocol=$(echo $PROXY_URL | cut -d: -f1); \
|
protocol=$(echo $PROXY_URL | cut -d: -f1); \
|
||||||
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
|
host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \
|
||||||
port=$(echo $PROXY_URL | cut -d: -f3); \
|
port=$(echo $PROXY_URL | cut -d: -f3); \
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
141
README.md
141
README.md
@@ -1,8 +1,5 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
<img src="./docs/images/head-cover.png" alt="icon"/>
|
||||||
<a href='#企业版'>
|
|
||||||
<img src="./docs/images/ent.svg" alt="icon"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
|
<h1 align="center">NextChat (ChatGPT Next Web)</h1>
|
||||||
|
|
||||||
@@ -17,51 +14,27 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
|
|||||||
[![MacOS][MacOS-image]][download-url]
|
[![MacOS][MacOS-image]][download-url]
|
||||||
[![Linux][Linux-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://app.nextchat.dev/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Twitter](https://twitter.com/NextChatDev)
|
||||||
|
|
||||||
[网页版](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://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues)
|
||||||
|
|
||||||
[web-url]: https://app.nextchat.dev/
|
[web-url]: https://chatgpt.nextweb.fun
|
||||||
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
|
[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases
|
||||||
[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge
|
[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge
|
||||||
[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows
|
[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows
|
||||||
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
|
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
|
||||||
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
|
[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%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat)
|
||||||
|
|
||||||
[<img src="https://github.com/user-attachments/assets/903482d4-3e87-4134-9af1-f2588fa90659" height="60" width="288" >](https://monica.im/?utm=nxcrp)
|
[](https://zeabur.com/templates/ZBUEFA)
|
||||||
|
|
||||||
|
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
</div>
|
</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
|
## Features
|
||||||
|
|
||||||
- **Deploy for free with one-click** on Vercel in under 1 minute
|
- **Deploy for free with one-click** on Vercel in under 1 minute
|
||||||
@@ -76,12 +49,6 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
|||||||
- Automatically compresses chat history to support long conversations while also saving your tokens
|
- 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
|
- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
- [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
|
||||||
@@ -90,15 +57,10 @@ 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] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||||
- [x] Desktop App with tauri
|
- [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] 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)
|
- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||||
- [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
|
|
||||||
|
|
||||||
## What's New
|
## 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.10.1 support Google Gemini Pro model.
|
||||||
- 🚀 v2.9.11 you can use azure endpoint now.
|
- 🚀 v2.9.11 you can use azure endpoint now.
|
||||||
- 🚀 v2.8 now we have a client that runs across all platforms!
|
- 🚀 v2.8 now we have a client that runs across all platforms!
|
||||||
@@ -127,21 +89,15 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
|
|||||||
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
|
||||||
- [x] 使用 tauri 打包桌面应用
|
- [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] 支持自部署的大语言模型:开箱即用 [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)
|
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
|
||||||
- [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)
|
|
||||||
- [ ] 本地知识库
|
|
||||||
|
|
||||||
## 最新动态
|
## 最新动态
|
||||||
|
|
||||||
- 🚀 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)。
|
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
|
||||||
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
|
||||||
|
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
|
||||||
|
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
|
||||||
|
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
@@ -224,7 +180,7 @@ Specify OpenAI organization ID.
|
|||||||
|
|
||||||
### `AZURE_URL` (optional)
|
### `AZURE_URL` (optional)
|
||||||
|
|
||||||
> Example: https://{azure-resource-url}/openai
|
> Example: https://{azure-resource-url}/openai/deployments/{deploy-name}
|
||||||
|
|
||||||
Azure deploy url.
|
Azure deploy url.
|
||||||
|
|
||||||
@@ -256,46 +212,6 @@ anthropic claude Api version.
|
|||||||
|
|
||||||
anthropic claude Api Url.
|
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)
|
### `HIDE_USER_API_KEY` (optional)
|
||||||
|
|
||||||
> Default: Empty
|
> Default: Empty
|
||||||
@@ -329,36 +245,13 @@ To control custom models, use `+` to add a custom model, use `-` to hide a model
|
|||||||
|
|
||||||
User `-all` to disable all default models, `+all` to enable all default models.
|
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.
|
### `WHITE_WEBDEV_ENDPOINTS` (可选)
|
||||||
> 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:
|
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
|
- Each address must be a complete endpoint
|
||||||
> `https://xxxx/yyy`
|
> `https://xxxx/yyy`
|
||||||
- Multiple addresses are connected by ', '
|
- 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.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
NodeJS >= 18, Docker >= 20
|
NodeJS >= 18, Docker >= 20
|
||||||
|
|||||||
115
README_CN.md
115
README_CN.md
@@ -1,34 +1,22 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
<img src="./docs/images/icon.svg" alt="预览"/>
|
||||||
<a href='#企业版'>
|
|
||||||
<img src="./docs/images/ent.svg" alt="icon"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h1 align="center">NextChat</h1>
|
<h1 align="center">NextChat</h1>
|
||||||
|
|
||||||
一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
|
一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
|
||||||
|
|
||||||
[企业版](#%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)
|
[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N)
|
||||||
|
|
||||||
[<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)
|
||||||
|
|
||||||
|
[](https://zeabur.com/templates/ZBUEFA)
|
||||||
|
|
||||||
|
[](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
</div>
|
</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);
|
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
|
||||||
@@ -37,12 +25,6 @@
|
|||||||
3. 部署完毕后,即可开始使用;
|
3. 部署完毕后,即可开始使用;
|
||||||
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
|
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 保持更新
|
## 保持更新
|
||||||
|
|
||||||
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
|
如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。
|
||||||
@@ -112,7 +94,7 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
|
|||||||
|
|
||||||
### `AZURE_URL` (可选)
|
### `AZURE_URL` (可选)
|
||||||
|
|
||||||
> 形如:https://{azure-resource-url}/openai
|
> 形如:https://{azure-resource-url}/openai/deployments/{deploy-name}
|
||||||
|
|
||||||
Azure 部署地址。
|
Azure 部署地址。
|
||||||
|
|
||||||
@@ -124,68 +106,26 @@ Azure 密钥。
|
|||||||
|
|
||||||
Azure Api 版本,你可以在这里找到:[Azure 文档](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)。
|
Azure Api 版本,你可以在这里找到:[Azure 文档](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)。
|
||||||
|
|
||||||
### `GOOGLE_API_KEY` (可选)
|
### `GOOGLE_API_KEY` (optional)
|
||||||
|
|
||||||
Google Gemini Pro 密钥.
|
Google Gemini Pro 密钥.
|
||||||
|
|
||||||
### `GOOGLE_URL` (可选)
|
### `GOOGLE_URL` (optional)
|
||||||
|
|
||||||
Google Gemini Pro Api Url.
|
Google Gemini Pro Api Url.
|
||||||
|
|
||||||
### `ANTHROPIC_API_KEY` (可选)
|
### `ANTHROPIC_API_KEY` (optional)
|
||||||
|
|
||||||
anthropic claude Api Key.
|
anthropic claude Api Key.
|
||||||
|
|
||||||
### `ANTHROPIC_API_VERSION` (可选)
|
### `ANTHROPIC_API_VERSION` (optional)
|
||||||
|
|
||||||
anthropic claude Api version.
|
anthropic claude Api version.
|
||||||
|
|
||||||
### `ANTHROPIC_URL` (可选)
|
### `ANTHROPIC_URL` (optional)
|
||||||
|
|
||||||
anthropic claude Api 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` (可选)
|
### `HIDE_USER_API_KEY` (可选)
|
||||||
|
|
||||||
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
||||||
@@ -216,31 +156,6 @@ ByteDance Api Url.
|
|||||||
|
|
||||||
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
||||||
|
|
||||||
在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请求地址
|
|
||||||
|
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
点击下方按钮,开始二次开发:
|
点击下方按钮,开始二次开发:
|
||||||
|
|||||||
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, NextResponse } 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,131 +0,0 @@
|
|||||||
import { getServerSideConfig } from "@/app/config/server";
|
|
||||||
import {
|
|
||||||
Alibaba,
|
|
||||||
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";
|
|
||||||
import type { RequestPayload } from "@/app/client/platforms/openai";
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,18 +4,16 @@ import {
|
|||||||
Anthropic,
|
Anthropic,
|
||||||
ApiPath,
|
ApiPath,
|
||||||
DEFAULT_MODELS,
|
DEFAULT_MODELS,
|
||||||
ServiceProvider,
|
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
} from "@/app/constant";
|
} from "@/app/constant";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "./auth";
|
import { auth } from "../../auth";
|
||||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
import { collectModelTable } from "@/app/utils/model";
|
||||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
|
||||||
|
|
||||||
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
||||||
|
|
||||||
export async function handle(
|
async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
@@ -56,6 +54,30 @@ export async function handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const GET = handle;
|
||||||
|
export const POST = handle;
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
export const preferredRegion = [
|
||||||
|
"arn1",
|
||||||
|
"bom1",
|
||||||
|
"cdg1",
|
||||||
|
"cle1",
|
||||||
|
"cpt1",
|
||||||
|
"dub1",
|
||||||
|
"fra1",
|
||||||
|
"gru1",
|
||||||
|
"hnd1",
|
||||||
|
"iad1",
|
||||||
|
"icn1",
|
||||||
|
"kix1",
|
||||||
|
"lhr1",
|
||||||
|
"pdx1",
|
||||||
|
"sfo1",
|
||||||
|
"sin1",
|
||||||
|
"syd1",
|
||||||
|
];
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
async function request(req: NextRequest) {
|
async function request(req: NextRequest) {
|
||||||
@@ -91,8 +113,7 @@ async function request(req: NextRequest) {
|
|||||||
10 * 60 * 1000,
|
10 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
// try rebuild url, when using cloudflare ai gateway in server
|
const fetchUrl = `${baseUrl}${path}`;
|
||||||
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}${path}`);
|
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -115,19 +136,17 @@ async function request(req: NextRequest) {
|
|||||||
// #1815 try to refuse some request to some models
|
// #1815 try to refuse some request to some models
|
||||||
if (serverConfig.customModels && req.body) {
|
if (serverConfig.customModels && req.body) {
|
||||||
try {
|
try {
|
||||||
|
const modelTable = collectModelTable(
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
serverConfig.customModels,
|
||||||
|
);
|
||||||
const clonedBody = await req.text();
|
const clonedBody = await req.text();
|
||||||
fetchOptions.body = clonedBody;
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
// not undefined and is false
|
// not undefined and is false
|
||||||
if (
|
if (modelTable[jsonBody?.model ?? ""].available === false) {
|
||||||
isModelAvailableInServer(
|
|
||||||
serverConfig.customModels,
|
|
||||||
jsonBody?.model as string,
|
|
||||||
ServiceProvider.Anthropic as string,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -142,17 +161,17 @@ async function request(req: NextRequest) {
|
|||||||
console.error(`[Anthropic] filter`, e);
|
console.error(`[Anthropic] filter`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// console.log("[Anthropic request]", fetchOptions.headers, req.method);
|
console.log("[Anthropic request]", fetchOptions.headers, req.method);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(fetchUrl, fetchOptions);
|
const res = await fetch(fetchUrl, fetchOptions);
|
||||||
|
|
||||||
// console.log(
|
console.log(
|
||||||
// "[Anthropic response]",
|
"[Anthropic response]",
|
||||||
// res.status,
|
res.status,
|
||||||
// " ",
|
" ",
|
||||||
// res.headers,
|
res.headers,
|
||||||
// res.url,
|
res.url,
|
||||||
// );
|
);
|
||||||
// to prevent browser prompt for credentials
|
// to prevent browser prompt for credentials
|
||||||
const newHeaders = new Headers(res.headers);
|
const newHeaders = new Headers(res.headers);
|
||||||
newHeaders.delete("www-authenticate");
|
newHeaders.delete("www-authenticate");
|
||||||
@@ -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";
|
|
||||||
@@ -67,34 +67,15 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
|||||||
let systemApiKey: string | undefined;
|
let systemApiKey: string | undefined;
|
||||||
|
|
||||||
switch (modelProvider) {
|
switch (modelProvider) {
|
||||||
case ModelProvider.Stability:
|
|
||||||
systemApiKey = serverConfig.stabilityApiKey;
|
|
||||||
break;
|
|
||||||
case ModelProvider.GeminiPro:
|
case ModelProvider.GeminiPro:
|
||||||
systemApiKey = serverConfig.googleApiKey;
|
systemApiKey = serverConfig.googleApiKey;
|
||||||
break;
|
break;
|
||||||
case ModelProvider.Claude:
|
case ModelProvider.Claude:
|
||||||
systemApiKey = serverConfig.anthropicApiKey;
|
systemApiKey = serverConfig.anthropicApiKey;
|
||||||
break;
|
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:
|
case ModelProvider.GPT:
|
||||||
default:
|
default:
|
||||||
if (req.nextUrl.pathname.includes("azure/deployments")) {
|
if (serverConfig.isAzure) {
|
||||||
systemApiKey = serverConfig.azureApiKey;
|
systemApiKey = serverConfig.azureApiKey;
|
||||||
} else {
|
} else {
|
||||||
systemApiKey = serverConfig.apiKey;
|
systemApiKey = serverConfig.apiKey;
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { getServerSideConfig } from "@/app/config/server";
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
145
app/api/baidu.ts
145
app/api/baidu.ts
@@ -1,145 +0,0 @@
|
|||||||
import { getServerSideConfig } from "@/app/config/server";
|
|
||||||
import {
|
|
||||||
BAIDU_BASE_URL,
|
|
||||||
ApiPath,
|
|
||||||
ModelProvider,
|
|
||||||
BAIDU_OATUH_URL,
|
|
||||||
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,24 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSideConfig } from "../config/server";
|
import { getServerSideConfig } from "../config/server";
|
||||||
import {
|
import { DEFAULT_MODELS, OPENAI_BASE_URL, GEMINI_BASE_URL } from "../constant";
|
||||||
DEFAULT_MODELS,
|
import { collectModelTable } from "../utils/model";
|
||||||
OPENAI_BASE_URL,
|
import { makeAzurePath } from "../azure";
|
||||||
GEMINI_BASE_URL,
|
|
||||||
ServiceProvider,
|
|
||||||
} from "../constant";
|
|
||||||
import { isModelAvailableInServer } from "../utils/model";
|
|
||||||
import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
|
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
export async function requestOpenai(req: NextRequest) {
|
export async function requestOpenai(req: NextRequest) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
const isAzure = req.nextUrl.pathname.includes("azure/deployments");
|
|
||||||
|
|
||||||
var authValue,
|
var authValue,
|
||||||
authHeaderName = "";
|
authHeaderName = "";
|
||||||
if (isAzure) {
|
if (serverConfig.isAzure) {
|
||||||
authValue =
|
authValue =
|
||||||
req.headers
|
req.headers
|
||||||
.get("Authorization")
|
.get("Authorization")
|
||||||
@@ -32,10 +25,13 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
authHeaderName = "Authorization";
|
authHeaderName = "Authorization";
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = `${req.nextUrl.pathname}`.replaceAll("/api/openai/", "");
|
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
||||||
|
"/api/openai/",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
let baseUrl =
|
let baseUrl =
|
||||||
(isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL;
|
serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL;
|
||||||
|
|
||||||
if (!baseUrl.startsWith("http")) {
|
if (!baseUrl.startsWith("http")) {
|
||||||
baseUrl = `https://${baseUrl}`;
|
baseUrl = `https://${baseUrl}`;
|
||||||
@@ -55,46 +51,17 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
10 * 60 * 1000,
|
10 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isAzure) {
|
if (serverConfig.isAzure) {
|
||||||
const azureApiVersion =
|
if (!serverConfig.azureApiVersion) {
|
||||||
req?.nextUrl?.searchParams?.get("api-version") ||
|
return NextResponse.json({
|
||||||
serverConfig.azureApiVersion;
|
error: true,
|
||||||
baseUrl = baseUrl.split("/deployments").shift() as string;
|
message: `missing AZURE_API_VERSION in server env vars`,
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
path = makeAzurePath(path, serverConfig.azureApiVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
|
const fetchUrl = `${baseUrl}/${path}`;
|
||||||
console.log("fetchUrl", fetchUrl);
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -116,24 +83,17 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
// #1815 try to refuse gpt4 request
|
// #1815 try to refuse gpt4 request
|
||||||
if (serverConfig.customModels && req.body) {
|
if (serverConfig.customModels && req.body) {
|
||||||
try {
|
try {
|
||||||
|
const modelTable = collectModelTable(
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
serverConfig.customModels,
|
||||||
|
);
|
||||||
const clonedBody = await req.text();
|
const clonedBody = await req.text();
|
||||||
fetchOptions.body = clonedBody;
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||||
|
|
||||||
// not undefined and is false
|
// not undefined and is false
|
||||||
if (
|
if (modelTable[jsonBody?.model ?? ""].available === false) {
|
||||||
isModelAvailableInServer(
|
|
||||||
serverConfig.customModels,
|
|
||||||
jsonBody?.model as string,
|
|
||||||
ServiceProvider.OpenAI as string,
|
|
||||||
) ||
|
|
||||||
isModelAvailableInServer(
|
|
||||||
serverConfig.customModels,
|
|
||||||
jsonBody?.model as string,
|
|
||||||
ServiceProvider.Azure as string,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -169,6 +129,7 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
// to disable nginx buffering
|
// to disable nginx buffering
|
||||||
newHeaders.set("X-Accel-Buffering", "no");
|
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)
|
// 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
|
// Also, this is to prevent the header from being sent to the client
|
||||||
if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
|
if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
|
||||||
@@ -181,6 +142,7 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
// The browser will try to decode the response with brotli and fail
|
// The browser will try to decode the response with brotli and fail
|
||||||
newHeaders.delete("content-encoding");
|
newHeaders.delete("content-encoding");
|
||||||
|
|
||||||
|
|
||||||
return new Response(res.body, {
|
return new Response(res.body, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "./auth";
|
import { auth } from "../../auth";
|
||||||
import { getServerSideConfig } from "@/app/config/server";
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
import {
|
import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant";
|
||||||
ApiPath,
|
|
||||||
GEMINI_BASE_URL,
|
|
||||||
Google,
|
|
||||||
ModelProvider,
|
|
||||||
} from "@/app/constant";
|
|
||||||
import { prettyObject } from "@/app/utils/format";
|
|
||||||
|
|
||||||
const serverConfig = getServerSideConfig();
|
async function handle(
|
||||||
|
|
||||||
export async function handle(
|
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { provider: string; path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
console.log("[Google Route] params ", params);
|
console.log("[Google Route] params ", params);
|
||||||
|
|
||||||
@@ -21,6 +13,32 @@ export async function handle(
|
|||||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const serverConfig = getServerSideConfig();
|
||||||
|
|
||||||
|
let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL;
|
||||||
|
|
||||||
|
if (!baseUrl.startsWith("http")) {
|
||||||
|
baseUrl = `https://${baseUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.endsWith("/")) {
|
||||||
|
baseUrl = baseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = `${req.nextUrl.pathname}`.replaceAll("/api/google/", "");
|
||||||
|
|
||||||
|
console.log("[Proxy] ", path);
|
||||||
|
console.log("[Base Url]", baseUrl);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
const authResult = auth(req, ModelProvider.GeminiPro);
|
const authResult = auth(req, ModelProvider.GeminiPro);
|
||||||
if (authResult.error) {
|
if (authResult.error) {
|
||||||
return NextResponse.json(authResult, {
|
return NextResponse.json(authResult, {
|
||||||
@@ -31,9 +49,9 @@ export async function handle(
|
|||||||
const bearToken = req.headers.get("Authorization") ?? "";
|
const bearToken = req.headers.get("Authorization") ?? "";
|
||||||
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||||
|
|
||||||
const apiKey = token ? token : serverConfig.googleApiKey;
|
const key = token ? token : serverConfig.googleApiKey;
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!key) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -44,63 +62,8 @@ export async function handle(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const response = await request(req, apiKey);
|
|
||||||
return response;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[Google] ", e);
|
|
||||||
return NextResponse.json(prettyObject(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET = handle;
|
const fetchUrl = `${baseUrl}/${path}?key=${key}`;
|
||||||
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 = {
|
const fetchOptions: RequestInit = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -132,3 +95,22 @@ async function request(req: NextRequest, apiKey: string) {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
];
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { getServerSideConfig } from "@/app/config/server";
|
|
||||||
import {
|
|
||||||
Iflytek,
|
|
||||||
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";
|
|
||||||
import type { RequestPayload } from "@/app/client/platforms/openai";
|
|
||||||
// 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,130 +0,0 @@
|
|||||||
import { getServerSideConfig } from "@/app/config/server";
|
|
||||||
import {
|
|
||||||
Moonshot,
|
|
||||||
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";
|
|
||||||
import type { RequestPayload } from "@/app/client/platforms/openai";
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,8 @@ import { getServerSideConfig } from "@/app/config/server";
|
|||||||
import { ModelProvider, OpenaiPath } from "@/app/constant";
|
import { ModelProvider, OpenaiPath } from "@/app/constant";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "./auth";
|
import { auth } from "../../auth";
|
||||||
import { requestOpenai } from "./common";
|
import { requestOpenai } from "../../common";
|
||||||
|
|
||||||
const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
|
const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
|
||||||
|
|
||||||
@@ -13,16 +13,14 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
|
|||||||
|
|
||||||
if (config.disableGPT4) {
|
if (config.disableGPT4) {
|
||||||
remoteModelRes.data = remoteModelRes.data.filter(
|
remoteModelRes.data = remoteModelRes.data.filter(
|
||||||
(m) =>
|
(m) => !m.id.startsWith("gpt-4"),
|
||||||
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) ||
|
|
||||||
m.id.startsWith("gpt-4o-mini"),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return remoteModelRes;
|
return remoteModelRes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handle(
|
async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
) {
|
) {
|
||||||
@@ -72,3 +70,27 @@ export async function handle(
|
|||||||
return NextResponse.json(prettyObject(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",
|
||||||
|
];
|
||||||
@@ -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,124 +0,0 @@
|
|||||||
import { getServerSideConfig } from "@/app/config/server";
|
|
||||||
import {
|
|
||||||
TENCENT_BASE_URL,
|
|
||||||
ApiPath,
|
|
||||||
ModelProvider,
|
|
||||||
ServiceProvider,
|
|
||||||
Tencent,
|
|
||||||
} 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 { 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,22 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
|
import { STORAGE_KEY, internalWhiteWebDavEndpoints } from "../../../constant";
|
||||||
import { getServerSideConfig } from "@/app/config/server";
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
|
||||||
const config = getServerSideConfig();
|
const config = getServerSideConfig();
|
||||||
|
|
||||||
const mergedAllowedWebDavEndpoints = [
|
const mergedWhiteWebDavEndpoints = [
|
||||||
...internalAllowedWebDavEndpoints,
|
...internalWhiteWebDavEndpoints,
|
||||||
...config.allowedWebDevEndpoints,
|
...config.whiteWebDevEndpoints,
|
||||||
].filter((domain) => Boolean(domain.trim()));
|
].filter((domain) => Boolean(domain.trim()));
|
||||||
|
|
||||||
const normalizeUrl = (url: string) => {
|
|
||||||
try {
|
|
||||||
return new URL(url);
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handle(
|
async function handle(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { path: string[] } },
|
{ params }: { params: { path: string[] } },
|
||||||
@@ -29,23 +21,10 @@ async function handle(
|
|||||||
|
|
||||||
const requestUrl = new URL(req.url);
|
const requestUrl = new URL(req.url);
|
||||||
let endpoint = requestUrl.searchParams.get("endpoint");
|
let endpoint = requestUrl.searchParams.get("endpoint");
|
||||||
let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
|
|
||||||
|
|
||||||
// Validate the endpoint to prevent potential SSRF attacks
|
// Validate the endpoint to prevent potential SSRF attacks
|
||||||
if (
|
if (
|
||||||
!endpoint ||
|
!mergedWhiteWebDavEndpoints.some((white) => endpoint?.startsWith(white))
|
||||||
!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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -66,11 +45,7 @@ async function handle(
|
|||||||
const targetPath = `${endpoint}${endpointPath}`;
|
const targetPath = `${endpoint}${endpointPath}`;
|
||||||
|
|
||||||
// only allow MKCOL, GET, PUT
|
// only allow MKCOL, GET, PUT
|
||||||
if (
|
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
|
||||||
proxy_method !== "MKCOL" &&
|
|
||||||
proxy_method !== "GET" &&
|
|
||||||
proxy_method !== "PUT"
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -83,7 +58,7 @@ async function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for MKCOL request, only allow request ${folder}
|
// for MKCOL request, only allow request ${folder}
|
||||||
if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) {
|
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -96,7 +71,7 @@ async function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for GET request, only allow request ending with fileName
|
// for GET request, only allow request ending with fileName
|
||||||
if (proxy_method === "GET" && !targetPath.endsWith(fileName)) {
|
if (req.method === "GET" && !targetPath.endsWith(fileName)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -109,7 +84,7 @@ async function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for PUT request, only allow request ending with fileName
|
// for PUT request, only allow request ending with fileName
|
||||||
if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) {
|
if (req.method === "PUT" && !targetPath.endsWith(fileName)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
@@ -123,7 +98,7 @@ async function handle(
|
|||||||
|
|
||||||
const targetUrl = targetPath;
|
const targetUrl = targetPath;
|
||||||
|
|
||||||
const method = proxy_method || req.method;
|
const method = req.method;
|
||||||
const shouldNotHaveBody = ["get", "head"].includes(
|
const shouldNotHaveBody = ["get", "head"].includes(
|
||||||
method?.toLowerCase() ?? "",
|
method?.toLowerCase() ?? "",
|
||||||
);
|
);
|
||||||
@@ -148,7 +123,7 @@ async function handle(
|
|||||||
"[Any Proxy]",
|
"[Any Proxy]",
|
||||||
targetUrl,
|
targetUrl,
|
||||||
{
|
{
|
||||||
method: method,
|
method: req.method,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: fetchResult?.status,
|
status: fetchResult?.status,
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -5,23 +5,10 @@ import {
|
|||||||
ModelProvider,
|
ModelProvider,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
} from "../constant";
|
} from "../constant";
|
||||||
import {
|
import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
|
||||||
ChatMessageTool,
|
import { ChatGPTApi } from "./platforms/openai";
|
||||||
ChatMessage,
|
|
||||||
ModelType,
|
|
||||||
useAccessStore,
|
|
||||||
useChatStore,
|
|
||||||
} from "../store";
|
|
||||||
import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
|
|
||||||
import { GeminiProApi } from "./platforms/google";
|
import { GeminiProApi } from "./platforms/google";
|
||||||
import { ClaudeApi } from "./platforms/anthropic";
|
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";
|
|
||||||
|
|
||||||
export const ROLES = ["system", "user", "assistant"] as const;
|
export const ROLES = ["system", "user", "assistant"] as const;
|
||||||
export type MessageRole = (typeof ROLES)[number];
|
export type MessageRole = (typeof ROLES)[number];
|
||||||
|
|
||||||
@@ -43,15 +30,11 @@ export interface RequestMessage {
|
|||||||
|
|
||||||
export interface LLMConfig {
|
export interface LLMConfig {
|
||||||
model: string;
|
model: string;
|
||||||
providerName?: string;
|
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
presence_penalty?: number;
|
presence_penalty?: number;
|
||||||
frequency_penalty?: number;
|
frequency_penalty?: number;
|
||||||
size?: DalleRequestPayload["size"];
|
|
||||||
quality?: DalleRequestPayload["quality"];
|
|
||||||
style?: DalleRequestPayload["style"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatOptions {
|
export interface ChatOptions {
|
||||||
@@ -62,8 +45,6 @@ export interface ChatOptions {
|
|||||||
onFinish: (message: string) => void;
|
onFinish: (message: string) => void;
|
||||||
onError?: (err: Error) => void;
|
onError?: (err: Error) => void;
|
||||||
onController?: (controller: AbortController) => void;
|
onController?: (controller: AbortController) => void;
|
||||||
onBeforeTool?: (tool: ChatMessageTool) => void;
|
|
||||||
onAfterTool?: (tool: ChatMessageTool) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMUsage {
|
export interface LLMUsage {
|
||||||
@@ -73,17 +54,14 @@ export interface LLMUsage {
|
|||||||
|
|
||||||
export interface LLMModel {
|
export interface LLMModel {
|
||||||
name: string;
|
name: string;
|
||||||
displayName?: string;
|
|
||||||
available: boolean;
|
available: boolean;
|
||||||
provider: LLMModelProvider;
|
provider: LLMModelProvider;
|
||||||
sorted: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMModelProvider {
|
export interface LLMModelProvider {
|
||||||
id: string;
|
id: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
providerType: string;
|
providerType: string;
|
||||||
sorted: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class LLMApi {
|
export abstract class LLMApi {
|
||||||
@@ -124,24 +102,6 @@ export class ClientApi {
|
|||||||
case ModelProvider.Claude:
|
case ModelProvider.Claude:
|
||||||
this.llm = new ClaudeApi();
|
this.llm = new ClaudeApi();
|
||||||
break;
|
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:
|
default:
|
||||||
this.llm = new ChatGPTApi();
|
this.llm = new ChatGPTApi();
|
||||||
}
|
}
|
||||||
@@ -193,122 +153,39 @@ export class ClientApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBearerToken(
|
|
||||||
apiKey: string,
|
|
||||||
noBearer: boolean = false,
|
|
||||||
): string {
|
|
||||||
return validString(apiKey)
|
|
||||||
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validString(x: string): boolean {
|
|
||||||
return x?.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getHeaders() {
|
export function getHeaders() {
|
||||||
const accessStore = useAccessStore.getState();
|
const accessStore = useAccessStore.getState();
|
||||||
const chatStore = useChatStore.getState();
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
};
|
};
|
||||||
|
const modelConfig = useChatStore.getState().currentSession().mask.modelConfig;
|
||||||
const clientConfig = getClientConfig();
|
const isGoogle = modelConfig.model.startsWith("gemini");
|
||||||
|
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||||
function getConfig() {
|
const authHeader = isAzure ? "api-key" : "Authorization";
|
||||||
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
|
const apiKey = isGoogle
|
||||||
? accessStore.googleApiKey
|
? accessStore.googleApiKey
|
||||||
: isAzure
|
: isAzure
|
||||||
? accessStore.azureApiKey
|
? 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;
|
: accessStore.openaiApiKey;
|
||||||
return {
|
const clientConfig = getClientConfig();
|
||||||
isGoogle,
|
const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`;
|
||||||
isAzure,
|
const validString = (x: string) => x && x.length > 0;
|
||||||
isAnthropic,
|
|
||||||
isBaidu,
|
|
||||||
isByteDance,
|
|
||||||
isAlibaba,
|
|
||||||
isMoonshot,
|
|
||||||
isIflytek,
|
|
||||||
apiKey,
|
|
||||||
isEnabledAccessControl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// when using google api in app, not set auth header
|
||||||
if (isGoogle && clientConfig?.isApp) return headers;
|
if (!(isGoogle && clientConfig?.isApp)) {
|
||||||
// when using baidu api in app, not set auth header
|
// use user's api key first
|
||||||
if (isBaidu && clientConfig?.isApp) return headers;
|
if (validString(apiKey)) {
|
||||||
|
headers[authHeader] = makeBearer(apiKey);
|
||||||
const authHeader = getAuthHeader();
|
} else if (
|
||||||
|
accessStore.enabledAccessControl() &&
|
||||||
const bearerToken = getBearerToken(apiKey, isAzure || isAnthropic);
|
validString(accessStore.accessCode)
|
||||||
|
) {
|
||||||
if (bearerToken) {
|
headers[authHeader] = makeBearer(
|
||||||
headers[authHeader] = bearerToken;
|
|
||||||
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) {
|
|
||||||
headers["Authorization"] = getBearerToken(
|
|
||||||
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
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,268 +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,
|
|
||||||
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 ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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,14 +1,9 @@
|
|||||||
import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant";
|
import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant";
|
||||||
import { ChatOptions, getHeaders, LLMApi, MultimodalContent } from "../api";
|
import { ChatOptions, LLMApi, MultimodalContent } from "../api";
|
||||||
import {
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
useAccessStore,
|
|
||||||
useAppConfig,
|
|
||||||
useChatStore,
|
|
||||||
usePluginStore,
|
|
||||||
ChatMessageTool,
|
|
||||||
} from "@/app/store";
|
|
||||||
import { getClientConfig } from "@/app/config/client";
|
import { getClientConfig } from "@/app/config/client";
|
||||||
import { DEFAULT_API_HOST } from "@/app/constant";
|
import { DEFAULT_API_HOST } from "@/app/constant";
|
||||||
|
import { RequestMessage } from "@/app/typing";
|
||||||
import {
|
import {
|
||||||
EventStreamContentType,
|
EventStreamContentType,
|
||||||
fetchEventSource,
|
fetchEventSource,
|
||||||
@@ -17,9 +12,6 @@ import {
|
|||||||
import Locale from "../../locales";
|
import Locale from "../../locales";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
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 = {
|
export type MultiBlockContent = {
|
||||||
type: "image" | "text";
|
type: "image" | "text";
|
||||||
@@ -100,12 +92,7 @@ export class ClaudeApi implements LLMApi {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// try get base64image from local cache image_url
|
const messages = [...options.messages];
|
||||||
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"];
|
const keys = ["system", "user"];
|
||||||
|
|
||||||
@@ -174,13 +161,6 @@ export class ClaudeApi implements LLMApi {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prompt[0]?.role === "assistant") {
|
|
||||||
prompt.unshift({
|
|
||||||
role: "user",
|
|
||||||
content: ";",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestBody: AnthropicChatRequest = {
|
const requestBody: AnthropicChatRequest = {
|
||||||
messages: prompt,
|
messages: prompt,
|
||||||
stream: shouldStream,
|
stream: shouldStream,
|
||||||
@@ -198,126 +178,113 @@ export class ClaudeApi implements LLMApi {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
options.onController?.(controller);
|
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 = {
|
const payload = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
...getHeaders(), // get common headers
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"x-api-key": accessStore.anthropicApiKey,
|
||||||
"anthropic-version": accessStore.anthropicApiVersion,
|
"anthropic-version": accessStore.anthropicApiVersion,
|
||||||
// do not send `anthropicApiKey` in browser!!!
|
Authorization: getAuthKey(accessStore.anthropicApiKey),
|
||||||
// Authorization: getAuthKey(accessStore.anthropicApiKey),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (shouldStream) {
|
||||||
|
try {
|
||||||
|
const context = {
|
||||||
|
text: "",
|
||||||
|
finished: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (!context.finished) {
|
||||||
|
options.onFinish(context.text);
|
||||||
|
context.finished = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.signal.onabort = finish;
|
||||||
|
fetchEventSource(path, {
|
||||||
|
...payload,
|
||||||
|
async onopen(res) {
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
console.log("response content type: ", contentType);
|
||||||
|
|
||||||
|
if (contentType?.startsWith("text/plain")) {
|
||||||
|
context.text = await res.clone().text();
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!res.ok ||
|
||||||
|
!res.headers
|
||||||
|
.get("content-type")
|
||||||
|
?.startsWith(EventStreamContentType) ||
|
||||||
|
res.status !== 200
|
||||||
|
) {
|
||||||
|
const responseTexts = [context.text];
|
||||||
|
let extraInfo = await res.clone().text();
|
||||||
|
try {
|
||||||
|
const resJson = await res.clone().json();
|
||||||
|
extraInfo = prettyObject(resJson);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
responseTexts.push(Locale.Error.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraInfo) {
|
||||||
|
responseTexts.push(extraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.text = responseTexts.join("\n\n");
|
||||||
|
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmessage(msg) {
|
||||||
|
let chunkJson:
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
|
type: "content_block_delta" | "content_block_stop";
|
||||||
|
delta?: {
|
||||||
|
type: "text_delta";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
chunkJson = JSON.parse(msg.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Response] parse error", msg.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chunkJson || chunkJson.type === "content_block_stop") {
|
||||||
|
return finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { delta } = chunkJson;
|
||||||
|
if (delta?.text) {
|
||||||
|
context.text += delta.text;
|
||||||
|
options.onUpdate?.(context.text, delta.text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
options.onError?.(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("failed to chat", e);
|
||||||
|
options.onError?.(e as Error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
controller.signal.onabort = () => options.onFinish("");
|
controller.signal.onabort = () => options.onFinish("");
|
||||||
|
|
||||||
@@ -402,8 +369,7 @@ export class ClaudeApi implements LLMApi {
|
|||||||
|
|
||||||
baseUrl = trimEnd(baseUrl, "/");
|
baseUrl = trimEnd(baseUrl, "/");
|
||||||
|
|
||||||
// try rebuild url, when using cloudflare ai gateway in client
|
return `${baseUrl}/${path}`;
|
||||||
return cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,3 +382,27 @@ function trimEnd(s: string, end = " ") {
|
|||||||
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bearer(value: string) {
|
||||||
|
return `Bearer ${value.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthKey(apiKey = "") {
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
let authKey = "";
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
// use user's api key first
|
||||||
|
authKey = bearer(apiKey);
|
||||||
|
} else if (
|
||||||
|
accessStore.enabledAccessControl() &&
|
||||||
|
!isApp &&
|
||||||
|
!!accessStore.accessCode
|
||||||
|
) {
|
||||||
|
// or use access code
|
||||||
|
authKey = bearer(ACCESS_CODE_PREFIX + accessStore.accessCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return authKey;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,281 +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,
|
|
||||||
} 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("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
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,255 +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,
|
|
||||||
} 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 ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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,52 +1,15 @@
|
|||||||
import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||||
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
|
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
|
||||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
import { getClientConfig } from "@/app/config/client";
|
import { getClientConfig } from "@/app/config/client";
|
||||||
import { DEFAULT_API_HOST } from "@/app/constant";
|
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 {
|
import {
|
||||||
getMessageTextContent,
|
getMessageTextContent,
|
||||||
getMessageImages,
|
getMessageImages,
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
} from "@/app/utils";
|
} from "@/app/utils";
|
||||||
import { preProcessImageContent } from "@/app/utils/chat";
|
|
||||||
|
|
||||||
export class GeminiProApi implements LLMApi {
|
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) {
|
extractMessage(res: any) {
|
||||||
console.log("[Response] gemini-pro response: ", res);
|
console.log("[Response] gemini-pro response: ", res);
|
||||||
|
|
||||||
@@ -57,18 +20,12 @@ export class GeminiProApi implements LLMApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
async chat(options: ChatOptions): Promise<void> {
|
async chat(options: ChatOptions): Promise<void> {
|
||||||
const apiClient = this;
|
// const apiClient = this;
|
||||||
|
const visionModel = isVisionModel(options.config.model);
|
||||||
let multimodal = false;
|
let multimodal = false;
|
||||||
|
const messages = options.messages.map((v) => {
|
||||||
// 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) }];
|
let parts: any[] = [{ text: getMessageTextContent(v) }];
|
||||||
if (isVisionModel(options.config.model)) {
|
if (visionModel) {
|
||||||
const images = getMessageImages(v);
|
const images = getMessageImages(v);
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
multimodal = true;
|
multimodal = true;
|
||||||
@@ -108,9 +65,6 @@ export class GeminiProApi implements LLMApi {
|
|||||||
// if (visionModel && messages.length > 1) {
|
// if (visionModel && messages.length > 1) {
|
||||||
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
|
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
|
||||||
// }
|
// }
|
||||||
|
|
||||||
const accessStore = useAccessStore.getState();
|
|
||||||
|
|
||||||
const modelConfig = {
|
const modelConfig = {
|
||||||
...useAppConfig.getState().modelConfig,
|
...useAppConfig.getState().modelConfig,
|
||||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
@@ -132,30 +86,53 @@ export class GeminiProApi implements LLMApi {
|
|||||||
safetySettings: [
|
safetySettings: [
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_HARASSMENT",
|
category: "HARM_CATEGORY_HARASSMENT",
|
||||||
threshold: accessStore.googleSafetySettings,
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_HATE_SPEECH",
|
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||||
threshold: accessStore.googleSafetySettings,
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||||
threshold: accessStore.googleSafetySettings,
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||||
threshold: accessStore.googleSafetySettings,
|
threshold: "BLOCK_ONLY_HIGH",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const accessStore = useAccessStore.getState();
|
||||||
|
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
if (accessStore.useCustomConfig) {
|
||||||
|
baseUrl = accessStore.googleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
|
|
||||||
let shouldStream = !!options.config.stream;
|
let shouldStream = !!options.config.stream;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
options.onController?.(controller);
|
options.onController?.(controller);
|
||||||
try {
|
try {
|
||||||
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
|
let googleChatPath = visionModel
|
||||||
const chatPath = this.path(Google.ChatPath(modelConfig.model));
|
? Google.VisionChatPath(modelConfig.model)
|
||||||
|
: Google.ChatPath(modelConfig.model);
|
||||||
|
let chatPath = this.path(googleChatPath);
|
||||||
|
|
||||||
|
// let baseUrl = accessStore.googleUrl;
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
baseUrl = isApp
|
||||||
|
? DEFAULT_API_HOST + "/api/proxy/google/" + googleChatPath
|
||||||
|
: chatPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isApp) {
|
||||||
|
baseUrl += `?key=${accessStore.googleApiKey}`;
|
||||||
|
}
|
||||||
const chatPayload = {
|
const chatPayload = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(requestPayload),
|
body: JSON.stringify(requestPayload),
|
||||||
@@ -168,17 +145,15 @@ export class GeminiProApi implements LLMApi {
|
|||||||
() => controller.abort(),
|
() => controller.abort(),
|
||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldStream) {
|
if (shouldStream) {
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
let remainText = "";
|
let remainText = "";
|
||||||
let finished = false;
|
let finished = false;
|
||||||
|
|
||||||
|
let existingTexts: string[] = [];
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (!finished) {
|
|
||||||
finished = true;
|
finished = true;
|
||||||
options.onFinish(responseText + remainText);
|
options.onFinish(existingTexts.join(""));
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// animate response to make it looks smooth
|
// animate response to make it looks smooth
|
||||||
@@ -203,83 +178,74 @@ export class GeminiProApi implements LLMApi {
|
|||||||
// start animaion
|
// start animaion
|
||||||
animateResponseText();
|
animateResponseText();
|
||||||
|
|
||||||
controller.signal.onabort = finish;
|
fetch(
|
||||||
|
baseUrl.replace("generateContent", "streamGenerateContent"),
|
||||||
|
chatPayload,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
const reader = response?.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let partialData = "";
|
||||||
|
|
||||||
fetchEventSource(chatPath, {
|
return reader?.read().then(function processText({
|
||||||
...chatPayload,
|
done,
|
||||||
async onopen(res) {
|
value,
|
||||||
clearTimeout(requestTimeoutId);
|
}): Promise<any> {
|
||||||
const contentType = res.headers.get("content-type");
|
if (done) {
|
||||||
console.log(
|
if (response.status !== 200) {
|
||||||
"[Gemini] request response content type: ",
|
try {
|
||||||
contentType,
|
let data = JSON.parse(ensureProperEnding(partialData));
|
||||||
|
if (data && data[0].error) {
|
||||||
|
options.onError?.(new Error(data[0].error.message));
|
||||||
|
} else {
|
||||||
|
options.onError?.(new Error("Request failed"));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
options.onError?.(new Error("Request failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Stream complete");
|
||||||
|
// options.onFinish(responseText + remainText);
|
||||||
|
finished = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
partialData += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(ensureProperEnding(partialData));
|
||||||
|
|
||||||
|
const textArray = data.reduce(
|
||||||
|
(acc: string[], item: { candidates: any[] }) => {
|
||||||
|
const texts = item.candidates.map((candidate) =>
|
||||||
|
candidate.content.parts
|
||||||
|
.map((part: { text: any }) => part.text)
|
||||||
|
.join(""),
|
||||||
|
);
|
||||||
|
return acc.concat(texts);
|
||||||
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (contentType?.startsWith("text/plain")) {
|
if (textArray.length > existingTexts.length) {
|
||||||
responseText = await res.clone().text();
|
const deltaArray = textArray.slice(existingTexts.length);
|
||||||
return finish();
|
existingTexts = textArray;
|
||||||
|
remainText += deltaArray.join("");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.log("[Response Animation] error: ", error,partialData);
|
||||||
|
// skip error message when parsing json
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
return reader.read().then(processText);
|
||||||
!res.ok ||
|
});
|
||||||
!res.headers
|
})
|
||||||
.get("content-type")
|
.catch((error) => {
|
||||||
?.startsWith(EventStreamContentType) ||
|
console.error("Error:", error);
|
||||||
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 {
|
} else {
|
||||||
const res = await fetch(chatPath, chatPayload);
|
const res = await fetch(baseUrl, chatPayload);
|
||||||
clearTimeout(requestTimeoutId);
|
clearTimeout(requestTimeoutId);
|
||||||
const resJson = await res.json();
|
const resJson = await res.json();
|
||||||
if (resJson?.promptFeedback?.blockReason) {
|
if (resJson?.promptFeedback?.blockReason) {
|
||||||
@@ -291,7 +257,7 @@ export class GeminiProApi implements LLMApi {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const message = apiClient.extractMessage(resJson);
|
const message = this.extractMessage(resJson);
|
||||||
options.onFinish(message);
|
options.onFinish(message);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -305,4 +271,14 @@ export class GeminiProApi implements LLMApi {
|
|||||||
async models(): Promise<LLMModel[]> {
|
async models(): Promise<LLMModel[]> {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
path(path: string): string {
|
||||||
|
return "/api/google/" + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProperEnding(str: string) {
|
||||||
|
if (str.startsWith("[") && !str.endsWith("]")) {
|
||||||
|
return str + "]";
|
||||||
|
}
|
||||||
|
return str;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,240 +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 } 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 { OpenAIListModelResponse, 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 ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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,208 +0,0 @@
|
|||||||
"use client";
|
|
||||||
// azure and openai, using same models. so using same LLMApi.
|
|
||||||
import {
|
|
||||||
ApiPath,
|
|
||||||
DEFAULT_API_HOST,
|
|
||||||
DEFAULT_MODELS,
|
|
||||||
Moonshot,
|
|
||||||
REQUEST_TIMEOUT_MS,
|
|
||||||
ServiceProvider,
|
|
||||||
} from "@/app/constant";
|
|
||||||
import {
|
|
||||||
useAccessStore,
|
|
||||||
useAppConfig,
|
|
||||||
useChatStore,
|
|
||||||
ChatMessageTool,
|
|
||||||
usePluginStore,
|
|
||||||
} from "@/app/store";
|
|
||||||
import { collectModelsWithDefaultModel } from "@/app/utils/model";
|
|
||||||
import { preProcessImageContent, stream } from "@/app/utils/chat";
|
|
||||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChatOptions,
|
|
||||||
getHeaders,
|
|
||||||
LLMApi,
|
|
||||||
LLMModel,
|
|
||||||
LLMUsage,
|
|
||||||
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";
|
|
||||||
|
|
||||||
import { OpenAIListModelResponse, 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 ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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,30 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
// azure and openai, using same models. so using same LLMApi.
|
|
||||||
import {
|
import {
|
||||||
ApiPath,
|
ApiPath,
|
||||||
DEFAULT_API_HOST,
|
DEFAULT_API_HOST,
|
||||||
DEFAULT_MODELS,
|
DEFAULT_MODELS,
|
||||||
OpenaiPath,
|
OpenaiPath,
|
||||||
Azure,
|
|
||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
} from "@/app/constant";
|
} from "@/app/constant";
|
||||||
import {
|
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||||
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 {
|
import {
|
||||||
ChatOptions,
|
ChatOptions,
|
||||||
@@ -41,11 +24,11 @@ import {
|
|||||||
} from "@fortaine/fetch-event-source";
|
} from "@fortaine/fetch-event-source";
|
||||||
import { prettyObject } from "@/app/utils/format";
|
import { prettyObject } from "@/app/utils/format";
|
||||||
import { getClientConfig } from "@/app/config/client";
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { makeAzurePath } from "@/app/azure";
|
||||||
import {
|
import {
|
||||||
getMessageTextContent,
|
getMessageTextContent,
|
||||||
getMessageImages,
|
getMessageImages,
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
isDalle3 as _isDalle3,
|
|
||||||
} from "@/app/utils";
|
} from "@/app/utils";
|
||||||
|
|
||||||
export interface OpenAIListModelResponse {
|
export interface OpenAIListModelResponse {
|
||||||
@@ -57,7 +40,7 @@ export interface OpenAIListModelResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestPayload {
|
interface RequestPayload {
|
||||||
messages: {
|
messages: {
|
||||||
role: "system" | "user" | "assistant";
|
role: "system" | "user" | "assistant";
|
||||||
content: string | MultimodalContent[];
|
content: string | MultimodalContent[];
|
||||||
@@ -71,16 +54,6 @@ export interface RequestPayload {
|
|||||||
max_tokens?: 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 {
|
export class ChatGPTApi implements LLMApi {
|
||||||
private disableListModels = true;
|
private disableListModels = true;
|
||||||
|
|
||||||
@@ -89,112 +62,68 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
|
|
||||||
let baseUrl = "";
|
let baseUrl = "";
|
||||||
|
|
||||||
const isAzure = path.includes("deployments");
|
|
||||||
if (accessStore.useCustomConfig) {
|
if (accessStore.useCustomConfig) {
|
||||||
|
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||||
|
|
||||||
if (isAzure && !accessStore.isValidAzure()) {
|
if (isAzure && !accessStore.isValidAzure()) {
|
||||||
throw Error(
|
throw Error(
|
||||||
"incomplete azure config, please check it in your settings page",
|
"incomplete azure config, please check it in your settings page",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAzure) {
|
||||||
|
path = makeAzurePath(path, accessStore.azureApiVersion);
|
||||||
|
}
|
||||||
|
|
||||||
baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseUrl.length === 0) {
|
if (baseUrl.length === 0) {
|
||||||
const isApp = !!getClientConfig()?.isApp;
|
const isApp = !!getClientConfig()?.isApp;
|
||||||
const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI;
|
baseUrl = isApp
|
||||||
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
|
? DEFAULT_API_HOST + "/proxy" + ApiPath.OpenAI
|
||||||
|
: ApiPath.OpenAI;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseUrl.endsWith("/")) {
|
if (baseUrl.endsWith("/")) {
|
||||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||||
}
|
}
|
||||||
if (
|
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) {
|
||||||
!baseUrl.startsWith("http") &&
|
|
||||||
!isAzure &&
|
|
||||||
!baseUrl.startsWith(ApiPath.OpenAI)
|
|
||||||
) {
|
|
||||||
baseUrl = "https://" + baseUrl;
|
baseUrl = "https://" + baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||||
|
|
||||||
// try rebuild url, when using cloudflare ai gateway in client
|
return [baseUrl, path].join("/");
|
||||||
return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractMessage(res: any) {
|
extractMessage(res: any) {
|
||||||
if (res.error) {
|
return res.choices?.at(0)?.message?.content ?? "";
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(options: ChatOptions) {
|
async chat(options: ChatOptions) {
|
||||||
|
const visionModel = isVisionModel(options.config.model);
|
||||||
|
const messages = options.messages.map((v) => ({
|
||||||
|
role: v.role,
|
||||||
|
content: visionModel ? v.content : getMessageTextContent(v),
|
||||||
|
}));
|
||||||
|
|
||||||
const modelConfig = {
|
const modelConfig = {
|
||||||
...useAppConfig.getState().modelConfig,
|
...useAppConfig.getState().modelConfig,
|
||||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||||
...{
|
...{
|
||||||
model: options.config.model,
|
model: options.config.model,
|
||||||
providerName: options.config.providerName,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let requestPayload: RequestPayload | DalleRequestPayload;
|
const requestPayload: RequestPayload = {
|
||||||
|
|
||||||
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,
|
messages,
|
||||||
stream: !isO1 ? options.config.stream : false,
|
stream: options.config.stream,
|
||||||
model: modelConfig.model,
|
model: modelConfig.model,
|
||||||
temperature: !isO1 ? modelConfig.temperature : 1,
|
temperature: modelConfig.temperature,
|
||||||
presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
|
presence_penalty: modelConfig.presence_penalty,
|
||||||
frequency_penalty: !isO1 ? modelConfig.frequency_penalty : 0,
|
frequency_penalty: modelConfig.frequency_penalty,
|
||||||
top_p: !isO1 ? modelConfig.top_p : 1,
|
top_p: modelConfig.top_p,
|
||||||
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
|
// 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.
|
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
|
||||||
};
|
};
|
||||||
@@ -203,109 +132,15 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
if (visionModel) {
|
if (visionModel) {
|
||||||
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[Request] openai payload: ", requestPayload);
|
console.log("[Request] openai payload: ", requestPayload);
|
||||||
|
|
||||||
const shouldStream = !isDalle3 && !!options.config.stream && !isO1;
|
const shouldStream = !!options.config.stream;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
options.onController?.(controller);
|
options.onController?.(controller);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let chatPath = "";
|
const chatPath = this.path(OpenaiPath.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 = {
|
const chatPayload = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(requestPayload),
|
body: JSON.stringify(requestPayload),
|
||||||
@@ -316,14 +151,138 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
// make a fetch request
|
// make a fetch request
|
||||||
const requestTimeoutId = setTimeout(
|
const requestTimeoutId = setTimeout(
|
||||||
() => controller.abort(),
|
() => controller.abort(),
|
||||||
isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
|
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(
|
||||||
|
"[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);
|
||||||
|
const choices = json.choices as Array<{
|
||||||
|
delta: { content: string };
|
||||||
|
}>;
|
||||||
|
const delta = choices[0]?.delta?.content;
|
||||||
|
const textmoderation = json?.prompt_filter_results;
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
remainText += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
textmoderation &&
|
||||||
|
textmoderation.length > 0 &&
|
||||||
|
ServiceProvider.Azure
|
||||||
|
) {
|
||||||
|
const contentFilterResults =
|
||||||
|
textmoderation[0]?.content_filter_results;
|
||||||
|
console.log(
|
||||||
|
`[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`,
|
||||||
|
contentFilterResults,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Request] parse error", text, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose() {
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
onerror(e) {
|
||||||
|
options.onError?.(e);
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const res = await fetch(chatPath, chatPayload);
|
const res = await fetch(chatPath, chatPayload);
|
||||||
clearTimeout(requestTimeoutId);
|
clearTimeout(requestTimeoutId);
|
||||||
|
|
||||||
const resJson = await res.json();
|
const resJson = await res.json();
|
||||||
const message = await this.extractMessage(resJson);
|
const message = this.extractMessage(resJson);
|
||||||
options.onFinish(message);
|
options.onFinish(message);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -410,26 +369,20 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resJson = (await res.json()) as OpenAIListModelResponse;
|
const resJson = (await res.json()) as OpenAIListModelResponse;
|
||||||
const chatModels = resJson.data?.filter(
|
const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
|
||||||
(m) => m.id.startsWith("gpt-") || m.id.startsWith("chatgpt-"),
|
|
||||||
);
|
|
||||||
console.log("[Models]", chatModels);
|
console.log("[Models]", chatModels);
|
||||||
|
|
||||||
if (!chatModels) {
|
if (!chatModels) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
//由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场
|
|
||||||
let seq = 1000; //同 Constant.ts 中的排序保持一致
|
|
||||||
return chatModels.map((m) => ({
|
return chatModels.map((m) => ({
|
||||||
name: m.id,
|
name: m.id,
|
||||||
available: true,
|
available: true,
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
provider: {
|
||||||
id: "openai",
|
id: "openai",
|
||||||
providerName: "OpenAI",
|
providerName: "OpenAI",
|
||||||
providerType: "openai",
|
providerType: "openai",
|
||||||
sorted: 1,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,268 +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,
|
|
||||||
} 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 ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,16 +41,13 @@ interface ChatCommands {
|
|||||||
del?: Command;
|
del?: Command;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compatible with Chinese colon character ":"
|
export const ChatCommandPrefix = ":";
|
||||||
export const ChatCommandPrefix = /^[::]/;
|
|
||||||
|
|
||||||
export function useChatCommand(commands: ChatCommands = {}) {
|
export function useChatCommand(commands: ChatCommands = {}) {
|
||||||
function extract(userInput: string) {
|
function extract(userInput: string) {
|
||||||
const match = userInput.match(ChatCommandPrefix);
|
return (
|
||||||
if (match) {
|
userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput
|
||||||
return userInput.slice(1) as keyof ChatCommands;
|
) as keyof ChatCommands;
|
||||||
}
|
|
||||||
return userInput as keyof ChatCommands;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(userInput: string) {
|
function search(userInput: string) {
|
||||||
@@ -60,7 +57,7 @@ export function useChatCommand(commands: ChatCommands = {}) {
|
|||||||
.filter((c) => c.startsWith(input))
|
.filter((c) => c.startsWith(input))
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
title: desc[c as keyof ChatCommands],
|
title: desc[c as keyof ChatCommands],
|
||||||
content: ":" + c,
|
content: ChatCommandPrefix + c,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
app/components/ActionsBar/index.tsx
Normal file
123
app/components/ActionsBar/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { isValidElement } from "react";
|
||||||
|
|
||||||
|
type IconMap = {
|
||||||
|
active?: JSX.Element;
|
||||||
|
inactive?: JSX.Element;
|
||||||
|
mobileActive?: JSX.Element;
|
||||||
|
mobileInactive?: JSX.Element;
|
||||||
|
};
|
||||||
|
interface Action {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
icons: JSX.Element | IconMap;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
activeClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Groups = {
|
||||||
|
normal: string[][];
|
||||||
|
mobile: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ActionsBarProps {
|
||||||
|
actionsShema: Action[];
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
selected?: string;
|
||||||
|
groups: string[][] | Groups;
|
||||||
|
className?: string;
|
||||||
|
inMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionsBar(props: ActionsBarProps) {
|
||||||
|
const { actionsShema, onSelect, selected, groups, className, inMobile } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
const handlerClick =
|
||||||
|
(action: Action) => (e: { preventDefault: () => void }) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (action.onClick) {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
if (selected !== action.id) {
|
||||||
|
onSelect?.(action.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const internalGroup = Array.isArray(groups)
|
||||||
|
? groups
|
||||||
|
: inMobile
|
||||||
|
? groups.mobile
|
||||||
|
: groups.normal;
|
||||||
|
|
||||||
|
const content = internalGroup.reduce((res, group, ind, arr) => {
|
||||||
|
res.push(
|
||||||
|
...group.map((i) => {
|
||||||
|
const action = actionsShema.find((a) => a.id === i);
|
||||||
|
if (!action) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icons } = action;
|
||||||
|
let activeIcon, inactiveIcon, mobileActiveIcon, mobileInactiveIcon;
|
||||||
|
|
||||||
|
if (isValidElement(icons)) {
|
||||||
|
activeIcon = icons;
|
||||||
|
inactiveIcon = icons;
|
||||||
|
mobileActiveIcon = icons;
|
||||||
|
mobileInactiveIcon = icons;
|
||||||
|
} else {
|
||||||
|
activeIcon = (icons as IconMap).active;
|
||||||
|
inactiveIcon = (icons as IconMap).inactive;
|
||||||
|
mobileActiveIcon = (icons as IconMap).mobileActive;
|
||||||
|
mobileInactiveIcon = (icons as IconMap).mobileInactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inMobile) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
className={` cursor-pointer shrink-1 grow-0 basis-[${
|
||||||
|
(100 - 1) / arr.length
|
||||||
|
}%] flex flex-col items-center justify-around gap-0.5 py-1.5
|
||||||
|
${
|
||||||
|
selected === action.id
|
||||||
|
? "text-text-sidebar-tab-mobile-active"
|
||||||
|
: "text-text-sidebar-tab-mobile-inactive"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={handlerClick(action)}
|
||||||
|
>
|
||||||
|
{selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
|
||||||
|
<div className=" leading-3 text-sm-mobile-tab h-3 font-common w-[100%]">
|
||||||
|
{action.title || " "}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
className={`cursor-pointer p-3 ${
|
||||||
|
selected === action.id
|
||||||
|
? `!bg-actions-bar-btn-default ${action.activeClassName}`
|
||||||
|
: "bg-transparent"
|
||||||
|
} rounded-md items-center ${
|
||||||
|
action.className
|
||||||
|
} transition duration-300 ease-in-out`}
|
||||||
|
onClick={handlerClick(action)}
|
||||||
|
>
|
||||||
|
{selected === action.id ? activeIcon : inactiveIcon}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (ind < arr.length - 1) {
|
||||||
|
res.push(<div key={String(ind)} className=" flex-1"></div>);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}, [] as JSX.Element[]);
|
||||||
|
|
||||||
|
return <div className={`flex items-center ${className} `}>{content}</div>;
|
||||||
|
}
|
||||||
78
app/components/Btn/index.tsx
Normal file
78
app/components/Btn/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export type ButtonType = "primary" | "danger" | null;
|
||||||
|
|
||||||
|
export interface BtnProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
prefixIcon?: JSX.Element;
|
||||||
|
type?: ButtonType;
|
||||||
|
text?: React.ReactNode;
|
||||||
|
bordered?: boolean;
|
||||||
|
shadow?: boolean;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Btn(props: BtnProps) {
|
||||||
|
const {
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
disabled,
|
||||||
|
tabIndex,
|
||||||
|
autoFocus,
|
||||||
|
prefixIcon,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
let btnClassName;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "primary":
|
||||||
|
btnClassName = `${
|
||||||
|
disabled
|
||||||
|
? "bg-primary-btn-disabled dark:opacity-30 dark:text-primary-btn-disabled-dark"
|
||||||
|
: "bg-primary-btn shadow-btn"
|
||||||
|
} text-text-btn-primary `;
|
||||||
|
break;
|
||||||
|
case "danger":
|
||||||
|
btnClassName = `bg-danger-btn text-text-btn-danger hover:bg-hovered-danger-btn`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
btnClassName = `bg-default-btn text-text-btn-default hover:bg-hovered-btn`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
${className ?? ""}
|
||||||
|
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn transition-all duration-300 select-none
|
||||||
|
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
|
||||||
|
${btnClassName}
|
||||||
|
follow-parent-svg
|
||||||
|
`}
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
role="button"
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
>
|
||||||
|
{prefixIcon && (
|
||||||
|
<div className={`flex items-center justify-center`}>{prefixIcon}</div>
|
||||||
|
)}
|
||||||
|
{text && (
|
||||||
|
<div className={`font-common text-sm-title leading-4 line-clamp-1`}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{icon && <div className={`flex items-center justify-center`}>{icon}</div>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/components/Card/index.tsx
Normal file
32
app/components/Card/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface CardProps {
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card(props: CardProps) {
|
||||||
|
const { className, children, title } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
capitalize !font-semibold text-sm-mobile font-weight-setting-card-title text-text-card-title
|
||||||
|
mb-3
|
||||||
|
|
||||||
|
ml-3
|
||||||
|
md:ml-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`px-4 py-1 rounded-lg bg-card ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/components/GlobalLoading/index.tsx
Normal file
18
app/components/GlobalLoading/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import BotIcon from "@/app/icons/bot.svg";
|
||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
|
||||||
|
export default function GloablLoading({
|
||||||
|
noLogo,
|
||||||
|
}: {
|
||||||
|
noLogo?: boolean;
|
||||||
|
useSkeleton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-center items-center w-[100%] h-[100%]`}
|
||||||
|
>
|
||||||
|
{!noLogo && <BotIcon />}
|
||||||
|
<LoadingIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/components/HoverPopover/index.tsx
Normal file
39
app/components/HoverPopover/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export interface PopoverProps {
|
||||||
|
content?: JSX.Element | string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
arrowClassName?: string;
|
||||||
|
popoverClassName?: string;
|
||||||
|
noArrow?: boolean;
|
||||||
|
align?: ComponentProps<typeof HoverCard.Content>["align"];
|
||||||
|
openDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HoverPopover(props: PopoverProps) {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
arrowClassName,
|
||||||
|
popoverClassName,
|
||||||
|
noArrow = false,
|
||||||
|
align,
|
||||||
|
openDelay = 300,
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<HoverCard.Root openDelay={openDelay}>
|
||||||
|
<HoverCard.Trigger asChild>{children}</HoverCard.Trigger>
|
||||||
|
<HoverCard.Portal>
|
||||||
|
<HoverCard.Content
|
||||||
|
className={`${popoverClassName}`}
|
||||||
|
sideOffset={5}
|
||||||
|
align={align}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
{!noArrow && <HoverCard.Arrow className={`${arrowClassName}`} />}
|
||||||
|
</HoverCard.Content>
|
||||||
|
</HoverCard.Portal>
|
||||||
|
</HoverCard.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
app/components/Imgs/index.tsx
Normal file
42
app/components/Imgs/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { CSSProperties } from "react";
|
||||||
|
import { getMessageImages } from "@/app/utils";
|
||||||
|
import { RequestMessage } from "@/app/client/api";
|
||||||
|
|
||||||
|
interface ImgsProps {
|
||||||
|
message: RequestMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Imgs(props: ImgsProps) {
|
||||||
|
const { message } = props;
|
||||||
|
const imgSrcs = getMessageImages(message);
|
||||||
|
|
||||||
|
if (imgSrcs.length < 1) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgVars = {
|
||||||
|
"--imgs-width": `calc(var(--max-message-width) - ${
|
||||||
|
imgSrcs.length - 1
|
||||||
|
}*0.25rem)`,
|
||||||
|
"--img-width": `calc(var(--imgs-width)/ ${imgSrcs.length})`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-[100%] mt-[0.625rem] flex gap-1`}
|
||||||
|
style={imgVars as CSSProperties}
|
||||||
|
>
|
||||||
|
{imgSrcs.map((image, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 min-w-[var(--img-width)] pb-[var(--img-width)] object-cover bg-cover bg-no-repeat bg-center box-border rounded-chat-img"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${image})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
app/components/Input/index.tsx
Normal file
88
app/components/Input/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import PasswordVisible from "@/app/icons/passwordVisible.svg";
|
||||||
|
import PasswordInvisible from "@/app/icons/passwordInvisible.svg";
|
||||||
|
import {
|
||||||
|
DetailedHTMLProps,
|
||||||
|
InputHTMLAttributes,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import List, { ListContext } from "@/app/components/List";
|
||||||
|
|
||||||
|
export interface CommonInputProps
|
||||||
|
extends Omit<
|
||||||
|
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||||
|
"onChange" | "type" | "value"
|
||||||
|
> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberInputProps {
|
||||||
|
onChange?: (v: number) => void;
|
||||||
|
type?: "number";
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextInputProps {
|
||||||
|
onChange?: (v: string) => void;
|
||||||
|
type?: "text" | "password";
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputProps {
|
||||||
|
onChange?: ((v: string) => void) | ((v: number) => void);
|
||||||
|
type?: "text" | "password" | "number";
|
||||||
|
value?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input(
|
||||||
|
props: CommonInputProps & NumberInputProps,
|
||||||
|
): JSX.Element;
|
||||||
|
export default function Input(
|
||||||
|
props: CommonInputProps & TextInputProps,
|
||||||
|
): JSX.Element;
|
||||||
|
export default function Input(props: CommonInputProps & InputProps) {
|
||||||
|
const { value, type = "text", onChange, className, ...rest } = props;
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
const { inputClassName } = useContext(ListContext);
|
||||||
|
|
||||||
|
const internalType = (show && "text") || type;
|
||||||
|
|
||||||
|
const { update, handleValidate } = useContext(List.ListContext);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
update?.({ type: "input" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
handleValidate?.(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={` group/input w-[100%] rounded-chat-input bg-input transition-colors duration-300 ease-in-out flex gap-3 items-center px-3 py-2 ${className} hover:bg-select-hover ${inputClassName}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
className=" overflow-hidden text-text-input text-sm-title leading-input outline-none flex-1 group-hover/input:bg-input-input-ele-hover"
|
||||||
|
type={internalType}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (type === "number") {
|
||||||
|
const v = e.currentTarget.valueAsNumber;
|
||||||
|
(onChange as NumberInputProps["onChange"])?.(v);
|
||||||
|
} else {
|
||||||
|
const v = e.currentTarget.value;
|
||||||
|
(onChange as TextInputProps["onChange"])?.(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{type == "password" && (
|
||||||
|
<div className=" cursor-pointer" onClick={() => setShow((pre) => !pre)}>
|
||||||
|
{show ? <PasswordVisible /> : <PasswordInvisible />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
app/components/List/index.tsx
Normal file
157
app/components/List/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
interface WidgetStyle {
|
||||||
|
selectClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
rangeClassName?: string;
|
||||||
|
switchClassName?: string;
|
||||||
|
inputNextLine?: boolean;
|
||||||
|
rangeNextLine?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChildrenMeta {
|
||||||
|
type?: "unknown" | "input" | "range";
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListProps {
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
id?: string;
|
||||||
|
isMobileScreen?: boolean;
|
||||||
|
widgetStyle?: WidgetStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error =
|
||||||
|
| {
|
||||||
|
error: true;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
error: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ListItemProps {
|
||||||
|
title: string;
|
||||||
|
subTitle?: string;
|
||||||
|
children?: JSX.Element | JSX.Element[];
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
nextline?: boolean;
|
||||||
|
validator?: (v: any) => Error | Promise<Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListContext = createContext<
|
||||||
|
{
|
||||||
|
isMobileScreen?: boolean;
|
||||||
|
update?: (m: ChildrenMeta) => void;
|
||||||
|
handleValidate?: (v: any) => void;
|
||||||
|
} & WidgetStyle
|
||||||
|
>({ isMobileScreen: false });
|
||||||
|
|
||||||
|
export function ListItem(props: ListItemProps) {
|
||||||
|
const {
|
||||||
|
className = "",
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
subTitle,
|
||||||
|
children,
|
||||||
|
nextline,
|
||||||
|
validator,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const context = useContext(ListContext);
|
||||||
|
|
||||||
|
const [childrenMeta, setMeta] = useState<ChildrenMeta>({});
|
||||||
|
|
||||||
|
const { inputNextLine, rangeNextLine } = context;
|
||||||
|
|
||||||
|
const { type, error } = childrenMeta;
|
||||||
|
|
||||||
|
let internalNextLine;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "input":
|
||||||
|
internalNextLine = !!(nextline || inputNextLine);
|
||||||
|
break;
|
||||||
|
case "range":
|
||||||
|
internalNextLine = !!(nextline || rangeNextLine);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
internalNextLine = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = useCallback((m: ChildrenMeta) => {
|
||||||
|
setMeta((pre) => ({ ...pre, ...m }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValidate = useCallback((v: any) => {
|
||||||
|
const insideValidator = validator || (() => {});
|
||||||
|
|
||||||
|
Promise.resolve(insideValidator(v)).then((result) => {
|
||||||
|
if (result && result.error) {
|
||||||
|
return update({
|
||||||
|
error: result.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
update({
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative after:h-[0.5px] after:bottom-0 after:w-[100%] after:left-0 after:absolute last:after:hidden after:bg-list-item-divider ${
|
||||||
|
internalNextLine ? "" : "flex gap-3"
|
||||||
|
} justify-between items-center px-0 py-2 md:py-3 ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className={`flex-1 flex flex-col justify-start gap-1`}>
|
||||||
|
<div className=" font-common text-sm-mobile font-weight-[500] line-clamp-1 text-text-list-title">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subTitle && (
|
||||||
|
<div className={` text-sm text-text-list-subtitle`}>{subTitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ListContext.Provider value={{ ...context, update, handleValidate }}>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
internalNextLine ? "mt-[0.625rem]" : "max-w-[70%]"
|
||||||
|
} flex flex-col items-center justify-center`}
|
||||||
|
>
|
||||||
|
<div>{children}</div>
|
||||||
|
{!!error && (
|
||||||
|
<div className="text-text-btn-danger text-sm-mobile-tab mt-[0.3125rem] flex items-start w-[100%]">
|
||||||
|
<div className="">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ListContext.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function List(props: ListProps) {
|
||||||
|
const { className, children, id, widgetStyle } = props;
|
||||||
|
const { isMobileScreen } = useContext(ListContext);
|
||||||
|
return (
|
||||||
|
<ListContext.Provider value={{ isMobileScreen, ...widgetStyle }}>
|
||||||
|
<div className={`flex flex-col w-[100%] ${className}`} id={id}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ListContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List.ListItem = ListItem;
|
||||||
|
List.ListContext = ListContext;
|
||||||
|
|
||||||
|
export default List;
|
||||||
35
app/components/Loading/index.tsx
Normal file
35
app/components/Loading/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import BotIcon from "@/app/icons/bot.svg";
|
||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
|
||||||
|
import { getCSSVar } from "@/app/utils";
|
||||||
|
|
||||||
|
export default function Loading({
|
||||||
|
noLogo,
|
||||||
|
useSkeleton = true,
|
||||||
|
}: {
|
||||||
|
noLogo?: boolean;
|
||||||
|
useSkeleton?: boolean;
|
||||||
|
}) {
|
||||||
|
let theme;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
theme = getCSSVar("--default-container-bg");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex flex-col justify-center items-center w-[100%]
|
||||||
|
h-[100%]
|
||||||
|
md:my-2.5
|
||||||
|
md:ml-1
|
||||||
|
md:mr-2.5
|
||||||
|
md:rounded-md
|
||||||
|
md:h-[calc(100%-1.25rem)]
|
||||||
|
`}
|
||||||
|
style={{ background: useSkeleton ? theme : "" }}
|
||||||
|
>
|
||||||
|
{!noLogo && <BotIcon />}
|
||||||
|
<LoadingIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
app/components/MenuLayout/index.tsx
Normal file
115
app/components/MenuLayout/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_SIDEBAR_WIDTH,
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
Path,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import useDrag from "@/app/hooks/useDrag";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
import { updateGlobalCSSVars } from "@/app/utils/client";
|
||||||
|
import { ComponentType, useRef, useState } from "react";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
|
||||||
|
export interface MenuWrapperInspectProps {
|
||||||
|
setExternalProps?: (v: Record<string, any>) => void;
|
||||||
|
setShowPanel?: (v: boolean) => void;
|
||||||
|
showPanel?: boolean;
|
||||||
|
[k: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MenuLayout<
|
||||||
|
ListComponentProps extends MenuWrapperInspectProps,
|
||||||
|
PanelComponentProps extends MenuWrapperInspectProps,
|
||||||
|
>(
|
||||||
|
ListComponent: ComponentType<ListComponentProps>,
|
||||||
|
PanelComponent: ComponentType<PanelComponentProps>,
|
||||||
|
) {
|
||||||
|
return function MenuHood(props: ListComponentProps & PanelComponentProps) {
|
||||||
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
|
const [externalProps, setExternalProps] = useState({});
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
|
||||||
|
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||||
|
// drag side bar
|
||||||
|
const { onDragStart } = useDrag({
|
||||||
|
customToggle: () => {
|
||||||
|
config.update((config) => {
|
||||||
|
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
customDragMove: (nextWidth: number) => {
|
||||||
|
const { menuWidth } = updateGlobalCSSVars(nextWidth);
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--menu-width",
|
||||||
|
`${menuWidth}px`,
|
||||||
|
);
|
||||||
|
config.update((config) => {
|
||||||
|
config.sidebarWidth = nextWidth;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
customLimit: (x: number) =>
|
||||||
|
Math.max(
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-[100%] relative bg-center
|
||||||
|
max-md:h-[100%]
|
||||||
|
md:flex md:my-2.5
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex flex-col px-6
|
||||||
|
h-[100%]
|
||||||
|
max-md:w-[100%] max-md:px-4 max-md:pb-4 max-md:flex-1
|
||||||
|
md:relative md:basis-sidebar md:pb-6 md:rounded-md md:bg-menu
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<ListComponent
|
||||||
|
{...props}
|
||||||
|
setShowPanel={setShowPanel}
|
||||||
|
setExternalProps={setExternalProps}
|
||||||
|
showPanel={showPanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isMobileScreen && (
|
||||||
|
<div
|
||||||
|
className={`group/menu-dragger cursor-col-resize w-[0.25rem] flex items-center justify-center`}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
startDragWidth.current = config.sidebarWidth;
|
||||||
|
onDragStart(e as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-[2px] opacity-0 group-hover/menu-dragger:opacity-100 bg-menu-dragger h-[100%] rounded-[2px]">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
md:flex-1 md:h-[100%] md:w-page
|
||||||
|
max-md:transition-all max-md:duration-300 max-md:absolute max-md:top-0 max-md:max-h-[100vh] max-md:w-[100%] ${
|
||||||
|
showPanel ? "max-md:left-0" : "max-md:left-[101%]"
|
||||||
|
} max-md:z-10
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PanelComponent
|
||||||
|
{...props}
|
||||||
|
{...externalProps}
|
||||||
|
setShowPanel={setShowPanel}
|
||||||
|
setExternalProps={setExternalProps}
|
||||||
|
showPanel={showPanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
352
app/components/Modal/index.tsx
Normal file
352
app/components/Modal/index.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import React, { useLayoutEffect, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import * as AlertDialog from "@radix-ui/react-alert-dialog";
|
||||||
|
import Btn, { BtnProps } from "@/app/components/Btn";
|
||||||
|
|
||||||
|
import Warning from "@/app/icons/warning.svg";
|
||||||
|
import Close from "@/app/icons/closeIcon.svg";
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
onOk?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
okText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
okBtnProps?: BtnProps;
|
||||||
|
cancelBtnProps?: BtnProps;
|
||||||
|
content?:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((handlers: { close: () => void }) => JSX.Element);
|
||||||
|
title?: React.ReactNode;
|
||||||
|
visible?: boolean;
|
||||||
|
noFooter?: boolean;
|
||||||
|
noHeader?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
closeble?: boolean;
|
||||||
|
type?: "modal" | "bottom-drawer";
|
||||||
|
headerBordered?: boolean;
|
||||||
|
modelClassName?: string;
|
||||||
|
onOpen?: (v: boolean) => void;
|
||||||
|
maskCloseble?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarnProps
|
||||||
|
extends Omit<
|
||||||
|
ModalProps,
|
||||||
|
| "closeble"
|
||||||
|
| "isMobile"
|
||||||
|
| "noHeader"
|
||||||
|
| "noFooter"
|
||||||
|
| "onOk"
|
||||||
|
| "okBtnProps"
|
||||||
|
| "cancelBtnProps"
|
||||||
|
| "content"
|
||||||
|
> {
|
||||||
|
onOk?: () => Promise<void> | void;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerProps
|
||||||
|
extends Omit<ModalProps, "visible" | "onOk" | "onCancel"> {
|
||||||
|
children: JSX.Element;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseZIndex = 150;
|
||||||
|
|
||||||
|
const Modal = (props: ModalProps) => {
|
||||||
|
const {
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
okText,
|
||||||
|
cancelText,
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
visible,
|
||||||
|
noFooter,
|
||||||
|
noHeader,
|
||||||
|
closeble = true,
|
||||||
|
okBtnProps,
|
||||||
|
cancelBtnProps,
|
||||||
|
type = "modal",
|
||||||
|
headerBordered,
|
||||||
|
modelClassName,
|
||||||
|
onOpen,
|
||||||
|
maskCloseble = true,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(!!visible);
|
||||||
|
|
||||||
|
const mergeOpen = visible ?? open;
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
onCancel?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = () => {
|
||||||
|
setOpen(false);
|
||||||
|
onOk?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onOpen?.(mergeOpen);
|
||||||
|
}, [mergeOpen]);
|
||||||
|
|
||||||
|
let layoutClassName = "";
|
||||||
|
let panelClassName = "";
|
||||||
|
let titleClassName = "";
|
||||||
|
let footerClassName = "";
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "bottom-drawer":
|
||||||
|
layoutClassName = "fixed inset-0 flex flex-col w-[100%] bottom-0";
|
||||||
|
panelClassName =
|
||||||
|
"rounded-t-chat-model-select overflow-y-auto overflow-x-hidden";
|
||||||
|
titleClassName = "px-4 py-3";
|
||||||
|
footerClassName = "absolute w-[100%]";
|
||||||
|
break;
|
||||||
|
case "modal":
|
||||||
|
default:
|
||||||
|
layoutClassName =
|
||||||
|
"fixed inset-0 flex flex-col item-start top-0 left-[50vw] translate-x-[-50%] max-sm:w-modal-modal-type-mobile";
|
||||||
|
panelClassName = "rounded-lg px-6 sm:w-modal-modal-type";
|
||||||
|
titleClassName = "py-6 max-sm:pb-3";
|
||||||
|
footerClassName = "py-6";
|
||||||
|
}
|
||||||
|
const btnCommonClass = "px-4 py-2.5 rounded-md max-sm:flex-1";
|
||||||
|
const { className: okBtnClass } = okBtnProps || {};
|
||||||
|
const { className: cancelBtnClass } = cancelBtnProps || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog.Root open={mergeOpen} onOpenChange={setOpen}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay
|
||||||
|
className="bg-modal-mask fixed inset-0 animate-mask "
|
||||||
|
style={{ zIndex: baseZIndex - 1 }}
|
||||||
|
onClick={() => {
|
||||||
|
if (maskCloseble) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AlertDialog.Content
|
||||||
|
className={`
|
||||||
|
${layoutClassName}
|
||||||
|
`}
|
||||||
|
style={{ zIndex: baseZIndex - 1 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
if (maskCloseble) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col flex-0
|
||||||
|
bg-moda-panel text-modal-panel
|
||||||
|
${modelClassName}
|
||||||
|
${panelClassName}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{!noHeader && (
|
||||||
|
<AlertDialog.Title
|
||||||
|
className={`
|
||||||
|
flex items-center justify-between gap-3 font-common
|
||||||
|
md:text-chat-header-title md:font-bold md:leading-5
|
||||||
|
${
|
||||||
|
headerBordered
|
||||||
|
? " border-b border-modal-header-bottom"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${titleClassName}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 justify-start flex-1 items-center text-text-modal-title text-chat-header-title">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{closeble && (
|
||||||
|
<div
|
||||||
|
className="items-center"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDialog.Title>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-hidden text-text-modal-content text-sm-title">
|
||||||
|
{typeof content === "function"
|
||||||
|
? content({
|
||||||
|
close: () => {
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: content}
|
||||||
|
</div>
|
||||||
|
{!noFooter && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex gap-3 sm:justify-end max-sm:justify-between
|
||||||
|
${footerClassName}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<Btn
|
||||||
|
{...cancelBtnProps}
|
||||||
|
onClick={() => handleClose()}
|
||||||
|
text={cancelText}
|
||||||
|
className={`${btnCommonClass} ${cancelBtnClass}`}
|
||||||
|
/>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<Btn
|
||||||
|
{...okBtnProps}
|
||||||
|
onClick={handleOk}
|
||||||
|
text={okText}
|
||||||
|
className={`${btnCommonClass} ${okBtnClass}`}
|
||||||
|
/>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{type === "modal" && (
|
||||||
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
if (maskCloseble) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Warn = ({
|
||||||
|
title,
|
||||||
|
onOk,
|
||||||
|
visible,
|
||||||
|
content,
|
||||||
|
...props
|
||||||
|
}: WarnProps) => {
|
||||||
|
const [internalVisible, setVisible] = useState(visible);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
{...props}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<Warning />
|
||||||
|
{title}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<AlertDialog.Description
|
||||||
|
className={`
|
||||||
|
font-common font-normal
|
||||||
|
md:text-sm-title md:leading-[158%]
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</AlertDialog.Description>
|
||||||
|
}
|
||||||
|
closeble={false}
|
||||||
|
onOk={() => {
|
||||||
|
const toDo = onOk?.();
|
||||||
|
if (toDo instanceof Promise) {
|
||||||
|
toDo.then(() => {
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
visible={internalVisible}
|
||||||
|
okBtnProps={{
|
||||||
|
className: `bg-delete-chat-ok-btn text-text-delete-chat-ok-btn `,
|
||||||
|
}}
|
||||||
|
cancelBtnProps={{
|
||||||
|
className: `bg-delete-chat-cancel-btn border border-delete-chat-cancel-btn text-text-delete-chat-cancel-btn`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.id = "confirm-root";
|
||||||
|
div.style.height = "0px";
|
||||||
|
document.body.appendChild(div);
|
||||||
|
|
||||||
|
Modal.warn = (props: Omit<WarnProps, "visible" | "onCancel" | "onOk">) => {
|
||||||
|
const root = createRoot(div);
|
||||||
|
const closeModal = () => {
|
||||||
|
root.unmount();
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
root.render(
|
||||||
|
<Warn
|
||||||
|
{...props}
|
||||||
|
visible={true}
|
||||||
|
onCancel={() => {
|
||||||
|
closeModal();
|
||||||
|
resolve(false);
|
||||||
|
}}
|
||||||
|
onOk={() => {
|
||||||
|
closeModal();
|
||||||
|
resolve(true);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Trigger = (props: TriggerProps) => {
|
||||||
|
const { children, className, content, ...rest } = props;
|
||||||
|
|
||||||
|
const [internalVisible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
onClick={() => {
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<Modal
|
||||||
|
{...rest}
|
||||||
|
visible={internalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
content={
|
||||||
|
typeof content === "function"
|
||||||
|
? content({
|
||||||
|
close: () => {
|
||||||
|
setVisible(false);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: content
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Modal.Trigger = Trigger;
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
352
app/components/Popover/index.tsx
Normal file
352
app/components/Popover/index.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import useRelativePosition from "@/app/hooks/useRelativePosition";
|
||||||
|
import {
|
||||||
|
RefObject,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
const ArrowIcon = ({ sibling }: { sibling: RefObject<HTMLDivElement> }) => {
|
||||||
|
const [color, setColor] = useState<string>("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (sibling.current) {
|
||||||
|
const { backgroundColor } = window.getComputedStyle(sibling.current);
|
||||||
|
setColor(backgroundColor);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="6"
|
||||||
|
viewBox="0 0 16 6"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16 0H0C1.28058 0 2.50871 0.508709 3.41421 1.41421L6.91 4.91C7.51199 5.51199 8.48801 5.51199 9.09 4.91L12.5858 1.41421C13.4913 0.508708 14.7194 0 16 0Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseZIndex = 100;
|
||||||
|
const popoverRootName = "popoverRoot";
|
||||||
|
let popoverRoot = document.querySelector(
|
||||||
|
`#${popoverRootName}`,
|
||||||
|
) as HTMLDivElement;
|
||||||
|
if (!popoverRoot) {
|
||||||
|
popoverRoot = document.createElement("div");
|
||||||
|
document.body.appendChild(popoverRoot);
|
||||||
|
popoverRoot.style.height = "0px";
|
||||||
|
popoverRoot.style.width = "100%";
|
||||||
|
popoverRoot.style.position = "fixed";
|
||||||
|
popoverRoot.style.bottom = "0";
|
||||||
|
popoverRoot.style.zIndex = "10000";
|
||||||
|
popoverRoot.id = "popover-root";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopoverProps {
|
||||||
|
content?: JSX.Element | string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
show?: boolean;
|
||||||
|
onShow?: (v: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
popoverClassName?: string;
|
||||||
|
trigger?: "hover" | "click";
|
||||||
|
placement?: "t" | "lt" | "rt" | "lb" | "rb" | "b" | "l" | "r";
|
||||||
|
noArrow?: boolean;
|
||||||
|
delayClose?: number;
|
||||||
|
useGlobalRoot?: boolean;
|
||||||
|
getPopoverPanelRef?: (ref: RefObject<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Popover(props: PopoverProps) {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
show,
|
||||||
|
onShow,
|
||||||
|
className,
|
||||||
|
popoverClassName,
|
||||||
|
trigger = "hover",
|
||||||
|
placement = "t",
|
||||||
|
noArrow = false,
|
||||||
|
delayClose = 0,
|
||||||
|
useGlobalRoot,
|
||||||
|
getPopoverPanelRef,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [internalShow, setShow] = useState(false);
|
||||||
|
const { position, getRelativePosition } = useRelativePosition({
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const popoverCommonClass = `absolute p-2 box-border`;
|
||||||
|
|
||||||
|
const mergedShow = show ?? internalShow;
|
||||||
|
|
||||||
|
const { arrowClassName, placementStyle, placementClassName } = useMemo(() => {
|
||||||
|
const arrowCommonClassName = `${
|
||||||
|
noArrow ? "hidden" : ""
|
||||||
|
} absolute z-10 left-[50%] translate-x-[calc(-50%)]`;
|
||||||
|
|
||||||
|
let defaultTopPlacement = true; // when users dont config 't' or 'b'
|
||||||
|
|
||||||
|
const {
|
||||||
|
distanceToBottomBoundary = 0,
|
||||||
|
distanceToLeftBoundary = 0,
|
||||||
|
distanceToRightBoundary = -10000,
|
||||||
|
distanceToTopBoundary = 0,
|
||||||
|
targetH = 0,
|
||||||
|
targetW = 0,
|
||||||
|
} = position?.poi || {};
|
||||||
|
|
||||||
|
if (distanceToBottomBoundary > distanceToTopBoundary) {
|
||||||
|
defaultTopPlacement = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placements = {
|
||||||
|
lt: {
|
||||||
|
placementStyle: {
|
||||||
|
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||||
|
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||||
|
placementClassName: "bottom-[calc(100%+0.5rem)] left-[calc(-2%)]",
|
||||||
|
},
|
||||||
|
lb: {
|
||||||
|
placementStyle: {
|
||||||
|
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||||
|
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||||
|
placementClassName: "top-[calc(100%+0.5rem)] left-[calc(-2%)]",
|
||||||
|
},
|
||||||
|
rt: {
|
||||||
|
placementStyle: {
|
||||||
|
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||||
|
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||||
|
placementClassName: "bottom-[calc(100%+0.5rem)] right-[calc(-2%)]",
|
||||||
|
},
|
||||||
|
rb: {
|
||||||
|
placementStyle: {
|
||||||
|
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||||
|
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||||
|
placementClassName: "top-[calc(100%+0.5rem)] right-[calc(-2%)]",
|
||||||
|
},
|
||||||
|
t: {
|
||||||
|
placementStyle: {
|
||||||
|
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||||
|
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||||
|
placementClassName:
|
||||||
|
"bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
placementStyle: {
|
||||||
|
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||||
|
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
},
|
||||||
|
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||||
|
placementClassName:
|
||||||
|
"top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyle = () => {
|
||||||
|
if (["l", "r"].includes(placement)) {
|
||||||
|
return placements[
|
||||||
|
`${placement}${defaultTopPlacement ? "t" : "b"}` as
|
||||||
|
| "lt"
|
||||||
|
| "lb"
|
||||||
|
| "rb"
|
||||||
|
| "rt"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return placements[placement as Exclude<typeof placement, "l" | "r">];
|
||||||
|
};
|
||||||
|
|
||||||
|
return getStyle();
|
||||||
|
}, [Object.values(position?.poi || {})]);
|
||||||
|
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const closeTimer = useRef<number>(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
getPopoverPanelRef?.(popoverRef);
|
||||||
|
onShow?.(internalShow);
|
||||||
|
}, [internalShow]);
|
||||||
|
|
||||||
|
if (trigger === "click") {
|
||||||
|
const handleOpen = (e: { currentTarget: any }) => {
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
setShow(true);
|
||||||
|
getRelativePosition(e.currentTarget, "");
|
||||||
|
window.document.documentElement.style.overflow = "hidden";
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
if (delayClose) {
|
||||||
|
closeTimer.current = window.setTimeout(() => {
|
||||||
|
setShow(false);
|
||||||
|
}, delayClose);
|
||||||
|
} else {
|
||||||
|
setShow(false);
|
||||||
|
}
|
||||||
|
window.document.documentElement.style.overflow = "auto";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ${className}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!mergedShow) {
|
||||||
|
handleOpen(e);
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{mergedShow && (
|
||||||
|
<>
|
||||||
|
{!noArrow && (
|
||||||
|
<div className={`${arrowClassName}`}>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className={`${popoverCommonClass} ${popoverClassName} cursor-pointer overflow-auto`}
|
||||||
|
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||||
|
ref={popoverRef}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className=" fixed w-[100vw] h-[100vh] right-0 bottom-0"
|
||||||
|
style={{ zIndex: baseZIndex }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useGlobalRoot) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ${className}`}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
onShow?.(true);
|
||||||
|
setShow(true);
|
||||||
|
getRelativePosition(e.currentTarget, "");
|
||||||
|
window.document.documentElement.style.overflow = "hidden";
|
||||||
|
}}
|
||||||
|
onPointerLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (delayClose) {
|
||||||
|
closeTimer.current = window.setTimeout(() => {
|
||||||
|
onShow?.(false);
|
||||||
|
setShow(false);
|
||||||
|
}, delayClose);
|
||||||
|
} else {
|
||||||
|
onShow?.(false);
|
||||||
|
setShow(false);
|
||||||
|
}
|
||||||
|
window.document.documentElement.style.overflow = "auto";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{mergedShow && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
noArrow ? "opacity-0" : ""
|
||||||
|
} bg-inherit ${arrowClassName}`}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className={` whitespace-nowrap ${popoverCommonClass} ${popoverClassName} cursor-pointer`}
|
||||||
|
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||||
|
ref={popoverRef}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group/popover relative ${className}`}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
getRelativePosition(e.currentTarget, "");
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
hidden group-hover/popover:block
|
||||||
|
${noArrow ? "opacity-0" : ""}
|
||||||
|
bg-inherit
|
||||||
|
${arrowClassName}
|
||||||
|
`}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
hidden group-hover/popover:block whitespace-nowrap
|
||||||
|
${popoverCommonClass}
|
||||||
|
${placementClassName}
|
||||||
|
${popoverClassName}
|
||||||
|
`}
|
||||||
|
ref={popoverRef}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/components/Screen/index.tsx
Normal file
71
app/components/Screen/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useMemo, ReactNode } from "react";
|
||||||
|
import { Path, SIDEBAR_ID, SlotID } from "@/app/constant";
|
||||||
|
import { getLang } from "@/app/locales";
|
||||||
|
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
import { isIOS } from "@/app/utils";
|
||||||
|
import useListenWinResize from "@/app/hooks/useListenWinResize";
|
||||||
|
|
||||||
|
interface ScreenProps {
|
||||||
|
children: ReactNode;
|
||||||
|
noAuth: ReactNode;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Screen(props: ScreenProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isAuth = location.pathname === Path.Auth;
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
const isIOSMobile = useMemo(
|
||||||
|
() => isIOS() && isMobileScreen,
|
||||||
|
[isMobileScreen],
|
||||||
|
);
|
||||||
|
|
||||||
|
useListenWinResize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex h-[100%] w-[100%] bg-center
|
||||||
|
max-md:relative max-md:flex-col-reverse max-md:bg-global-mobile
|
||||||
|
md:overflow-hidden md:bg-global
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
direction: getLang() === "ar" ? "rtl" : "ltr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAuth ? (
|
||||||
|
props.noAuth
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
max-md:absolute max-md:w-[100%] max-md:bottom-0 max-md:z-10
|
||||||
|
md:flex-0 md:overflow-hidden
|
||||||
|
`}
|
||||||
|
id={SIDEBAR_ID}
|
||||||
|
>
|
||||||
|
{props.sidebar}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
h-[100%]
|
||||||
|
max-md:w-[100%]
|
||||||
|
md:flex-1 md:min-w-0 md:overflow-hidden md:flex
|
||||||
|
`}
|
||||||
|
id={SlotID.AppBody}
|
||||||
|
style={{
|
||||||
|
// #3016 disable transition on ios mobile screen
|
||||||
|
transition: isIOSMobile ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/components/Search/index.module.scss
Normal file
24
app/components/Search/index.module.scss
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
max-width: 460px;
|
||||||
|
height: 50px;
|
||||||
|
padding: 16px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--Light-Text-Black, #18182A);
|
||||||
|
background: var(--light-opacity-white-70, rgba(255, 255, 255, 0.70));
|
||||||
|
box-shadow: 0px 8px 40px 0px rgba(60, 68, 255, 0.12);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
flex: 0 0;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
height: 18px;
|
||||||
|
flex: 1 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/components/Search/index.tsx
Normal file
30
app/components/Search/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import styles from "./index.module.scss";
|
||||||
|
import SearchIcon from "@/app/icons/search.svg";
|
||||||
|
|
||||||
|
export interface SearchProps {
|
||||||
|
value?: string;
|
||||||
|
onSearch?: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Search = (props: SearchProps) => {
|
||||||
|
const { placeholder = "", value, onSearch } = props;
|
||||||
|
return (
|
||||||
|
<div className={styles["search"]}>
|
||||||
|
<div className={styles["icon"]}>
|
||||||
|
<SearchIcon />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className={styles["input"]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch?.(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
||||||
118
app/components/Select/index.tsx
Normal file
118
app/components/Select/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import SelectIcon from "@/app/icons/downArrowIcon.svg";
|
||||||
|
import Popover from "@/app/components/Popover";
|
||||||
|
import React, { useContext, useMemo, useRef } from "react";
|
||||||
|
import useRelativePosition, {
|
||||||
|
Orientation,
|
||||||
|
} from "@/app/hooks/useRelativePosition";
|
||||||
|
import List from "@/app/components/List";
|
||||||
|
|
||||||
|
import Selected from "@/app/icons/selectedIcon.svg";
|
||||||
|
|
||||||
|
export type Option<Value> = {
|
||||||
|
value: Value;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SearchProps<Value> {
|
||||||
|
value?: string;
|
||||||
|
onSelect?: (v: Value) => void;
|
||||||
|
options?: Option<Value>[];
|
||||||
|
inMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = <Value extends number | string>(props: SearchProps<Value>) => {
|
||||||
|
const { value, onSelect, options = [], inMobile } = props;
|
||||||
|
|
||||||
|
const { isMobileScreen, selectClassName } = useContext(List.ListContext);
|
||||||
|
|
||||||
|
const optionsRef = useRef<Option<Value>[]>([]);
|
||||||
|
optionsRef.current = options;
|
||||||
|
const selectedOption = useMemo(
|
||||||
|
() => optionsRef.current.find((o) => o.value === value),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { position, getRelativePosition } = useRelativePosition({
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let headerH = 100;
|
||||||
|
let baseH = position?.poi.distanceToBottomBoundary || 0;
|
||||||
|
if (isMobileScreen) {
|
||||||
|
headerH = 60;
|
||||||
|
}
|
||||||
|
if (position?.poi.relativePosition[1] === Orientation.bottom) {
|
||||||
|
baseH = position?.poi.distanceToTopBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxHeight = `${baseH - headerH}px`;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={` flex flex-col gap-1 overflow-y-auto overflow-x-hidden`}
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
{options?.map((o) => (
|
||||||
|
<div
|
||||||
|
key={o.value}
|
||||||
|
className={`
|
||||||
|
flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect?.(o.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 flex-1 follow-parent-svg text-text-select-option">
|
||||||
|
{!!o.icon && <div className="flex items-center">{o.icon}</div>}
|
||||||
|
<div className={`flex-1 text-text-select-option`}>{o.label}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
selectedOption?.value === o.value ? "opacity-100" : "opacity-0"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Selected />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
trigger="click"
|
||||||
|
noArrow
|
||||||
|
placement={
|
||||||
|
position?.poi.relativePosition[1] !== Orientation.bottom ? "rb" : "rt"
|
||||||
|
}
|
||||||
|
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-select-popover-panel"
|
||||||
|
onShow={(e) => {
|
||||||
|
getRelativePosition(contentRef.current!, "");
|
||||||
|
}}
|
||||||
|
className={selectClassName}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 py-2 px-3 bg-select rounded-action-btn font-common text-sm-title cursor-pointer hover:bg-select-hover transition duration-300 ease-in-out`}
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 flex-1 follow-parent-svg text-text-select`}
|
||||||
|
>
|
||||||
|
{!!selectedOption?.icon && (
|
||||||
|
<div className={``}>{selectedOption?.icon}</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex-1`}>{selectedOption?.label}</div>
|
||||||
|
</div>
|
||||||
|
<div className={``}>
|
||||||
|
<SelectIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Select;
|
||||||
99
app/components/SlideRange/index.tsx
Normal file
99
app/components/SlideRange/index.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useContext, useEffect, useRef } from "react";
|
||||||
|
import { ListContext } from "@/app/components/List";
|
||||||
|
import { useResizeObserver } from "usehooks-ts";
|
||||||
|
|
||||||
|
interface SlideRangeProps {
|
||||||
|
className?: string;
|
||||||
|
description?: string;
|
||||||
|
range?: {
|
||||||
|
start?: number;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
onSlide?: (v: number) => void;
|
||||||
|
value?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = 15;
|
||||||
|
|
||||||
|
export default function SlideRange(props: SlideRangeProps) {
|
||||||
|
const {
|
||||||
|
className = "",
|
||||||
|
description = "",
|
||||||
|
range = {},
|
||||||
|
value,
|
||||||
|
onSlide,
|
||||||
|
step,
|
||||||
|
} = props;
|
||||||
|
const { start = 0, stroke = 1 } = range;
|
||||||
|
|
||||||
|
const { rangeClassName, update } = useContext(ListContext);
|
||||||
|
|
||||||
|
const slideRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useResizeObserver({
|
||||||
|
ref: slideRef,
|
||||||
|
onResize: () => {
|
||||||
|
setProperty(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformToWidth = (x: number = start) => {
|
||||||
|
const abs = x - start;
|
||||||
|
const maxWidth = (slideRef.current?.clientWidth || 1) - margin * 2;
|
||||||
|
const result = (abs / stroke) * maxWidth;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProperty = (value?: number) => {
|
||||||
|
const initWidth = transformToWidth(value);
|
||||||
|
slideRef.current?.style.setProperty(
|
||||||
|
"--slide-value-size",
|
||||||
|
`${initWidth + margin}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
update?.({ type: "range" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-center items-end gap-1 w-[100%] ${className} ${rangeClassName}`}
|
||||||
|
>
|
||||||
|
{!!description && (
|
||||||
|
<div className=" text-common text-sm ">{description}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex my-1.5 relative w-[100%] h-1.5 bg-slider rounded-slide cursor-pointer"
|
||||||
|
ref={slideRef}
|
||||||
|
>
|
||||||
|
<div className="cursor-pointer absolute marker:top-0 h-[100%] w-[var(--slide-value-size)] bg-slider-slided-travel rounded-slide">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer absolute z-1 w-[30px] top-[50%] translate-y-[-50%] left-[var(--slide-value-size)] translate-x-[-50%] h-slide-btn leading-slide-btn text-sm-mobile text-center rounded-slide border border-slider-block bg-slider-block hover:bg-slider-block-hover text-text-slider-block"
|
||||||
|
// onPointerDown={onPointerDown}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="w-[100%] h-[100%] opacity-0 cursor-pointer"
|
||||||
|
value={value}
|
||||||
|
min={start}
|
||||||
|
max={start + stroke}
|
||||||
|
step={step}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProperty(e.target.valueAsNumber);
|
||||||
|
onSlide?.(e.target.valueAsNumber);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginLeft: margin,
|
||||||
|
marginRight: margin,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/components/Switch/index.tsx
Normal file
33
app/components/Switch/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as RadixSwitch from "@radix-ui/react-switch";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import List from "../List";
|
||||||
|
|
||||||
|
interface SwitchProps {
|
||||||
|
value: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Switch(props: SwitchProps) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
const { switchClassName = "" } = useContext(List.ListContext);
|
||||||
|
return (
|
||||||
|
<RadixSwitch.Root
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
className={`
|
||||||
|
cursor-pointer flex w-switch h-switch p-0.5 box-content rounded-md transition-colors duration-300 ease-in-out
|
||||||
|
${switchClassName}
|
||||||
|
${
|
||||||
|
value
|
||||||
|
? "bg-switch-checked justify-end"
|
||||||
|
: "bg-switch-unchecked justify-start"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<RadixSwitch.Thumb
|
||||||
|
className={` bg-switch-btn block w-4 h-4 drop-shadow-sm rounded-md`}
|
||||||
|
/>
|
||||||
|
</RadixSwitch.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/components/ThumbnailImg/index.tsx
Normal file
27
app/components/ThumbnailImg/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import ImgDeleteIcon from "@/app/icons/imgDeleteIcon.svg";
|
||||||
|
|
||||||
|
export interface ThumbnailProps {
|
||||||
|
image: string;
|
||||||
|
deleteImage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Thumbnail(props: ThumbnailProps) {
|
||||||
|
const { image, deleteImage } = props;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={` h-thumbnail w-thumbnail cursor-default border border-thumbnail rounded-action-btn flex-0 bg-cover bg-center`}
|
||||||
|
style={{ backgroundImage: `url("${image}")` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={` w-[100%] h-[100%] opacity-0 transition-all duration-200 rounded-action-btn hover:opacity-100 hover:bg-thumbnail-mask`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer flex items-center justify-center float-right`}
|
||||||
|
onClick={deleteImage}
|
||||||
|
>
|
||||||
|
<ImgDeleteIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,267 +0,0 @@
|
|||||||
import {
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
useMemo,
|
|
||||||
forwardRef,
|
|
||||||
useImperativeHandle,
|
|
||||||
} from "react";
|
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { useWindowSize } from "@/app/utils";
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
background-color: var(--white);
|
||||||
|
|
||||||
.auth-logo {
|
.auth-logo {
|
||||||
transform: scale(1.4);
|
transform: scale(1.4);
|
||||||
}
|
}
|
||||||
@@ -33,4 +35,18 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
input[type="number"],
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import styles from "./button.module.scss";
|
import styles from "./button.module.scss";
|
||||||
import { CSSProperties } from "react";
|
|
||||||
|
|
||||||
export type ButtonType = "primary" | "danger" | null;
|
export type ButtonType = "primary" | "danger" | null;
|
||||||
|
|
||||||
@@ -17,8 +16,6 @@ export function IconButton(props: {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
style?: CSSProperties;
|
|
||||||
aria?: string;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -34,12 +31,9 @@ export function IconButton(props: {
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={props.tabIndex}
|
tabIndex={props.tabIndex}
|
||||||
autoFocus={props.autoFocus}
|
autoFocus={props.autoFocus}
|
||||||
style={props.style}
|
|
||||||
aria-label={props.aria}
|
|
||||||
>
|
>
|
||||||
{props.icon && (
|
{props.icon && (
|
||||||
<div
|
<div
|
||||||
aria-label={props.text || props.title}
|
|
||||||
className={
|
className={
|
||||||
styles["icon-button-icon"] +
|
styles["icon-button-icon"] +
|
||||||
` ${props.type === "primary" && "no-dark"}`
|
` ${props.type === "primary" && "no-dark"}`
|
||||||
@@ -50,12 +44,7 @@ export function IconButton(props: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{props.text && (
|
{props.text && (
|
||||||
<div
|
<div className={styles["icon-button-text"]}>{props.text}</div>
|
||||||
aria-label={props.text || props.title}
|
|
||||||
className={styles["icon-button-text"]}
|
|
||||||
>
|
|
||||||
{props.text}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -346,12 +346,6 @@
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-model-name {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--black);
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-container {
|
.chat-message-container {
|
||||||
@@ -413,21 +407,6 @@
|
|||||||
margin-top: 5px;
|
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 {
|
.chat-message-item {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -646,51 +625,3 @@
|
|||||||
bottom: 30px;
|
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);
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,6 @@ import DeleteIcon from "../icons/clear.svg";
|
|||||||
import PinIcon from "../icons/pin.svg";
|
import PinIcon from "../icons/pin.svg";
|
||||||
import EditIcon from "../icons/rename.svg";
|
import EditIcon from "../icons/rename.svg";
|
||||||
import ConfirmIcon from "../icons/confirm.svg";
|
import ConfirmIcon from "../icons/confirm.svg";
|
||||||
import CloseIcon from "../icons/close.svg";
|
|
||||||
import CancelIcon from "../icons/cancel.svg";
|
import CancelIcon from "../icons/cancel.svg";
|
||||||
import ImageIcon from "../icons/image.svg";
|
import ImageIcon from "../icons/image.svg";
|
||||||
|
|
||||||
@@ -38,11 +37,6 @@ import AutoIcon from "../icons/auto.svg";
|
|||||||
import BottomIcon from "../icons/bottom.svg";
|
import BottomIcon from "../icons/bottom.svg";
|
||||||
import StopIcon from "../icons/pause.svg";
|
import StopIcon from "../icons/pause.svg";
|
||||||
import RobotIcon from "../icons/robot.svg";
|
import RobotIcon from "../icons/robot.svg";
|
||||||
import SizeIcon from "../icons/size.svg";
|
|
||||||
import QualityIcon from "../icons/hd.svg";
|
|
||||||
import StyleIcon from "../icons/palette.svg";
|
|
||||||
import PluginIcon from "../icons/plugin.svg";
|
|
||||||
import ShortcutkeyIcon from "../icons/shortcutkey.svg";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -55,7 +49,6 @@ import {
|
|||||||
useAppConfig,
|
useAppConfig,
|
||||||
DEFAULT_TOPIC,
|
DEFAULT_TOPIC,
|
||||||
ModelType,
|
ModelType,
|
||||||
usePluginStore,
|
|
||||||
} from "../store";
|
} from "../store";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -66,17 +59,12 @@ import {
|
|||||||
getMessageTextContent,
|
getMessageTextContent,
|
||||||
getMessageImages,
|
getMessageImages,
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
isDalle3,
|
compressImage,
|
||||||
showPlugins,
|
|
||||||
safeLocalStorage,
|
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
import { ChatControllerPool } from "../client/controller";
|
import { ChatControllerPool } from "../client/controller";
|
||||||
import { DalleSize, DalleQuality, DalleStyle } from "../typing";
|
|
||||||
import { Prompt, usePromptStore } from "../store/prompt";
|
import { Prompt, usePromptStore } from "../store/prompt";
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
|
|
||||||
@@ -99,7 +87,6 @@ import {
|
|||||||
Path,
|
Path,
|
||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
UNFINISHED_INPUT,
|
UNFINISHED_INPUT,
|
||||||
ServiceProvider,
|
|
||||||
} from "../constant";
|
} from "../constant";
|
||||||
import { Avatar } from "./emoji";
|
import { Avatar } from "./emoji";
|
||||||
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
||||||
@@ -111,8 +98,6 @@ import { getClientConfig } from "../config/client";
|
|||||||
import { useAllModels } from "../utils/hooks";
|
import { useAllModels } from "../utils/hooks";
|
||||||
import { MultimodalContent } from "../client/api";
|
import { MultimodalContent } from "../client/api";
|
||||||
|
|
||||||
const localStorage = safeLocalStorage();
|
|
||||||
|
|
||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||||
loading: () => <LoadingIcon />,
|
loading: () => <LoadingIcon />,
|
||||||
});
|
});
|
||||||
@@ -192,7 +177,7 @@ function PromptToast(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["prompt-toast"]} key="prompt-toast">
|
<div className={styles["prompt-toast"]} key="prompt-toast">
|
||||||
{props.showToast && context.length > 0 && (
|
{props.showToast && (
|
||||||
<div
|
<div
|
||||||
className={styles["prompt-toast-inner"] + " clickable"}
|
className={styles["prompt-toast-inner"] + " clickable"}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -258,11 +243,11 @@ function useSubmitHandler() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderPrompt = Pick<Prompt, "title" | "content">;
|
export type RenderPompt = Pick<Prompt, "title" | "content">;
|
||||||
|
|
||||||
export function PromptHints(props: {
|
export function PromptHints(props: {
|
||||||
prompts: RenderPrompt[];
|
prompts: RenderPompt[];
|
||||||
onPromptSelect: (prompt: RenderPrompt) => void;
|
onPromptSelect: (prompt: RenderPompt) => void;
|
||||||
}) {
|
}) {
|
||||||
const noPrompts = props.prompts.length === 0;
|
const noPrompts = props.prompts.length === 0;
|
||||||
const [selectIndex, setSelectIndex] = useState(0);
|
const [selectIndex, setSelectIndex] = useState(0);
|
||||||
@@ -351,7 +336,7 @@ function ClearContextDivider() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatAction(props: {
|
function ChatAction(props: {
|
||||||
text: string;
|
text: string;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -441,12 +426,10 @@ export function ChatActions(props: {
|
|||||||
showPromptHints: () => void;
|
showPromptHints: () => void;
|
||||||
hitBottom: boolean;
|
hitBottom: boolean;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}) {
|
}) {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const pluginStore = usePluginStore();
|
|
||||||
|
|
||||||
// switch themes
|
// switch themes
|
||||||
const theme = config.theme;
|
const theme = config.theme;
|
||||||
@@ -464,9 +447,6 @@ export function ChatActions(props: {
|
|||||||
|
|
||||||
// switch model
|
// switch model
|
||||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||||
const currentProviderName =
|
|
||||||
chatStore.currentSession().mask.modelConfig?.providerName ||
|
|
||||||
ServiceProvider.OpenAI;
|
|
||||||
const allModels = useAllModels();
|
const allModels = useAllModels();
|
||||||
const models = useMemo(() => {
|
const models = useMemo(() => {
|
||||||
const filteredModels = allModels.filter((m) => m.available);
|
const filteredModels = allModels.filter((m) => m.available);
|
||||||
@@ -482,33 +462,9 @@ export function ChatActions(props: {
|
|||||||
return filteredModels;
|
return filteredModels;
|
||||||
}
|
}
|
||||||
}, [allModels]);
|
}, [allModels]);
|
||||||
const currentModelName = useMemo(() => {
|
|
||||||
const model = models.find(
|
|
||||||
(m) =>
|
|
||||||
m.name == currentModel &&
|
|
||||||
m?.provider?.providerName == currentProviderName,
|
|
||||||
);
|
|
||||||
return model?.displayName ?? "";
|
|
||||||
}, [models, currentModel, currentProviderName]);
|
|
||||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||||
const [showPluginSelector, setShowPluginSelector] = useState(false);
|
|
||||||
const [showUploadImage, setShowUploadImage] = useState(false);
|
const [showUploadImage, setShowUploadImage] = useState(false);
|
||||||
|
|
||||||
const [showSizeSelector, setShowSizeSelector] = useState(false);
|
|
||||||
const [showQualitySelector, setShowQualitySelector] = useState(false);
|
|
||||||
const [showStyleSelector, setShowStyleSelector] = useState(false);
|
|
||||||
const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
|
|
||||||
const dalle3Qualitys: DalleQuality[] = ["standard", "hd"];
|
|
||||||
const dalle3Styles: DalleStyle[] = ["vivid", "natural"];
|
|
||||||
const currentSize =
|
|
||||||
chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
|
|
||||||
const currentQuality =
|
|
||||||
chatStore.currentSession().mask.modelConfig?.quality ?? "standard";
|
|
||||||
const currentStyle =
|
|
||||||
chatStore.currentSession().mask.modelConfig?.style ?? "vivid";
|
|
||||||
|
|
||||||
const isMobileScreen = useMobileScreen();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const show = isVisionModel(currentModel);
|
const show = isVisionModel(currentModel);
|
||||||
setShowUploadImage(show);
|
setShowUploadImage(show);
|
||||||
@@ -522,17 +478,13 @@ export function ChatActions(props: {
|
|||||||
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
||||||
if (isUnavaliableModel && models.length > 0) {
|
if (isUnavaliableModel && models.length > 0) {
|
||||||
// show next model to default model if exist
|
// show next model to default model if exist
|
||||||
let nextModel = models.find((model) => model.isDefault) || models[0];
|
let nextModel: ModelType = (
|
||||||
chatStore.updateCurrentSession((session) => {
|
models.find((model) => model.isDefault) || models[0]
|
||||||
session.mask.modelConfig.model = nextModel.name;
|
).name;
|
||||||
session.mask.modelConfig.providerName = nextModel?.provider
|
chatStore.updateCurrentSession(
|
||||||
?.providerName as ServiceProvider;
|
(session) => (session.mask.modelConfig.model = nextModel),
|
||||||
});
|
|
||||||
showToast(
|
|
||||||
nextModel?.provider?.providerName == "ByteDance"
|
|
||||||
? nextModel.displayName
|
|
||||||
: nextModel.name,
|
|
||||||
);
|
);
|
||||||
|
showToast(nextModel);
|
||||||
}
|
}
|
||||||
}, [chatStore, currentModel, models]);
|
}, [chatStore, currentModel, models]);
|
||||||
|
|
||||||
@@ -614,162 +566,28 @@ export function ChatActions(props: {
|
|||||||
|
|
||||||
<ChatAction
|
<ChatAction
|
||||||
onClick={() => setShowModelSelector(true)}
|
onClick={() => setShowModelSelector(true)}
|
||||||
text={currentModelName}
|
text={currentModel}
|
||||||
icon={<RobotIcon />}
|
icon={<RobotIcon />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showModelSelector && (
|
{showModelSelector && (
|
||||||
<Selector
|
<Selector
|
||||||
defaultSelectedValue={`${currentModel}@${currentProviderName}`}
|
defaultSelectedValue={currentModel}
|
||||||
items={models.map((m) => ({
|
items={models.map((m) => ({
|
||||||
title: `${m.displayName}${
|
title: m.displayName,
|
||||||
m?.provider?.providerName
|
value: m.name,
|
||||||
? "(" + m?.provider?.providerName + ")"
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
value: `${m.name}@${m?.provider?.providerName}`,
|
|
||||||
}))}
|
}))}
|
||||||
onClose={() => setShowModelSelector(false)}
|
onClose={() => setShowModelSelector(false)}
|
||||||
onSelection={(s) => {
|
onSelection={(s) => {
|
||||||
if (s.length === 0) return;
|
if (s.length === 0) return;
|
||||||
const [model, providerName] = s[0].split("@");
|
|
||||||
chatStore.updateCurrentSession((session) => {
|
chatStore.updateCurrentSession((session) => {
|
||||||
session.mask.modelConfig.model = model as ModelType;
|
session.mask.modelConfig.model = s[0] as ModelType;
|
||||||
session.mask.modelConfig.providerName =
|
|
||||||
providerName as ServiceProvider;
|
|
||||||
session.mask.syncGlobalConfig = false;
|
session.mask.syncGlobalConfig = false;
|
||||||
});
|
});
|
||||||
if (providerName == "ByteDance") {
|
showToast(s[0]);
|
||||||
const selectedModel = models.find(
|
|
||||||
(m) =>
|
|
||||||
m.name == model && m?.provider?.providerName == providerName,
|
|
||||||
);
|
|
||||||
showToast(selectedModel?.displayName ?? "");
|
|
||||||
} else {
|
|
||||||
showToast(model);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDalle3(currentModel) && (
|
|
||||||
<ChatAction
|
|
||||||
onClick={() => setShowSizeSelector(true)}
|
|
||||||
text={currentSize}
|
|
||||||
icon={<SizeIcon />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSizeSelector && (
|
|
||||||
<Selector
|
|
||||||
defaultSelectedValue={currentSize}
|
|
||||||
items={dalle3Sizes.map((m) => ({
|
|
||||||
title: m,
|
|
||||||
value: m,
|
|
||||||
}))}
|
|
||||||
onClose={() => setShowSizeSelector(false)}
|
|
||||||
onSelection={(s) => {
|
|
||||||
if (s.length === 0) return;
|
|
||||||
const size = s[0];
|
|
||||||
chatStore.updateCurrentSession((session) => {
|
|
||||||
session.mask.modelConfig.size = size;
|
|
||||||
});
|
|
||||||
showToast(size);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDalle3(currentModel) && (
|
|
||||||
<ChatAction
|
|
||||||
onClick={() => setShowQualitySelector(true)}
|
|
||||||
text={currentQuality}
|
|
||||||
icon={<QualityIcon />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showQualitySelector && (
|
|
||||||
<Selector
|
|
||||||
defaultSelectedValue={currentQuality}
|
|
||||||
items={dalle3Qualitys.map((m) => ({
|
|
||||||
title: m,
|
|
||||||
value: m,
|
|
||||||
}))}
|
|
||||||
onClose={() => setShowQualitySelector(false)}
|
|
||||||
onSelection={(q) => {
|
|
||||||
if (q.length === 0) return;
|
|
||||||
const quality = q[0];
|
|
||||||
chatStore.updateCurrentSession((session) => {
|
|
||||||
session.mask.modelConfig.quality = quality;
|
|
||||||
});
|
|
||||||
showToast(quality);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDalle3(currentModel) && (
|
|
||||||
<ChatAction
|
|
||||||
onClick={() => setShowStyleSelector(true)}
|
|
||||||
text={currentStyle}
|
|
||||||
icon={<StyleIcon />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showStyleSelector && (
|
|
||||||
<Selector
|
|
||||||
defaultSelectedValue={currentStyle}
|
|
||||||
items={dalle3Styles.map((m) => ({
|
|
||||||
title: m,
|
|
||||||
value: m,
|
|
||||||
}))}
|
|
||||||
onClose={() => setShowStyleSelector(false)}
|
|
||||||
onSelection={(s) => {
|
|
||||||
if (s.length === 0) return;
|
|
||||||
const style = s[0];
|
|
||||||
chatStore.updateCurrentSession((session) => {
|
|
||||||
session.mask.modelConfig.style = style;
|
|
||||||
});
|
|
||||||
showToast(style);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showPlugins(currentProviderName, currentModel) && (
|
|
||||||
<ChatAction
|
|
||||||
onClick={() => {
|
|
||||||
if (pluginStore.getAll().length == 0) {
|
|
||||||
navigate(Path.Plugins);
|
|
||||||
} else {
|
|
||||||
setShowPluginSelector(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
text={Locale.Plugin.Name}
|
|
||||||
icon={<PluginIcon />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showPluginSelector && (
|
|
||||||
<Selector
|
|
||||||
multiple
|
|
||||||
defaultSelectedValue={chatStore.currentSession().mask?.plugin}
|
|
||||||
items={pluginStore.getAll().map((item) => ({
|
|
||||||
title: `${item?.title}@${item?.version}`,
|
|
||||||
value: item?.id,
|
|
||||||
}))}
|
|
||||||
onClose={() => setShowPluginSelector(false)}
|
|
||||||
onSelection={(s) => {
|
|
||||||
chatStore.updateCurrentSession((session) => {
|
|
||||||
session.mask.plugin = s as string[];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isMobileScreen && (
|
|
||||||
<ChatAction
|
|
||||||
onClick={() => props.setShowShortcutKeyModal(true)}
|
|
||||||
text={Locale.Chat.ShortcutKey.Title}
|
|
||||||
icon={<ShortcutkeyIcon />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -844,67 +662,6 @@ export function DeleteImageButton(props: { deleteImage: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShortcutKeyModal(props: { onClose: () => void }) {
|
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
||||||
const shortcuts = [
|
|
||||||
{
|
|
||||||
title: Locale.Chat.ShortcutKey.newChat,
|
|
||||||
keys: isMac ? ["⌘", "Shift", "O"] : ["Ctrl", "Shift", "O"],
|
|
||||||
},
|
|
||||||
{ title: Locale.Chat.ShortcutKey.focusInput, keys: ["Shift", "Esc"] },
|
|
||||||
{
|
|
||||||
title: Locale.Chat.ShortcutKey.copyLastCode,
|
|
||||||
keys: isMac ? ["⌘", "Shift", ";"] : ["Ctrl", "Shift", ";"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: Locale.Chat.ShortcutKey.copyLastMessage,
|
|
||||||
keys: isMac ? ["⌘", "Shift", "C"] : ["Ctrl", "Shift", "C"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: Locale.Chat.ShortcutKey.showShortcutKey,
|
|
||||||
keys: isMac ? ["⌘", "/"] : ["Ctrl", "/"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div className="modal-mask">
|
|
||||||
<Modal
|
|
||||||
title={Locale.Chat.ShortcutKey.Title}
|
|
||||||
onClose={props.onClose}
|
|
||||||
actions={[
|
|
||||||
<IconButton
|
|
||||||
type="primary"
|
|
||||||
text={Locale.UI.Confirm}
|
|
||||||
icon={<ConfirmIcon />}
|
|
||||||
key="ok"
|
|
||||||
onClick={() => {
|
|
||||||
props.onClose();
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div className={styles["shortcut-key-container"]}>
|
|
||||||
<div className={styles["shortcut-key-grid"]}>
|
|
||||||
{shortcuts.map((shortcut, index) => (
|
|
||||||
<div key={index} className={styles["shortcut-key-item"]}>
|
|
||||||
<div className={styles["shortcut-key-title"]}>
|
|
||||||
{shortcut.title}
|
|
||||||
</div>
|
|
||||||
<div className={styles["shortcut-key-keys"]}>
|
|
||||||
{shortcut.keys.map((key, i) => (
|
|
||||||
<div key={i} className={styles["shortcut-key"]}>
|
|
||||||
<span>{key}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _Chat() {
|
function _Chat() {
|
||||||
type RenderMessage = ChatMessage & { preview?: boolean };
|
type RenderMessage = ChatMessage & { preview?: boolean };
|
||||||
|
|
||||||
@@ -912,7 +669,6 @@ function _Chat() {
|
|||||||
const session = chatStore.currentSession();
|
const session = chatStore.currentSession();
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const fontSize = config.fontSize;
|
const fontSize = config.fontSize;
|
||||||
const fontFamily = config.fontFamily;
|
|
||||||
|
|
||||||
const [showExport, setShowExport] = useState(false);
|
const [showExport, setShowExport] = useState(false);
|
||||||
|
|
||||||
@@ -939,7 +695,7 @@ function _Chat() {
|
|||||||
|
|
||||||
// prompt hints
|
// prompt hints
|
||||||
const promptStore = usePromptStore();
|
const promptStore = usePromptStore();
|
||||||
const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]);
|
const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
|
||||||
const onSearch = useDebouncedCallback(
|
const onSearch = useDebouncedCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
const matchedPrompts = promptStore.search(text);
|
const matchedPrompts = promptStore.search(text);
|
||||||
@@ -992,7 +748,7 @@ function _Chat() {
|
|||||||
// clear search results
|
// clear search results
|
||||||
if (n === 0) {
|
if (n === 0) {
|
||||||
setPromptHints([]);
|
setPromptHints([]);
|
||||||
} else if (text.match(ChatCommandPrefix)) {
|
} else if (text.startsWith(ChatCommandPrefix)) {
|
||||||
setPromptHints(chatCommands.search(text));
|
setPromptHints(chatCommands.search(text));
|
||||||
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
|
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
|
||||||
// check if need to trigger auto completion
|
// check if need to trigger auto completion
|
||||||
@@ -1017,14 +773,14 @@ function _Chat() {
|
|||||||
.onUserInput(userInput, attachImages)
|
.onUserInput(userInput, attachImages)
|
||||||
.then(() => setIsLoading(false));
|
.then(() => setIsLoading(false));
|
||||||
setAttachImages([]);
|
setAttachImages([]);
|
||||||
chatStore.setLastInput(userInput);
|
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setPromptHints([]);
|
setPromptHints([]);
|
||||||
if (!isMobileScreen) inputRef.current?.focus();
|
if (!isMobileScreen) inputRef.current?.focus();
|
||||||
setAutoScroll(true);
|
setAutoScroll(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPromptSelect = (prompt: RenderPrompt) => {
|
const onPromptSelect = (prompt: RenderPompt) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setPromptHints([]);
|
setPromptHints([]);
|
||||||
|
|
||||||
@@ -1083,7 +839,7 @@ function _Chat() {
|
|||||||
userInput.length <= 0 &&
|
userInput.length <= 0 &&
|
||||||
!(e.metaKey || e.altKey || e.ctrlKey)
|
!(e.metaKey || e.altKey || e.ctrlKey)
|
||||||
) {
|
) {
|
||||||
setUserInput(chatStore.lastInput ?? "");
|
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1332,7 +1088,6 @@ function _Chat() {
|
|||||||
if (payload.url) {
|
if (payload.url) {
|
||||||
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
||||||
}
|
}
|
||||||
accessStore.update((access) => (access.useCustomConfig = true));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1379,7 +1134,7 @@ function _Chat() {
|
|||||||
...(await new Promise<string[]>((res, rej) => {
|
...(await new Promise<string[]>((res, rej) => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
const imagesData: string[] = [];
|
const imagesData: string[] = [];
|
||||||
uploadImageRemote(file)
|
compressImage(file, 256 * 1024)
|
||||||
.then((dataUrl) => {
|
.then((dataUrl) => {
|
||||||
imagesData.push(dataUrl);
|
imagesData.push(dataUrl);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
@@ -1421,7 +1176,7 @@ function _Chat() {
|
|||||||
const imagesData: string[] = [];
|
const imagesData: string[] = [];
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = event.target.files[i];
|
const file = event.target.files[i];
|
||||||
uploadImageRemote(file)
|
compressImage(file, 256 * 1024)
|
||||||
.then((dataUrl) => {
|
.then((dataUrl) => {
|
||||||
imagesData.push(dataUrl);
|
imagesData.push(dataUrl);
|
||||||
if (
|
if (
|
||||||
@@ -1449,70 +1204,6 @@ function _Chat() {
|
|||||||
setAttachImages(images);
|
setAttachImages(images);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快捷键 shortcut keys
|
|
||||||
const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: any) => {
|
|
||||||
// 打开新聊天 command + shift + o
|
|
||||||
if (
|
|
||||||
(event.metaKey || event.ctrlKey) &&
|
|
||||||
event.shiftKey &&
|
|
||||||
event.key.toLowerCase() === "o"
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
setTimeout(() => {
|
|
||||||
chatStore.newSession();
|
|
||||||
navigate(Path.Chat);
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
// 聚焦聊天输入 shift + esc
|
|
||||||
else if (event.shiftKey && event.key.toLowerCase() === "escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
// 复制最后一个代码块 command + shift + ;
|
|
||||||
else if (
|
|
||||||
(event.metaKey || event.ctrlKey) &&
|
|
||||||
event.shiftKey &&
|
|
||||||
event.code === "Semicolon"
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
const copyCodeButton =
|
|
||||||
document.querySelectorAll<HTMLElement>(".copy-code-button");
|
|
||||||
if (copyCodeButton.length > 0) {
|
|
||||||
copyCodeButton[copyCodeButton.length - 1].click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 复制最后一个回复 command + shift + c
|
|
||||||
else if (
|
|
||||||
(event.metaKey || event.ctrlKey) &&
|
|
||||||
event.shiftKey &&
|
|
||||||
event.key.toLowerCase() === "c"
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
const lastNonUserMessage = messages
|
|
||||||
.filter((message) => message.role !== "user")
|
|
||||||
.pop();
|
|
||||||
if (lastNonUserMessage) {
|
|
||||||
const lastMessageContent = getMessageTextContent(lastNonUserMessage);
|
|
||||||
copyToClipboard(lastMessageContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 展示快捷键 command + /
|
|
||||||
else if ((event.metaKey || event.ctrlKey) && event.key === "/") {
|
|
||||||
event.preventDefault();
|
|
||||||
setShowShortcutKeyModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [messages, chatStore, navigate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.chat} key={session.id}>
|
<div className={styles.chat} key={session.id}>
|
||||||
<div className="window-header" data-tauri-drag-region>
|
<div className="window-header" data-tauri-drag-region>
|
||||||
@@ -1546,8 +1237,6 @@ function _Chat() {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon={<RenameIcon />}
|
icon={<RenameIcon />}
|
||||||
bordered
|
bordered
|
||||||
title={Locale.Chat.EditMessage.Title}
|
|
||||||
aria={Locale.Chat.EditMessage.Title}
|
|
||||||
onClick={() => setIsEditingMessage(true)}
|
onClick={() => setIsEditingMessage(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1567,8 +1256,6 @@ function _Chat() {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
|
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
|
||||||
bordered
|
bordered
|
||||||
title={Locale.Chat.Actions.FullScreen}
|
|
||||||
aria={Locale.Chat.Actions.FullScreen}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
config.update(
|
config.update(
|
||||||
(config) => (config.tightBorder = !config.tightBorder),
|
(config) => (config.tightBorder = !config.tightBorder),
|
||||||
@@ -1620,7 +1307,6 @@ function _Chat() {
|
|||||||
<div className={styles["chat-message-edit"]}>
|
<div className={styles["chat-message-edit"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<EditIcon />}
|
icon={<EditIcon />}
|
||||||
aria={Locale.Chat.Actions.Edit}
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const newMessage = await showPrompt(
|
const newMessage = await showPrompt(
|
||||||
Locale.Chat.Actions.Edit,
|
Locale.Chat.Actions.Edit,
|
||||||
@@ -1669,11 +1355,6 @@ function _Chat() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isUser && (
|
|
||||||
<div className={styles["chat-model-name"]}>
|
|
||||||
{message.model}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<div className={styles["chat-message-actions"]}>
|
<div className={styles["chat-message-actions"]}>
|
||||||
@@ -1718,47 +1399,25 @@ function _Chat() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{message?.tools?.length == 0 && showTyping && (
|
{showTyping && (
|
||||||
<div className={styles["chat-message-status"]}>
|
<div className={styles["chat-message-status"]}>
|
||||||
{Locale.Chat.Typing}
|
{Locale.Chat.Typing}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/*@ts-ignore*/}
|
|
||||||
{message?.tools?.length > 0 && (
|
|
||||||
<div className={styles["chat-message-tools"]}>
|
|
||||||
{message?.tools?.map((tool) => (
|
|
||||||
<div
|
|
||||||
key={tool.id}
|
|
||||||
className={styles["chat-message-tool"]}
|
|
||||||
>
|
|
||||||
{tool.isError === false ? (
|
|
||||||
<ConfirmIcon />
|
|
||||||
) : tool.isError === true ? (
|
|
||||||
<CloseIcon />
|
|
||||||
) : (
|
|
||||||
<LoadingButtonIcon />
|
|
||||||
)}
|
|
||||||
<span>{tool?.function?.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles["chat-message-item"]}>
|
<div className={styles["chat-message-item"]}>
|
||||||
<Markdown
|
<Markdown
|
||||||
key={message.streaming ? "loading" : "done"}
|
|
||||||
content={getMessageTextContent(message)}
|
content={getMessageTextContent(message)}
|
||||||
loading={
|
loading={
|
||||||
(message.preview || message.streaming) &&
|
(message.preview || message.streaming) &&
|
||||||
message.content.length === 0 &&
|
message.content.length === 0 &&
|
||||||
!isUser
|
!isUser
|
||||||
}
|
}
|
||||||
// onContextMenu={(e) => onRightClick(e, message)} // hard to use
|
onContextMenu={(e) => onRightClick(e, message)}
|
||||||
onDoubleClickCapture={() => {
|
onDoubleClickCapture={() => {
|
||||||
if (!isMobileScreen) return;
|
if (!isMobileScreen) return;
|
||||||
setUserInput(getMessageTextContent(message));
|
setUserInput(getMessageTextContent(message));
|
||||||
}}
|
}}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
fontFamily={fontFamily}
|
|
||||||
parentRef={scrollRef}
|
parentRef={scrollRef}
|
||||||
defaultShow={i >= messages.length - 6}
|
defaultShow={i >= messages.length - 6}
|
||||||
/>
|
/>
|
||||||
@@ -1829,7 +1488,6 @@ function _Chat() {
|
|||||||
setUserInput("/");
|
setUserInput("/");
|
||||||
onSearch("");
|
onSearch("");
|
||||||
}}
|
}}
|
||||||
setShowShortcutKeyModal={setShowShortcutKeyModal}
|
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
className={`${styles["chat-input-panel-inner"]} ${
|
className={`${styles["chat-input-panel-inner"]} ${
|
||||||
@@ -1854,7 +1512,6 @@ function _Chat() {
|
|||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
style={{
|
style={{
|
||||||
fontSize: config.fontSize,
|
fontSize: config.fontSize,
|
||||||
fontFamily: config.fontFamily,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{attachImages.length != 0 && (
|
{attachImages.length != 0 && (
|
||||||
@@ -1901,10 +1558,6 @@ function _Chat() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showShortcutKeyModal && (
|
|
||||||
<ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) {
|
|||||||
if (props.model) {
|
if (props.model) {
|
||||||
return (
|
return (
|
||||||
<div className="no-dark">
|
<div className="no-dark">
|
||||||
{props.model?.startsWith("gpt-4") ||
|
{props.model?.startsWith("gpt-4") ? (
|
||||||
props.model?.startsWith("chatgpt-4o") ? (
|
|
||||||
<BlackBotIcon className="user-avatar" />
|
<BlackBotIcon className="user-avatar" />
|
||||||
) : (
|
) : (
|
||||||
<BotIcon className="user-avatar" />
|
<BotIcon className="user-avatar" />
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
import GithubIcon from "../icons/github.svg";
|
import GithubIcon from "../icons/github.svg";
|
||||||
@@ -8,7 +6,6 @@ import { ISSUE_URL } from "../constant";
|
|||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
import { useSyncStore } from "../store/sync";
|
import { useSyncStore } from "../store/sync";
|
||||||
import { useChatStore } from "../store/chat";
|
|
||||||
|
|
||||||
interface IErrorBoundaryState {
|
interface IErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@@ -31,7 +28,8 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
|||||||
try {
|
try {
|
||||||
useSyncStore.getState().export();
|
useSyncStore.getState().export();
|
||||||
} finally {
|
} finally {
|
||||||
useChatStore.getState().clearAllData();
|
localStorage.clear();
|
||||||
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
&-body {
|
&-body {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-content {
|
.export-content {
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ import { toBlob, toPng } from "html-to-image";
|
|||||||
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
||||||
|
|
||||||
import { prettyObject } from "../utils/format";
|
import { prettyObject } from "../utils/format";
|
||||||
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
|
||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
import { type ClientApi, getClientApi } from "../client/api";
|
import { ClientApi } from "../client/api";
|
||||||
import { getMessageTextContent } from "../utils";
|
import { getMessageTextContent } from "../utils";
|
||||||
|
import { identifyDefaultClaudeModel } from "../utils/checkers";
|
||||||
|
|
||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||||
loading: () => <LoadingIcon />,
|
loading: () => <LoadingIcon />,
|
||||||
@@ -312,7 +313,14 @@ export function PreviewActions(props: {
|
|||||||
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
||||||
setShouldExport(false);
|
setShouldExport(false);
|
||||||
|
|
||||||
const api: ClientApi = getClientApi(config.modelConfig.providerName);
|
var api: ClientApi;
|
||||||
|
if (config.modelConfig.model.startsWith("gemini")) {
|
||||||
|
api = new ClientApi(ModelProvider.GeminiPro);
|
||||||
|
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||||
|
api = new ClientApi(ModelProvider.Claude);
|
||||||
|
} else {
|
||||||
|
api = new ClientApi(ModelProvider.GPT);
|
||||||
|
}
|
||||||
|
|
||||||
api
|
api
|
||||||
.share(msgs)
|
.share(msgs)
|
||||||
@@ -541,7 +549,7 @@ export function ImagePreviewer(props: {
|
|||||||
<div>
|
<div>
|
||||||
<div className={styles["main-title"]}>NextChat</div>
|
<div className={styles["main-title"]}>NextChat</div>
|
||||||
<div className={styles["sub-title"]}>
|
<div className={styles["sub-title"]}>
|
||||||
github.com/ChatGPTNextWeb/ChatGPT-Next-Web
|
github.com/Yidadaa/ChatGPT-Next-Web
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["icons"]}>
|
<div className={styles["icons"]}>
|
||||||
<ExportAvatar avatar={config.avatar} />
|
<ExportAvatar avatar={config.avatar} />
|
||||||
@@ -583,7 +591,6 @@ export function ImagePreviewer(props: {
|
|||||||
<Markdown
|
<Markdown
|
||||||
content={getMessageTextContent(m)}
|
content={getMessageTextContent(m)}
|
||||||
fontSize={config.fontSize}
|
fontSize={config.fontSize}
|
||||||
fontFamily={config.fontFamily}
|
|
||||||
defaultShow
|
defaultShow
|
||||||
/>
|
/>
|
||||||
{getMessageImages(m).length == 1 && (
|
{getMessageImages(m).length == 1 && (
|
||||||
|
|||||||
@@ -137,18 +137,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
display: inline-flex;
|
position: absolute;
|
||||||
}
|
right: 0;
|
||||||
|
bottom: 18px;
|
||||||
.sidebar-title-container {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import LoadingIcon from "../icons/three-dots.svg";
|
|||||||
import { getCSSVar, useMobileScreen } from "../utils";
|
import { getCSSVar, useMobileScreen } from "../utils";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Path, SlotID } from "../constant";
|
import { ModelProvider, Path, SlotID } from "../constant";
|
||||||
import { ErrorBoundary } from "./error";
|
import { ErrorBoundary } from "./error";
|
||||||
|
|
||||||
import { getISOLang, getLang } from "../locales";
|
import { getISOLang, getLang } from "../locales";
|
||||||
@@ -27,8 +27,9 @@ import { SideBar } from "./sidebar";
|
|||||||
import { useAppConfig } from "../store/config";
|
import { useAppConfig } from "../store/config";
|
||||||
import { AuthPage } from "./auth";
|
import { AuthPage } from "./auth";
|
||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
import { type ClientApi, getClientApi } from "../client/api";
|
import { ClientApi } from "../client/api";
|
||||||
import { useAccessStore } from "../store";
|
import { useAccessStore } from "../store";
|
||||||
|
import { identifyDefaultClaudeModel } from "../utils/checkers";
|
||||||
|
|
||||||
export function Loading(props: { noLogo?: boolean }) {
|
export function Loading(props: { noLogo?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -39,10 +40,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, {
|
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
||||||
loading: () => <Loading noLogo />,
|
loading: () => <Loading noLogo />,
|
||||||
});
|
});
|
||||||
@@ -59,21 +56,6 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
|
|||||||
loading: () => <Loading noLogo />,
|
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() {
|
export function useSwitchTheme() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
|
|
||||||
@@ -141,23 +123,11 @@ const loadAsyncGoogleFont = () => {
|
|||||||
document.head.appendChild(linkEl);
|
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() {
|
function Screen() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isArtifact = location.pathname.includes(Path.Artifacts);
|
|
||||||
const isHome = location.pathname === Path.Home;
|
const isHome = location.pathname === Path.Home;
|
||||||
const isAuth = location.pathname === Path.Auth;
|
const isAuth = location.pathname === Path.Auth;
|
||||||
const isSd = location.pathname === Path.Sd;
|
|
||||||
const isSdNew = location.pathname === Path.SdNew;
|
|
||||||
|
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
const shouldTightBorder =
|
const shouldTightBorder =
|
||||||
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
||||||
@@ -166,42 +136,34 @@ function Screen() {
|
|||||||
loadAsyncGoogleFont();
|
loadAsyncGoogleFont();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isArtifact) {
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<div
|
||||||
<Route path="/artifacts/:id" element={<Artifacts />} />
|
className={
|
||||||
</Routes>
|
styles.container +
|
||||||
);
|
` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${
|
||||||
|
getLang() === "ar" ? styles["rtl-screen"] : ""
|
||||||
|
}`
|
||||||
}
|
}
|
||||||
const renderContent = () => {
|
>
|
||||||
if (isAuth) return <AuthPage />;
|
{isAuth ? (
|
||||||
if (isSd) return <Sd />;
|
<>
|
||||||
if (isSdNew) return <Sd />;
|
<AuthPage />
|
||||||
return (
|
</>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
|
||||||
<WindowContent>
|
|
||||||
|
<div className={styles["window-content"]} id={SlotID.AppBody}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={Path.Home} element={<Chat />} />
|
<Route path={Path.Home} element={<Chat />} />
|
||||||
<Route path={Path.NewChat} element={<NewChat />} />
|
<Route path={Path.NewChat} element={<NewChat />} />
|
||||||
<Route path={Path.Masks} element={<MaskPage />} />
|
<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.Chat} element={<Chat />} />
|
||||||
<Route path={Path.Settings} element={<Settings />} />
|
<Route path={Path.Settings} element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</WindowContent>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${styles.container} ${
|
|
||||||
shouldTightBorder ? styles["tight-container"] : styles.container
|
|
||||||
} ${getLang() === "ar" ? styles["rtl-screen"] : ""}`}
|
|
||||||
>
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,8 +171,14 @@ function Screen() {
|
|||||||
export function useLoadData() {
|
export function useLoadData() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
|
|
||||||
const api: ClientApi = getClientApi(config.modelConfig.providerName);
|
var api: ClientApi;
|
||||||
|
if (config.modelConfig.model.startsWith("gemini")) {
|
||||||
|
api = new ClientApi(ModelProvider.GeminiPro);
|
||||||
|
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||||
|
api = new ClientApi(ModelProvider.Claude);
|
||||||
|
} else {
|
||||||
|
api = new ClientApi(ModelProvider.GPT);
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const models = await api.llm.models();
|
const models = await api.llm.models();
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ interface InputRangeProps {
|
|||||||
min: string;
|
min: string;
|
||||||
max: string;
|
max: string;
|
||||||
step: string;
|
step: string;
|
||||||
aria: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputRange({
|
export function InputRange({
|
||||||
@@ -20,13 +19,11 @@ export function InputRange({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
aria,
|
|
||||||
}: InputRangeProps) {
|
}: InputRangeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
||||||
{title || value}
|
{title || value}
|
||||||
<input
|
<input
|
||||||
aria-label={aria}
|
|
||||||
type="range"
|
type="range"
|
||||||
title={title}
|
title={title}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -6,21 +6,13 @@ import RehypeKatex from "rehype-katex";
|
|||||||
import RemarkGfm from "remark-gfm";
|
import RemarkGfm from "remark-gfm";
|
||||||
import RehypeHighlight from "rehype-highlight";
|
import RehypeHighlight from "rehype-highlight";
|
||||||
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
|
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
|
||||||
import { copyToClipboard, useWindowSize } from "../utils";
|
import { copyToClipboard } from "../utils";
|
||||||
import mermaid from "mermaid";
|
import mermaid from "mermaid";
|
||||||
import Locale from "../locales";
|
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
import ReloadButtonIcon from "../icons/reload.svg";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { showImageModal, FullScreen } from "./ui-lib";
|
import { showImageModal } from "./ui-lib";
|
||||||
import {
|
|
||||||
ArtifactsShareButton,
|
|
||||||
HTMLPreview,
|
|
||||||
HTMLPreviewHander,
|
|
||||||
} from "./artifacts";
|
|
||||||
import { useChatStore } from "../store";
|
|
||||||
import { IconButton } from "./button";
|
|
||||||
|
|
||||||
export function Mermaid(props: { code: string }) {
|
export function Mermaid(props: { code: string }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -70,59 +62,27 @@ export function Mermaid(props: { code: string }) {
|
|||||||
|
|
||||||
export function PreCode(props: { children: any }) {
|
export function PreCode(props: { children: any }) {
|
||||||
const ref = useRef<HTMLPreElement>(null);
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
const previewRef = useRef<HTMLPreviewHander>(null);
|
const refText = ref.current?.innerText;
|
||||||
const [mermaidCode, setMermaidCode] = useState("");
|
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;
|
if (!ref.current) return;
|
||||||
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
||||||
if (mermaidDom) {
|
if (mermaidDom) {
|
||||||
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
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);
|
}, 600);
|
||||||
|
|
||||||
const enableArtifacts = session.mask?.enableArtifacts !== false;
|
|
||||||
|
|
||||||
//Wrap the paragraph for plain-text
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
setTimeout(renderMermaid, 1);
|
||||||
const codeElements = ref.current.querySelectorAll(
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
"code",
|
}, [refText]);
|
||||||
) 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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{mermaidCode.length > 0 && (
|
||||||
|
<Mermaid code={mermaidCode} key={mermaidCode} />
|
||||||
|
)}
|
||||||
<pre ref={ref}>
|
<pre ref={ref}>
|
||||||
<span
|
<span
|
||||||
className="copy-code-button"
|
className="copy-code-button"
|
||||||
@@ -135,69 +95,6 @@ export function PreCode(props: { children: any }) {
|
|||||||
></span>
|
></span>
|
||||||
{props.children}
|
{props.children}
|
||||||
</pre>
|
</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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,26 +134,9 @@ function escapeBrackets(text: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
function _MarkDownContent(props: { content: string }) {
|
||||||
const escapedContent = useMemo(() => {
|
const escapedContent = useMemo(() => {
|
||||||
return tryWrapHtmlCode(escapeBrackets(escapeDollarNumber(props.content)));
|
return escapeBrackets(escapeDollarNumber(props.content));
|
||||||
}, [props.content]);
|
}, [props.content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -274,7 +154,6 @@ function _MarkDownContent(props: { content: string }) {
|
|||||||
]}
|
]}
|
||||||
components={{
|
components={{
|
||||||
pre: PreCode,
|
pre: PreCode,
|
||||||
code: CustomCode,
|
|
||||||
p: (pProps) => <p {...pProps} dir="auto" />,
|
p: (pProps) => <p {...pProps} dir="auto" />,
|
||||||
a: (aProps) => {
|
a: (aProps) => {
|
||||||
const href = aProps.href || "";
|
const href = aProps.href || "";
|
||||||
@@ -296,19 +175,18 @@ export function Markdown(
|
|||||||
content: string;
|
content: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontFamily?: string;
|
|
||||||
parentRef?: RefObject<HTMLDivElement>;
|
parentRef?: RefObject<HTMLDivElement>;
|
||||||
defaultShow?: boolean;
|
defaultShow?: boolean;
|
||||||
|
className?: string;
|
||||||
} & React.DOMAttributes<HTMLDivElement>,
|
} & React.DOMAttributes<HTMLDivElement>,
|
||||||
) {
|
) {
|
||||||
const mdRef = useRef<HTMLDivElement>(null);
|
const mdRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="markdown-body"
|
className={`markdown-body ${props.className}`}
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${props.fontSize ?? 14}px`,
|
fontSize: `${props.fontSize ?? 14}px`,
|
||||||
fontFamily: props.fontFamily || "inherit",
|
|
||||||
}}
|
}}
|
||||||
ref={mdRef}
|
ref={mdRef}
|
||||||
onContextMenu={props.onContextMenu}
|
onContextMenu={props.onContextMenu}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.mask-page-body {
|
.mask-page-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
import { ErrorBoundary } from "./error";
|
|
||||||
|
|
||||||
import styles from "./mask.module.scss";
|
import styles from "./mask.module.scss";
|
||||||
|
|
||||||
@@ -56,6 +55,7 @@ import {
|
|||||||
OnDragEndResponder,
|
OnDragEndResponder,
|
||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
import { getMessageTextContent } from "../utils";
|
import { getMessageTextContent } from "../utils";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
|
||||||
// drag and drop helper function
|
// drag and drop helper function
|
||||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||||
@@ -127,8 +127,6 @@ export function MaskConfig(props: {
|
|||||||
onClose={() => setShowPicker(false)}
|
onClose={() => setShowPicker(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
|
||||||
aria-label={Locale.Mask.Config.Avatar}
|
|
||||||
onClick={() => setShowPicker(true)}
|
onClick={() => setShowPicker(true)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
@@ -141,7 +139,6 @@ export function MaskConfig(props: {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={Locale.Mask.Config.Name}>
|
<ListItem title={Locale.Mask.Config.Name}>
|
||||||
<input
|
<input
|
||||||
aria-label={Locale.Mask.Config.Name}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={props.mask.name}
|
value={props.mask.name}
|
||||||
onInput={(e) =>
|
onInput={(e) =>
|
||||||
@@ -156,7 +153,6 @@ export function MaskConfig(props: {
|
|||||||
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={Locale.Mask.Config.HideContext.Title}
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.mask.hideContext}
|
checked={props.mask.hideContext}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -167,29 +163,12 @@ export function MaskConfig(props: {
|
|||||||
></input>
|
></input>
|
||||||
</ListItem>
|
</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 ? (
|
{!props.shouldSyncFromGlobal ? (
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Mask.Config.Share.Title}
|
title={Locale.Mask.Config.Share.Title}
|
||||||
subTitle={Locale.Mask.Config.Share.SubTitle}
|
subTitle={Locale.Mask.Config.Share.SubTitle}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria={Locale.Mask.Config.Share.Title}
|
|
||||||
icon={<CopyIcon />}
|
icon={<CopyIcon />}
|
||||||
text={Locale.Mask.Config.Share.Action}
|
text={Locale.Mask.Config.Share.Action}
|
||||||
onClick={copyMaskLink}
|
onClick={copyMaskLink}
|
||||||
@@ -203,7 +182,6 @@ export function MaskConfig(props: {
|
|||||||
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={Locale.Mask.Config.Sync.Title}
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.mask.syncGlobalConfig}
|
checked={props.mask.syncGlobalConfig}
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
@@ -420,13 +398,22 @@ export function ContextPrompts(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaskPage() {
|
export function MaskPage(props: { className?: string }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
const filterLang = maskStore.language;
|
const [filterLang, setFilterLang] = useState<Lang | undefined>(
|
||||||
|
() => localStorage.getItem("Mask-language") as Lang | undefined,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterLang) {
|
||||||
|
localStorage.setItem("Mask-language", filterLang);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("Mask-language");
|
||||||
|
}
|
||||||
|
}, [filterLang]);
|
||||||
|
|
||||||
const allMasks = maskStore
|
const allMasks = maskStore
|
||||||
.getAll()
|
.getAll()
|
||||||
@@ -479,8 +466,13 @@ export function MaskPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<>
|
||||||
<div className={styles["mask-page"]}>
|
<div
|
||||||
|
className={`
|
||||||
|
${styles["mask-page"]}
|
||||||
|
${props.className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<div className="window-header">
|
<div className="window-header">
|
||||||
<div className="window-header-title">
|
<div className="window-header-title">
|
||||||
<div className="window-header-main-title">
|
<div className="window-header-main-title">
|
||||||
@@ -533,9 +525,9 @@ export function MaskPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.currentTarget.value;
|
const value = e.currentTarget.value;
|
||||||
if (value === Locale.Settings.Lang.All) {
|
if (value === Locale.Settings.Lang.All) {
|
||||||
maskStore.setLanguage(undefined);
|
setFilterLang(undefined);
|
||||||
} else {
|
} else {
|
||||||
maskStore.setLanguage(value as Lang);
|
setFilterLang(value as Lang);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -658,6 +650,6 @@ export function MaskPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ErrorBoundary>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ServiceProvider } from "@/app/constant";
|
|
||||||
import { ModalConfigValidator, ModelConfig } from "../store";
|
import { ModalConfigValidator, ModelConfig } from "../store";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
@@ -11,26 +10,25 @@ export function ModelConfigList(props: {
|
|||||||
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||||
}) {
|
}) {
|
||||||
const allModels = useAllModels();
|
const allModels = useAllModels();
|
||||||
const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListItem title={Locale.Settings.Model}>
|
<ListItem title={Locale.Settings.Model}>
|
||||||
<Select
|
<Select
|
||||||
aria-label={Locale.Settings.Model}
|
value={props.modelConfig.model}
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const [model, providerName] = e.currentTarget.value.split("@");
|
props.updateConfig(
|
||||||
props.updateConfig((config) => {
|
(config) =>
|
||||||
config.model = ModalConfigValidator.model(model);
|
(config.model = ModalConfigValidator.model(
|
||||||
config.providerName = providerName as ServiceProvider;
|
e.currentTarget.value,
|
||||||
});
|
)),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{allModels
|
{allModels
|
||||||
.filter((v) => v.available)
|
.filter((v) => v.available)
|
||||||
.map((v, i) => (
|
.map((v, i) => (
|
||||||
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
<option value={v.name} key={i}>
|
||||||
{v.displayName}({v.provider?.providerName})
|
{v.displayName}({v.provider?.providerName})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -41,7 +39,6 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
aria={Locale.Settings.Temperature.Title}
|
|
||||||
value={props.modelConfig.temperature?.toFixed(1)}
|
value={props.modelConfig.temperature?.toFixed(1)}
|
||||||
min="0"
|
min="0"
|
||||||
max="1" // lets limit it to 0-1
|
max="1" // lets limit it to 0-1
|
||||||
@@ -61,7 +58,6 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.TopP.SubTitle}
|
subTitle={Locale.Settings.TopP.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
aria={Locale.Settings.TopP.Title}
|
|
||||||
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
|
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
|
||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
@@ -81,7 +77,6 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={Locale.Settings.MaxTokens.Title}
|
|
||||||
type="number"
|
type="number"
|
||||||
min={1024}
|
min={1024}
|
||||||
max={512000}
|
max={512000}
|
||||||
@@ -97,14 +92,13 @@ export function ModelConfigList(props: {
|
|||||||
></input>
|
></input>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{props.modelConfig?.providerName == ServiceProvider.Google ? null : (
|
{props.modelConfig.model.startsWith("gemini") ? null : (
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={Locale.Settings.PresencePenalty.Title}
|
title={Locale.Settings.PresencePenalty.Title}
|
||||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
aria={Locale.Settings.PresencePenalty.Title}
|
|
||||||
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
||||||
min="-2"
|
min="-2"
|
||||||
max="2"
|
max="2"
|
||||||
@@ -126,7 +120,6 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
aria={Locale.Settings.FrequencyPenalty.Title}
|
|
||||||
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
||||||
min="-2"
|
min="-2"
|
||||||
max="2"
|
max="2"
|
||||||
@@ -148,7 +141,6 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={Locale.Settings.InjectSystemPrompts.Title}
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.modelConfig.enableInjectSystemPrompts}
|
checked={props.modelConfig.enableInjectSystemPrompts}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -166,7 +158,6 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={Locale.Settings.InputTemplate.Title}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={props.modelConfig.template}
|
value={props.modelConfig.template}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -183,7 +174,6 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||||
>
|
>
|
||||||
<InputRange
|
<InputRange
|
||||||
aria={Locale.Settings.HistoryCount.Title}
|
|
||||||
title={props.modelConfig.historyMessageCount.toString()}
|
title={props.modelConfig.historyMessageCount.toString()}
|
||||||
value={props.modelConfig.historyMessageCount}
|
value={props.modelConfig.historyMessageCount}
|
||||||
min="0"
|
min="0"
|
||||||
@@ -202,7 +192,6 @@ export function ModelConfigList(props: {
|
|||||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={Locale.Settings.CompressThreshold.Title}
|
|
||||||
type="number"
|
type="number"
|
||||||
min={500}
|
min={500}
|
||||||
max={4000}
|
max={4000}
|
||||||
@@ -218,7 +207,6 @@ export function ModelConfigList(props: {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||||
<input
|
<input
|
||||||
aria-label={Locale.Memory.Title}
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.modelConfig.sendMemory}
|
checked={props.modelConfig.sendMemory}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.mask-header {
|
.mask-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { MaskAvatar } from "./mask";
|
|||||||
import { useCommand } from "../command";
|
import { useCommand } from "../command";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
import { BUILTIN_MASK_STORE } from "../masks";
|
import { BUILTIN_MASK_STORE } from "../masks";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
|
||||||
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +72,7 @@ function useMaskGroup(masks: Mask[]) {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewChat() {
|
export function NewChat(props: { className?: string }) {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
|
|
||||||
@@ -110,8 +111,15 @@ export function NewChat() {
|
|||||||
}
|
}
|
||||||
}, [groups]);
|
}, [groups]);
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["new-chat"]}>
|
<div
|
||||||
|
className={`
|
||||||
|
${styles["new-chat"]}
|
||||||
|
${props.className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<div className={styles["mask-header"]}>
|
<div className={styles["mask-header"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<LeftIcon />}
|
icon={<LeftIcon />}
|
||||||
|
|||||||
@@ -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 { useEffect, 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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";
|
import styles from "./home.module.scss";
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ import AddIcon from "../icons/add.svg";
|
|||||||
import CloseIcon from "../icons/close.svg";
|
import CloseIcon from "../icons/close.svg";
|
||||||
import DeleteIcon from "../icons/delete.svg";
|
import DeleteIcon from "../icons/delete.svg";
|
||||||
import MaskIcon from "../icons/mask.svg";
|
import MaskIcon from "../icons/mask.svg";
|
||||||
|
import PluginIcon from "../icons/plugin.svg";
|
||||||
import DragIcon from "../icons/drag.svg";
|
import DragIcon from "../icons/drag.svg";
|
||||||
import DiscoveryIcon from "../icons/discovery.svg";
|
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
|
|
||||||
@@ -23,20 +23,19 @@ import {
|
|||||||
MIN_SIDEBAR_WIDTH,
|
MIN_SIDEBAR_WIDTH,
|
||||||
NARROW_SIDEBAR_WIDTH,
|
NARROW_SIDEBAR_WIDTH,
|
||||||
Path,
|
Path,
|
||||||
PLUGINS,
|
|
||||||
REPO_URL,
|
REPO_URL,
|
||||||
} from "../constant";
|
} from "../constant";
|
||||||
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { isIOS, useMobileScreen } from "../utils";
|
import { isIOS, useMobileScreen } from "../utils";
|
||||||
import dynamic from "next/dynamic";
|
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, {
|
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
||||||
loading: () => null,
|
loading: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useHotKey() {
|
function useHotKey() {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,7 +54,7 @@ export function useHotKey() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDragSideBar() {
|
function useDragSideBar() {
|
||||||
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
|
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
|
||||||
|
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
@@ -128,21 +127,25 @@ export function useDragSideBar() {
|
|||||||
shouldNarrow,
|
shouldNarrow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function SideBarContainer(props: {
|
|
||||||
children: React.ReactNode;
|
export function SideBar(props: { className?: string }) {
|
||||||
onDragStart: (e: MouseEvent) => void;
|
const chatStore = useChatStore();
|
||||||
shouldNarrow: boolean;
|
|
||||||
className?: string;
|
// drag side bar
|
||||||
}) {
|
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const config = useAppConfig();
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
const isIOSMobile = useMemo(
|
const isIOSMobile = useMemo(
|
||||||
() => isIOS() && isMobileScreen,
|
() => isIOS() && isMobileScreen,
|
||||||
[isMobileScreen],
|
[isMobileScreen],
|
||||||
);
|
);
|
||||||
const { children, className, onDragStart, shouldNarrow } = props;
|
|
||||||
|
useHotKey();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.sidebar} ${className} ${
|
className={`${styles.sidebar} ${props.className} ${
|
||||||
shouldNarrow && styles["narrow-sidebar"]
|
shouldNarrow && styles["narrow-sidebar"]
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -150,85 +153,18 @@ export function SideBarContainer(props: {
|
|||||||
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
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-header"]} data-tauri-drag-region>
|
||||||
<div className={styles["sidebar-title-container"]}>
|
|
||||||
<div className={styles["sidebar-title"]} data-tauri-drag-region>
|
<div className={styles["sidebar-title"]} data-tauri-drag-region>
|
||||||
{title}
|
NextChat
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["sidebar-sub-title"]}>{subTitle}</div>
|
<div className={styles["sidebar-sub-title"]}>
|
||||||
|
Build your own AI assistant.
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
|
<div className={styles["sidebar-logo"] + " no-dark"}>
|
||||||
|
<ChatGptIcon />
|
||||||
</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>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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"]}>
|
<div className={styles["sidebar-header-bar"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<MaskIcon />}
|
icon={<MaskIcon />}
|
||||||
@@ -244,36 +180,16 @@ export function SideBar(props: { className?: string }) {
|
|||||||
shadow
|
shadow
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<DiscoveryIcon />}
|
icon={<PluginIcon />}
|
||||||
text={shouldNarrow ? undefined : Locale.Discovery.Name}
|
text={shouldNarrow ? undefined : Locale.Plugin.Name}
|
||||||
className={styles["sidebar-bar-button"]}
|
className={styles["sidebar-bar-button"]}
|
||||||
onClick={() => setShowPluginSelector(true)}
|
onClick={() => showToast(Locale.WIP)}
|
||||||
shadow
|
shadow
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showPluginSelector && (
|
|
||||||
<Selector
|
<div
|
||||||
items={[
|
className={styles["sidebar-body"]}
|
||||||
{
|
|
||||||
title: "👇 Please select the plugin you need to use",
|
|
||||||
value: "-",
|
|
||||||
disable: true,
|
|
||||||
},
|
|
||||||
...PLUGINS.map((item) => {
|
|
||||||
return {
|
|
||||||
title: item.name,
|
|
||||||
value: item.path,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
onClose={() => setShowPluginSelector(false)}
|
|
||||||
onSelection={(s) => {
|
|
||||||
navigate(s[0], { state: { fromHome: true } });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SideBarHeader>
|
|
||||||
<SideBarBody
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
navigate(Path.Home);
|
navigate(Path.Home);
|
||||||
@@ -281,10 +197,10 @@ export function SideBar(props: { className?: string }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChatList narrow={shouldNarrow} />
|
<ChatList narrow={shouldNarrow} />
|
||||||
</SideBarBody>
|
</div>
|
||||||
<SideBarTail
|
|
||||||
primaryAction={
|
<div className={styles["sidebar-tail"]}>
|
||||||
<>
|
<div className={styles["sidebar-actions"]}>
|
||||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<DeleteIcon />}
|
icon={<DeleteIcon />}
|
||||||
@@ -297,25 +213,16 @@ export function SideBar(props: { className?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles["sidebar-action"]}>
|
<div className={styles["sidebar-action"]}>
|
||||||
<Link to={Path.Settings}>
|
<Link to={Path.Settings}>
|
||||||
<IconButton
|
<IconButton icon={<SettingsIcon />} shadow />
|
||||||
aria={Locale.Settings.Title}
|
|
||||||
icon={<SettingsIcon />}
|
|
||||||
shadow
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles["sidebar-action"]}>
|
<div className={styles["sidebar-action"]}>
|
||||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||||
<IconButton
|
<IconButton icon={<GithubIcon />} shadow />
|
||||||
aria={Locale.Export.MessageFromChatGPT}
|
|
||||||
icon={<GithubIcon />}
|
|
||||||
shadow
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
}
|
<div>
|
||||||
secondaryAction={
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<AddIcon />}
|
icon={<AddIcon />}
|
||||||
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
||||||
@@ -329,8 +236,15 @@ export function SideBar(props: { className?: string }) {
|
|||||||
}}
|
}}
|
||||||
shadow
|
shadow
|
||||||
/>
|
/>
|
||||||
}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</SideBarContainer>
|
|
||||||
|
<div
|
||||||
|
className={styles["sidebar-drag"]}
|
||||||
|
onPointerDown={(e) => onDragStart(e as any)}
|
||||||
|
>
|
||||||
|
<DragIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,19 +61,6 @@
|
|||||||
font-weight: normal;
|
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 {
|
.list {
|
||||||
@@ -304,12 +291,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
.selector-item-disabled{
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
min-width: 300px;
|
|
||||||
.list {
|
.list {
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@@ -13,15 +13,7 @@ import MinIcon from "../icons/min.svg";
|
|||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
|
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import React, {
|
import React, { HTMLProps, useEffect, useState } from "react";
|
||||||
CSSProperties,
|
|
||||||
HTMLProps,
|
|
||||||
MouseEvent,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
|
|
||||||
export function Popover(props: {
|
export function Popover(props: {
|
||||||
@@ -50,21 +42,16 @@ export function Card(props: { children: JSX.Element[]; className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ListItem(props: {
|
export function ListItem(props: {
|
||||||
title?: string;
|
title: string;
|
||||||
subTitle?: string | JSX.Element;
|
subTitle?: string;
|
||||||
children?: JSX.Element | JSX.Element[];
|
children?: JSX.Element | JSX.Element[];
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: (e: MouseEvent) => void;
|
onClick?: () => void;
|
||||||
vertical?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={styles["list-item"] + ` ${props.className || ""}`}
|
||||||
styles["list-item"] +
|
|
||||||
` ${props.vertical ? styles["vertical"] : ""} ` +
|
|
||||||
` ${props.className || ""}`
|
|
||||||
}
|
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<div className={styles["list-header"]}>
|
<div className={styles["list-header"]}>
|
||||||
@@ -114,6 +101,7 @@ interface ModalProps {
|
|||||||
defaultMax?: boolean;
|
defaultMax?: boolean;
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
export function Modal(props: ModalProps) {
|
export function Modal(props: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,14 +123,14 @@ export function Modal(props: ModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={`${styles["modal-container"]} ${
|
||||||
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
isMax && styles["modal-container-max"]
|
||||||
}
|
} ${props.className ?? ""}`}
|
||||||
>
|
>
|
||||||
<div className={styles["modal-header"]}>
|
<div className={`${styles["modal-header"]} new-header follow-parent-svg`}>
|
||||||
<div className={styles["modal-title"]}>{props.title}</div>
|
<div className={`${styles["modal-title"]}`}>{props.title}</div>
|
||||||
|
|
||||||
<div className={styles["modal-header-actions"]}>
|
<div className={`${styles["modal-header-actions"]}`}>
|
||||||
<div
|
<div
|
||||||
className={styles["modal-header-action"]}
|
className={styles["modal-header-action"]}
|
||||||
onClick={() => setMax(!isMax)}
|
onClick={() => setMax(!isMax)}
|
||||||
@@ -160,11 +148,11 @@ export function Modal(props: ModalProps) {
|
|||||||
|
|
||||||
<div className={styles["modal-content"]}>{props.children}</div>
|
<div className={styles["modal-content"]}>{props.children}</div>
|
||||||
|
|
||||||
<div className={styles["modal-footer"]}>
|
<div className={`${styles["modal-footer"]} new-footer`}>
|
||||||
{props.footer}
|
{props.footer}
|
||||||
<div className={styles["modal-actions"]}>
|
<div className={styles["modal-actions"]}>
|
||||||
{props.actions?.map((action, i) => (
|
{props.actions?.map((action, i) => (
|
||||||
<div key={i} className={styles["modal-action"]}>
|
<div key={i} className={`${styles["modal-action"]} new-btn`}>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -265,10 +253,9 @@ export function Input(props: InputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordInput(
|
export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
||||||
props: HTMLProps<HTMLInputElement> & { aria?: string },
|
|
||||||
) {
|
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
function changeVisibility() {
|
function changeVisibility() {
|
||||||
setVisible(!visible);
|
setVisible(!visible);
|
||||||
}
|
}
|
||||||
@@ -276,7 +263,6 @@ export function PasswordInput(
|
|||||||
return (
|
return (
|
||||||
<div className={"password-input-container"}>
|
<div className={"password-input-container"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria={props.aria}
|
|
||||||
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
||||||
onClick={changeVisibility}
|
onClick={changeVisibility}
|
||||||
className={"password-eye"}
|
className={"password-eye"}
|
||||||
@@ -435,25 +421,17 @@ export function showPrompt(content: any, value = "", rows = 3) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showImageModal(
|
export function showImageModal(img: string) {
|
||||||
img: string,
|
|
||||||
defaultMax?: boolean,
|
|
||||||
style?: CSSProperties,
|
|
||||||
boxStyle?: CSSProperties,
|
|
||||||
) {
|
|
||||||
showModal({
|
showModal({
|
||||||
title: Locale.Export.Image.Modal,
|
title: Locale.Export.Image.Modal,
|
||||||
defaultMax: defaultMax,
|
|
||||||
children: (
|
children: (
|
||||||
<div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
|
<div>
|
||||||
<img
|
<img
|
||||||
src={img}
|
src={img}
|
||||||
alt="preview"
|
alt="preview"
|
||||||
style={
|
style={{
|
||||||
style ?? {
|
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
}
|
}}
|
||||||
}
|
|
||||||
></img>
|
></img>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -465,56 +443,27 @@ export function Selector<T>(props: {
|
|||||||
title: string;
|
title: string;
|
||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
value: T;
|
value: T;
|
||||||
disable?: boolean;
|
|
||||||
}>;
|
}>;
|
||||||
defaultSelectedValue?: T[] | T;
|
defaultSelectedValue?: T;
|
||||||
onSelection?: (selection: T[]) => void;
|
onSelection?: (selection: T[]) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
multiple?: boolean;
|
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 (
|
return (
|
||||||
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
||||||
<div className={styles["selector-content"]}>
|
<div className={styles["selector-content"]}>
|
||||||
<List>
|
<List>
|
||||||
{props.items.map((item, i) => {
|
{props.items.map((item, i) => {
|
||||||
const selected = selectedValues.includes(item.value);
|
const selected = props.defaultSelectedValue === item.value;
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
className={`${styles["selector-item"]} ${
|
className={styles["selector-item"]}
|
||||||
item.disable && styles["selector-item-disabled"]
|
|
||||||
}`}
|
|
||||||
key={i}
|
key={i}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
subTitle={item.subTitle}
|
subTitle={item.subTitle}
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
if (item.disable) {
|
props.onSelection?.([item.value]);
|
||||||
e.stopPropagation();
|
props.onClose?.();
|
||||||
} else {
|
|
||||||
handleSelection(e, item.value);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
@@ -537,38 +486,3 @@ export function Selector<T>(props: {
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import tauriConfig from "../../src-tauri/tauri.conf.json";
|
import tauriConfig from "../../src-tauri/tauri.conf.json";
|
||||||
import { DEFAULT_INPUT_TEMPLATE } from "../constant";
|
|
||||||
|
|
||||||
export const getBuildConfig = () => {
|
export const getBuildConfig = () => {
|
||||||
if (typeof process === "undefined") {
|
if (typeof process === "undefined") {
|
||||||
@@ -39,7 +38,6 @@ export const getBuildConfig = () => {
|
|||||||
...commitInfo,
|
...commitInfo,
|
||||||
buildMode,
|
buildMode,
|
||||||
isApp,
|
isApp,
|
||||||
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build";
|
|||||||
export function getClientConfig() {
|
export function getClientConfig() {
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
// client side
|
// client side
|
||||||
return JSON.parse(queryMeta("config") || "{}") as BuildConfig;
|
return JSON.parse(queryMeta("config")) as BuildConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof process !== "undefined") {
|
if (typeof process !== "undefined") {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import md5 from "spark-md5";
|
import md5 from "spark-md5";
|
||||||
import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant";
|
import { DEFAULT_MODELS } from "../constant";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
@@ -21,11 +21,7 @@ declare global {
|
|||||||
ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
|
ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
|
||||||
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
|
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
|
||||||
CUSTOM_MODELS?: string; // to control custom models
|
CUSTOM_MODELS?: string; // to control custom models
|
||||||
DEFAULT_MODEL?: string; // to control default model in every new chat window
|
DEFAULT_MODEL?: string; // to cnntrol default model in every new chat window
|
||||||
|
|
||||||
// stability only
|
|
||||||
STABILITY_URL?: string;
|
|
||||||
STABILITY_API_KEY?: string;
|
|
||||||
|
|
||||||
// azure only
|
// azure only
|
||||||
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
|
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
|
||||||
@@ -38,41 +34,6 @@ declare global {
|
|||||||
|
|
||||||
// google tag manager
|
// google tag manager
|
||||||
GTM_ID?: string;
|
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 +51,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 = () => {
|
export const getServerSideConfig = () => {
|
||||||
if (typeof process === "undefined") {
|
if (typeof process === "undefined") {
|
||||||
throw Error(
|
throw Error(
|
||||||
@@ -119,102 +64,48 @@ export const getServerSideConfig = () => {
|
|||||||
|
|
||||||
if (disableGPT4) {
|
if (disableGPT4) {
|
||||||
if (customModels) customModels += ",";
|
if (customModels) customModels += ",";
|
||||||
customModels += DEFAULT_MODELS.filter(
|
customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4"))
|
||||||
(m) =>
|
|
||||||
(m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o")) &&
|
|
||||||
!m.name.startsWith("gpt-4o-mini"),
|
|
||||||
)
|
|
||||||
.map((m) => "-" + m.name)
|
.map((m) => "-" + m.name)
|
||||||
.join(",");
|
.join(",");
|
||||||
if (
|
if (defaultModel.startsWith("gpt-4")) defaultModel = "";
|
||||||
(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 isAzure = !!process.env.AZURE_URL;
|
||||||
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
||||||
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
||||||
const isTencent = !!process.env.TENCENT_API_KEY;
|
|
||||||
|
|
||||||
const isBaidu = !!process.env.BAIDU_API_KEY;
|
const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
||||||
const isBytedance = !!process.env.BYTEDANCE_API_KEY;
|
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||||
const isAlibaba = !!process.env.ALIBABA_API_KEY;
|
const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||||
const isMoonshot = !!process.env.MOONSHOT_API_KEY;
|
const apiKey = apiKeys[randomIndex];
|
||||||
const isIflytek = !!process.env.IFLYTEK_API_KEY;
|
console.log(
|
||||||
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
`[Server Config] using ${randomIndex + 1} of ${apiKeys.length} 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 = (
|
const whiteWebDevEndpoints = (process.env.WHITE_WEBDEV_ENDPOINTS ?? "").split(
|
||||||
process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
|
",",
|
||||||
).split(",");
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl: process.env.BASE_URL,
|
baseUrl: process.env.BASE_URL,
|
||||||
apiKey: getApiKey(process.env.OPENAI_API_KEY),
|
apiKey,
|
||||||
openaiOrgId: process.env.OPENAI_ORG_ID,
|
openaiOrgId: process.env.OPENAI_ORG_ID,
|
||||||
|
|
||||||
isStability,
|
|
||||||
stabilityUrl: process.env.STABILITY_URL,
|
|
||||||
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
|
|
||||||
|
|
||||||
isAzure,
|
isAzure,
|
||||||
azureUrl: process.env.AZURE_URL,
|
azureUrl: process.env.AZURE_URL,
|
||||||
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
|
azureApiKey: process.env.AZURE_API_KEY,
|
||||||
azureApiVersion: process.env.AZURE_API_VERSION,
|
azureApiVersion: process.env.AZURE_API_VERSION,
|
||||||
|
|
||||||
isGoogle,
|
isGoogle,
|
||||||
googleApiKey: getApiKey(process.env.GOOGLE_API_KEY),
|
googleApiKey: process.env.GOOGLE_API_KEY,
|
||||||
googleUrl: process.env.GOOGLE_URL,
|
googleUrl: process.env.GOOGLE_URL,
|
||||||
|
|
||||||
isAnthropic,
|
isAnthropic,
|
||||||
anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY),
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
||||||
anthropicUrl: process.env.ANTHROPIC_URL,
|
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,
|
gtmId: process.env.GTM_ID,
|
||||||
gaId: process.env.GA_ID || DEFAULT_GA_ID,
|
|
||||||
|
|
||||||
needCode: ACCESS_CODES.size > 0,
|
needCode: ACCESS_CODES.size > 0,
|
||||||
code: process.env.CODE,
|
code: process.env.CODE,
|
||||||
@@ -229,6 +120,6 @@ export const getServerSideConfig = () => {
|
|||||||
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
||||||
customModels,
|
customModels,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
allowedWebDevEndpoints,
|
whiteWebDevEndpoints,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
315
app/constant.ts
315
app/constant.ts
@@ -1,9 +1,6 @@
|
|||||||
import path from "path";
|
export const OWNER = "Yidadaa";
|
||||||
|
|
||||||
export const OWNER = "ChatGPTNextWeb";
|
|
||||||
export const REPO = "ChatGPT-Next-Web";
|
export const REPO = "ChatGPT-Next-Web";
|
||||||
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
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 ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
|
||||||
export const UPDATE_URL = `${REPO_URL}#keep-updated`;
|
export const UPDATE_URL = `${REPO_URL}#keep-updated`;
|
||||||
export const RELEASE_URL = `${REPO_URL}/releases`;
|
export const RELEASE_URL = `${REPO_URL}/releases`;
|
||||||
@@ -11,57 +8,25 @@ 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 FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
|
||||||
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
|
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_API_HOST = "https://api.nextchat.dev";
|
||||||
export const OPENAI_BASE_URL = "https://api.openai.com";
|
export const OPENAI_BASE_URL = "https://api.openai.com";
|
||||||
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||||
|
|
||||||
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.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 {
|
export enum Path {
|
||||||
Home = "/",
|
Home = "/",
|
||||||
Chat = "/chat",
|
Chat = "/chat",
|
||||||
Settings = "/settings",
|
Settings = "/settings",
|
||||||
NewChat = "/new-chat",
|
NewChat = "/new-chat",
|
||||||
Masks = "/masks",
|
Masks = "/masks",
|
||||||
Plugins = "/plugins",
|
|
||||||
Auth = "/auth",
|
Auth = "/auth",
|
||||||
Sd = "/sd",
|
|
||||||
SdNew = "/sd-new",
|
|
||||||
Artifacts = "/artifacts",
|
|
||||||
SearchChat = "/search-chat",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ApiPath {
|
export enum ApiPath {
|
||||||
Cors = "",
|
Cors = "",
|
||||||
Azure = "/api/azure",
|
|
||||||
OpenAI = "/api/openai",
|
OpenAI = "/api/openai",
|
||||||
Anthropic = "/api/anthropic",
|
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 {
|
export enum SlotID {
|
||||||
@@ -76,21 +41,26 @@ export enum FileName {
|
|||||||
|
|
||||||
export enum StoreKey {
|
export enum StoreKey {
|
||||||
Chat = "chat-next-web-store",
|
Chat = "chat-next-web-store",
|
||||||
Plugin = "chat-next-web-plugin",
|
|
||||||
Access = "access-control",
|
Access = "access-control",
|
||||||
Config = "app-config",
|
Config = "app-config",
|
||||||
Mask = "mask-store",
|
Mask = "mask-store",
|
||||||
Prompt = "prompt-store",
|
Prompt = "prompt-store",
|
||||||
Update = "chat-update",
|
Update = "chat-update",
|
||||||
Sync = "sync",
|
Sync = "sync",
|
||||||
SdList = "sd-list",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
|
||||||
export const MAX_SIDEBAR_WIDTH = 500;
|
|
||||||
export const MIN_SIDEBAR_WIDTH = 230;
|
|
||||||
export const NARROW_SIDEBAR_WIDTH = 100;
|
export const NARROW_SIDEBAR_WIDTH = 100;
|
||||||
|
|
||||||
|
export const DEFAULT_SIDEBAR_WIDTH = 340;
|
||||||
|
export const MAX_SIDEBAR_WIDTH = 440;
|
||||||
|
export const MIN_SIDEBAR_WIDTH = 230;
|
||||||
|
|
||||||
|
export const WINDOW_WIDTH_SM = 480;
|
||||||
|
export const WINDOW_WIDTH_MD = 768;
|
||||||
|
export const WINDOW_WIDTH_LG = 1120;
|
||||||
|
export const WINDOW_WIDTH_XL = 1440;
|
||||||
|
export const WINDOW_WIDTH_2XL = 1980;
|
||||||
|
|
||||||
export const ACCESS_CODE_PREFIX = "nk-";
|
export const ACCESS_CODE_PREFIX = "nk-";
|
||||||
|
|
||||||
export const LAST_INPUT_KEY = "last-input";
|
export const LAST_INPUT_KEY = "last-input";
|
||||||
@@ -107,42 +77,14 @@ export enum ServiceProvider {
|
|||||||
Azure = "Azure",
|
Azure = "Azure",
|
||||||
Google = "Google",
|
Google = "Google",
|
||||||
Anthropic = "Anthropic",
|
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 {
|
export enum ModelProvider {
|
||||||
Stability = "Stability",
|
|
||||||
GPT = "GPT",
|
GPT = "GPT",
|
||||||
GeminiPro = "GeminiPro",
|
GeminiPro = "GeminiPro",
|
||||||
Claude = "Claude",
|
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 = {
|
export const Anthropic = {
|
||||||
ChatPath: "v1/messages",
|
ChatPath: "v1/messages",
|
||||||
ChatPath1: "v1/complete",
|
ChatPath1: "v1/complete",
|
||||||
@@ -152,69 +94,19 @@ export const Anthropic = {
|
|||||||
|
|
||||||
export const OpenaiPath = {
|
export const OpenaiPath = {
|
||||||
ChatPath: "v1/chat/completions",
|
ChatPath: "v1/chat/completions",
|
||||||
ImagePath: "v1/images/generations",
|
|
||||||
UsagePath: "dashboard/billing/usage",
|
UsagePath: "dashboard/billing/usage",
|
||||||
SubsPath: "dashboard/billing/subscription",
|
SubsPath: "dashboard/billing/subscription",
|
||||||
ListModelPath: "v1/models",
|
ListModelPath: "v1/models",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Azure = {
|
export const Azure = {
|
||||||
ChatPath: (deployName: string, apiVersion: string) =>
|
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
|
||||||
`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 = {
|
export const Google = {
|
||||||
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
|
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
|
||||||
ChatPath: (modelName: string) =>
|
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
||||||
`v1beta/models/${modelName}:streamGenerateContent`,
|
VisionChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
||||||
@@ -235,7 +127,7 @@ Latex inline: \\(x^2\\)
|
|||||||
Latex block: $$e=mc^2$$
|
Latex block: $$e=mc^2$$
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SUMMARIZE_MODEL = "gpt-4o-mini";
|
export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
|
||||||
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
|
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
|
||||||
|
|
||||||
export const KnowledgeCutOffDate: Record<string, string> = {
|
export const KnowledgeCutOffDate: Record<string, string> = {
|
||||||
@@ -243,15 +135,9 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
|||||||
"gpt-4-turbo": "2023-12",
|
"gpt-4-turbo": "2023-12",
|
||||||
"gpt-4-turbo-2024-04-09": "2023-12",
|
"gpt-4-turbo-2024-04-09": "2023-12",
|
||||||
"gpt-4-turbo-preview": "2023-12",
|
"gpt-4-turbo-preview": "2023-12",
|
||||||
"gpt-4o": "2023-10",
|
"gpt-4-1106-preview": "2023-04",
|
||||||
"gpt-4o-2024-05-13": "2023-10",
|
"gpt-4-0125-preview": "2023-12",
|
||||||
"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-vision-preview": "2023-04",
|
"gpt-4-vision-preview": "2023-04",
|
||||||
"o1-mini": "2023-10",
|
|
||||||
"o1-preview": "2023-10",
|
|
||||||
// After improvements,
|
// After improvements,
|
||||||
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
||||||
"gemini-pro": "2023-12",
|
"gemini-pro": "2023-12",
|
||||||
@@ -260,32 +146,29 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
|||||||
|
|
||||||
const openaiModels = [
|
const openaiModels = [
|
||||||
"gpt-3.5-turbo",
|
"gpt-3.5-turbo",
|
||||||
|
"gpt-3.5-turbo-0301",
|
||||||
|
"gpt-3.5-turbo-0613",
|
||||||
"gpt-3.5-turbo-1106",
|
"gpt-3.5-turbo-1106",
|
||||||
"gpt-3.5-turbo-0125",
|
"gpt-3.5-turbo-0125",
|
||||||
|
"gpt-3.5-turbo-16k",
|
||||||
|
"gpt-3.5-turbo-16k-0613",
|
||||||
"gpt-4",
|
"gpt-4",
|
||||||
|
"gpt-4-0314",
|
||||||
"gpt-4-0613",
|
"gpt-4-0613",
|
||||||
|
"gpt-4-1106-preview",
|
||||||
|
"gpt-4-0125-preview",
|
||||||
"gpt-4-32k",
|
"gpt-4-32k",
|
||||||
|
"gpt-4-32k-0314",
|
||||||
"gpt-4-32k-0613",
|
"gpt-4-32k-0613",
|
||||||
"gpt-4-turbo",
|
"gpt-4-turbo",
|
||||||
"gpt-4-turbo-preview",
|
"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-vision-preview",
|
||||||
"gpt-4-turbo-2024-04-09",
|
"gpt-4-turbo-2024-04-09",
|
||||||
"gpt-4-1106-preview",
|
|
||||||
"dall-e-3",
|
|
||||||
"o1-mini",
|
|
||||||
"o1-preview"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const googleModels = [
|
const googleModels = [
|
||||||
"gemini-1.0-pro",
|
"gemini-1.0-pro",
|
||||||
"gemini-1.5-pro-latest",
|
"gemini-1.5-pro-latest",
|
||||||
"gemini-1.5-flash-latest",
|
|
||||||
"gemini-pro-vision",
|
"gemini-pro-vision",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -296,172 +179,34 @@ const anthropicModels = [
|
|||||||
"claude-3-sonnet-20240229",
|
"claude-3-sonnet-20240229",
|
||||||
"claude-3-opus-20240229",
|
"claude-3-opus-20240229",
|
||||||
"claude-3-haiku-20240307",
|
"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 = [
|
export const DEFAULT_MODELS = [
|
||||||
...openaiModels.map((name) => ({
|
...openaiModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
sorted: seq++, // Global sequence sort(index)
|
|
||||||
provider: {
|
provider: {
|
||||||
id: "openai",
|
id: "openai",
|
||||||
providerName: "OpenAI",
|
providerName: "OpenAI",
|
||||||
providerType: "openai",
|
providerType: "openai",
|
||||||
sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...openaiModels.map((name) => ({
|
|
||||||
name,
|
|
||||||
available: true,
|
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
|
||||||
id: "azure",
|
|
||||||
providerName: "Azure",
|
|
||||||
providerType: "azure",
|
|
||||||
sorted: 2,
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...googleModels.map((name) => ({
|
...googleModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
provider: {
|
||||||
id: "google",
|
id: "google",
|
||||||
providerName: "Google",
|
providerName: "Google",
|
||||||
providerType: "google",
|
providerType: "google",
|
||||||
sorted: 3,
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...anthropicModels.map((name) => ({
|
...anthropicModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
available: true,
|
available: true,
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
provider: {
|
||||||
id: "anthropic",
|
id: "anthropic",
|
||||||
providerName: "Anthropic",
|
providerName: "Anthropic",
|
||||||
providerType: "anthropic",
|
providerType: "anthropic",
|
||||||
sorted: 4,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...baiduModels.map((name) => ({
|
|
||||||
name,
|
|
||||||
available: true,
|
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
|
||||||
id: "baidu",
|
|
||||||
providerName: "Baidu",
|
|
||||||
providerType: "baidu",
|
|
||||||
sorted: 5,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...bytedanceModels.map((name) => ({
|
|
||||||
name,
|
|
||||||
available: true,
|
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
|
||||||
id: "bytedance",
|
|
||||||
providerName: "ByteDance",
|
|
||||||
providerType: "bytedance",
|
|
||||||
sorted: 6,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...alibabaModes.map((name) => ({
|
|
||||||
name,
|
|
||||||
available: true,
|
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
|
||||||
id: "alibaba",
|
|
||||||
providerName: "Alibaba",
|
|
||||||
providerType: "alibaba",
|
|
||||||
sorted: 7,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...tencentModels.map((name) => ({
|
|
||||||
name,
|
|
||||||
available: true,
|
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
|
||||||
id: "tencent",
|
|
||||||
providerName: "Tencent",
|
|
||||||
providerType: "tencent",
|
|
||||||
sorted: 8,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...moonshotModes.map((name) => ({
|
|
||||||
name,
|
|
||||||
available: true,
|
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
|
||||||
id: "moonshot",
|
|
||||||
providerName: "Moonshot",
|
|
||||||
providerType: "moonshot",
|
|
||||||
sorted: 9,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...iflytekModels.map((name) => ({
|
|
||||||
name,
|
|
||||||
available: true,
|
|
||||||
sorted: seq++,
|
|
||||||
provider: {
|
|
||||||
id: "iflytek",
|
|
||||||
providerName: "Iflytek",
|
|
||||||
providerType: "iflytek",
|
|
||||||
sorted: 10,
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
] as const;
|
] as const;
|
||||||
@@ -470,21 +215,15 @@ export const CHAT_PAGE_SIZE = 15;
|
|||||||
export const MAX_RENDER_MSG_COUNT = 45;
|
export const MAX_RENDER_MSG_COUNT = 45;
|
||||||
|
|
||||||
// some famous webdav endpoints
|
// some famous webdav endpoints
|
||||||
export const internalAllowedWebDavEndpoints = [
|
export const internalWhiteWebDavEndpoints = [
|
||||||
"https://dav.jianguoyun.com/dav/",
|
"https://dav.jianguoyun.com/dav/",
|
||||||
"https://dav.dropdav.com/",
|
"https://dav.dropdav.com/",
|
||||||
"https://dav.box.com/dav",
|
"https://dav.box.com/dav",
|
||||||
"https://nanao.teracloud.jp/dav/",
|
"https://nanao.teracloud.jp/dav/",
|
||||||
"https://bora.teracloud.jp/dav/",
|
|
||||||
"https://webdav.4shared.com/",
|
"https://webdav.4shared.com/",
|
||||||
"https://dav.idrivesync.com",
|
"https://dav.idrivesync.com",
|
||||||
"https://webdav.yandex.com",
|
"https://webdav.yandex.com",
|
||||||
"https://app.koofr.net/dav/Koofr",
|
"https://app.koofr.net/dav/Koofr",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_GA_ID = "G-89WN60ZK2E";
|
export const SIDEBAR_ID = "sidebar";
|
||||||
export const PLUGINS = [
|
|
||||||
{ name: "Plugins", path: Path.Plugins },
|
|
||||||
{ name: "Stable Diffusion", path: Path.Sd },
|
|
||||||
{ name: "Search Chat", path: Path.SearchChat },
|
|
||||||
];
|
|
||||||
|
|||||||
301
app/containers/Chat/ChatPanel.tsx
Normal file
301
app/containers/Chat/ChatPanel.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
useChatStore,
|
||||||
|
BOT_HELLO,
|
||||||
|
createMessage,
|
||||||
|
useAccessStore,
|
||||||
|
useAppConfig,
|
||||||
|
ModelType,
|
||||||
|
} from "@/app/store";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { Selector, showConfirm, showToast } from "@/app/components/ui-lib";
|
||||||
|
import {
|
||||||
|
CHAT_PAGE_SIZE,
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
UNFINISHED_INPUT,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { useCommand } from "@/app/command";
|
||||||
|
import { prettyObject } from "@/app/utils/format";
|
||||||
|
import { ExportMessageModal } from "@/app/components/exporter";
|
||||||
|
|
||||||
|
import PromptToast from "./components/PromptToast";
|
||||||
|
import { EditMessageModal } from "./components/EditMessageModal";
|
||||||
|
import ChatHeader from "./components/ChatHeader";
|
||||||
|
import ChatInputPanel, {
|
||||||
|
ChatInputPanelInstance,
|
||||||
|
} from "./components/ChatInputPanel";
|
||||||
|
import ChatMessagePanel, { RenderMessage } from "./components/ChatMessagePanel";
|
||||||
|
import { useAllModels } from "@/app/utils/hooks";
|
||||||
|
import useRows from "@/app/hooks/useRows";
|
||||||
|
import SessionConfigModel from "./components/SessionConfigModal";
|
||||||
|
import useScrollToBottom from "@/app/hooks/useScrollToBottom";
|
||||||
|
|
||||||
|
function _Chat() {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
|
||||||
|
const [showExport, setShowExport] = useState(false);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [userInput, setUserInput] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chatInputPanelRef = useRef<ChatInputPanelInstance | null>(null);
|
||||||
|
|
||||||
|
const [hitBottom, setHitBottom] = useState(true);
|
||||||
|
|
||||||
|
const [attachImages, setAttachImages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// auto grow input
|
||||||
|
const { measure, inputRows } = useRows({
|
||||||
|
inputRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(scrollRef);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(measure, [userInput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatStore.updateCurrentSession((session) => {
|
||||||
|
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
|
||||||
|
session.messages.forEach((m) => {
|
||||||
|
// check if should stop all stale messages
|
||||||
|
if (m.isError || new Date(m.date).getTime() < stopTiming) {
|
||||||
|
if (m.streaming) {
|
||||||
|
m.streaming = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.content.length === 0) {
|
||||||
|
m.isError = true;
|
||||||
|
m.content = prettyObject({
|
||||||
|
error: true,
|
||||||
|
message: "empty response",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// auto sync mask config from global config
|
||||||
|
if (session.mask.syncGlobalConfig) {
|
||||||
|
console.log("[Mask] syncing from global, name = ", session.mask.name);
|
||||||
|
session.mask.modelConfig = { ...config.modelConfig };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const context: RenderMessage[] = useMemo(() => {
|
||||||
|
return session.mask.hideContext ? [] : session.mask.context.slice();
|
||||||
|
}, [session.mask.context, session.mask.hideContext]);
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
|
if (
|
||||||
|
context.length === 0 &&
|
||||||
|
session.messages.at(0)?.content !== BOT_HELLO.content
|
||||||
|
) {
|
||||||
|
const copiedHello = Object.assign({}, BOT_HELLO);
|
||||||
|
if (!accessStore.isAuthorized()) {
|
||||||
|
copiedHello.content = Locale.Error.Unauthorized;
|
||||||
|
}
|
||||||
|
context.push(copiedHello);
|
||||||
|
}
|
||||||
|
|
||||||
|
// preview messages
|
||||||
|
const renderMessages = useMemo(() => {
|
||||||
|
return context
|
||||||
|
.concat(session.messages as RenderMessage[])
|
||||||
|
.concat(
|
||||||
|
isLoading
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...createMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: "……",
|
||||||
|
}),
|
||||||
|
preview: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
.concat(
|
||||||
|
userInput.length > 0 && config.sendPreviewBubble
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...createMessage(
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: userInput,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customId: "typing",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
preview: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
config.sendPreviewBubble,
|
||||||
|
context,
|
||||||
|
isLoading,
|
||||||
|
session.messages,
|
||||||
|
userInput,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [msgRenderIndex, _setMsgRenderIndex] = useState(
|
||||||
|
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
fill: setUserInput,
|
||||||
|
submit: (text) => {
|
||||||
|
chatInputPanelRef.current?.doSubmit(text);
|
||||||
|
},
|
||||||
|
code: (text) => {
|
||||||
|
if (accessStore.disableFastLink) return;
|
||||||
|
console.log("[Command] got code from url: ", text);
|
||||||
|
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
|
||||||
|
if (res) {
|
||||||
|
accessStore.update((access) => (access.accessCode = text));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
settings: (text) => {
|
||||||
|
if (accessStore.disableFastLink) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(text) as {
|
||||||
|
key?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Command] got settings from url: ", payload);
|
||||||
|
|
||||||
|
if (payload.key || payload.url) {
|
||||||
|
showConfirm(
|
||||||
|
Locale.URLCommand.Settings +
|
||||||
|
`\n${JSON.stringify(payload, null, 4)}`,
|
||||||
|
).then((res) => {
|
||||||
|
if (!res) return;
|
||||||
|
if (payload.key) {
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.openaiApiKey = payload.key!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.url) {
|
||||||
|
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error("[Command] failed to get settings from url: ", text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// edit / insert message modal
|
||||||
|
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
||||||
|
|
||||||
|
// remember unfinished input
|
||||||
|
useEffect(() => {
|
||||||
|
// try to load from local storage
|
||||||
|
const key = UNFINISHED_INPUT(session.id);
|
||||||
|
const mayBeUnfinishedInput = localStorage.getItem(key);
|
||||||
|
if (mayBeUnfinishedInput && userInput.length === 0) {
|
||||||
|
setUserInput(mayBeUnfinishedInput);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dom = inputRef.current;
|
||||||
|
return () => {
|
||||||
|
localStorage.setItem(key, dom?.value ?? "");
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const chatinputPanelProps = {
|
||||||
|
inputRef,
|
||||||
|
isMobileScreen,
|
||||||
|
renderMessages,
|
||||||
|
attachImages,
|
||||||
|
userInput,
|
||||||
|
hitBottom,
|
||||||
|
inputRows,
|
||||||
|
setAttachImages,
|
||||||
|
setUserInput,
|
||||||
|
setIsLoading,
|
||||||
|
showChatSetting: setShowPromptModal,
|
||||||
|
_setMsgRenderIndex,
|
||||||
|
scrollDomToBottom,
|
||||||
|
setAutoScroll,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatMessagePanelProps = {
|
||||||
|
scrollRef,
|
||||||
|
inputRef,
|
||||||
|
isMobileScreen,
|
||||||
|
msgRenderIndex,
|
||||||
|
userInput,
|
||||||
|
context,
|
||||||
|
renderMessages,
|
||||||
|
setAutoScroll,
|
||||||
|
setMsgRenderIndex: chatInputPanelRef.current?.setMsgRenderIndex,
|
||||||
|
setHitBottom,
|
||||||
|
setUserInput,
|
||||||
|
setIsLoading,
|
||||||
|
setShowPromptModal,
|
||||||
|
scrollDomToBottom,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative flex flex-col overflow-hidden bg-chat-panel
|
||||||
|
max-md:absolute max-md:h-[100vh] max-md:w-[100%]
|
||||||
|
md:h-[100%] md:mr-2.5 md:rounded-md
|
||||||
|
`}
|
||||||
|
key={session.id}
|
||||||
|
>
|
||||||
|
<ChatHeader
|
||||||
|
setIsEditingMessage={setIsEditingMessage}
|
||||||
|
setShowExport={setShowExport}
|
||||||
|
isMobileScreen={isMobileScreen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatMessagePanel {...chatMessagePanelProps} />
|
||||||
|
|
||||||
|
<ChatInputPanel ref={chatInputPanelRef} {...chatinputPanelProps} />
|
||||||
|
|
||||||
|
{showExport && (
|
||||||
|
<ExportMessageModal onClose={() => setShowExport(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditingMessage && (
|
||||||
|
<EditMessageModal
|
||||||
|
onClose={() => {
|
||||||
|
setIsEditingMessage(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PromptToast showToast={!hitBottom} setShowModal={setShowPromptModal} />
|
||||||
|
|
||||||
|
{showPromptModal && (
|
||||||
|
<SessionConfigModel onClose={() => setShowPromptModal(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chat() {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const sessionIndex = chatStore.currentSessionIndex;
|
||||||
|
return <_Chat key={sessionIndex}></_Chat>;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user