Compare commits
	
		
			4 Commits
		
	
	
		
			add_tip
			...
			feat/voice
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 52316785d1 | ||
|  | e2b15f785a | ||
|  | 42d04d473e | ||
|  | 2f53107581 | 
| @@ -1,97 +1,8 @@ | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
|  | ||||
| # Runtime data | ||||
| pids | ||||
| *.pid | ||||
| *.seed | ||||
| *.pid.lock | ||||
|  | ||||
| # Directory for instrumented libs generated by jscoverage/JSCover | ||||
| lib-cov | ||||
|  | ||||
| # Coverage directory used by tools like istanbul | ||||
| coverage | ||||
| *.lcov | ||||
|  | ||||
| # nyc test coverage | ||||
| .nyc_output | ||||
|  | ||||
| # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) | ||||
| .grunt | ||||
|  | ||||
| # Node.js dependencies | ||||
| /node_modules | ||||
| /jspm_packages | ||||
|  | ||||
| # TypeScript v1 declaration files | ||||
| typings | ||||
|  | ||||
| # Optional npm cache directory | ||||
| .npm | ||||
|  | ||||
| # Optional eslint cache | ||||
| .eslintcache | ||||
|  | ||||
| # Optional REPL history | ||||
| .node_repl_history | ||||
|  | ||||
| # Output of 'npm pack' | ||||
| *.tgz | ||||
|  | ||||
| # Yarn Integrity file | ||||
| .yarn-integrity | ||||
|  | ||||
| # dotenv environment variable files | ||||
| .env | ||||
| .env.test | ||||
|  | ||||
| # local env files | ||||
| .env*.local | ||||
|  | ||||
| # Next.js build output | ||||
| .next | ||||
| out | ||||
| # docker-compose env files | ||||
| .env | ||||
|  | ||||
| # Nuxt.js build output | ||||
| .nuxt | ||||
| dist | ||||
|  | ||||
| # Gatsby files | ||||
| .cache/ | ||||
|  | ||||
|  | ||||
| # Vuepress build output | ||||
| .vuepress/dist | ||||
|  | ||||
| # Serverless directories | ||||
| .serverless/ | ||||
|  | ||||
| # FuseBox cache | ||||
| .fusebox/ | ||||
|  | ||||
| # DynamoDB Local files | ||||
| .dynamodb/ | ||||
|  | ||||
| # Temporary folders | ||||
| tmp | ||||
| temp | ||||
|  | ||||
| # IDE and editor directories | ||||
| .idea | ||||
| .vscode | ||||
| *.swp | ||||
| *.swo | ||||
| *~ | ||||
|  | ||||
| # OS generated files | ||||
| .DS_Store | ||||
| Thumbs.db | ||||
|  | ||||
| # secret key | ||||
| *.key | ||||
| *.key.pub | ||||
| @@ -1,20 +1,21 @@ | ||||
|  | ||||
| # Your openai api key. (required) | ||||
| OPENAI_API_KEY=sk-xxxx | ||||
|  | ||||
| # Access password, separated by comma. (optional) | ||||
| # Access passsword, separated by comma. (optional) | ||||
| CODE=your-password | ||||
|  | ||||
| # You can start service behind a proxy. (optional) | ||||
| # You can start service behind a proxy | ||||
| PROXY_URL=http://localhost:7890 | ||||
|  | ||||
| # (optional) | ||||
| # Default: Empty | ||||
| # Google Gemini Pro API key, set if you want to use Google Gemini Pro API. | ||||
| # Googel Gemini Pro API key, set if you want to use Google Gemini Pro API. | ||||
| GOOGLE_API_KEY= | ||||
|  | ||||
| # (optional) | ||||
| # Default: https://generativelanguage.googleapis.com/ | ||||
| # Google Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url. | ||||
| # Googel Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url. | ||||
| GOOGLE_URL= | ||||
|  | ||||
| # Override openai api request base url. (optional) | ||||
| @@ -46,24 +47,3 @@ ENABLE_BALANCE_QUERY= | ||||
| # If you want to disable parse settings from url, set this value to 1. | ||||
| DISABLE_FAST_LINK= | ||||
|  | ||||
| # (optional) | ||||
| # Default: Empty | ||||
| # To control custom models, use + to add a custom model, use - to hide a model, use name=displayName to customize model name, separated by comma. | ||||
| CUSTOM_MODELS= | ||||
|  | ||||
| # (optional) | ||||
| # Default: Empty | ||||
| # Change default model | ||||
| DEFAULT_MODEL= | ||||
|  | ||||
| # anthropic claude Api Key.(optional) | ||||
| ANTHROPIC_API_KEY= | ||||
|  | ||||
| ### anthropic claude Api version. (optional) | ||||
| ANTHROPIC_API_VERSION= | ||||
|  | ||||
| ### anthropic claude Api url (optional) | ||||
| ANTHROPIC_URL= | ||||
|  | ||||
| ### (optional) | ||||
| WHITE_WEBDEV_ENDPOINTS= | ||||
| @@ -1,7 +1,4 @@ | ||||
| { | ||||
|   "extends": "next/core-web-vitals", | ||||
|   "plugins": ["prettier", "unused-imports"], | ||||
|   "rules": { | ||||
|     "unused-imports/no-unused-imports": "warn" | ||||
|   } | ||||
|   "plugins": ["prettier"] | ||||
| } | ||||
|   | ||||
							
								
								
									
										80
									
								
								.github/ISSUE_TEMPLATE/1_bug_report.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -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
									
									
								
							
							
						
						| @@ -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
									
									
								
							
							
						
						| @@ -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
									
									
								
							
							
						
						| @@ -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
									
								
							
							
						
						| @@ -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
									
								
							
							
						
						| @@ -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
									
									
								
							
							
						
						| @@ -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
									
								
								.github/workflows/deploy_preview.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -49,7 +49,7 @@ jobs: | ||||
|         run: npm install --global vercel@latest | ||||
|  | ||||
|       - name: Cache dependencies | ||||
|         uses: actions/cache@v4 | ||||
|         uses: actions/cache@v2 | ||||
|         id: cache-npm | ||||
|         with: | ||||
|           path: ~/.npm | ||||
|   | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -44,5 +44,3 @@ dev | ||||
|  | ||||
| *.key | ||||
| *.key.pub | ||||
|  | ||||
| masks.json | ||||
|   | ||||
| @@ -43,7 +43,7 @@ COPY --from=builder /app/.next/server ./.next/server | ||||
| EXPOSE 3000 | ||||
|  | ||||
| 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); \ | ||||
|     host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \ | ||||
|     port=$(echo $PROXY_URL | cut -d: -f3); \ | ||||
|   | ||||
							
								
								
									
										2
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,6 @@ | ||||
| MIT License | ||||
|  | ||||
| Copyright (c) 2023-2024 Zhang Yifei | ||||
| Copyright (c) 2023 Zhang Yifei | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| of this software and associated documentation files (the "Software"), to deal | ||||
|   | ||||
							
								
								
									
										160
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,8 +1,5 @@ | ||||
| <div align="center"> | ||||
|  | ||||
| <a href='#企业版'> | ||||
|   <img src="./docs/images/ent.svg" alt="icon"/> | ||||
| </a> | ||||
| <img src="./docs/images/head-cover.png" alt="icon"/> | ||||
|  | ||||
| <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] | ||||
| [![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 | ||||
| [Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge | ||||
| [Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows | ||||
| [MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple | ||||
| [Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu | ||||
|  | ||||
| [<img src="https://vercel.com/button" alt="Deploy on Zeabur" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA)  [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
| [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%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> | ||||
|  | ||||
| ## Enterprise Edition | ||||
|  | ||||
| Meeting Your Company's Privatization and Customization Deployment Requirements: | ||||
| - **Brand Customization**: Tailored VI/UI to seamlessly align with your corporate brand image. | ||||
| - **Resource Integration**: Unified configuration and management of dozens of AI resources by company administrators, ready for use by team members. | ||||
| - **Permission Control**: Clearly defined member permissions, resource permissions, and knowledge base permissions, all controlled via a corporate-grade Admin Panel. | ||||
| - **Knowledge Integration**: Combining your internal knowledge base with AI capabilities, making it more relevant to your company's specific business needs compared to general AI. | ||||
| - **Security Auditing**: Automatically intercept sensitive inquiries and trace all historical conversation records, ensuring AI adherence to corporate information security standards. | ||||
| - **Private Deployment**: Enterprise-level private deployment supporting various mainstream private cloud solutions, ensuring data security and privacy protection. | ||||
| - **Continuous Updates**: Ongoing updates and upgrades in cutting-edge capabilities like multimodal AI, ensuring consistent innovation and advancement. | ||||
|  | ||||
| For enterprise inquiries, please contact: **business@nextchat.dev** | ||||
|  | ||||
| ## 企业版 | ||||
|  | ||||
| 满足企业用户私有化部署和个性化定制需求: | ||||
| - **品牌定制**:企业量身定制 VI/UI,与企业品牌形象无缝契合 | ||||
| - **资源集成**:由企业管理人员统一配置和管理数十种 AI 资源,团队成员开箱即用 | ||||
| - **权限管理**:成员权限、资源权限、知识库权限层级分明,企业级 Admin Panel 统一控制 | ||||
| - **知识接入**:企业内部知识库与 AI 能力相结合,比通用 AI 更贴近企业自身业务需求 | ||||
| - **安全审计**:自动拦截敏感提问,支持追溯全部历史对话记录,让 AI 也能遵循企业信息安全规范 | ||||
| - **私有部署**:企业级私有部署,支持各类主流私有云部署,确保数据安全和隐私保护 | ||||
| - **持续更新**:提供多模态、智能体等前沿能力持续更新升级服务,常用常新、持续先进 | ||||
|  | ||||
| 企业版咨询: **business@nextchat.dev** | ||||
|  | ||||
| <img width="300" src="https://github.com/user-attachments/assets/3daeb7b6-ab63-4542-9141-2e4a12c80601"> | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| - **Deploy for free with one-click** on Vercel in under 1 minute | ||||
| @@ -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 | ||||
| - I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia | ||||
|  | ||||
| <div align="center"> | ||||
|     | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## Roadmap | ||||
|  | ||||
| - [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) | ||||
| @@ -90,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] Desktop App with tauri | ||||
| - [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc. | ||||
| - [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) | ||||
| - [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) | ||||
|   - [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) | ||||
| - [ ] local knowledge base | ||||
| - [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) | ||||
|  | ||||
| ## What's New | ||||
|  | ||||
| - 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins) | ||||
| - 🚀 v2.14.0 Now supports  Artifacts & SD  | ||||
| - 🚀 v2.10.1 support Google Gemini Pro model. | ||||
| - 🚀 v2.9.11 you can use azure endpoint now. | ||||
| - 🚀 v2.8 now we have a client that runs across all platforms! | ||||
| @@ -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] 使用 tauri 打包桌面应用 | ||||
| - [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm) | ||||
| - [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) | ||||
| - [x] 插件机制,支持`联网搜索`、`计算器`、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) | ||||
|    - [x] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) | ||||
|  - [ ] 本地知识库 | ||||
| - [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) | ||||
|  | ||||
| ## 最新动态 | ||||
|  | ||||
| - 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins) | ||||
| - 🚀 v2.14.0 现在支持 Artifacts & SD 了。 | ||||
| - 🚀 v2.10.1 现在支持 Gemini Pro 模型。 | ||||
| - 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。 | ||||
| - 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。 | ||||
| - 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。 | ||||
| - 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。 | ||||
| - 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com | ||||
| - 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。 | ||||
| - 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。 | ||||
| - 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。 | ||||
|  | ||||
| ## Get Started | ||||
|  | ||||
| @@ -224,7 +180,7 @@ Specify OpenAI organization ID. | ||||
|  | ||||
| ### `AZURE_URL` (optional) | ||||
|  | ||||
| > Example: https://{azure-resource-url}/openai | ||||
| > Example: https://{azure-resource-url}/openai/deployments/{deploy-name} | ||||
|  | ||||
| Azure deploy url. | ||||
|  | ||||
| @@ -244,58 +200,6 @@ Google Gemini Pro Api Key. | ||||
|  | ||||
| Google Gemini Pro Api Url. | ||||
|  | ||||
| ### `ANTHROPIC_API_KEY` (optional) | ||||
|  | ||||
| anthropic claude Api Key. | ||||
|  | ||||
| ### `ANTHROPIC_API_VERSION` (optional) | ||||
|  | ||||
| anthropic claude Api version. | ||||
|  | ||||
| ### `ANTHROPIC_URL` (optional) | ||||
|  | ||||
| anthropic claude Api Url. | ||||
|  | ||||
| ### `BAIDU_API_KEY` (optional) | ||||
|  | ||||
| Baidu Api Key. | ||||
|  | ||||
| ### `BAIDU_SECRET_KEY` (optional) | ||||
|  | ||||
| Baidu Secret Key. | ||||
|  | ||||
| ### `BAIDU_URL` (optional) | ||||
|  | ||||
| Baidu Api Url. | ||||
|  | ||||
| ### `BYTEDANCE_API_KEY` (optional) | ||||
|  | ||||
| ByteDance Api Key. | ||||
|  | ||||
| ### `BYTEDANCE_URL` (optional) | ||||
|  | ||||
| ByteDance Api Url. | ||||
|  | ||||
| ### `ALIBABA_API_KEY` (optional) | ||||
|  | ||||
| Alibaba Cloud Api Key. | ||||
|  | ||||
| ### `ALIBABA_URL` (optional) | ||||
|  | ||||
| Alibaba Cloud Api Url. | ||||
|  | ||||
| ### `IFLYTEK_URL` (Optional) | ||||
|  | ||||
| iflytek Api Url. | ||||
|  | ||||
| ### `IFLYTEK_API_KEY` (Optional) | ||||
|  | ||||
| iflytek Api Key. | ||||
|  | ||||
| ### `IFLYTEK_API_SECRET` (Optional) | ||||
|  | ||||
| iflytek Api Secret. | ||||
|  | ||||
| ### `HIDE_USER_API_KEY` (optional) | ||||
|  | ||||
| > Default: Empty | ||||
| @@ -312,7 +216,7 @@ If you do not want users to use GPT-4, set this value to 1. | ||||
|  | ||||
| > Default: Empty | ||||
|  | ||||
| If you do want users to query balance, set this value to 1. | ||||
| If you do want users to query balance, set this value to 1, or you should set it to 0. | ||||
|  | ||||
| ### `DISABLE_FAST_LINK` (optional) | ||||
|  | ||||
| @@ -329,36 +233,6 @@ 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. | ||||
|  | ||||
| For Azure: use `modelName@azure=deploymentName` to customize model name and deployment name. | ||||
| > Example: `+gpt-3.5-turbo@azure=gpt35` will show option `gpt35(Azure)` in model list. | ||||
| > If you only can use Azure model, `-all,+gpt-3.5-turbo@azure=gpt35` will `gpt35(Azure)` the only option in model list. | ||||
|  | ||||
| For ByteDance: use `modelName@bytedance=deploymentName` to customize model name and deployment name. | ||||
| > Example: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` will show option `Doubao-lite-4k(ByteDance)` in model list. | ||||
|  | ||||
| ### `DEFAULT_MODEL` (optional) | ||||
|  | ||||
| Change default model | ||||
|  | ||||
| ### `WHITE_WEBDEV_ENDPOINTS` (optional) | ||||
|  | ||||
| You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format: | ||||
| - Each address must be a complete endpoint  | ||||
| > `https://xxxx/yyy` | ||||
| - Multiple addresses are connected by ', ' | ||||
|  | ||||
| ### `DEFAULT_INPUT_TEMPLATE` (optional) | ||||
|  | ||||
| Customize the default template used to initialize the User Input Preprocessing configuration item in Settings. | ||||
|  | ||||
| ### `STABILITY_API_KEY` (optional) | ||||
|  | ||||
| Stability API key. | ||||
|  | ||||
| ### `STABILITY_URL` (optional) | ||||
|  | ||||
| Customize Stability API url. | ||||
|  | ||||
| ## Requirements | ||||
|  | ||||
| NodeJS >= 18, Docker >= 20 | ||||
|   | ||||
							
								
								
									
										128
									
								
								README_CN.md
									
									
									
									
									
								
							
							
						
						| @@ -1,34 +1,22 @@ | ||||
| <div align="center"> | ||||
|  | ||||
| <a href='#企业版'> | ||||
|   <img src="./docs/images/ent.svg" alt="icon"/> | ||||
| </a> | ||||
| <img src="./docs/images/icon.svg" alt="预览"/> | ||||
|  | ||||
| <h1 align="center">NextChat</h1> | ||||
|  | ||||
| 一键免费部署你的私人 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> | ||||
|  | ||||
| ## 企业版 | ||||
|  | ||||
| 满足您公司私有化部署和定制需求 | ||||
| - **品牌定制**:企业量身定制 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); | ||||
| @@ -37,12 +25,6 @@ | ||||
| 3. 部署完毕后,即可开始使用; | ||||
| 4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。 | ||||
|  | ||||
| <div align="center"> | ||||
|     | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## 保持更新 | ||||
|  | ||||
| 如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。 | ||||
| @@ -112,7 +94,7 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填 | ||||
|  | ||||
| ### `AZURE_URL` (可选) | ||||
|  | ||||
| > 形如:https://{azure-resource-url}/openai | ||||
| > 形如:https://{azure-resource-url}/openai/deployments/{deploy-name} | ||||
|  | ||||
| Azure 部署地址。 | ||||
|  | ||||
| @@ -124,68 +106,14 @@ Azure 密钥。 | ||||
|  | ||||
| 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_URL` (可选) | ||||
| ### `GOOGLE_URL` (optional) | ||||
|  | ||||
| Google Gemini Pro Api Url. | ||||
|  | ||||
| ### `ANTHROPIC_API_KEY` (可选) | ||||
|  | ||||
| anthropic claude Api Key. | ||||
|  | ||||
| ### `ANTHROPIC_API_VERSION` (可选) | ||||
|  | ||||
| anthropic claude Api version. | ||||
|  | ||||
| ### `ANTHROPIC_URL` (可选) | ||||
|  | ||||
| anthropic claude Api Url. | ||||
|  | ||||
| ### `BAIDU_API_KEY` (可选) | ||||
|  | ||||
| Baidu Api Key. | ||||
|  | ||||
| ### `BAIDU_SECRET_KEY` (可选) | ||||
|  | ||||
| Baidu Secret Key. | ||||
|  | ||||
| ### `BAIDU_URL` (可选) | ||||
|  | ||||
| Baidu Api Url. | ||||
|  | ||||
| ### `BYTEDANCE_API_KEY` (可选) | ||||
|  | ||||
| ByteDance Api Key. | ||||
|  | ||||
| ### `BYTEDANCE_URL` (可选) | ||||
|  | ||||
| ByteDance Api Url. | ||||
|  | ||||
| ### `ALIBABA_API_KEY` (可选) | ||||
|  | ||||
| 阿里云(千问)Api Key. | ||||
|  | ||||
| ### `ALIBABA_URL` (可选) | ||||
|  | ||||
| 阿里云(千问)Api Url. | ||||
|  | ||||
| ### `IFLYTEK_URL` (可选) | ||||
|  | ||||
| 讯飞星火Api Url. | ||||
|  | ||||
| ### `IFLYTEK_API_KEY` (可选) | ||||
|  | ||||
| 讯飞星火Api Key. | ||||
|  | ||||
| ### `IFLYTEK_API_SECRET` (可选) | ||||
|  | ||||
| 讯飞星火Api Secret. | ||||
|  | ||||
|  | ||||
|  | ||||
| ### `HIDE_USER_API_KEY` (可选) | ||||
|  | ||||
| 如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。 | ||||
| @@ -202,13 +130,6 @@ ByteDance Api Url. | ||||
|  | ||||
| 如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。 | ||||
|  | ||||
| ### `WHITE_WEBDEV_ENDPOINTS` (可选) | ||||
|  | ||||
| 如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求: | ||||
| - 每一个地址必须是一个完整的 endpoint | ||||
| > `https://xxxx/xxx` | ||||
| - 多个地址以`,`相连 | ||||
|  | ||||
| ### `CUSTOM_MODELS` (可选) | ||||
|  | ||||
| > 示例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` 表示增加 `qwen-7b-chat` 和 `glm-6b` 到模型列表,而从列表中删除 `gpt-3.5-turbo`,并将 `gpt-4-1106-preview` 模型名字展示为 `gpt-4-turbo`。 | ||||
| @@ -216,31 +137,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
									
									
									
									
									
								
							
							
						
						| @@ -1,310 +0,0 @@ | ||||
| <div align="center"> | ||||
| <img src="./docs/images/ent.svg" alt="プレビュー"/> | ||||
|  | ||||
| <h1 align="center">NextChat</h1> | ||||
|  | ||||
| ワンクリックで無料であなた専用の ChatGPT ウェブアプリをデプロイ。GPT3、GPT4 & Gemini Pro モデルをサポート。 | ||||
|  | ||||
| [企業版](#企業版) / [デモ](https://chat-gpt-next-web.vercel.app/) / [フィードバック](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Discordに参加](https://discord.gg/zrhvHCr79N) | ||||
|  | ||||
| [<img src="https://vercel.com/button" alt="Zeaburでデプロイ" height="30">](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [<img src="https://zeabur.com/button.svg" alt="Zeaburでデプロイ" height="30">](https://zeabur.com/templates/ZBUEFA)  [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Gitpodで開く" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
| ## 企業版 | ||||
|  | ||||
| あなたの会社のプライベートデプロイとカスタマイズのニーズに応える | ||||
| - **ブランドカスタマイズ**:企業向けに特別に設計された VI/UI、企業ブランドイメージとシームレスにマッチ | ||||
| - **リソース統合**:企業管理者が数十種類のAIリソースを統一管理、チームメンバーはすぐに使用可能 | ||||
| - **権限管理**:メンバーの権限、リソースの権限、ナレッジベースの権限を明確にし、企業レベルのAdmin Panelで統一管理 | ||||
| - **知識の統合**:企業内部のナレッジベースとAI機能を結びつけ、汎用AIよりも企業自身の業務ニーズに近づける | ||||
| - **セキュリティ監査**:機密質問を自動的にブロックし、すべての履歴対話を追跡可能にし、AIも企業の情報セキュリティ基準に従わせる | ||||
| - **プライベートデプロイ**:企業レベルのプライベートデプロイ、主要なプライベートクラウドデプロイをサポートし、データのセキュリティとプライバシーを保護 | ||||
| - **継続的な更新**:マルチモーダル、エージェントなどの最先端機能を継続的に更新し、常に最新であり続ける | ||||
|  | ||||
| 企業版のお問い合わせ: **business@nextchat.dev** | ||||
|  | ||||
|  | ||||
| ## 始めに | ||||
|  | ||||
| 1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する; | ||||
| 2. 右側のボタンをクリックしてデプロイを開始: | ||||
|    [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください; | ||||
| 3. デプロイが完了したら、すぐに使用を開始できます; | ||||
| 4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。 | ||||
|  | ||||
| <div align="center"> | ||||
|     | ||||
|  | ||||
|  | ||||
| </div> | ||||
|  | ||||
|  | ||||
| ## 更新を維持する | ||||
|  | ||||
| もし上記の手順に従ってワンクリックでプロジェクトをデプロイした場合、「更新があります」というメッセージが常に表示されることがあります。これは、Vercel がデフォルトで新しいプロジェクトを作成するためで、本プロジェクトを fork していないことが原因です。そのため、正しく更新を検出できません。 | ||||
|  | ||||
| 以下の手順に従って再デプロイすることをお勧めします: | ||||
|  | ||||
| - 元のリポジトリを削除する | ||||
| - ページ右上の fork ボタンを使って、本プロジェクトを fork する | ||||
| - Vercel で再度選択してデプロイする、[詳細な手順はこちらを参照してください](./docs/vercel-ja.md)。 | ||||
|  | ||||
|  | ||||
| ### 自動更新を開く | ||||
|  | ||||
| > Upstream Sync の実行エラーが発生した場合は、手動で Sync Fork してください! | ||||
|  | ||||
| プロジェクトを fork した後、GitHub の制限により、fork 後のプロジェクトの Actions ページで Workflows を手動で有効にし、Upstream Sync Action を有効にする必要があります。有効化後、毎時の定期自動更新が可能になります: | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ### 手動でコードを更新する | ||||
|  | ||||
| 手動で即座に更新したい場合は、[GitHub のドキュメント](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork)を参照して、fork したプロジェクトを上流のコードと同期する方法を確認してください。 | ||||
|  | ||||
| このプロジェクトをスターまたはウォッチしたり、作者をフォローすることで、新機能の更新通知をすぐに受け取ることができます。 | ||||
|  | ||||
|  | ||||
|  | ||||
| ## ページアクセスパスワードを設定する | ||||
|  | ||||
| > パスワードを設定すると、ユーザーは設定ページでアクセスコードを手動で入力しない限り、通常のチャットができず、未承認の状態であることを示すメッセージが表示されます。 | ||||
|  | ||||
| > **警告**:パスワードの桁数は十分に長く設定してください。7桁以上が望ましいです。さもないと、[ブルートフォース攻撃を受ける可能性があります](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。 | ||||
|  | ||||
| このプロジェクトは限られた権限管理機能を提供しています。Vercel プロジェクトのコントロールパネルで、環境変数ページに `CODE` という名前の環境変数を追加し、値をカンマで区切ったカスタムパスワードに設定してください: | ||||
|  | ||||
| ``` | ||||
| code1,code2,code3 | ||||
| ``` | ||||
|  | ||||
| この環境変数を追加または変更した後、**プロジェクトを再デプロイ**して変更を有効にしてください。 | ||||
|  | ||||
|  | ||||
| ## 環境変数 | ||||
|  | ||||
| > 本プロジェクトのほとんどの設定は環境変数で行います。チュートリアル:[Vercel の環境変数を変更する方法](./docs/vercel-ja.md)。 | ||||
|  | ||||
| ### `OPENAI_API_KEY` (必須) | ||||
|  | ||||
| OpenAI の API キー。OpenAI アカウントページで申請したキーをカンマで区切って複数設定できます。これにより、ランダムにキーが選択されます。 | ||||
|  | ||||
| ### `CODE` (オプション) | ||||
|  | ||||
| アクセスパスワード。カンマで区切って複数設定可能。 | ||||
|  | ||||
| **警告**:この項目を設定しないと、誰でもデプロイしたウェブサイトを利用でき、トークンが急速に消耗する可能性があるため、設定をお勧めします。 | ||||
|  | ||||
| ### `BASE_URL` (オプション) | ||||
|  | ||||
| > デフォルト: `https://api.openai.com` | ||||
|  | ||||
| > 例: `http://your-openai-proxy.com` | ||||
|  | ||||
| OpenAI API のプロキシ URL。手動で OpenAI API のプロキシを設定している場合はこのオプションを設定してください。 | ||||
|  | ||||
| > SSL 証明書の問題がある場合は、`BASE_URL` のプロトコルを http に設定してください。 | ||||
|  | ||||
| ### `OPENAI_ORG_ID` (オプション) | ||||
|  | ||||
| OpenAI の組織 ID を指定します。 | ||||
|  | ||||
| ### `AZURE_URL` (オプション) | ||||
|  | ||||
| > 形式: https://{azure-resource-url}/openai/deployments/{deploy-name} | ||||
| > `CUSTOM_MODELS` で `displayName` 形式で {deploy-name} を設定した場合、`AZURE_URL` から {deploy-name} を省略できます。 | ||||
|  | ||||
| Azure のデプロイ URL。 | ||||
|  | ||||
| ### `AZURE_API_KEY` (オプション) | ||||
|  | ||||
| Azure の API キー。 | ||||
|  | ||||
| ### `AZURE_API_VERSION` (オプション) | ||||
|  | ||||
| Azure API バージョン。[Azure ドキュメント](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)で確認できます。 | ||||
|  | ||||
| ### `GOOGLE_API_KEY` (オプション) | ||||
|  | ||||
| Google Gemini Pro API キー。 | ||||
|  | ||||
| ### `GOOGLE_URL` (オプション) | ||||
|  | ||||
| Google Gemini Pro API の URL。 | ||||
|  | ||||
| ### `ANTHROPIC_API_KEY` (オプション) | ||||
|  | ||||
| Anthropic Claude API キー。 | ||||
|  | ||||
| ### `ANTHROPIC_API_VERSION` (オプション) | ||||
|  | ||||
| Anthropic Claude API バージョン。 | ||||
|  | ||||
| ### `ANTHROPIC_URL` (オプション) | ||||
|  | ||||
| Anthropic Claude API の URL。 | ||||
|  | ||||
| ### `BAIDU_API_KEY` (オプション) | ||||
|  | ||||
| Baidu API キー。 | ||||
|  | ||||
| ### `BAIDU_SECRET_KEY` (オプション) | ||||
|  | ||||
| Baidu シークレットキー。 | ||||
|  | ||||
| ### `BAIDU_URL` (オプション) | ||||
|  | ||||
| Baidu API の URL。 | ||||
|  | ||||
| ### `BYTEDANCE_API_KEY` (オプション) | ||||
|  | ||||
| ByteDance API キー。 | ||||
|  | ||||
| ### `BYTEDANCE_URL` (オプション) | ||||
|  | ||||
| ByteDance API の URL。 | ||||
|  | ||||
| ### `ALIBABA_API_KEY` (オプション) | ||||
|  | ||||
| アリババ(千问)API キー。 | ||||
|  | ||||
| ### `ALIBABA_URL` (オプション) | ||||
|  | ||||
| アリババ(千问)API の URL。 | ||||
|  | ||||
| ### `HIDE_USER_API_KEY` (オプション) | ||||
|  | ||||
| ユーザーが API キーを入力できないようにしたい場合は、この環境変数を 1 に設定します。 | ||||
|  | ||||
| ### `DISABLE_GPT4` (オプション) | ||||
|  | ||||
| ユーザーが GPT-4 を使用できないようにしたい場合は、この環境変数を 1 に設定します。 | ||||
|  | ||||
| ### `ENABLE_BALANCE_QUERY` (オプション) | ||||
|  | ||||
| バランスクエリ機能を有効にしたい場合は、この環境変数を 1 に設定します。 | ||||
|  | ||||
| ### `DISABLE_FAST_LINK` (オプション) | ||||
|  | ||||
| リンクからのプリセット設定解析を無効にしたい場合は、この環境変数を 1 に設定します。 | ||||
|  | ||||
| ### `WHITE_WEBDEV_ENDPOINTS` (オプション) | ||||
|  | ||||
| アクセス許可を与える WebDAV サービスのアドレスを追加したい場合、このオプションを使用します。フォーマット要件: | ||||
| - 各アドレスは完全なエンドポイントでなければなりません。 | ||||
| > `https://xxxx/xxx` | ||||
| - 複数のアドレスは `,` で接続します。 | ||||
|  | ||||
| ### `CUSTOM_MODELS` (オプション) | ||||
|  | ||||
| > 例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` は `qwen-7b-chat` と `glm-6b` をモデルリストに追加し、`gpt-3.5-turbo` を削除し、`gpt-4-1106-preview` のモデル名を `gpt-4-turbo` として表示します。 | ||||
| > すべてのモデルを無効にし、特定のモデルを有効にしたい場合は、`-all,+gpt-3.5-turbo` を使用します。これは `gpt-3.5-turbo` のみを有効にすることを意味します。 | ||||
|  | ||||
| モデルリストを管理します。`+` でモデルを追加し、`-` でモデルを非表示にし、`モデル名=表示名` でモデルの表示名をカスタマイズし、カンマで区切ります。 | ||||
|  | ||||
| Azure モードでは、`modelName@azure=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。 | ||||
| > 例:`+gpt-3.5-turbo@azure=gpt35` この設定でモデルリストに `gpt35(Azure)` のオプションが表示されます。 | ||||
|  | ||||
| ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデル名とデプロイ名(deploy-name)を設定できます。 | ||||
| > 例: `+Doubao-lite-4k@bytedance=ep-xxxxx-xxx` この設定でモデルリストに `Doubao-lite-4k(ByteDance)` のオプションが表示されます。 | ||||
|  | ||||
| ### `DEFAULT_MODEL` (オプション) | ||||
|  | ||||
| デフォルトのモデルを変更します。 | ||||
|  | ||||
| ### `DEFAULT_INPUT_TEMPLATE` (オプション) | ||||
|  | ||||
| 『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。 | ||||
|  | ||||
|  | ||||
| ## 開発 | ||||
|  | ||||
| 下のボタンをクリックして二次開発を開始してください: | ||||
|  | ||||
| [](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) | ||||
|  | ||||
| コードを書く前に、プロジェクトのルートディレクトリに `.env.local` ファイルを新規作成し、環境変数を記入します: | ||||
|  | ||||
| ``` | ||||
| OPENAI_API_KEY=<your api key here> | ||||
| ``` | ||||
|  | ||||
|  | ||||
| ### ローカル開発 | ||||
|  | ||||
| 1. Node.js 18 と Yarn をインストールします。具体的な方法は ChatGPT にお尋ねください。 | ||||
| 2. `yarn install && yarn dev` を実行します。⚠️ 注意:このコマンドはローカル開発用であり、デプロイには使用しないでください。 | ||||
| 3. ローカルでデプロイしたい場合は、`yarn install && yarn build && yarn start` コマンドを使用してください。プロセスを守るために pm2 を使用することもできます。詳細は ChatGPT にお尋ねください。 | ||||
|  | ||||
|  | ||||
| ## デプロイ | ||||
|  | ||||
| ### コンテナデプロイ(推奨) | ||||
|  | ||||
| > Docker バージョンは 20 以上が必要です。それ以下だとイメージが見つからないというエラーが出ます。 | ||||
|  | ||||
| > ⚠️ 注意:Docker バージョンは最新バージョンより 1~2 日遅れることが多いため、デプロイ後に「更新があります」の通知が出続けることがありますが、正常です。 | ||||
|  | ||||
| ```shell | ||||
| docker pull yidadaa/chatgpt-next-web | ||||
|  | ||||
| docker run -d -p 3000:3000 \ | ||||
|    -e OPENAI_API_KEY=sk-xxxx \ | ||||
|    -e CODE=ページアクセスパスワード \ | ||||
|    yidadaa/chatgpt-next-web | ||||
| ``` | ||||
|  | ||||
| プロキシを指定することもできます: | ||||
|  | ||||
| ```shell | ||||
| docker run -d -p 3000:3000 \ | ||||
|    -e OPENAI_API_KEY=sk-xxxx \ | ||||
|    -e CODE=ページアクセスパスワード \ | ||||
|    --net=host \ | ||||
|    -e PROXY_URL=http://127.0.0.1:7890 \ | ||||
|    yidadaa/chatgpt-next-web | ||||
| ``` | ||||
|  | ||||
| ローカルプロキシがアカウントとパスワードを必要とする場合は、以下を使用できます: | ||||
|  | ||||
| ```shell | ||||
| -e PROXY_URL="http://127.0.0.1:7890 user password" | ||||
| ``` | ||||
|  | ||||
| 他の環境変数を指定する必要がある場合は、上記のコマンドに `-e 環境変数=環境変数値` を追加して指定してください。 | ||||
|  | ||||
|  | ||||
| ### ローカルデプロイ | ||||
|  | ||||
| コンソールで以下のコマンドを実行します: | ||||
|  | ||||
| ```shell | ||||
| bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) | ||||
| ``` | ||||
|  | ||||
| ⚠️ 注意:インストール中に問題が発生した場合は、Docker を使用してデプロイしてください。 | ||||
|  | ||||
|  | ||||
| ## 謝辞 | ||||
|  | ||||
| ### 寄付者 | ||||
|  | ||||
| > 英語版をご覧ください。 | ||||
|  | ||||
| ### 貢献者 | ||||
|  | ||||
| [プロジェクトの貢献者リストはこちら](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors) | ||||
|  | ||||
| ### 関連プロジェクト | ||||
|  | ||||
| - [one-api](https://github.com/songquanpeng/one-api): 一つのプラットフォームで大規模モデルのクォータ管理を提供し、市場に出回っているすべての主要な大規模言語モデルをサポートします。 | ||||
|  | ||||
|  | ||||
| ## オープンソースライセンス | ||||
|  | ||||
| [MIT](https://opensource.org/license/mit/) | ||||
| @@ -1,70 +0,0 @@ | ||||
| import { ApiPath } from "@/app/constant"; | ||||
| import { NextRequest } from "next/server"; | ||||
| import { handle as openaiHandler } from "../../openai"; | ||||
| import { handle as azureHandler } from "../../azure"; | ||||
| import { handle as googleHandler } from "../../google"; | ||||
| import { handle as anthropicHandler } from "../../anthropic"; | ||||
| import { handle as baiduHandler } from "../../baidu"; | ||||
| import { handle as bytedanceHandler } from "../../bytedance"; | ||||
| import { handle as alibabaHandler } from "../../alibaba"; | ||||
| import { handle as moonshotHandler } from "../../moonshot"; | ||||
| import { handle as stabilityHandler } from "../../stability"; | ||||
| import { handle as iflytekHandler } from "../../iflytek"; | ||||
| import { handle as proxyHandler } from "../../proxy"; | ||||
|  | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { provider: string; path: string[] } }, | ||||
| ) { | ||||
|   const apiPath = `/api/${params.provider}`; | ||||
|   console.log(`[${params.provider} Route] params `, params); | ||||
|   switch (apiPath) { | ||||
|     case ApiPath.Azure: | ||||
|       return azureHandler(req, { params }); | ||||
|     case ApiPath.Google: | ||||
|       return googleHandler(req, { params }); | ||||
|     case ApiPath.Anthropic: | ||||
|       return anthropicHandler(req, { params }); | ||||
|     case ApiPath.Baidu: | ||||
|       return baiduHandler(req, { params }); | ||||
|     case ApiPath.ByteDance: | ||||
|       return bytedanceHandler(req, { params }); | ||||
|     case ApiPath.Alibaba: | ||||
|       return alibabaHandler(req, { params }); | ||||
|     // case ApiPath.Tencent: using "/api/tencent" | ||||
|     case ApiPath.Moonshot: | ||||
|       return moonshotHandler(req, { params }); | ||||
|     case ApiPath.Stability: | ||||
|       return stabilityHandler(req, { params }); | ||||
|     case ApiPath.Iflytek: | ||||
|       return iflytekHandler(req, { params }); | ||||
|     case ApiPath.OpenAI: | ||||
|       return openaiHandler(req, { params }); | ||||
|     default: | ||||
|       return proxyHandler(req, { params }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const GET = handle; | ||||
| export const POST = handle; | ||||
|  | ||||
| export const runtime = "edge"; | ||||
| export const preferredRegion = [ | ||||
|   "arn1", | ||||
|   "bom1", | ||||
|   "cdg1", | ||||
|   "cle1", | ||||
|   "cpt1", | ||||
|   "dub1", | ||||
|   "fra1", | ||||
|   "gru1", | ||||
|   "hnd1", | ||||
|   "iad1", | ||||
|   "icn1", | ||||
|   "kix1", | ||||
|   "lhr1", | ||||
|   "pdx1", | ||||
|   "sfo1", | ||||
|   "sin1", | ||||
|   "syd1", | ||||
| ]; | ||||
| @@ -1,129 +0,0 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   ALIBABA_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Alibaba Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Qwen); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Alibaba] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   // alibaba use base url or just remove the path | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Alibaba, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.alibabaUrl || ALIBABA_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}${path}`; | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: req.headers.get("Authorization") ?? "", | ||||
|       "X-DashScope-SSE": req.headers.get("X-DashScope-SSE") ?? "disable", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Alibaba as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Alibaba] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -1,170 +0,0 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   ANTHROPIC_BASE_URL, | ||||
|   Anthropic, | ||||
|   ApiPath, | ||||
|   ServiceProvider, | ||||
|   ModelProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "./auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; | ||||
|  | ||||
| const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Anthropic Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const subpath = params.path.join("/"); | ||||
|  | ||||
|   if (!ALLOWD_PATH.has(subpath)) { | ||||
|     console.log("[Anthropic Route] forbidden path ", subpath); | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + subpath, | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Claude); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Anthropic] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   let authHeaderName = "x-api-key"; | ||||
|   let authValue = | ||||
|     req.headers.get(authHeaderName) || | ||||
|     req.headers.get("Authorization")?.replaceAll("Bearer ", "").trim() || | ||||
|     serverConfig.anthropicApiKey || | ||||
|     ""; | ||||
|  | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Anthropic, ""); | ||||
|  | ||||
|   let baseUrl = | ||||
|     serverConfig.anthropicUrl || serverConfig.baseUrl || ANTHROPIC_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   // try rebuild url, when using cloudflare ai gateway in server | ||||
|   const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}${path}`); | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       "Cache-Control": "no-store", | ||||
|       "anthropic-dangerous-direct-browser-access": "true", | ||||
|       [authHeaderName]: authValue, | ||||
|       "anthropic-version": | ||||
|         req.headers.get("anthropic-version") || | ||||
|         serverConfig.anthropicApiVersion || | ||||
|         Anthropic.Vision, | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Anthropic as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Anthropic] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   // console.log("[Anthropic request]", fetchOptions.headers, req.method); | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // console.log( | ||||
|     //   "[Anthropic response]", | ||||
|     //   res.status, | ||||
|     //   "   ", | ||||
|     //   res.headers, | ||||
|     //   res.url, | ||||
|     // ); | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -1,73 +0,0 @@ | ||||
| import md5 from "spark-md5"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
|  | ||||
| async function handle(req: NextRequest, res: NextResponse) { | ||||
|   const serverConfig = getServerSideConfig(); | ||||
|   const storeUrl = () => | ||||
|     `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`; | ||||
|   const storeHeaders = () => ({ | ||||
|     Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`, | ||||
|   }); | ||||
|   if (req.method === "POST") { | ||||
|     const clonedBody = await req.text(); | ||||
|     const hashedCode = md5.hash(clonedBody).trim(); | ||||
|     const body: { | ||||
|       key: string; | ||||
|       value: string; | ||||
|       expiration_ttl?: number; | ||||
|     } = { | ||||
|       key: hashedCode, | ||||
|       value: clonedBody, | ||||
|     }; | ||||
|     try { | ||||
|       const ttl = parseInt(serverConfig.cloudflareKVTTL as string); | ||||
|       if (ttl > 60) { | ||||
|         body["expiration_ttl"] = ttl; | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|     } | ||||
|     const res = await fetch(`${storeUrl()}/bulk`, { | ||||
|       headers: { | ||||
|         ...storeHeaders(), | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|       method: "PUT", | ||||
|       body: JSON.stringify([body]), | ||||
|     }); | ||||
|     const result = await res.json(); | ||||
|     console.log("save data", result); | ||||
|     if (result?.success) { | ||||
|       return NextResponse.json( | ||||
|         { code: 0, id: hashedCode, result }, | ||||
|         { status: res.status }, | ||||
|       ); | ||||
|     } | ||||
|     return NextResponse.json( | ||||
|       { error: true, msg: "Save data error" }, | ||||
|       { status: 400 }, | ||||
|     ); | ||||
|   } | ||||
|   if (req.method === "GET") { | ||||
|     const id = req?.nextUrl?.searchParams?.get("id"); | ||||
|     const res = await fetch(`${storeUrl()}/values/${id}`, { | ||||
|       headers: storeHeaders(), | ||||
|       method: "GET", | ||||
|     }); | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: res.headers, | ||||
|     }); | ||||
|   } | ||||
|   return NextResponse.json( | ||||
|     { error: true, msg: "Invalid request" }, | ||||
|     { status: 400 }, | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export const POST = handle; | ||||
| export const GET = handle; | ||||
|  | ||||
| export const runtime = "edge"; | ||||
| @@ -57,50 +57,12 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { | ||||
|   if (!apiKey) { | ||||
|     const serverConfig = getServerSideConfig(); | ||||
|  | ||||
|     // const systemApiKey = | ||||
|     //   modelProvider === ModelProvider.GeminiPro | ||||
|     //     ? serverConfig.googleApiKey | ||||
|     //     : serverConfig.isAzure | ||||
|     //     ? serverConfig.azureApiKey | ||||
|     //     : serverConfig.apiKey; | ||||
|  | ||||
|     let systemApiKey: string | undefined; | ||||
|  | ||||
|     switch (modelProvider) { | ||||
|       case ModelProvider.Stability: | ||||
|         systemApiKey = serverConfig.stabilityApiKey; | ||||
|         break; | ||||
|       case ModelProvider.GeminiPro: | ||||
|         systemApiKey = serverConfig.googleApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Claude: | ||||
|         systemApiKey = serverConfig.anthropicApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Doubao: | ||||
|         systemApiKey = serverConfig.bytedanceApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Ernie: | ||||
|         systemApiKey = serverConfig.baiduApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Qwen: | ||||
|         systemApiKey = serverConfig.alibabaApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Moonshot: | ||||
|         systemApiKey = serverConfig.moonshotApiKey; | ||||
|         break; | ||||
|       case ModelProvider.Iflytek: | ||||
|         systemApiKey = | ||||
|           serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret; | ||||
|         break; | ||||
|       case ModelProvider.GPT: | ||||
|       default: | ||||
|         if (req.nextUrl.pathname.includes("azure/deployments")) { | ||||
|           systemApiKey = serverConfig.azureApiKey; | ||||
|         } else { | ||||
|           systemApiKey = serverConfig.apiKey; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const systemApiKey = | ||||
|       modelProvider === ModelProvider.GeminiPro | ||||
|         ? serverConfig.googleApiKey | ||||
|         : serverConfig.isAzure | ||||
|         ? serverConfig.azureApiKey | ||||
|         : serverConfig.apiKey; | ||||
|     if (systemApiKey) { | ||||
|       console.log("[Auth] use system api key"); | ||||
|       req.headers.set("Authorization", `Bearer ${systemApiKey}`); | ||||
|   | ||||
| @@ -1,32 +0,0 @@ | ||||
| import { ModelProvider } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "./auth"; | ||||
| import { requestOpenai } from "./common"; | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Azure Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const subpath = params.path.join("/"); | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.GPT); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     return await requestOpenai(req); | ||||
|   } catch (e) { | ||||
|     console.error("[Azure] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										144
									
								
								app/api/baidu.ts
									
									
									
									
									
								
							
							
						
						| @@ -1,144 +0,0 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   BAIDU_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| import { getAccessToken } from "@/app/utils/baidu"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Baidu Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Ernie); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   if (!serverConfig.baiduApiKey || !serverConfig.baiduSecretKey) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         message: `missing BAIDU_API_KEY or BAIDU_SECRET_KEY in server env vars`, | ||||
|       }, | ||||
|       { | ||||
|         status: 401, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Baidu] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Baidu, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.baiduUrl || BAIDU_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const { access_token } = await getAccessToken( | ||||
|     serverConfig.baiduApiKey as string, | ||||
|     serverConfig.baiduSecretKey as string, | ||||
|   ); | ||||
|   const fetchUrl = `${baseUrl}${path}?access_token=${access_token}`; | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Baidu as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Baidu] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -1,129 +0,0 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   BYTEDANCE_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[ByteDance Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Doubao); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[ByteDance] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.ByteDance, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.bytedanceUrl || BYTEDANCE_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}${path}`; | ||||
|  | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: req.headers.get("Authorization") ?? "", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.ByteDance as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[ByteDance] filter`, e); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -1,19 +1,17 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { getServerSideConfig } from "../config/server"; | ||||
| import { OPENAI_BASE_URL, ServiceProvider } from "../constant"; | ||||
| import { isModelAvailableInServer } from "../utils/model"; | ||||
| import { cloudflareAIGatewayUrl } from "../utils/cloudflare"; | ||||
| import { DEFAULT_MODELS, OPENAI_BASE_URL, GEMINI_BASE_URL } from "../constant"; | ||||
| import { collectModelTable } from "../utils/model"; | ||||
| import { makeAzurePath } from "../azure"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function requestOpenai(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   const isAzure = req.nextUrl.pathname.includes("azure/deployments"); | ||||
|  | ||||
|   var authValue, | ||||
|     authHeaderName = ""; | ||||
|   if (isAzure) { | ||||
|   if (serverConfig.isAzure) { | ||||
|     authValue = | ||||
|       req.headers | ||||
|         .get("Authorization") | ||||
| @@ -27,10 +25,13 @@ export async function requestOpenai(req: NextRequest) { | ||||
|     authHeaderName = "Authorization"; | ||||
|   } | ||||
|  | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll("/api/openai/", ""); | ||||
|   let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll( | ||||
|     "/api/openai/", | ||||
|     "", | ||||
|   ); | ||||
|  | ||||
|   let baseUrl = | ||||
|     (isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL; | ||||
|     serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
| @@ -42,6 +43,10 @@ export async function requestOpenai(req: NextRequest) { | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|   // this fix [Org ID] undefined in server side if not using custom point | ||||
|   if (serverConfig.openaiOrgId !== undefined) { | ||||
|     console.log("[Org ID]", serverConfig.openaiOrgId); | ||||
|   } | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
| @@ -50,46 +55,17 @@ export async function requestOpenai(req: NextRequest) { | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   if (isAzure) { | ||||
|     const azureApiVersion = | ||||
|       req?.nextUrl?.searchParams?.get("api-version") || | ||||
|       serverConfig.azureApiVersion; | ||||
|     baseUrl = baseUrl.split("/deployments").shift() as string; | ||||
|     path = `${req.nextUrl.pathname.replaceAll( | ||||
|       "/api/azure/", | ||||
|       "", | ||||
|     )}?api-version=${azureApiVersion}`; | ||||
|  | ||||
|     // Forward compatibility: | ||||
|     // if display_name(deployment_name) not set, and '{deploy-id}' in AZURE_URL | ||||
|     // then using default '{deploy-id}' | ||||
|     if (serverConfig.customModels && serverConfig.azureUrl) { | ||||
|       const modelName = path.split("/")[1]; | ||||
|       let realDeployName = ""; | ||||
|       serverConfig.customModels | ||||
|         .split(",") | ||||
|         .filter((v) => !!v && !v.startsWith("-") && v.includes(modelName)) | ||||
|         .forEach((m) => { | ||||
|           const [fullName, displayName] = m.split("="); | ||||
|           const [_, providerName] = fullName.split("@"); | ||||
|           if (providerName === "azure" && !displayName) { | ||||
|             const [_, deployId] = (serverConfig?.azureUrl ?? "").split( | ||||
|               "deployments/", | ||||
|             ); | ||||
|             if (deployId) { | ||||
|               realDeployName = deployId; | ||||
|             } | ||||
|           } | ||||
|   if (serverConfig.isAzure) { | ||||
|     if (!serverConfig.azureApiVersion) { | ||||
|       return NextResponse.json({ | ||||
|         error: true, | ||||
|         message: `missing AZURE_API_VERSION in server env vars`, | ||||
|       }); | ||||
|       if (realDeployName) { | ||||
|         console.log("[Replace with DeployId", realDeployName); | ||||
|         path = path.replaceAll(modelName, realDeployName); | ||||
|       } | ||||
|     } | ||||
|     path = makeAzurePath(path, serverConfig.azureApiVersion); | ||||
|   } | ||||
|  | ||||
|   const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}/${path}`); | ||||
|   console.log("fetchUrl", fetchUrl); | ||||
|   const fetchUrl = `${baseUrl}/${path}`; | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
| @@ -111,24 +87,17 @@ export async function requestOpenai(req: NextRequest) { | ||||
|   // #1815 try to refuse gpt4 request | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const modelTable = collectModelTable( | ||||
|         DEFAULT_MODELS, | ||||
|         serverConfig.customModels, | ||||
|       ); | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.OpenAI as string, | ||||
|         ) || | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Azure as string, | ||||
|         ) | ||||
|       ) { | ||||
|       if (modelTable[jsonBody?.model ?? ""].available === false) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
| @@ -147,29 +116,12 @@ export async function requestOpenai(req: NextRequest) { | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // Extract the OpenAI-Organization header from the response | ||||
|     const openaiOrganizationHeader = res.headers.get("OpenAI-Organization"); | ||||
|  | ||||
|     // Check if serverConfig.openaiOrgId is defined and not an empty string | ||||
|     if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") { | ||||
|       // If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present | ||||
|       console.log("[Org ID]", openaiOrganizationHeader); | ||||
|     } else { | ||||
|       console.log("[Org ID] is not set up."); | ||||
|     } | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     // Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV) | ||||
|     // Also, this is to prevent the header from being sent to the client | ||||
|     if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") { | ||||
|       newHeaders.delete("OpenAI-Organization"); | ||||
|     } | ||||
|  | ||||
|     // The latest version of the OpenAI API forced the content-encoding to be "br" in json response | ||||
|     // So if the streaming is disabled, we need to remove the content-encoding header | ||||
|     // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header | ||||
|   | ||||
| @@ -13,7 +13,6 @@ const DANGER_CONFIG = { | ||||
|   hideBalanceQuery: serverConfig.hideBalanceQuery, | ||||
|   disableFastLink: serverConfig.disableFastLink, | ||||
|   customModels: serverConfig.customModels, | ||||
|   defaultModel: serverConfig.defaultModel, | ||||
| }; | ||||
|  | ||||
| declare global { | ||||
|   | ||||
| @@ -1,14 +1,11 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "./auth"; | ||||
| import { auth } from "../../auth"; | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { ApiPath, GEMINI_BASE_URL, ModelProvider } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant"; | ||||
| 
 | ||||
| const serverConfig = getServerSideConfig(); | ||||
| 
 | ||||
| export async function handle( | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { provider: string; path: string[] } }, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Google Route] params ", params); | ||||
| 
 | ||||
| @@ -16,6 +13,32 @@ export async function handle( | ||||
|     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); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
| @@ -26,9 +49,9 @@ export async function handle( | ||||
|   const bearToken = req.headers.get("Authorization") ?? ""; | ||||
|   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( | ||||
|       { | ||||
|         error: true, | ||||
| @@ -39,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; | ||||
| 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 fetchUrl = `${baseUrl}/${path}?key=${key}`; | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
| @@ -127,3 +95,22 @@ async function request(req: NextRequest, apiKey: string) { | ||||
|     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,129 +0,0 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   IFLYTEK_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
| // iflytek | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Iflytek Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Iflytek); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Iflytek] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   // iflytek use base url or just remove the path | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Iflytek, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.iflytekUrl || IFLYTEK_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}${path}`; | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: req.headers.get("Authorization") ?? "", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Iflytek as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Iflytek] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -1,128 +0,0 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { | ||||
|   MOONSHOT_BASE_URL, | ||||
|   ApiPath, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { isModelAvailableInServer } from "@/app/utils/model"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| export async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Moonshot Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Moonshot); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Moonshot] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   // alibaba use base url or just remove the path | ||||
|   let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Moonshot, ""); | ||||
|  | ||||
|   let baseUrl = serverConfig.moonshotUrl || MOONSHOT_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Proxy] ", path); | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = `${baseUrl}${path}`; | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|       Authorization: req.headers.get("Authorization") ?? "", | ||||
|     }, | ||||
|     method: req.method, | ||||
|     body: req.body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   // #1815 try to refuse some request to some models | ||||
|   if (serverConfig.customModels && req.body) { | ||||
|     try { | ||||
|       const clonedBody = await req.text(); | ||||
|       fetchOptions.body = clonedBody; | ||||
|  | ||||
|       const jsonBody = JSON.parse(clonedBody) as { model?: string }; | ||||
|  | ||||
|       // not undefined and is false | ||||
|       if ( | ||||
|         isModelAvailableInServer( | ||||
|           serverConfig.customModels, | ||||
|           jsonBody?.model as string, | ||||
|           ServiceProvider.Moonshot as string, | ||||
|         ) | ||||
|       ) { | ||||
|         return NextResponse.json( | ||||
|           { | ||||
|             error: true, | ||||
|             message: `you are not allowed to use ${jsonBody?.model} model`, | ||||
|           }, | ||||
|           { | ||||
|             status: 403, | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.error(`[Moonshot] filter`, e); | ||||
|     } | ||||
|   } | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -3,8 +3,8 @@ import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { ModelProvider, OpenaiPath } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "./auth"; | ||||
| import { requestOpenai } from "./common"; | ||||
| import { auth } from "../../auth"; | ||||
| import { requestOpenai } from "../../common"; | ||||
| 
 | ||||
| const ALLOWD_PATH = new Set(Object.values(OpenaiPath)); | ||||
| 
 | ||||
| @@ -13,16 +13,14 @@ function getModels(remoteModelRes: OpenAIListModelResponse) { | ||||
| 
 | ||||
|   if (config.disableGPT4) { | ||||
|     remoteModelRes.data = remoteModelRes.data.filter( | ||||
|       (m) => | ||||
|         !(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) || | ||||
|         m.id.startsWith("gpt-4o-mini"), | ||||
|       (m) => !m.id.startsWith("gpt-4"), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return remoteModelRes; | ||||
| } | ||||
| 
 | ||||
| export async function handle( | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
| @@ -72,3 +70,27 @@ export async function handle( | ||||
|     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,117 +0,0 @@ | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
| import { TENCENT_BASE_URL, ModelProvider } from "@/app/constant"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { auth } from "@/app/api/auth"; | ||||
| import { getHeader } from "@/app/utils/tencent"; | ||||
|  | ||||
| const serverConfig = getServerSideConfig(); | ||||
|  | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| ) { | ||||
|   console.log("[Tencent Route] params ", params); | ||||
|  | ||||
|   if (req.method === "OPTIONS") { | ||||
|     return NextResponse.json({ body: "OK" }, { status: 200 }); | ||||
|   } | ||||
|  | ||||
|   const authResult = auth(req, ModelProvider.Hunyuan); | ||||
|   if (authResult.error) { | ||||
|     return NextResponse.json(authResult, { | ||||
|       status: 401, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const response = await request(req); | ||||
|     return response; | ||||
|   } catch (e) { | ||||
|     console.error("[Tencent] ", e); | ||||
|     return NextResponse.json(prettyObject(e)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const GET = handle; | ||||
| export const POST = handle; | ||||
|  | ||||
| export const runtime = "edge"; | ||||
| export const preferredRegion = [ | ||||
|   "arn1", | ||||
|   "bom1", | ||||
|   "cdg1", | ||||
|   "cle1", | ||||
|   "cpt1", | ||||
|   "dub1", | ||||
|   "fra1", | ||||
|   "gru1", | ||||
|   "hnd1", | ||||
|   "iad1", | ||||
|   "icn1", | ||||
|   "kix1", | ||||
|   "lhr1", | ||||
|   "pdx1", | ||||
|   "sfo1", | ||||
|   "sin1", | ||||
|   "syd1", | ||||
| ]; | ||||
|  | ||||
| async function request(req: NextRequest) { | ||||
|   const controller = new AbortController(); | ||||
|  | ||||
|   let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL; | ||||
|  | ||||
|   if (!baseUrl.startsWith("http")) { | ||||
|     baseUrl = `https://${baseUrl}`; | ||||
|   } | ||||
|  | ||||
|   if (baseUrl.endsWith("/")) { | ||||
|     baseUrl = baseUrl.slice(0, -1); | ||||
|   } | ||||
|  | ||||
|   console.log("[Base Url]", baseUrl); | ||||
|  | ||||
|   const timeoutId = setTimeout( | ||||
|     () => { | ||||
|       controller.abort(); | ||||
|     }, | ||||
|     10 * 60 * 1000, | ||||
|   ); | ||||
|  | ||||
|   const fetchUrl = baseUrl; | ||||
|  | ||||
|   const body = await req.text(); | ||||
|   const headers = await getHeader( | ||||
|     body, | ||||
|     serverConfig.tencentSecretId as string, | ||||
|     serverConfig.tencentSecretKey as string, | ||||
|   ); | ||||
|   const fetchOptions: RequestInit = { | ||||
|     headers, | ||||
|     method: req.method, | ||||
|     body, | ||||
|     redirect: "manual", | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|     signal: controller.signal, | ||||
|   }; | ||||
|  | ||||
|   try { | ||||
|     const res = await fetch(fetchUrl, fetchOptions); | ||||
|  | ||||
|     // to prevent browser prompt for credentials | ||||
|     const newHeaders = new Headers(res.headers); | ||||
|     newHeaders.delete("www-authenticate"); | ||||
|     // to disable nginx buffering | ||||
|     newHeaders.set("X-Accel-Buffering", "no"); | ||||
|  | ||||
|     return new Response(res.body, { | ||||
|       status: res.status, | ||||
|       statusText: res.statusText, | ||||
|       headers: newHeaders, | ||||
|     }); | ||||
|   } finally { | ||||
|     clearTimeout(timeoutId); | ||||
|   } | ||||
| } | ||||
| @@ -1,22 +1,5 @@ | ||||
| import { NextRequest, NextResponse } from "next/server"; | ||||
| import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant"; | ||||
| import { getServerSideConfig } from "@/app/config/server"; | ||||
|  | ||||
| const config = getServerSideConfig(); | ||||
|  | ||||
| const mergedAllowedWebDavEndpoints = [ | ||||
|   ...internalAllowedWebDavEndpoints, | ||||
|   ...config.allowedWebDevEndpoints, | ||||
| ].filter((domain) => Boolean(domain.trim())); | ||||
|  | ||||
| const normalizeUrl = (url: string) => { | ||||
|   try { | ||||
|     return new URL(url); | ||||
|   } catch (err) { | ||||
|     return null; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| import { STORAGE_KEY } from "../../../constant"; | ||||
| async function handle( | ||||
|   req: NextRequest, | ||||
|   { params }: { params: { path: string[] } }, | ||||
| @@ -29,52 +12,17 @@ async function handle( | ||||
|  | ||||
|   const requestUrl = new URL(req.url); | ||||
|   let endpoint = requestUrl.searchParams.get("endpoint"); | ||||
|   let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method; | ||||
|  | ||||
|   // Validate the endpoint to prevent potential SSRF attacks | ||||
|   if ( | ||||
|     !endpoint || | ||||
|     !mergedAllowedWebDavEndpoints.some((allowedEndpoint) => { | ||||
|       const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint); | ||||
|       const normalizedEndpoint = normalizeUrl(endpoint as string); | ||||
|  | ||||
|       return ( | ||||
|         normalizedEndpoint && | ||||
|         normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname && | ||||
|         normalizedEndpoint.pathname.startsWith( | ||||
|           normalizedAllowedEndpoint.pathname, | ||||
|         ) | ||||
|       ); | ||||
|     }) | ||||
|   ) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "Invalid endpoint", | ||||
|       }, | ||||
|       { | ||||
|         status: 400, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (!endpoint?.endsWith("/")) { | ||||
|     endpoint += "/"; | ||||
|   } | ||||
|  | ||||
|   const endpointPath = params.path.join("/"); | ||||
|   const targetPath = `${endpoint}${endpointPath}`; | ||||
|  | ||||
|   // only allow MKCOL, GET, PUT | ||||
|   if ( | ||||
|     proxy_method !== "MKCOL" && | ||||
|     proxy_method !== "GET" && | ||||
|     proxy_method !== "PUT" | ||||
|   ) { | ||||
|   if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + targetPath, | ||||
|         msg: "you are not allowed to request " + params.path.join("/"), | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
| @@ -83,11 +31,14 @@ async function handle( | ||||
|   } | ||||
|  | ||||
|   // for MKCOL request, only allow request ${folder} | ||||
|   if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) { | ||||
|   if ( | ||||
|     req.method == "MKCOL" && | ||||
|     !new URL(endpointPath).pathname.endsWith(folder) | ||||
|   ) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + targetPath, | ||||
|         msg: "you are not allowed to request " + params.path.join("/"), | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
| @@ -96,11 +47,14 @@ async function handle( | ||||
|   } | ||||
|  | ||||
|   // for GET request, only allow request ending with fileName | ||||
|   if (proxy_method === "GET" && !targetPath.endsWith(fileName)) { | ||||
|   if ( | ||||
|     req.method == "GET" && | ||||
|     !new URL(endpointPath).pathname.endsWith(fileName) | ||||
|   ) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + targetPath, | ||||
|         msg: "you are not allowed to request " + params.path.join("/"), | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
| @@ -109,11 +63,14 @@ async function handle( | ||||
|   } | ||||
|  | ||||
|   //   for PUT request, only allow request ending with fileName | ||||
|   if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) { | ||||
|   if ( | ||||
|     req.method == "PUT" && | ||||
|     !new URL(endpointPath).pathname.endsWith(fileName) | ||||
|   ) { | ||||
|     return NextResponse.json( | ||||
|       { | ||||
|         error: true, | ||||
|         msg: "you are not allowed to request " + targetPath, | ||||
|         msg: "you are not allowed to request " + params.path.join("/"), | ||||
|       }, | ||||
|       { | ||||
|         status: 403, | ||||
| @@ -121,9 +78,9 @@ async function handle( | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const targetUrl = targetPath; | ||||
|   const targetUrl = `${endpoint + endpointPath}`; | ||||
|  | ||||
|   const method = proxy_method || req.method; | ||||
|   const method = req.method; | ||||
|   const shouldNotHaveBody = ["get", "head"].includes( | ||||
|     method?.toLowerCase() ?? "", | ||||
|   ); | ||||
| @@ -133,34 +90,22 @@ async function handle( | ||||
|       authorization: req.headers.get("authorization") ?? "", | ||||
|     }, | ||||
|     body: shouldNotHaveBody ? null : req.body, | ||||
|     redirect: "manual", | ||||
|     method, | ||||
|     // @ts-ignore | ||||
|     duplex: "half", | ||||
|   }; | ||||
|  | ||||
|   let fetchResult; | ||||
|   const fetchResult = await fetch(targetUrl, fetchOptions); | ||||
|  | ||||
|   try { | ||||
|     fetchResult = await fetch(targetUrl, fetchOptions); | ||||
|   } finally { | ||||
|     console.log( | ||||
|       "[Any Proxy]", | ||||
|       targetUrl, | ||||
|       { | ||||
|         method: method, | ||||
|       }, | ||||
|       { | ||||
|         status: fetchResult?.status, | ||||
|         statusText: fetchResult?.statusText, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|   console.log("[Any Proxy]", targetUrl, { | ||||
|     status: fetchResult.status, | ||||
|     statusText: fetchResult.statusText, | ||||
|   }); | ||||
|  | ||||
|   return fetchResult; | ||||
| } | ||||
|  | ||||
| export const PUT = handle; | ||||
| export const POST = handle; | ||||
| export const GET = handle; | ||||
| export const OPTIONS = handle; | ||||
|  | ||||
|   | ||||
							
								
								
									
										9
									
								
								app/azure.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| export function makeAzurePath(path: string, apiVersion: string) { | ||||
|   // should omit /v1 prefix | ||||
|   path = path.replaceAll("v1/", ""); | ||||
|  | ||||
|   // should add api-key to query string | ||||
|   path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`; | ||||
|  | ||||
|   return path; | ||||
| } | ||||
| @@ -1,31 +1,17 @@ | ||||
| import { getClientConfig } from "../config/client"; | ||||
| import { | ||||
|   ACCESS_CODE_PREFIX, | ||||
|   Azure, | ||||
|   ModelProvider, | ||||
|   ServiceProvider, | ||||
| } from "../constant"; | ||||
| import { | ||||
|   ChatMessageTool, | ||||
|   ChatMessage, | ||||
|   ModelType, | ||||
|   useAccessStore, | ||||
|   useChatStore, | ||||
| } from "../store"; | ||||
| import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai"; | ||||
| import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; | ||||
| import { ChatGPTApi } from "./platforms/openai"; | ||||
| import { GeminiProApi } from "./platforms/google"; | ||||
| import { ClaudeApi } from "./platforms/anthropic"; | ||||
| import { ErnieApi } from "./platforms/baidu"; | ||||
| import { DoubaoApi } from "./platforms/bytedance"; | ||||
| import { QwenApi } from "./platforms/alibaba"; | ||||
| import { HunyuanApi } from "./platforms/tencent"; | ||||
| import { MoonshotApi } from "./platforms/moonshot"; | ||||
| import { SparkApi } from "./platforms/iflytek"; | ||||
|  | ||||
| export const ROLES = ["system", "user", "assistant"] as const; | ||||
| export type MessageRole = (typeof ROLES)[number]; | ||||
|  | ||||
| export const Models = ["gpt-3.5-turbo", "gpt-4"] as const; | ||||
| export const TTSModels = ["tts-1", "tts-1-hd"] as const; | ||||
| export type ChatModel = ModelType; | ||||
|  | ||||
| export interface MultimodalContent { | ||||
| @@ -43,24 +29,11 @@ export interface RequestMessage { | ||||
|  | ||||
| export interface LLMConfig { | ||||
|   model: string; | ||||
|   providerName?: string; | ||||
|   temperature?: number; | ||||
|   top_p?: number; | ||||
|   stream?: boolean; | ||||
|   presence_penalty?: number; | ||||
|   frequency_penalty?: number; | ||||
|   size?: DalleRequestPayload["size"]; | ||||
|   quality?: DalleRequestPayload["quality"]; | ||||
|   style?: DalleRequestPayload["style"]; | ||||
| } | ||||
|  | ||||
| export interface SpeechOptions { | ||||
|   model: string; | ||||
|   input: string; | ||||
|   voice: string; | ||||
|   response_format?: string; | ||||
|   speed?: number; | ||||
|   onController?: (controller: AbortController) => void; | ||||
| } | ||||
|  | ||||
| export interface ChatOptions { | ||||
| @@ -71,8 +44,6 @@ export interface ChatOptions { | ||||
|   onFinish: (message: string) => void; | ||||
|   onError?: (err: Error) => void; | ||||
|   onController?: (controller: AbortController) => void; | ||||
|   onBeforeTool?: (tool: ChatMessageTool) => void; | ||||
|   onAfterTool?: (tool: ChatMessageTool) => void; | ||||
| } | ||||
|  | ||||
| export interface LLMUsage { | ||||
| @@ -82,22 +53,18 @@ export interface LLMUsage { | ||||
|  | ||||
| export interface LLMModel { | ||||
|   name: string; | ||||
|   displayName?: string; | ||||
|   available: boolean; | ||||
|   provider: LLMModelProvider; | ||||
|   sorted: number; | ||||
| } | ||||
|  | ||||
| export interface LLMModelProvider { | ||||
|   id: string; | ||||
|   providerName: string; | ||||
|   providerType: string; | ||||
|   sorted: number; | ||||
| } | ||||
|  | ||||
| export abstract class LLMApi { | ||||
|   abstract chat(options: ChatOptions): Promise<void>; | ||||
|   abstract speech(options: SpeechOptions): Promise<ArrayBuffer>; | ||||
|   abstract usage(): Promise<LLMUsage>; | ||||
|   abstract models(): Promise<LLMModel[]>; | ||||
| } | ||||
| @@ -127,34 +94,11 @@ export class ClientApi { | ||||
|   public llm: LLMApi; | ||||
|  | ||||
|   constructor(provider: ModelProvider = ModelProvider.GPT) { | ||||
|     switch (provider) { | ||||
|       case ModelProvider.GeminiPro: | ||||
|     if (provider === ModelProvider.GeminiPro) { | ||||
|       this.llm = new GeminiProApi(); | ||||
|         break; | ||||
|       case ModelProvider.Claude: | ||||
|         this.llm = new ClaudeApi(); | ||||
|         break; | ||||
|       case ModelProvider.Ernie: | ||||
|         this.llm = new ErnieApi(); | ||||
|         break; | ||||
|       case ModelProvider.Doubao: | ||||
|         this.llm = new DoubaoApi(); | ||||
|         break; | ||||
|       case ModelProvider.Qwen: | ||||
|         this.llm = new QwenApi(); | ||||
|         break; | ||||
|       case ModelProvider.Hunyuan: | ||||
|         this.llm = new HunyuanApi(); | ||||
|         break; | ||||
|       case ModelProvider.Moonshot: | ||||
|         this.llm = new MoonshotApi(); | ||||
|         break; | ||||
|       case ModelProvider.Iflytek: | ||||
|         this.llm = new SparkApi(); | ||||
|         break; | ||||
|       default: | ||||
|         this.llm = new ChatGPTApi(); | ||||
|       return; | ||||
|     } | ||||
|     this.llm = new ChatGPTApi(); | ||||
|   } | ||||
|  | ||||
|   config() {} | ||||
| @@ -203,125 +147,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(ignoreHeaders: boolean = false) { | ||||
| export function getHeaders() { | ||||
|   const accessStore = useAccessStore.getState(); | ||||
|   const chatStore = useChatStore.getState(); | ||||
|   let headers: Record<string, string> = {}; | ||||
|   if (!ignoreHeaders) { | ||||
|     headers = { | ||||
|   const headers: Record<string, string> = { | ||||
|     "Content-Type": "application/json", | ||||
|     Accept: "application/json", | ||||
|   }; | ||||
|   } | ||||
|  | ||||
|   const clientConfig = getClientConfig(); | ||||
|  | ||||
|   function getConfig() { | ||||
|     const modelConfig = chatStore.currentSession().mask.modelConfig; | ||||
|     const isGoogle = modelConfig.providerName == ServiceProvider.Google; | ||||
|     const isAzure = modelConfig.providerName === ServiceProvider.Azure; | ||||
|     const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic; | ||||
|     const isBaidu = modelConfig.providerName == ServiceProvider.Baidu; | ||||
|     const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance; | ||||
|     const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba; | ||||
|     const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot; | ||||
|     const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek; | ||||
|     const isEnabledAccessControl = accessStore.enabledAccessControl(); | ||||
|   const modelConfig = useChatStore.getState().currentSession().mask.modelConfig; | ||||
|   const isGoogle = modelConfig.model.startsWith("gemini"); | ||||
|   const isAzure = accessStore.provider === ServiceProvider.Azure; | ||||
|   const authHeader = isAzure ? "api-key" : "Authorization"; | ||||
|   const apiKey = isGoogle | ||||
|     ? accessStore.googleApiKey | ||||
|     : isAzure | ||||
|     ? accessStore.azureApiKey | ||||
|       : isAnthropic | ||||
|       ? accessStore.anthropicApiKey | ||||
|       : isByteDance | ||||
|       ? accessStore.bytedanceApiKey | ||||
|       : isAlibaba | ||||
|       ? accessStore.alibabaApiKey | ||||
|       : isMoonshot | ||||
|       ? accessStore.moonshotApiKey | ||||
|       : isIflytek | ||||
|       ? accessStore.iflytekApiKey && accessStore.iflytekApiSecret | ||||
|         ? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret | ||||
|         : "" | ||||
|     : accessStore.openaiApiKey; | ||||
|     return { | ||||
|       isGoogle, | ||||
|       isAzure, | ||||
|       isAnthropic, | ||||
|       isBaidu, | ||||
|       isByteDance, | ||||
|       isAlibaba, | ||||
|       isMoonshot, | ||||
|       isIflytek, | ||||
|       apiKey, | ||||
|       isEnabledAccessControl, | ||||
|     }; | ||||
|   } | ||||
|   const clientConfig = getClientConfig(); | ||||
|   const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`; | ||||
|   const validString = (x: string) => x && x.length > 0; | ||||
|  | ||||
|   function getAuthHeader(): string { | ||||
|     return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization"; | ||||
|   } | ||||
|  | ||||
|   const { | ||||
|     isGoogle, | ||||
|     isAzure, | ||||
|     isAnthropic, | ||||
|     isBaidu, | ||||
|     apiKey, | ||||
|     isEnabledAccessControl, | ||||
|   } = getConfig(); | ||||
|   // when using google api in app, not set auth header | ||||
|   if (isGoogle && clientConfig?.isApp) return headers; | ||||
|   // when using baidu api in app, not set auth header | ||||
|   if (isBaidu && clientConfig?.isApp) return headers; | ||||
|  | ||||
|   const authHeader = getAuthHeader(); | ||||
|  | ||||
|   const bearerToken = getBearerToken(apiKey, isAzure || isAnthropic); | ||||
|  | ||||
|   if (bearerToken) { | ||||
|     headers[authHeader] = bearerToken; | ||||
|   } else if (isEnabledAccessControl && validString(accessStore.accessCode)) { | ||||
|     headers["Authorization"] = getBearerToken( | ||||
|   if (!(isGoogle && clientConfig?.isApp)) { | ||||
|     // use user's api key first | ||||
|     if (validString(apiKey)) { | ||||
|       headers[authHeader] = makeBearer(apiKey); | ||||
|     } else if ( | ||||
|       accessStore.enabledAccessControl() && | ||||
|       validString(accessStore.accessCode) | ||||
|     ) { | ||||
|       headers[authHeader] = makeBearer( | ||||
|         ACCESS_CODE_PREFIX + accessStore.accessCode, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return headers; | ||||
| } | ||||
|  | ||||
| export function getClientApi(provider: ServiceProvider): ClientApi { | ||||
|   switch (provider) { | ||||
|     case ServiceProvider.Google: | ||||
|       return new ClientApi(ModelProvider.GeminiPro); | ||||
|     case ServiceProvider.Anthropic: | ||||
|       return new ClientApi(ModelProvider.Claude); | ||||
|     case ServiceProvider.Baidu: | ||||
|       return new ClientApi(ModelProvider.Ernie); | ||||
|     case ServiceProvider.ByteDance: | ||||
|       return new ClientApi(ModelProvider.Doubao); | ||||
|     case ServiceProvider.Alibaba: | ||||
|       return new ClientApi(ModelProvider.Qwen); | ||||
|     case ServiceProvider.Tencent: | ||||
|       return new ClientApi(ModelProvider.Hunyuan); | ||||
|     case ServiceProvider.Moonshot: | ||||
|       return new ClientApi(ModelProvider.Moonshot); | ||||
|     case ServiceProvider.Iflytek: | ||||
|       return new ClientApi(ModelProvider.Iflytek); | ||||
|     default: | ||||
|       return new ClientApi(ModelProvider.GPT); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,273 +0,0 @@ | ||||
| "use client"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   Alibaba, | ||||
|   ALIBABA_BASE_URL, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   SpeechOptions, | ||||
|   MultimodalContent, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent } from "@/app/utils"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
|   object: string; | ||||
|   data: Array<{ | ||||
|     id: string; | ||||
|     object: string; | ||||
|     root: string; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| interface RequestInput { | ||||
|   messages: { | ||||
|     role: "system" | "user" | "assistant"; | ||||
|     content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
| } | ||||
| interface RequestParam { | ||||
|   result_format: string; | ||||
|   incremental_output?: boolean; | ||||
|   temperature: number; | ||||
|   repetition_penalty?: number; | ||||
|   top_p: number; | ||||
|   max_tokens?: number; | ||||
| } | ||||
| interface RequestPayload { | ||||
|   model: string; | ||||
|   input: RequestInput; | ||||
|   parameters: RequestParam; | ||||
| } | ||||
|  | ||||
| export class QwenApi implements LLMApi { | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.alibabaUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       baseUrl = isApp ? ALIBABA_BASE_URL : ApiPath.Alibaba; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Alibaba)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     return res?.output?.choices?.at(0)?.message?.content ?? ""; | ||||
|   } | ||||
|  | ||||
|   speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     throw new Error("Method not implemented."); | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       role: v.role, | ||||
|       content: getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const requestPayload: RequestPayload = { | ||||
|       model: modelConfig.model, | ||||
|       input: { | ||||
|         messages, | ||||
|       }, | ||||
|       parameters: { | ||||
|         result_format: "message", | ||||
|         incremental_output: shouldStream, | ||||
|         temperature: modelConfig.temperature, | ||||
|         // max_tokens: modelConfig.max_tokens, | ||||
|         top_p: modelConfig.top_p === 1 ? 0.99 : modelConfig.top_p, // qwen top_p is should be < 1 | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(Alibaba.ChatPath); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: { | ||||
|           ...getHeaders(), | ||||
|           "X-DashScope-SSE": shouldStream ? "enable" : "disable", | ||||
|         }, | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log( | ||||
|               "[Alibaba] request response content type: ", | ||||
|               contentType, | ||||
|             ); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const choices = json.output.choices as Array<{ | ||||
|                 message: { content: string }; | ||||
|               }>; | ||||
|               const delta = choices[0]?.message?.content; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| export { Alibaba }; | ||||
| @@ -1,415 +0,0 @@ | ||||
| import { Anthropic, ApiPath } from "@/app/constant"; | ||||
| import { ChatOptions, getHeaders, LLMApi, SpeechOptions } from "../api"; | ||||
| import { | ||||
|   useAccessStore, | ||||
|   useAppConfig, | ||||
|   useChatStore, | ||||
|   usePluginStore, | ||||
|   ChatMessageTool, | ||||
| } from "@/app/store"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { DEFAULT_API_HOST } from "@/app/constant"; | ||||
| import { getMessageTextContent, isVisionModel } from "@/app/utils"; | ||||
| import { preProcessImageContent, stream } from "@/app/utils/chat"; | ||||
| import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; | ||||
| import { RequestPayload } from "./openai"; | ||||
|  | ||||
| export type MultiBlockContent = { | ||||
|   type: "image" | "text"; | ||||
|   source?: { | ||||
|     type: string; | ||||
|     media_type: string; | ||||
|     data: string; | ||||
|   }; | ||||
|   text?: string; | ||||
| }; | ||||
|  | ||||
| export type AnthropicMessage = { | ||||
|   role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper]; | ||||
|   content: string | MultiBlockContent[]; | ||||
| }; | ||||
|  | ||||
| export interface AnthropicChatRequest { | ||||
|   model: string; // The model that will complete your prompt. | ||||
|   messages: AnthropicMessage[]; // The prompt that you want Claude to complete. | ||||
|   max_tokens: number; // The maximum number of tokens to generate before stopping. | ||||
|   stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text. | ||||
|   temperature?: number; // Amount of randomness injected into the response. | ||||
|   top_p?: number; // Use nucleus sampling. | ||||
|   top_k?: number; // Only sample from the top K options for each subsequent token. | ||||
|   metadata?: object; // An object describing metadata about the request. | ||||
|   stream?: boolean; // Whether to incrementally stream the response using server-sent events. | ||||
| } | ||||
|  | ||||
| export interface ChatRequest { | ||||
|   model: string; // The model that will complete your prompt. | ||||
|   prompt: string; // The prompt that you want Claude to complete. | ||||
|   max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping. | ||||
|   stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text. | ||||
|   temperature?: number; // Amount of randomness injected into the response. | ||||
|   top_p?: number; // Use nucleus sampling. | ||||
|   top_k?: number; // Only sample from the top K options for each subsequent token. | ||||
|   metadata?: object; // An object describing metadata about the request. | ||||
|   stream?: boolean; // Whether to incrementally stream the response using server-sent events. | ||||
| } | ||||
|  | ||||
| export interface ChatResponse { | ||||
|   completion: string; | ||||
|   stop_reason: "stop_sequence" | "max_tokens"; | ||||
|   model: string; | ||||
| } | ||||
|  | ||||
| export type ChatStreamResponse = ChatResponse & { | ||||
|   stop?: string; | ||||
|   log_id: string; | ||||
| }; | ||||
|  | ||||
| const ClaudeMapper = { | ||||
|   assistant: "assistant", | ||||
|   user: "user", | ||||
|   system: "user", | ||||
| } as const; | ||||
|  | ||||
| const keys = ["claude-2, claude-instant-1"]; | ||||
|  | ||||
| export class ClaudeApi implements LLMApi { | ||||
|   speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     throw new Error("Method not implemented."); | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     console.log("[Response] claude response: ", res); | ||||
|  | ||||
|     return res?.content?.[0]?.text; | ||||
|   } | ||||
|   async chat(options: ChatOptions): Promise<void> { | ||||
|     const visionModel = isVisionModel(options.config.model); | ||||
|  | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     // try get base64image from local cache image_url | ||||
|     const messages: ChatOptions["messages"] = []; | ||||
|     for (const v of options.messages) { | ||||
|       const content = await preProcessImageContent(v.content); | ||||
|       messages.push({ role: v.role, content }); | ||||
|     } | ||||
|  | ||||
|     const keys = ["system", "user"]; | ||||
|  | ||||
|     // roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages | ||||
|     for (let i = 0; i < messages.length - 1; i++) { | ||||
|       const message = messages[i]; | ||||
|       const nextMessage = messages[i + 1]; | ||||
|  | ||||
|       if (keys.includes(message.role) && keys.includes(nextMessage.role)) { | ||||
|         messages[i] = [ | ||||
|           message, | ||||
|           { | ||||
|             role: "assistant", | ||||
|             content: ";", | ||||
|           }, | ||||
|         ] as any; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const prompt = messages | ||||
|       .flat() | ||||
|       .filter((v) => { | ||||
|         if (!v.content) return false; | ||||
|         if (typeof v.content === "string" && !v.content.trim()) return false; | ||||
|         return true; | ||||
|       }) | ||||
|       .map((v) => { | ||||
|         const { role, content } = v; | ||||
|         const insideRole = ClaudeMapper[role] ?? "user"; | ||||
|  | ||||
|         if (!visionModel || typeof content === "string") { | ||||
|           return { | ||||
|             role: insideRole, | ||||
|             content: getMessageTextContent(v), | ||||
|           }; | ||||
|         } | ||||
|         return { | ||||
|           role: insideRole, | ||||
|           content: content | ||||
|             .filter((v) => v.image_url || v.text) | ||||
|             .map(({ type, text, image_url }) => { | ||||
|               if (type === "text") { | ||||
|                 return { | ||||
|                   type, | ||||
|                   text: text!, | ||||
|                 }; | ||||
|               } | ||||
|               const { url = "" } = image_url || {}; | ||||
|               const colonIndex = url.indexOf(":"); | ||||
|               const semicolonIndex = url.indexOf(";"); | ||||
|               const comma = url.indexOf(","); | ||||
|  | ||||
|               const mimeType = url.slice(colonIndex + 1, semicolonIndex); | ||||
|               const encodeType = url.slice(semicolonIndex + 1, comma); | ||||
|               const data = url.slice(comma + 1); | ||||
|  | ||||
|               return { | ||||
|                 type: "image" as const, | ||||
|                 source: { | ||||
|                   type: encodeType, | ||||
|                   media_type: mimeType, | ||||
|                   data, | ||||
|                 }, | ||||
|               }; | ||||
|             }), | ||||
|         }; | ||||
|       }); | ||||
|  | ||||
|     if (prompt[0]?.role === "assistant") { | ||||
|       prompt.unshift({ | ||||
|         role: "user", | ||||
|         content: ";", | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     const requestBody: AnthropicChatRequest = { | ||||
|       messages: prompt, | ||||
|       stream: shouldStream, | ||||
|  | ||||
|       model: modelConfig.model, | ||||
|       max_tokens: modelConfig.max_tokens, | ||||
|       temperature: modelConfig.temperature, | ||||
|       top_p: modelConfig.top_p, | ||||
|       // top_k: modelConfig.top_k, | ||||
|       top_k: 5, | ||||
|     }; | ||||
|  | ||||
|     const path = this.path(Anthropic.ChatPath); | ||||
|  | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     if (shouldStream) { | ||||
|       let index = -1; | ||||
|       const [tools, funcs] = usePluginStore | ||||
|         .getState() | ||||
|         .getAsTools( | ||||
|           useChatStore.getState().currentSession().mask?.plugin || [], | ||||
|         ); | ||||
|       return stream( | ||||
|         path, | ||||
|         requestBody, | ||||
|         { | ||||
|           ...getHeaders(), | ||||
|           "anthropic-version": accessStore.anthropicApiVersion, | ||||
|         }, | ||||
|         // @ts-ignore | ||||
|         tools.map((tool) => ({ | ||||
|           name: tool?.function?.name, | ||||
|           description: tool?.function?.description, | ||||
|           input_schema: tool?.function?.parameters, | ||||
|         })), | ||||
|         funcs, | ||||
|         controller, | ||||
|         // parseSSE | ||||
|         (text: string, runTools: ChatMessageTool[]) => { | ||||
|           // console.log("parseSSE", text, runTools); | ||||
|           let chunkJson: | ||||
|             | undefined | ||||
|             | { | ||||
|                 type: "content_block_delta" | "content_block_stop"; | ||||
|                 content_block?: { | ||||
|                   type: "tool_use"; | ||||
|                   id: string; | ||||
|                   name: string; | ||||
|                 }; | ||||
|                 delta?: { | ||||
|                   type: "text_delta" | "input_json_delta"; | ||||
|                   text?: string; | ||||
|                   partial_json?: string; | ||||
|                 }; | ||||
|                 index: number; | ||||
|               }; | ||||
|           chunkJson = JSON.parse(text); | ||||
|  | ||||
|           if (chunkJson?.content_block?.type == "tool_use") { | ||||
|             index += 1; | ||||
|             const id = chunkJson?.content_block.id; | ||||
|             const name = chunkJson?.content_block.name; | ||||
|             runTools.push({ | ||||
|               id, | ||||
|               type: "function", | ||||
|               function: { | ||||
|                 name, | ||||
|                 arguments: "", | ||||
|               }, | ||||
|             }); | ||||
|           } | ||||
|           if ( | ||||
|             chunkJson?.delta?.type == "input_json_delta" && | ||||
|             chunkJson?.delta?.partial_json | ||||
|           ) { | ||||
|             // @ts-ignore | ||||
|             runTools[index]["function"]["arguments"] += | ||||
|               chunkJson?.delta?.partial_json; | ||||
|           } | ||||
|           return chunkJson?.delta?.text; | ||||
|         }, | ||||
|         // processToolMessage, include tool_calls message and tool call results | ||||
|         ( | ||||
|           requestPayload: RequestPayload, | ||||
|           toolCallMessage: any, | ||||
|           toolCallResult: any[], | ||||
|         ) => { | ||||
|           // reset index value | ||||
|           index = -1; | ||||
|           // @ts-ignore | ||||
|           requestPayload?.messages?.splice( | ||||
|             // @ts-ignore | ||||
|             requestPayload?.messages?.length, | ||||
|             0, | ||||
|             { | ||||
|               role: "assistant", | ||||
|               content: toolCallMessage.tool_calls.map( | ||||
|                 (tool: ChatMessageTool) => ({ | ||||
|                   type: "tool_use", | ||||
|                   id: tool.id, | ||||
|                   name: tool?.function?.name, | ||||
|                   input: tool?.function?.arguments | ||||
|                     ? JSON.parse(tool?.function?.arguments) | ||||
|                     : {}, | ||||
|                 }), | ||||
|               ), | ||||
|             }, | ||||
|             // @ts-ignore | ||||
|             ...toolCallResult.map((result) => ({ | ||||
|               role: "user", | ||||
|               content: [ | ||||
|                 { | ||||
|                   type: "tool_result", | ||||
|                   tool_use_id: result.tool_call_id, | ||||
|                   content: result.content, | ||||
|                 }, | ||||
|               ], | ||||
|             })), | ||||
|           ); | ||||
|         }, | ||||
|         options, | ||||
|       ); | ||||
|     } else { | ||||
|       const payload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestBody), | ||||
|         signal: controller.signal, | ||||
|         headers: { | ||||
|           ...getHeaders(), // get common headers | ||||
|           "anthropic-version": accessStore.anthropicApiVersion, | ||||
|           // do not send `anthropicApiKey` in browser!!! | ||||
|           // Authorization: getAuthKey(accessStore.anthropicApiKey), | ||||
|         }, | ||||
|       }; | ||||
|  | ||||
|       try { | ||||
|         controller.signal.onabort = () => options.onFinish(""); | ||||
|  | ||||
|         const res = await fetch(path, payload); | ||||
|         const resJson = await res.json(); | ||||
|  | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } catch (e) { | ||||
|         console.error("failed to chat", e); | ||||
|         options.onError?.(e as Error); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|   async models() { | ||||
|     // const provider = { | ||||
|     //   id: "anthropic", | ||||
|     //   providerName: "Anthropic", | ||||
|     //   providerType: "anthropic", | ||||
|     // }; | ||||
|  | ||||
|     return [ | ||||
|       // { | ||||
|       //   name: "claude-instant-1.2", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-2.0", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-2.1", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-3-opus-20240229", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-3-sonnet-20240229", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|       // { | ||||
|       //   name: "claude-3-haiku-20240307", | ||||
|       //   available: true, | ||||
|       //   provider, | ||||
|       // }, | ||||
|     ]; | ||||
|   } | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl: string = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.anthropicUrl; | ||||
|     } | ||||
|  | ||||
|     // if endpoint is empty, use default endpoint | ||||
|     if (baseUrl.trim().length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|  | ||||
|       baseUrl = isApp | ||||
|         ? DEFAULT_API_HOST + "/api/proxy/anthropic" | ||||
|         : ApiPath.Anthropic; | ||||
|     } | ||||
|  | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     baseUrl = trimEnd(baseUrl, "/"); | ||||
|  | ||||
|     // try rebuild url, when using cloudflare ai gateway in client | ||||
|     return cloudflareAIGatewayUrl(`${baseUrl}/${path}`); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function trimEnd(s: string, end = " ") { | ||||
|   if (end.length === 0) return s; | ||||
|  | ||||
|   while (s.endsWith(end)) { | ||||
|     s = s.slice(0, -end.length); | ||||
|   } | ||||
|  | ||||
|   return s; | ||||
| } | ||||
| @@ -1,286 +0,0 @@ | ||||
| "use client"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   Baidu, | ||||
|   BAIDU_BASE_URL, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
| import { getAccessToken } from "@/app/utils/baidu"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   MultimodalContent, | ||||
|   SpeechOptions, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent } from "@/app/utils"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
|   object: string; | ||||
|   data: Array<{ | ||||
|     id: string; | ||||
|     object: string; | ||||
|     root: string; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| interface RequestPayload { | ||||
|   messages: { | ||||
|     role: "system" | "user" | "assistant"; | ||||
|     content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
|   stream?: boolean; | ||||
|   model: string; | ||||
|   temperature: number; | ||||
|   presence_penalty: number; | ||||
|   frequency_penalty: number; | ||||
|   top_p: number; | ||||
|   max_tokens?: number; | ||||
| } | ||||
|  | ||||
| export class ErnieApi implements LLMApi { | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.baiduUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       // do not use proxy for baidubce api | ||||
|       baseUrl = isApp ? BAIDU_BASE_URL : ApiPath.Baidu; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Baidu)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     throw new Error("Method not implemented."); | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       // "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function", | ||||
|       role: v.role === "system" ? "user" : v.role, | ||||
|       content: getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     // "error_code": 336006, "error_msg": "the length of messages must be an odd number", | ||||
|     if (messages.length % 2 === 0) { | ||||
|       if (messages.at(0)?.role === "user") { | ||||
|         messages.splice(1, 0, { | ||||
|           role: "assistant", | ||||
|           content: " ", | ||||
|         }); | ||||
|       } else { | ||||
|         messages.unshift({ | ||||
|           role: "user", | ||||
|           content: " ", | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const requestPayload: RequestPayload = { | ||||
|       messages, | ||||
|       stream: shouldStream, | ||||
|       model: modelConfig.model, | ||||
|       temperature: modelConfig.temperature, | ||||
|       presence_penalty: modelConfig.presence_penalty, | ||||
|       frequency_penalty: modelConfig.frequency_penalty, | ||||
|       top_p: modelConfig.top_p, | ||||
|     }; | ||||
|  | ||||
|     console.log("[Request] Baidu payload: ", requestPayload); | ||||
|  | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       let chatPath = this.path(Baidu.ChatPath(modelConfig.model)); | ||||
|  | ||||
|       // getAccessToken can not run in browser, because cors error | ||||
|       if (!!getClientConfig()?.isApp) { | ||||
|         const accessStore = useAccessStore.getState(); | ||||
|         if (accessStore.useCustomConfig) { | ||||
|           if (accessStore.isValidBaidu()) { | ||||
|             const { access_token } = await getAccessToken( | ||||
|               accessStore.baiduApiKey, | ||||
|               accessStore.baiduSecretKey, | ||||
|             ); | ||||
|             chatPath = `${chatPath}${ | ||||
|               chatPath.includes("?") ? "&" : "?" | ||||
|             }access_token=${access_token}`; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log("[Baidu] request response content type: ", contentType); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const delta = json?.result; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = resJson?.result; | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| export { Baidu }; | ||||
| @@ -1,260 +0,0 @@ | ||||
| "use client"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   ByteDance, | ||||
|   BYTEDANCE_BASE_URL, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   MultimodalContent, | ||||
|   SpeechOptions, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent } from "@/app/utils"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
|   object: string; | ||||
|   data: Array<{ | ||||
|     id: string; | ||||
|     object: string; | ||||
|     root: string; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| interface RequestPayload { | ||||
|   messages: { | ||||
|     role: "system" | "user" | "assistant"; | ||||
|     content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
|   stream?: boolean; | ||||
|   model: string; | ||||
|   temperature: number; | ||||
|   presence_penalty: number; | ||||
|   frequency_penalty: number; | ||||
|   top_p: number; | ||||
|   max_tokens?: number; | ||||
| } | ||||
|  | ||||
| export class DoubaoApi implements LLMApi { | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.bytedanceUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       baseUrl = isApp ? BYTEDANCE_BASE_URL : ApiPath.ByteDance; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ByteDance)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     return res.choices?.at(0)?.message?.content ?? ""; | ||||
|   } | ||||
|  | ||||
|   speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     throw new Error("Method not implemented."); | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       role: v.role, | ||||
|       content: getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const requestPayload: RequestPayload = { | ||||
|       messages, | ||||
|       stream: shouldStream, | ||||
|       model: modelConfig.model, | ||||
|       temperature: modelConfig.temperature, | ||||
|       presence_penalty: modelConfig.presence_penalty, | ||||
|       frequency_penalty: modelConfig.frequency_penalty, | ||||
|       top_p: modelConfig.top_p, | ||||
|     }; | ||||
|  | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(ByteDance.ChatPath); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log( | ||||
|               "[ByteDance] request response content type: ", | ||||
|               contentType, | ||||
|             ); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const choices = json.choices as Array<{ | ||||
|                 delta: { content: string }; | ||||
|               }>; | ||||
|               const delta = choices[0]?.delta?.content; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| export { ByteDance }; | ||||
| @@ -1,59 +1,15 @@ | ||||
| import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant"; | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   LLMUsage, | ||||
|   SpeechOptions, | ||||
| } from "../api"; | ||||
| import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant"; | ||||
| import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { DEFAULT_API_HOST } from "@/app/constant"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { | ||||
|   getMessageTextContent, | ||||
|   getMessageImages, | ||||
|   isVisionModel, | ||||
| } from "@/app/utils"; | ||||
| import { preProcessImageContent } from "@/app/utils/chat"; | ||||
|  | ||||
| export class GeminiProApi implements LLMApi { | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.googleUrl; | ||||
|     } | ||||
|  | ||||
|     const isApp = !!getClientConfig()?.isApp; | ||||
|     if (baseUrl.length === 0) { | ||||
|       baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google; | ||||
|     } | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Google)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     let chatPath = [baseUrl, path].join("/"); | ||||
|  | ||||
|     chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse"; | ||||
|     // if chatPath.startsWith('http') then add key in query string | ||||
|     if (chatPath.startsWith("http") && accessStore.googleApiKey) { | ||||
|       chatPath += `&key=${accessStore.googleApiKey}`; | ||||
|     } | ||||
|     return chatPath; | ||||
|   } | ||||
|   extractMessage(res: any) { | ||||
|     console.log("[Response] gemini-pro response: ", res); | ||||
|  | ||||
| @@ -63,23 +19,13 @@ export class GeminiProApi implements LLMApi { | ||||
|       "" | ||||
|     ); | ||||
|   } | ||||
|   speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     throw new Error("Method not implemented."); | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions): Promise<void> { | ||||
|     const apiClient = this; | ||||
|     // const apiClient = this; | ||||
|     const visionModel = isVisionModel(options.config.model); | ||||
|     let multimodal = false; | ||||
|  | ||||
|     // try get base64image from local cache image_url | ||||
|     const _messages: ChatOptions["messages"] = []; | ||||
|     for (const v of options.messages) { | ||||
|       const content = await preProcessImageContent(v.content); | ||||
|       _messages.push({ role: v.role, content }); | ||||
|     } | ||||
|     const messages = _messages.map((v) => { | ||||
|     const messages = options.messages.map((v) => { | ||||
|       let parts: any[] = [{ text: getMessageTextContent(v) }]; | ||||
|       if (isVisionModel(options.config.model)) { | ||||
|       if (visionModel) { | ||||
|         const images = getMessageImages(v); | ||||
|         if (images.length > 0) { | ||||
|           multimodal = true; | ||||
| @@ -119,9 +65,6 @@ export class GeminiProApi implements LLMApi { | ||||
|     // if (visionModel && messages.length > 1) { | ||||
|     //   options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision")); | ||||
|     // } | ||||
|  | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
| @@ -143,30 +86,47 @@ export class GeminiProApi implements LLMApi { | ||||
|       safetySettings: [ | ||||
|         { | ||||
|           category: "HARM_CATEGORY_HARASSMENT", | ||||
|           threshold: accessStore.googleSafetySettings, | ||||
|           threshold: "BLOCK_ONLY_HIGH", | ||||
|         }, | ||||
|         { | ||||
|           category: "HARM_CATEGORY_HATE_SPEECH", | ||||
|           threshold: accessStore.googleSafetySettings, | ||||
|           threshold: "BLOCK_ONLY_HIGH", | ||||
|         }, | ||||
|         { | ||||
|           category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", | ||||
|           threshold: accessStore.googleSafetySettings, | ||||
|           threshold: "BLOCK_ONLY_HIGH", | ||||
|         }, | ||||
|         { | ||||
|           category: "HARM_CATEGORY_DANGEROUS_CONTENT", | ||||
|           threshold: accessStore.googleSafetySettings, | ||||
|           threshold: "BLOCK_ONLY_HIGH", | ||||
|         }, | ||||
|       ], | ||||
|     }; | ||||
|  | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|     let baseUrl = accessStore.googleUrl; | ||||
|     const isApp = !!getClientConfig()?.isApp; | ||||
|  | ||||
|     let shouldStream = !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|     try { | ||||
|       // https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb | ||||
|       const chatPath = this.path(Google.ChatPath(modelConfig.model)); | ||||
|       let googleChatPath = visionModel | ||||
|         ? Google.VisionChatPath | ||||
|         : Google.ChatPath; | ||||
|       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 = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
| @@ -179,17 +139,15 @@ export class GeminiProApi implements LLMApi { | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         let existingTexts: string[] = []; | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|           finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|           options.onFinish(existingTexts.join("")); | ||||
|         }; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
| @@ -214,83 +172,74 @@ export class GeminiProApi implements LLMApi { | ||||
|         // start animaion | ||||
|         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, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log( | ||||
|               "[Gemini] request response content type: ", | ||||
|               contentType, | ||||
|             return reader?.read().then(function processText({ | ||||
|               done, | ||||
|               value, | ||||
|             }): Promise<any> { | ||||
|               if (done) { | ||||
|                 if (response.status !== 200) { | ||||
|                   try { | ||||
|                     let data = JSON.parse(ensureProperEnding(partialData)); | ||||
|                     if (data && data[0].error) { | ||||
|                       options.onError?.(new Error(data[0].error.message)); | ||||
|                     } else { | ||||
|                       options.onError?.(new Error("Request failed")); | ||||
|                     } | ||||
|                   } catch (_) { | ||||
|                     options.onError?.(new Error("Request failed")); | ||||
|                   } | ||||
|                 } | ||||
|  | ||||
|                 console.log("Stream complete"); | ||||
|                 // options.onFinish(responseText + remainText); | ||||
|                 finished = true; | ||||
|                 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")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|                 if (textArray.length > existingTexts.length) { | ||||
|                   const deltaArray = textArray.slice(existingTexts.length); | ||||
|                   existingTexts = textArray; | ||||
|                   remainText += deltaArray.join(""); | ||||
|                 } | ||||
|               } catch (error) { | ||||
|                 // console.log("[Response Animation] error: ", error,partialData); | ||||
|                 // skip error message when parsing json | ||||
|               } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const delta = apiClient.extractMessage(json); | ||||
|  | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|  | ||||
|               const blockReason = json?.promptFeedback?.blockReason; | ||||
|               if (blockReason) { | ||||
|                 // being blocked | ||||
|                 console.log(`[Google] [Safety Ratings] result:`, blockReason); | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|               return reader.read().then(processText); | ||||
|             }); | ||||
|           }) | ||||
|           .catch((error) => { | ||||
|             console.error("Error:", error); | ||||
|           }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         const res = await fetch(baseUrl, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|         const resJson = await res.json(); | ||||
|         if (resJson?.promptFeedback?.blockReason) { | ||||
| @@ -302,7 +251,7 @@ export class GeminiProApi implements LLMApi { | ||||
|             ), | ||||
|           ); | ||||
|         } | ||||
|         const message = apiClient.extractMessage(resJson); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
| @@ -316,4 +265,14 @@ export class GeminiProApi implements LLMApi { | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
|   path(path: string): string { | ||||
|     return "/api/google/" + path; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function ensureProperEnding(str: string) { | ||||
|   if (str.startsWith("[") && !str.endsWith("]")) { | ||||
|     return str + "]"; | ||||
|   } | ||||
|   return str; | ||||
| } | ||||
|   | ||||
| @@ -1,250 +0,0 @@ | ||||
| "use client"; | ||||
| import { | ||||
|   ApiPath, | ||||
|   DEFAULT_API_HOST, | ||||
|   Iflytek, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   SpeechOptions, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent } from "@/app/utils"; | ||||
|  | ||||
| import { RequestPayload } from "./openai"; | ||||
|  | ||||
| export class SparkApi implements LLMApi { | ||||
|   private disableListModels = true; | ||||
|  | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.iflytekUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       const apiPath = ApiPath.Iflytek; | ||||
|       baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Iflytek)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     return res.choices?.at(0)?.message?.content ?? ""; | ||||
|   } | ||||
|  | ||||
|   speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     throw new Error("Method not implemented."); | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages: ChatOptions["messages"] = []; | ||||
|     for (const v of options.messages) { | ||||
|       const content = getMessageTextContent(v); | ||||
|       messages.push({ role: v.role, content }); | ||||
|     } | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|         providerName: options.config.providerName, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const requestPayload: RequestPayload = { | ||||
|       messages, | ||||
|       stream: options.config.stream, | ||||
|       model: modelConfig.model, | ||||
|       temperature: modelConfig.temperature, | ||||
|       presence_penalty: modelConfig.presence_penalty, | ||||
|       frequency_penalty: modelConfig.frequency_penalty, | ||||
|       top_p: modelConfig.top_p, | ||||
|       // max_tokens: Math.max(modelConfig.max_tokens, 1024), | ||||
|       // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. | ||||
|     }; | ||||
|  | ||||
|     console.log("[Request] Spark payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(Iflytek.ChatPath); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // Make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // Animate response text to make it look smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // Start animation | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log("[Spark] request response content type: ", contentType); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             // Handle different error scenarios | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 extraInfo = Locale.Error.Unauthorized; | ||||
|               } | ||||
|  | ||||
|               options.onError?.( | ||||
|                 new Error( | ||||
|                   `Request failed with status ${res.status}: ${extraInfo}`, | ||||
|                 ), | ||||
|               ); | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const choices = json.choices as Array<{ | ||||
|                 delta: { content: string }; | ||||
|               }>; | ||||
|               const delta = choices[0]?.delta?.content; | ||||
|  | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text); | ||||
|               options.onError?.(new Error(`Failed to parse response: ${text}`)); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         if (!res.ok) { | ||||
|           const errorText = await res.text(); | ||||
|           options.onError?.( | ||||
|             new Error(`Request failed with status ${res.status}: ${errorText}`), | ||||
|           ); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| @@ -1,199 +0,0 @@ | ||||
| "use client"; | ||||
| // azure and openai, using same models. so using same LLMApi. | ||||
| import { | ||||
|   ApiPath, | ||||
|   DEFAULT_API_HOST, | ||||
|   Moonshot, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
| } from "@/app/constant"; | ||||
| import { | ||||
|   useAccessStore, | ||||
|   useAppConfig, | ||||
|   useChatStore, | ||||
|   ChatMessageTool, | ||||
|   usePluginStore, | ||||
| } from "@/app/store"; | ||||
| import { stream } from "@/app/utils/chat"; | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   SpeechOptions, | ||||
| } from "../api"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent } from "@/app/utils"; | ||||
| import { RequestPayload } from "./openai"; | ||||
|  | ||||
| export class MoonshotApi implements LLMApi { | ||||
|   private disableListModels = true; | ||||
|  | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.moonshotUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       const apiPath = ApiPath.Moonshot; | ||||
|       baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Moonshot)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     return res.choices?.at(0)?.message?.content ?? ""; | ||||
|   } | ||||
|  | ||||
|   speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     throw new Error("Method not implemented."); | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const messages: ChatOptions["messages"] = []; | ||||
|     for (const v of options.messages) { | ||||
|       const content = getMessageTextContent(v); | ||||
|       messages.push({ role: v.role, content }); | ||||
|     } | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|         providerName: options.config.providerName, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const requestPayload: RequestPayload = { | ||||
|       messages, | ||||
|       stream: options.config.stream, | ||||
|       model: modelConfig.model, | ||||
|       temperature: modelConfig.temperature, | ||||
|       presence_penalty: modelConfig.presence_penalty, | ||||
|       frequency_penalty: modelConfig.frequency_penalty, | ||||
|       top_p: modelConfig.top_p, | ||||
|       // max_tokens: Math.max(modelConfig.max_tokens, 1024), | ||||
|       // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. | ||||
|     }; | ||||
|  | ||||
|     console.log("[Request] openai payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(Moonshot.ChatPath); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         const [tools, funcs] = usePluginStore | ||||
|           .getState() | ||||
|           .getAsTools( | ||||
|             useChatStore.getState().currentSession().mask?.plugin || [], | ||||
|           ); | ||||
|         return stream( | ||||
|           chatPath, | ||||
|           requestPayload, | ||||
|           getHeaders(), | ||||
|           tools as any, | ||||
|           funcs, | ||||
|           controller, | ||||
|           // parseSSE | ||||
|           (text: string, runTools: ChatMessageTool[]) => { | ||||
|             // console.log("parseSSE", text, runTools); | ||||
|             const json = JSON.parse(text); | ||||
|             const choices = json.choices as Array<{ | ||||
|               delta: { | ||||
|                 content: string; | ||||
|                 tool_calls: ChatMessageTool[]; | ||||
|               }; | ||||
|             }>; | ||||
|             const tool_calls = choices[0]?.delta?.tool_calls; | ||||
|             if (tool_calls?.length > 0) { | ||||
|               const index = tool_calls[0]?.index; | ||||
|               const id = tool_calls[0]?.id; | ||||
|               const args = tool_calls[0]?.function?.arguments; | ||||
|               if (id) { | ||||
|                 runTools.push({ | ||||
|                   id, | ||||
|                   type: tool_calls[0]?.type, | ||||
|                   function: { | ||||
|                     name: tool_calls[0]?.function?.name as string, | ||||
|                     arguments: args, | ||||
|                   }, | ||||
|                 }); | ||||
|               } else { | ||||
|                 // @ts-ignore | ||||
|                 runTools[index]["function"]["arguments"] += args; | ||||
|               } | ||||
|             } | ||||
|             return choices[0]?.delta?.content; | ||||
|           }, | ||||
|           // processToolMessage, include tool_calls message and tool call results | ||||
|           ( | ||||
|             requestPayload: RequestPayload, | ||||
|             toolCallMessage: any, | ||||
|             toolCallResult: any[], | ||||
|           ) => { | ||||
|             // @ts-ignore | ||||
|             requestPayload?.messages?.splice( | ||||
|               // @ts-ignore | ||||
|               requestPayload?.messages?.length, | ||||
|               0, | ||||
|               toolCallMessage, | ||||
|               ...toolCallResult, | ||||
|             ); | ||||
|           }, | ||||
|           options, | ||||
|         ); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| @@ -1,30 +1,13 @@ | ||||
| "use client"; | ||||
| // azure and openai, using same models. so using same LLMApi. | ||||
| import { | ||||
|   ApiPath, | ||||
|   DEFAULT_API_HOST, | ||||
|   DEFAULT_MODELS, | ||||
|   OpenaiPath, | ||||
|   Azure, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
|   ServiceProvider, | ||||
| } from "@/app/constant"; | ||||
| import { | ||||
|   ChatMessageTool, | ||||
|   useAccessStore, | ||||
|   useAppConfig, | ||||
|   useChatStore, | ||||
|   usePluginStore, | ||||
| } from "@/app/store"; | ||||
| import { collectModelsWithDefaultModel } from "@/app/utils/model"; | ||||
| import { | ||||
|   preProcessImageContent, | ||||
|   uploadImage, | ||||
|   base64Image2Blob, | ||||
|   stream, | ||||
| } from "@/app/utils/chat"; | ||||
| import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; | ||||
| import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
| @@ -33,14 +16,19 @@ import { | ||||
|   LLMModel, | ||||
|   LLMUsage, | ||||
|   MultimodalContent, | ||||
|   SpeechOptions, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { makeAzurePath } from "@/app/azure"; | ||||
| import { | ||||
|   getMessageTextContent, | ||||
|   getMessageImages, | ||||
|   isVisionModel, | ||||
|   isDalle3 as _isDalle3, | ||||
| } from "@/app/utils"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
| @@ -52,113 +40,95 @@ export interface OpenAIListModelResponse { | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| export interface RequestPayload { | ||||
|   messages: { | ||||
|     role: "system" | "user" | "assistant"; | ||||
|     content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
|   stream?: boolean; | ||||
|   model: string; | ||||
|   temperature: number; | ||||
|   presence_penalty: number; | ||||
|   frequency_penalty: number; | ||||
|   top_p: number; | ||||
|   max_tokens?: number; | ||||
| } | ||||
|  | ||||
| export interface DalleRequestPayload { | ||||
|   model: string; | ||||
|   prompt: string; | ||||
|   response_format: "url" | "b64_json"; | ||||
|   n: number; | ||||
|   size: DalleSize; | ||||
|   quality: DalleQuality; | ||||
|   style: DalleStyle; | ||||
| } | ||||
|  | ||||
| export class ChatGPTApi implements LLMApi { | ||||
|   private disableListModels = true; | ||||
|  | ||||
|   path(path: string): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|     const isAzure = accessStore.provider === ServiceProvider.Azure; | ||||
|  | ||||
|     const isAzure = path.includes("deployments"); | ||||
|     if (accessStore.useCustomConfig) { | ||||
|     if (isAzure && !accessStore.isValidAzure()) { | ||||
|       throw Error( | ||||
|         "incomplete azure config, please check it in your settings page", | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|       baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl; | ||||
|     } | ||||
|     let baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl; | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI; | ||||
|       baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath; | ||||
|       baseUrl = isApp | ||||
|         ? DEFAULT_API_HOST + "/proxy" + ApiPath.OpenAI | ||||
|         : ApiPath.OpenAI; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if ( | ||||
|       !baseUrl.startsWith("http") && | ||||
|       !isAzure && | ||||
|       !baseUrl.startsWith(ApiPath.OpenAI) | ||||
|     ) { | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     if (isAzure) { | ||||
|       path = makeAzurePath(path, accessStore.azureApiVersion); | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl, path); | ||||
|  | ||||
|     // try rebuild url, when using cloudflare ai gateway in client | ||||
|     return cloudflareAIGatewayUrl([baseUrl, path].join("/")); | ||||
|     return [baseUrl, path].join("/"); | ||||
|   } | ||||
|  | ||||
|   async extractMessage(res: any) { | ||||
|     if (res.error) { | ||||
|       return "```\n" + JSON.stringify(res, null, 4) + "\n```"; | ||||
|     } | ||||
|     // dalle3 model return url, using url create image message | ||||
|     if (res.data) { | ||||
|       let url = res.data?.at(0)?.url ?? ""; | ||||
|       const b64_json = res.data?.at(0)?.b64_json ?? ""; | ||||
|       if (!url && b64_json) { | ||||
|         // uploadImage | ||||
|         url = await uploadImage(base64Image2Blob(b64_json, "image/png")); | ||||
|       } | ||||
|       return [ | ||||
|         { | ||||
|           type: "image_url", | ||||
|           image_url: { | ||||
|             url, | ||||
|           }, | ||||
|         }, | ||||
|       ]; | ||||
|     } | ||||
|     return res.choices?.at(0)?.message?.content ?? res; | ||||
|   extractMessage(res: any) { | ||||
|     return res.choices?.at(0)?.message?.content ?? ""; | ||||
|   } | ||||
|  | ||||
|   async speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     const requestPayload = { | ||||
|       model: options.model, | ||||
|       input: options.input, | ||||
|       voice: options.voice, | ||||
|       response_format: options.response_format, | ||||
|       speed: options.speed, | ||||
|   async chat(options: ChatOptions) { | ||||
|     const visionModel = isVisionModel(options.config.model); | ||||
|     const messages = options.messages.map((v) => ({ | ||||
|       role: v.role, | ||||
|       content: visionModel ? v.content : getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     console.log("[Request] openai speech payload: ", requestPayload); | ||||
|     const requestPayload = { | ||||
|       messages, | ||||
|       stream: options.config.stream, | ||||
|       model: modelConfig.model, | ||||
|       temperature: modelConfig.temperature, | ||||
|       presence_penalty: modelConfig.presence_penalty, | ||||
|       frequency_penalty: modelConfig.frequency_penalty, | ||||
|       top_p: modelConfig.top_p, | ||||
|       // max_tokens: Math.max(modelConfig.max_tokens, 1024), | ||||
|       // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. | ||||
|     }; | ||||
|  | ||||
|     // add max_tokens to vision model | ||||
|     if (visionModel) { | ||||
|       Object.defineProperty(requestPayload, "max_tokens", { | ||||
|         enumerable: true, | ||||
|         configurable: true, | ||||
|         writable: true, | ||||
|         value: modelConfig.max_tokens, | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     console.log("[Request] openai payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const speechPath = this.path(OpenaiPath.SpeechPath); | ||||
|       const speechPayload = { | ||||
|       const chatPath = this.path(OpenaiPath.ChatPath); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
| @@ -171,192 +141,120 @@ export class ChatGPTApi implements LLMApi { | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       const res = await fetch(speechPath, speechPayload); | ||||
|       clearTimeout(requestTimeoutId); | ||||
|       return await res.arrayBuffer(); | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a speech request", e); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|         providerName: options.config.providerName, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     let requestPayload: RequestPayload | DalleRequestPayload; | ||||
|  | ||||
|     const isDalle3 = _isDalle3(options.config.model); | ||||
|     const isO1 = options.config.model.startsWith("o1"); | ||||
|     if (isDalle3) { | ||||
|       const prompt = getMessageTextContent( | ||||
|         options.messages.slice(-1)?.pop() as any, | ||||
|       ); | ||||
|       requestPayload = { | ||||
|         model: options.config.model, | ||||
|         prompt, | ||||
|         // URLs are only valid for 60 minutes after the image has been generated. | ||||
|         response_format: "b64_json", // using b64_json, and save image in CacheStorage | ||||
|         n: 1, | ||||
|         size: options.config?.size ?? "1024x1024", | ||||
|         quality: options.config?.quality ?? "standard", | ||||
|         style: options.config?.style ?? "vivid", | ||||
|       }; | ||||
|     } else { | ||||
|       const visionModel = isVisionModel(options.config.model); | ||||
|       const messages: ChatOptions["messages"] = []; | ||||
|       for (const v of options.messages) { | ||||
|         const content = visionModel | ||||
|           ? await preProcessImageContent(v.content) | ||||
|           : getMessageTextContent(v); | ||||
|         if (!(isO1 && v.role === "system")) | ||||
|           messages.push({ role: v.role, content }); | ||||
|       } | ||||
|  | ||||
|       // O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet. | ||||
|       requestPayload = { | ||||
|         messages, | ||||
|         stream: !isO1 ? options.config.stream : false, | ||||
|         model: modelConfig.model, | ||||
|         temperature: !isO1 ? modelConfig.temperature : 1, | ||||
|         presence_penalty: !isO1 ? modelConfig.presence_penalty : 0, | ||||
|         frequency_penalty: !isO1 ? modelConfig.frequency_penalty : 0, | ||||
|         top_p: !isO1 ? modelConfig.top_p : 1, | ||||
|         // max_tokens: Math.max(modelConfig.max_tokens, 1024), | ||||
|         // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. | ||||
|       }; | ||||
|  | ||||
|       // add max_tokens to vision model | ||||
|       if (visionModel) { | ||||
|         requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     console.log("[Request] openai payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !isDalle3 && !!options.config.stream && !isO1; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       let chatPath = ""; | ||||
|       if (modelConfig.providerName === ServiceProvider.Azure) { | ||||
|         // find model, and get displayName as deployName | ||||
|         const { models: configModels, customModels: configCustomModels } = | ||||
|           useAppConfig.getState(); | ||||
|         const { | ||||
|           defaultModel, | ||||
|           customModels: accessCustomModels, | ||||
|           useCustomConfig, | ||||
|         } = useAccessStore.getState(); | ||||
|         const models = collectModelsWithDefaultModel( | ||||
|           configModels, | ||||
|           [configCustomModels, accessCustomModels].join(","), | ||||
|           defaultModel, | ||||
|         ); | ||||
|         const model = models.find( | ||||
|           (model) => | ||||
|             model.name === modelConfig.model && | ||||
|             model?.provider?.providerName === ServiceProvider.Azure, | ||||
|         ); | ||||
|         chatPath = this.path( | ||||
|           (isDalle3 ? Azure.ImagePath : Azure.ChatPath)( | ||||
|             (model?.displayName ?? model?.name) as string, | ||||
|             useCustomConfig ? useAccessStore.getState().azureApiVersion : "", | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         chatPath = this.path( | ||||
|           isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath, | ||||
|         ); | ||||
|       } | ||||
|       if (shouldStream) { | ||||
|         const [tools, funcs] = usePluginStore | ||||
|           .getState() | ||||
|           .getAsTools( | ||||
|             useChatStore.getState().currentSession().mask?.plugin || [], | ||||
|         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"); | ||||
|             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, | ||||
|             ); | ||||
|         // 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<{ | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text) as { | ||||
|                 choices: Array<{ | ||||
|                   delta: { | ||||
|                     content: string; | ||||
|                 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, | ||||
|               }; | ||||
|               const delta = json.choices[0]?.delta?.content; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|                 // @ts-ignore | ||||
|                 runTools[index]["function"]["arguments"] += args; | ||||
|               } | ||||
|             } | ||||
|             return choices[0]?.delta?.content; | ||||
|           }, | ||||
|           // processToolMessage, include tool_calls message and tool call results | ||||
|           ( | ||||
|             requestPayload: RequestPayload, | ||||
|             toolCallMessage: any, | ||||
|             toolCallResult: any[], | ||||
|           ) => { | ||||
|             // @ts-ignore | ||||
|             requestPayload?.messages?.splice( | ||||
|               // @ts-ignore | ||||
|               requestPayload?.messages?.length, | ||||
|               0, | ||||
|               toolCallMessage, | ||||
|               ...toolCallResult, | ||||
|             ); | ||||
|           }, | ||||
|           options, | ||||
|         ); | ||||
|       } else { | ||||
|         const chatPayload = { | ||||
|           method: "POST", | ||||
|           body: JSON.stringify(requestPayload), | ||||
|           signal: controller.signal, | ||||
|           headers: getHeaders(), | ||||
|         }; | ||||
|  | ||||
|         // make a fetch request | ||||
|         const requestTimeoutId = setTimeout( | ||||
|           () => controller.abort(), | ||||
|           isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow. | ||||
|         ); | ||||
|  | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = await this.extractMessage(resJson); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
| @@ -443,26 +341,20 @@ export class ChatGPTApi implements LLMApi { | ||||
|     }); | ||||
|  | ||||
|     const resJson = (await res.json()) as OpenAIListModelResponse; | ||||
|     const chatModels = resJson.data?.filter( | ||||
|       (m) => m.id.startsWith("gpt-") || m.id.startsWith("chatgpt-"), | ||||
|     ); | ||||
|     const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-")); | ||||
|     console.log("[Models]", chatModels); | ||||
|  | ||||
|     if (!chatModels) { | ||||
|       return []; | ||||
|     } | ||||
|  | ||||
|     //由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场 | ||||
|     let seq = 1000; //同 Constant.ts 中的排序保持一致 | ||||
|     return chatModels.map((m) => ({ | ||||
|       name: m.id, | ||||
|       available: true, | ||||
|       sorted: seq++, | ||||
|       provider: { | ||||
|         id: "openai", | ||||
|         providerName: "OpenAI", | ||||
|         providerType: "openai", | ||||
|         sorted: 1, | ||||
|       }, | ||||
|     })); | ||||
|   } | ||||
|   | ||||
| @@ -1,273 +0,0 @@ | ||||
| "use client"; | ||||
| import { ApiPath, DEFAULT_API_HOST, REQUEST_TIMEOUT_MS } from "@/app/constant"; | ||||
| import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; | ||||
|  | ||||
| import { | ||||
|   ChatOptions, | ||||
|   getHeaders, | ||||
|   LLMApi, | ||||
|   LLMModel, | ||||
|   MultimodalContent, | ||||
|   SpeechOptions, | ||||
| } from "../api"; | ||||
| import Locale from "../../locales"; | ||||
| import { | ||||
|   EventStreamContentType, | ||||
|   fetchEventSource, | ||||
| } from "@fortaine/fetch-event-source"; | ||||
| import { prettyObject } from "@/app/utils/format"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { getMessageTextContent, isVisionModel } from "@/app/utils"; | ||||
| import mapKeys from "lodash-es/mapKeys"; | ||||
| import mapValues from "lodash-es/mapValues"; | ||||
| import isArray from "lodash-es/isArray"; | ||||
| import isObject from "lodash-es/isObject"; | ||||
|  | ||||
| export interface OpenAIListModelResponse { | ||||
|   object: string; | ||||
|   data: Array<{ | ||||
|     id: string; | ||||
|     object: string; | ||||
|     root: string; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| interface RequestPayload { | ||||
|   Messages: { | ||||
|     Role: "system" | "user" | "assistant"; | ||||
|     Content: string | MultimodalContent[]; | ||||
|   }[]; | ||||
|   Stream?: boolean; | ||||
|   Model: string; | ||||
|   Temperature: number; | ||||
|   TopP: number; | ||||
| } | ||||
|  | ||||
| function capitalizeKeys(obj: any): any { | ||||
|   if (isArray(obj)) { | ||||
|     return obj.map(capitalizeKeys); | ||||
|   } else if (isObject(obj)) { | ||||
|     return mapValues( | ||||
|       mapKeys(obj, (value: any, key: string) => | ||||
|         key.replace(/(^|_)(\w)/g, (m, $1, $2) => $2.toUpperCase()), | ||||
|       ), | ||||
|       capitalizeKeys, | ||||
|     ); | ||||
|   } else { | ||||
|     return obj; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class HunyuanApi implements LLMApi { | ||||
|   path(): string { | ||||
|     const accessStore = useAccessStore.getState(); | ||||
|  | ||||
|     let baseUrl = ""; | ||||
|  | ||||
|     if (accessStore.useCustomConfig) { | ||||
|       baseUrl = accessStore.tencentUrl; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.length === 0) { | ||||
|       const isApp = !!getClientConfig()?.isApp; | ||||
|       baseUrl = isApp | ||||
|         ? DEFAULT_API_HOST + "/api/proxy/tencent" | ||||
|         : ApiPath.Tencent; | ||||
|     } | ||||
|  | ||||
|     if (baseUrl.endsWith("/")) { | ||||
|       baseUrl = baseUrl.slice(0, baseUrl.length - 1); | ||||
|     } | ||||
|     if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Tencent)) { | ||||
|       baseUrl = "https://" + baseUrl; | ||||
|     } | ||||
|  | ||||
|     console.log("[Proxy Endpoint] ", baseUrl); | ||||
|     return baseUrl; | ||||
|   } | ||||
|  | ||||
|   extractMessage(res: any) { | ||||
|     return res.Choices?.at(0)?.Message?.Content ?? ""; | ||||
|   } | ||||
|  | ||||
|   speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||||
|     throw new Error("Method not implemented."); | ||||
|   } | ||||
|  | ||||
|   async chat(options: ChatOptions) { | ||||
|     const visionModel = isVisionModel(options.config.model); | ||||
|     const messages = options.messages.map((v, index) => ({ | ||||
|       // "Messages 中 system 角色必须位于列表的最开始" | ||||
|       role: index !== 0 && v.role === "system" ? "user" : v.role, | ||||
|       content: visionModel ? v.content : getMessageTextContent(v), | ||||
|     })); | ||||
|  | ||||
|     const modelConfig = { | ||||
|       ...useAppConfig.getState().modelConfig, | ||||
|       ...useChatStore.getState().currentSession().mask.modelConfig, | ||||
|       ...{ | ||||
|         model: options.config.model, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const requestPayload: RequestPayload = capitalizeKeys({ | ||||
|       model: modelConfig.model, | ||||
|       messages, | ||||
|       temperature: modelConfig.temperature, | ||||
|       top_p: modelConfig.top_p, | ||||
|       stream: options.config.stream, | ||||
|     }); | ||||
|  | ||||
|     console.log("[Request] Tencent payload: ", requestPayload); | ||||
|  | ||||
|     const shouldStream = !!options.config.stream; | ||||
|     const controller = new AbortController(); | ||||
|     options.onController?.(controller); | ||||
|  | ||||
|     try { | ||||
|       const chatPath = this.path(); | ||||
|       const chatPayload = { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify(requestPayload), | ||||
|         signal: controller.signal, | ||||
|         headers: getHeaders(), | ||||
|       }; | ||||
|  | ||||
|       // make a fetch request | ||||
|       const requestTimeoutId = setTimeout( | ||||
|         () => controller.abort(), | ||||
|         REQUEST_TIMEOUT_MS, | ||||
|       ); | ||||
|  | ||||
|       if (shouldStream) { | ||||
|         let responseText = ""; | ||||
|         let remainText = ""; | ||||
|         let finished = false; | ||||
|  | ||||
|         // animate response to make it looks smooth | ||||
|         function animateResponseText() { | ||||
|           if (finished || controller.signal.aborted) { | ||||
|             responseText += remainText; | ||||
|             console.log("[Response Animation] finished"); | ||||
|             if (responseText?.length === 0) { | ||||
|               options.onError?.(new Error("empty response from server")); | ||||
|             } | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           if (remainText.length > 0) { | ||||
|             const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | ||||
|             const fetchText = remainText.slice(0, fetchCount); | ||||
|             responseText += fetchText; | ||||
|             remainText = remainText.slice(fetchCount); | ||||
|             options.onUpdate?.(responseText, fetchText); | ||||
|           } | ||||
|  | ||||
|           requestAnimationFrame(animateResponseText); | ||||
|         } | ||||
|  | ||||
|         // start animaion | ||||
|         animateResponseText(); | ||||
|  | ||||
|         const finish = () => { | ||||
|           if (!finished) { | ||||
|             finished = true; | ||||
|             options.onFinish(responseText + remainText); | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         controller.signal.onabort = finish; | ||||
|  | ||||
|         fetchEventSource(chatPath, { | ||||
|           ...chatPayload, | ||||
|           async onopen(res) { | ||||
|             clearTimeout(requestTimeoutId); | ||||
|             const contentType = res.headers.get("content-type"); | ||||
|             console.log( | ||||
|               "[Tencent] request response content type: ", | ||||
|               contentType, | ||||
|             ); | ||||
|  | ||||
|             if (contentType?.startsWith("text/plain")) { | ||||
|               responseText = await res.clone().text(); | ||||
|               return finish(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|               !res.ok || | ||||
|               !res.headers | ||||
|                 .get("content-type") | ||||
|                 ?.startsWith(EventStreamContentType) || | ||||
|               res.status !== 200 | ||||
|             ) { | ||||
|               const responseTexts = [responseText]; | ||||
|               let extraInfo = await res.clone().text(); | ||||
|               try { | ||||
|                 const resJson = await res.clone().json(); | ||||
|                 extraInfo = prettyObject(resJson); | ||||
|               } catch {} | ||||
|  | ||||
|               if (res.status === 401) { | ||||
|                 responseTexts.push(Locale.Error.Unauthorized); | ||||
|               } | ||||
|  | ||||
|               if (extraInfo) { | ||||
|                 responseTexts.push(extraInfo); | ||||
|               } | ||||
|  | ||||
|               responseText = responseTexts.join("\n\n"); | ||||
|  | ||||
|               return finish(); | ||||
|             } | ||||
|           }, | ||||
|           onmessage(msg) { | ||||
|             if (msg.data === "[DONE]" || finished) { | ||||
|               return finish(); | ||||
|             } | ||||
|             const text = msg.data; | ||||
|             try { | ||||
|               const json = JSON.parse(text); | ||||
|               const choices = json.Choices as Array<{ | ||||
|                 Delta: { Content: string }; | ||||
|               }>; | ||||
|               const delta = choices[0]?.Delta?.Content; | ||||
|               if (delta) { | ||||
|                 remainText += delta; | ||||
|               } | ||||
|             } catch (e) { | ||||
|               console.error("[Request] parse error", text, msg); | ||||
|             } | ||||
|           }, | ||||
|           onclose() { | ||||
|             finish(); | ||||
|           }, | ||||
|           onerror(e) { | ||||
|             options.onError?.(e); | ||||
|             throw e; | ||||
|           }, | ||||
|           openWhenHidden: true, | ||||
|         }); | ||||
|       } else { | ||||
|         const res = await fetch(chatPath, chatPayload); | ||||
|         clearTimeout(requestTimeoutId); | ||||
|  | ||||
|         const resJson = await res.json(); | ||||
|         const message = this.extractMessage(resJson); | ||||
|         options.onFinish(message); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       console.log("[Request] failed to make a chat request", e); | ||||
|       options.onError?.(e as Error); | ||||
|     } | ||||
|   } | ||||
|   async usage() { | ||||
|     return { | ||||
|       used: 0, | ||||
|       total: 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async models(): Promise<LLMModel[]> { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| @@ -38,20 +38,16 @@ interface ChatCommands { | ||||
|   next?: Command; | ||||
|   prev?: Command; | ||||
|   clear?: Command; | ||||
|   fork?: Command; | ||||
|   del?: Command; | ||||
| } | ||||
|  | ||||
| // Compatible with Chinese colon character ":" | ||||
| export const ChatCommandPrefix = /^[::]/; | ||||
| export const ChatCommandPrefix = ":"; | ||||
|  | ||||
| export function useChatCommand(commands: ChatCommands = {}) { | ||||
|   function extract(userInput: string) { | ||||
|     const match = userInput.match(ChatCommandPrefix); | ||||
|     if (match) { | ||||
|       return userInput.slice(1) as keyof ChatCommands; | ||||
|     } | ||||
|     return userInput as keyof ChatCommands; | ||||
|     return ( | ||||
|       userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput | ||||
|     ) as keyof ChatCommands; | ||||
|   } | ||||
|  | ||||
|   function search(userInput: string) { | ||||
| @@ -61,7 +57,7 @@ export function useChatCommand(commands: ChatCommands = {}) { | ||||
|       .filter((c) => c.startsWith(input)) | ||||
|       .map((c) => ({ | ||||
|         title: desc[c as keyof ChatCommands], | ||||
|         content: ":" + c, | ||||
|         content: ChatCommandPrefix + c, | ||||
|       })); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,31 +0,0 @@ | ||||
| .artifacts { | ||||
|   display: flex; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   flex-direction: column; | ||||
|   &-header { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     height: 36px; | ||||
|     padding: 20px; | ||||
|     background: var(--second); | ||||
|   } | ||||
|   &-title { | ||||
|     flex: 1; | ||||
|     text-align: center; | ||||
|     font-weight: bold; | ||||
|     font-size: 24px; | ||||
|   } | ||||
|   &-content { | ||||
|     flex-grow: 1; | ||||
|     padding: 0 20px 20px 20px; | ||||
|     background-color: var(--second); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .artifacts-iframe { | ||||
|   width: 100%; | ||||
|   border: var(--border-in-light); | ||||
|   border-radius: 6px; | ||||
|   background-color: var(--gray); | ||||
| } | ||||
| @@ -1,266 +0,0 @@ | ||||
| import { | ||||
|   useEffect, | ||||
|   useState, | ||||
|   useRef, | ||||
|   useMemo, | ||||
|   forwardRef, | ||||
|   useImperativeHandle, | ||||
| } from "react"; | ||||
| import { useParams } from "react-router"; | ||||
| import { IconButton } from "./button"; | ||||
| import { nanoid } from "nanoid"; | ||||
| import ExportIcon from "../icons/share.svg"; | ||||
| import CopyIcon from "../icons/copy.svg"; | ||||
| import DownloadIcon from "../icons/download.svg"; | ||||
| import GithubIcon from "../icons/github.svg"; | ||||
| import LoadingButtonIcon from "../icons/loading.svg"; | ||||
| import ReloadButtonIcon from "../icons/reload.svg"; | ||||
| import Locale from "../locales"; | ||||
| import { Modal, showToast } from "./ui-lib"; | ||||
| import { copyToClipboard, downloadAs } from "../utils"; | ||||
| import { Path, ApiPath, REPO_URL } from "@/app/constant"; | ||||
| import { Loading } from "./home"; | ||||
| import styles from "./artifacts.module.scss"; | ||||
|  | ||||
| type HTMLPreviewProps = { | ||||
|   code: string; | ||||
|   autoHeight?: boolean; | ||||
|   height?: number | string; | ||||
|   onLoad?: (title?: string) => void; | ||||
| }; | ||||
|  | ||||
| export type HTMLPreviewHander = { | ||||
|   reload: () => void; | ||||
| }; | ||||
|  | ||||
| export const HTMLPreview = forwardRef<HTMLPreviewHander, HTMLPreviewProps>( | ||||
|   function HTMLPreview(props, ref) { | ||||
|     const iframeRef = useRef<HTMLIFrameElement>(null); | ||||
|     const [frameId, setFrameId] = useState<string>(nanoid()); | ||||
|     const [iframeHeight, setIframeHeight] = useState(600); | ||||
|     const [title, setTitle] = useState(""); | ||||
|     /* | ||||
|      * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an | ||||
|      * 1. using srcdoc | ||||
|      * 2. using src with dataurl: | ||||
|      *    easy to share | ||||
|      *    length limit (Data URIs cannot be larger than 32,768 characters.) | ||||
|      */ | ||||
|  | ||||
|     useEffect(() => { | ||||
|       const handleMessage = (e: any) => { | ||||
|         const { id, height, title } = e.data; | ||||
|         setTitle(title); | ||||
|         if (id == frameId) { | ||||
|           setIframeHeight(height); | ||||
|         } | ||||
|       }; | ||||
|       window.addEventListener("message", handleMessage); | ||||
|       return () => { | ||||
|         window.removeEventListener("message", handleMessage); | ||||
|       }; | ||||
|     }, [frameId]); | ||||
|  | ||||
|     useImperativeHandle(ref, () => ({ | ||||
|       reload: () => { | ||||
|         setFrameId(nanoid()); | ||||
|       }, | ||||
|     })); | ||||
|  | ||||
|     const height = useMemo(() => { | ||||
|       if (!props.autoHeight) return props.height || 600; | ||||
|       if (typeof props.height === "string") { | ||||
|         return props.height; | ||||
|       } | ||||
|       const parentHeight = props.height || 600; | ||||
|       return iframeHeight + 40 > parentHeight | ||||
|         ? parentHeight | ||||
|         : iframeHeight + 40; | ||||
|     }, [props.autoHeight, props.height, iframeHeight]); | ||||
|  | ||||
|     const srcDoc = useMemo(() => { | ||||
|       const script = `<script>window.addEventListener("DOMContentLoaded", () => new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body))</script>`; | ||||
|       if (props.code.includes("<!DOCTYPE html>")) { | ||||
|         props.code.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + script); | ||||
|       } | ||||
|       return script + props.code; | ||||
|     }, [props.code, frameId]); | ||||
|  | ||||
|     const handleOnLoad = () => { | ||||
|       if (props?.onLoad) { | ||||
|         props.onLoad(title); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|       <iframe | ||||
|         className={styles["artifacts-iframe"]} | ||||
|         key={frameId} | ||||
|         ref={iframeRef} | ||||
|         sandbox="allow-forms allow-modals allow-scripts" | ||||
|         style={{ height }} | ||||
|         srcDoc={srcDoc} | ||||
|         onLoad={handleOnLoad} | ||||
|       /> | ||||
|     ); | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| export function ArtifactsShareButton({ | ||||
|   getCode, | ||||
|   id, | ||||
|   style, | ||||
|   fileName, | ||||
| }: { | ||||
|   getCode: () => string; | ||||
|   id?: string; | ||||
|   style?: any; | ||||
|   fileName?: string; | ||||
| }) { | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [name, setName] = useState(id); | ||||
|   const [show, setShow] = useState(false); | ||||
|   const shareUrl = useMemo( | ||||
|     () => [location.origin, "#", Path.Artifacts, "/", name].join(""), | ||||
|     [name], | ||||
|   ); | ||||
|   const upload = (code: string) => | ||||
|     id | ||||
|       ? Promise.resolve({ id }) | ||||
|       : fetch(ApiPath.Artifacts, { | ||||
|           method: "POST", | ||||
|           body: code, | ||||
|         }) | ||||
|           .then((res) => res.json()) | ||||
|           .then(({ id }) => { | ||||
|             if (id) { | ||||
|               return { id }; | ||||
|             } | ||||
|             throw Error(); | ||||
|           }) | ||||
|           .catch((e) => { | ||||
|             showToast(Locale.Export.Artifacts.Error); | ||||
|           }); | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="window-action-button" style={style}> | ||||
|         <IconButton | ||||
|           icon={loading ? <LoadingButtonIcon /> : <ExportIcon />} | ||||
|           bordered | ||||
|           title={Locale.Export.Artifacts.Title} | ||||
|           onClick={() => { | ||||
|             if (loading) return; | ||||
|             setLoading(true); | ||||
|             upload(getCode()) | ||||
|               .then((res) => { | ||||
|                 if (res?.id) { | ||||
|                   setShow(true); | ||||
|                   setName(res?.id); | ||||
|                 } | ||||
|               }) | ||||
|               .finally(() => setLoading(false)); | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|       {show && ( | ||||
|         <div className="modal-mask"> | ||||
|           <Modal | ||||
|             title={Locale.Export.Artifacts.Title} | ||||
|             onClose={() => setShow(false)} | ||||
|             actions={[ | ||||
|               <IconButton | ||||
|                 key="download" | ||||
|                 icon={<DownloadIcon />} | ||||
|                 bordered | ||||
|                 text={Locale.Export.Download} | ||||
|                 onClick={() => { | ||||
|                   downloadAs(getCode(), `${fileName || name}.html`).then(() => | ||||
|                     setShow(false), | ||||
|                   ); | ||||
|                 }} | ||||
|               />, | ||||
|               <IconButton | ||||
|                 key="copy" | ||||
|                 icon={<CopyIcon />} | ||||
|                 bordered | ||||
|                 text={Locale.Chat.Actions.Copy} | ||||
|                 onClick={() => { | ||||
|                   copyToClipboard(shareUrl).then(() => setShow(false)); | ||||
|                 }} | ||||
|               />, | ||||
|             ]} | ||||
|           > | ||||
|             <div> | ||||
|               <a target="_blank" href={shareUrl}> | ||||
|                 {shareUrl} | ||||
|               </a> | ||||
|             </div> | ||||
|           </Modal> | ||||
|         </div> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function Artifacts() { | ||||
|   const { id } = useParams(); | ||||
|   const [code, setCode] = useState(""); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [fileName, setFileName] = useState(""); | ||||
|   const previewRef = useRef<HTMLPreviewHander>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (id) { | ||||
|       fetch(`${ApiPath.Artifacts}?id=${id}`) | ||||
|         .then((res) => { | ||||
|           if (res.status > 300) { | ||||
|             throw Error("can not get content"); | ||||
|           } | ||||
|           return res; | ||||
|         }) | ||||
|         .then((res) => res.text()) | ||||
|         .then(setCode) | ||||
|         .catch((e) => { | ||||
|           showToast(Locale.Export.Artifacts.Error); | ||||
|         }); | ||||
|     } | ||||
|   }, [id]); | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["artifacts"]}> | ||||
|       <div className={styles["artifacts-header"]}> | ||||
|         <a href={REPO_URL} target="_blank" rel="noopener noreferrer"> | ||||
|           <IconButton bordered icon={<GithubIcon />} shadow /> | ||||
|         </a> | ||||
|         <IconButton | ||||
|           bordered | ||||
|           style={{ marginLeft: 20 }} | ||||
|           icon={<ReloadButtonIcon />} | ||||
|           shadow | ||||
|           onClick={() => previewRef.current?.reload()} | ||||
|         /> | ||||
|         <div className={styles["artifacts-title"]}>NextChat Artifacts</div> | ||||
|         <ArtifactsShareButton | ||||
|           id={id} | ||||
|           getCode={() => code} | ||||
|           fileName={fileName} | ||||
|         /> | ||||
|       </div> | ||||
|       <div className={styles["artifacts-content"]}> | ||||
|         {loading && <Loading />} | ||||
|         {code && ( | ||||
|           <HTMLPreview | ||||
|             code={code} | ||||
|             ref={previewRef} | ||||
|             autoHeight={false} | ||||
|             height={"100%"} | ||||
|             onLoad={(title) => { | ||||
|               setFileName(title as string); | ||||
|               setLoading(false); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @@ -1,70 +1,12 @@ | ||||
| .auth-page { | ||||
|   display: flex; | ||||
|   justify-content: flex-start; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
|   flex-direction: column; | ||||
|   .top-banner { | ||||
|     position: relative; | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     padding: 12px 64px; | ||||
|     box-sizing: border-box; | ||||
|     background: var(--second); | ||||
|     .top-banner-inner { | ||||
|       display: flex; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
|       font-size: 14px; | ||||
|       line-height: 150%; | ||||
|       span { | ||||
|         gap: 8px; | ||||
|         a { | ||||
|           display: inline-flex; | ||||
|           align-items: center; | ||||
|           text-decoration: none; | ||||
|           margin-left: 8px; | ||||
|           color: var(--primary); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     .top-banner-close { | ||||
|       cursor: pointer; | ||||
|       position: absolute; | ||||
|       top: 50%; | ||||
|       right: 48px; | ||||
|       transform: translateY(-50%); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @media (max-width: 600px) { | ||||
|     .top-banner { | ||||
|       padding: 12px 24px 12px 12px; | ||||
|       .top-banner-close { | ||||
|         right: 10px; | ||||
|       } | ||||
|       .top-banner-inner { | ||||
|         .top-banner-logo { | ||||
|           margin-right: 8px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .auth-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     width: 100%; | ||||
|     padding: 10px; | ||||
|     box-sizing: border-box; | ||||
|     animation: slide-in-from-top ease 0.3s; | ||||
|   } | ||||
|  | ||||
|   .auth-logo { | ||||
|     margin-top: 10vh; | ||||
|     transform: scale(1.4); | ||||
|   } | ||||
|  | ||||
| @@ -72,7 +14,6 @@ | ||||
|     font-size: 24px; | ||||
|     font-weight: bold; | ||||
|     line-height: 2; | ||||
|     margin-bottom: 1vh; | ||||
|   } | ||||
|  | ||||
|   .auth-tips { | ||||
| @@ -83,10 +24,6 @@ | ||||
|     margin: 3vh 0; | ||||
|   } | ||||
|  | ||||
|   .auth-input-second { | ||||
|     margin: 0 0 3vh 0; | ||||
|   } | ||||
|  | ||||
|   .auth-actions { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|   | ||||
| @@ -1,34 +1,21 @@ | ||||
| import styles from "./auth.module.scss"; | ||||
| import { IconButton } from "./button"; | ||||
| import { useState, useEffect } from "react"; | ||||
|  | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { Path, SAAS_CHAT_URL } from "../constant"; | ||||
| import { Path } from "../constant"; | ||||
| import { useAccessStore } from "../store"; | ||||
| import Locale from "../locales"; | ||||
| import Delete from "../icons/close.svg"; | ||||
| import Arrow from "../icons/arrow.svg"; | ||||
| import Logo from "../icons/logo.svg"; | ||||
| import { useMobileScreen } from "@/app/utils"; | ||||
|  | ||||
| import BotIcon from "../icons/bot.svg"; | ||||
| import { useEffect } from "react"; | ||||
| import { getClientConfig } from "../config/client"; | ||||
| import LeftIcon from "@/app/icons/left.svg"; | ||||
| import { safeLocalStorage } from "@/app/utils"; | ||||
| import { | ||||
|   trackSettingsPageGuideToCPaymentClick, | ||||
|   trackAuthorizationPageButtonToCPaymentClick, | ||||
| } from "../utils/auth-settings-events"; | ||||
| const storage = safeLocalStorage(); | ||||
|  | ||||
| export function AuthPage() { | ||||
|   const navigate = useNavigate(); | ||||
|   const accessStore = useAccessStore(); | ||||
|  | ||||
|   const goHome = () => navigate(Path.Home); | ||||
|   const goChat = () => navigate(Path.Chat); | ||||
|   const goSaas = () => { | ||||
|     trackAuthorizationPageButtonToCPaymentClick(); | ||||
|     window.location.href = SAAS_CHAT_URL; | ||||
|   }; | ||||
|  | ||||
|   const resetAccessCode = () => { | ||||
|     accessStore.update((access) => { | ||||
|       access.openaiApiKey = ""; | ||||
| @@ -45,14 +32,6 @@ export function AuthPage() { | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["auth-page"]}> | ||||
|       <TopBanner></TopBanner> | ||||
|       <div className={styles["auth-header"]}> | ||||
|         <IconButton | ||||
|           icon={<LeftIcon />} | ||||
|           text={Locale.Auth.Return} | ||||
|           onClick={() => navigate(Path.Home)} | ||||
|         ></IconButton> | ||||
|       </div> | ||||
|       <div className={`no-dark ${styles["auth-logo"]}`}> | ||||
|         <BotIcon /> | ||||
|       </div> | ||||
| @@ -86,7 +65,7 @@ export function AuthPage() { | ||||
|             }} | ||||
|           /> | ||||
|           <input | ||||
|             className={styles["auth-input-second"]} | ||||
|             className={styles["auth-input"]} | ||||
|             type="password" | ||||
|             placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder} | ||||
|             value={accessStore.googleApiKey} | ||||
| @@ -106,74 +85,13 @@ export function AuthPage() { | ||||
|           onClick={goChat} | ||||
|         /> | ||||
|         <IconButton | ||||
|           text={Locale.Auth.SaasTips} | ||||
|           text={Locale.Auth.Later} | ||||
|           onClick={() => { | ||||
|             goSaas(); | ||||
|             resetAccessCode(); | ||||
|             goHome(); | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function TopBanner() { | ||||
|   const [isHovered, setIsHovered] = useState(false); | ||||
|   const [isVisible, setIsVisible] = useState(true); | ||||
|   const isMobile = useMobileScreen(); | ||||
|   useEffect(() => { | ||||
|     // 检查 localStorage 中是否有标记 | ||||
|     const bannerDismissed = storage.getItem("bannerDismissed"); | ||||
|     // 如果标记不存在,存储默认值并显示横幅 | ||||
|     if (!bannerDismissed) { | ||||
|       storage.setItem("bannerDismissed", "false"); | ||||
|       setIsVisible(true); // 显示横幅 | ||||
|     } else if (bannerDismissed === "true") { | ||||
|       // 如果标记为 "true",则隐藏横幅 | ||||
|       setIsVisible(false); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   const handleMouseEnter = () => { | ||||
|     setIsHovered(true); | ||||
|   }; | ||||
|  | ||||
|   const handleMouseLeave = () => { | ||||
|     setIsHovered(false); | ||||
|   }; | ||||
|  | ||||
|   const handleClose = () => { | ||||
|     setIsVisible(false); | ||||
|     storage.setItem("bannerDismissed", "true"); | ||||
|   }; | ||||
|  | ||||
|   if (!isVisible) { | ||||
|     return null; | ||||
|   } | ||||
|   return ( | ||||
|     <div | ||||
|       className={styles["top-banner"]} | ||||
|       onMouseEnter={handleMouseEnter} | ||||
|       onMouseLeave={handleMouseLeave} | ||||
|     > | ||||
|       <div className={`${styles["top-banner-inner"]} no-dark`}> | ||||
|         <Logo className={styles["top-banner-logo"]}></Logo> | ||||
|         <span> | ||||
|           {Locale.Auth.TopTips} | ||||
|           <a | ||||
|             href={SAAS_CHAT_URL} | ||||
|             rel="stylesheet" | ||||
|             onClick={() => { | ||||
|               trackSettingsPageGuideToCPaymentClick(); | ||||
|             }} | ||||
|           > | ||||
|             {Locale.Settings.Access.SaasStart.ChatNow} | ||||
|             <Arrow style={{ marginLeft: "4px" }} /> | ||||
|           </a> | ||||
|         </span> | ||||
|       </div> | ||||
|       {(isHovered || isMobile) && ( | ||||
|         <Delete className={styles["top-banner-close"]} onClick={handleClose} /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   padding: 10px; | ||||
|  | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   overflow: hidden; | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import * as React from "react"; | ||||
|  | ||||
| import styles from "./button.module.scss"; | ||||
| import { CSSProperties } from "react"; | ||||
|  | ||||
| export type ButtonType = "primary" | "danger" | null; | ||||
|  | ||||
| @@ -17,8 +16,6 @@ export function IconButton(props: { | ||||
|   disabled?: boolean; | ||||
|   tabIndex?: number; | ||||
|   autoFocus?: boolean; | ||||
|   style?: CSSProperties; | ||||
|   aria?: string; | ||||
| }) { | ||||
|   return ( | ||||
|     <button | ||||
| @@ -34,12 +31,9 @@ export function IconButton(props: { | ||||
|       role="button" | ||||
|       tabIndex={props.tabIndex} | ||||
|       autoFocus={props.autoFocus} | ||||
|       style={props.style} | ||||
|       aria-label={props.aria} | ||||
|     > | ||||
|       {props.icon && ( | ||||
|         <div | ||||
|           aria-label={props.text || props.title} | ||||
|           className={ | ||||
|             styles["icon-button-icon"] + | ||||
|             ` ${props.type === "primary" && "no-dark"}` | ||||
| @@ -50,12 +44,7 @@ export function IconButton(props: { | ||||
|       )} | ||||
|  | ||||
|       {props.text && ( | ||||
|         <div | ||||
|           aria-label={props.text || props.title} | ||||
|           className={styles["icon-button-text"]} | ||||
|         > | ||||
|           {props.text} | ||||
|         </div> | ||||
|         <div className={styles["icon-button-text"]}>{props.text}</div> | ||||
|       )} | ||||
|     </button> | ||||
|   ); | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import DeleteIcon from "../icons/delete.svg"; | ||||
| import BotIcon from "../icons/bot.svg"; | ||||
|  | ||||
| import styles from "./home.module.scss"; | ||||
| import { | ||||
| @@ -11,7 +12,7 @@ import { | ||||
| import { useChatStore } from "../store"; | ||||
|  | ||||
| import Locale from "../locales"; | ||||
| import { useLocation, useNavigate } from "react-router-dom"; | ||||
| import { Link, useNavigate } from "react-router-dom"; | ||||
| import { Path } from "../constant"; | ||||
| import { MaskAvatar } from "./mask"; | ||||
| import { Mask } from "../store/mask"; | ||||
| @@ -39,16 +40,12 @@ export function ChatItem(props: { | ||||
|       }); | ||||
|     } | ||||
|   }, [props.selected]); | ||||
|  | ||||
|   const { pathname: currentPath } = useLocation(); | ||||
|   return ( | ||||
|     <Draggable draggableId={`${props.id}`} index={props.index}> | ||||
|       {(provided) => ( | ||||
|         <div | ||||
|           className={`${styles["chat-item"]} ${ | ||||
|             props.selected && | ||||
|             (currentPath === Path.Chat || currentPath === Path.Home) && | ||||
|             styles["chat-item-selected"] | ||||
|             props.selected && styles["chat-item-selected"] | ||||
|           }`} | ||||
|           onClick={props.onClick} | ||||
|           ref={(ele) => { | ||||
|   | ||||
| @@ -58,7 +58,7 @@ | ||||
|     box-shadow: var(--card-shadow); | ||||
|     transition: width ease 0.3s; | ||||
|     align-items: center; | ||||
|     height: 16px; | ||||
|     height: 24px; | ||||
|     width: var(--icon-width); | ||||
|     overflow: hidden; | ||||
|  | ||||
| @@ -68,7 +68,6 @@ | ||||
|  | ||||
|     .text { | ||||
|       white-space: nowrap; | ||||
|       padding-left: 5px; | ||||
|       opacity: 0; | ||||
|       transform: translateX(-5px); | ||||
|       transition: all ease 0.3s; | ||||
| @@ -346,12 +345,6 @@ | ||||
|       flex-wrap: nowrap; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .chat-model-name { | ||||
|     font-size: 12px; | ||||
|     color: var(--black); | ||||
|     margin-left: 6px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-message-container { | ||||
| @@ -413,21 +406,6 @@ | ||||
|   margin-top: 5px; | ||||
| } | ||||
|  | ||||
| .chat-message-tools { | ||||
|   font-size: 12px; | ||||
|   color: #aaa; | ||||
|   line-height: 1.5; | ||||
|   margin-top: 5px; | ||||
|   .chat-message-tool { | ||||
|     display: flex; | ||||
|     align-items: end; | ||||
|     svg { | ||||
|       margin-left: 5px; | ||||
|       margin-right: 5px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .chat-message-item { | ||||
|   box-sizing: border-box; | ||||
|   max-width: 100%; | ||||
| @@ -631,10 +609,6 @@ | ||||
| .chat-input-send { | ||||
|   background-color: var(--primary); | ||||
|   color: white; | ||||
|  | ||||
|   position: absolute; | ||||
|   right: 30px; | ||||
|   bottom: 32px; | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 600px) { | ||||
| @@ -646,51 +620,3 @@ | ||||
|     bottom: 30px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .shortcut-key-container { | ||||
|   padding: 10px; | ||||
|   overflow-y: auto; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
|  | ||||
| .shortcut-key-grid { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); | ||||
|   gap: 16px; | ||||
| } | ||||
|  | ||||
| .shortcut-key-item { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   overflow: hidden; | ||||
|   padding: 10px; | ||||
|   background-color: var(--white); | ||||
| } | ||||
|  | ||||
| .shortcut-key-title { | ||||
|   font-size: 14px; | ||||
|   color: var(--black); | ||||
| } | ||||
|  | ||||
| .shortcut-key-keys { | ||||
|   display: flex; | ||||
|   gap: 8px; | ||||
| } | ||||
|  | ||||
| .shortcut-key { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   border: var(--border-in-light); | ||||
|   border-radius: 8px; | ||||
|   padding: 4px; | ||||
|   background-color: var(--gray); | ||||
|   min-width: 32px; | ||||
| } | ||||
|  | ||||
| .shortcut-key span { | ||||
|   font-size: 12px; | ||||
|   color: var(--black); | ||||
| } | ||||
| @@ -15,8 +15,6 @@ import RenameIcon from "../icons/rename.svg"; | ||||
| import ExportIcon from "../icons/share.svg"; | ||||
| import ReturnIcon from "../icons/return.svg"; | ||||
| import CopyIcon from "../icons/copy.svg"; | ||||
| import SpeakIcon from "../icons/speak.svg"; | ||||
| import SpeakStopIcon from "../icons/speak-stop.svg"; | ||||
| import LoadingIcon from "../icons/three-dots.svg"; | ||||
| import LoadingButtonIcon from "../icons/loading.svg"; | ||||
| import PromptIcon from "../icons/prompt.svg"; | ||||
| @@ -30,7 +28,6 @@ import DeleteIcon from "../icons/clear.svg"; | ||||
| import PinIcon from "../icons/pin.svg"; | ||||
| import EditIcon from "../icons/rename.svg"; | ||||
| import ConfirmIcon from "../icons/confirm.svg"; | ||||
| import CloseIcon from "../icons/close.svg"; | ||||
| import CancelIcon from "../icons/cancel.svg"; | ||||
| import ImageIcon from "../icons/image.svg"; | ||||
|  | ||||
| @@ -40,12 +37,6 @@ import AutoIcon from "../icons/auto.svg"; | ||||
| import BottomIcon from "../icons/bottom.svg"; | ||||
| import StopIcon from "../icons/pause.svg"; | ||||
| import RobotIcon from "../icons/robot.svg"; | ||||
| import 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 ReloadIcon from "../icons/reload.svg"; | ||||
|  | ||||
| import { | ||||
|   ChatMessage, | ||||
| @@ -58,7 +49,6 @@ import { | ||||
|   useAppConfig, | ||||
|   DEFAULT_TOPIC, | ||||
|   ModelType, | ||||
|   usePluginStore, | ||||
| } from "../store"; | ||||
|  | ||||
| import { | ||||
| @@ -69,17 +59,12 @@ import { | ||||
|   getMessageTextContent, | ||||
|   getMessageImages, | ||||
|   isVisionModel, | ||||
|   isDalle3, | ||||
|   showPlugins, | ||||
|   safeLocalStorage, | ||||
|   compressImage, | ||||
| } from "../utils"; | ||||
|  | ||||
| import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; | ||||
|  | ||||
| import dynamic from "next/dynamic"; | ||||
|  | ||||
| import { ChatControllerPool } from "../client/controller"; | ||||
| import { DalleSize, DalleQuality, DalleStyle } from "../typing"; | ||||
| import { Prompt, usePromptStore } from "../store/prompt"; | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| @@ -98,12 +83,10 @@ import { | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { | ||||
|   CHAT_PAGE_SIZE, | ||||
|   DEFAULT_TTS_ENGINE, | ||||
|   ModelProvider, | ||||
|   LAST_INPUT_KEY, | ||||
|   Path, | ||||
|   REQUEST_TIMEOUT_MS, | ||||
|   UNFINISHED_INPUT, | ||||
|   ServiceProvider, | ||||
| } from "../constant"; | ||||
| import { Avatar } from "./emoji"; | ||||
| import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; | ||||
| @@ -114,14 +97,7 @@ import { ExportMessageModal } from "./exporter"; | ||||
| import { getClientConfig } from "../config/client"; | ||||
| import { useAllModels } from "../utils/hooks"; | ||||
| import { MultimodalContent } from "../client/api"; | ||||
|  | ||||
| const localStorage = safeLocalStorage(); | ||||
| import { ClientApi } from "../client/api"; | ||||
| import { createTTSPlayer } from "../utils/audio"; | ||||
| import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts"; | ||||
|  | ||||
| const ttsPlayer = createTTSPlayer(); | ||||
|  | ||||
| import SpeechRecorder from "./chat/speechRecorder"; | ||||
| const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | ||||
|   loading: () => <LoadingIcon />, | ||||
| }); | ||||
| @@ -201,7 +177,7 @@ function PromptToast(props: { | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["prompt-toast"]} key="prompt-toast"> | ||||
|       {props.showToast && context.length > 0 && ( | ||||
|       {props.showToast && ( | ||||
|         <div | ||||
|           className={styles["prompt-toast-inner"] + " clickable"} | ||||
|           role="button" | ||||
| @@ -267,11 +243,11 @@ function useSubmitHandler() { | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export type RenderPrompt = Pick<Prompt, "title" | "content">; | ||||
| export type RenderPompt = Pick<Prompt, "title" | "content">; | ||||
|  | ||||
| export function PromptHints(props: { | ||||
|   prompts: RenderPrompt[]; | ||||
|   onPromptSelect: (prompt: RenderPrompt) => void; | ||||
|   prompts: RenderPompt[]; | ||||
|   onPromptSelect: (prompt: RenderPompt) => void; | ||||
| }) { | ||||
|   const noPrompts = props.prompts.length === 0; | ||||
|   const [selectIndex, setSelectIndex] = useState(0); | ||||
| @@ -360,7 +336,7 @@ function ClearContextDivider() { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function ChatAction(props: { | ||||
| function ChatAction(props: { | ||||
|   text: string; | ||||
|   icon: JSX.Element; | ||||
|   onClick: () => void; | ||||
| @@ -371,7 +347,7 @@ export function ChatAction(props: { | ||||
|     full: 16, | ||||
|     icon: 16, | ||||
|   }); | ||||
|  | ||||
|   const [isActive, setIsActive] = useState(false); | ||||
|   function updateWidth() { | ||||
|     if (!iconRef.current || !textRef.current) return; | ||||
|     const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width; | ||||
| @@ -385,27 +361,24 @@ export function ChatAction(props: { | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${styles["chat-input-action"]} clickable`} | ||||
|       className={`${styles["chat-input-action"]} clickable group`} | ||||
|       onClick={() => { | ||||
|         props.onClick(); | ||||
|         setTimeout(updateWidth, 1); | ||||
|       }} | ||||
|       onMouseEnter={updateWidth} | ||||
|       onTouchStart={updateWidth} | ||||
|       style={ | ||||
|         { | ||||
|           "--icon-width": `${width.icon}px`, | ||||
|           "--full-width": `${width.full}px`, | ||||
|         } as React.CSSProperties | ||||
|       } | ||||
|     > | ||||
|       <div className="flex"> | ||||
|         <div ref={iconRef} className={styles["icon"]}> | ||||
|           {props.icon} | ||||
|         </div> | ||||
|       <div className={styles["text"]} ref={textRef}> | ||||
|         <div | ||||
|           className={`${styles["text"]} transition-all duration-1000 w-0 group-hover:w-[60px]`} | ||||
|           ref={textRef} | ||||
|         > | ||||
|           {props.text} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @@ -448,15 +421,13 @@ export function ChatActions(props: { | ||||
|   showPromptModal: () => void; | ||||
|   scrollToBottom: () => void; | ||||
|   showPromptHints: () => void; | ||||
|   setUserInput: (text: string) => void; | ||||
|   hitBottom: boolean; | ||||
|   uploading: boolean; | ||||
|   setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>; | ||||
|   setUserInput: (input: string) => void; | ||||
| }) { | ||||
|   const config = useAppConfig(); | ||||
|   const navigate = useNavigate(); | ||||
|   const chatStore = useChatStore(); | ||||
|   const pluginStore = usePluginStore(); | ||||
|  | ||||
|   // switch themes | ||||
|   const theme = config.theme; | ||||
| @@ -474,51 +445,14 @@ export function ChatActions(props: { | ||||
|  | ||||
|   // switch model | ||||
|   const currentModel = chatStore.currentSession().mask.modelConfig.model; | ||||
|   const currentProviderName = | ||||
|     chatStore.currentSession().mask.modelConfig?.providerName || | ||||
|     ServiceProvider.OpenAI; | ||||
|   const allModels = useAllModels(); | ||||
|   const models = useMemo(() => { | ||||
|     const filteredModels = allModels.filter((m) => m.available); | ||||
|     const defaultModel = filteredModels.find((m) => m.isDefault); | ||||
|  | ||||
|     if (defaultModel) { | ||||
|       const arr = [ | ||||
|         defaultModel, | ||||
|         ...filteredModels.filter((m) => m !== defaultModel), | ||||
|       ]; | ||||
|       return arr; | ||||
|     } else { | ||||
|       return filteredModels; | ||||
|     } | ||||
|   }, [allModels]); | ||||
|   const currentModelName = useMemo(() => { | ||||
|     const model = models.find( | ||||
|       (m) => | ||||
|         m.name == currentModel && | ||||
|         m?.provider?.providerName == currentProviderName, | ||||
|   const models = useMemo( | ||||
|     () => allModels.filter((m) => m.available), | ||||
|     [allModels], | ||||
|   ); | ||||
|     return model?.displayName ?? ""; | ||||
|   }, [models, currentModel, currentProviderName]); | ||||
|   const [showModelSelector, setShowModelSelector] = useState(false); | ||||
|   const [showPluginSelector, setShowPluginSelector] = 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(() => { | ||||
|     const show = isVisionModel(currentModel); | ||||
|     setShowUploadImage(show); | ||||
| @@ -529,20 +463,13 @@ export function ChatActions(props: { | ||||
|  | ||||
|     // if current model is not available | ||||
|     // switch to first available model | ||||
|     const isUnavailableModel = !models.some((m) => m.name === currentModel); | ||||
|     if (isUnavailableModel && models.length > 0) { | ||||
|       // show next model to default model if exist | ||||
|       let nextModel = models.find((model) => model.isDefault) || models[0]; | ||||
|       chatStore.updateCurrentSession((session) => { | ||||
|         session.mask.modelConfig.model = nextModel.name; | ||||
|         session.mask.modelConfig.providerName = nextModel?.provider | ||||
|           ?.providerName as ServiceProvider; | ||||
|       }); | ||||
|       showToast( | ||||
|         nextModel?.provider?.providerName == "ByteDance" | ||||
|           ? nextModel.displayName | ||||
|           : nextModel.name, | ||||
|     const isUnavaliableModel = !models.some((m) => m.name === currentModel); | ||||
|     if (isUnavaliableModel && models.length > 0) { | ||||
|       const nextModel = models[0].name as ModelType; | ||||
|       chatStore.updateCurrentSession( | ||||
|         (session) => (session.mask.modelConfig.model = nextModel), | ||||
|       ); | ||||
|       showToast(nextModel); | ||||
|     } | ||||
|   }, [chatStore, currentModel, models]); | ||||
|  | ||||
| @@ -624,162 +551,28 @@ export function ChatActions(props: { | ||||
|  | ||||
|       <ChatAction | ||||
|         onClick={() => setShowModelSelector(true)} | ||||
|         text={currentModelName} | ||||
|         text={currentModel} | ||||
|         icon={<RobotIcon />} | ||||
|       /> | ||||
|  | ||||
|       {showModelSelector && ( | ||||
|         <Selector | ||||
|           defaultSelectedValue={`${currentModel}@${currentProviderName}`} | ||||
|           defaultSelectedValue={currentModel} | ||||
|           items={models.map((m) => ({ | ||||
|             title: `${m.displayName}${ | ||||
|               m?.provider?.providerName | ||||
|                 ? " (" + m?.provider?.providerName + ")" | ||||
|                 : "" | ||||
|             }`, | ||||
|             value: `${m.name}@${m?.provider?.providerName}`, | ||||
|             title: m.displayName, | ||||
|             value: m.name, | ||||
|           }))} | ||||
|           onClose={() => setShowModelSelector(false)} | ||||
|           onSelection={(s) => { | ||||
|             if (s.length === 0) return; | ||||
|             const [model, providerName] = s[0].split("@"); | ||||
|             chatStore.updateCurrentSession((session) => { | ||||
|               session.mask.modelConfig.model = model as ModelType; | ||||
|               session.mask.modelConfig.providerName = | ||||
|                 providerName as ServiceProvider; | ||||
|               session.mask.modelConfig.model = s[0] as ModelType; | ||||
|               session.mask.syncGlobalConfig = false; | ||||
|             }); | ||||
|             if (providerName == "ByteDance") { | ||||
|               const selectedModel = models.find( | ||||
|                 (m) => | ||||
|                   m.name == model && m?.provider?.providerName == providerName, | ||||
|               ); | ||||
|               showToast(selectedModel?.displayName ?? ""); | ||||
|             } else { | ||||
|               showToast(model); | ||||
|             } | ||||
|             showToast(s[0]); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {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> | ||||
|   ); | ||||
| } | ||||
| @@ -854,67 +647,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() { | ||||
|   type RenderMessage = ChatMessage & { preview?: boolean }; | ||||
|  | ||||
| @@ -922,7 +654,6 @@ function _Chat() { | ||||
|   const session = chatStore.currentSession(); | ||||
|   const config = useAppConfig(); | ||||
|   const fontSize = config.fontSize; | ||||
|   const fontFamily = config.fontFamily; | ||||
|  | ||||
|   const [showExport, setShowExport] = useState(false); | ||||
|  | ||||
| @@ -949,7 +680,7 @@ function _Chat() { | ||||
|  | ||||
|   // prompt hints | ||||
|   const promptStore = usePromptStore(); | ||||
|   const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]); | ||||
|   const [promptHints, setPromptHints] = useState<RenderPompt[]>([]); | ||||
|   const onSearch = useDebouncedCallback( | ||||
|     (text: string) => { | ||||
|       const matchedPrompts = promptStore.search(text); | ||||
| @@ -990,7 +721,6 @@ function _Chat() { | ||||
|       chatStore.updateCurrentSession( | ||||
|         (session) => (session.clearContextIndex = session.messages.length), | ||||
|       ), | ||||
|     fork: () => chatStore.forkSession(), | ||||
|     del: () => chatStore.deleteSession(chatStore.currentSessionIndex), | ||||
|   }); | ||||
|  | ||||
| @@ -1003,7 +733,7 @@ function _Chat() { | ||||
|     // clear search results | ||||
|     if (n === 0) { | ||||
|       setPromptHints([]); | ||||
|     } else if (text.match(ChatCommandPrefix)) { | ||||
|     } else if (text.startsWith(ChatCommandPrefix)) { | ||||
|       setPromptHints(chatCommands.search(text)); | ||||
|     } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { | ||||
|       // check if need to trigger auto completion | ||||
| @@ -1028,14 +758,14 @@ function _Chat() { | ||||
|       .onUserInput(userInput, attachImages) | ||||
|       .then(() => setIsLoading(false)); | ||||
|     setAttachImages([]); | ||||
|     chatStore.setLastInput(userInput); | ||||
|     localStorage.setItem(LAST_INPUT_KEY, userInput); | ||||
|     setUserInput(""); | ||||
|     setPromptHints([]); | ||||
|     if (!isMobileScreen) inputRef.current?.focus(); | ||||
|     setAutoScroll(true); | ||||
|   }; | ||||
|  | ||||
|   const onPromptSelect = (prompt: RenderPrompt) => { | ||||
|   const onPromptSelect = (prompt: RenderPompt) => { | ||||
|     setTimeout(() => { | ||||
|       setPromptHints([]); | ||||
|  | ||||
| @@ -1094,7 +824,7 @@ function _Chat() { | ||||
|       userInput.length <= 0 && | ||||
|       !(e.metaKey || e.altKey || e.ctrlKey) | ||||
|     ) { | ||||
|       setUserInput(chatStore.lastInput ?? ""); | ||||
|       setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? ""); | ||||
|       e.preventDefault(); | ||||
|       return; | ||||
|     } | ||||
| @@ -1194,55 +924,10 @@ function _Chat() { | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const accessStore = useAccessStore(); | ||||
|   const [speechStatus, setSpeechStatus] = useState(false); | ||||
|   const [speechLoading, setSpeechLoading] = useState(false); | ||||
|   async function openaiSpeech(text: string) { | ||||
|     if (speechStatus) { | ||||
|       ttsPlayer.stop(); | ||||
|       setSpeechStatus(false); | ||||
|     } else { | ||||
|       var api: ClientApi; | ||||
|       api = new ClientApi(ModelProvider.GPT); | ||||
|       const config = useAppConfig.getState(); | ||||
|       setSpeechLoading(true); | ||||
|       ttsPlayer.init(); | ||||
|       let audioBuffer: ArrayBuffer; | ||||
|       const { markdownToTxt } = require("markdown-to-txt"); | ||||
|       const textContent = markdownToTxt(text); | ||||
|       if (config.ttsConfig.engine !== DEFAULT_TTS_ENGINE) { | ||||
|         const edgeVoiceName = accessStore.edgeVoiceName(); | ||||
|         const tts = new MsEdgeTTS(); | ||||
|         await tts.setMetadata( | ||||
|           edgeVoiceName, | ||||
|           OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, | ||||
|         ); | ||||
|         audioBuffer = await tts.toArrayBuffer(textContent); | ||||
|       } else { | ||||
|         audioBuffer = await api.llm.speech({ | ||||
|           model: config.ttsConfig.model, | ||||
|           input: textContent, | ||||
|           voice: config.ttsConfig.voice, | ||||
|           speed: config.ttsConfig.speed, | ||||
|         }); | ||||
|       } | ||||
|       setSpeechStatus(true); | ||||
|       ttsPlayer | ||||
|         .play(audioBuffer, () => { | ||||
|           setSpeechStatus(false); | ||||
|         }) | ||||
|         .catch((e) => { | ||||
|           console.error("[OpenAI Speech]", e); | ||||
|           showToast(prettyObject(e)); | ||||
|           setSpeechStatus(false); | ||||
|         }) | ||||
|         .finally(() => setSpeechLoading(false)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   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 && | ||||
| @@ -1388,7 +1073,6 @@ function _Chat() { | ||||
|             if (payload.url) { | ||||
|               accessStore.update((access) => (access.openaiUrl = payload.url!)); | ||||
|             } | ||||
|             accessStore.update((access) => (access.useCustomConfig = true)); | ||||
|           }); | ||||
|         } | ||||
|       } catch { | ||||
| @@ -1420,9 +1104,7 @@ function _Chat() { | ||||
|   const handlePaste = useCallback( | ||||
|     async (event: React.ClipboardEvent<HTMLTextAreaElement>) => { | ||||
|       const currentModel = chatStore.currentSession().mask.modelConfig.model; | ||||
|       if (!isVisionModel(currentModel)) { | ||||
|         return; | ||||
|       } | ||||
|       if(!isVisionModel(currentModel)){return;} | ||||
|       const items = (event.clipboardData || window.clipboardData).items; | ||||
|       for (const item of items) { | ||||
|         if (item.kind === "file" && item.type.startsWith("image/")) { | ||||
| @@ -1435,7 +1117,7 @@ function _Chat() { | ||||
|               ...(await new Promise<string[]>((res, rej) => { | ||||
|                 setUploading(true); | ||||
|                 const imagesData: string[] = []; | ||||
|                 uploadImageRemote(file) | ||||
|                 compressImage(file, 256 * 1024) | ||||
|                   .then((dataUrl) => { | ||||
|                     imagesData.push(dataUrl); | ||||
|                     setUploading(false); | ||||
| @@ -1477,7 +1159,7 @@ function _Chat() { | ||||
|           const imagesData: string[] = []; | ||||
|           for (let i = 0; i < files.length; i++) { | ||||
|             const file = event.target.files[i]; | ||||
|             uploadImageRemote(file) | ||||
|             compressImage(file, 256 * 1024) | ||||
|               .then((dataUrl) => { | ||||
|                 imagesData.push(dataUrl); | ||||
|                 if ( | ||||
| @@ -1505,70 +1187,6 @@ function _Chat() { | ||||
|     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 ( | ||||
|     <div className={styles.chat} key={session.id}> | ||||
|       <div className="window-header" data-tauri-drag-region> | ||||
| @@ -1597,24 +1215,11 @@ function _Chat() { | ||||
|           </div> | ||||
|         </div> | ||||
|         <div className="window-actions"> | ||||
|           <div className="window-action-button"> | ||||
|             <IconButton | ||||
|               icon={<ReloadIcon />} | ||||
|               bordered | ||||
|               title={Locale.Chat.Actions.RefreshTitle} | ||||
|               onClick={() => { | ||||
|                 showToast(Locale.Chat.Actions.RefreshToast); | ||||
|                 chatStore.summarizeSession(true); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           {!isMobileScreen && ( | ||||
|             <div className="window-action-button"> | ||||
|               <IconButton | ||||
|                 icon={<RenameIcon />} | ||||
|                 bordered | ||||
|                 title={Locale.Chat.EditMessage.Title} | ||||
|                 aria={Locale.Chat.EditMessage.Title} | ||||
|                 onClick={() => setIsEditingMessage(true)} | ||||
|               /> | ||||
|             </div> | ||||
| @@ -1634,8 +1239,6 @@ function _Chat() { | ||||
|               <IconButton | ||||
|                 icon={config.tightBorder ? <MinIcon /> : <MaxIcon />} | ||||
|                 bordered | ||||
|                 title={Locale.Chat.Actions.FullScreen} | ||||
|                 aria={Locale.Chat.Actions.FullScreen} | ||||
|                 onClick={() => { | ||||
|                   config.update( | ||||
|                     (config) => (config.tightBorder = !config.tightBorder), | ||||
| @@ -1687,7 +1290,6 @@ function _Chat() { | ||||
|                       <div className={styles["chat-message-edit"]}> | ||||
|                         <IconButton | ||||
|                           icon={<EditIcon />} | ||||
|                           aria={Locale.Chat.Actions.Edit} | ||||
|                           onClick={async () => { | ||||
|                             const newMessage = await showPrompt( | ||||
|                               Locale.Chat.Actions.Edit, | ||||
| @@ -1736,11 +1338,6 @@ function _Chat() { | ||||
|                         </> | ||||
|                       )} | ||||
|                     </div> | ||||
|                     {!isUser && ( | ||||
|                       <div className={styles["chat-model-name"]}> | ||||
|                         {message.model} | ||||
|                       </div> | ||||
|                     )} | ||||
|  | ||||
|                     {showActions && ( | ||||
|                       <div className={styles["chat-message-actions"]}> | ||||
| @@ -1779,72 +1376,31 @@ function _Chat() { | ||||
|                                   ) | ||||
|                                 } | ||||
|                               /> | ||||
|                               {config.ttsConfig.enable && ( | ||||
|                                 <ChatAction | ||||
|                                   text={ | ||||
|                                     speechStatus | ||||
|                                       ? Locale.Chat.Actions.StopSpeech | ||||
|                                       : Locale.Chat.Actions.Speech | ||||
|                                   } | ||||
|                                   icon={ | ||||
|                                     speechStatus ? ( | ||||
|                                       <SpeakStopIcon /> | ||||
|                                     ) : ( | ||||
|                                       <SpeakIcon /> | ||||
|                                     ) | ||||
|                                   } | ||||
|                                   onClick={() => | ||||
|                                     openaiSpeech(getMessageTextContent(message)) | ||||
|                                   } | ||||
|                                 /> | ||||
|                               )} | ||||
|                             </> | ||||
|                           )} | ||||
|                         </div> | ||||
|                       </div> | ||||
|                     )} | ||||
|                   </div> | ||||
|                   {message?.tools?.length == 0 && showTyping && ( | ||||
|                   {showTyping && ( | ||||
|                     <div className={styles["chat-message-status"]}> | ||||
|                       {Locale.Chat.Typing} | ||||
|                     </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"]}> | ||||
|                     <Markdown | ||||
|                       key={message.streaming ? "loading" : "done"} | ||||
|                       content={getMessageTextContent(message)} | ||||
|                       loading={ | ||||
|                         (message.preview || message.streaming) && | ||||
|                         message.content.length === 0 && | ||||
|                         !isUser | ||||
|                       } | ||||
|                       //   onContextMenu={(e) => onRightClick(e, message)} // hard to use | ||||
|                       onContextMenu={(e) => onRightClick(e, message)} | ||||
|                       onDoubleClickCapture={() => { | ||||
|                         if (!isMobileScreen) return; | ||||
|                         setUserInput(getMessageTextContent(message)); | ||||
|                       }} | ||||
|                       fontSize={fontSize} | ||||
|                       fontFamily={fontFamily} | ||||
|                       parentRef={scrollRef} | ||||
|                       defaultShow={i >= messages.length - 6} | ||||
|                     /> | ||||
| @@ -1904,6 +1460,7 @@ function _Chat() { | ||||
|           scrollToBottom={scrollToBottom} | ||||
|           hitBottom={hitBottom} | ||||
|           uploading={uploading} | ||||
|           setUserInput={setUserInput} | ||||
|           showPromptHints={() => { | ||||
|             // Click again to close | ||||
|             if (promptHints.length > 0) { | ||||
| @@ -1915,8 +1472,6 @@ function _Chat() { | ||||
|             setUserInput("/"); | ||||
|             onSearch(""); | ||||
|           }} | ||||
|           setShowShortcutKeyModal={setShowShortcutKeyModal} | ||||
|           setUserInput={setUserInput} | ||||
|         /> | ||||
|         <label | ||||
|           className={`${styles["chat-input-panel-inner"]} ${ | ||||
| @@ -1941,7 +1496,6 @@ function _Chat() { | ||||
|             autoFocus={autoFocus} | ||||
|             style={{ | ||||
|               fontSize: config.fontSize, | ||||
|               fontFamily: config.fontFamily, | ||||
|             }} | ||||
|           /> | ||||
|           {attachImages.length != 0 && ( | ||||
| @@ -1967,6 +1521,11 @@ function _Chat() { | ||||
|               })} | ||||
|             </div> | ||||
|           )} | ||||
|           <div className="flex gap-2 absolute left-[30px] bottom-[32px]"> | ||||
|             <SpeechRecorder textUpdater={setUserInput}></SpeechRecorder> | ||||
|           </div> | ||||
|  | ||||
|           <div className="flex gap-2 absolute right-[30px] bottom-[32px]"> | ||||
|             <IconButton | ||||
|               icon={<SendWhiteIcon />} | ||||
|               text={Locale.Chat.Send} | ||||
| @@ -1974,6 +1533,7 @@ function _Chat() { | ||||
|               type="primary" | ||||
|               onClick={() => doSubmit(userInput)} | ||||
|             /> | ||||
|           </div> | ||||
|         </label> | ||||
|       </div> | ||||
|  | ||||
| @@ -1988,10 +1548,6 @@ function _Chat() { | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {showShortcutKeyModal && ( | ||||
|         <ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										64
									
								
								app/components/chat/speechRecorder.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,64 @@ | ||||
| import React, { useState, useEffect } from "react"; | ||||
| import VoiceIcon from "@/app/icons/voice.svg"; | ||||
| import { getLang, formatLang } from "@/app/locales"; | ||||
| type SpeechRecognitionType = | ||||
|   | typeof window.SpeechRecognition | ||||
|   | typeof window.webkitSpeechRecognition; | ||||
|  | ||||
| export default function SpeechRecorder({ | ||||
|   textUpdater, | ||||
|   onStop, | ||||
| }: { | ||||
|   textUpdater: (text: string) => void; | ||||
|   onStop?: () => void; | ||||
| }) { | ||||
|   const [speechRecognition, setSpeechRecognition] = | ||||
|     useState<SpeechRecognitionType | null>(null); | ||||
|   const [isRecording, setIsRecording] = useState(false); | ||||
|   useEffect(() => { | ||||
|     if ("SpeechRecognition" in window) { | ||||
|       setSpeechRecognition(new (window as any).SpeechRecognition()); | ||||
|     } else if ("webkitSpeechRecognition" in window) { | ||||
|       setSpeechRecognition(new (window as any).webkitSpeechRecognition()); | ||||
|     } | ||||
|   }, []); | ||||
|   return ( | ||||
|     <> | ||||
|       {speechRecognition && ( | ||||
|         <div> | ||||
|           <button | ||||
|             onClick={() => { | ||||
|               if (!isRecording && speechRecognition) { | ||||
|                 speechRecognition.continuous = true; | ||||
|                 speechRecognition.lang = formatLang(getLang()); | ||||
|                 console.log(speechRecognition.lang); | ||||
|                 speechRecognition.interimResults = true; | ||||
|                 speechRecognition.start(); | ||||
|                 speechRecognition.onresult = function (event: any) { | ||||
|                   console.log(event); | ||||
|                   var transcript = event.results[0][0].transcript; | ||||
|                   console.log(transcript); | ||||
|                   textUpdater(transcript); | ||||
|                 }; | ||||
|                 setIsRecording(true); | ||||
|               } else { | ||||
|                 speechRecognition.stop(); | ||||
|                 setIsRecording(false); | ||||
|               } | ||||
|             }} | ||||
|           > | ||||
|             {isRecording ? ( | ||||
|               <button className="p-2 rounded-full bg-blue-500 hover:bg-blue-600 ring-4 ring-blue-200 transition animate-pulse"> | ||||
|                 <VoiceIcon fill={"white"} /> | ||||
|               </button> | ||||
|             ) : ( | ||||
|               <button className="p-2 rounded-full bg-zinc-100 hover:bg-zinc-200 transition"> | ||||
|                 <VoiceIcon fill={"#8282A5"} /> | ||||
|               </button> | ||||
|             )} | ||||
|           </button> | ||||
|         </div> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -36,8 +36,7 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) { | ||||
|   if (props.model) { | ||||
|     return ( | ||||
|       <div className="no-dark"> | ||||
|         {props.model?.startsWith("gpt-4") || | ||||
|         props.model?.startsWith("chatgpt-4o") ? ( | ||||
|         {props.model?.startsWith("gpt-4") ? ( | ||||
|           <BlackBotIcon className="user-avatar" /> | ||||
|         ) : ( | ||||
|           <BotIcon className="user-avatar" /> | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| "use client"; | ||||
|  | ||||
| import React from "react"; | ||||
| import { IconButton } from "./button"; | ||||
| import GithubIcon from "../icons/github.svg"; | ||||
| @@ -8,7 +6,6 @@ import { ISSUE_URL } from "../constant"; | ||||
| import Locale from "../locales"; | ||||
| import { showConfirm } from "./ui-lib"; | ||||
| import { useSyncStore } from "../store/sync"; | ||||
| import { useChatStore } from "../store/chat"; | ||||
|  | ||||
| interface IErrorBoundaryState { | ||||
|   hasError: boolean; | ||||
| @@ -31,7 +28,8 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> { | ||||
|     try { | ||||
|       useSyncStore.getState().export(); | ||||
|     } finally { | ||||
|       useChatStore.getState().clearAllData(); | ||||
|       localStorage.clear(); | ||||
|       location.reload(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| /* eslint-disable @next/next/no-img-element */ | ||||
| import { ChatMessage, useAppConfig, useChatStore } from "../store"; | ||||
| import { ChatMessage, ModelType, useAppConfig, useChatStore } from "../store"; | ||||
| import Locale from "../locales"; | ||||
| import styles from "./exporter.module.scss"; | ||||
| import { | ||||
| @@ -36,9 +36,9 @@ import { toBlob, toPng } from "html-to-image"; | ||||
| import { DEFAULT_MASK_AVATAR } from "../store/mask"; | ||||
|  | ||||
| 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 { type ClientApi, getClientApi } from "../client/api"; | ||||
| import { ClientApi } from "../client/api"; | ||||
| import { getMessageTextContent } from "../utils"; | ||||
|  | ||||
| const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | ||||
| @@ -312,7 +312,12 @@ export function PreviewActions(props: { | ||||
|   const onRenderMsgs = (msgs: ChatMessage[]) => { | ||||
|     setShouldExport(false); | ||||
|  | ||||
|     const api: ClientApi = getClientApi(config.modelConfig.providerName); | ||||
|     var api: ClientApi; | ||||
|     if (config.modelConfig.model.startsWith("gemini")) { | ||||
|       api = new ClientApi(ModelProvider.GeminiPro); | ||||
|     } else { | ||||
|       api = new ClientApi(ModelProvider.GPT); | ||||
|     } | ||||
|  | ||||
|     api | ||||
|       .share(msgs) | ||||
| @@ -541,7 +546,7 @@ export function ImagePreviewer(props: { | ||||
|           <div> | ||||
|             <div className={styles["main-title"]}>NextChat</div> | ||||
|             <div className={styles["sub-title"]}> | ||||
|               github.com/ChatGPTNextWeb/ChatGPT-Next-Web | ||||
|               github.com/Yidadaa/ChatGPT-Next-Web | ||||
|             </div> | ||||
|             <div className={styles["icons"]}> | ||||
|               <ExportAvatar avatar={config.avatar} /> | ||||
| @@ -583,7 +588,6 @@ export function ImagePreviewer(props: { | ||||
|                 <Markdown | ||||
|                   content={getMessageTextContent(m)} | ||||
|                   fontSize={config.fontSize} | ||||
|                   fontFamily={config.fontFamily} | ||||
|                   defaultShow | ||||
|                 /> | ||||
|                 {getMessageImages(m).length == 1 && ( | ||||
|   | ||||
| @@ -137,18 +137,12 @@ | ||||
|   position: relative; | ||||
|   padding-top: 20px; | ||||
|   padding-bottom: 20px; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .sidebar-logo { | ||||
|   display: inline-flex; | ||||
| } | ||||
|  | ||||
| .sidebar-title-container { | ||||
|   display: inline-flex; | ||||
|   flex-direction: column; | ||||
|   position: absolute; | ||||
|   right: 0; | ||||
|   bottom: 18px; | ||||
| } | ||||
|  | ||||
| .sidebar-title { | ||||
|   | ||||
| @@ -12,7 +12,7 @@ import LoadingIcon from "../icons/three-dots.svg"; | ||||
| import { getCSSVar, useMobileScreen } from "../utils"; | ||||
|  | ||||
| import dynamic from "next/dynamic"; | ||||
| import { Path, SlotID } from "../constant"; | ||||
| import { ModelProvider, Path, SlotID } from "../constant"; | ||||
| import { ErrorBoundary } from "./error"; | ||||
|  | ||||
| import { getISOLang, getLang } from "../locales"; | ||||
| @@ -27,7 +27,7 @@ import { SideBar } from "./sidebar"; | ||||
| import { useAppConfig } from "../store/config"; | ||||
| import { AuthPage } from "./auth"; | ||||
| import { getClientConfig } from "../config/client"; | ||||
| import { type ClientApi, getClientApi } from "../client/api"; | ||||
| import { ClientApi } from "../client/api"; | ||||
| import { useAccessStore } from "../store"; | ||||
|  | ||||
| export function Loading(props: { noLogo?: boolean }) { | ||||
| @@ -39,10 +39,6 @@ export function Loading(props: { noLogo?: boolean }) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
|  | ||||
| const Settings = dynamic(async () => (await import("./settings")).Settings, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
| @@ -59,21 +55,6 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
|  | ||||
| const PluginPage = dynamic(async () => (await import("./plugin")).PluginPage, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
|  | ||||
| const SearchChat = dynamic( | ||||
|   async () => (await import("./search-chat")).SearchChatPage, | ||||
|   { | ||||
|     loading: () => <Loading noLogo />, | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| const Sd = dynamic(async () => (await import("./sd")).Sd, { | ||||
|   loading: () => <Loading noLogo />, | ||||
| }); | ||||
|  | ||||
| export function useSwitchTheme() { | ||||
|   const config = useAppConfig(); | ||||
|  | ||||
| @@ -141,23 +122,11 @@ const loadAsyncGoogleFont = () => { | ||||
|   document.head.appendChild(linkEl); | ||||
| }; | ||||
|  | ||||
| export function WindowContent(props: { children: React.ReactNode }) { | ||||
|   return ( | ||||
|     <div className={styles["window-content"]} id={SlotID.AppBody}> | ||||
|       {props?.children} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function Screen() { | ||||
|   const config = useAppConfig(); | ||||
|   const location = useLocation(); | ||||
|   const isArtifact = location.pathname.includes(Path.Artifacts); | ||||
|   const isHome = location.pathname === Path.Home; | ||||
|   const isAuth = location.pathname === Path.Auth; | ||||
|   const isSd = location.pathname === Path.Sd; | ||||
|   const isSdNew = location.pathname === Path.SdNew; | ||||
|  | ||||
|   const isMobileScreen = useMobileScreen(); | ||||
|   const shouldTightBorder = | ||||
|     getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen); | ||||
| @@ -166,42 +135,34 @@ function Screen() { | ||||
|     loadAsyncGoogleFont(); | ||||
|   }, []); | ||||
|  | ||||
|   if (isArtifact) { | ||||
|   return ( | ||||
|       <Routes> | ||||
|         <Route path="/artifacts/:id" element={<Artifacts />} /> | ||||
|       </Routes> | ||||
|     ); | ||||
|     <div | ||||
|       className={ | ||||
|         styles.container + | ||||
|         ` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${ | ||||
|           getLang() === "ar" ? styles["rtl-screen"] : "" | ||||
|         }` | ||||
|       } | ||||
|   const renderContent = () => { | ||||
|     if (isAuth) return <AuthPage />; | ||||
|     if (isSd) return <Sd />; | ||||
|     if (isSdNew) return <Sd />; | ||||
|     return ( | ||||
|     > | ||||
|       {isAuth ? ( | ||||
|         <> | ||||
|           <AuthPage /> | ||||
|         </> | ||||
|       ) : ( | ||||
|         <> | ||||
|           <SideBar className={isHome ? styles["sidebar-show"] : ""} /> | ||||
|         <WindowContent> | ||||
|  | ||||
|           <div className={styles["window-content"]} id={SlotID.AppBody}> | ||||
|             <Routes> | ||||
|               <Route path={Path.Home} element={<Chat />} /> | ||||
|               <Route path={Path.NewChat} element={<NewChat />} /> | ||||
|               <Route path={Path.Masks} element={<MaskPage />} /> | ||||
|             <Route path={Path.Plugins} element={<PluginPage />} /> | ||||
|             <Route path={Path.SearchChat} element={<SearchChat />} /> | ||||
|               <Route path={Path.Chat} element={<Chat />} /> | ||||
|               <Route path={Path.Settings} element={<Settings />} /> | ||||
|             </Routes> | ||||
|         </WindowContent> | ||||
|           </div> | ||||
|         </> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${styles.container} ${ | ||||
|         shouldTightBorder ? styles["tight-container"] : styles.container | ||||
|       } ${getLang() === "ar" ? styles["rtl-screen"] : ""}`} | ||||
|     > | ||||
|       {renderContent()} | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @@ -209,8 +170,12 @@ function Screen() { | ||||
| export function useLoadData() { | ||||
|   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 { | ||||
|     api = new ClientApi(ModelProvider.GPT); | ||||
|   } | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       const models = await api.llm.models(); | ||||
|   | ||||
| @@ -9,7 +9,6 @@ interface InputRangeProps { | ||||
|   min: string; | ||||
|   max: string; | ||||
|   step: string; | ||||
|   aria: string; | ||||
| } | ||||
|  | ||||
| export function InputRange({ | ||||
| @@ -20,13 +19,11 @@ export function InputRange({ | ||||
|   min, | ||||
|   max, | ||||
|   step, | ||||
|   aria, | ||||
| }: InputRangeProps) { | ||||
|   return ( | ||||
|     <div className={styles["input-range"] + ` ${className ?? ""}`}> | ||||
|       {title || value} | ||||
|       <input | ||||
|         aria-label={aria} | ||||
|         type="range" | ||||
|         title={title} | ||||
|         value={value} | ||||
|   | ||||
| @@ -6,21 +6,14 @@ import RehypeKatex from "rehype-katex"; | ||||
| import RemarkGfm from "remark-gfm"; | ||||
| import RehypeHighlight from "rehype-highlight"; | ||||
| import { useRef, useState, RefObject, useEffect, useMemo } from "react"; | ||||
| import { copyToClipboard, useWindowSize } from "../utils"; | ||||
| import { copyToClipboard } from "../utils"; | ||||
| import mermaid from "mermaid"; | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| import LoadingIcon from "../icons/three-dots.svg"; | ||||
| import ReloadButtonIcon from "../icons/reload.svg"; | ||||
| import React from "react"; | ||||
| import { useDebouncedCallback } from "use-debounce"; | ||||
| import { showImageModal, FullScreen } from "./ui-lib"; | ||||
| import { | ||||
|   ArtifactsShareButton, | ||||
|   HTMLPreview, | ||||
|   HTMLPreviewHander, | ||||
| } from "./artifacts"; | ||||
| import { useChatStore } from "../store"; | ||||
| import { IconButton } from "./button"; | ||||
| import { showImageModal } from "./ui-lib"; | ||||
|  | ||||
| export function Mermaid(props: { code: string }) { | ||||
|   const ref = useRef<HTMLDivElement>(null); | ||||
|   const [hasError, setHasError] = useState(false); | ||||
| @@ -69,59 +62,27 @@ export function Mermaid(props: { code: string }) { | ||||
|  | ||||
| export function PreCode(props: { children: any }) { | ||||
|   const ref = useRef<HTMLPreElement>(null); | ||||
|   const previewRef = useRef<HTMLPreviewHander>(null); | ||||
|   const refText = ref.current?.innerText; | ||||
|   const [mermaidCode, setMermaidCode] = useState(""); | ||||
|   const [htmlCode, setHtmlCode] = useState(""); | ||||
|   const { height } = useWindowSize(); | ||||
|   const chatStore = useChatStore(); | ||||
|   const session = chatStore.currentSession(); | ||||
|  | ||||
|   const renderArtifacts = useDebouncedCallback(() => { | ||||
|   const renderMermaid = useDebouncedCallback(() => { | ||||
|     if (!ref.current) return; | ||||
|     const mermaidDom = ref.current.querySelector("code.language-mermaid"); | ||||
|     if (mermaidDom) { | ||||
|       setMermaidCode((mermaidDom as HTMLElement).innerText); | ||||
|     } | ||||
|     const htmlDom = ref.current.querySelector("code.language-html"); | ||||
|     const refText = ref.current.querySelector("code")?.innerText; | ||||
|     if (htmlDom) { | ||||
|       setHtmlCode((htmlDom as HTMLElement).innerText); | ||||
|     } else if (refText?.startsWith("<!DOCTYPE")) { | ||||
|       setHtmlCode(refText); | ||||
|     } | ||||
|   }, 600); | ||||
|  | ||||
|   const enableArtifacts = session.mask?.enableArtifacts !== false; | ||||
|  | ||||
|   //Wrap the paragraph for plain-text | ||||
|   useEffect(() => { | ||||
|     if (ref.current) { | ||||
|       const codeElements = ref.current.querySelectorAll( | ||||
|         "code", | ||||
|       ) as NodeListOf<HTMLElement>; | ||||
|       const wrapLanguages = [ | ||||
|         "", | ||||
|         "md", | ||||
|         "markdown", | ||||
|         "text", | ||||
|         "txt", | ||||
|         "plaintext", | ||||
|         "tex", | ||||
|         "latex", | ||||
|       ]; | ||||
|       codeElements.forEach((codeElement) => { | ||||
|         let languageClass = codeElement.className.match(/language-(\w+)/); | ||||
|         let name = languageClass ? languageClass[1] : ""; | ||||
|         if (wrapLanguages.includes(name)) { | ||||
|           codeElement.style.whiteSpace = "pre-wrap"; | ||||
|         } | ||||
|       }); | ||||
|       setTimeout(renderArtifacts, 1); | ||||
|     } | ||||
|   }, []); | ||||
|     setTimeout(renderMermaid, 1); | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||
|   }, [refText]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {mermaidCode.length > 0 && ( | ||||
|         <Mermaid code={mermaidCode} key={mermaidCode} /> | ||||
|       )} | ||||
|       <pre ref={ref}> | ||||
|         <span | ||||
|           className="copy-code-button" | ||||
| @@ -134,69 +95,6 @@ export function PreCode(props: { children: any }) { | ||||
|         ></span> | ||||
|         {props.children} | ||||
|       </pre> | ||||
|       {mermaidCode.length > 0 && ( | ||||
|         <Mermaid code={mermaidCode} key={mermaidCode} /> | ||||
|       )} | ||||
|       {htmlCode.length > 0 && enableArtifacts && ( | ||||
|         <FullScreen className="no-dark html" right={70}> | ||||
|           <ArtifactsShareButton | ||||
|             style={{ position: "absolute", right: 20, top: 10 }} | ||||
|             getCode={() => htmlCode} | ||||
|           /> | ||||
|           <IconButton | ||||
|             style={{ position: "absolute", right: 120, top: 10 }} | ||||
|             bordered | ||||
|             icon={<ReloadButtonIcon />} | ||||
|             shadow | ||||
|             onClick={() => previewRef.current?.reload()} | ||||
|           /> | ||||
|           <HTMLPreview | ||||
|             ref={previewRef} | ||||
|             code={htmlCode} | ||||
|             autoHeight={!document.fullscreenElement} | ||||
|             height={!document.fullscreenElement ? 600 : height} | ||||
|           /> | ||||
|         </FullScreen> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function CustomCode(props: { children: any; className?: string }) { | ||||
|   const ref = useRef<HTMLPreElement>(null); | ||||
|   const [collapsed, setCollapsed] = useState(true); | ||||
|   const [showToggle, setShowToggle] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (ref.current) { | ||||
|       const codeHeight = ref.current.scrollHeight; | ||||
|       setShowToggle(codeHeight > 400); | ||||
|       ref.current.scrollTop = ref.current.scrollHeight; | ||||
|     } | ||||
|   }, [props.children]); | ||||
|  | ||||
|   const toggleCollapsed = () => { | ||||
|     setCollapsed((collapsed) => !collapsed); | ||||
|   }; | ||||
|   return ( | ||||
|     <> | ||||
|       <code | ||||
|         className={props?.className} | ||||
|         ref={ref} | ||||
|         style={{ | ||||
|           maxHeight: collapsed ? "400px" : "none", | ||||
|           overflowY: "hidden", | ||||
|         }} | ||||
|       > | ||||
|         {props.children} | ||||
|       </code> | ||||
|       {showToggle && collapsed && ( | ||||
|         <div | ||||
|           className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`} | ||||
|         > | ||||
|           <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button> | ||||
|         </div> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -218,45 +116,11 @@ function escapeDollarNumber(text: string) { | ||||
|   return escapedText; | ||||
| } | ||||
|  | ||||
| function escapeBrackets(text: string) { | ||||
|   const pattern = | ||||
|     /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g; | ||||
|   return text.replace( | ||||
|     pattern, | ||||
|     (match, codeBlock, squareBracket, roundBracket) => { | ||||
|       if (codeBlock) { | ||||
|         return codeBlock; | ||||
|       } else if (squareBracket) { | ||||
|         return `$$${squareBracket}$$`; | ||||
|       } else if (roundBracket) { | ||||
|         return `$${roundBracket}$`; | ||||
|       } | ||||
|       return match; | ||||
|     }, | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function tryWrapHtmlCode(text: string) { | ||||
|   // try add wrap html code (fixed: html codeblock include 2 newline) | ||||
|   return text | ||||
|     .replace( | ||||
|       /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g, | ||||
|       (match, quoteStart, lang, newLine, doctype) => { | ||||
|         return !quoteStart ? "\n```html\n" + doctype : match; | ||||
|       }, | ||||
|     ) | ||||
|     .replace( | ||||
|       /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*?)([`]*?)([\n\r]*?)/g, | ||||
|       (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => { | ||||
|         return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match; | ||||
|       }, | ||||
|     ); | ||||
| } | ||||
|  | ||||
| function _MarkDownContent(props: { content: string }) { | ||||
|   const escapedContent = useMemo(() => { | ||||
|     return tryWrapHtmlCode(escapeBrackets(escapeDollarNumber(props.content))); | ||||
|   }, [props.content]); | ||||
|   const escapedContent = useMemo( | ||||
|     () => escapeDollarNumber(props.content), | ||||
|     [props.content], | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <ReactMarkdown | ||||
| @@ -273,7 +137,6 @@ function _MarkDownContent(props: { content: string }) { | ||||
|       ]} | ||||
|       components={{ | ||||
|         pre: PreCode, | ||||
|         code: CustomCode, | ||||
|         p: (pProps) => <p {...pProps} dir="auto" />, | ||||
|         a: (aProps) => { | ||||
|           const href = aProps.href || ""; | ||||
| @@ -295,7 +158,6 @@ export function Markdown( | ||||
|     content: string; | ||||
|     loading?: boolean; | ||||
|     fontSize?: number; | ||||
|     fontFamily?: string; | ||||
|     parentRef?: RefObject<HTMLDivElement>; | ||||
|     defaultShow?: boolean; | ||||
|   } & React.DOMAttributes<HTMLDivElement>, | ||||
| @@ -307,7 +169,6 @@ export function Markdown( | ||||
|       className="markdown-body" | ||||
|       style={{ | ||||
|         fontSize: `${props.fontSize ?? 14}px`, | ||||
|         fontFamily: props.fontFamily || "inherit", | ||||
|       }} | ||||
|       ref={mdRef} | ||||
|       onContextMenu={props.onContextMenu} | ||||
|   | ||||
| @@ -37,7 +37,7 @@ import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
|  | ||||
| import chatStyle from "./chat.module.scss"; | ||||
| import { useState } from "react"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { | ||||
|   copyToClipboard, | ||||
|   downloadAs, | ||||
| @@ -48,6 +48,7 @@ import { Updater } from "../typing"; | ||||
| import { ModelConfigList } from "./model-config"; | ||||
| import { FileName, Path } from "../constant"; | ||||
| import { BUILTIN_MASK_STORE } from "../masks"; | ||||
| import { nanoid } from "nanoid"; | ||||
| import { | ||||
|   DragDropContext, | ||||
|   Droppable, | ||||
| @@ -126,8 +127,6 @@ export function MaskConfig(props: { | ||||
|             onClose={() => setShowPicker(false)} | ||||
|           > | ||||
|             <div | ||||
|               tabIndex={0} | ||||
|               aria-label={Locale.Mask.Config.Avatar} | ||||
|               onClick={() => setShowPicker(true)} | ||||
|               style={{ cursor: "pointer" }} | ||||
|             > | ||||
| @@ -140,7 +139,6 @@ export function MaskConfig(props: { | ||||
|         </ListItem> | ||||
|         <ListItem title={Locale.Mask.Config.Name}> | ||||
|           <input | ||||
|             aria-label={Locale.Mask.Config.Name} | ||||
|             type="text" | ||||
|             value={props.mask.name} | ||||
|             onInput={(e) => | ||||
| @@ -155,7 +153,6 @@ export function MaskConfig(props: { | ||||
|           subTitle={Locale.Mask.Config.HideContext.SubTitle} | ||||
|         > | ||||
|           <input | ||||
|             aria-label={Locale.Mask.Config.HideContext.Title} | ||||
|             type="checkbox" | ||||
|             checked={props.mask.hideContext} | ||||
|             onChange={(e) => { | ||||
| @@ -166,29 +163,12 @@ export function MaskConfig(props: { | ||||
|           ></input> | ||||
|         </ListItem> | ||||
|  | ||||
|         <ListItem | ||||
|           title={Locale.Mask.Config.Artifacts.Title} | ||||
|           subTitle={Locale.Mask.Config.Artifacts.SubTitle} | ||||
|         > | ||||
|           <input | ||||
|             aria-label={Locale.Mask.Config.Artifacts.Title} | ||||
|             type="checkbox" | ||||
|             checked={props.mask.enableArtifacts !== false} | ||||
|             onChange={(e) => { | ||||
|               props.updateMask((mask) => { | ||||
|                 mask.enableArtifacts = e.currentTarget.checked; | ||||
|               }); | ||||
|             }} | ||||
|           ></input> | ||||
|         </ListItem> | ||||
|  | ||||
|         {!props.shouldSyncFromGlobal ? ( | ||||
|           <ListItem | ||||
|             title={Locale.Mask.Config.Share.Title} | ||||
|             subTitle={Locale.Mask.Config.Share.SubTitle} | ||||
|           > | ||||
|             <IconButton | ||||
|               aria={Locale.Mask.Config.Share.Title} | ||||
|               icon={<CopyIcon />} | ||||
|               text={Locale.Mask.Config.Share.Action} | ||||
|               onClick={copyMaskLink} | ||||
| @@ -202,7 +182,6 @@ export function MaskConfig(props: { | ||||
|             subTitle={Locale.Mask.Config.Sync.SubTitle} | ||||
|           > | ||||
|             <input | ||||
|               aria-label={Locale.Mask.Config.Sync.Title} | ||||
|               type="checkbox" | ||||
|               checked={props.mask.syncGlobalConfig} | ||||
|               onChange={async (e) => { | ||||
| @@ -425,7 +404,7 @@ export function MaskPage() { | ||||
|   const maskStore = useMaskStore(); | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   const filterLang = maskStore.language; | ||||
|   const [filterLang, setFilterLang] = useState<Lang>(); | ||||
|  | ||||
|   const allMasks = maskStore | ||||
|     .getAll() | ||||
| @@ -532,9 +511,9 @@ export function MaskPage() { | ||||
|               onChange={(e) => { | ||||
|                 const value = e.currentTarget.value; | ||||
|                 if (value === Locale.Settings.Lang.All) { | ||||
|                   maskStore.setLanguage(undefined); | ||||
|                   setFilterLang(undefined); | ||||
|                 } else { | ||||
|                   maskStore.setLanguage(value as Lang); | ||||
|                   setFilterLang(value as Lang); | ||||
|                 } | ||||
|               }} | ||||
|             > | ||||
|   | ||||
| @@ -227,7 +227,7 @@ export function MessageSelector(props: { | ||||
|               </div> | ||||
|  | ||||
|               <div className={styles["checkbox"]}> | ||||
|                 <input type="checkbox" checked={isSelected} readOnly></input> | ||||
|                 <input type="checkbox" checked={isSelected}></input> | ||||
|               </div> | ||||
|             </div> | ||||
|           ); | ||||
|   | ||||
| @@ -1,7 +0,0 @@ | ||||
| .select-compress-model { | ||||
|   width: 60%; | ||||
|   select { | ||||
|     max-width: 100%; | ||||
|     white-space: normal; | ||||
|   } | ||||
| } | ||||
| @@ -1,49 +1,37 @@ | ||||
| import { ServiceProvider } from "@/app/constant"; | ||||
| import { ModalConfigValidator, ModelConfig } from "../store"; | ||||
|  | ||||
| import Locale from "../locales"; | ||||
| import { InputRange } from "./input-range"; | ||||
| import { ListItem, Select } from "./ui-lib"; | ||||
| import { useAllModels } from "../utils/hooks"; | ||||
| import { groupBy } from "lodash-es"; | ||||
| import styles from "./model-config.module.scss"; | ||||
|  | ||||
| export function ModelConfigList(props: { | ||||
|   modelConfig: ModelConfig; | ||||
|   updateConfig: (updater: (config: ModelConfig) => void) => void; | ||||
| }) { | ||||
|   const allModels = useAllModels(); | ||||
|   const groupModels = groupBy( | ||||
|     allModels.filter((v) => v.available), | ||||
|     "provider.providerName", | ||||
|   ); | ||||
|   const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`; | ||||
|   const compressModelValue = `${props.modelConfig.compressModel}@${props.modelConfig?.compressProviderName}`; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ListItem title={Locale.Settings.Model}> | ||||
|         <Select | ||||
|           aria-label={Locale.Settings.Model} | ||||
|           value={value} | ||||
|           align="left" | ||||
|           value={props.modelConfig.model} | ||||
|           onChange={(e) => { | ||||
|             const [model, providerName] = e.currentTarget.value.split("@"); | ||||
|             props.updateConfig((config) => { | ||||
|               config.model = ModalConfigValidator.model(model); | ||||
|               config.providerName = providerName as ServiceProvider; | ||||
|             }); | ||||
|             props.updateConfig( | ||||
|               (config) => | ||||
|                 (config.model = ModalConfigValidator.model( | ||||
|                   e.currentTarget.value, | ||||
|                 )), | ||||
|             ); | ||||
|           }} | ||||
|         > | ||||
|           {Object.keys(groupModels).map((providerName, index) => ( | ||||
|             <optgroup label={providerName} key={index}> | ||||
|               {groupModels[providerName].map((v, i) => ( | ||||
|                 <option value={`${v.name}@${v.provider?.providerName}`} key={i}> | ||||
|                   {v.displayName} | ||||
|           {allModels | ||||
|             .filter((v) => v.available) | ||||
|             .map((v, i) => ( | ||||
|               <option value={v.name} key={i}> | ||||
|                 {v.displayName}({v.provider?.providerName}) | ||||
|               </option> | ||||
|             ))} | ||||
|             </optgroup> | ||||
|           ))} | ||||
|         </Select> | ||||
|       </ListItem> | ||||
|       <ListItem | ||||
| @@ -51,7 +39,6 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.Temperature.SubTitle} | ||||
|       > | ||||
|         <InputRange | ||||
|           aria={Locale.Settings.Temperature.Title} | ||||
|           value={props.modelConfig.temperature?.toFixed(1)} | ||||
|           min="0" | ||||
|           max="1" // lets limit it to 0-1 | ||||
| @@ -71,7 +58,6 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.TopP.SubTitle} | ||||
|       > | ||||
|         <InputRange | ||||
|           aria={Locale.Settings.TopP.Title} | ||||
|           value={(props.modelConfig.top_p ?? 1).toFixed(1)} | ||||
|           min="0" | ||||
|           max="1" | ||||
| @@ -91,7 +77,6 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.MaxTokens.SubTitle} | ||||
|       > | ||||
|         <input | ||||
|           aria-label={Locale.Settings.MaxTokens.Title} | ||||
|           type="number" | ||||
|           min={1024} | ||||
|           max={512000} | ||||
| @@ -107,14 +92,13 @@ export function ModelConfigList(props: { | ||||
|         ></input> | ||||
|       </ListItem> | ||||
|  | ||||
|       {props.modelConfig?.providerName == ServiceProvider.Google ? null : ( | ||||
|       {props.modelConfig.model.startsWith("gemini") ? null : ( | ||||
|         <> | ||||
|           <ListItem | ||||
|             title={Locale.Settings.PresencePenalty.Title} | ||||
|             subTitle={Locale.Settings.PresencePenalty.SubTitle} | ||||
|           > | ||||
|             <InputRange | ||||
|               aria={Locale.Settings.PresencePenalty.Title} | ||||
|               value={props.modelConfig.presence_penalty?.toFixed(1)} | ||||
|               min="-2" | ||||
|               max="2" | ||||
| @@ -136,7 +120,6 @@ export function ModelConfigList(props: { | ||||
|             subTitle={Locale.Settings.FrequencyPenalty.SubTitle} | ||||
|           > | ||||
|             <InputRange | ||||
|               aria={Locale.Settings.FrequencyPenalty.Title} | ||||
|               value={props.modelConfig.frequency_penalty?.toFixed(1)} | ||||
|               min="-2" | ||||
|               max="2" | ||||
| @@ -158,7 +141,6 @@ export function ModelConfigList(props: { | ||||
|             subTitle={Locale.Settings.InjectSystemPrompts.SubTitle} | ||||
|           > | ||||
|             <input | ||||
|               aria-label={Locale.Settings.InjectSystemPrompts.Title} | ||||
|               type="checkbox" | ||||
|               checked={props.modelConfig.enableInjectSystemPrompts} | ||||
|               onChange={(e) => | ||||
| @@ -176,7 +158,6 @@ export function ModelConfigList(props: { | ||||
|             subTitle={Locale.Settings.InputTemplate.SubTitle} | ||||
|           > | ||||
|             <input | ||||
|               aria-label={Locale.Settings.InputTemplate.Title} | ||||
|               type="text" | ||||
|               value={props.modelConfig.template} | ||||
|               onChange={(e) => | ||||
| @@ -193,7 +174,6 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.HistoryCount.SubTitle} | ||||
|       > | ||||
|         <InputRange | ||||
|           aria={Locale.Settings.HistoryCount.Title} | ||||
|           title={props.modelConfig.historyMessageCount.toString()} | ||||
|           value={props.modelConfig.historyMessageCount} | ||||
|           min="0" | ||||
| @@ -212,7 +192,6 @@ export function ModelConfigList(props: { | ||||
|         subTitle={Locale.Settings.CompressThreshold.SubTitle} | ||||
|       > | ||||
|         <input | ||||
|           aria-label={Locale.Settings.CompressThreshold.Title} | ||||
|           type="number" | ||||
|           min={500} | ||||
|           max={4000} | ||||
| @@ -228,7 +207,6 @@ export function ModelConfigList(props: { | ||||
|       </ListItem> | ||||
|       <ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}> | ||||
|         <input | ||||
|           aria-label={Locale.Memory.Title} | ||||
|           type="checkbox" | ||||
|           checked={props.modelConfig.sendMemory} | ||||
|           onChange={(e) => | ||||
| @@ -238,31 +216,6 @@ export function ModelConfigList(props: { | ||||
|           } | ||||
|         ></input> | ||||
|       </ListItem> | ||||
|       <ListItem | ||||
|         title={Locale.Settings.CompressModel.Title} | ||||
|         subTitle={Locale.Settings.CompressModel.SubTitle} | ||||
|       > | ||||
|         <Select | ||||
|           className={styles["select-compress-model"]} | ||||
|           aria-label={Locale.Settings.CompressModel.Title} | ||||
|           value={compressModelValue} | ||||
|           onChange={(e) => { | ||||
|             const [model, providerName] = e.currentTarget.value.split("@"); | ||||
|             props.updateConfig((config) => { | ||||
|               config.compressModel = ModalConfigValidator.model(model); | ||||
|               config.compressProviderName = providerName as ServiceProvider; | ||||
|             }); | ||||
|           }} | ||||
|         > | ||||
|           {allModels | ||||
|             .filter((v) => v.available) | ||||
|             .map((v, i) => ( | ||||
|               <option value={`${v.name}@${v.provider?.providerName}`} key={i}> | ||||
|                 {v.displayName}({v.provider?.providerName}) | ||||
|               </option> | ||||
|             ))} | ||||
|         </Select> | ||||
|       </ListItem> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,16 +0,0 @@ | ||||
| .plugin-title { | ||||
|   font-weight: bolder; | ||||
|   font-size: 16px; | ||||
|   margin: 10px 0; | ||||
| } | ||||
| .plugin-content { | ||||
|   font-size: 14px; | ||||
|   font-family: inherit; | ||||
|   pre code { | ||||
|     max-height: 240px; | ||||
|     overflow-y: auto; | ||||
|     white-space: pre-wrap; | ||||
|     min-width: 300px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -1,393 +0,0 @@ | ||||
| import { useDebouncedCallback } from "use-debounce"; | ||||
| import OpenAPIClientAxios from "openapi-client-axios"; | ||||
| import yaml from "js-yaml"; | ||||
| import { PLUGINS_REPO_URL } from "../constant"; | ||||
| import { IconButton } from "./button"; | ||||
| import { ErrorBoundary } from "./error"; | ||||
|  | ||||
| import styles from "./mask.module.scss"; | ||||
| import pluginStyles from "./plugin.module.scss"; | ||||
|  | ||||
| import EditIcon from "../icons/edit.svg"; | ||||
| import AddIcon from "../icons/add.svg"; | ||||
| import CloseIcon from "../icons/close.svg"; | ||||
| import DeleteIcon from "../icons/delete.svg"; | ||||
| import EyeIcon from "../icons/eye.svg"; | ||||
| import ConfirmIcon from "../icons/confirm.svg"; | ||||
| import ReloadIcon from "../icons/reload.svg"; | ||||
| import GithubIcon from "../icons/github.svg"; | ||||
|  | ||||
| import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin"; | ||||
| import { | ||||
|   PasswordInput, | ||||
|   List, | ||||
|   ListItem, | ||||
|   Modal, | ||||
|   showConfirm, | ||||
|   showToast, | ||||
| } from "./ui-lib"; | ||||
| import Locale from "../locales"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { useState } from "react"; | ||||
| import { getClientConfig } from "../config/client"; | ||||
|  | ||||
| export function PluginPage() { | ||||
|   const navigate = useNavigate(); | ||||
|   const pluginStore = usePluginStore(); | ||||
|  | ||||
|   const allPlugins = pluginStore.getAll(); | ||||
|   const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]); | ||||
|   const [searchText, setSearchText] = useState(""); | ||||
|   const plugins = searchText.length > 0 ? searchPlugins : allPlugins; | ||||
|  | ||||
|   // refactored already, now it accurate | ||||
|   const onSearch = (text: string) => { | ||||
|     setSearchText(text); | ||||
|     if (text.length > 0) { | ||||
|       const result = allPlugins.filter( | ||||
|         (m) => m?.title.toLowerCase().includes(text.toLowerCase()), | ||||
|       ); | ||||
|       setSearchPlugins(result); | ||||
|     } else { | ||||
|       setSearchPlugins(allPlugins); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const [editingPluginId, setEditingPluginId] = useState<string | undefined>(); | ||||
|   const editingPlugin = pluginStore.get(editingPluginId); | ||||
|   const editingPluginTool = FunctionToolService.get(editingPlugin?.id); | ||||
|   const closePluginModal = () => setEditingPluginId(undefined); | ||||
|  | ||||
|   const onChangePlugin = useDebouncedCallback((editingPlugin, e) => { | ||||
|     const content = e.target.innerText; | ||||
|     try { | ||||
|       const api = new OpenAPIClientAxios({ | ||||
|         definition: yaml.load(content) as any, | ||||
|       }); | ||||
|       api | ||||
|         .init() | ||||
|         .then(() => { | ||||
|           if (content != editingPlugin.content) { | ||||
|             pluginStore.updatePlugin(editingPlugin.id, (plugin) => { | ||||
|               plugin.content = content; | ||||
|               const tool = FunctionToolService.add(plugin, true); | ||||
|               plugin.title = tool.api.definition.info.title; | ||||
|               plugin.version = tool.api.definition.info.version; | ||||
|             }); | ||||
|           } | ||||
|         }) | ||||
|         .catch((e) => { | ||||
|           console.error(e); | ||||
|           showToast(Locale.Plugin.EditModal.Error); | ||||
|         }); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       showToast(Locale.Plugin.EditModal.Error); | ||||
|     } | ||||
|   }, 100).bind(null, editingPlugin); | ||||
|  | ||||
|   const [loadUrl, setLoadUrl] = useState<string>(""); | ||||
|   const loadFromUrl = (loadUrl: string) => | ||||
|     fetch(loadUrl) | ||||
|       .catch((e) => { | ||||
|         const p = new URL(loadUrl); | ||||
|         return fetch(`/api/proxy/${p.pathname}?${p.search}`, { | ||||
|           headers: { | ||||
|             "X-Base-URL": p.origin, | ||||
|           }, | ||||
|         }); | ||||
|       }) | ||||
|       .then((res) => res.text()) | ||||
|       .then((content) => { | ||||
|         try { | ||||
|           return JSON.stringify(JSON.parse(content), null, "  "); | ||||
|         } catch (e) { | ||||
|           return content; | ||||
|         } | ||||
|       }) | ||||
|       .then((content) => { | ||||
|         pluginStore.updatePlugin(editingPlugin.id, (plugin) => { | ||||
|           plugin.content = content; | ||||
|           const tool = FunctionToolService.add(plugin, true); | ||||
|           plugin.title = tool.api.definition.info.title; | ||||
|           plugin.version = tool.api.definition.info.version; | ||||
|         }); | ||||
|       }) | ||||
|       .catch((e) => { | ||||
|         showToast(Locale.Plugin.EditModal.Error); | ||||
|       }); | ||||
|  | ||||
|   return ( | ||||
|     <ErrorBoundary> | ||||
|       <div className={styles["mask-page"]}> | ||||
|         <div className="window-header"> | ||||
|           <div className="window-header-title"> | ||||
|             <div className="window-header-main-title"> | ||||
|               {Locale.Plugin.Page.Title} | ||||
|             </div> | ||||
|             <div className="window-header-submai-title"> | ||||
|               {Locale.Plugin.Page.SubTitle(plugins.length)} | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <div className="window-actions"> | ||||
|             <div className="window-action-button"> | ||||
|               <a | ||||
|                 href={PLUGINS_REPO_URL} | ||||
|                 target="_blank" | ||||
|                 rel="noopener noreferrer" | ||||
|               > | ||||
|                 <IconButton icon={<GithubIcon />} bordered /> | ||||
|               </a> | ||||
|             </div> | ||||
|             <div className="window-action-button"> | ||||
|               <IconButton | ||||
|                 icon={<CloseIcon />} | ||||
|                 bordered | ||||
|                 onClick={() => navigate(-1)} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div className={styles["mask-page-body"]}> | ||||
|           <div className={styles["mask-filter"]}> | ||||
|             <input | ||||
|               type="text" | ||||
|               className={styles["search-bar"]} | ||||
|               placeholder={Locale.Plugin.Page.Search} | ||||
|               autoFocus | ||||
|               onInput={(e) => onSearch(e.currentTarget.value)} | ||||
|             /> | ||||
|  | ||||
|             <IconButton | ||||
|               className={styles["mask-create"]} | ||||
|               icon={<AddIcon />} | ||||
|               text={Locale.Plugin.Page.Create} | ||||
|               bordered | ||||
|               onClick={() => { | ||||
|                 const createdPlugin = pluginStore.create(); | ||||
|                 setEditingPluginId(createdPlugin.id); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|  | ||||
|           <div> | ||||
|             {plugins.length == 0 && ( | ||||
|               <div | ||||
|                 style={{ | ||||
|                   display: "flex", | ||||
|                   margin: "60px auto", | ||||
|                   alignItems: "center", | ||||
|                   justifyContent: "center", | ||||
|                 }} | ||||
|               > | ||||
|                 {Locale.Plugin.Page.Find} | ||||
|                 <a | ||||
|                   href={PLUGINS_REPO_URL} | ||||
|                   target="_blank" | ||||
|                   rel="noopener noreferrer" | ||||
|                   style={{ marginLeft: 16 }} | ||||
|                 > | ||||
|                   <IconButton icon={<GithubIcon />} bordered /> | ||||
|                 </a> | ||||
|               </div> | ||||
|             )} | ||||
|             {plugins.map((m) => ( | ||||
|               <div className={styles["mask-item"]} key={m.id}> | ||||
|                 <div className={styles["mask-header"]}> | ||||
|                   <div className={styles["mask-icon"]}></div> | ||||
|                   <div className={styles["mask-title"]}> | ||||
|                     <div className={styles["mask-name"]}> | ||||
|                       {m.title}@<small>{m.version}</small> | ||||
|                     </div> | ||||
|                     <div className={styles["mask-info"] + " one-line"}> | ||||
|                       {Locale.Plugin.Item.Info( | ||||
|                         FunctionToolService.add(m).length, | ||||
|                       )} | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <div className={styles["mask-actions"]}> | ||||
|                   {m.builtin ? ( | ||||
|                     <IconButton | ||||
|                       icon={<EyeIcon />} | ||||
|                       text={Locale.Plugin.Item.View} | ||||
|                       onClick={() => setEditingPluginId(m.id)} | ||||
|                     /> | ||||
|                   ) : ( | ||||
|                     <IconButton | ||||
|                       icon={<EditIcon />} | ||||
|                       text={Locale.Plugin.Item.Edit} | ||||
|                       onClick={() => setEditingPluginId(m.id)} | ||||
|                     /> | ||||
|                   )} | ||||
|                   {!m.builtin && ( | ||||
|                     <IconButton | ||||
|                       icon={<DeleteIcon />} | ||||
|                       text={Locale.Plugin.Item.Delete} | ||||
|                       onClick={async () => { | ||||
|                         if ( | ||||
|                           await showConfirm(Locale.Plugin.Item.DeleteConfirm) | ||||
|                         ) { | ||||
|                           pluginStore.delete(m.id); | ||||
|                         } | ||||
|                       }} | ||||
|                     /> | ||||
|                   )} | ||||
|                 </div> | ||||
|               </div> | ||||
|             ))} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {editingPlugin && ( | ||||
|         <div className="modal-mask"> | ||||
|           <Modal | ||||
|             title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)} | ||||
|             onClose={closePluginModal} | ||||
|             actions={[ | ||||
|               <IconButton | ||||
|                 icon={<ConfirmIcon />} | ||||
|                 text={Locale.UI.Confirm} | ||||
|                 key="export" | ||||
|                 bordered | ||||
|                 onClick={() => setEditingPluginId("")} | ||||
|               />, | ||||
|             ]} | ||||
|           > | ||||
|             <List> | ||||
|               <ListItem title={Locale.Plugin.EditModal.Auth}> | ||||
|                 <select | ||||
|                   value={editingPlugin?.authType} | ||||
|                   onChange={(e) => { | ||||
|                     pluginStore.updatePlugin(editingPlugin.id, (plugin) => { | ||||
|                       plugin.authType = e.target.value; | ||||
|                     }); | ||||
|                   }} | ||||
|                 > | ||||
|                   <option value="">{Locale.Plugin.Auth.None}</option> | ||||
|                   <option value="bearer">{Locale.Plugin.Auth.Bearer}</option> | ||||
|                   <option value="basic">{Locale.Plugin.Auth.Basic}</option> | ||||
|                   <option value="custom">{Locale.Plugin.Auth.Custom}</option> | ||||
|                 </select> | ||||
|               </ListItem> | ||||
|               {["bearer", "basic", "custom"].includes( | ||||
|                 editingPlugin.authType as string, | ||||
|               ) && ( | ||||
|                 <ListItem title={Locale.Plugin.Auth.Location}> | ||||
|                   <select | ||||
|                     value={editingPlugin?.authLocation} | ||||
|                     onChange={(e) => { | ||||
|                       pluginStore.updatePlugin(editingPlugin.id, (plugin) => { | ||||
|                         plugin.authLocation = e.target.value; | ||||
|                       }); | ||||
|                     }} | ||||
|                   > | ||||
|                     <option value="header"> | ||||
|                       {Locale.Plugin.Auth.LocationHeader} | ||||
|                     </option> | ||||
|                     <option value="query"> | ||||
|                       {Locale.Plugin.Auth.LocationQuery} | ||||
|                     </option> | ||||
|                     <option value="body"> | ||||
|                       {Locale.Plugin.Auth.LocationBody} | ||||
|                     </option> | ||||
|                   </select> | ||||
|                 </ListItem> | ||||
|               )} | ||||
|               {editingPlugin.authType == "custom" && ( | ||||
|                 <ListItem title={Locale.Plugin.Auth.CustomHeader}> | ||||
|                   <input | ||||
|                     type="text" | ||||
|                     value={editingPlugin?.authHeader} | ||||
|                     onChange={(e) => { | ||||
|                       pluginStore.updatePlugin(editingPlugin.id, (plugin) => { | ||||
|                         plugin.authHeader = e.target.value; | ||||
|                       }); | ||||
|                     }} | ||||
|                   ></input> | ||||
|                 </ListItem> | ||||
|               )} | ||||
|               {["bearer", "basic", "custom"].includes( | ||||
|                 editingPlugin.authType as string, | ||||
|               ) && ( | ||||
|                 <ListItem title={Locale.Plugin.Auth.Token}> | ||||
|                   <PasswordInput | ||||
|                     type="text" | ||||
|                     value={editingPlugin?.authToken} | ||||
|                     onChange={(e) => { | ||||
|                       pluginStore.updatePlugin(editingPlugin.id, (plugin) => { | ||||
|                         plugin.authToken = e.currentTarget.value; | ||||
|                       }); | ||||
|                     }} | ||||
|                   ></PasswordInput> | ||||
|                 </ListItem> | ||||
|               )} | ||||
|               {!getClientConfig()?.isApp && ( | ||||
|                 <ListItem | ||||
|                   title={Locale.Plugin.Auth.Proxy} | ||||
|                   subTitle={Locale.Plugin.Auth.ProxyDescription} | ||||
|                 > | ||||
|                   <input | ||||
|                     type="checkbox" | ||||
|                     checked={editingPlugin?.usingProxy} | ||||
|                     style={{ minWidth: 16 }} | ||||
|                     onChange={(e) => { | ||||
|                       pluginStore.updatePlugin(editingPlugin.id, (plugin) => { | ||||
|                         plugin.usingProxy = e.currentTarget.checked; | ||||
|                       }); | ||||
|                     }} | ||||
|                   ></input> | ||||
|                 </ListItem> | ||||
|               )} | ||||
|             </List> | ||||
|             <List> | ||||
|               <ListItem title={Locale.Plugin.EditModal.Content}> | ||||
|                 <div style={{ display: "flex", justifyContent: "flex-end" }}> | ||||
|                   <input | ||||
|                     type="text" | ||||
|                     style={{ minWidth: 200, marginRight: 20 }} | ||||
|                     onInput={(e) => setLoadUrl(e.currentTarget.value)} | ||||
|                   ></input> | ||||
|                   <IconButton | ||||
|                     icon={<ReloadIcon />} | ||||
|                     text={Locale.Plugin.EditModal.Load} | ||||
|                     bordered | ||||
|                     onClick={() => loadFromUrl(loadUrl)} | ||||
|                   /> | ||||
|                 </div> | ||||
|               </ListItem> | ||||
|               <ListItem | ||||
|                 subTitle={ | ||||
|                   <div | ||||
|                     className={`markdown-body ${pluginStyles["plugin-content"]}`} | ||||
|                     dir="auto" | ||||
|                   > | ||||
|                     <pre> | ||||
|                       <code | ||||
|                         contentEditable={true} | ||||
|                         dangerouslySetInnerHTML={{ | ||||
|                           __html: editingPlugin.content, | ||||
|                         }} | ||||
|                         onBlur={onChangePlugin} | ||||
|                       ></code> | ||||
|                     </pre> | ||||
|                   </div> | ||||
|                 } | ||||
|               ></ListItem> | ||||
|               {editingPluginTool?.tools.map((tool, index) => ( | ||||
|                 <ListItem | ||||
|                   key={index} | ||||
|                   title={tool?.function?.name} | ||||
|                   subTitle={tool?.function?.description} | ||||
|                 /> | ||||
|               ))} | ||||
|             </List> | ||||
|           </Modal> | ||||
|         </div> | ||||
|       )} | ||||
|     </ErrorBoundary> | ||||
|   ); | ||||
| } | ||||
| @@ -1,2 +0,0 @@ | ||||
| export * from "./sd"; | ||||
| export * from "./sd-panel"; | ||||
| @@ -1,45 +0,0 @@ | ||||
| .ctrl-param-item { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   min-height: 40px; | ||||
|   padding: 10px 0; | ||||
|   animation: slide-in ease 0.6s; | ||||
|   flex-direction: column; | ||||
|  | ||||
|   .ctrl-param-item-header { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|  | ||||
|     .ctrl-param-item-title { | ||||
|       font-size: 14px; | ||||
|       font-weight: bolder; | ||||
|       margin-bottom: 5px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .ctrl-param-item-sub-title { | ||||
|     font-size: 12px; | ||||
|     font-weight: normal; | ||||
|     margin-top: 3px; | ||||
|   } | ||||
|   textarea { | ||||
|     appearance: none; | ||||
|     border-radius: 10px; | ||||
|     border: var(--border-in-light); | ||||
|     min-height: 36px; | ||||
|     box-sizing: border-box; | ||||
|     background: var(--white); | ||||
|     color: var(--black); | ||||
|     padding: 0 10px; | ||||
|     max-width: 50%; | ||||
|     font-family: inherit; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .ai-models { | ||||
|   button { | ||||
|     margin-bottom: 10px; | ||||
|     padding: 10px; | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| @@ -1,320 +0,0 @@ | ||||
| import styles from "./sd-panel.module.scss"; | ||||
| import React from "react"; | ||||
| import { Select } from "@/app/components/ui-lib"; | ||||
| import { IconButton } from "@/app/components/button"; | ||||
| import Locale from "@/app/locales"; | ||||
| import { useSdStore } from "@/app/store/sd"; | ||||
|  | ||||
| export const params = [ | ||||
|   { | ||||
|     name: Locale.SdPanel.Prompt, | ||||
|     value: "prompt", | ||||
|     type: "textarea", | ||||
|     placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.Prompt), | ||||
|     required: true, | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.ModelVersion, | ||||
|     value: "model", | ||||
|     type: "select", | ||||
|     default: "sd3-medium", | ||||
|     support: ["sd3"], | ||||
|     options: [ | ||||
|       { name: "SD3 Medium", value: "sd3-medium" }, | ||||
|       { name: "SD3 Large", value: "sd3-large" }, | ||||
|       { name: "SD3 Large Turbo", value: "sd3-large-turbo" }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.NegativePrompt, | ||||
|     value: "negative_prompt", | ||||
|     type: "textarea", | ||||
|     placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.NegativePrompt), | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.AspectRatio, | ||||
|     value: "aspect_ratio", | ||||
|     type: "select", | ||||
|     default: "1:1", | ||||
|     options: [ | ||||
|       { name: "1:1", value: "1:1" }, | ||||
|       { name: "16:9", value: "16:9" }, | ||||
|       { name: "21:9", value: "21:9" }, | ||||
|       { name: "2:3", value: "2:3" }, | ||||
|       { name: "3:2", value: "3:2" }, | ||||
|       { name: "4:5", value: "4:5" }, | ||||
|       { name: "5:4", value: "5:4" }, | ||||
|       { name: "9:16", value: "9:16" }, | ||||
|       { name: "9:21", value: "9:21" }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.ImageStyle, | ||||
|     value: "style", | ||||
|     type: "select", | ||||
|     default: "3d-model", | ||||
|     support: ["core"], | ||||
|     options: [ | ||||
|       { name: Locale.SdPanel.Styles.D3Model, value: "3d-model" }, | ||||
|       { name: Locale.SdPanel.Styles.AnalogFilm, value: "analog-film" }, | ||||
|       { name: Locale.SdPanel.Styles.Anime, value: "anime" }, | ||||
|       { name: Locale.SdPanel.Styles.Cinematic, value: "cinematic" }, | ||||
|       { name: Locale.SdPanel.Styles.ComicBook, value: "comic-book" }, | ||||
|       { name: Locale.SdPanel.Styles.DigitalArt, value: "digital-art" }, | ||||
|       { name: Locale.SdPanel.Styles.Enhance, value: "enhance" }, | ||||
|       { name: Locale.SdPanel.Styles.FantasyArt, value: "fantasy-art" }, | ||||
|       { name: Locale.SdPanel.Styles.Isometric, value: "isometric" }, | ||||
|       { name: Locale.SdPanel.Styles.LineArt, value: "line-art" }, | ||||
|       { name: Locale.SdPanel.Styles.LowPoly, value: "low-poly" }, | ||||
|       { | ||||
|         name: Locale.SdPanel.Styles.ModelingCompound, | ||||
|         value: "modeling-compound", | ||||
|       }, | ||||
|       { name: Locale.SdPanel.Styles.NeonPunk, value: "neon-punk" }, | ||||
|       { name: Locale.SdPanel.Styles.Origami, value: "origami" }, | ||||
|       { name: Locale.SdPanel.Styles.Photographic, value: "photographic" }, | ||||
|       { name: Locale.SdPanel.Styles.PixelArt, value: "pixel-art" }, | ||||
|       { name: Locale.SdPanel.Styles.TileTexture, value: "tile-texture" }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     name: "Seed", | ||||
|     value: "seed", | ||||
|     type: "number", | ||||
|     default: 0, | ||||
|     min: 0, | ||||
|     max: 4294967294, | ||||
|   }, | ||||
|   { | ||||
|     name: Locale.SdPanel.OutFormat, | ||||
|     value: "output_format", | ||||
|     type: "select", | ||||
|     default: "png", | ||||
|     options: [ | ||||
|       { name: "PNG", value: "png" }, | ||||
|       { name: "JPEG", value: "jpeg" }, | ||||
|       { name: "WebP", value: "webp" }, | ||||
|     ], | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| const sdCommonParams = (model: string, data: any) => { | ||||
|   return params.filter((item) => { | ||||
|     return !(item.support && !item.support.includes(model)); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const models = [ | ||||
|   { | ||||
|     name: "Stable Image Ultra", | ||||
|     value: "ultra", | ||||
|     params: (data: any) => sdCommonParams("ultra", data), | ||||
|   }, | ||||
|   { | ||||
|     name: "Stable Image Core", | ||||
|     value: "core", | ||||
|     params: (data: any) => sdCommonParams("core", data), | ||||
|   }, | ||||
|   { | ||||
|     name: "Stable Diffusion 3", | ||||
|     value: "sd3", | ||||
|     params: (data: any) => { | ||||
|       return sdCommonParams("sd3", data).filter((item) => { | ||||
|         return !( | ||||
|           data.model === "sd3-large-turbo" && item.value == "negative_prompt" | ||||
|         ); | ||||
|       }); | ||||
|     }, | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| export function ControlParamItem(props: { | ||||
|   title: string; | ||||
|   subTitle?: string; | ||||
|   required?: boolean; | ||||
|   children?: JSX.Element | JSX.Element[]; | ||||
|   className?: string; | ||||
| }) { | ||||
|   return ( | ||||
|     <div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}> | ||||
|       <div className={styles["ctrl-param-item-header"]}> | ||||
|         <div className={styles["ctrl-param-item-title"]}> | ||||
|           <div> | ||||
|             {props.title} | ||||
|             {props.required && <span style={{ color: "red" }}>*</span>} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       {props.children} | ||||
|       {props.subTitle && ( | ||||
|         <div className={styles["ctrl-param-item-sub-title"]}> | ||||
|           {props.subTitle} | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function ControlParam(props: { | ||||
|   columns: any[]; | ||||
|   data: any; | ||||
|   onChange: (field: string, val: any) => void; | ||||
| }) { | ||||
|   return ( | ||||
|     <> | ||||
|       {props.columns?.map((item) => { | ||||
|         let element: null | JSX.Element; | ||||
|         switch (item.type) { | ||||
|           case "textarea": | ||||
|             element = ( | ||||
|               <ControlParamItem | ||||
|                 title={item.name} | ||||
|                 subTitle={item.sub} | ||||
|                 required={item.required} | ||||
|               > | ||||
|                 <textarea | ||||
|                   rows={item.rows || 3} | ||||
|                   style={{ maxWidth: "100%", width: "100%", padding: "10px" }} | ||||
|                   placeholder={item.placeholder} | ||||
|                   onChange={(e) => { | ||||
|                     props.onChange(item.value, e.currentTarget.value); | ||||
|                   }} | ||||
|                   value={props.data[item.value]} | ||||
|                 ></textarea> | ||||
|               </ControlParamItem> | ||||
|             ); | ||||
|             break; | ||||
|           case "select": | ||||
|             element = ( | ||||
|               <ControlParamItem | ||||
|                 title={item.name} | ||||
|                 subTitle={item.sub} | ||||
|                 required={item.required} | ||||
|               > | ||||
|                 <Select | ||||
|                   aria-label={item.name} | ||||
|                   value={props.data[item.value]} | ||||
|                   onChange={(e) => { | ||||
|                     props.onChange(item.value, e.currentTarget.value); | ||||
|                   }} | ||||
|                 > | ||||
|                   {item.options.map((opt: any) => { | ||||
|                     return ( | ||||
|                       <option value={opt.value} key={opt.value}> | ||||
|                         {opt.name} | ||||
|                       </option> | ||||
|                     ); | ||||
|                   })} | ||||
|                 </Select> | ||||
|               </ControlParamItem> | ||||
|             ); | ||||
|             break; | ||||
|           case "number": | ||||
|             element = ( | ||||
|               <ControlParamItem | ||||
|                 title={item.name} | ||||
|                 subTitle={item.sub} | ||||
|                 required={item.required} | ||||
|               > | ||||
|                 <input | ||||
|                   aria-label={item.name} | ||||
|                   type="number" | ||||
|                   min={item.min} | ||||
|                   max={item.max} | ||||
|                   value={props.data[item.value] || 0} | ||||
|                   onChange={(e) => { | ||||
|                     props.onChange(item.value, parseInt(e.currentTarget.value)); | ||||
|                   }} | ||||
|                 /> | ||||
|               </ControlParamItem> | ||||
|             ); | ||||
|             break; | ||||
|           default: | ||||
|             element = ( | ||||
|               <ControlParamItem | ||||
|                 title={item.name} | ||||
|                 subTitle={item.sub} | ||||
|                 required={item.required} | ||||
|               > | ||||
|                 <input | ||||
|                   aria-label={item.name} | ||||
|                   type="text" | ||||
|                   value={props.data[item.value]} | ||||
|                   style={{ maxWidth: "100%", width: "100%" }} | ||||
|                   onChange={(e) => { | ||||
|                     props.onChange(item.value, e.currentTarget.value); | ||||
|                   }} | ||||
|                 /> | ||||
|               </ControlParamItem> | ||||
|             ); | ||||
|         } | ||||
|         return <div key={item.value}>{element}</div>; | ||||
|       })} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export const getModelParamBasicData = ( | ||||
|   columns: any[], | ||||
|   data: any, | ||||
|   clearText?: boolean, | ||||
| ) => { | ||||
|   const newParams: any = {}; | ||||
|   columns.forEach((item: any) => { | ||||
|     if (clearText && ["text", "textarea", "number"].includes(item.type)) { | ||||
|       newParams[item.value] = item.default || ""; | ||||
|     } else { | ||||
|       // @ts-ignore | ||||
|       newParams[item.value] = data[item.value] || item.default || ""; | ||||
|     } | ||||
|   }); | ||||
|   return newParams; | ||||
| }; | ||||
|  | ||||
| export const getParams = (model: any, params: any) => { | ||||
|   return models.find((m) => m.value === model.value)?.params(params) || []; | ||||
| }; | ||||
|  | ||||
| export function SdPanel() { | ||||
|   const sdStore = useSdStore(); | ||||
|   const currentModel = sdStore.currentModel; | ||||
|   const setCurrentModel = sdStore.setCurrentModel; | ||||
|   const params = sdStore.currentParams; | ||||
|   const setParams = sdStore.setCurrentParams; | ||||
|  | ||||
|   const handleValueChange = (field: string, val: any) => { | ||||
|     setParams({ | ||||
|       ...params, | ||||
|       [field]: val, | ||||
|     }); | ||||
|   }; | ||||
|   const handleModelChange = (model: any) => { | ||||
|     setCurrentModel(model); | ||||
|     setParams(getModelParamBasicData(model.params({}), params)); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ControlParamItem title={Locale.SdPanel.AIModel}> | ||||
|         <div className={styles["ai-models"]}> | ||||
|           {models.map((item) => { | ||||
|             return ( | ||||
|               <IconButton | ||||
|                 text={item.name} | ||||
|                 key={item.value} | ||||
|                 type={currentModel.value == item.value ? "primary" : null} | ||||
|                 shadow | ||||
|                 onClick={() => handleModelChange(item)} | ||||
|               /> | ||||
|             ); | ||||
|           })} | ||||
|         </div> | ||||
|       </ControlParamItem> | ||||
|       <ControlParam | ||||
|         columns={getParams?.(currentModel, params) as any[]} | ||||
|         data={params} | ||||
|         onChange={handleValueChange} | ||||
|       ></ControlParam> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,140 +0,0 @@ | ||||
| import { IconButton } from "@/app/components/button"; | ||||
| import GithubIcon from "@/app/icons/github.svg"; | ||||
| import SDIcon from "@/app/icons/sd.svg"; | ||||
| import ReturnIcon from "@/app/icons/return.svg"; | ||||
| import HistoryIcon from "@/app/icons/history.svg"; | ||||
| import Locale from "@/app/locales"; | ||||
|  | ||||
| import { Path, REPO_URL } from "@/app/constant"; | ||||
|  | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import dynamic from "next/dynamic"; | ||||
| import { | ||||
|   SideBarContainer, | ||||
|   SideBarBody, | ||||
|   SideBarHeader, | ||||
|   SideBarTail, | ||||
|   useDragSideBar, | ||||
|   useHotKey, | ||||
| } from "@/app/components/sidebar"; | ||||
|  | ||||
| import { getParams, getModelParamBasicData } from "./sd-panel"; | ||||
| import { useSdStore } from "@/app/store/sd"; | ||||
| import { showToast } from "@/app/components/ui-lib"; | ||||
| import { useMobileScreen } from "@/app/utils"; | ||||
|  | ||||
| const SdPanel = dynamic( | ||||
|   async () => (await import("@/app/components/sd")).SdPanel, | ||||
|   { | ||||
|     loading: () => null, | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| export function SideBar(props: { className?: string }) { | ||||
|   useHotKey(); | ||||
|   const isMobileScreen = useMobileScreen(); | ||||
|   const { onDragStart, shouldNarrow } = useDragSideBar(); | ||||
|   const navigate = useNavigate(); | ||||
|   const sdStore = useSdStore(); | ||||
|   const currentModel = sdStore.currentModel; | ||||
|   const params = sdStore.currentParams; | ||||
|   const setParams = sdStore.setCurrentParams; | ||||
|  | ||||
|   const handleSubmit = () => { | ||||
|     const columns = getParams?.(currentModel, params); | ||||
|     const reqParams: any = {}; | ||||
|     for (let i = 0; i < columns.length; i++) { | ||||
|       const item = columns[i]; | ||||
|       reqParams[item.value] = params[item.value] ?? null; | ||||
|       if (item.required) { | ||||
|         if (!reqParams[item.value]) { | ||||
|           showToast(Locale.SdPanel.ParamIsRequired(item.name)); | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     let data: any = { | ||||
|       model: currentModel.value, | ||||
|       model_name: currentModel.name, | ||||
|       status: "wait", | ||||
|       params: reqParams, | ||||
|       created_at: new Date().toLocaleString(), | ||||
|       img_data: "", | ||||
|     }; | ||||
|     sdStore.sendTask(data, () => { | ||||
|       setParams(getModelParamBasicData(columns, params, true)); | ||||
|       navigate(Path.SdNew); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <SideBarContainer | ||||
|       onDragStart={onDragStart} | ||||
|       shouldNarrow={shouldNarrow} | ||||
|       {...props} | ||||
|     > | ||||
|       {isMobileScreen ? ( | ||||
|         <div | ||||
|           className="window-header" | ||||
|           data-tauri-drag-region | ||||
|           style={{ | ||||
|             paddingLeft: 0, | ||||
|             paddingRight: 0, | ||||
|           }} | ||||
|         > | ||||
|           <div className="window-actions"> | ||||
|             <div className="window-action-button"> | ||||
|               <IconButton | ||||
|                 icon={<ReturnIcon />} | ||||
|                 bordered | ||||
|                 title={Locale.Sd.Actions.ReturnHome} | ||||
|                 onClick={() => navigate(Path.Home)} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <SDIcon width={50} height={50} /> | ||||
|           <div className="window-actions"> | ||||
|             <div className="window-action-button"> | ||||
|               <IconButton | ||||
|                 icon={<HistoryIcon />} | ||||
|                 bordered | ||||
|                 title={Locale.Sd.Actions.History} | ||||
|                 onClick={() => navigate(Path.SdNew)} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       ) : ( | ||||
|         <SideBarHeader | ||||
|           title={ | ||||
|             <IconButton | ||||
|               icon={<ReturnIcon />} | ||||
|               bordered | ||||
|               title={Locale.Sd.Actions.ReturnHome} | ||||
|               onClick={() => navigate(Path.Home)} | ||||
|             /> | ||||
|           } | ||||
|           logo={<SDIcon width={38} height={"100%"} />} | ||||
|         ></SideBarHeader> | ||||
|       )} | ||||
|       <SideBarBody> | ||||
|         <SdPanel /> | ||||
|       </SideBarBody> | ||||
|       <SideBarTail | ||||
|         primaryAction={ | ||||
|           <a href={REPO_URL} target="_blank" rel="noopener noreferrer"> | ||||
|             <IconButton icon={<GithubIcon />} shadow /> | ||||
|           </a> | ||||
|         } | ||||
|         secondaryAction={ | ||||
|           <IconButton | ||||
|             text={Locale.SdPanel.Submit} | ||||
|             type="primary" | ||||
|             shadow | ||||
|             onClick={handleSubmit} | ||||
|           ></IconButton> | ||||
|         } | ||||
|       /> | ||||
|     </SideBarContainer> | ||||
|   ); | ||||
| } | ||||
| @@ -1,53 +0,0 @@ | ||||
| .sd-img-list{ | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   justify-content: space-between; | ||||
|   .sd-img-item{ | ||||
|     width: 48%; | ||||
|     .sd-img-item-info{ | ||||
|       flex:1; | ||||
|       width: 100%; | ||||
|       overflow: hidden; | ||||
|       user-select: text; | ||||
|       p{ | ||||
|         margin: 6px; | ||||
|         font-size: 12px; | ||||
|       } | ||||
|       .line-1{ | ||||
|         overflow: hidden; | ||||
|         white-space: nowrap; | ||||
|         text-overflow: ellipsis; | ||||
|       } | ||||
|     } | ||||
|     .pre-img{ | ||||
|       display: flex; | ||||
|       width: 130px; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
|       background-color: var(--second); | ||||
|       border-radius: 10px; | ||||
|     } | ||||
|     .img{ | ||||
|       width: 130px; | ||||
|       height: 130px; | ||||
|       border-radius: 10px; | ||||
|       overflow: hidden; | ||||
|       cursor: pointer; | ||||
|       transition: all .3s; | ||||
|       &:hover{ | ||||
|         opacity: .7; | ||||
|       } | ||||
|     } | ||||
|     &:not(:last-child){ | ||||
|       margin-bottom: 20px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 600px) { | ||||
|   .sd-img-list{ | ||||
|     .sd-img-item{ | ||||
|       width: 100%; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,336 +0,0 @@ | ||||
| import chatStyles from "@/app/components/chat.module.scss"; | ||||
| import styles from "@/app/components/sd/sd.module.scss"; | ||||
| import homeStyles from "@/app/components/home.module.scss"; | ||||
|  | ||||
| import { IconButton } from "@/app/components/button"; | ||||
| import ReturnIcon from "@/app/icons/return.svg"; | ||||
| import Locale from "@/app/locales"; | ||||
| import { Path } from "@/app/constant"; | ||||
| import React, { useEffect, useMemo, useRef, useState } from "react"; | ||||
| import { | ||||
|   copyToClipboard, | ||||
|   getMessageTextContent, | ||||
|   useMobileScreen, | ||||
| } from "@/app/utils"; | ||||
| import { useNavigate, useLocation } from "react-router-dom"; | ||||
| import { useAppConfig } from "@/app/store"; | ||||
| import MinIcon from "@/app/icons/min.svg"; | ||||
| import MaxIcon from "@/app/icons/max.svg"; | ||||
| import { getClientConfig } from "@/app/config/client"; | ||||
| import { ChatAction } from "@/app/components/chat"; | ||||
| import DeleteIcon from "@/app/icons/clear.svg"; | ||||
| import CopyIcon from "@/app/icons/copy.svg"; | ||||
| import PromptIcon from "@/app/icons/prompt.svg"; | ||||
| import ResetIcon from "@/app/icons/reload.svg"; | ||||
| import { useSdStore } from "@/app/store/sd"; | ||||
| import LoadingIcon from "@/app/icons/three-dots.svg"; | ||||
| import ErrorIcon from "@/app/icons/delete.svg"; | ||||
| import SDIcon from "@/app/icons/sd.svg"; | ||||
| import { Property } from "csstype"; | ||||
| import { | ||||
|   showConfirm, | ||||
|   showImageModal, | ||||
|   showModal, | ||||
| } from "@/app/components/ui-lib"; | ||||
| import { removeImage } from "@/app/utils/chat"; | ||||
| import { SideBar } from "./sd-sidebar"; | ||||
| import { WindowContent } from "@/app/components/home"; | ||||
| import { params } from "./sd-panel"; | ||||
|  | ||||
| function getSdTaskStatus(item: any) { | ||||
|   let s: string; | ||||
|   let color: Property.Color | undefined = undefined; | ||||
|   switch (item.status) { | ||||
|     case "success": | ||||
|       s = Locale.Sd.Status.Success; | ||||
|       color = "green"; | ||||
|       break; | ||||
|     case "error": | ||||
|       s = Locale.Sd.Status.Error; | ||||
|       color = "red"; | ||||
|       break; | ||||
|     case "wait": | ||||
|       s = Locale.Sd.Status.Wait; | ||||
|       color = "yellow"; | ||||
|       break; | ||||
|     case "running": | ||||
|       s = Locale.Sd.Status.Running; | ||||
|       color = "blue"; | ||||
|       break; | ||||
|     default: | ||||
|       s = item.status.toUpperCase(); | ||||
|   } | ||||
|   return ( | ||||
|     <p className={styles["line-1"]} title={item.error} style={{ color: color }}> | ||||
|       <span> | ||||
|         {Locale.Sd.Status.Name}: {s} | ||||
|       </span> | ||||
|       {item.status === "error" && ( | ||||
|         <span | ||||
|           className="clickable" | ||||
|           onClick={() => { | ||||
|             showModal({ | ||||
|               title: Locale.Sd.Detail, | ||||
|               children: ( | ||||
|                 <div style={{ color: color, userSelect: "text" }}> | ||||
|                   {item.error} | ||||
|                 </div> | ||||
|               ), | ||||
|             }); | ||||
|           }} | ||||
|         > | ||||
|           - {item.error} | ||||
|         </span> | ||||
|       )} | ||||
|     </p> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function Sd() { | ||||
|   const isMobileScreen = useMobileScreen(); | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|   const clientConfig = useMemo(() => getClientConfig(), []); | ||||
|   const showMaxIcon = !isMobileScreen && !clientConfig?.isApp; | ||||
|   const config = useAppConfig(); | ||||
|   const scrollRef = useRef<HTMLDivElement>(null); | ||||
|   const sdStore = useSdStore(); | ||||
|   const [sdImages, setSdImages] = useState(sdStore.draw); | ||||
|   const isSd = location.pathname === Path.Sd; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setSdImages(sdStore.draw); | ||||
|   }, [sdStore.currentId]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <SideBar className={isSd ? homeStyles["sidebar-show"] : ""} /> | ||||
|       <WindowContent> | ||||
|         <div className={chatStyles.chat} key={"1"}> | ||||
|           <div className="window-header" data-tauri-drag-region> | ||||
|             {isMobileScreen && ( | ||||
|               <div className="window-actions"> | ||||
|                 <div className={"window-action-button"}> | ||||
|                   <IconButton | ||||
|                     icon={<ReturnIcon />} | ||||
|                     bordered | ||||
|                     title={Locale.Chat.Actions.ChatList} | ||||
|                     onClick={() => navigate(Path.Sd)} | ||||
|                   /> | ||||
|                 </div> | ||||
|               </div> | ||||
|             )} | ||||
|             <div | ||||
|               className={`window-header-title ${chatStyles["chat-body-title"]}`} | ||||
|             > | ||||
|               <div className={`window-header-main-title`}>Stability AI</div> | ||||
|               <div className="window-header-sub-title"> | ||||
|                 {Locale.Sd.SubTitle(sdImages.length || 0)} | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div className="window-actions"> | ||||
|               {showMaxIcon && ( | ||||
|                 <div className="window-action-button"> | ||||
|                   <IconButton | ||||
|                     aria={Locale.Chat.Actions.FullScreen} | ||||
|                     icon={config.tightBorder ? <MinIcon /> : <MaxIcon />} | ||||
|                     bordered | ||||
|                     onClick={() => { | ||||
|                       config.update( | ||||
|                         (config) => (config.tightBorder = !config.tightBorder), | ||||
|                       ); | ||||
|                     }} | ||||
|                   /> | ||||
|                 </div> | ||||
|               )} | ||||
|               {isMobileScreen && <SDIcon width={50} height={50} />} | ||||
|             </div> | ||||
|           </div> | ||||
|           <div className={chatStyles["chat-body"]} ref={scrollRef}> | ||||
|             <div className={styles["sd-img-list"]}> | ||||
|               {sdImages.length > 0 ? ( | ||||
|                 sdImages.map((item: any) => { | ||||
|                   return ( | ||||
|                     <div | ||||
|                       key={item.id} | ||||
|                       style={{ display: "flex" }} | ||||
|                       className={styles["sd-img-item"]} | ||||
|                     > | ||||
|                       {item.status === "success" ? ( | ||||
|                         <img | ||||
|                           className={styles["img"]} | ||||
|                           src={item.img_data} | ||||
|                           alt={item.id} | ||||
|                           onClick={(e) => | ||||
|                             showImageModal( | ||||
|                               item.img_data, | ||||
|                               true, | ||||
|                               isMobileScreen | ||||
|                                 ? { width: "100%", height: "fit-content" } | ||||
|                                 : { maxWidth: "100%", maxHeight: "100%" }, | ||||
|                               isMobileScreen | ||||
|                                 ? { width: "100%", height: "fit-content" } | ||||
|                                 : { width: "100%", height: "100%" }, | ||||
|                             ) | ||||
|                           } | ||||
|                         /> | ||||
|                       ) : item.status === "error" ? ( | ||||
|                         <div className={styles["pre-img"]}> | ||||
|                           <ErrorIcon /> | ||||
|                         </div> | ||||
|                       ) : ( | ||||
|                         <div className={styles["pre-img"]}> | ||||
|                           <LoadingIcon /> | ||||
|                         </div> | ||||
|                       )} | ||||
|                       <div | ||||
|                         style={{ marginLeft: "10px" }} | ||||
|                         className={styles["sd-img-item-info"]} | ||||
|                       > | ||||
|                         <p className={styles["line-1"]}> | ||||
|                           {Locale.SdPanel.Prompt}:{" "} | ||||
|                           <span | ||||
|                             className="clickable" | ||||
|                             title={item.params.prompt} | ||||
|                             onClick={() => { | ||||
|                               showModal({ | ||||
|                                 title: Locale.Sd.Detail, | ||||
|                                 children: ( | ||||
|                                   <div style={{ userSelect: "text" }}> | ||||
|                                     {item.params.prompt} | ||||
|                                   </div> | ||||
|                                 ), | ||||
|                               }); | ||||
|                             }} | ||||
|                           > | ||||
|                             {item.params.prompt} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p> | ||||
|                           {Locale.SdPanel.AIModel}: {item.model_name} | ||||
|                         </p> | ||||
|                         {getSdTaskStatus(item)} | ||||
|                         <p>{item.created_at}</p> | ||||
|                         <div className={chatStyles["chat-message-actions"]}> | ||||
|                           <div className={chatStyles["chat-input-actions"]}> | ||||
|                             <ChatAction | ||||
|                               text={Locale.Sd.Actions.Params} | ||||
|                               icon={<PromptIcon />} | ||||
|                               onClick={() => { | ||||
|                                 showModal({ | ||||
|                                   title: Locale.Sd.GenerateParams, | ||||
|                                   children: ( | ||||
|                                     <div style={{ userSelect: "text" }}> | ||||
|                                       {Object.keys(item.params).map((key) => { | ||||
|                                         let label = key; | ||||
|                                         let value = item.params[key]; | ||||
|                                         switch (label) { | ||||
|                                           case "prompt": | ||||
|                                             label = Locale.SdPanel.Prompt; | ||||
|                                             break; | ||||
|                                           case "negative_prompt": | ||||
|                                             label = | ||||
|                                               Locale.SdPanel.NegativePrompt; | ||||
|                                             break; | ||||
|                                           case "aspect_ratio": | ||||
|                                             label = Locale.SdPanel.AspectRatio; | ||||
|                                             break; | ||||
|                                           case "seed": | ||||
|                                             label = "Seed"; | ||||
|                                             value = value || 0; | ||||
|                                             break; | ||||
|                                           case "output_format": | ||||
|                                             label = Locale.SdPanel.OutFormat; | ||||
|                                             value = value?.toUpperCase(); | ||||
|                                             break; | ||||
|                                           case "style": | ||||
|                                             label = Locale.SdPanel.ImageStyle; | ||||
|                                             value = params | ||||
|                                               .find( | ||||
|                                                 (item) => | ||||
|                                                   item.value === "style", | ||||
|                                               ) | ||||
|                                               ?.options?.find( | ||||
|                                                 (item) => item.value === value, | ||||
|                                               )?.name; | ||||
|                                             break; | ||||
|                                           default: | ||||
|                                             break; | ||||
|                                         } | ||||
|  | ||||
|                                         return ( | ||||
|                                           <div | ||||
|                                             key={key} | ||||
|                                             style={{ margin: "10px" }} | ||||
|                                           > | ||||
|                                             <strong>{label}: </strong> | ||||
|                                             {value} | ||||
|                                           </div> | ||||
|                                         ); | ||||
|                                       })} | ||||
|                                     </div> | ||||
|                                   ), | ||||
|                                 }); | ||||
|                               }} | ||||
|                             /> | ||||
|                             <ChatAction | ||||
|                               text={Locale.Sd.Actions.Copy} | ||||
|                               icon={<CopyIcon />} | ||||
|                               onClick={() => | ||||
|                                 copyToClipboard( | ||||
|                                   getMessageTextContent({ | ||||
|                                     role: "user", | ||||
|                                     content: item.params.prompt, | ||||
|                                   }), | ||||
|                                 ) | ||||
|                               } | ||||
|                             /> | ||||
|                             <ChatAction | ||||
|                               text={Locale.Sd.Actions.Retry} | ||||
|                               icon={<ResetIcon />} | ||||
|                               onClick={() => { | ||||
|                                 const reqData = { | ||||
|                                   model: item.model, | ||||
|                                   model_name: item.model_name, | ||||
|                                   status: "wait", | ||||
|                                   params: { ...item.params }, | ||||
|                                   created_at: new Date().toLocaleString(), | ||||
|                                   img_data: "", | ||||
|                                 }; | ||||
|                                 sdStore.sendTask(reqData); | ||||
|                               }} | ||||
|                             /> | ||||
|                             <ChatAction | ||||
|                               text={Locale.Sd.Actions.Delete} | ||||
|                               icon={<DeleteIcon />} | ||||
|                               onClick={async () => { | ||||
|                                 if ( | ||||
|                                   await showConfirm(Locale.Sd.Danger.Delete) | ||||
|                                 ) { | ||||
|                                   // remove img_data + remove item in list | ||||
|                                   removeImage(item.img_data).finally(() => { | ||||
|                                     sdStore.draw = sdImages.filter( | ||||
|                                       (i: any) => i.id !== item.id, | ||||
|                                     ); | ||||
|                                     sdStore.getNextId(); | ||||
|                                   }); | ||||
|                                 } | ||||
|                               }} | ||||
|                             /> | ||||
|                           </div> | ||||
|                         </div> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   ); | ||||
|                 }) | ||||
|               ) : ( | ||||
|                 <div>{Locale.Sd.EmptyRecord}</div> | ||||
|               )} | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </WindowContent> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,167 +0,0 @@ | ||||
| import { useState, useEffect, useRef, useCallback } from "react"; | ||||
| import { ErrorBoundary } from "./error"; | ||||
| import styles from "./mask.module.scss"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { IconButton } from "./button"; | ||||
| import CloseIcon from "../icons/close.svg"; | ||||
| import EyeIcon from "../icons/eye.svg"; | ||||
| import Locale from "../locales"; | ||||
| import { Path } from "../constant"; | ||||
|  | ||||
| import { useChatStore } from "../store"; | ||||
|  | ||||
| type Item = { | ||||
|   id: number; | ||||
|   name: string; | ||||
|   content: string; | ||||
| }; | ||||
| export function SearchChatPage() { | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   const sessions = chatStore.sessions; | ||||
|   const selectSession = chatStore.selectSession; | ||||
|  | ||||
|   const [searchResults, setSearchResults] = useState<Item[]>([]); | ||||
|  | ||||
|   const previousValueRef = useRef<string>(""); | ||||
|   const searchInputRef = useRef<HTMLInputElement>(null); | ||||
|   const doSearch = useCallback((text: string) => { | ||||
|     const lowerCaseText = text.toLowerCase(); | ||||
|     const results: Item[] = []; | ||||
|  | ||||
|     sessions.forEach((session, index) => { | ||||
|       const fullTextContents: string[] = []; | ||||
|  | ||||
|       session.messages.forEach((message) => { | ||||
|         const content = message.content as string; | ||||
|         if (!content.toLowerCase || content === "") return; | ||||
|         const lowerCaseContent = content.toLowerCase(); | ||||
|  | ||||
|         // full text search | ||||
|         let pos = lowerCaseContent.indexOf(lowerCaseText); | ||||
|         while (pos !== -1) { | ||||
|           const start = Math.max(0, pos - 35); | ||||
|           const end = Math.min(content.length, pos + lowerCaseText.length + 35); | ||||
|           fullTextContents.push(content.substring(start, end)); | ||||
|           pos = lowerCaseContent.indexOf( | ||||
|             lowerCaseText, | ||||
|             pos + lowerCaseText.length, | ||||
|           ); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       if (fullTextContents.length > 0) { | ||||
|         results.push({ | ||||
|           id: index, | ||||
|           name: session.topic, | ||||
|           content: fullTextContents.join("... "), // concat content with... | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // sort by length of matching content | ||||
|     results.sort((a, b) => b.content.length - a.content.length); | ||||
|  | ||||
|     return results; | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const intervalId = setInterval(() => { | ||||
|       if (searchInputRef.current) { | ||||
|         const currentValue = searchInputRef.current.value; | ||||
|         if (currentValue !== previousValueRef.current) { | ||||
|           if (currentValue.length > 0) { | ||||
|             const result = doSearch(currentValue); | ||||
|             setSearchResults(result); | ||||
|           } | ||||
|           previousValueRef.current = currentValue; | ||||
|         } | ||||
|       } | ||||
|     }, 1000); | ||||
|  | ||||
|     // Cleanup the interval on component unmount | ||||
|     return () => clearInterval(intervalId); | ||||
|   }, [doSearch]); | ||||
|  | ||||
|   return ( | ||||
|     <ErrorBoundary> | ||||
|       <div className={styles["mask-page"]}> | ||||
|         {/* header */} | ||||
|         <div className="window-header"> | ||||
|           <div className="window-header-title"> | ||||
|             <div className="window-header-main-title"> | ||||
|               {Locale.SearchChat.Page.Title} | ||||
|             </div> | ||||
|             <div className="window-header-submai-title"> | ||||
|               {Locale.SearchChat.Page.SubTitle(searchResults.length)} | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <div className="window-actions"> | ||||
|             <div className="window-action-button"> | ||||
|               <IconButton | ||||
|                 icon={<CloseIcon />} | ||||
|                 bordered | ||||
|                 onClick={() => navigate(-1)} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div className={styles["mask-page-body"]}> | ||||
|           <div className={styles["mask-filter"]}> | ||||
|             {/**搜索输入框 */} | ||||
|             <input | ||||
|               type="text" | ||||
|               className={styles["search-bar"]} | ||||
|               placeholder={Locale.SearchChat.Page.Search} | ||||
|               autoFocus | ||||
|               ref={searchInputRef} | ||||
|               onKeyDown={(e) => { | ||||
|                 if (e.key === "Enter") { | ||||
|                   e.preventDefault(); | ||||
|                   const searchText = e.currentTarget.value; | ||||
|                   if (searchText.length > 0) { | ||||
|                     const result = doSearch(searchText); | ||||
|                     setSearchResults(result); | ||||
|                   } | ||||
|                 } | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|  | ||||
|           <div> | ||||
|             {searchResults.map((item) => ( | ||||
|               <div | ||||
|                 className={styles["mask-item"]} | ||||
|                 key={item.id} | ||||
|                 onClick={() => { | ||||
|                   navigate(Path.Chat); | ||||
|                   selectSession(item.id); | ||||
|                 }} | ||||
|                 style={{ cursor: "pointer" }} | ||||
|               > | ||||
|                 {/** 搜索匹配的文本 */} | ||||
|                 <div className={styles["mask-header"]}> | ||||
|                   <div className={styles["mask-title"]}> | ||||
|                     <div className={styles["mask-name"]}>{item.name}</div> | ||||
|                     {item.content.slice(0, 70)} | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 {/** 操作按钮 */} | ||||
|                 <div className={styles["mask-actions"]}> | ||||
|                   <IconButton | ||||
|                     icon={<EyeIcon />} | ||||
|                     text={Locale.SearchChat.Item.View} | ||||
|                   /> | ||||
|                 </div> | ||||
|               </div> | ||||
|             ))} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </ErrorBoundary> | ||||
|   ); | ||||
| } | ||||
| @@ -72,9 +72,3 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .subtitle-button { | ||||
|   button { | ||||
|     overflow:visible ; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import React, { useEffect, useRef, useMemo, useState, Fragment } from "react"; | ||||
| import { useEffect, useRef, useMemo } from "react"; | ||||
|  | ||||
| import styles from "./home.module.scss"; | ||||
|  | ||||
| @@ -7,10 +7,11 @@ import SettingsIcon from "../icons/settings.svg"; | ||||
| import GithubIcon from "../icons/github.svg"; | ||||
| import ChatGptIcon from "../icons/chatgpt.svg"; | ||||
| import AddIcon from "../icons/add.svg"; | ||||
| import CloseIcon from "../icons/close.svg"; | ||||
| import DeleteIcon from "../icons/delete.svg"; | ||||
| import MaskIcon from "../icons/mask.svg"; | ||||
| import PluginIcon from "../icons/plugin.svg"; | ||||
| import DragIcon from "../icons/drag.svg"; | ||||
| import DiscoveryIcon from "../icons/discovery.svg"; | ||||
|  | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| @@ -22,20 +23,19 @@ import { | ||||
|   MIN_SIDEBAR_WIDTH, | ||||
|   NARROW_SIDEBAR_WIDTH, | ||||
|   Path, | ||||
|   PLUGINS, | ||||
|   REPO_URL, | ||||
| } from "../constant"; | ||||
|  | ||||
| import { Link, useNavigate } from "react-router-dom"; | ||||
| import { isIOS, useMobileScreen } from "../utils"; | ||||
| import dynamic from "next/dynamic"; | ||||
| import { showConfirm, Selector } from "./ui-lib"; | ||||
| import { showConfirm, showToast } from "./ui-lib"; | ||||
|  | ||||
| const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { | ||||
|   loading: () => null, | ||||
| }); | ||||
|  | ||||
| export function useHotKey() { | ||||
| function useHotKey() { | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -54,7 +54,7 @@ export function useHotKey() { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function useDragSideBar() { | ||||
| function useDragSideBar() { | ||||
|   const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x); | ||||
|  | ||||
|   const config = useAppConfig(); | ||||
| @@ -127,21 +127,25 @@ export function useDragSideBar() { | ||||
|     shouldNarrow, | ||||
|   }; | ||||
| } | ||||
| export function SideBarContainer(props: { | ||||
|   children: React.ReactNode; | ||||
|   onDragStart: (e: MouseEvent) => void; | ||||
|   shouldNarrow: boolean; | ||||
|   className?: string; | ||||
| }) { | ||||
|  | ||||
| export function SideBar(props: { className?: string }) { | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   // drag side bar | ||||
|   const { onDragStart, shouldNarrow } = useDragSideBar(); | ||||
|   const navigate = useNavigate(); | ||||
|   const config = useAppConfig(); | ||||
|   const isMobileScreen = useMobileScreen(); | ||||
|   const isIOSMobile = useMemo( | ||||
|     () => isIOS() && isMobileScreen, | ||||
|     [isMobileScreen], | ||||
|   ); | ||||
|   const { children, className, onDragStart, shouldNarrow } = props; | ||||
|  | ||||
|   useHotKey(); | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${styles.sidebar} ${className} ${ | ||||
|       className={`${styles.sidebar} ${props.className} ${ | ||||
|         shouldNarrow && styles["narrow-sidebar"] | ||||
|       }`} | ||||
|       style={{ | ||||
| @@ -149,85 +153,18 @@ export function SideBarContainer(props: { | ||||
|         transition: isMobileScreen && isIOSMobile ? "none" : undefined, | ||||
|       }} | ||||
|     > | ||||
|       {children} | ||||
|       <div | ||||
|         className={styles["sidebar-drag"]} | ||||
|         onPointerDown={(e) => onDragStart(e as any)} | ||||
|       > | ||||
|         <DragIcon /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function SideBarHeader(props: { | ||||
|   title?: string | React.ReactNode; | ||||
|   subTitle?: string | React.ReactNode; | ||||
|   logo?: React.ReactNode; | ||||
|   children?: React.ReactNode; | ||||
| }) { | ||||
|   const { title, subTitle, logo, children } = props; | ||||
|   return ( | ||||
|     <Fragment> | ||||
|       <div className={styles["sidebar-header"]} data-tauri-drag-region> | ||||
|         <div className={styles["sidebar-title-container"]}> | ||||
|         <div className={styles["sidebar-title"]} data-tauri-drag-region> | ||||
|             {title} | ||||
|           NextChat | ||||
|         </div> | ||||
|           <div className={styles["sidebar-sub-title"]}>{subTitle}</div> | ||||
|         <div className={styles["sidebar-sub-title"]}> | ||||
|           Build your own AI assistant. | ||||
|         </div> | ||||
|         <div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div> | ||||
|         <div className={styles["sidebar-logo"] + " no-dark"}> | ||||
|           <ChatGptIcon /> | ||||
|         </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> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function SideBarTail(props: { | ||||
|   primaryAction?: React.ReactNode; | ||||
|   secondaryAction?: React.ReactNode; | ||||
| }) { | ||||
|   const { primaryAction, secondaryAction } = props; | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["sidebar-tail"]}> | ||||
|       <div className={styles["sidebar-actions"]}>{primaryAction}</div> | ||||
|       <div className={styles["sidebar-actions"]}>{secondaryAction}</div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function SideBar(props: { className?: string }) { | ||||
|   useHotKey(); | ||||
|   const { onDragStart, shouldNarrow } = useDragSideBar(); | ||||
|   const [showPluginSelector, setShowPluginSelector] = useState(false); | ||||
|   const navigate = useNavigate(); | ||||
|   const config = useAppConfig(); | ||||
|   const chatStore = useChatStore(); | ||||
|  | ||||
|   return ( | ||||
|     <SideBarContainer | ||||
|       onDragStart={onDragStart} | ||||
|       shouldNarrow={shouldNarrow} | ||||
|       {...props} | ||||
|     > | ||||
|       <SideBarHeader | ||||
|         title="NextChat" | ||||
|         subTitle="Build your own AI assistant." | ||||
|         logo={<ChatGptIcon />} | ||||
|       > | ||||
|       <div className={styles["sidebar-header-bar"]}> | ||||
|         <IconButton | ||||
|           icon={<MaskIcon />} | ||||
| @@ -243,31 +180,16 @@ export function SideBar(props: { className?: string }) { | ||||
|           shadow | ||||
|         /> | ||||
|         <IconButton | ||||
|             icon={<DiscoveryIcon />} | ||||
|             text={shouldNarrow ? undefined : Locale.Discovery.Name} | ||||
|           icon={<PluginIcon />} | ||||
|           text={shouldNarrow ? undefined : Locale.Plugin.Name} | ||||
|           className={styles["sidebar-bar-button"]} | ||||
|             onClick={() => setShowPluginSelector(true)} | ||||
|           onClick={() => showToast(Locale.WIP)} | ||||
|           shadow | ||||
|         /> | ||||
|       </div> | ||||
|         {showPluginSelector && ( | ||||
|           <Selector | ||||
|             items={[ | ||||
|               ...PLUGINS.map((item) => { | ||||
|                 return { | ||||
|                   title: item.name, | ||||
|                   value: item.path, | ||||
|                 }; | ||||
|               }), | ||||
|             ]} | ||||
|             onClose={() => setShowPluginSelector(false)} | ||||
|             onSelection={(s) => { | ||||
|               navigate(s[0], { state: { fromHome: true } }); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </SideBarHeader> | ||||
|       <SideBarBody | ||||
|  | ||||
|       <div | ||||
|         className={styles["sidebar-body"]} | ||||
|         onClick={(e) => { | ||||
|           if (e.target === e.currentTarget) { | ||||
|             navigate(Path.Home); | ||||
| @@ -275,10 +197,10 @@ export function SideBar(props: { className?: string }) { | ||||
|         }} | ||||
|       > | ||||
|         <ChatList narrow={shouldNarrow} /> | ||||
|       </SideBarBody> | ||||
|       <SideBarTail | ||||
|         primaryAction={ | ||||
|           <> | ||||
|       </div> | ||||
|  | ||||
|       <div className={styles["sidebar-tail"]}> | ||||
|         <div className={styles["sidebar-actions"]}> | ||||
|           <div className={styles["sidebar-action"] + " " + styles.mobile}> | ||||
|             <IconButton | ||||
|               icon={<DeleteIcon />} | ||||
| @@ -291,25 +213,16 @@ export function SideBar(props: { className?: string }) { | ||||
|           </div> | ||||
|           <div className={styles["sidebar-action"]}> | ||||
|             <Link to={Path.Settings}> | ||||
|                 <IconButton | ||||
|                   aria={Locale.Settings.Title} | ||||
|                   icon={<SettingsIcon />} | ||||
|                   shadow | ||||
|                 /> | ||||
|               <IconButton icon={<SettingsIcon />} shadow /> | ||||
|             </Link> | ||||
|           </div> | ||||
|           <div className={styles["sidebar-action"]}> | ||||
|             <a href={REPO_URL} target="_blank" rel="noopener noreferrer"> | ||||
|                 <IconButton | ||||
|                   aria={Locale.Export.MessageFromChatGPT} | ||||
|                   icon={<GithubIcon />} | ||||
|                   shadow | ||||
|                 /> | ||||
|               <IconButton icon={<GithubIcon />} shadow /> | ||||
|             </a> | ||||
|           </div> | ||||
|           </> | ||||
|         } | ||||
|         secondaryAction={ | ||||
|         </div> | ||||
|         <div> | ||||
|           <IconButton | ||||
|             icon={<AddIcon />} | ||||
|             text={shouldNarrow ? undefined : Locale.Home.NewChat} | ||||
| @@ -323,8 +236,15 @@ export function SideBar(props: { className?: string }) { | ||||
|             }} | ||||
|             shadow | ||||
|           /> | ||||
|         } | ||||
|       /> | ||||
|     </SideBarContainer> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div | ||||
|         className={styles["sidebar-drag"]} | ||||
|         onPointerDown={(e) => onDragStart(e as any)} | ||||
|       > | ||||
|         <DragIcon /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,133 +0,0 @@ | ||||
| import { TTSConfig, TTSConfigValidator } from "../store"; | ||||
|  | ||||
| import Locale from "../locales"; | ||||
| import { ListItem, Select } from "./ui-lib"; | ||||
| import { | ||||
|   DEFAULT_TTS_ENGINE, | ||||
|   DEFAULT_TTS_ENGINES, | ||||
|   DEFAULT_TTS_MODELS, | ||||
|   DEFAULT_TTS_VOICES, | ||||
| } from "../constant"; | ||||
| import { InputRange } from "./input-range"; | ||||
|  | ||||
| export function TTSConfigList(props: { | ||||
|   ttsConfig: TTSConfig; | ||||
|   updateConfig: (updater: (config: TTSConfig) => void) => void; | ||||
| }) { | ||||
|   return ( | ||||
|     <> | ||||
|       <ListItem | ||||
|         title={Locale.Settings.TTS.Enable.Title} | ||||
|         subTitle={Locale.Settings.TTS.Enable.SubTitle} | ||||
|       > | ||||
|         <input | ||||
|           type="checkbox" | ||||
|           checked={props.ttsConfig.enable} | ||||
|           onChange={(e) => | ||||
|             props.updateConfig( | ||||
|               (config) => (config.enable = e.currentTarget.checked), | ||||
|             ) | ||||
|           } | ||||
|         ></input> | ||||
|       </ListItem> | ||||
|       {/* <ListItem | ||||
|         title={Locale.Settings.TTS.Autoplay.Title} | ||||
|         subTitle={Locale.Settings.TTS.Autoplay.SubTitle} | ||||
|       > | ||||
|         <input | ||||
|           type="checkbox" | ||||
|           checked={props.ttsConfig.autoplay} | ||||
|           onChange={(e) => | ||||
|             props.updateConfig( | ||||
|               (config) => (config.autoplay = e.currentTarget.checked), | ||||
|             ) | ||||
|           } | ||||
|         ></input> | ||||
|       </ListItem> */} | ||||
|       <ListItem title={Locale.Settings.TTS.Engine}> | ||||
|         <Select | ||||
|           value={props.ttsConfig.engine} | ||||
|           onChange={(e) => { | ||||
|             props.updateConfig( | ||||
|               (config) => | ||||
|                 (config.engine = TTSConfigValidator.engine( | ||||
|                   e.currentTarget.value, | ||||
|                 )), | ||||
|             ); | ||||
|           }} | ||||
|         > | ||||
|           {DEFAULT_TTS_ENGINES.map((v, i) => ( | ||||
|             <option value={v} key={i}> | ||||
|               {v} | ||||
|             </option> | ||||
|           ))} | ||||
|         </Select> | ||||
|       </ListItem> | ||||
|       {props.ttsConfig.engine === DEFAULT_TTS_ENGINE && ( | ||||
|         <> | ||||
|           <ListItem title={Locale.Settings.TTS.Model}> | ||||
|             <Select | ||||
|               value={props.ttsConfig.model} | ||||
|               onChange={(e) => { | ||||
|                 props.updateConfig( | ||||
|                   (config) => | ||||
|                     (config.model = TTSConfigValidator.model( | ||||
|                       e.currentTarget.value, | ||||
|                     )), | ||||
|                 ); | ||||
|               }} | ||||
|             > | ||||
|               {DEFAULT_TTS_MODELS.map((v, i) => ( | ||||
|                 <option value={v} key={i}> | ||||
|                   {v} | ||||
|                 </option> | ||||
|               ))} | ||||
|             </Select> | ||||
|           </ListItem> | ||||
|           <ListItem | ||||
|             title={Locale.Settings.TTS.Voice.Title} | ||||
|             subTitle={Locale.Settings.TTS.Voice.SubTitle} | ||||
|           > | ||||
|             <Select | ||||
|               value={props.ttsConfig.voice} | ||||
|               onChange={(e) => { | ||||
|                 props.updateConfig( | ||||
|                   (config) => | ||||
|                     (config.voice = TTSConfigValidator.voice( | ||||
|                       e.currentTarget.value, | ||||
|                     )), | ||||
|                 ); | ||||
|               }} | ||||
|             > | ||||
|               {DEFAULT_TTS_VOICES.map((v, i) => ( | ||||
|                 <option value={v} key={i}> | ||||
|                   {v} | ||||
|                 </option> | ||||
|               ))} | ||||
|             </Select> | ||||
|           </ListItem> | ||||
|           <ListItem | ||||
|             title={Locale.Settings.TTS.Speed.Title} | ||||
|             subTitle={Locale.Settings.TTS.Speed.SubTitle} | ||||
|           > | ||||
|             <InputRange | ||||
|               aria={Locale.Settings.TTS.Speed.Title} | ||||
|               value={props.ttsConfig.speed?.toFixed(1)} | ||||
|               min="0.3" | ||||
|               max="4.0" | ||||
|               step="0.1" | ||||
|               onChange={(e) => { | ||||
|                 props.updateConfig( | ||||
|                   (config) => | ||||
|                     (config.speed = TTSConfigValidator.speed( | ||||
|                       e.currentTarget.valueAsNumber, | ||||
|                     )), | ||||
|                 ); | ||||
|               }} | ||||
|             ></InputRange> | ||||
|           </ListItem> | ||||
|         </> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,119 +0,0 @@ | ||||
| @import "../styles/animation.scss"; | ||||
| .plugin-page { | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|  | ||||
|   .plugin-page-body { | ||||
|     padding: 20px; | ||||
|     overflow-y: auto; | ||||
|  | ||||
|     .plugin-filter { | ||||
|       width: 100%; | ||||
|       max-width: 100%; | ||||
|       margin-bottom: 20px; | ||||
|       animation: slide-in ease 0.3s; | ||||
|       height: 40px; | ||||
|  | ||||
|       display: flex; | ||||
|  | ||||
|       .search-bar { | ||||
|         flex-grow: 1; | ||||
|         max-width: 100%; | ||||
|         min-width: 0; | ||||
|         outline: none; | ||||
|       } | ||||
|  | ||||
|       .search-bar:focus { | ||||
|         border: 1px solid var(--primary); | ||||
|       } | ||||
|  | ||||
|       .plugin-filter-lang { | ||||
|         height: 100%; | ||||
|         margin-left: 10px; | ||||
|       } | ||||
|  | ||||
|       .plugin-create { | ||||
|         height: 100%; | ||||
|         margin-left: 10px; | ||||
|         box-sizing: border-box; | ||||
|         min-width: 80px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .plugin-item { | ||||
|       display: flex; | ||||
|       justify-content: space-between; | ||||
|       padding: 20px; | ||||
|       border: var(--border-in-light); | ||||
|       animation: slide-in ease 0.3s; | ||||
|  | ||||
|       &:not(:last-child) { | ||||
|         border-bottom: 0; | ||||
|       } | ||||
|  | ||||
|       &:first-child { | ||||
|         border-top-left-radius: 10px; | ||||
|         border-top-right-radius: 10px; | ||||
|       } | ||||
|  | ||||
|       &:last-child { | ||||
|         border-bottom-left-radius: 10px; | ||||
|         border-bottom-right-radius: 10px; | ||||
|       } | ||||
|  | ||||
|       .plugin-header { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|  | ||||
|         .plugin-icon { | ||||
|           display: flex; | ||||
|           align-items: center; | ||||
|           justify-content: center; | ||||
|           margin-right: 10px; | ||||
|         } | ||||
|  | ||||
|         .plugin-title { | ||||
|           .plugin-name { | ||||
|             font-size: 14px; | ||||
|             font-weight: bold; | ||||
|           } | ||||
|           .plugin-info { | ||||
|             font-size: 12px; | ||||
|           } | ||||
|           .plugin-runtime-warning { | ||||
|             font-size: 12px; | ||||
|             color: #f86c6c; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .plugin-actions { | ||||
|         display: flex; | ||||
|         flex-wrap: nowrap; | ||||
|         transition: all ease 0.3s; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|       } | ||||
|  | ||||
|       @media screen and (max-width: 600px) { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         padding-bottom: 10px; | ||||
|         border-radius: 10px; | ||||
|         margin-bottom: 20px; | ||||
|         box-shadow: var(--card-shadow); | ||||
|  | ||||
|         &:not(:last-child) { | ||||
|           border-bottom: var(--border-in-light); | ||||
|         } | ||||
|  | ||||
|         .plugin-actions { | ||||
|           width: 100%; | ||||
|           justify-content: space-between; | ||||
|           padding-top: 10px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -61,19 +61,6 @@ | ||||
|       font-weight: normal; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.vertical { | ||||
|     flex-direction: column; | ||||
|     align-items: start; | ||||
|     .list-header { | ||||
|       .list-item-title { | ||||
|         margin-bottom: 5px; | ||||
|       } | ||||
|       .list-item-sub-title { | ||||
|         margin-bottom: 2px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .list { | ||||
| @@ -252,12 +239,6 @@ | ||||
|   position: relative; | ||||
|   max-width: fit-content; | ||||
|  | ||||
|   &.left-align-option { | ||||
|     option { | ||||
|       text-align: left; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .select-with-icon-select { | ||||
|     height: 100%; | ||||
|     border: var(--border-in-light); | ||||
| @@ -310,12 +291,7 @@ | ||||
|   justify-content: center; | ||||
|   z-index: 999; | ||||
|  | ||||
|   .selector-item-disabled { | ||||
|     opacity: 0.6; | ||||
|   } | ||||
|  | ||||
|   &-content { | ||||
|     min-width: 300px; | ||||
|     .list { | ||||
|       max-height: 90vh; | ||||
|       overflow-x: hidden; | ||||
| @@ -336,4 +312,3 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -13,15 +13,7 @@ import MinIcon from "../icons/min.svg"; | ||||
| import Locale from "../locales"; | ||||
|  | ||||
| import { createRoot } from "react-dom/client"; | ||||
| import React, { | ||||
|   CSSProperties, | ||||
|   HTMLProps, | ||||
|   MouseEvent, | ||||
|   useEffect, | ||||
|   useState, | ||||
|   useCallback, | ||||
|   useRef, | ||||
| } from "react"; | ||||
| import React, { HTMLProps, useEffect, useState } from "react"; | ||||
| import { IconButton } from "./button"; | ||||
|  | ||||
| export function Popover(props: { | ||||
| @@ -50,21 +42,16 @@ export function Card(props: { children: JSX.Element[]; className?: string }) { | ||||
| } | ||||
|  | ||||
| export function ListItem(props: { | ||||
|   title?: string; | ||||
|   subTitle?: string | JSX.Element; | ||||
|   title: string; | ||||
|   subTitle?: string; | ||||
|   children?: JSX.Element | JSX.Element[]; | ||||
|   icon?: JSX.Element; | ||||
|   className?: string; | ||||
|   onClick?: (e: MouseEvent) => void; | ||||
|   vertical?: boolean; | ||||
|   onClick?: () => void; | ||||
| }) { | ||||
|   return ( | ||||
|     <div | ||||
|       className={ | ||||
|         styles["list-item"] + | ||||
|         ` ${props.vertical ? styles["vertical"] : ""} ` + | ||||
|         ` ${props.className || ""}` | ||||
|       } | ||||
|       className={styles["list-item"] + ` ${props.className || ""}`} | ||||
|       onClick={props.onClick} | ||||
|     > | ||||
|       <div className={styles["list-header"]}> | ||||
| @@ -265,10 +252,9 @@ export function Input(props: InputProps) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function PasswordInput( | ||||
|   props: HTMLProps<HTMLInputElement> & { aria?: string }, | ||||
| ) { | ||||
| export function PasswordInput(props: HTMLProps<HTMLInputElement>) { | ||||
|   const [visible, setVisible] = useState(false); | ||||
|  | ||||
|   function changeVisibility() { | ||||
|     setVisible(!visible); | ||||
|   } | ||||
| @@ -276,7 +262,6 @@ export function PasswordInput( | ||||
|   return ( | ||||
|     <div className={"password-input-container"}> | ||||
|       <IconButton | ||||
|         aria={props.aria} | ||||
|         icon={visible ? <EyeIcon /> : <EyeOffIcon />} | ||||
|         onClick={changeVisibility} | ||||
|         className={"password-eye"} | ||||
| @@ -292,19 +277,13 @@ export function PasswordInput( | ||||
|  | ||||
| export function Select( | ||||
|   props: React.DetailedHTMLProps< | ||||
|     React.SelectHTMLAttributes<HTMLSelectElement> & { | ||||
|       align?: "left" | "center"; | ||||
|     }, | ||||
|     React.SelectHTMLAttributes<HTMLSelectElement>, | ||||
|     HTMLSelectElement | ||||
|   >, | ||||
| ) { | ||||
|   const { className, children, align, ...otherProps } = props; | ||||
|   const { className, children, ...otherProps } = props; | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${styles["select-with-icon"]} ${ | ||||
|         align === "left" ? styles["left-align-option"] : "" | ||||
|       } ${className}`} | ||||
|     > | ||||
|     <div className={`${styles["select-with-icon"]} ${className}`}> | ||||
|       <select className={styles["select-with-icon-select"]} {...otherProps}> | ||||
|         {children} | ||||
|       </select> | ||||
| @@ -441,25 +420,17 @@ export function showPrompt(content: any, value = "", rows = 3) { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function showImageModal( | ||||
|   img: string, | ||||
|   defaultMax?: boolean, | ||||
|   style?: CSSProperties, | ||||
|   boxStyle?: CSSProperties, | ||||
| ) { | ||||
| export function showImageModal(img: string) { | ||||
|   showModal({ | ||||
|     title: Locale.Export.Image.Modal, | ||||
|     defaultMax: defaultMax, | ||||
|     children: ( | ||||
|       <div style={{ display: "flex", justifyContent: "center", ...boxStyle }}> | ||||
|       <div> | ||||
|         <img | ||||
|           src={img} | ||||
|           alt="preview" | ||||
|           style={ | ||||
|             style ?? { | ||||
|           style={{ | ||||
|             maxWidth: "100%", | ||||
|             } | ||||
|           } | ||||
|           }} | ||||
|         ></img> | ||||
|       </div> | ||||
|     ), | ||||
| @@ -471,56 +442,27 @@ export function Selector<T>(props: { | ||||
|     title: string; | ||||
|     subTitle?: string; | ||||
|     value: T; | ||||
|     disable?: boolean; | ||||
|   }>; | ||||
|   defaultSelectedValue?: T[] | T; | ||||
|   defaultSelectedValue?: T; | ||||
|   onSelection?: (selection: T[]) => void; | ||||
|   onClose?: () => void; | ||||
|   multiple?: boolean; | ||||
| }) { | ||||
|   const [selectedValues, setSelectedValues] = useState<T[]>( | ||||
|     Array.isArray(props.defaultSelectedValue) | ||||
|       ? props.defaultSelectedValue | ||||
|       : props.defaultSelectedValue !== undefined | ||||
|       ? [props.defaultSelectedValue] | ||||
|       : [], | ||||
|   ); | ||||
|  | ||||
|   const handleSelection = (e: MouseEvent, value: T) => { | ||||
|     if (props.multiple) { | ||||
|       e.stopPropagation(); | ||||
|       const newSelectedValues = selectedValues.includes(value) | ||||
|         ? selectedValues.filter((v) => v !== value) | ||||
|         : [...selectedValues, value]; | ||||
|       setSelectedValues(newSelectedValues); | ||||
|       props.onSelection?.(newSelectedValues); | ||||
|     } else { | ||||
|       setSelectedValues([value]); | ||||
|       props.onSelection?.([value]); | ||||
|       props.onClose?.(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className={styles["selector"]} onClick={() => props.onClose?.()}> | ||||
|       <div className={styles["selector-content"]}> | ||||
|         <List> | ||||
|           {props.items.map((item, i) => { | ||||
|             const selected = selectedValues.includes(item.value); | ||||
|             const selected = props.defaultSelectedValue === item.value; | ||||
|             return ( | ||||
|               <ListItem | ||||
|                 className={`${styles["selector-item"]} ${ | ||||
|                   item.disable && styles["selector-item-disabled"] | ||||
|                 }`} | ||||
|                 className={styles["selector-item"]} | ||||
|                 key={i} | ||||
|                 title={item.title} | ||||
|                 subTitle={item.subTitle} | ||||
|                 onClick={(e) => { | ||||
|                   if (item.disable) { | ||||
|                     e.stopPropagation(); | ||||
|                   } else { | ||||
|                     handleSelection(e, item.value); | ||||
|                   } | ||||
|                 onClick={() => { | ||||
|                   props.onSelection?.([item.value]); | ||||
|                   props.onClose?.(); | ||||
|                 }} | ||||
|               > | ||||
|                 {selected ? ( | ||||
| @@ -543,38 +485,3 @@ export function Selector<T>(props: { | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| export function FullScreen(props: any) { | ||||
|   const { children, right = 10, top = 10, ...rest } = props; | ||||
|   const ref = useRef<HTMLDivElement>(); | ||||
|   const [fullScreen, setFullScreen] = useState(false); | ||||
|   const toggleFullscreen = useCallback(() => { | ||||
|     if (!document.fullscreenElement) { | ||||
|       ref.current?.requestFullscreen(); | ||||
|     } else { | ||||
|       document.exitFullscreen(); | ||||
|     } | ||||
|   }, []); | ||||
|   useEffect(() => { | ||||
|     const handleScreenChange = (e: any) => { | ||||
|       if (e.target === ref.current) { | ||||
|         setFullScreen(!!document.fullscreenElement); | ||||
|       } | ||||
|     }; | ||||
|     document.addEventListener("fullscreenchange", handleScreenChange); | ||||
|     return () => { | ||||
|       document.removeEventListener("fullscreenchange", handleScreenChange); | ||||
|     }; | ||||
|   }, []); | ||||
|   return ( | ||||
|     <div ref={ref} style={{ position: "relative" }} {...rest}> | ||||
|       <div style={{ position: "absolute", right, top }}> | ||||
|         <IconButton | ||||
|           icon={fullScreen ? <MinIcon /> : <MaxIcon />} | ||||
|           onClick={toggleFullscreen} | ||||
|           bordered | ||||
|         /> | ||||
|       </div> | ||||
|       {children} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| import tauriConfig from "../../src-tauri/tauri.conf.json"; | ||||
| import { DEFAULT_INPUT_TEMPLATE } from "../constant"; | ||||
|  | ||||
| export const getBuildConfig = () => { | ||||
|   if (typeof process === "undefined") { | ||||
| @@ -39,7 +38,6 @@ export const getBuildConfig = () => { | ||||
|     ...commitInfo, | ||||
|     buildMode, | ||||
|     isApp, | ||||
|     template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build"; | ||||
| export function getClientConfig() { | ||||
|   if (typeof document !== "undefined") { | ||||
|     // client side | ||||
|     return JSON.parse(queryMeta("config") || "{}") as BuildConfig; | ||||
|     return JSON.parse(queryMeta("config")) as BuildConfig; | ||||
|   } | ||||
|  | ||||
|   if (typeof process !== "undefined") { | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import md5 from "spark-md5"; | ||||
| import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant"; | ||||
| import { DEFAULT_MODELS } from "../constant"; | ||||
|  | ||||
| declare global { | ||||
|   namespace NodeJS { | ||||
| @@ -21,11 +21,6 @@ declare global { | ||||
|       ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not | ||||
|       DISABLE_FAST_LINK?: string; // disallow parse settings from url or not | ||||
|       CUSTOM_MODELS?: string; // to control custom models | ||||
|       DEFAULT_MODEL?: string; // to control default model in every new chat window | ||||
|  | ||||
|       // stability only | ||||
|       STABILITY_URL?: string; | ||||
|       STABILITY_API_KEY?: string; | ||||
|  | ||||
|       // azure only | ||||
|       AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name} | ||||
| @@ -38,41 +33,6 @@ declare global { | ||||
|  | ||||
|       // google tag manager | ||||
|       GTM_ID?: string; | ||||
|  | ||||
|       // anthropic only | ||||
|       ANTHROPIC_URL?: string; | ||||
|       ANTHROPIC_API_KEY?: string; | ||||
|       ANTHROPIC_API_VERSION?: string; | ||||
|  | ||||
|       // baidu only | ||||
|       BAIDU_URL?: string; | ||||
|       BAIDU_API_KEY?: string; | ||||
|       BAIDU_SECRET_KEY?: string; | ||||
|  | ||||
|       // bytedance only | ||||
|       BYTEDANCE_URL?: string; | ||||
|       BYTEDANCE_API_KEY?: string; | ||||
|  | ||||
|       // alibaba only | ||||
|       ALIBABA_URL?: string; | ||||
|       ALIBABA_API_KEY?: string; | ||||
|  | ||||
|       // tencent only | ||||
|       TENCENT_URL?: string; | ||||
|       TENCENT_SECRET_KEY?: string; | ||||
|       TENCENT_SECRET_ID?: string; | ||||
|  | ||||
|       // moonshot only | ||||
|       MOONSHOT_URL?: string; | ||||
|       MOONSHOT_API_KEY?: string; | ||||
|  | ||||
|       // iflytek only | ||||
|       IFLYTEK_URL?: string; | ||||
|       IFLYTEK_API_KEY?: string; | ||||
|       IFLYTEK_API_SECRET?: string; | ||||
|  | ||||
|       // custom template for preprocessing user input | ||||
|       DEFAULT_INPUT_TEMPLATE?: string; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -90,22 +50,6 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> { | ||||
|   } | ||||
| })(); | ||||
|  | ||||
| function getApiKey(keys?: string) { | ||||
|   const apiKeyEnvVar = keys ?? ""; | ||||
|   const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); | ||||
|   const randomIndex = Math.floor(Math.random() * apiKeys.length); | ||||
|   const apiKey = apiKeys[randomIndex]; | ||||
|   if (apiKey) { | ||||
|     console.log( | ||||
|       `[Server Config] using ${randomIndex + 1} of ${ | ||||
|         apiKeys.length | ||||
|       } api key - ${apiKey}`, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return apiKey; | ||||
| } | ||||
|  | ||||
| export const getServerSideConfig = () => { | ||||
|   if (typeof process === "undefined") { | ||||
|     throw Error( | ||||
| @@ -115,106 +59,40 @@ export const getServerSideConfig = () => { | ||||
|  | ||||
|   const disableGPT4 = !!process.env.DISABLE_GPT4; | ||||
|   let customModels = process.env.CUSTOM_MODELS ?? ""; | ||||
|   let defaultModel = process.env.DEFAULT_MODEL ?? ""; | ||||
|  | ||||
|   if (disableGPT4) { | ||||
|     if (customModels) customModels += ","; | ||||
|     customModels += DEFAULT_MODELS.filter( | ||||
|       (m) => | ||||
|         (m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o")) && | ||||
|         !m.name.startsWith("gpt-4o-mini"), | ||||
|     ) | ||||
|     customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4")) | ||||
|       .map((m) => "-" + m.name) | ||||
|       .join(","); | ||||
|     if ( | ||||
|       (defaultModel.startsWith("gpt-4") || | ||||
|         defaultModel.startsWith("chatgpt-4o")) && | ||||
|       !defaultModel.startsWith("gpt-4o-mini") | ||||
|     ) | ||||
|       defaultModel = ""; | ||||
|   } | ||||
|  | ||||
|   const isStability = !!process.env.STABILITY_API_KEY; | ||||
|  | ||||
|   const isAzure = !!process.env.AZURE_URL; | ||||
|   const isGoogle = !!process.env.GOOGLE_API_KEY; | ||||
|   const isAnthropic = !!process.env.ANTHROPIC_API_KEY; | ||||
|   const isTencent = !!process.env.TENCENT_API_KEY; | ||||
|  | ||||
|   const isBaidu = !!process.env.BAIDU_API_KEY; | ||||
|   const isBytedance = !!process.env.BYTEDANCE_API_KEY; | ||||
|   const isAlibaba = !!process.env.ALIBABA_API_KEY; | ||||
|   const isMoonshot = !!process.env.MOONSHOT_API_KEY; | ||||
|   const isIflytek = !!process.env.IFLYTEK_API_KEY; | ||||
|   // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; | ||||
|   // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); | ||||
|   // const randomIndex = Math.floor(Math.random() * apiKeys.length); | ||||
|   // const apiKey = apiKeys[randomIndex]; | ||||
|   // console.log( | ||||
|   //   `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`, | ||||
|   // ); | ||||
|  | ||||
|   const allowedWebDevEndpoints = ( | ||||
|     process.env.WHITE_WEBDEV_ENDPOINTS ?? "" | ||||
|   ).split(","); | ||||
|   const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; | ||||
|   const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); | ||||
|   const randomIndex = Math.floor(Math.random() * apiKeys.length); | ||||
|   const apiKey = apiKeys[randomIndex]; | ||||
|   console.log( | ||||
|     `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`, | ||||
|   ); | ||||
|  | ||||
|   return { | ||||
|     baseUrl: process.env.BASE_URL, | ||||
|     apiKey: getApiKey(process.env.OPENAI_API_KEY), | ||||
|     apiKey, | ||||
|     openaiOrgId: process.env.OPENAI_ORG_ID, | ||||
|  | ||||
|     isStability, | ||||
|     stabilityUrl: process.env.STABILITY_URL, | ||||
|     stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY), | ||||
|  | ||||
|     isAzure, | ||||
|     azureUrl: process.env.AZURE_URL, | ||||
|     azureApiKey: getApiKey(process.env.AZURE_API_KEY), | ||||
|     azureApiKey: process.env.AZURE_API_KEY, | ||||
|     azureApiVersion: process.env.AZURE_API_VERSION, | ||||
|  | ||||
|     isGoogle, | ||||
|     googleApiKey: getApiKey(process.env.GOOGLE_API_KEY), | ||||
|     googleApiKey: process.env.GOOGLE_API_KEY, | ||||
|     googleUrl: process.env.GOOGLE_URL, | ||||
|  | ||||
|     isAnthropic, | ||||
|     anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY), | ||||
|     anthropicApiVersion: process.env.ANTHROPIC_API_VERSION, | ||||
|     anthropicUrl: process.env.ANTHROPIC_URL, | ||||
|  | ||||
|     isBaidu, | ||||
|     baiduUrl: process.env.BAIDU_URL, | ||||
|     baiduApiKey: getApiKey(process.env.BAIDU_API_KEY), | ||||
|     baiduSecretKey: process.env.BAIDU_SECRET_KEY, | ||||
|  | ||||
|     isBytedance, | ||||
|     bytedanceApiKey: getApiKey(process.env.BYTEDANCE_API_KEY), | ||||
|     bytedanceUrl: process.env.BYTEDANCE_URL, | ||||
|  | ||||
|     isAlibaba, | ||||
|     alibabaUrl: process.env.ALIBABA_URL, | ||||
|     alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY), | ||||
|  | ||||
|     isTencent, | ||||
|     tencentUrl: process.env.TENCENT_URL, | ||||
|     tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY), | ||||
|     tencentSecretId: process.env.TENCENT_SECRET_ID, | ||||
|  | ||||
|     isMoonshot, | ||||
|     moonshotUrl: process.env.MOONSHOT_URL, | ||||
|     moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY), | ||||
|  | ||||
|     isIflytek, | ||||
|     iflytekUrl: process.env.IFLYTEK_URL, | ||||
|     iflytekApiKey: process.env.IFLYTEK_API_KEY, | ||||
|     iflytekApiSecret: process.env.IFLYTEK_API_SECRET, | ||||
|  | ||||
|     cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID, | ||||
|     cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID, | ||||
|     cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY), | ||||
|     cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL, | ||||
|  | ||||
|     gtmId: process.env.GTM_ID, | ||||
|     gaId: process.env.GA_ID || DEFAULT_GA_ID, | ||||
|  | ||||
|     needCode: ACCESS_CODES.size > 0, | ||||
|     code: process.env.CODE, | ||||
| @@ -228,7 +106,5 @@ export const getServerSideConfig = () => { | ||||
|     hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY, | ||||
|     disableFastLink: !!process.env.DISABLE_FAST_LINK, | ||||
|     customModels, | ||||
|     defaultModel, | ||||
|     allowedWebDevEndpoints, | ||||
|   }; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										532
									
								
								app/constant.ts
									
									
									
									
									
								
							
							
						
						| @@ -1,7 +1,6 @@ | ||||
| export const OWNER = "ChatGPTNextWeb"; | ||||
| export const OWNER = "Yidadaa"; | ||||
| export const REPO = "ChatGPT-Next-Web"; | ||||
| export const REPO_URL = `https://github.com/${OWNER}/${REPO}`; | ||||
| export const PLUGINS_REPO_URL = `https://github.com/${OWNER}/NextChat-Awesome-Plugins`; | ||||
| export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`; | ||||
| export const UPDATE_URL = `${REPO_URL}#keep-updated`; | ||||
| export const RELEASE_URL = `${REPO_URL}/releases`; | ||||
| @@ -9,57 +8,23 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c | ||||
| export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`; | ||||
| export const RUNTIME_CONFIG_DOM = "danger-runtime-config"; | ||||
|  | ||||
| export const STABILITY_BASE_URL = "https://api.stability.ai"; | ||||
|  | ||||
| export const DEFAULT_API_HOST = "https://api.nextchat.dev"; | ||||
| export const OPENAI_BASE_URL = "https://api.openai.com"; | ||||
| export const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; | ||||
|  | ||||
| export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/"; | ||||
|  | ||||
| export const BAIDU_BASE_URL = "https://aip.baidubce.com"; | ||||
| export const BAIDU_OATUH_URL = `${BAIDU_BASE_URL}/oauth/2.0/token`; | ||||
|  | ||||
| export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com"; | ||||
|  | ||||
| export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/"; | ||||
|  | ||||
| export const TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com"; | ||||
|  | ||||
| export const MOONSHOT_BASE_URL = "https://api.moonshot.cn"; | ||||
| export const IFLYTEK_BASE_URL = "https://spark-api-open.xf-yun.com"; | ||||
|  | ||||
| export const CACHE_URL_PREFIX = "/api/cache"; | ||||
| export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`; | ||||
|  | ||||
| export enum Path { | ||||
|   Home = "/", | ||||
|   Chat = "/chat", | ||||
|   Settings = "/settings", | ||||
|   NewChat = "/new-chat", | ||||
|   Masks = "/masks", | ||||
|   Plugins = "/plugins", | ||||
|   Auth = "/auth", | ||||
|   Sd = "/sd", | ||||
|   SdNew = "/sd-new", | ||||
|   Artifacts = "/artifacts", | ||||
|   SearchChat = "/search-chat", | ||||
| } | ||||
|  | ||||
| export enum ApiPath { | ||||
|   Cors = "", | ||||
|   Azure = "/api/azure", | ||||
|   OpenAI = "/api/openai", | ||||
|   Anthropic = "/api/anthropic", | ||||
|   Google = "/api/google", | ||||
|   Baidu = "/api/baidu", | ||||
|   ByteDance = "/api/bytedance", | ||||
|   Alibaba = "/api/alibaba", | ||||
|   Tencent = "/api/tencent", | ||||
|   Moonshot = "/api/moonshot", | ||||
|   Iflytek = "/api/iflytek", | ||||
|   Stability = "/api/stability", | ||||
|   Artifacts = "/api/artifacts", | ||||
| } | ||||
|  | ||||
| export enum SlotID { | ||||
| @@ -74,14 +39,12 @@ export enum FileName { | ||||
|  | ||||
| export enum StoreKey { | ||||
|   Chat = "chat-next-web-store", | ||||
|   Plugin = "chat-next-web-plugin", | ||||
|   Access = "access-control", | ||||
|   Config = "app-config", | ||||
|   Mask = "mask-store", | ||||
|   Prompt = "prompt-store", | ||||
|   Update = "chat-update", | ||||
|   Sync = "sync", | ||||
|   SdList = "sd-list", | ||||
| } | ||||
|  | ||||
| export const DEFAULT_SIDEBAR_WIDTH = 300; | ||||
| @@ -104,402 +67,229 @@ export enum ServiceProvider { | ||||
|   OpenAI = "OpenAI", | ||||
|   Azure = "Azure", | ||||
|   Google = "Google", | ||||
|   Anthropic = "Anthropic", | ||||
|   Baidu = "Baidu", | ||||
|   ByteDance = "ByteDance", | ||||
|   Alibaba = "Alibaba", | ||||
|   Tencent = "Tencent", | ||||
|   Moonshot = "Moonshot", | ||||
|   Stability = "Stability", | ||||
|   Iflytek = "Iflytek", | ||||
| } | ||||
|  | ||||
| // Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings | ||||
| // BLOCK_NONE will not block any content, and BLOCK_ONLY_HIGH will block only high-risk content. | ||||
| export enum GoogleSafetySettingsThreshold { | ||||
|   BLOCK_NONE = "BLOCK_NONE", | ||||
|   BLOCK_ONLY_HIGH = "BLOCK_ONLY_HIGH", | ||||
|   BLOCK_MEDIUM_AND_ABOVE = "BLOCK_MEDIUM_AND_ABOVE", | ||||
|   BLOCK_LOW_AND_ABOVE = "BLOCK_LOW_AND_ABOVE", | ||||
| } | ||||
|  | ||||
| export enum ModelProvider { | ||||
|   Stability = "Stability", | ||||
|   GPT = "GPT", | ||||
|   GeminiPro = "GeminiPro", | ||||
|   Claude = "Claude", | ||||
|   Ernie = "Ernie", | ||||
|   Doubao = "Doubao", | ||||
|   Qwen = "Qwen", | ||||
|   Hunyuan = "Hunyuan", | ||||
|   Moonshot = "Moonshot", | ||||
|   Iflytek = "Iflytek", | ||||
| } | ||||
|  | ||||
| export const Stability = { | ||||
|   GeneratePath: "v2beta/stable-image/generate", | ||||
|   ExampleEndpoint: "https://api.stability.ai", | ||||
| }; | ||||
|  | ||||
| export const Anthropic = { | ||||
|   ChatPath: "v1/messages", | ||||
|   ChatPath1: "v1/complete", | ||||
|   ExampleEndpoint: "https://api.anthropic.com", | ||||
|   Vision: "2023-06-01", | ||||
| }; | ||||
|  | ||||
| export const OpenaiPath = { | ||||
|   ChatPath: "v1/chat/completions", | ||||
|   SpeechPath: "v1/audio/speech", | ||||
|   ImagePath: "v1/images/generations", | ||||
|   UsagePath: "dashboard/billing/usage", | ||||
|   SubsPath: "dashboard/billing/subscription", | ||||
|   ListModelPath: "v1/models", | ||||
| }; | ||||
|  | ||||
| export const Azure = { | ||||
|   ChatPath: (deployName: string, apiVersion: string) => | ||||
|     `deployments/${deployName}/chat/completions?api-version=${apiVersion}`, | ||||
|   // https://<your_resource_name>.openai.azure.com/openai/deployments/<your_deployment_name>/images/generations?api-version=<api_version> | ||||
|   ImagePath: (deployName: string, apiVersion: string) => | ||||
|     `deployments/${deployName}/images/generations?api-version=${apiVersion}`, | ||||
|   ExampleEndpoint: "https://{resource-url}/openai", | ||||
|   ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}", | ||||
| }; | ||||
|  | ||||
| export const Google = { | ||||
|   ExampleEndpoint: "https://generativelanguage.googleapis.com/", | ||||
|   ChatPath: (modelName: string) => | ||||
|     `v1beta/models/${modelName}:streamGenerateContent`, | ||||
| }; | ||||
|   ChatPath: "v1beta/models/gemini-pro:generateContent", | ||||
|   VisionChatPath: "v1beta/models/gemini-pro-vision: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", | ||||
|   // /api/openai/v1/chat/completions | ||||
| }; | ||||
|  | ||||
| export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang | ||||
| // export const DEFAULT_SYSTEM_TEMPLATE = ` | ||||
| // You are ChatGPT, a large language model trained by {{ServiceProvider}}. | ||||
| // Knowledge cutoff: {{cutoff}} | ||||
| // Current model: {{model}} | ||||
| // Current time: {{time}} | ||||
| // Latex inline: $x^2$ | ||||
| // Latex block: $$e=mc^2$$ | ||||
| // `; | ||||
| export const DEFAULT_SYSTEM_TEMPLATE = ` | ||||
| You are ChatGPT, a large language model trained by {{ServiceProvider}}. | ||||
| Knowledge cutoff: {{cutoff}} | ||||
| Current model: {{model}} | ||||
| Current time: {{time}} | ||||
| Latex inline: \\(x^2\\)  | ||||
| Latex inline: $x^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 KnowledgeCutOffDate: Record<string, string> = { | ||||
|   default: "2021-09", | ||||
|   "gpt-4-turbo": "2023-12", | ||||
|   "gpt-4-turbo-2024-04-09": "2023-12", | ||||
|   "gpt-4-turbo-preview": "2023-12", | ||||
|   "gpt-4o": "2023-10", | ||||
|   "gpt-4o-2024-05-13": "2023-10", | ||||
|   "gpt-4o-2024-08-06": "2023-10", | ||||
|   "chatgpt-4o-latest": "2023-10", | ||||
|   "gpt-4o-mini": "2023-10", | ||||
|   "gpt-4o-mini-2024-07-18": "2023-10", | ||||
|   "gpt-4-1106-preview": "2023-04", | ||||
|   "gpt-4-0125-preview": "2023-12", | ||||
|   "gpt-4-vision-preview": "2023-04", | ||||
|   "o1-mini": "2023-10", | ||||
|   "o1-preview": "2023-10", | ||||
|   // After improvements, | ||||
|   // it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously. | ||||
|   "gemini-pro": "2023-12", | ||||
|   "gemini-pro-vision": "2023-12", | ||||
| }; | ||||
|  | ||||
| export const DEFAULT_TTS_ENGINE = "OpenAI-TTS"; | ||||
| export const DEFAULT_TTS_ENGINES = ["OpenAI-TTS", "Edge-TTS"]; | ||||
| export const DEFAULT_TTS_MODEL = "tts-1"; | ||||
| export const DEFAULT_TTS_VOICE = "alloy"; | ||||
| export const DEFAULT_TTS_MODELS = ["tts-1", "tts-1-hd"]; | ||||
| export const DEFAULT_TTS_VOICES = [ | ||||
|   "alloy", | ||||
|   "echo", | ||||
|   "fable", | ||||
|   "onyx", | ||||
|   "nova", | ||||
|   "shimmer", | ||||
| ]; | ||||
|  | ||||
| const openaiModels = [ | ||||
|   "gpt-3.5-turbo", | ||||
|   "gpt-3.5-turbo-1106", | ||||
|   "gpt-3.5-turbo-0125", | ||||
|   "gpt-4", | ||||
|   "gpt-4-0613", | ||||
|   "gpt-4-32k", | ||||
|   "gpt-4-32k-0613", | ||||
|   "gpt-4-turbo", | ||||
|   "gpt-4-turbo-preview", | ||||
|   "gpt-4o", | ||||
|   "gpt-4o-2024-05-13", | ||||
|   "gpt-4o-2024-08-06", | ||||
|   "chatgpt-4o-latest", | ||||
|   "gpt-4o-mini", | ||||
|   "gpt-4o-mini-2024-07-18", | ||||
|   "gpt-4-vision-preview", | ||||
|   "gpt-4-turbo-2024-04-09", | ||||
|   "gpt-4-1106-preview", | ||||
|   "dall-e-3", | ||||
|   "o1-mini", | ||||
|   "o1-preview", | ||||
| ]; | ||||
|  | ||||
| const googleModels = [ | ||||
|   "gemini-1.0-pro", | ||||
|   "gemini-1.5-pro-latest", | ||||
|   "gemini-1.5-flash-latest", | ||||
|   "gemini-pro-vision", | ||||
| ]; | ||||
|  | ||||
| const anthropicModels = [ | ||||
|   "claude-instant-1.2", | ||||
|   "claude-2.0", | ||||
|   "claude-2.1", | ||||
|   "claude-3-sonnet-20240229", | ||||
|   "claude-3-opus-20240229", | ||||
|   "claude-3-haiku-20240307", | ||||
|   "claude-3-5-sonnet-20240620", | ||||
| ]; | ||||
|  | ||||
| const baiduModels = [ | ||||
|   "ernie-4.0-turbo-8k", | ||||
|   "ernie-4.0-8k", | ||||
|   "ernie-4.0-8k-preview", | ||||
|   "ernie-4.0-8k-preview-0518", | ||||
|   "ernie-4.0-8k-latest", | ||||
|   "ernie-3.5-8k", | ||||
|   "ernie-3.5-8k-0205", | ||||
|   "ernie-speed-128k", | ||||
|   "ernie-speed-8k", | ||||
|   "ernie-lite-8k", | ||||
|   "ernie-tiny-8k", | ||||
| ]; | ||||
|  | ||||
| const bytedanceModels = [ | ||||
|   "Doubao-lite-4k", | ||||
|   "Doubao-lite-32k", | ||||
|   "Doubao-lite-128k", | ||||
|   "Doubao-pro-4k", | ||||
|   "Doubao-pro-32k", | ||||
|   "Doubao-pro-128k", | ||||
| ]; | ||||
|  | ||||
| const alibabaModes = [ | ||||
|   "qwen-turbo", | ||||
|   "qwen-plus", | ||||
|   "qwen-max", | ||||
|   "qwen-max-0428", | ||||
|   "qwen-max-0403", | ||||
|   "qwen-max-0107", | ||||
|   "qwen-max-longcontext", | ||||
| ]; | ||||
|  | ||||
| const tencentModels = [ | ||||
|   "hunyuan-pro", | ||||
|   "hunyuan-standard", | ||||
|   "hunyuan-lite", | ||||
|   "hunyuan-role", | ||||
|   "hunyuan-functioncall", | ||||
|   "hunyuan-code", | ||||
|   "hunyuan-vision", | ||||
| ]; | ||||
|  | ||||
| const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]; | ||||
|  | ||||
| const iflytekModels = [ | ||||
|   "general", | ||||
|   "generalv3", | ||||
|   "pro-128k", | ||||
|   "generalv3.5", | ||||
|   "4.0Ultra", | ||||
| ]; | ||||
|  | ||||
| let seq = 1000; // 内置的模型序号生成器从1000开始 | ||||
| export const DEFAULT_MODELS = [ | ||||
|   ...openaiModels.map((name) => ({ | ||||
|     name, | ||||
|   { | ||||
|     name: "gpt-4", | ||||
|     available: true, | ||||
|     sorted: seq++, // Global sequence sort(index) | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|       sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致 | ||||
|     }, | ||||
|   })), | ||||
|   ...openaiModels.map((name) => ({ | ||||
|     name, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-0314", | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "azure", | ||||
|       providerName: "Azure", | ||||
|       providerType: "azure", | ||||
|       sorted: 2, | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   })), | ||||
|   ...googleModels.map((name) => ({ | ||||
|     name, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-0613", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-32k", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-32k-0314", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-32k-0613", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-turbo-preview", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-1106-preview", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-0125-preview", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-4-vision-preview", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-0125", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-0301", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-0613", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-1106", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-16k", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gpt-3.5-turbo-16k-0613", | ||||
|     available: true, | ||||
|     provider: { | ||||
|       id: "openai", | ||||
|       providerName: "OpenAI", | ||||
|       providerType: "openai", | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     name: "gemini-pro", | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "google", | ||||
|       providerName: "Google", | ||||
|       providerType: "google", | ||||
|       sorted: 3, | ||||
|     }, | ||||
|   })), | ||||
|   ...anthropicModels.map((name) => ({ | ||||
|     name, | ||||
|   }, | ||||
|   { | ||||
|     name: "gemini-pro-vision", | ||||
|     available: true, | ||||
|     sorted: seq++, | ||||
|     provider: { | ||||
|       id: "anthropic", | ||||
|       providerName: "Anthropic", | ||||
|       providerType: "anthropic", | ||||
|       sorted: 4, | ||||
|       id: "google", | ||||
|       providerName: "Google", | ||||
|       providerType: "google", | ||||
|     }, | ||||
|   })), | ||||
|   ...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; | ||||
|  | ||||
| export const CHAT_PAGE_SIZE = 15; | ||||
| export const MAX_RENDER_MSG_COUNT = 45; | ||||
|  | ||||
| // some famous webdav endpoints | ||||
| export const internalAllowedWebDavEndpoints = [ | ||||
|   "https://dav.jianguoyun.com/dav/", | ||||
|   "https://dav.dropdav.com/", | ||||
|   "https://dav.box.com/dav", | ||||
|   "https://nanao.teracloud.jp/dav/", | ||||
|   "https://bora.teracloud.jp/dav/", | ||||
|   "https://webdav.4shared.com/", | ||||
|   "https://dav.idrivesync.com", | ||||
|   "https://webdav.yandex.com", | ||||
|   "https://app.koofr.net/dav/Koofr", | ||||
| ]; | ||||
|  | ||||
| export const DEFAULT_GA_ID = "G-89WN60ZK2E"; | ||||
| export const PLUGINS = [ | ||||
|   { name: "Plugins", path: Path.Plugins }, | ||||
|   { name: "Stable Diffusion", path: Path.Sd }, | ||||
|   { name: "Search Chat", path: Path.SearchChat }, | ||||
| ]; | ||||
|  | ||||
| export const SAAS_CHAT_URL = "https://nextchat.dev/chat"; | ||||
|   | ||||
							
								
								
									
										6
									
								
								app/global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -26,11 +26,5 @@ declare interface Window { | ||||
|       isPermissionGranted(): Promise<boolean>; | ||||
|       sendNotification(options: string | Options): void; | ||||
|     }; | ||||
|     http: { | ||||
|       fetch<T>( | ||||
|         url: string, | ||||
|         options?: Record<string, unknown>, | ||||
|       ): Promise<Response<T>>; | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -1 +0,0 @@ | ||||
| <svg class="icon--SJP_d" width="16" height="16" fill="none" viewBox="0 0 16 16" style="min-width: 16px; min-height: 16px;"><g><path data-follow-fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M5.248 14.444a.625.625 0 0 1-.005-.884l5.068-5.12a.625.625 0 0 0 0-.88L5.243 2.44a.625.625 0 1 1 .889-.88l5.067 5.121c.723.73.723 1.907 0 2.638l-5.067 5.12a.625.625 0 0 1-.884.005Z" fill="currentColor"></path></g></svg> | ||||
| Before Width: | Height: | Size: 426 B | 
| @@ -1,7 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" height="1.2rem" viewBox="0 0 24 24"> | ||||
|     <g fill="none" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> | ||||
|         <circle cx="12" cy="12" r="9" /> | ||||
|         <path | ||||
|             d="M11.307 9.739L15 9l-.739 3.693a2 2 0 0 1-1.568 1.569L9 15l.739-3.693a2 2 0 0 1 1.568-1.568" /> | ||||
|     </g> | ||||
| </svg> | ||||
| Before Width: | Height: | Size: 371 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12.832 21.801c3.126-.626 7.168-2.875 7.168-8.69c0-5.291-3.873-8.815-6.658-10.434c-.619-.36-1.342.113-1.342.828v1.828c0 1.442-.606 4.074-2.29 5.169c-.86.559-1.79-.278-1.894-1.298l-.086-.838c-.1-.974-1.092-1.565-1.87-.971C4.461 8.46 3 10.33 3 13.11C3 20.221 8.289 22 10.933 22q.232 0 .484-.015C10.111 21.874 8 21.064 8 18.444c0-2.05 1.495-3.435 2.631-4.11c.306-.18.663.055.663.41v.59c0 .45.175 1.155.59 1.637c.47.546 1.159-.026 1.214-.744c.018-.226.246-.37.442-.256c.641.375 1.46 1.175 1.46 2.473c0 2.048-1.129 2.99-2.168 3.357"/></svg> | ||||
| Before Width: | Height: | Size: 648 B | 
| @@ -1,4 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#333" class="bi bi-badge-hd" viewBox="0 0 16 16"> | ||||
|   <path d="M7.396 11V5.001H6.209v2.44H3.687V5H2.5v6h1.187V8.43h2.522V11zM8.5 5.001V11h2.188c1.811 0 2.685-1.107 2.685-3.015 0-1.894-.86-2.984-2.684-2.984zm1.187.967h.843c1.112 0 1.622.686 1.622 2.04 0 1.353-.505 2.02-1.622 2.02h-.843z"/> | ||||
|   <path d="M14 3a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/> | ||||
| </svg> | ||||
| Before Width: | Height: | Size: 514 B | 
| @@ -1,10 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|     <path d="M5.81836 6.72729V14H13.0911" stroke="#333" stroke-width="4" stroke-linecap="round" | ||||
|         stroke-linejoin="round" /> | ||||
|     <path | ||||
|         d="M4 24C4 35.0457 12.9543 44 24 44V44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C16.598 4 10.1351 8.02111 6.67677 13.9981" | ||||
|         stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" /> | ||||
|     <path d="M24.005 12L24.0038 24.0088L32.4832 32.4882" stroke="#333" stroke-width="4" | ||||
|         stroke-linecap="round" stroke-linejoin="round" /> | ||||
| </svg> | ||||
| Before Width: | Height: | Size: 660 B | 
| @@ -1,19 +0,0 @@ | ||||
| <svg width="38.73" height="42" viewBox="0 0 221 240" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|   <rect x="160.697" y="38.125" width="65.007" height="145.932" rx="32.503" transform="rotate(21.987 160.697 38.125)" fill="url(#logo_svg__a)"></rect> | ||||
|   <path fill-rule="evenodd" clip-rule="evenodd" d="m48.642 79.125-25.92 71.213c-6.139 16.869 2.558 35.52 19.427 41.66 16.868 6.14 35.52-2.558 41.66-19.426L94.23 143.94l-36.658-37.439a32.42 32.42 0 0 1-9.244-23.497c.033-1.326.14-2.62.314-3.879Z" fill="url(#logo_svg__b)"></path> | ||||
|   <path d="M172.578 132.787a32.765 32.765 0 0 1 8.981 24.238c-1.458 28.748-36.622 41.778-56.46 20.92l-67.644-71.122a32.763 32.763 0 0 1-8.981-24.238c1.457-28.748 36.622-41.778 56.46-20.92l67.644 71.122Z" fill="url(#logo_svg__c)" fill-opacity="0.96"></path> | ||||
|   <defs> | ||||
|     <linearGradient id="logo_svg__a" x1="215.063" y1="59.628" x2="160.714" y2="157.96" gradientUnits="userSpaceOnUse"> | ||||
|       <stop stop-color="#3EADFE"></stop> | ||||
|       <stop offset="1" stop-color="#2A7AFF"></stop> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="logo_svg__b" x1="105.376" y1="84.416" x2="19.745" y2="131.163" gradientUnits="userSpaceOnUse"> | ||||
|       <stop stop-color="#01B3FF"></stop> | ||||
|       <stop offset="1" stop-color="#59ECFA"></stop> | ||||
|     </linearGradient> | ||||
|     <linearGradient id="logo_svg__c" x1="102.734" y1="136.396" x2="192.577" y2="155.859" gradientUnits="userSpaceOnUse"> | ||||
|       <stop stop-color="#023BFF" stop-opacity="0.82"></stop> | ||||
|       <stop offset="0.88" stop-color="#2D86FF" stop-opacity="0.76"></stop> | ||||
|     </linearGradient> | ||||
|   </defs> | ||||
| </svg> | ||||
| Before Width: | Height: | Size: 1.5 KiB | 
| @@ -1,4 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#333" class="bi bi-palette" viewBox="0 0 16 16"> | ||||
|   <path d="M8 5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m4 3a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M5.5 7a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m.5 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/> | ||||
|   <path d="M16 8c0 3.15-1.866 2.585-3.567 2.07C11.42 9.763 10.465 9.473 10 10c-.603.683-.475 1.819-.351 2.92C9.826 14.495 9.996 16 8 16a8 8 0 1 1 8-8m-8 7c.611 0 .654-.171.655-.176.078-.146.124-.464.07-1.119-.014-.168-.037-.37-.061-.591-.052-.464-.112-1.005-.118-1.462-.01-.707.083-1.61.704-2.314.369-.417.845-.578 1.272-.618.404-.038.812.026 1.16.104.343.077.702.186 1.025.284l.028.008c.346.105.658.199.953.266.653.148.904.083.991.024C14.717 9.38 15 9.161 15 8a7 7 0 1 0-7 7"/> | ||||
| </svg> | ||||
| Before Width: | Height: | Size: 781 B | 
| @@ -1,12 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="1.21em" height="1em" viewBox="0 0 256 213"> | ||||
|     <defs> | ||||
|         <linearGradient id="logosStabilityAiIcon0" x1="50%" x2="50%" y1="0%" y2="100%"> | ||||
|             <stop offset="0%" stop-color="#9d39ff" /> | ||||
|             <stop offset="100%" stop-color="#a380ff" /> | ||||
|         </linearGradient> | ||||
|     </defs> | ||||
|     <path fill="url(#logosStabilityAiIcon0)" | ||||
|         d="M72.418 212.45c49.478 0 81.658-26.205 81.658-65.626c0-30.572-19.572-49.998-54.569-58.043l-22.469-6.74c-19.71-4.424-31.215-9.738-28.505-23.312c2.255-11.292 9.002-17.667 24.69-17.667c49.872 0 68.35 17.667 68.35 17.667V16.237S123.583 0 73.223 0C25.757 0 0 24.424 0 62.236c0 30.571 17.85 48.35 54.052 56.798q3.802.95 3.885.976q8.26 2.556 22.293 6.755c18.504 4.425 23.262 9.121 23.262 23.2c0 12.872-13.374 20.19-31.074 20.19C21.432 170.154 0 144.36 0 144.36v47.078s13.402 21.01 72.418 21.01" /> | ||||
|     <path fill="#e80000" | ||||
|         d="M225.442 209.266c17.515 0 30.558-12.67 30.558-29.812c0-17.515-12.67-29.813-30.558-29.813c-17.515 0-30.185 12.298-30.185 29.813s12.67 29.812 30.185 29.812" /> | ||||
| </svg> | ||||
| Before Width: | Height: | Size: 1.1 KiB | 
| @@ -1 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?><svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M42 7H6C4.89543 7 4 7.89543 4 9V37C4 38.1046 4.89543 39 6 39H42C43.1046 39 44 38.1046 44 37V9C44 7.89543 43.1046 7 42 7Z" fill="none" stroke="#000" stroke-width="3" stroke-linejoin="round"/><path d="M12 19H14" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M21 19H23" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M29 19H36" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 28H36" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg> | ||||
| Before Width: | Height: | Size: 734 B | 
| @@ -1 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?><svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M42 7H6C4.89543 7 4 7.89543 4 9V39C4 40.1046 4.89543 41 6 41H42C43.1046 41 44 40.1046 44 39V9C44 7.89543 43.1046 7 42 7Z" fill="none" stroke="#333" stroke-width="4"/><path d="M30 30V18L38 30V18" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 30V18L18 30V18" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M24 20V21" stroke="#333" stroke-width="4" stroke-linecap="round"/><path d="M24 27V28" stroke="#333" stroke-width="4" stroke-linecap="round"/></svg> | ||||
| Before Width: | Height: | Size: 681 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" width="16" height="16" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"></path></svg> | ||||
| Before Width: | Height: | Size: 495 B |