Compare commits

...

46 Commits

Author SHA1 Message Date
Seefs
725d61c5d3 feat: ionet integrate (#2105)
* wip ionet integrate

* wip ionet integrate

* wip ionet integrate

* ollama wip

* wip

* feat: ionet integration & ollama manage

* fix merge conflict

* wip

* fix: test conn cors

* wip

* fix ionet

* fix ionet

* wip

* fix model select

* refactor: Remove `pkg/ionet` test files and update related Go source and web UI model deployment components.

* feat: Enhance model deployment UI with styling improvements, updated text, and a new description component.

* Revert "feat: Enhance model deployment UI with styling improvements, updated text, and a new description component."

This reverts commit 8b75cb5bf0d1a534b339df8c033be9a6c7df7964.
2025-12-28 15:55:35 +08:00
Seefs
1a69a93d20 Merge pull request #2536 from RedwindA/feat/oaiDevRole2Gemini 2025-12-28 15:52:45 +08:00
RedwindA
1de78f8749 feat: map OpenAI developer role to Gemini system instructions 2025-12-27 02:52:33 +08:00
skynono
9aeef6abec feat: support first bind update password (#2520) 2025-12-26 13:59:56 +08:00
Seefs
58db72d459 fix: Fix Openrouter test errors and optimize error messages (#2433)
* fix: Refine openrouter error

* fix: Refine openrouter error

* fix: openrouter test max_output_token

* fix: optimize messages

* fix: maxToken unified to 16

* fix: codex系列模型使用 responses接口

* fix: codex系列模型使用 responses接口

* fix: 状态码非200打印错误信息

* fix: 日志里没有报错的响应体
2025-12-26 13:58:44 +08:00
Calcium-Ion
654bb10b45 Merge pull request #2460 from seefs001/feature/gemini-flash-minial
fix(gemini): handle minimal reasoning effort budget
2025-12-26 13:57:56 +08:00
Seefs
f51b5bb0c8 Merge pull request #2455 from comeback01/french-translation 2025-12-26 13:56:30 +08:00
Calcium-Ion
a4cd84f276 Merge pull request #2450 from seefs001/fix/gemini-system-prompt
fix: 支持传入system_instruction和systemInstruction两种风格系统提示词参数名
2025-12-26 13:54:21 +08:00
Calcium-Ion
c722ddd58b Merge pull request #2512 from seefs001/fix/warning-pass-through-body
fix: add warning for pass through body
2025-12-26 13:52:51 +08:00
Calcium-Ion
88e394a976 Merge pull request #2513 from seefs001/fix/token-auth-bearer
fix: 支持小写bearer和Bearer后带多个空格 && 修复 WSS预扣费错误提取key的问题
2025-12-26 13:51:32 +08:00
Seefs
31a3487139 Merge pull request #2528 from QuantumNous/fix/model-sync-overwrite-empty-missing 2025-12-26 13:49:55 +08:00
Seefs
a07406d97e Merge pull request #2530 from RedwindA/fix/i18n-with-http 2025-12-26 13:49:30 +08:00
RedwindA
f68858121c fix(i18n): disable namespace separator to fix URL display in translations
i18next uses ':' as namespace separator by default, causing URLs like
'https://api.openai.com' to be incorrectly parsed as namespace 'https'
with key '//api.openai.com', resulting in truncated display.

Setting nsSeparator to false fixes this issue since the project doesn't
use multiple namespaces.
2025-12-26 00:10:19 +08:00
t0ng7u
83fbaba768 🚀 fix(model-sync): avoid unnecessary upstream fetch while keeping overwrite updates working
- Only short-circuit when there are no missing models AND no overwrite fields requested
- Preserve overwrite behavior even when the missing-model list is empty
- Always return empty arrays (not null) for list fields to keep API responses stable
- Clarify SyncUpstreamModels behavior in comments (create missing models + optional overwrite updates)
2025-12-25 23:01:09 +08:00
Calcium-Ion
d3c854fbed Merge pull request #2154 from feitianbubu/pr/fix-model-sync
fix: ensure overwrite works correctly when no missing models
2025-12-25 22:34:49 +08:00
Calcium-Ion
97b02685b1 Merge pull request #2475 from seefs001/feature/pyro
feat: pyroscope integrate
2025-12-25 17:54:39 +08:00
Seefs
da1b51ac31 Merge branch 'upstream-main' into feature/pyro 2025-12-25 17:08:02 +08:00
CaIon
f17b3810d6 feat(user): simplify user response structure in JSON output 2025-12-25 15:39:58 +08:00
Calcium-Ion
8206084a77 Merge pull request #2524 from seefs001/fix/revert-model-ratio
fix: revert model ratio
2025-12-25 15:38:36 +08:00
Seefs
559da6362a fix: revert model ratio 2025-12-25 15:37:54 +08:00
Calcium-Ion
0b1a562df9 Merge pull request #2477 from 1420970597/fix/anthropic-cache-billing
fix: 修复 Anthropic 渠道缓存计费错误
2025-12-24 16:59:23 +08:00
Seefs
a0c3d37d66 Merge pull request #2493 from shikaiwei1/patch-1 2025-12-24 16:52:24 +08:00
Seefs
347f2326f3 Merge pull request #2511 from JerryKwan/issue2499 2025-12-24 16:51:51 +08:00
Seefs
14c58aea77 fix: 支持小写bearer和Bearer后带多个空格 && 修复 WSS预扣费错误提取key的问题 2025-12-24 15:52:56 +08:00
Seefs
09f3957362 fix: add warning for pass through body 2025-12-24 15:35:36 +08:00
Jerry
31a79620ba Resolving event mismatch in OpenAI2Claude
add stricter validation for content_block_start corresponding to
tool call
and fix the crash issue when Claude Code is processing tool call
2025-12-24 14:52:39 +08:00
Calcium-Ion
12555a37d3 Merge pull request #2510 from feitianbubu/pr/0e7050dc89c1b761069f5e528d8ecf786e7008ae
修复claudeResponse流式请求空指针Panic
2025-12-24 14:15:51 +08:00
feitianbubu
3652dfdbd5 fix: check claudeResponse delta StopReason nil point 2025-12-24 11:54:23 +08:00
CaIon
42109c5840 feat(token): enhance error handling in ValidateUserToken for better clarity 2025-12-22 18:01:38 +08:00
John Chen
dbaba87c39 为Moonshot添加缓存tokens读取逻辑
为Moonshot添加缓存tokens读取逻辑。其与智普V4的逻辑相同,所以共用逻辑
2025-12-22 17:05:16 +08:00
Calcium-Ion
afd9c29ace Merge pull request #2486 from QuantumNous/docs/readme-update-doc-links-new-routing
🔗 docs(readme): update documentation links to new site routing
2025-12-21 21:28:35 +08:00
t0ng7u
470e0304d8 🔗 docs(readme): revert missing docs links to legacy site
Keep new-site links (/{lang}/docs/...) where matching pages exist in the current docs repo
Revert links that have no equivalent in the new docs to the legacy paths on doc.newapi.pro:
Google Gemini Chat
Midjourney-Proxy image docs
Suno music docs
Apply the same rule consistently across all README translations (zh/en/ja/fr)
2025-12-21 21:18:59 +08:00
t0ng7u
d6e97ab184 🔗 docs(readme): update documentation links to new site routing
- Replace legacy `docs.newapi.pro` paths with the new `/{lang}/docs/...` structure across all README translations
- Point key sections (installation, env vars, API, support, features) to their new locations
- Ensure language-specific links use the correct locale prefix (zh/en/ja) and keep FR aligned with English routes
2025-12-21 21:00:33 +08:00
Calcium-Ion
d8aa327f05 Merge pull request #2483 from seefs001/fix/vertex-function-response-id
fix: 模型设置增加针对Vertex渠道过滤content[].part[].functionResponse.id的选项,默认启用
2025-12-21 17:24:07 +08:00
Seefs
28f7a4feef fix: 在Vertex Adapter过滤content[].part[].functionResponse.id 2025-12-21 17:22:04 +08:00
Seefs
5a64ae2a29 fix: 模型设置增加针对Vertex渠道过滤content[].part[].functionResponse.id的选项,默认启用 2025-12-21 17:09:49 +08:00
comeback01
f04ed7584a Merge branch 'main' into french-translation 2025-12-20 11:08:07 +01:00
长安
0a2f12c04e fix: 修复 Anthropic 渠道缓存计费错误
## 问题描述

当使用 Anthropic 渠道通过 `/v1/chat/completions` 端点调用且启用缓存功能时,
计费逻辑错误地减去了缓存 tokens,导致严重的收入损失(94.5%)。

## 根本原因

不同 API 的 `prompt_tokens` 定义不同:

- **Anthropic API**: `input_tokens` 字段已经是纯输入 tokens(不包含缓存)
- **OpenAI API**: `prompt_tokens` 字段包含所有 tokens(包含缓存)
- **OpenRouter API**: `prompt_tokens` 字段包含所有 tokens(包含缓存)

当前 `postConsumeQuota` 函数对所有渠道都减去缓存 tokens,这对 Anthropic
渠道是错误的,因为其 `input_tokens` 已经不包含缓存。

## 修复方案

在 `relay/compatible_handler.go` 的 `postConsumeQuota` 函数中,添加渠道类型判断:

```go
if relayInfo.ChannelType != constant.ChannelTypeAnthropic {
    baseTokens = baseTokens.Sub(dCacheTokens)
}
```

只对非 Anthropic 渠道减去缓存 tokens。

## 影响分析

###  不受影响的场景

1. **无缓存调用**(所有渠道)
   - cache_tokens = 0
   - 减去 0 = 不减去
   - 结果:完全一致

2. **OpenAI/OpenRouter 渠道 + 缓存**
   - 继续减去缓存(因为 ChannelType != Anthropic)
   - 结果:完全一致

3. **Anthropic 渠道 + /v1/messages 端点**
   - 使用 PostClaudeConsumeQuota(不修改)
   - 结果:完全不受影响

###  修复的场景

4. **Anthropic 渠道 + /v1/chat/completions + 缓存**
   - 修复前:错误地减去缓存,导致 94.5% 收入损失
   - 修复后:不减去缓存,计费正确

## 验证数据

以实际记录 143509 为例:

| 项目 | 修复前 | 修复后 | 差异 |
|------|--------|--------|------|
| Quota | 10,489 | 191,330 | +180,841 |
| 费用 | ¥0.020978 | ¥0.382660 | +¥0.361682 |
| 收入恢复 | - | - | **+1724.1%** |

## 测试建议

1. 测试 Anthropic 渠道 + 缓存场景
2. 测试 OpenAI 渠道 + 缓存场景(确保不受影响)
3. 测试无缓存场景(确保不受影响)

## 相关 Issue

修复 Anthropic 渠道使用 prompt caching 时的计费错误。
2025-12-20 14:17:12 +08:00
Seefs
531dfb2555 docs: document pyroscope env var 2025-12-19 23:16:56 +08:00
Seefs
5ef7247eac docs: document pyroscope env var 2025-12-19 23:03:04 +08:00
Seefs
1168ddf9f9 fix: systemname 2025-12-19 22:27:35 +08:00
Seefs
da24a165d0 fix(gemini): handle minimal reasoning effort budget
- Add minimal case to clampThinkingBudgetByEffort to avoid defaulting to full thinking budget
2025-12-18 08:10:46 +08:00
comeback01
f88fc26150 Refine French translations for UI conciseness
Updated web/src/i18n/locales/fr.json to improve French translations for the user interface.

Removed verbose prefixes like 'Gestion des...' and 'Paramètres de...' to prevent truncation in sidebars and menus.

Harmonized terms for consistency (e.g., 'Tâches', 'Journaux', 'Dessins').

Renamed 'Place du marché' to 'Marché des modèles'.
2025-12-17 12:10:36 +01:00
Seefs
2a511c6ee4 fix: 支持传入system_instruction和systemInstruction两种风格系统提示词参数名 2025-12-16 13:08:58 +08:00
Seefs
fcafadc6bb feat: pyroscope integrate 2025-12-13 13:49:38 +08:00
feitianbubu
35538ecb3b fix: ensure overwrite works correctly when no missing models 2025-11-03 17:50:00 +08:00
91 changed files with 12639 additions and 677 deletions

View File

@@ -6,4 +6,5 @@
Makefile
docs
.eslintcache
.gocache
.gocache
/web/node_modules

View File

@@ -9,6 +9,14 @@
# ENABLE_PPROF=true
# 启用调试模式
# DEBUG=true
# Pyroscope 配置
# PYROSCOPE_URL=http://localhost:4040
# PYROSCOPE_APP_NAME=new-api
# PYROSCOPE_BASIC_AUTH_USER=your-user
# PYROSCOPE_BASIC_AUTH_PASSWORD=your-password
# PYROSCOPE_MUTEX_RATE=5
# PYROSCOPE_BLOCK_RATE=5
# HOSTNAME=your-hostname
# 数据库相关配置
# 数据库连接字符串

4
.gitignore vendored
View File

@@ -16,9 +16,13 @@ new-api
tiktoken_cache
.eslintcache
.gocache
.gomodcache/
.cache
web/bun.lock
electron/node_modules
electron/dist
data/
.gomodcache/
.gocache-temp
.gopath

View File

@@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/installation)
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
---
@@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
<div align="center">
### 📖 [Official Documentation](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \
| Category | Link |
|------|------|
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/installation) |
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/installation/environment-variables) |
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) |
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
---
## ✨ Key Features
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction)
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
### 🎨 Core Functions
@@ -201,11 +201,11 @@ docker run --name new-api -d --restart always \
### 🚀 Advanced Features
**API Format Support:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (including Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerank Models](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina)
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
- 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
**Intelligent Routing:**
- ⚖️ Channel weighted random
@@ -246,16 +246,16 @@ docker run --name new-api -d --restart always \
## 🤖 Model Support
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/api)
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
| Model Type | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
| 🔧 Dify | ChatFlow mode | - |
| 🎯 Custom | Supports complete call address | - |
@@ -264,16 +264,16 @@ docker run --name new-api -d --restart always \
<details>
<summary>View complete interface list</summary>
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [Response Interface (Responses)](https://docs.newapi.pro/api/openai-responses)
- [Image Interface (Image)](https://docs.newapi.pro/api/openai-image)
- [Audio Interface (Audio)](https://docs.newapi.pro/api/openai-audio)
- [Video Interface (Video)](https://docs.newapi.pro/api/openai-video)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claude Chat](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini Chat](https://docs.newapi.pro/api/google-gemini-chat/)
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
- [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- [Google Gemini Chat](https://doc.newapi.pro/en/api/google-gemini-chat)
</details>
@@ -308,8 +308,15 @@ docker run --name new-api -d --restart always \
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
| `PYROSCOPE_URL` | Pyroscope server address | - |
| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables)
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
</details>
@@ -411,10 +418,10 @@ docker run --name new-api -d --restart always \
| Resource | Link |
|------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) |
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/support/feedback-issues) |
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/support) |
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
### 🤝 Contribution Guide
@@ -443,7 +450,7 @@ Welcome all forms of contribution!
If this project is helpful to you, welcome to give us a ⭐️ Star
**[Official Documentation](https://docs.newapi.pro/)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
**[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub>

View File

@@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/installation)
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/en/docs/installation)
---
@@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
<div align="center">
### 📖 [Documentation officielle](https://docs.newapi.pro/) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
### 📖 [Documentation officielle](https://docs.newapi.pro/en/docs) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \
| Catégorie | Lien |
|------|------|
| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/installation) |
| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/installation/environment-variables) |
| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) |
| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/en/docs/installation) |
| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/en/docs/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) |
---
## ✨ Fonctionnalités clés
> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) |
> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction) |
### 🎨 Fonctions principales
@@ -200,11 +200,11 @@ docker run --name new-api -d --restart always \
### 🚀 Fonctionnalités avancées
**Prise en charge des formats d'API:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (y compris Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Modèles Rerank](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina)
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (y compris Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
- 🔄 [Modèles Rerank](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
**Routage intelligent:**
- ⚖️ Sélection aléatoire pondérée des canaux
@@ -242,16 +242,16 @@ docker run --name new-api -d --restart always \
## 🤖 Prise en charge des modèles
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/api)
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/en/docs/api)
| Type de modèle | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI GPTs | série gpt-4-gizmo-* | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
| 🌐 Gemini | Format Google Gemini | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
| 🔧 Dify | Mode ChatFlow | - |
| 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - |
@@ -260,16 +260,16 @@ docker run --name new-api -d --restart always \
<details>
<summary>Voir la liste complète des interfaces</summary>
- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [Interface de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
- [Interface d'image (Image)](https://docs.newapi.pro/api/openai-image)
- [Interface audio (Audio)](https://docs.newapi.pro/api/openai-audio)
- [Interface vidéo (Video)](https://docs.newapi.pro/api/openai-video)
- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [Interface de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
- [Discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
- [Interface de réponse (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- [Interface d'image (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
- [Interface audio (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
- [Interface vidéo (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation)
- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
- [Interface de rerank (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
- [Discussion Claude](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- [Discussion Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
</details>
@@ -304,8 +304,15 @@ docker run --name new-api -d --restart always \
| `MAX_REQUEST_BODY_MB` | Taille maximale du corps de requête (Mo, comptée **après décompression** ; évite les requêtes énormes/zip bombs qui saturent la mémoire). Dépassement ⇒ `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` |
| `PYROSCOPE_URL` | Adresse du serveur Pyroscope | - |
| `PYROSCOPE_APP_NAME` | Nom de l'application Pyroscope | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Utilisateur Basic Auth Pyroscope | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Mot de passe Basic Auth Pyroscope | - |
| `PYROSCOPE_MUTEX_RATE` | Taux d'échantillonnage mutex Pyroscope | `5` |
| `PYROSCOPE_BLOCK_RATE` | Taux d'échantillonnage block Pyroscope | `5` |
| `HOSTNAME` | Nom d'hôte tagué pour Pyroscope | `new-api` |
📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/installation/environment-variables)
📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
</details>
@@ -405,10 +412,10 @@ docker run --name new-api -d --restart always \
| Ressource | Lien |
|------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) |
| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues) |
| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/support) |
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) |
| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/en/docs/support/feedback-issues) |
| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/en/docs) |
### 🤝 Guide de contribution
@@ -437,7 +444,7 @@ Bienvenue à toutes les formes de contribution!
Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile
**[Documentation officielle](https://docs.newapi.pro/)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
**[Documentation officielle](https://docs.newapi.pro/en/docs)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Construit avec ❤️ par QuantumNous</sub>

View File

@@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/installation)を参照してください。
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/ja/docs/installation)を参照してください。
---
@@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
<div align="center">
### 📖 [公式ドキュメント](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
### 📖 [公式ドキュメント](https://docs.newapi.pro/ja/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \
| カテゴリ | リンク |
|------|------|
| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/installation) |
| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/installation/environment-variables) |
| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/api) |
| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) |
| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/ja/docs/installation) |
| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables) |
| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/ja/docs/api) |
| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) |
---
## ✨ 主な機能
> 詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください。
> 詳細な機能については[機能説明](https://docs.newapi.pro/ja/docs/guide/wiki/basic-concepts/features-introduction)を参照してください。
### 🎨 コア機能
@@ -202,15 +202,15 @@ docker run --name new-api -d --restart always \
### 🚀 高度な機能
**APIフォーマットサポート:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)Azureを含む
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)Cohere、Jina
- ⚡ [OpenAI Responses](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)Azureを含む
- ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/ja/api/google-gemini-chat)
- 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)
- ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/ja/api/google-gemini-chat)
- 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)Cohere、Jina
**インテリジェントルーティング:**
- ⚖️ チャネル重み付けランダム
@@ -251,16 +251,16 @@ docker run --name new-api -d --restart always \
## 🤖 モデルサポート
> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/api)
> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/ja/docs/api)
| モデルタイプ | 説明 | ドキュメント |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* シリーズ | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/api/suno-music) |
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://doc.newapi.pro/ja/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://doc.newapi.pro/ja/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) |
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://doc.newapi.pro/ja/api/google-gemini-chat) |
| 🔧 Dify | ChatFlowモード | - |
| 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - |
@@ -269,16 +269,16 @@ docker run --name new-api -d --restart always \
<details>
<summary>完全なインターフェースリストを表示</summary>
- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/api/openai-responses)
- [イメージインターフェース (Image)](https://docs.newapi.pro/api/openai-image)
- [オーディオインターフェース (Audio)](https://docs.newapi.pro/api/openai-audio)
- [ビデオインターフェース (Video)](https://docs.newapi.pro/api/openai-video)
- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claudeチャット](https://docs.newapi.pro/api/anthropic-chat)
- [Google Geminiチャット](https://docs.newapi.pro/api/google-gemini-chat/)
- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion)
- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response)
- [イメージインターフェース (Image)](https://docs.newapi.pro/ja/docs/api/ai-model/images/openai/v1-images-generations--post)
- [オーディオインターフェース (Audio)](https://docs.newapi.pro/ja/docs/api/ai-model/audio/openai/create-transcription)
- [ビデオインターフェース (Video)](https://docs.newapi.pro/ja/docs/api/ai-model/videos/create-video-generation)
- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/ja/docs/api/ai-model/embeddings/create-embedding)
- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)
- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)
- [Claudeチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message)
- [Google Geminiチャット](https://doc.newapi.pro/ja/api/google-gemini-chat)
</details>
@@ -313,8 +313,15 @@ docker run --name new-api -d --restart always \
| `MAX_REQUEST_BODY_MB` | リクエストボディ最大サイズMB、**解凍後**に計測。巨大リクエスト/zip bomb によるメモリ枯渇を防止)。超過時は `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` |
| `PYROSCOPE_URL` | Pyroscopeサーバーのアドレス | - |
| `PYROSCOPE_APP_NAME` | Pyroscopeアプリ名 | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Authユーザー | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Authパスワード | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutexサンプリング率 | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope blockサンプリング率 | `5` |
| `HOSTNAME` | Pyroscope用のホスト名タグ | `new-api` |
📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/installation/environment-variables)
📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables)
</details>
@@ -414,10 +421,10 @@ docker run --name new-api -d --restart always \
| リソース | リンク |
|------|------|
| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) |
| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/support/feedback-issues) |
| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/support) |
| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) |
| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/ja/docs/support/feedback-issues) |
| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/ja/docs) |
### 🤝 貢献ガイド
@@ -446,7 +453,7 @@ docker run --name new-api -d --restart always \
このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください!
**[公式ドキュメント](https://docs.newapi.pro/)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
**[公式ドキュメント](https://docs.newapi.pro/ja/docs)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
<sub>❤️ で構築された QuantumNous</sub>

View File

@@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \
🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/installation)
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
---
@@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
<div align="center">
### 📖 [官方文档](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
### 📖 [官方文档](https://docs.newapi.pro/zh/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \
| 分类 | 链接 |
|------|------|
| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/installation) |
| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/installation/environment-variables) |
| 📡 接口文档 | [API 文档](https://docs.newapi.pro/api) |
| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) |
| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/zh/docs/installation) |
| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) |
| 📡 接口文档 | [API 文档](https://docs.newapi.pro/zh/docs/api) |
| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
---
## ✨ 主要特性
> 详细特性请参考 [特性说明](https://docs.newapi.pro/wiki/features-introduction)
> 详细特性请参考 [特性说明](https://docs.newapi.pro/zh/docs/guide/wiki/basic-concepts/features-introduction)
### 🎨 核心功能
@@ -202,11 +202,11 @@ docker run --name new-api -d --restart always \
### 🚀 高级功能
**API 格式支持:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(含 Azure
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerank 模型](https://docs.newapi.pro/api/jinaai-rerank)Cohere、Jina
- ⚡ [OpenAI Responses](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)(含 Azure
- ⚡ [Claude Messages](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/api/google-gemini-chat)
- 🔄 [Rerank 模型](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)Cohere、Jina
**智能路由:**
- ⚖️ 渠道加权随机
@@ -247,16 +247,16 @@ docker run --name new-api -d --restart always \
## 🤖 模型支持
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/api)
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/zh/docs/api)
| 模型类型 | 说明 | 文档 |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://doc.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://doc.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message) |
| 🌐 Gemini | Google Gemini 格式 | [文档](https://doc.newapi.pro/api/google-gemini-chat) |
| 🔧 Dify | ChatFlow 模式 | - |
| 🎯 自定义 | 支持完整调用地址 | - |
@@ -265,16 +265,16 @@ docker run --name new-api -d --restart always \
<details>
<summary>查看完整接口列表</summary>
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses)
- [图像接口 (Image)](https://docs.newapi.pro/api/openai-image)
- [音频接口 (Audio)](https://docs.newapi.pro/api/openai-audio)
- [视频接口 (Video)](https://docs.newapi.pro/api/openai-video)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [重排序接口 (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [实时对话 (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claude 聊天](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini 聊天](https://docs.newapi.pro/api/google-gemini-chat)
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion)
- [响应接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response)
- [图像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/v1-images-generations--post)
- [音频接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription)
- [视频接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/create-video-generation)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/create-embedding)
- [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)
- [实时对话 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)
- [Claude 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message)
- [Google Gemini 聊天](https://doc.newapi.pro/api/google-gemini-chat)
</details>
@@ -309,8 +309,15 @@ docker run --name new-api -d --restart always \
| `MAX_REQUEST_BODY_MB` | 请求体最大大小MB**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` |
| `PYROSCOPE_URL` | Pyroscope 服务地址 | - |
| `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密码 | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex 采样率 | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block 采样率 | `5` |
| `HOSTNAME` | Pyroscope 标签里的主机名 | `new-api` |
📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables)
📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables)
</details>
@@ -412,10 +419,10 @@ docker run --name new-api -d --restart always \
| 资源 | 链接 |
|------|------|
| 📘 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) |
| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/support/feedback-issues) |
| 📚 完整文档 | [官方文档](https://docs.newapi.pro/support) |
| 📘 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/zh/docs/support/feedback-issues) |
| 📚 完整文档 | [官方文档](https://docs.newapi.pro/zh/docs) |
### 🤝 贡献指南
@@ -444,7 +451,7 @@ docker run --name new-api -d --restart always \
如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star
**[官方文档](https://docs.newapi.pro/)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
**[官方文档](https://docs.newapi.pro/zh/docs)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub>

56
common/pyro.go Normal file
View File

@@ -0,0 +1,56 @@
package common
import (
"runtime"
"github.com/grafana/pyroscope-go"
)
func StartPyroScope() error {
pyroscopeUrl := GetEnvOrDefaultString("PYROSCOPE_URL", "")
if pyroscopeUrl == "" {
return nil
}
pyroscopeAppName := GetEnvOrDefaultString("PYROSCOPE_APP_NAME", "new-api")
pyroscopeBasicAuthUser := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_USER", "")
pyroscopeBasicAuthPassword := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_PASSWORD", "")
pyroscopeHostname := GetEnvOrDefaultString("HOSTNAME", "new-api")
mutexRate := GetEnvOrDefault("PYROSCOPE_MUTEX_RATE", 5)
blockRate := GetEnvOrDefault("PYROSCOPE_BLOCK_RATE", 5)
runtime.SetMutexProfileFraction(mutexRate)
runtime.SetBlockProfileRate(blockRate)
_, err := pyroscope.Start(pyroscope.Config{
ApplicationName: pyroscopeAppName,
ServerAddress: pyroscopeUrl,
BasicAuthUser: pyroscopeBasicAuthUser,
BasicAuthPassword: pyroscopeBasicAuthPassword,
Logger: nil,
Tags: map[string]string{"hostname": pyroscopeHostname},
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
pyroscope.ProfileGoroutines,
pyroscope.ProfileMutexCount,
pyroscope.ProfileMutexDuration,
pyroscope.ProfileBlockCount,
pyroscope.ProfileBlockDuration,
},
})
if err != nil {
return err
}
return nil
}

View File

@@ -97,6 +97,11 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
}
// responses-only models
if strings.Contains(strings.ToLower(testModel), "codex") {
requestPath = "/v1/responses"
}
}
c.Request = &http.Request{
@@ -176,7 +181,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
}
}
request := buildTestRequest(testModel, endpointType)
request := buildTestRequest(testModel, endpointType, channel)
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
@@ -319,6 +324,16 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
common.SysError(fmt.Sprintf(
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
channel.Id,
channel.Name,
channel.Type,
testModel,
endpointType,
httpResp.StatusCode,
err,
))
return testResult{
context: c,
localErr: err,
@@ -389,7 +404,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
}
}
func buildTestRequest(model string, endpointType string) dto.Request {
func buildTestRequest(model string, endpointType string, channel *model.Channel) dto.Request {
// 根据端点类型构建不同的测试请求
if endpointType != "" {
switch constant.EndpointType(endpointType) {
@@ -423,7 +438,7 @@ func buildTestRequest(model string, endpointType string) dto.Request {
}
case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI:
// 返回 GeneralOpenAIRequest
maxTokens := uint(10)
maxTokens := uint(16)
if constant.EndpointType(endpointType) == constant.EndpointTypeGemini {
maxTokens = 3000
}
@@ -453,6 +468,14 @@ func buildTestRequest(model string, endpointType string) dto.Request {
}
}
// Responses-only models (e.g. codex series)
if strings.Contains(strings.ToLower(model), "codex") {
return &dto.OpenAIResponsesRequest{
Model: model,
Input: json.RawMessage("\"hi\""),
}
}
// Chat/Completion 请求 - 返回 GeneralOpenAIRequest
testRequest := &dto.GeneralOpenAIRequest{
Model: model,
@@ -466,7 +489,7 @@ func buildTestRequest(model string, endpointType string) dto.Request {
}
if strings.HasPrefix(model, "o") {
testRequest.MaxCompletionTokens = 10
testRequest.MaxCompletionTokens = 16
} else if strings.Contains(model, "thinking") {
if !strings.Contains(model, "claude") {
testRequest.MaxTokens = 50
@@ -474,7 +497,7 @@ func buildTestRequest(model string, endpointType string) dto.Request {
} else if strings.Contains(model, "gemini") {
testRequest.MaxTokens = 3000
} else {
testRequest.MaxTokens = 10
testRequest.MaxTokens = 16
}
return testRequest

View File

@@ -11,16 +11,18 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/ollama"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
type OpenAIModel struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
Metadata map[string]any `json:"metadata,omitempty"`
Permission []struct {
ID string `json:"id"`
Object string `json:"object"`
@@ -207,6 +209,57 @@ func FetchUpstreamModels(c *gin.Context) {
baseURL = channel.GetBaseURL()
}
// 对于 Ollama 渠道,使用特殊处理
if channel.Type == constant.ChannelTypeOllama {
key := strings.Split(channel.Key, "\n")[0]
models, err := ollama.FetchOllamaModels(baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
})
return
}
result := OpenAIModelsResponse{
Data: make([]OpenAIModel, 0, len(models)),
}
for _, modelInfo := range models {
metadata := map[string]any{}
if modelInfo.Size > 0 {
metadata["size"] = modelInfo.Size
}
if modelInfo.Digest != "" {
metadata["digest"] = modelInfo.Digest
}
if modelInfo.ModifiedAt != "" {
metadata["modified_at"] = modelInfo.ModifiedAt
}
details := modelInfo.Details
if details.ParentModel != "" || details.Format != "" || details.Family != "" || len(details.Families) > 0 || details.ParameterSize != "" || details.QuantizationLevel != "" {
metadata["details"] = modelInfo.Details
}
if len(metadata) == 0 {
metadata = nil
}
result.Data = append(result.Data, OpenAIModel{
ID: modelInfo.Name,
Object: "model",
Created: 0,
OwnedBy: "ollama",
Metadata: metadata,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result.Data,
})
return
}
var url string
switch channel.Type {
case constant.ChannelTypeGemini:
@@ -975,6 +1028,32 @@ func FetchModels(c *gin.Context) {
baseURL = constant.ChannelBaseURLs[req.Type]
}
// remove line breaks and extra spaces.
key := strings.TrimSpace(req.Key)
key = strings.Split(key, "\n")[0]
if req.Type == constant.ChannelTypeOllama {
models, err := ollama.FetchOllamaModels(baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
})
return
}
names := make([]string, 0, len(models))
for _, modelInfo := range models {
names = append(names, modelInfo.Name)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": names,
})
return
}
client := &http.Client{}
url := fmt.Sprintf("%s/v1/models", baseURL)
@@ -987,10 +1066,6 @@ func FetchModels(c *gin.Context) {
return
}
// remove line breaks and extra spaces.
key := strings.TrimSpace(req.Key)
// If the key contains a line break, only take the first part.
key = strings.Split(key, "\n")[0]
request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request)
@@ -1640,3 +1715,262 @@ func ManageMultiKeys(c *gin.Context) {
return
}
}
// OllamaPullModel 拉取 Ollama 模型
func OllamaPullModel(c *gin.Context) {
var req struct {
ChannelID int `json:"channel_id"`
ModelName string `json:"model_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request parameters",
})
return
}
if req.ChannelID == 0 || req.ModelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Channel ID and model name are required",
})
return
}
// 获取渠道信息
channel, err := model.GetChannelById(req.ChannelID, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "Channel not found",
})
return
}
// 检查是否是 Ollama 渠道
if channel.Type != constant.ChannelTypeOllama {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "This operation is only supported for Ollama channels",
})
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
key := strings.Split(channel.Key, "\n")[0]
err = ollama.PullOllamaModel(baseURL, key, req.ModelName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": fmt.Sprintf("Failed to pull model: %s", err.Error()),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("Model %s pulled successfully", req.ModelName),
})
}
// OllamaPullModelStream 流式拉取 Ollama 模型
func OllamaPullModelStream(c *gin.Context) {
var req struct {
ChannelID int `json:"channel_id"`
ModelName string `json:"model_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request parameters",
})
return
}
if req.ChannelID == 0 || req.ModelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Channel ID and model name are required",
})
return
}
// 获取渠道信息
channel, err := model.GetChannelById(req.ChannelID, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "Channel not found",
})
return
}
// 检查是否是 Ollama 渠道
if channel.Type != constant.ChannelTypeOllama {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "This operation is only supported for Ollama channels",
})
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
// 设置 SSE 头部
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
key := strings.Split(channel.Key, "\n")[0]
// 创建进度回调函数
progressCallback := func(progress ollama.OllamaPullResponse) {
data, _ := json.Marshal(progress)
fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
c.Writer.Flush()
}
// 执行拉取
err = ollama.PullOllamaModelStream(baseURL, key, req.ModelName, progressCallback)
if err != nil {
errorData, _ := json.Marshal(gin.H{
"error": err.Error(),
})
fmt.Fprintf(c.Writer, "data: %s\n\n", string(errorData))
} else {
successData, _ := json.Marshal(gin.H{
"message": fmt.Sprintf("Model %s pulled successfully", req.ModelName),
})
fmt.Fprintf(c.Writer, "data: %s\n\n", string(successData))
}
// 发送结束标志
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
c.Writer.Flush()
}
// OllamaDeleteModel 删除 Ollama 模型
func OllamaDeleteModel(c *gin.Context) {
var req struct {
ChannelID int `json:"channel_id"`
ModelName string `json:"model_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request parameters",
})
return
}
if req.ChannelID == 0 || req.ModelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Channel ID and model name are required",
})
return
}
// 获取渠道信息
channel, err := model.GetChannelById(req.ChannelID, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "Channel not found",
})
return
}
// 检查是否是 Ollama 渠道
if channel.Type != constant.ChannelTypeOllama {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "This operation is only supported for Ollama channels",
})
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
key := strings.Split(channel.Key, "\n")[0]
err = ollama.DeleteOllamaModel(baseURL, key, req.ModelName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": fmt.Sprintf("Failed to delete model: %s", err.Error()),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("Model %s deleted successfully", req.ModelName),
})
}
// OllamaVersion 获取 Ollama 服务版本信息
func OllamaVersion(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid channel id",
})
return
}
channel, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "Channel not found",
})
return
}
if channel.Type != constant.ChannelTypeOllama {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "This operation is only supported for Ollama channels",
})
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
key := strings.Split(channel.Key, "\n")[0]
version, err := ollama.FetchOllamaVersion(baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取Ollama版本失败: %s", err.Error()),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"version": version,
},
})
}

781
controller/deployment.go Normal file
View File

@@ -0,0 +1,781 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/ionet"
"github.com/gin-gonic/gin"
)
func getIoAPIKey(c *gin.Context) (string, bool) {
common.OptionMapRWMutex.RLock()
enabled := common.OptionMap["model_deployment.ionet.enabled"] == "true"
apiKey := common.OptionMap["model_deployment.ionet.api_key"]
common.OptionMapRWMutex.RUnlock()
if !enabled || strings.TrimSpace(apiKey) == "" {
common.ApiErrorMsg(c, "io.net model deployment is not enabled or api key missing")
return "", false
}
return apiKey, true
}
func getIoClient(c *gin.Context) (*ionet.Client, bool) {
apiKey, ok := getIoAPIKey(c)
if !ok {
return nil, false
}
return ionet.NewClient(apiKey), true
}
func getIoEnterpriseClient(c *gin.Context) (*ionet.Client, bool) {
apiKey, ok := getIoAPIKey(c)
if !ok {
return nil, false
}
return ionet.NewEnterpriseClient(apiKey), true
}
func TestIoNetConnection(c *gin.Context) {
var req struct {
APIKey string `json:"api_key"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "invalid request payload")
return
}
apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" {
common.ApiErrorMsg(c, "api_key is required")
return
}
client := ionet.NewEnterpriseClient(apiKey)
result, err := client.GetMaxGPUsPerContainer()
if err != nil {
if apiErr, ok := err.(*ionet.APIError); ok {
message := strings.TrimSpace(apiErr.Message)
if message == "" {
message = "failed to validate api key"
}
common.ApiErrorMsg(c, message)
return
}
common.ApiError(c, err)
return
}
totalHardware := 0
totalAvailable := 0
if result != nil {
totalHardware = len(result.Hardware)
totalAvailable = result.Total
if totalAvailable == 0 {
for _, hw := range result.Hardware {
totalAvailable += hw.Available
}
}
}
common.ApiSuccess(c, gin.H{
"hardware_count": totalHardware,
"total_available": totalAvailable,
})
}
func requireDeploymentID(c *gin.Context) (string, bool) {
deploymentID := strings.TrimSpace(c.Param("id"))
if deploymentID == "" {
common.ApiErrorMsg(c, "deployment ID is required")
return "", false
}
return deploymentID, true
}
func requireContainerID(c *gin.Context) (string, bool) {
containerID := strings.TrimSpace(c.Param("container_id"))
if containerID == "" {
common.ApiErrorMsg(c, "container ID is required")
return "", false
}
return containerID, true
}
func mapIoNetDeployment(d ionet.Deployment) map[string]interface{} {
var created int64
if d.CreatedAt.IsZero() {
created = time.Now().Unix()
} else {
created = d.CreatedAt.Unix()
}
timeRemainingHours := d.ComputeMinutesRemaining / 60
timeRemainingMins := d.ComputeMinutesRemaining % 60
var timeRemaining string
if timeRemainingHours > 0 {
timeRemaining = fmt.Sprintf("%d hour %d minutes", timeRemainingHours, timeRemainingMins)
} else if timeRemainingMins > 0 {
timeRemaining = fmt.Sprintf("%d minutes", timeRemainingMins)
} else {
timeRemaining = "completed"
}
hardwareInfo := fmt.Sprintf("%s %s x%d", d.BrandName, d.HardwareName, d.HardwareQuantity)
return map[string]interface{}{
"id": d.ID,
"deployment_name": d.Name,
"container_name": d.Name,
"status": strings.ToLower(d.Status),
"type": "Container",
"time_remaining": timeRemaining,
"time_remaining_minutes": d.ComputeMinutesRemaining,
"hardware_info": hardwareInfo,
"hardware_name": d.HardwareName,
"brand_name": d.BrandName,
"hardware_quantity": d.HardwareQuantity,
"completed_percent": d.CompletedPercent,
"compute_minutes_served": d.ComputeMinutesServed,
"compute_minutes_remaining": d.ComputeMinutesRemaining,
"created_at": created,
"updated_at": created,
"model_name": "",
"model_version": "",
"instance_count": d.HardwareQuantity,
"resource_config": map[string]interface{}{
"cpu": "",
"memory": "",
"gpu": strconv.Itoa(d.HardwareQuantity),
},
"description": "",
"provider": "io.net",
}
}
func computeStatusCounts(total int, deployments []ionet.Deployment) map[string]int64 {
counts := map[string]int64{
"all": int64(total),
}
for _, status := range []string{"running", "completed", "failed", "deployment requested", "termination requested", "destroyed"} {
counts[status] = 0
}
for _, d := range deployments {
status := strings.ToLower(strings.TrimSpace(d.Status))
counts[status] = counts[status] + 1
}
return counts
}
func GetAllDeployments(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
status := c.Query("status")
opts := &ionet.ListDeploymentsOptions{
Status: strings.ToLower(strings.TrimSpace(status)),
Page: pageInfo.GetPage(),
PageSize: pageInfo.GetPageSize(),
SortBy: "created_at",
SortOrder: "desc",
}
dl, err := client.ListDeployments(opts)
if err != nil {
common.ApiError(c, err)
return
}
items := make([]map[string]interface{}, 0, len(dl.Deployments))
for _, d := range dl.Deployments {
items = append(items, mapIoNetDeployment(d))
}
data := gin.H{
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"total": dl.Total,
"items": items,
"status_counts": computeStatusCounts(dl.Total, dl.Deployments),
}
common.ApiSuccess(c, data)
}
func SearchDeployments(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
keyword := strings.TrimSpace(c.Query("keyword"))
dl, err := client.ListDeployments(&ionet.ListDeploymentsOptions{
Status: status,
Page: pageInfo.GetPage(),
PageSize: pageInfo.GetPageSize(),
SortBy: "created_at",
SortOrder: "desc",
})
if err != nil {
common.ApiError(c, err)
return
}
filtered := make([]ionet.Deployment, 0, len(dl.Deployments))
if keyword == "" {
filtered = dl.Deployments
} else {
kw := strings.ToLower(keyword)
for _, d := range dl.Deployments {
if strings.Contains(strings.ToLower(d.Name), kw) {
filtered = append(filtered, d)
}
}
}
items := make([]map[string]interface{}, 0, len(filtered))
for _, d := range filtered {
items = append(items, mapIoNetDeployment(d))
}
total := dl.Total
if keyword != "" {
total = len(filtered)
}
data := gin.H{
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"total": total,
"items": items,
}
common.ApiSuccess(c, data)
}
func GetDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
details, err := client.GetDeployment(deploymentID)
if err != nil {
common.ApiError(c, err)
return
}
data := map[string]interface{}{
"id": details.ID,
"deployment_name": details.ID,
"model_name": "",
"model_version": "",
"status": strings.ToLower(details.Status),
"instance_count": details.TotalContainers,
"hardware_id": details.HardwareID,
"resource_config": map[string]interface{}{
"cpu": "",
"memory": "",
"gpu": strconv.Itoa(details.TotalGPUs),
},
"created_at": details.CreatedAt.Unix(),
"updated_at": details.CreatedAt.Unix(),
"description": "",
"amount_paid": details.AmountPaid,
"completed_percent": details.CompletedPercent,
"gpus_per_container": details.GPUsPerContainer,
"total_gpus": details.TotalGPUs,
"total_containers": details.TotalContainers,
"hardware_name": details.HardwareName,
"brand_name": details.BrandName,
"compute_minutes_served": details.ComputeMinutesServed,
"compute_minutes_remaining": details.ComputeMinutesRemaining,
"locations": details.Locations,
"container_config": details.ContainerConfig,
}
common.ApiSuccess(c, data)
}
func UpdateDeploymentName(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
updateReq := &ionet.UpdateClusterNameRequest{
Name: strings.TrimSpace(req.Name),
}
if updateReq.Name == "" {
common.ApiErrorMsg(c, "deployment name cannot be empty")
return
}
available, err := client.CheckClusterNameAvailability(updateReq.Name)
if err != nil {
common.ApiError(c, fmt.Errorf("failed to check name availability: %w", err))
return
}
if !available {
common.ApiErrorMsg(c, "deployment name is not available, please choose a different name")
return
}
resp, err := client.UpdateClusterName(deploymentID, updateReq)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"status": resp.Status,
"message": resp.Message,
"id": deploymentID,
"name": updateReq.Name,
}
common.ApiSuccess(c, data)
}
func UpdateDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
var req ionet.UpdateDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
resp, err := client.UpdateDeployment(deploymentID, &req)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"status": resp.Status,
"deployment_id": resp.DeploymentID,
}
common.ApiSuccess(c, data)
}
func ExtendDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
var req ionet.ExtendDurationRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
details, err := client.ExtendDeployment(deploymentID, &req)
if err != nil {
common.ApiError(c, err)
return
}
data := mapIoNetDeployment(ionet.Deployment{
ID: details.ID,
Status: details.Status,
Name: deploymentID,
CompletedPercent: float64(details.CompletedPercent),
HardwareQuantity: details.TotalGPUs,
BrandName: details.BrandName,
HardwareName: details.HardwareName,
ComputeMinutesServed: details.ComputeMinutesServed,
ComputeMinutesRemaining: details.ComputeMinutesRemaining,
CreatedAt: details.CreatedAt,
})
common.ApiSuccess(c, data)
}
func DeleteDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
resp, err := client.DeleteDeployment(deploymentID)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"status": resp.Status,
"deployment_id": resp.DeploymentID,
"message": "Deployment termination requested successfully",
}
common.ApiSuccess(c, data)
}
func CreateDeployment(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
var req ionet.DeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
resp, err := client.DeployContainer(&req)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"deployment_id": resp.DeploymentID,
"status": resp.Status,
"message": "Deployment created successfully",
}
common.ApiSuccess(c, data)
}
func GetHardwareTypes(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
hardwareTypes, totalAvailable, err := client.ListHardwareTypes()
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"hardware_types": hardwareTypes,
"total": len(hardwareTypes),
"total_available": totalAvailable,
}
common.ApiSuccess(c, data)
}
func GetLocations(c *gin.Context) {
client, ok := getIoClient(c)
if !ok {
return
}
locationsResp, err := client.ListLocations()
if err != nil {
common.ApiError(c, err)
return
}
total := locationsResp.Total
if total == 0 {
total = len(locationsResp.Locations)
}
data := gin.H{
"locations": locationsResp.Locations,
"total": total,
}
common.ApiSuccess(c, data)
}
func GetAvailableReplicas(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
hardwareIDStr := c.Query("hardware_id")
gpuCountStr := c.Query("gpu_count")
if hardwareIDStr == "" {
common.ApiErrorMsg(c, "hardware_id parameter is required")
return
}
hardwareID, err := strconv.Atoi(hardwareIDStr)
if err != nil || hardwareID <= 0 {
common.ApiErrorMsg(c, "invalid hardware_id parameter")
return
}
gpuCount := 1
if gpuCountStr != "" {
if parsed, err := strconv.Atoi(gpuCountStr); err == nil && parsed > 0 {
gpuCount = parsed
}
}
replicas, err := client.GetAvailableReplicas(hardwareID, gpuCount)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, replicas)
}
func GetPriceEstimation(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
var req ionet.PriceEstimationRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
priceResp, err := client.GetPriceEstimation(&req)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, priceResp)
}
func CheckClusterNameAvailability(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
clusterName := strings.TrimSpace(c.Query("name"))
if clusterName == "" {
common.ApiErrorMsg(c, "name parameter is required")
return
}
available, err := client.CheckClusterNameAvailability(clusterName)
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"available": available,
"name": clusterName,
}
common.ApiSuccess(c, data)
}
func GetDeploymentLogs(c *gin.Context) {
client, ok := getIoClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
containerID := c.Query("container_id")
if containerID == "" {
common.ApiErrorMsg(c, "container_id parameter is required")
return
}
level := c.Query("level")
stream := c.Query("stream")
cursor := c.Query("cursor")
limitStr := c.Query("limit")
follow := c.Query("follow") == "true"
var limit int = 100
if limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
if limit > 1000 {
limit = 1000
}
}
}
opts := &ionet.GetLogsOptions{
Level: level,
Stream: stream,
Limit: limit,
Cursor: cursor,
Follow: follow,
}
if startTime := c.Query("start_time"); startTime != "" {
if t, err := time.Parse(time.RFC3339, startTime); err == nil {
opts.StartTime = &t
}
}
if endTime := c.Query("end_time"); endTime != "" {
if t, err := time.Parse(time.RFC3339, endTime); err == nil {
opts.EndTime = &t
}
}
rawLogs, err := client.GetContainerLogsRaw(deploymentID, containerID, opts)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, rawLogs)
}
func ListDeploymentContainers(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
containers, err := client.ListContainers(deploymentID)
if err != nil {
common.ApiError(c, err)
return
}
items := make([]map[string]interface{}, 0)
if containers != nil {
items = make([]map[string]interface{}, 0, len(containers.Workers))
for _, ctr := range containers.Workers {
events := make([]map[string]interface{}, 0, len(ctr.ContainerEvents))
for _, event := range ctr.ContainerEvents {
events = append(events, map[string]interface{}{
"time": event.Time.Unix(),
"message": event.Message,
})
}
items = append(items, map[string]interface{}{
"container_id": ctr.ContainerID,
"device_id": ctr.DeviceID,
"status": strings.ToLower(strings.TrimSpace(ctr.Status)),
"hardware": ctr.Hardware,
"brand_name": ctr.BrandName,
"created_at": ctr.CreatedAt.Unix(),
"uptime_percent": ctr.UptimePercent,
"gpus_per_container": ctr.GPUsPerContainer,
"public_url": ctr.PublicURL,
"events": events,
})
}
}
response := gin.H{
"total": 0,
"containers": items,
}
if containers != nil {
response["total"] = containers.Total
}
common.ApiSuccess(c, response)
}
func GetContainerDetails(c *gin.Context) {
client, ok := getIoEnterpriseClient(c)
if !ok {
return
}
deploymentID, ok := requireDeploymentID(c)
if !ok {
return
}
containerID, ok := requireContainerID(c)
if !ok {
return
}
details, err := client.GetContainerDetails(deploymentID, containerID)
if err != nil {
common.ApiError(c, err)
return
}
if details == nil {
common.ApiErrorMsg(c, "container details not found")
return
}
events := make([]map[string]interface{}, 0, len(details.ContainerEvents))
for _, event := range details.ContainerEvents {
events = append(events, map[string]interface{}{
"time": event.Time.Unix(),
"message": event.Message,
})
}
data := gin.H{
"deployment_id": deploymentID,
"container_id": details.ContainerID,
"device_id": details.DeviceID,
"status": strings.ToLower(strings.TrimSpace(details.Status)),
"hardware": details.Hardware,
"brand_name": details.BrandName,
"created_at": details.CreatedAt.Unix(),
"uptime_percent": details.UptimePercent,
"gpus_per_container": details.GPUsPerContainer,
"public_url": details.PublicURL,
"events": events,
}
common.ApiSuccess(c, data)
}

View File

@@ -249,7 +249,9 @@ func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, v
return 0
}
// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
// SyncUpstreamModels 同步上游模型与供应商
// - 默认仅创建「未配置模型」
// - 可通过 overwrite 选择性覆盖更新本地已有模型的字段前提sync_official <> 0
func SyncUpstreamModels(c *gin.Context) {
var req syncRequest
// 允许空体
@@ -260,12 +262,26 @@ func SyncUpstreamModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
if len(missing) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"created_models": 0,
"created_vendors": 0,
"skipped_models": []string{},
}})
// 若既无缺失模型需要创建,也未指定覆盖更新字段,则无需请求上游数据,直接返回
if len(missing) == 0 && len(req.Overwrite) == 0 {
modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"created_models": 0,
"created_vendors": 0,
"updated_models": 0,
"skipped_models": []string{},
"created_list": []string{},
"updated_list": []string{},
"source": gin.H{
"locale": req.Locale,
"models_url": modelsURL,
"vendors_url": vendorsURL,
},
},
})
return
}
@@ -315,9 +331,9 @@ func SyncUpstreamModels(c *gin.Context) {
createdModels := 0
createdVendors := 0
updatedModels := 0
var skipped []string
var createdList []string
var updatedList []string
skipped := make([]string, 0)
createdList := make([]string, 0)
updatedList := make([]string, 0)
// 本地缓存vendorName -> id
vendorIDCache := make(map[string]int)

View File

@@ -110,18 +110,17 @@ func setupLogin(user *model.User, c *gin.Context) {
})
return
}
cleanUser := model.User{
Id: user.Id,
Username: user.Username,
DisplayName: user.DisplayName,
Role: user.Role,
Status: user.Status,
Group: user.Group,
}
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
"data": cleanUser,
"data": map[string]any{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"group": user.Group,
},
})
}
@@ -764,7 +763,10 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int
if err != nil {
return
}
if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) {
// 密码不为空,需要验证原密码
// 支持第一次账号绑定时原密码为空的情况
if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) && currentUser.Password != "" {
err = fmt.Errorf("原密码错误")
return
}

7
docs/ionet-client.md Normal file
View File

@@ -0,0 +1,7 @@
Request URL
https://api.io.solutions/v1/io-cloud/clusters/654fc0a9-0d4a-4db4-9b95-3f56189348a2/update-name
Request Method
PUT
{"status":"succeeded","message":"Cluster name updated successfully"}

View File

@@ -26,6 +26,7 @@ type GeneralErrorResponse struct {
Msg string `json:"msg"`
Err string `json:"err"`
ErrorMsg string `json:"error_msg"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Header struct {
Message string `json:"message"`
} `json:"header"`

View File

@@ -22,6 +22,27 @@ type GeminiChatRequest struct {
CachedContent string `json:"cachedContent,omitempty"`
}
// UnmarshalJSON allows GeminiChatRequest to accept both snake_case and camelCase fields.
func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
type Alias GeminiChatRequest
var aux struct {
Alias
SystemInstructionSnake *GeminiChatContent `json:"system_instruction,omitempty"`
}
if err := common.Unmarshal(data, &aux); err != nil {
return err
}
*r = GeminiChatRequest(aux.Alias)
if aux.SystemInstructionSnake != nil {
r.SystemInstructions = aux.SystemInstructionSnake
}
return nil
}
type ToolConfig struct {
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`

8
go.mod
View File

@@ -27,6 +27,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/grafana/pyroscope-go v1.2.7
github.com/jfreymuth/oggvorbis v1.0.5
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
@@ -36,6 +37,7 @@ require (
github.com/samber/lo v1.52.0
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v81 v81.4.0
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
github.com/thanhpk/randstr v1.0.6
@@ -62,6 +64,7 @@ require (
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -77,11 +80,11 @@ require (
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -91,6 +94,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -101,7 +105,9 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect

48
go.sum
View File

@@ -118,9 +118,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -132,6 +131,10 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
@@ -160,12 +163,15 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@@ -214,14 +220,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
@@ -231,6 +234,7 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -288,12 +292,12 @@ golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
@@ -321,6 +325,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
@@ -350,19 +356,29 @@ gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBp
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -124,6 +124,11 @@ func main() {
common.SysLog("pprof enabled")
}
err = common.StartPyroScope()
if err != nil {
common.SysError(fmt.Sprintf("start pyroscope error : %v", err))
}
// Initialize HTTP server
server := gin.New()
server.Use(gin.CustomRecovery(func(c *gin.Context, err any) {

View File

@@ -218,10 +218,14 @@ func TokenAuth() func(c *gin.Context) {
}
key := c.Request.Header.Get("Authorization")
parts := make([]string, 0)
key = strings.TrimPrefix(key, "Bearer ")
if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") {
key = strings.TrimSpace(key[7:])
}
if key == "" || key == "midjourney-proxy" {
key = c.Request.Header.Get("mj-api-secret")
key = strings.TrimPrefix(key, "Bearer ")
if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") {
key = strings.TrimSpace(key[7:])
}
key = strings.TrimPrefix(key, "sk-")
parts = strings.Split(key, "-")
key = parts[0]

View File

@@ -248,26 +248,26 @@ func InitLogDB() (err error) {
}
func migrateDB() error {
err := DB.AutoMigrate(
&Channel{},
&Token{},
&User{},
&PasskeyCredential{},
err := DB.AutoMigrate(
&Channel{},
&Token{},
&User{},
&PasskeyCredential{},
&Option{},
&Redemption{},
&Ability{},
&Log{},
&Midjourney{},
&TopUp{},
&QuotaData{},
&Task{},
&Model{},
&Vendor{},
&PrefillGroup{},
&Setup{},
&TwoFA{},
&TwoFABackupCode{},
)
&Redemption{},
&Ability{},
&Log{},
&Midjourney{},
&TopUp{},
&QuotaData{},
&Task{},
&Model{},
&Vendor{},
&PrefillGroup{},
&Setup{},
&TwoFA{},
&TwoFABackupCode{},
)
if err != nil {
return err
}
@@ -278,29 +278,29 @@ func migrateDBFast() error {
var wg sync.WaitGroup
migrations := []struct {
model interface{}
name string
}{
{&Channel{}, "Channel"},
{&Token{}, "Token"},
{&User{}, "User"},
{&PasskeyCredential{}, "PasskeyCredential"},
migrations := []struct {
model interface{}
name string
}{
{&Channel{}, "Channel"},
{&Token{}, "Token"},
{&User{}, "User"},
{&PasskeyCredential{}, "PasskeyCredential"},
{&Option{}, "Option"},
{&Redemption{}, "Redemption"},
{&Ability{}, "Ability"},
{&Log{}, "Log"},
{&Midjourney{}, "Midjourney"},
{&TopUp{}, "TopUp"},
{&QuotaData{}, "QuotaData"},
{&Task{}, "Task"},
{&Model{}, "Model"},
{&Vendor{}, "Vendor"},
{&PrefillGroup{}, "PrefillGroup"},
{&Setup{}, "Setup"},
{&TwoFA{}, "TwoFA"},
{&TwoFABackupCode{}, "TwoFABackupCode"},
}
{&Redemption{}, "Redemption"},
{&Ability{}, "Ability"},
{&Log{}, "Log"},
{&Midjourney{}, "Midjourney"},
{&TopUp{}, "TopUp"},
{&QuotaData{}, "QuotaData"},
{&Task{}, "Task"},
{&Model{}, "Model"},
{&Vendor{}, "Vendor"},
{&PrefillGroup{}, "PrefillGroup"},
{&Setup{}, "Setup"},
{&TwoFA{}, "TwoFA"},
{&TwoFABackupCode{}, "TwoFABackupCode"},
}
// 动态计算migration数量确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))

View File

@@ -112,7 +112,12 @@ func ValidateUserToken(key string) (token *Token, err error) {
}
return token, nil
}
return nil, errors.New("无效的令牌")
common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("无效的令牌")
} else {
return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
}
}
func GetTokenByIds(id int, userId int) (*Token, error) {

219
pkg/ionet/client.go Normal file
View File

@@ -0,0 +1,219 @@
package ionet
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
const (
DefaultEnterpriseBaseURL = "https://api.io.solutions/enterprise/v1/io-cloud/caas"
DefaultBaseURL = "https://api.io.solutions/v1/io-cloud/caas"
DefaultTimeout = 30 * time.Second
)
// DefaultHTTPClient is the default HTTP client implementation
type DefaultHTTPClient struct {
client *http.Client
}
// NewDefaultHTTPClient creates a new default HTTP client
func NewDefaultHTTPClient(timeout time.Duration) *DefaultHTTPClient {
return &DefaultHTTPClient{
client: &http.Client{
Timeout: timeout,
},
}
}
// Do executes an HTTP request
func (c *DefaultHTTPClient) Do(req *HTTPRequest) (*HTTPResponse, error) {
httpReq, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(req.Body))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set headers
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}
resp, err := c.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
var body bytes.Buffer
_, err = body.ReadFrom(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Convert headers
headers := make(map[string]string)
for key, values := range resp.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
return &HTTPResponse{
StatusCode: resp.StatusCode,
Headers: headers,
Body: body.Bytes(),
}, nil
}
// NewEnterpriseClient creates a new IO.NET API client targeting the enterprise API base URL.
func NewEnterpriseClient(apiKey string) *Client {
return NewClientWithConfig(apiKey, DefaultEnterpriseBaseURL, nil)
}
// NewClient creates a new IO.NET API client targeting the public API base URL.
func NewClient(apiKey string) *Client {
return NewClientWithConfig(apiKey, DefaultBaseURL, nil)
}
// NewClientWithConfig creates a new IO.NET API client with custom configuration
func NewClientWithConfig(apiKey, baseURL string, httpClient HTTPClient) *Client {
if baseURL == "" {
baseURL = DefaultBaseURL
}
if httpClient == nil {
httpClient = NewDefaultHTTPClient(DefaultTimeout)
}
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: httpClient,
}
}
// makeRequest performs an HTTP request and handles common response processing
func (c *Client) makeRequest(method, endpoint string, body interface{}) (*HTTPResponse, error) {
var reqBody []byte
var err error
if body != nil {
reqBody, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
}
headers := map[string]string{
"X-API-KEY": c.APIKey,
"Content-Type": "application/json",
}
req := &HTTPRequest{
Method: method,
URL: c.BaseURL + endpoint,
Headers: headers,
Body: reqBody,
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
// Handle API errors
if resp.StatusCode >= 400 {
var apiErr APIError
if len(resp.Body) > 0 {
// Try to parse the actual error format: {"detail": "message"}
var errorResp struct {
Detail string `json:"detail"`
}
if err := json.Unmarshal(resp.Body, &errorResp); err == nil && errorResp.Detail != "" {
apiErr = APIError{
Code: resp.StatusCode,
Message: errorResp.Detail,
}
} else {
// Fallback: use raw body as details
apiErr = APIError{
Code: resp.StatusCode,
Message: fmt.Sprintf("API request failed with status %d", resp.StatusCode),
Details: string(resp.Body),
}
}
} else {
apiErr = APIError{
Code: resp.StatusCode,
Message: fmt.Sprintf("API request failed with status %d", resp.StatusCode),
}
}
return nil, &apiErr
}
return resp, nil
}
// buildQueryParams builds query parameters for GET requests
func buildQueryParams(params map[string]interface{}) string {
if len(params) == 0 {
return ""
}
values := url.Values{}
for key, value := range params {
if value == nil {
continue
}
switch v := value.(type) {
case string:
if v != "" {
values.Add(key, v)
}
case int:
if v != 0 {
values.Add(key, strconv.Itoa(v))
}
case int64:
if v != 0 {
values.Add(key, strconv.FormatInt(v, 10))
}
case float64:
if v != 0 {
values.Add(key, strconv.FormatFloat(v, 'f', -1, 64))
}
case bool:
values.Add(key, strconv.FormatBool(v))
case time.Time:
if !v.IsZero() {
values.Add(key, v.Format(time.RFC3339))
}
case *time.Time:
if v != nil && !v.IsZero() {
values.Add(key, v.Format(time.RFC3339))
}
case []int:
if len(v) > 0 {
if encoded, err := json.Marshal(v); err == nil {
values.Add(key, string(encoded))
}
}
case []string:
if len(v) > 0 {
if encoded, err := json.Marshal(v); err == nil {
values.Add(key, string(encoded))
}
}
default:
values.Add(key, fmt.Sprint(v))
}
}
if len(values) > 0 {
return "?" + values.Encode()
}
return ""
}

302
pkg/ionet/container.go Normal file
View File

@@ -0,0 +1,302 @@
package ionet
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/samber/lo"
)
// ListContainers retrieves all containers for a specific deployment
func (c *Client) ListContainers(deploymentID string) (*ContainerList, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/containers", deploymentID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var containerList ContainerList
if err := decodeDataWithFlexibleTimes(resp.Body, &containerList); err != nil {
return nil, fmt.Errorf("failed to parse containers list: %w", err)
}
return &containerList, nil
}
// GetContainerDetails retrieves detailed information about a specific container
func (c *Client) GetContainerDetails(deploymentID, containerID string) (*Container, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return nil, fmt.Errorf("container ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/container/%s", deploymentID, containerID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get container details: %w", err)
}
// API response format not documented, assuming direct format
var container Container
if err := decodeWithFlexibleTimes(resp.Body, &container); err != nil {
return nil, fmt.Errorf("failed to parse container details: %w", err)
}
return &container, nil
}
// GetContainerJobs retrieves containers jobs for a specific container (similar to containers endpoint)
func (c *Client) GetContainerJobs(deploymentID, containerID string) (*ContainerList, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return nil, fmt.Errorf("container ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/containers-jobs/%s", deploymentID, containerID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get container jobs: %w", err)
}
var containerList ContainerList
if err := decodeDataWithFlexibleTimes(resp.Body, &containerList); err != nil {
return nil, fmt.Errorf("failed to parse container jobs: %w", err)
}
return &containerList, nil
}
// buildLogEndpoint constructs the request path for fetching logs
func buildLogEndpoint(deploymentID, containerID string, opts *GetLogsOptions) (string, error) {
if deploymentID == "" {
return "", fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return "", fmt.Errorf("container ID cannot be empty")
}
params := make(map[string]interface{})
if opts != nil {
if opts.Level != "" {
params["level"] = opts.Level
}
if opts.Stream != "" {
params["stream"] = opts.Stream
}
if opts.Limit > 0 {
params["limit"] = opts.Limit
}
if opts.Cursor != "" {
params["cursor"] = opts.Cursor
}
if opts.Follow {
params["follow"] = true
}
if opts.StartTime != nil {
params["start_time"] = opts.StartTime
}
if opts.EndTime != nil {
params["end_time"] = opts.EndTime
}
}
endpoint := fmt.Sprintf("/deployment/%s/log/%s", deploymentID, containerID)
endpoint += buildQueryParams(params)
return endpoint, nil
}
// GetContainerLogs retrieves logs for containers in a deployment and normalizes them
func (c *Client) GetContainerLogs(deploymentID, containerID string, opts *GetLogsOptions) (*ContainerLogs, error) {
raw, err := c.GetContainerLogsRaw(deploymentID, containerID, opts)
if err != nil {
return nil, err
}
logs := &ContainerLogs{
ContainerID: containerID,
}
if raw == "" {
return logs, nil
}
normalized := strings.ReplaceAll(raw, "\r\n", "\n")
lines := strings.Split(normalized, "\n")
logs.Logs = lo.FilterMap(lines, func(line string, _ int) (LogEntry, bool) {
if strings.TrimSpace(line) == "" {
return LogEntry{}, false
}
return LogEntry{Message: line}, true
})
return logs, nil
}
// GetContainerLogsRaw retrieves the raw text logs for a specific container
func (c *Client) GetContainerLogsRaw(deploymentID, containerID string, opts *GetLogsOptions) (string, error) {
endpoint, err := buildLogEndpoint(deploymentID, containerID, opts)
if err != nil {
return "", err
}
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
return string(resp.Body), nil
}
// StreamContainerLogs streams real-time logs for a specific container
// This method uses a callback function to handle incoming log entries
func (c *Client) StreamContainerLogs(deploymentID, containerID string, opts *GetLogsOptions, callback func(*LogEntry) error) error {
if deploymentID == "" {
return fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return fmt.Errorf("container ID cannot be empty")
}
if callback == nil {
return fmt.Errorf("callback function cannot be nil")
}
// Set follow to true for streaming
if opts == nil {
opts = &GetLogsOptions{}
}
opts.Follow = true
endpoint, err := buildLogEndpoint(deploymentID, containerID, opts)
if err != nil {
return err
}
// Note: This is a simplified implementation. In a real scenario, you might want to use
// Server-Sent Events (SSE) or WebSocket for streaming logs
for {
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to stream container logs: %w", err)
}
var logs ContainerLogs
if err := decodeWithFlexibleTimes(resp.Body, &logs); err != nil {
return fmt.Errorf("failed to parse container logs: %w", err)
}
// Call the callback for each log entry
for _, logEntry := range logs.Logs {
if err := callback(&logEntry); err != nil {
return fmt.Errorf("callback error: %w", err)
}
}
// If there are no more logs or we have a cursor, continue polling
if !logs.HasMore && logs.NextCursor == "" {
break
}
// Update cursor for next request
if logs.NextCursor != "" {
opts.Cursor = logs.NextCursor
endpoint, err = buildLogEndpoint(deploymentID, containerID, opts)
if err != nil {
return err
}
}
// Wait a bit before next poll to avoid overwhelming the API
time.Sleep(2 * time.Second)
}
return nil
}
// RestartContainer restarts a specific container (if supported by the API)
func (c *Client) RestartContainer(deploymentID, containerID string) error {
if deploymentID == "" {
return fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return fmt.Errorf("container ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/container/%s/restart", deploymentID, containerID)
_, err := c.makeRequest("POST", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to restart container: %w", err)
}
return nil
}
// StopContainer stops a specific container (if supported by the API)
func (c *Client) StopContainer(deploymentID, containerID string) error {
if deploymentID == "" {
return fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return fmt.Errorf("container ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/container/%s/stop", deploymentID, containerID)
_, err := c.makeRequest("POST", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
return nil
}
// ExecuteInContainer executes a command in a specific container (if supported by the API)
func (c *Client) ExecuteInContainer(deploymentID, containerID string, command []string) (string, error) {
if deploymentID == "" {
return "", fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return "", fmt.Errorf("container ID cannot be empty")
}
if len(command) == 0 {
return "", fmt.Errorf("command cannot be empty")
}
reqBody := map[string]interface{}{
"command": command,
}
endpoint := fmt.Sprintf("/deployment/%s/container/%s/exec", deploymentID, containerID)
resp, err := c.makeRequest("POST", endpoint, reqBody)
if err != nil {
return "", fmt.Errorf("failed to execute command in container: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(resp.Body, &result); err != nil {
return "", fmt.Errorf("failed to parse execution result: %w", err)
}
if output, ok := result["output"].(string); ok {
return output, nil
}
return string(resp.Body), nil
}

377
pkg/ionet/deployment.go Normal file
View File

@@ -0,0 +1,377 @@
package ionet
import (
"encoding/json"
"fmt"
"strings"
"github.com/samber/lo"
)
// DeployContainer deploys a new container with the specified configuration
func (c *Client) DeployContainer(req *DeploymentRequest) (*DeploymentResponse, error) {
if req == nil {
return nil, fmt.Errorf("deployment request cannot be nil")
}
// Validate required fields
if req.ResourcePrivateName == "" {
return nil, fmt.Errorf("resource_private_name is required")
}
if len(req.LocationIDs) == 0 {
return nil, fmt.Errorf("location_ids is required")
}
if req.HardwareID <= 0 {
return nil, fmt.Errorf("hardware_id is required")
}
if req.RegistryConfig.ImageURL == "" {
return nil, fmt.Errorf("registry_config.image_url is required")
}
if req.GPUsPerContainer < 1 {
return nil, fmt.Errorf("gpus_per_container must be at least 1")
}
if req.DurationHours < 1 {
return nil, fmt.Errorf("duration_hours must be at least 1")
}
if req.ContainerConfig.ReplicaCount < 1 {
return nil, fmt.Errorf("container_config.replica_count must be at least 1")
}
resp, err := c.makeRequest("POST", "/deploy", req)
if err != nil {
return nil, fmt.Errorf("failed to deploy container: %w", err)
}
// API returns direct format:
// {"status": "string", "deployment_id": "..."}
var deployResp DeploymentResponse
if err := json.Unmarshal(resp.Body, &deployResp); err != nil {
return nil, fmt.Errorf("failed to parse deployment response: %w", err)
}
return &deployResp, nil
}
// ListDeployments retrieves a list of deployments with optional filtering
func (c *Client) ListDeployments(opts *ListDeploymentsOptions) (*DeploymentList, error) {
params := make(map[string]interface{})
if opts != nil {
params["status"] = opts.Status
params["location_id"] = opts.LocationID
params["page"] = opts.Page
params["page_size"] = opts.PageSize
params["sort_by"] = opts.SortBy
params["sort_order"] = opts.SortOrder
}
endpoint := "/deployments" + buildQueryParams(params)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to list deployments: %w", err)
}
var deploymentList DeploymentList
if err := decodeData(resp.Body, &deploymentList); err != nil {
return nil, fmt.Errorf("failed to parse deployments list: %w", err)
}
deploymentList.Deployments = lo.Map(deploymentList.Deployments, func(deployment Deployment, _ int) Deployment {
deployment.GPUCount = deployment.HardwareQuantity
deployment.Replicas = deployment.HardwareQuantity // Assuming 1:1 mapping for now
return deployment
})
return &deploymentList, nil
}
// GetDeployment retrieves detailed information about a specific deployment
func (c *Client) GetDeployment(deploymentID string) (*DeploymentDetail, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get deployment details: %w", err)
}
var deploymentDetail DeploymentDetail
if err := decodeDataWithFlexibleTimes(resp.Body, &deploymentDetail); err != nil {
return nil, fmt.Errorf("failed to parse deployment details: %w", err)
}
return &deploymentDetail, nil
}
// UpdateDeployment updates the configuration of an existing deployment
func (c *Client) UpdateDeployment(deploymentID string, req *UpdateDeploymentRequest) (*UpdateDeploymentResponse, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
if req == nil {
return nil, fmt.Errorf("update request cannot be nil")
}
endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
resp, err := c.makeRequest("PATCH", endpoint, req)
if err != nil {
return nil, fmt.Errorf("failed to update deployment: %w", err)
}
// API returns direct format:
// {"status": "string", "deployment_id": "..."}
var updateResp UpdateDeploymentResponse
if err := json.Unmarshal(resp.Body, &updateResp); err != nil {
return nil, fmt.Errorf("failed to parse update deployment response: %w", err)
}
return &updateResp, nil
}
// ExtendDeployment extends the duration of an existing deployment
func (c *Client) ExtendDeployment(deploymentID string, req *ExtendDurationRequest) (*DeploymentDetail, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
if req == nil {
return nil, fmt.Errorf("extend request cannot be nil")
}
if req.DurationHours < 1 {
return nil, fmt.Errorf("duration_hours must be at least 1")
}
endpoint := fmt.Sprintf("/deployment/%s/extend", deploymentID)
resp, err := c.makeRequest("POST", endpoint, req)
if err != nil {
return nil, fmt.Errorf("failed to extend deployment: %w", err)
}
var deploymentDetail DeploymentDetail
if err := decodeDataWithFlexibleTimes(resp.Body, &deploymentDetail); err != nil {
return nil, fmt.Errorf("failed to parse extended deployment details: %w", err)
}
return &deploymentDetail, nil
}
// DeleteDeployment deletes an active deployment
func (c *Client) DeleteDeployment(deploymentID string) (*UpdateDeploymentResponse, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
resp, err := c.makeRequest("DELETE", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to delete deployment: %w", err)
}
// API returns direct format:
// {"status": "string", "deployment_id": "..."}
var deleteResp UpdateDeploymentResponse
if err := json.Unmarshal(resp.Body, &deleteResp); err != nil {
return nil, fmt.Errorf("failed to parse delete deployment response: %w", err)
}
return &deleteResp, nil
}
// GetPriceEstimation calculates the estimated cost for a deployment
func (c *Client) GetPriceEstimation(req *PriceEstimationRequest) (*PriceEstimationResponse, error) {
if req == nil {
return nil, fmt.Errorf("price estimation request cannot be nil")
}
// Validate required fields
if len(req.LocationIDs) == 0 {
return nil, fmt.Errorf("location_ids is required")
}
if req.HardwareID == 0 {
return nil, fmt.Errorf("hardware_id is required")
}
if req.ReplicaCount < 1 {
return nil, fmt.Errorf("replica_count must be at least 1")
}
currency := strings.TrimSpace(req.Currency)
if currency == "" {
currency = "usdc"
}
durationType := strings.TrimSpace(req.DurationType)
if durationType == "" {
durationType = "hour"
}
durationType = strings.ToLower(durationType)
apiDurationType := ""
durationQty := req.DurationQty
if durationQty < 1 {
durationQty = req.DurationHours
}
if durationQty < 1 {
return nil, fmt.Errorf("duration_qty must be at least 1")
}
hardwareQty := req.HardwareQty
if hardwareQty < 1 {
hardwareQty = req.GPUsPerContainer
}
if hardwareQty < 1 {
return nil, fmt.Errorf("hardware_qty must be at least 1")
}
durationHoursForRate := req.DurationHours
if durationHoursForRate < 1 {
durationHoursForRate = durationQty
}
switch durationType {
case "hour", "hours", "hourly":
durationHoursForRate = durationQty
apiDurationType = "hourly"
case "day", "days", "daily":
durationHoursForRate = durationQty * 24
apiDurationType = "daily"
case "week", "weeks", "weekly":
durationHoursForRate = durationQty * 24 * 7
apiDurationType = "weekly"
case "month", "months", "monthly":
durationHoursForRate = durationQty * 24 * 30
apiDurationType = "monthly"
}
if durationHoursForRate < 1 {
durationHoursForRate = 1
}
if apiDurationType == "" {
apiDurationType = "hourly"
}
params := map[string]interface{}{
"location_ids": req.LocationIDs,
"hardware_id": req.HardwareID,
"hardware_qty": hardwareQty,
"gpus_per_container": req.GPUsPerContainer,
"duration_type": apiDurationType,
"duration_qty": durationQty,
"duration_hours": req.DurationHours,
"replica_count": req.ReplicaCount,
"currency": currency,
}
endpoint := "/price" + buildQueryParams(params)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get price estimation: %w", err)
}
// Parse according to the actual API response format from docs:
// {
// "data": {
// "replica_count": 0,
// "gpus_per_container": 0,
// "available_replica_count": [0],
// "discount": 0,
// "ionet_fee": 0,
// "ionet_fee_percent": 0,
// "currency_conversion_fee": 0,
// "currency_conversion_fee_percent": 0,
// "total_cost_usdc": 0
// }
// }
var pricingData struct {
ReplicaCount int `json:"replica_count"`
GPUsPerContainer int `json:"gpus_per_container"`
AvailableReplicaCount []int `json:"available_replica_count"`
Discount float64 `json:"discount"`
IonetFee float64 `json:"ionet_fee"`
IonetFeePercent float64 `json:"ionet_fee_percent"`
CurrencyConversionFee float64 `json:"currency_conversion_fee"`
CurrencyConversionFeePercent float64 `json:"currency_conversion_fee_percent"`
TotalCostUSDC float64 `json:"total_cost_usdc"`
}
if err := decodeData(resp.Body, &pricingData); err != nil {
return nil, fmt.Errorf("failed to parse price estimation response: %w", err)
}
// Convert to our internal format
durationHoursFloat := float64(durationHoursForRate)
if durationHoursFloat <= 0 {
durationHoursFloat = 1
}
priceResp := &PriceEstimationResponse{
EstimatedCost: pricingData.TotalCostUSDC,
Currency: strings.ToUpper(currency),
EstimationValid: true,
PriceBreakdown: PriceBreakdown{
ComputeCost: pricingData.TotalCostUSDC - pricingData.IonetFee - pricingData.CurrencyConversionFee,
TotalCost: pricingData.TotalCostUSDC,
HourlyRate: pricingData.TotalCostUSDC / durationHoursFloat,
},
}
return priceResp, nil
}
// CheckClusterNameAvailability checks if a cluster name is available
func (c *Client) CheckClusterNameAvailability(clusterName string) (bool, error) {
if clusterName == "" {
return false, fmt.Errorf("cluster name cannot be empty")
}
params := map[string]interface{}{
"cluster_name": clusterName,
}
endpoint := "/clusters/check_cluster_name_availability" + buildQueryParams(params)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return false, fmt.Errorf("failed to check cluster name availability: %w", err)
}
var availabilityResp bool
if err := json.Unmarshal(resp.Body, &availabilityResp); err != nil {
return false, fmt.Errorf("failed to parse cluster name availability response: %w", err)
}
return availabilityResp, nil
}
// UpdateClusterName updates the name of an existing cluster/deployment
func (c *Client) UpdateClusterName(clusterID string, req *UpdateClusterNameRequest) (*UpdateClusterNameResponse, error) {
if clusterID == "" {
return nil, fmt.Errorf("cluster ID cannot be empty")
}
if req == nil {
return nil, fmt.Errorf("update cluster name request cannot be nil")
}
if req.Name == "" {
return nil, fmt.Errorf("cluster name cannot be empty")
}
endpoint := fmt.Sprintf("/clusters/%s/update-name", clusterID)
resp, err := c.makeRequest("PUT", endpoint, req)
if err != nil {
return nil, fmt.Errorf("failed to update cluster name: %w", err)
}
// Parse the response directly without data wrapper based on API docs
var updateResp UpdateClusterNameResponse
if err := json.Unmarshal(resp.Body, &updateResp); err != nil {
return nil, fmt.Errorf("failed to parse update cluster name response: %w", err)
}
return &updateResp, nil
}

202
pkg/ionet/hardware.go Normal file
View File

@@ -0,0 +1,202 @@
package ionet
import (
"encoding/json"
"fmt"
"strings"
"github.com/samber/lo"
)
// GetAvailableReplicas retrieves available replicas per location for specified hardware
func (c *Client) GetAvailableReplicas(hardwareID int, gpuCount int) (*AvailableReplicasResponse, error) {
if hardwareID <= 0 {
return nil, fmt.Errorf("hardware_id must be greater than 0")
}
if gpuCount < 1 {
return nil, fmt.Errorf("gpu_count must be at least 1")
}
params := map[string]interface{}{
"hardware_id": hardwareID,
"hardware_qty": gpuCount,
}
endpoint := "/available-replicas" + buildQueryParams(params)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get available replicas: %w", err)
}
type availableReplicaPayload struct {
ID int `json:"id"`
ISO2 string `json:"iso2"`
Name string `json:"name"`
AvailableReplicas int `json:"available_replicas"`
}
var payload []availableReplicaPayload
if err := decodeData(resp.Body, &payload); err != nil {
return nil, fmt.Errorf("failed to parse available replicas response: %w", err)
}
replicas := lo.Map(payload, func(item availableReplicaPayload, _ int) AvailableReplica {
return AvailableReplica{
LocationID: item.ID,
LocationName: item.Name,
HardwareID: hardwareID,
HardwareName: "",
AvailableCount: item.AvailableReplicas,
MaxGPUs: gpuCount,
}
})
return &AvailableReplicasResponse{Replicas: replicas}, nil
}
// GetMaxGPUsPerContainer retrieves the maximum number of GPUs available per hardware type
func (c *Client) GetMaxGPUsPerContainer() (*MaxGPUResponse, error) {
resp, err := c.makeRequest("GET", "/hardware/max-gpus-per-container", nil)
if err != nil {
return nil, fmt.Errorf("failed to get max GPUs per container: %w", err)
}
var maxGPUResp MaxGPUResponse
if err := decodeData(resp.Body, &maxGPUResp); err != nil {
return nil, fmt.Errorf("failed to parse max GPU response: %w", err)
}
return &maxGPUResp, nil
}
// ListHardwareTypes retrieves available hardware types using the max GPUs endpoint
func (c *Client) ListHardwareTypes() ([]HardwareType, int, error) {
maxGPUResp, err := c.GetMaxGPUsPerContainer()
if err != nil {
return nil, 0, fmt.Errorf("failed to list hardware types: %w", err)
}
mapped := lo.Map(maxGPUResp.Hardware, func(hw MaxGPUInfo, _ int) HardwareType {
name := strings.TrimSpace(hw.HardwareName)
if name == "" {
name = fmt.Sprintf("Hardware %d", hw.HardwareID)
}
return HardwareType{
ID: hw.HardwareID,
Name: name,
GPUType: "",
GPUMemory: 0,
MaxGPUs: hw.MaxGPUsPerContainer,
CPU: "",
Memory: 0,
Storage: 0,
HourlyRate: 0,
Available: hw.Available > 0,
BrandName: strings.TrimSpace(hw.BrandName),
AvailableCount: hw.Available,
}
})
totalAvailable := maxGPUResp.Total
if totalAvailable == 0 {
totalAvailable = lo.SumBy(maxGPUResp.Hardware, func(hw MaxGPUInfo) int {
return hw.Available
})
}
return mapped, totalAvailable, nil
}
// ListLocations retrieves available deployment locations (if supported by the API)
func (c *Client) ListLocations() (*LocationsResponse, error) {
resp, err := c.makeRequest("GET", "/locations", nil)
if err != nil {
return nil, fmt.Errorf("failed to list locations: %w", err)
}
var locations LocationsResponse
if err := decodeData(resp.Body, &locations); err != nil {
return nil, fmt.Errorf("failed to parse locations response: %w", err)
}
locations.Locations = lo.Map(locations.Locations, func(location Location, _ int) Location {
location.ISO2 = strings.ToUpper(strings.TrimSpace(location.ISO2))
return location
})
if locations.Total == 0 {
locations.Total = lo.SumBy(locations.Locations, func(location Location) int {
return location.Available
})
}
return &locations, nil
}
// GetHardwareType retrieves details about a specific hardware type
func (c *Client) GetHardwareType(hardwareID int) (*HardwareType, error) {
if hardwareID <= 0 {
return nil, fmt.Errorf("hardware ID must be greater than 0")
}
endpoint := fmt.Sprintf("/hardware/types/%d", hardwareID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get hardware type: %w", err)
}
// API response format not documented, assuming direct format
var hardwareType HardwareType
if err := json.Unmarshal(resp.Body, &hardwareType); err != nil {
return nil, fmt.Errorf("failed to parse hardware type: %w", err)
}
return &hardwareType, nil
}
// GetLocation retrieves details about a specific location
func (c *Client) GetLocation(locationID int) (*Location, error) {
if locationID <= 0 {
return nil, fmt.Errorf("location ID must be greater than 0")
}
endpoint := fmt.Sprintf("/locations/%d", locationID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get location: %w", err)
}
// API response format not documented, assuming direct format
var location Location
if err := json.Unmarshal(resp.Body, &location); err != nil {
return nil, fmt.Errorf("failed to parse location: %w", err)
}
return &location, nil
}
// GetLocationAvailability retrieves real-time availability for a specific location
func (c *Client) GetLocationAvailability(locationID int) (*LocationAvailability, error) {
if locationID <= 0 {
return nil, fmt.Errorf("location ID must be greater than 0")
}
endpoint := fmt.Sprintf("/locations/%d/availability", locationID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get location availability: %w", err)
}
// API response format not documented, assuming direct format
var availability LocationAvailability
if err := json.Unmarshal(resp.Body, &availability); err != nil {
return nil, fmt.Errorf("failed to parse location availability: %w", err)
}
return &availability, nil
}

96
pkg/ionet/jsonutil.go Normal file
View File

@@ -0,0 +1,96 @@
package ionet
import (
"encoding/json"
"strings"
"time"
"github.com/samber/lo"
)
// decodeWithFlexibleTimes unmarshals API responses while tolerating timestamp strings
// that omit timezone information by normalizing them to RFC3339Nano.
func decodeWithFlexibleTimes(data []byte, target interface{}) error {
var intermediate interface{}
if err := json.Unmarshal(data, &intermediate); err != nil {
return err
}
normalized := normalizeTimeValues(intermediate)
reencoded, err := json.Marshal(normalized)
if err != nil {
return err
}
return json.Unmarshal(reencoded, target)
}
func decodeData[T any](data []byte, target *T) error {
var wrapper struct {
Data T `json:"data"`
}
if err := json.Unmarshal(data, &wrapper); err != nil {
return err
}
*target = wrapper.Data
return nil
}
func decodeDataWithFlexibleTimes[T any](data []byte, target *T) error {
var wrapper struct {
Data T `json:"data"`
}
if err := decodeWithFlexibleTimes(data, &wrapper); err != nil {
return err
}
*target = wrapper.Data
return nil
}
func normalizeTimeValues(value interface{}) interface{} {
switch v := value.(type) {
case map[string]interface{}:
return lo.MapValues(v, func(val interface{}, _ string) interface{} {
return normalizeTimeValues(val)
})
case []interface{}:
return lo.Map(v, func(item interface{}, _ int) interface{} {
return normalizeTimeValues(item)
})
case string:
if normalized, changed := normalizeTimeString(v); changed {
return normalized
}
return v
default:
return value
}
}
func normalizeTimeString(input string) (string, bool) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return input, false
}
if _, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return trimmed, trimmed != input
}
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
return trimmed, trimmed != input
}
layouts := []string{
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05.999999",
"2006-01-02T15:04:05",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, trimmed); err == nil {
return parsed.UTC().Format(time.RFC3339Nano), true
}
}
return input, false
}

353
pkg/ionet/types.go Normal file
View File

@@ -0,0 +1,353 @@
package ionet
import (
"time"
)
// Client represents the IO.NET API client
type Client struct {
BaseURL string
APIKey string
HTTPClient HTTPClient
}
// HTTPClient interface for making HTTP requests
type HTTPClient interface {
Do(req *HTTPRequest) (*HTTPResponse, error)
}
// HTTPRequest represents an HTTP request
type HTTPRequest struct {
Method string
URL string
Headers map[string]string
Body []byte
}
// HTTPResponse represents an HTTP response
type HTTPResponse struct {
StatusCode int
Headers map[string]string
Body []byte
}
// DeploymentRequest represents a container deployment request
type DeploymentRequest struct {
ResourcePrivateName string `json:"resource_private_name"`
DurationHours int `json:"duration_hours"`
GPUsPerContainer int `json:"gpus_per_container"`
HardwareID int `json:"hardware_id"`
LocationIDs []int `json:"location_ids"`
ContainerConfig ContainerConfig `json:"container_config"`
RegistryConfig RegistryConfig `json:"registry_config"`
}
// ContainerConfig represents container configuration
type ContainerConfig struct {
ReplicaCount int `json:"replica_count"`
EnvVariables map[string]string `json:"env_variables,omitempty"`
SecretEnvVariables map[string]string `json:"secret_env_variables,omitempty"`
Entrypoint []string `json:"entrypoint,omitempty"`
TrafficPort int `json:"traffic_port,omitempty"`
Args []string `json:"args,omitempty"`
}
// RegistryConfig represents registry configuration
type RegistryConfig struct {
ImageURL string `json:"image_url"`
RegistryUsername string `json:"registry_username,omitempty"`
RegistrySecret string `json:"registry_secret,omitempty"`
}
// DeploymentResponse represents the response from deployment creation
type DeploymentResponse struct {
DeploymentID string `json:"deployment_id"`
Status string `json:"status"`
}
// DeploymentDetail represents detailed deployment information
type DeploymentDetail struct {
ID string `json:"id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
AmountPaid float64 `json:"amount_paid"`
CompletedPercent float64 `json:"completed_percent"`
TotalGPUs int `json:"total_gpus"`
GPUsPerContainer int `json:"gpus_per_container"`
TotalContainers int `json:"total_containers"`
HardwareName string `json:"hardware_name"`
HardwareID int `json:"hardware_id"`
Locations []DeploymentLocation `json:"locations"`
BrandName string `json:"brand_name"`
ComputeMinutesServed int `json:"compute_minutes_served"`
ComputeMinutesRemaining int `json:"compute_minutes_remaining"`
ContainerConfig DeploymentContainerConfig `json:"container_config"`
}
// DeploymentLocation represents a location in deployment details
type DeploymentLocation struct {
ID int `json:"id"`
ISO2 string `json:"iso2"`
Name string `json:"name"`
}
// DeploymentContainerConfig represents container config in deployment details
type DeploymentContainerConfig struct {
Entrypoint []string `json:"entrypoint"`
EnvVariables map[string]interface{} `json:"env_variables"`
TrafficPort int `json:"traffic_port"`
ImageURL string `json:"image_url"`
}
// Container represents a container within a deployment
type Container struct {
DeviceID string `json:"device_id"`
ContainerID string `json:"container_id"`
Hardware string `json:"hardware"`
BrandName string `json:"brand_name"`
CreatedAt time.Time `json:"created_at"`
UptimePercent int `json:"uptime_percent"`
GPUsPerContainer int `json:"gpus_per_container"`
Status string `json:"status"`
ContainerEvents []ContainerEvent `json:"container_events"`
PublicURL string `json:"public_url"`
}
// ContainerEvent represents a container event
type ContainerEvent struct {
Time time.Time `json:"time"`
Message string `json:"message"`
}
// ContainerList represents a list of containers
type ContainerList struct {
Total int `json:"total"`
Workers []Container `json:"workers"`
}
// Deployment represents a deployment in the list
type Deployment struct {
ID string `json:"id"`
Status string `json:"status"`
Name string `json:"name"`
CompletedPercent float64 `json:"completed_percent"`
HardwareQuantity int `json:"hardware_quantity"`
BrandName string `json:"brand_name"`
HardwareName string `json:"hardware_name"`
Served string `json:"served"`
Remaining string `json:"remaining"`
ComputeMinutesServed int `json:"compute_minutes_served"`
ComputeMinutesRemaining int `json:"compute_minutes_remaining"`
CreatedAt time.Time `json:"created_at"`
GPUCount int `json:"-"` // Derived from HardwareQuantity
Replicas int `json:"-"` // Derived from HardwareQuantity
}
// DeploymentList represents a list of deployments with pagination
type DeploymentList struct {
Deployments []Deployment `json:"deployments"`
Total int `json:"total"`
Statuses []string `json:"statuses"`
}
// AvailableReplica represents replica availability for a location
type AvailableReplica struct {
LocationID int `json:"location_id"`
LocationName string `json:"location_name"`
HardwareID int `json:"hardware_id"`
HardwareName string `json:"hardware_name"`
AvailableCount int `json:"available_count"`
MaxGPUs int `json:"max_gpus"`
}
// AvailableReplicasResponse represents the response for available replicas
type AvailableReplicasResponse struct {
Replicas []AvailableReplica `json:"replicas"`
}
// MaxGPUResponse represents the response for maximum GPUs per container
type MaxGPUResponse struct {
Hardware []MaxGPUInfo `json:"hardware"`
Total int `json:"total"`
}
// MaxGPUInfo represents max GPU information for a hardware type
type MaxGPUInfo struct {
MaxGPUsPerContainer int `json:"max_gpus_per_container"`
Available int `json:"available"`
HardwareID int `json:"hardware_id"`
HardwareName string `json:"hardware_name"`
BrandName string `json:"brand_name"`
}
// PriceEstimationRequest represents a price estimation request
type PriceEstimationRequest struct {
LocationIDs []int `json:"location_ids"`
HardwareID int `json:"hardware_id"`
GPUsPerContainer int `json:"gpus_per_container"`
DurationHours int `json:"duration_hours"`
ReplicaCount int `json:"replica_count"`
Currency string `json:"currency"`
DurationType string `json:"duration_type"`
DurationQty int `json:"duration_qty"`
HardwareQty int `json:"hardware_qty"`
}
// PriceEstimationResponse represents the price estimation response
type PriceEstimationResponse struct {
EstimatedCost float64 `json:"estimated_cost"`
Currency string `json:"currency"`
PriceBreakdown PriceBreakdown `json:"price_breakdown"`
EstimationValid bool `json:"estimation_valid"`
}
// PriceBreakdown represents detailed cost breakdown
type PriceBreakdown struct {
ComputeCost float64 `json:"compute_cost"`
NetworkCost float64 `json:"network_cost,omitempty"`
StorageCost float64 `json:"storage_cost,omitempty"`
TotalCost float64 `json:"total_cost"`
HourlyRate float64 `json:"hourly_rate"`
}
// ContainerLogs represents container log entries
type ContainerLogs struct {
ContainerID string `json:"container_id"`
Logs []LogEntry `json:"logs"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor,omitempty"`
}
// LogEntry represents a single log entry
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level,omitempty"`
Message string `json:"message"`
Source string `json:"source,omitempty"`
}
// UpdateDeploymentRequest represents request to update deployment configuration
type UpdateDeploymentRequest struct {
EnvVariables map[string]string `json:"env_variables,omitempty"`
SecretEnvVariables map[string]string `json:"secret_env_variables,omitempty"`
Entrypoint []string `json:"entrypoint,omitempty"`
TrafficPort *int `json:"traffic_port,omitempty"`
ImageURL string `json:"image_url,omitempty"`
RegistryUsername string `json:"registry_username,omitempty"`
RegistrySecret string `json:"registry_secret,omitempty"`
Args []string `json:"args,omitempty"`
Command string `json:"command,omitempty"`
}
// ExtendDurationRequest represents request to extend deployment duration
type ExtendDurationRequest struct {
DurationHours int `json:"duration_hours"`
}
// UpdateDeploymentResponse represents response from deployment update
type UpdateDeploymentResponse struct {
Status string `json:"status"`
DeploymentID string `json:"deployment_id"`
}
// UpdateClusterNameRequest represents request to update cluster name
type UpdateClusterNameRequest struct {
Name string `json:"cluster_name"`
}
// UpdateClusterNameResponse represents response from cluster name update
type UpdateClusterNameResponse struct {
Status string `json:"status"`
Message string `json:"message"`
}
// APIError represents an API error response
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// Error implements the error interface
func (e *APIError) Error() string {
if e.Details != "" {
return e.Message + ": " + e.Details
}
return e.Message
}
// ListDeploymentsOptions represents options for listing deployments
type ListDeploymentsOptions struct {
Status string `json:"status,omitempty"` // filter by status
LocationID int `json:"location_id,omitempty"` // filter by location
Page int `json:"page,omitempty"` // pagination
PageSize int `json:"page_size,omitempty"` // pagination
SortBy string `json:"sort_by,omitempty"` // sort field
SortOrder string `json:"sort_order,omitempty"` // asc/desc
}
// GetLogsOptions represents options for retrieving container logs
type GetLogsOptions struct {
StartTime *time.Time `json:"start_time,omitempty"`
EndTime *time.Time `json:"end_time,omitempty"`
Level string `json:"level,omitempty"` // filter by log level
Stream string `json:"stream,omitempty"` // filter by stdout/stderr streams
Limit int `json:"limit,omitempty"` // max number of log entries
Cursor string `json:"cursor,omitempty"` // pagination cursor
Follow bool `json:"follow,omitempty"` // stream logs
}
// HardwareType represents a hardware type available for deployment
type HardwareType struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
GPUType string `json:"gpu_type"`
GPUMemory int `json:"gpu_memory"` // in GB
MaxGPUs int `json:"max_gpus"`
CPU string `json:"cpu,omitempty"`
Memory int `json:"memory,omitempty"` // in GB
Storage int `json:"storage,omitempty"` // in GB
HourlyRate float64 `json:"hourly_rate"`
Available bool `json:"available"`
BrandName string `json:"brand_name,omitempty"`
AvailableCount int `json:"available_count,omitempty"`
}
// Location represents a deployment location
type Location struct {
ID int `json:"id"`
Name string `json:"name"`
ISO2 string `json:"iso2,omitempty"`
Region string `json:"region,omitempty"`
Country string `json:"country,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
Available int `json:"available,omitempty"`
Description string `json:"description,omitempty"`
}
// LocationsResponse represents the list of locations and aggregated metadata.
type LocationsResponse struct {
Locations []Location `json:"locations"`
Total int `json:"total"`
}
// LocationAvailability represents real-time availability for a location
type LocationAvailability struct {
LocationID int `json:"location_id"`
LocationName string `json:"location_name"`
Available bool `json:"available"`
HardwareAvailability []HardwareAvailability `json:"hardware_availability"`
UpdatedAt time.Time `json:"updated_at"`
}
// HardwareAvailability represents availability for specific hardware at a location
type HardwareAvailability struct {
HardwareID int `json:"hardware_id"`
HardwareName string `json:"hardware_name"`
AvailableCount int `json:"available_count"`
MaxGPUs int `json:"max_gpus"`
}

View File

@@ -483,9 +483,11 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
}
}
} else if claudeResponse.Type == "message_delta" {
finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason)
if finishReason != "null" {
choice.FinishReason = &finishReason
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason)
if finishReason != "null" {
choice.FinishReason = &finishReason
}
}
//claudeUsage = &claudeResponse.Usage
} else if claudeResponse.Type == "message_stop" {

View File

@@ -13,6 +13,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -137,7 +138,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
} else if baseModel, level := parseThinkingLevelSuffix(info.UpstreamModelName); level != "" {
} else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
info.UpstreamModelName = baseModel
}
}

View File

@@ -98,6 +98,7 @@ func clampThinkingBudget(modelName string, budget int) int {
// "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens)
// "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens)
// "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens)
// "effort": "minimal" - Allocates a minimal portion of tokens (approximately 5% of max_tokens)
func clampThinkingBudgetByEffort(modelName string, effort string) int {
isNew25Pro := isNew25ProModel(modelName)
is25FlashLite := is25FlashLiteModel(modelName)
@@ -118,18 +119,12 @@ func clampThinkingBudgetByEffort(modelName string, effort string) int {
maxBudget = maxBudget * 50 / 100
case "low":
maxBudget = maxBudget * 20 / 100
case "minimal":
maxBudget = maxBudget * 5 / 100
}
return clampThinkingBudget(modelName, maxBudget)
}
func parseThinkingLevelSuffix(modelName string) (string, string) {
base, level, ok := reasoning.TrimEffortSuffix(modelName)
if !ok {
return modelName, ""
}
return base, level
}
func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
modelName := info.UpstreamModelName
@@ -186,7 +181,7 @@ func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.Rel
ThinkingBudget: common.GetPointer(0),
}
}
} else if _, level := parseThinkingLevelSuffix(modelName); level != "" {
} else if _, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
ThinkingLevel: level,
@@ -379,7 +374,7 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
var system_content []string
//shouldAddDummyModelMessage := false
for _, message := range textRequest.Messages {
if message.Role == "system" {
if message.Role == "system" || message.Role == "developer" {
system_content = append(system_content, message.StringContent())
continue
} else if message.Role == "tool" || message.Role == "function" {

View File

@@ -67,3 +67,40 @@ type OllamaEmbeddingResponse struct {
Embeddings [][]float64 `json:"embeddings"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
}
type OllamaTagsResponse struct {
Models []OllamaModel `json:"models"`
}
type OllamaModel struct {
Name string `json:"name"`
Size int64 `json:"size"`
Digest string `json:"digest,omitempty"`
ModifiedAt string `json:"modified_at"`
Details OllamaModelDetail `json:"details,omitempty"`
}
type OllamaModelDetail struct {
ParentModel string `json:"parent_model,omitempty"`
Format string `json:"format,omitempty"`
Family string `json:"family,omitempty"`
Families []string `json:"families,omitempty"`
ParameterSize string `json:"parameter_size,omitempty"`
QuantizationLevel string `json:"quantization_level,omitempty"`
}
type OllamaPullRequest struct {
Name string `json:"name"`
Stream bool `json:"stream,omitempty"`
}
type OllamaPullResponse struct {
Status string `json:"status"`
Digest string `json:"digest,omitempty"`
Total int64 `json:"total,omitempty"`
Completed int64 `json:"completed,omitempty"`
}
type OllamaDeleteRequest struct {
Name string `json:"name"`
}

View File

@@ -1,11 +1,13 @@
package ollama
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
@@ -283,3 +285,246 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
service.IOCopyBytesGracefully(c, resp, out)
return usage, nil
}
func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
url := fmt.Sprintf("%s/api/tags", baseURL)
client := &http.Client{}
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
// Ollama 通常不需要 Bearer token但为了兼容性保留
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
}
var tagsResponse OllamaTagsResponse
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
err = common.Unmarshal(body, &tagsResponse)
if err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
return tagsResponse.Models, nil
}
// 拉取 Ollama 模型 (非流式)
func PullOllamaModel(baseURL, apiKey, modelName string) error {
url := fmt.Sprintf("%s/api/pull", baseURL)
pullRequest := OllamaPullRequest{
Name: modelName,
Stream: false, // 非流式,简化处理
}
requestBody, err := common.Marshal(pullRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
}
client := &http.Client{
Timeout: 30 * 60 * 1000 * time.Millisecond, // 30分钟超时支持大模型
}
request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
request.Header.Set("Content-Type", "application/json")
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
}
return nil
}
// 流式拉取 Ollama 模型 (支持进度回调)
func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback func(OllamaPullResponse)) error {
url := fmt.Sprintf("%s/api/pull", baseURL)
pullRequest := OllamaPullRequest{
Name: modelName,
Stream: true, // 启用流式
}
requestBody, err := common.Marshal(pullRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
}
client := &http.Client{
Timeout: 60 * 60 * 1000 * time.Millisecond, // 1小时超时支持超大模型
}
request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
request.Header.Set("Content-Type", "application/json")
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
}
// 读取流式响应
scanner := bufio.NewScanner(response.Body)
successful := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
var pullResponse OllamaPullResponse
if err := common.Unmarshal([]byte(line), &pullResponse); err != nil {
continue // 忽略解析失败的行
}
if progressCallback != nil {
progressCallback(pullResponse)
}
// 检查是否出现错误或完成
if strings.EqualFold(pullResponse.Status, "error") {
return fmt.Errorf("拉取模型失败: %s", strings.TrimSpace(line))
}
if strings.EqualFold(pullResponse.Status, "success") {
successful = true
break
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("读取流式响应失败: %v", err)
}
if !successful {
return fmt.Errorf("拉取模型未完成: 未收到成功状态")
}
return nil
}
// 删除 Ollama 模型
func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
url := fmt.Sprintf("%s/api/delete", baseURL)
deleteRequest := OllamaDeleteRequest{
Name: modelName,
}
requestBody, err := common.Marshal(deleteRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
}
client := &http.Client{}
request, err := http.NewRequest("DELETE", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
request.Header.Set("Content-Type", "application/json")
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("删除模型失败 %d: %s", response.StatusCode, string(body))
}
return nil
}
func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
trimmedBase := strings.TrimRight(baseURL, "/")
if trimmedBase == "" {
return "", fmt.Errorf("baseURL 为空")
}
url := fmt.Sprintf("%s/api/version", trimmedBase)
client := &http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
}
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("查询版本失败 %d: %s", response.StatusCode, string(body))
}
var versionResp struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &versionResp); err != nil {
return "", fmt.Errorf("解析响应失败: %v", err)
}
if versionResp.Version == "" {
return "", fmt.Errorf("未返回版本信息")
}
return versionResp.Version, nil
}

View File

@@ -596,7 +596,7 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res
if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 {
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
}
case constant.ChannelTypeZhipu_v4:
case constant.ChannelTypeZhipu_v4, constant.ChannelTypeMoonshot:
if usage.PromptTokensDetails.CachedTokens == 0 {
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens

View File

@@ -51,10 +51,43 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
// Vertex AI does not support functionResponse.id; keep it stripped here for consistency.
if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
removeFunctionResponseID(request)
}
geminiAdaptor := gemini.Adaptor{}
return geminiAdaptor.ConvertGeminiRequest(c, info, request)
}
func removeFunctionResponseID(request *dto.GeminiChatRequest) {
if request == nil {
return
}
if len(request.Contents) > 0 {
for i := range request.Contents {
if len(request.Contents[i].Parts) == 0 {
continue
}
for j := range request.Contents[i].Parts {
part := &request.Contents[i].Parts[j]
if part.FunctionResponse == nil {
continue
}
if len(part.FunctionResponse.ID) > 0 {
part.FunctionResponse.ID = nil
}
}
}
}
if len(request.Requests) > 0 {
for i := range request.Requests {
removeFunctionResponseID(&request.Requests[i])
}
}
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
c.Set("request_model", v)

View File

@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -634,3 +635,47 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
}
return jsonDataAfter, nil
}
// RemoveGeminiDisabledFields removes disabled fields from Gemini request JSON data
// Currently supports removing functionResponse.id field which Vertex AI does not support
func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) {
if !model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
return jsonData, nil
}
var data map[string]interface{}
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveGeminiDisabledFields Unmarshal error: " + err.Error())
return jsonData, nil
}
// Process contents array
// Handle both camelCase (functionResponse) and snake_case (function_response)
if contents, ok := data["contents"].([]interface{}); ok {
for _, content := range contents {
if contentMap, ok := content.(map[string]interface{}); ok {
if parts, ok := contentMap["parts"].([]interface{}); ok {
for _, part := range parts {
if partMap, ok := part.(map[string]interface{}); ok {
// Check functionResponse (camelCase)
if funcResp, ok := partMap["functionResponse"].(map[string]interface{}); ok {
delete(funcResp, "id")
}
// Check function_response (snake_case)
if funcResp, ok := partMap["function_response"].(map[string]interface{}); ok {
delete(funcResp, "id")
}
}
}
}
}
}
}
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveGeminiDisabledFields Marshal error: " + err.Error())
return jsonData, nil
}
return jsonDataAfter, nil
}

View File

@@ -300,14 +300,20 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
if !relayInfo.PriceData.UsePrice {
baseTokens := dPromptTokens
// 减去 cached tokens
// Anthropic API 的 input_tokens 已经不包含缓存 tokens不需要减去
// OpenAI/OpenRouter 等 API 的 prompt_tokens 包含缓存 tokens需要减去
var cachedTokensWithRatio decimal.Decimal
if !dCacheTokens.IsZero() {
baseTokens = baseTokens.Sub(dCacheTokens)
if relayInfo.ChannelType != constant.ChannelTypeAnthropic {
baseTokens = baseTokens.Sub(dCacheTokens)
}
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
}
var dCachedCreationTokensWithRatio decimal.Decimal
if !dCachedCreationTokens.IsZero() {
baseTokens = baseTokens.Sub(dCachedCreationTokens)
if relayInfo.ChannelType != constant.ChannelTypeAnthropic {
baseTokens = baseTokens.Sub(dCachedCreationTokens)
}
dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio)
}

View File

@@ -152,6 +152,10 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/fix", controller.FixChannelsAbilities)
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
channelRoute.POST("/fetch_models", controller.FetchModels)
channelRoute.POST("/ollama/pull", controller.OllamaPullModel)
channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream)
channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel)
channelRoute.GET("/ollama/version/:id", controller.OllamaVersion)
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
channelRoute.GET("/tag/models", controller.GetTagModels)
channelRoute.POST("/copy/:id", controller.CopyChannel)
@@ -256,5 +260,45 @@ func SetApiRouter(router *gin.Engine) {
modelsRoute.PUT("/", controller.UpdateModelMeta)
modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
}
// Deployments (model deployment management)
deploymentsRoute := apiRouter.Group("/deployments")
deploymentsRoute.Use(middleware.AdminAuth())
{
// List and search deployments
deploymentsRoute.GET("/", controller.GetAllDeployments)
deploymentsRoute.GET("/search", controller.SearchDeployments)
// Connection utilities
deploymentsRoute.POST("/test-connection", controller.TestIoNetConnection)
// Resource and configuration endpoints
deploymentsRoute.GET("/hardware-types", controller.GetHardwareTypes)
deploymentsRoute.GET("/locations", controller.GetLocations)
deploymentsRoute.GET("/available-replicas", controller.GetAvailableReplicas)
deploymentsRoute.POST("/price-estimation", controller.GetPriceEstimation)
deploymentsRoute.GET("/check-name", controller.CheckClusterNameAvailability)
// Create new deployment
deploymentsRoute.POST("/", controller.CreateDeployment)
// Individual deployment operations
deploymentsRoute.GET("/:id", controller.GetDeployment)
deploymentsRoute.GET("/:id/logs", controller.GetDeploymentLogs)
deploymentsRoute.GET("/:id/containers", controller.ListDeploymentContainers)
deploymentsRoute.GET("/:id/containers/:container_id", controller.GetContainerDetails)
deploymentsRoute.PUT("/:id", controller.UpdateDeployment)
deploymentsRoute.PUT("/:id/name", controller.UpdateDeploymentName)
deploymentsRoute.POST("/:id/extend", controller.ExtendDeployment)
deploymentsRoute.DELETE("/:id", controller.DeleteDeployment)
// Future batch operations:
// deploymentsRoute.POST("/:id/start", controller.StartDeployment)
// deploymentsRoute.POST("/:id/stop", controller.StopDeployment)
// deploymentsRoute.POST("/:id/restart", controller.RestartDeployment)
// deploymentsRoute.POST("/batch_delete", controller.BatchDeleteDeployments)
// deploymentsRoute.POST("/batch_start", controller.BatchStartDeployments)
// deploymentsRoute.POST("/batch_stop", controller.BatchStopDeployments)
}
}
}

View File

@@ -389,25 +389,29 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
idx := blockIndex
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.ID,
Type: "tool_use",
Name: toolCall.Function.Name,
Input: map[string]interface{}{},
},
})
if toolCall.Function.Name != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.ID,
Type: "tool_use",
Name: toolCall.Function.Name,
Input: map[string]interface{}{},
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
if len(toolCall.Function.Arguments) > 0 {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
info.ClaudeConvertInfo.Index = blockIndex
}

View File

@@ -90,11 +90,17 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai
}
CloseResponseBodyGracefully(resp)
var errResponse dto.GeneralErrorResponse
buildErrWithBody := func(message string) error {
if message == "" {
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
}
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, string(responseBody))
}
err = common.Unmarshal(responseBody, &errResponse)
if err != nil {
if showBodyWhenFail {
newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
newApiErr.Err = buildErrWithBody("")
} else {
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
@@ -107,10 +113,16 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai
oaiError := errResponse.TryToOpenAIError()
if oaiError != nil {
newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
if showBodyWhenFail {
newApiErr.Err = buildErrWithBody(newApiErr.Error())
}
return
}
}
newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode)
if showBodyWhenFail {
newApiErr.Err = buildErrWithBody(newApiErr.Error())
}
return
}

View File

@@ -95,7 +95,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
return err
}
token, err := model.GetTokenByKey(strings.TrimLeft(relayInfo.TokenKey, "sk-"), false)
token, err := model.GetTokenByKey(strings.TrimPrefix(relayInfo.TokenKey, "sk-"), false)
if err != nil {
return err
}

View File

@@ -4,7 +4,7 @@ import (
"github.com/QuantumNous/new-api/setting/config"
)
// GeminiSettings 定义Gemini模型的配置
// GeminiSettings defines Gemini model configuration. 注意bool要以enabled结尾才可以生效编辑
type GeminiSettings struct {
SafetySettings map[string]string `json:"safety_settings"`
VersionSettings map[string]string `json:"version_settings"`
@@ -12,6 +12,7 @@ type GeminiSettings struct {
ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"`
ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"`
FunctionCallThoughtSignatureEnabled bool `json:"function_call_thought_signature_enabled"`
RemoveFunctionResponseIdEnabled bool `json:"remove_function_response_id_enabled"`
}
// 默认配置
@@ -30,6 +31,7 @@ var defaultGeminiSettings = GeminiSettings{
ThinkingAdapterEnabled: false,
ThinkingAdapterBudgetTokensPercentage: 0.6,
FunctionCallThoughtSignatureEnabled: true,
RemoveFunctionResponseIdEnabled: true,
}
// 全局实例

View File

@@ -7,7 +7,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
)
// from songquanpeng/one-api
@@ -829,10 +828,6 @@ func FormatMatchingModelName(name string) string {
name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*")
}
if base, _, ok := reasoning.TrimEffortSuffix(name); ok {
name = base
}
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/samber/lo"
)
var EffortSuffixes = []string{"-high", "-medium", "-low"}
var EffortSuffixes = []string{"-high", "-medium", "-low", "-minimal"}
// TrimEffortSuffix -> modelName level(low) exists
func TrimEffortSuffix(modelName string) (string, string, bool) {

View File

@@ -1,6 +1,7 @@
package types
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -10,10 +11,11 @@ import (
)
type OpenAIError struct {
Message string `json:"message"`
Type string `json:"type"`
Param string `json:"param"`
Code any `json:"code"`
Message string `json:"message"`
Type string `json:"type"`
Param string `json:"param"`
Code any `json:"code"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
type ClaudeError struct {
@@ -92,6 +94,7 @@ type NewAPIError struct {
errorType ErrorType
errorCode ErrorCode
StatusCode int
Metadata json.RawMessage
}
// Unwrap enables errors.Is / errors.As to work with NewAPIError by exposing the underlying error.
@@ -301,6 +304,13 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIError
Err: errors.New(openAIError.Message),
errorCode: ErrorCode(code),
}
// OpenRouter
if len(openAIError.Metadata) > 0 {
openAIError.Message = fmt.Sprintf("%s (%s)", openAIError.Message, openAIError.Metadata)
e.Metadata = openAIError.Metadata
e.RelayError = openAIError
e.Err = errors.New(openAIError.Message)
}
for _, op := range ops {
op(e)
}

View File

@@ -42,6 +42,7 @@ import Midjourney from './pages/Midjourney';
import Pricing from './pages/Pricing';
import Task from './pages/Task';
import ModelPage from './pages/Model';
import ModelDeploymentPage from './pages/ModelDeployment';
import Playground from './pages/Playground';
import OAuth2Callback from './components/auth/OAuth2Callback';
import PersonalSetting from './components/settings/PersonalSetting';
@@ -108,6 +109,14 @@ function App() {
</AdminRoute>
}
/>
<Route
path='/console/deployment'
element={
<AdminRoute>
<ModelDeploymentPage />
</AdminRoute>
}
/>
<Route
path='/console/channel'
element={

View File

@@ -45,6 +45,7 @@ const routerMap = {
pricing: '/pricing',
task: '/console/task',
models: '/console/models',
deployment: '/console/deployment',
playground: '/console/playground',
personal: '/console/personal',
};
@@ -157,6 +158,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
to: '/console/models',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('模型部署'),
itemKey: 'deployment',
to: '/deployment',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码管理'),
itemKey: 'redemption',

View File

@@ -52,7 +52,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobile ? 40 : width, height }}
/>
}
@@ -71,7 +70,7 @@ const SkeletonWrapper = ({
loading={true}
active
placeholder={
<Skeleton.Avatar active size='extra-small' className='shadow-sm' />
<Skeleton.Avatar size='extra-small' className='shadow-sm' />
}
/>
<div className='ml-1.5 mr-1'>
@@ -80,7 +79,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobile ? 15 : width, height: 12 }}
/>
}
@@ -98,7 +96,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Image
active
className={`absolute inset-0 !rounded-full ${className}`}
style={{ width: '100%', height: '100%' }}
/>
@@ -113,7 +110,7 @@ const SkeletonWrapper = ({
<Skeleton
loading={true}
active
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
placeholder={<Skeleton.Title style={{ width, height: 24 }} />}
/>
);
};
@@ -125,7 +122,7 @@ const SkeletonWrapper = ({
<Skeleton
loading={true}
active
placeholder={<Skeleton.Title active style={{ width, height }} />}
placeholder={<Skeleton.Title style={{ width, height }} />}
/>
</div>
);
@@ -140,7 +137,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Title
active
style={{ width, height, borderRadius: 9999 }}
/>
}
@@ -164,7 +160,7 @@ const SkeletonWrapper = ({
loading={true}
active
placeholder={
<Skeleton.Avatar active size='extra-small' shape='square' />
<Skeleton.Avatar size='extra-small' shape='square' />
}
/>
</div>
@@ -174,7 +170,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Title
active
style={{ width: width || 80, height: height || 14 }}
/>
}
@@ -191,10 +186,7 @@ const SkeletonWrapper = ({
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: width || 60, height: height || 12 }}
/>
<Skeleton.Title style={{ width: width || 60, height: height || 12 }} />
}
/>
</div>
@@ -217,7 +209,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Avatar
active
shape='square'
style={{ width: ICON_SIZE, height: ICON_SIZE }}
/>
@@ -231,7 +222,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Title
active
style={{ width: labelWidth, height: TEXT_HEIGHT }}
/>
}
@@ -269,7 +259,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Avatar
active
shape='square'
style={{ width: ICON_SIZE, height: ICON_SIZE }}
/>
@@ -329,7 +318,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Title
active
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
/>
}
@@ -350,7 +338,6 @@ const SkeletonWrapper = ({
active
placeholder={
<Skeleton.Title
active
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
/>
}

View File

@@ -0,0 +1,377 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Card, Button, Typography } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Settings, Server, AlertCircle, WifiOff } from 'lucide-react';
const { Title, Text } = Typography;
const DeploymentAccessGuard = ({
children,
loading,
isEnabled,
connectionLoading,
connectionOk,
connectionError,
onRetry,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToSettings = () => {
navigate('/console/setting?tab=model-deployment');
};
if (loading) {
return (
<div className='mt-[60px] px-2'>
<Card loading={true} style={{ minHeight: '400px' }}>
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type="secondary">{t('加载设置中...')}</Text>
</div>
</Card>
</div>
);
}
if (!isEnabled) {
return (
<div
className='mt-[60px] px-4'
style={{
minHeight: 'calc(100vh - 60px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<div
style={{
maxWidth: '600px',
width: '100%',
textAlign: 'center',
padding: '0 20px'
}}
>
<Card
style={{
padding: '60px 40px',
borderRadius: '16px',
border: '1px solid var(--semi-color-border)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
background: 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)'
}}
>
{/* 图标区域 */}
<div style={{ marginBottom: '32px' }}>
<div style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(var(--semi-orange-4), 0.15) 0%, rgba(var(--semi-orange-5), 0.1) 100%)',
border: '3px solid rgba(var(--semi-orange-4), 0.3)',
marginBottom: '24px'
}}>
<AlertCircle size={56} color="var(--semi-color-warning)" />
</div>
</div>
{/* 标题区域 */}
<div style={{ marginBottom: '24px' }}>
<Title
heading={2}
style={{
color: 'var(--semi-color-text-0)',
margin: '0 0 12px 0',
fontSize: '28px',
fontWeight: '700'
}}
>
{t('模型部署服务未启用')}
</Title>
<Text
style={{
fontSize: '18px',
lineHeight: '1.6',
color: 'var(--semi-color-text-1)',
display: 'block'
}}
>
{t('访问模型部署功能需要先启用 io.net 部署服务')}
</Text>
</div>
{/* 配置要求区域 */}
<div
style={{
backgroundColor: 'var(--semi-color-bg-1)',
padding: '24px',
borderRadius: '12px',
border: '1px solid var(--semi-color-border)',
margin: '32px 0',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
marginBottom: '16px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: '8px',
backgroundColor: 'rgba(var(--semi-blue-4), 0.15)'
}}>
<Server size={20} color="var(--semi-color-primary)" />
</div>
<Text
strong
style={{
fontSize: '16px',
color: 'var(--semi-color-text-0)'
}}
>
{t('需要配置的项目')}
</Text>
</div>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
alignItems: 'flex-start',
textAlign: 'left',
maxWidth: '320px',
margin: '0 auto'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--semi-color-primary)',
flexShrink: 0
}}></div>
<Text style={{ fontSize: '15px', color: 'var(--semi-color-text-1)' }}>
{t('启用 io.net 部署开关')}
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--semi-color-primary)',
flexShrink: 0
}}></div>
<Text style={{ fontSize: '15px', color: 'var(--semi-color-text-1)' }}>
{t('配置有效的 io.net API Key')}
</Text>
</div>
</div>
</div>
{/* 操作链接区域 */}
<div style={{ marginBottom: '20px' }}>
<div
onClick={handleGoToSettings}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
padding: '12px 24px',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '500',
color: 'var(--semi-color-primary)',
background: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-border)',
transition: 'all 0.2s ease',
textDecoration: 'none'
}}
onMouseEnter={(e) => {
e.target.style.background = 'var(--semi-color-fill-1)';
e.target.style.transform = 'translateY(-1px)';
e.target.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
}}
onMouseLeave={(e) => {
e.target.style.background = 'var(--semi-color-fill-0)';
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
<Settings size={18} />
{t('前往设置页面')}
</div>
</div>
{/* 底部提示 */}
<Text
type="tertiary"
style={{
fontSize: '14px',
color: 'var(--semi-color-text-2)',
lineHeight: '1.5'
}}
>
{t('配置完成后刷新页面即可使用模型部署功能')}
</Text>
</Card>
</div>
</div>
);
}
if (connectionLoading || (connectionOk === null && !connectionError)) {
return (
<div className='mt-[60px] px-2'>
<Card loading={true} style={{ minHeight: '400px' }}>
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type="secondary">{t('Checking io.net connection...')}</Text>
</div>
</Card>
</div>
);
}
if (connectionOk === false) {
const isExpired = connectionError?.type === 'expired';
const title = isExpired
? t('API key expired')
: t('io.net connection unavailable');
const description = isExpired
? t('The current API key is expired. Please update it in settings.')
: t('Unable to connect to io.net with the current configuration.');
const detail = connectionError?.message || '';
return (
<div
className='mt-[60px] px-4'
style={{
minHeight: 'calc(100vh - 60px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
maxWidth: '600px',
width: '100%',
textAlign: 'center',
padding: '0 20px',
}}
>
<Card
style={{
padding: '60px 40px',
borderRadius: '16px',
border: '1px solid var(--semi-color-border)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
background: 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
}}
>
<div style={{ marginBottom: '32px' }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(var(--semi-red-4), 0.15) 0%, rgba(var(--semi-red-5), 0.1) 100%)',
border: '3px solid rgba(var(--semi-red-4), 0.3)',
marginBottom: '24px',
}}
>
<WifiOff size={56} color="var(--semi-color-danger)" />
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<Title
heading={2}
style={{
color: 'var(--semi-color-text-0)',
margin: '0 0 12px 0',
fontSize: '28px',
fontWeight: '700',
}}
>
{title}
</Title>
<Text
style={{
fontSize: '18px',
lineHeight: '1.6',
color: 'var(--semi-color-text-1)',
display: 'block',
}}
>
{description}
</Text>
{detail ? (
<Text
type="tertiary"
style={{
fontSize: '14px',
lineHeight: '1.5',
display: 'block',
marginTop: '8px',
}}
>
{detail}
</Text>
) : null}
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<Button type="primary" icon={<Settings size={18} />} onClick={handleGoToSettings}>
{t('Go to settings')}
</Button>
{onRetry ? (
<Button type="tertiary" onClick={onRetry}>
{t('Retry connection')}
</Button>
) : null}
</div>
</Card>
</div>
</div>
);
}
return children;
};
export default DeploymentAccessGuard;

View File

@@ -0,0 +1,85 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
import SettingModelDeployment from '../../pages/Setting/Model/SettingModelDeployment';
const ModelDeploymentSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
};
data.forEach((item) => {
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
console.error(error);
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}>
<SettingModelDeployment options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default ModelDeploymentSetting;

View File

@@ -32,6 +32,7 @@ const ModelSetting = () => {
'gemini.safety_settings': '',
'gemini.version_settings': '',
'gemini.supported_imagine_models': '',
'gemini.remove_function_response_id_enabled': true,
'claude.model_headers_settings': '',
'claude.thinking_adapter_enabled': true,
'claude.default_max_tokens': '',
@@ -64,6 +65,7 @@ const ModelSetting = () => {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
}
// Keep boolean config keys ending with enabled/Enabled so UI parses correctly.
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
newInputs[item.key] = toBoolean(item.value);
} else {

View File

@@ -314,10 +314,10 @@ const PersonalSetting = () => {
};
const changePassword = async () => {
if (inputs.original_password === '') {
showError(t('请输入原密码!'));
return;
}
// if (inputs.original_password === '') {
// showError(t('请输入原密码!'));
// return;
// }
if (inputs.set_new_password === '') {
showError(t('请输入新密码!'));
return;

View File

@@ -39,11 +39,16 @@ import {
showError,
} from '../../../helpers';
import { CHANNEL_OPTIONS } from '../../../constants';
import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons';
import {
IconTreeTriangleDown,
IconMore,
IconAlertTriangle,
} from '@douyinfe/semi-icons';
import { FaRandom } from 'react-icons/fa';
// Render functions
const renderType = (type, channelInfo = undefined, t) => {
const renderType = (type, record = {}, t) => {
const channelInfo = record?.channel_info;
let type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
@@ -67,11 +72,65 @@ const renderType = (type, channelInfo = undefined, t) => {
);
}
return (
const typeTag = (
<Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
{type2label[type]?.label}
</Tag>
);
let ionetMeta = null;
if (record?.other_info) {
try {
const parsed = JSON.parse(record.other_info);
if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
ionetMeta = parsed;
}
} catch (error) {
// ignore invalid metadata
}
}
if (!ionetMeta) {
return typeTag;
}
const handleNavigate = (event) => {
event?.stopPropagation?.();
if (!ionetMeta?.deployment_id) {
return;
}
const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
window.open(targetUrl, '_blank', 'noopener');
};
return (
<Space spacing={6}>
{typeTag}
<Tooltip
content={
<div className='max-w-xs'>
<div className='text-xs text-gray-600'>{t('来源于 IO.NET 部署')}</div>
{ionetMeta?.deployment_id && (
<div className='text-xs text-gray-500 mt-1'>
{t('部署 ID')}: {ionetMeta.deployment_id}
</div>
)}
</div>
}
>
<span>
<Tag
color='purple'
type='light'
className='cursor-pointer'
onClick={handleNavigate}
>
IO.NET
</Tag>
</span>
</Tooltip>
</Space>
);
};
const renderTagType = (t) => {
@@ -187,6 +246,28 @@ const renderResponseTime = (responseTime, t) => {
}
};
const isRequestPassThroughEnabled = (record) => {
if (!record || record.children !== undefined) {
return false;
}
const settingValue = record.setting;
if (!settingValue) {
return false;
}
if (typeof settingValue === 'object') {
return settingValue.pass_through_body_enabled === true;
}
if (typeof settingValue !== 'string') {
return false;
}
try {
const parsed = JSON.parse(settingValue);
return parsed?.pass_through_body_enabled === true;
} catch (error) {
return false;
}
};
export const getChannelsColumns = ({
t,
COLUMN_KEYS,
@@ -205,6 +286,7 @@ export const getChannelsColumns = ({
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
}) => {
@@ -219,8 +301,9 @@ export const getChannelsColumns = ({
title: t('名称'),
dataIndex: 'name',
render: (text, record, index) => {
if (record.remark && record.remark.trim() !== '') {
return (
const passThroughEnabled = isRequestPassThroughEnabled(record);
const nameNode =
record.remark && record.remark.trim() !== '' ? (
<Tooltip
content={
<div className='flex flex-col gap-2 max-w-xs'>
@@ -250,9 +333,32 @@ export const getChannelsColumns = ({
>
<span>{text}</span>
</Tooltip>
) : (
<span>{text}</span>
);
if (!passThroughEnabled) {
return nameNode;
}
return text;
return (
<Space spacing={6} align='center'>
{nameNode}
<Tooltip
content={t(
'该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
)}
trigger='hover'
position='topLeft'
>
<span className='inline-flex items-center'>
<IconAlertTriangle
style={{ color: 'var(--semi-color-warning)' }}
/>
</span>
</Tooltip>
</Space>
);
},
},
{
@@ -280,12 +386,7 @@ export const getChannelsColumns = ({
dataIndex: 'type',
render: (text, record, index) => {
if (record.children === undefined) {
if (record.channel_info) {
if (record.channel_info.is_multi_key) {
return <>{renderType(text, record.channel_info, t)}</>;
}
}
return <>{renderType(text, undefined, t)}</>;
return <>{renderType(text, record, t)}</>;
} else {
return <>{renderTagType(t)}</>;
}
@@ -519,6 +620,15 @@ export const getChannelsColumns = ({
},
];
if (record.type === 4) {
moreMenuItems.unshift({
node: 'item',
name: t('测活'),
type: 'tertiary',
onClick: () => checkOllamaVersion(record),
});
}
return (
<Space wrap>
<SplitButtonGroup

View File

@@ -57,6 +57,7 @@ const ChannelsTable = (channelsData) => {
setEditingTag,
copySelectedChannel,
refresh,
checkOllamaVersion,
// Multi-key management
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
@@ -82,6 +83,7 @@ const ChannelsTable = (channelsData) => {
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
});
@@ -103,6 +105,7 @@ const ChannelsTable = (channelsData) => {
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
]);

View File

@@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Banner } from '@douyinfe/semi-ui';
import { IconAlertTriangle } from '@douyinfe/semi-icons';
import CardPro from '../../common/ui/CardPro';
import ChannelsTable from './ChannelsTable';
import ChannelsActions from './ChannelsActions';
@@ -63,6 +65,22 @@ const ChannelsPage = () => {
/>
{/* Main Content */}
{channelsData.globalPassThroughEnabled ? (
<Banner
type='warning'
closeIcon={null}
icon={
<IconAlertTriangle
size='large'
style={{ color: 'var(--semi-color-warning)' }}
/>
}
description={channelsData.t(
'已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。',
)}
style={{ marginBottom: 12 }}
/>
) : null}
<CardPro
type='type3'
tabsArea={<ChannelsTabs {...channelsData} />}

View File

@@ -55,6 +55,7 @@ import {
selectFilter,
} from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal';
import OllamaModelModal from './OllamaModelModal';
import JSONEditor from '../../../common/ui/JSONEditor';
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
@@ -180,6 +181,7 @@ const EditChannelModal = (props) => {
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [modelModalVisible, setModelModalVisible] = useState(false);
const [fetchedModels, setFetchedModels] = useState([]);
const [ollamaModalVisible, setOllamaModalVisible] = useState(false);
const formApiRef = useRef(null);
const [vertexKeys, setVertexKeys] = useState([]);
const [vertexFileList, setVertexFileList] = useState([]);
@@ -214,6 +216,8 @@ const EditChannelModal = (props) => {
return [];
}
}, [inputs.model_mapping]);
const [isIonetChannel, setIsIonetChannel] = useState(false);
const [ionetMetadata, setIonetMetadata] = useState(null);
// 密钥显示状态
const [keyDisplayState, setKeyDisplayState] = useState({
@@ -224,6 +228,21 @@ const EditChannelModal = (props) => {
// 专门的2FA验证状态用于TwoFactorAuthModal
const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
const [verifyCode, setVerifyCode] = useState('');
useEffect(() => {
if (!isEdit) {
setIsIonetChannel(false);
setIonetMetadata(null);
}
}, [isEdit]);
const handleOpenIonetDeployment = () => {
if (!ionetMetadata?.deployment_id) {
return;
}
const targetUrl = `/console/deployment?deployment_id=${ionetMetadata.deployment_id}`;
window.open(targetUrl, '_blank', 'noopener');
};
const [verifyLoading, setVerifyLoading] = useState(false);
// 表单块导航相关状态
@@ -404,7 +423,12 @@ const EditChannelModal = (props) => {
handleInputChange('settings', settingsJson);
};
const isIonetLocked = isIonetChannel && isEdit;
const handleInputChange = (name, value) => {
if (isIonetChannel && isEdit && ['type', 'key', 'base_url'].includes(name)) {
return;
}
if (formApiRef.current) {
formApiRef.current.setValue(name, value);
}
@@ -625,6 +649,25 @@ const EditChannelModal = (props) => {
.map((model) => (model || '').trim())
.filter(Boolean);
initialModelMappingRef.current = data.model_mapping || '';
let parsedIonet = null;
if (data.other_info) {
try {
const maybeMeta = JSON.parse(data.other_info);
if (
maybeMeta &&
typeof maybeMeta === 'object' &&
maybeMeta.source === 'ionet'
) {
parsedIonet = maybeMeta;
}
} catch (error) {
// ignore parse error
}
}
const managedByIonet = !!parsedIonet;
setIsIonetChannel(managedByIonet);
setIonetMetadata(parsedIonet);
// console.log(data);
} else {
showError(message);
@@ -632,7 +675,8 @@ const EditChannelModal = (props) => {
setLoading(false);
};
const fetchUpstreamModelList = async (name) => {
const fetchUpstreamModelList = async (name, options = {}) => {
const silent = !!options.silent;
// if (inputs['type'] !== 1) {
// showError(t('仅支持 OpenAI 接口格式'));
// return;
@@ -683,7 +727,9 @@ const EditChannelModal = (props) => {
if (!err) {
const uniqueModels = Array.from(new Set(models));
setFetchedModels(uniqueModels);
setModelModalVisible(true);
if (!silent) {
setModelModalVisible(true);
}
} else {
showError(t('获取模型列表失败'));
}
@@ -1626,20 +1672,44 @@ const EditChannelModal = (props) => {
</div>
</div>
<Form.Select
field='type'
label={t('类型')}
placeholder={t('请选择渠道类型')}
rules={[{ required: true, message: t('请选择渠道类型') }]}
optionList={channelOptionList}
style={{ width: '100%' }}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
onSearch={(value) => setChannelSearchValue(value)}
renderOptionItem={renderChannelOption}
onChange={(value) => handleInputChange('type', value)}
/>
{isIonetChannel && (
<Banner
type='info'
closeIcon={null}
className='mb-4 rounded-xl'
description={t('此渠道由 IO.NET 自动同步,类型、密钥和 API 地址已锁定。')}
>
<Space>
{ionetMetadata?.deployment_id && (
<Button
size='small'
theme='light'
type='primary'
icon={<IconGlobe />}
onClick={handleOpenIonetDeployment}
>
{t('查看关联部署')}
</Button>
)}
</Space>
</Banner>
)}
<Form.Select
field='type'
label={t('类型')}
placeholder={t('请选择渠道类型')}
rules={[{ required: true, message: t('请选择渠道类型') }]}
optionList={channelOptionList}
style={{ width: '100%' }}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
onSearch={(value) => setChannelSearchValue(value)}
renderOptionItem={renderChannelOption}
onChange={(value) => handleInputChange('type', value)}
disabled={isIonetLocked}
/>
{inputs.type === 20 && (
<Form.Switch
@@ -1778,87 +1848,86 @@ const EditChannelModal = (props) => {
autosize
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
)}
{isEdit && (
disabled={isIonetLocked}
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
)}
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
type={
!useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('查看密钥')}
{t('文件上传')}
</Button>
)}
{batchExtra}
<Button
size='small'
type={useManualInput ? 'primary' : 'tertiary'}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('手动输入')}
</Button>
</Space>
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type={
!useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('文件上传')}
</Button>
<Button
size='small'
type={
useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('手动输入')}
</Button>
</Space>
</div>
)}
)}
{batch && (
<Banner
@@ -2189,84 +2258,86 @@ const EditChannelModal = (props) => {
/>
)}
{inputs.type === 3 && (
<>
<Banner
type='warning'
description={t(
'2025年5月10日后添加的渠道不需要再在部署的时候移除模型名称中的"."',
{inputs.type === 3 && (
<>
<Banner
type='warning'
description={t(
'2025年5月10日后添加的渠道不需要再在部署的时候移除模型名称中的"."',
)}
className='!rounded-lg'
/>
<div>
<Form.Input
field='base_url'
label='AZURE_OPENAI_ENDPOINT'
placeholder={t(
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com',
)}
className='!rounded-lg'
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
<div>
<Form.Input
field='base_url'
label='AZURE_OPENAI_ENDPOINT'
placeholder={t(
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
/>
</div>
<div>
<Form.Input
field='other'
label={t('默认 API 版本')}
placeholder={t(
'请输入默认 API 版本例如2025-04-01-preview',
)}
onChange={(value) =>
handleInputChange('other', value)
}
showClear
/>
</div>
<div>
<Form.Input
field='azure_responses_version'
label={t(
'默认 Responses API 版本,为空则使用上方版本',
)}
placeholder={t('例如preview')}
onChange={(value) =>
handleChannelOtherSettingsChange(
'azure_responses_version',
value,
)
}
showClear
/>
</div>
</>
)}
</div>
<div>
<Form.Input
field='other'
label={t('默认 API 版本')}
placeholder={t(
'请输入默认 API 版本例如2025-04-01-preview',
)}
onChange={(value) =>
handleInputChange('other', value)
}
showClear
/>
</div>
<div>
<Form.Input
field='azure_responses_version'
label={t(
'默认 Responses API 版本,为空则使用上方版本',
)}
placeholder={t('例如preview')}
onChange={(value) =>
handleChannelOtherSettingsChange(
'azure_responses_version',
value,
)
}
showClear
/>
</div>
</>
)}
{inputs.type === 8 && (
<>
<Banner
type='warning'
description={t(
'如果你对接的是上游One API或者New API等转发项目请使用OpenAI类型不要使用此类型除非你知道你在做什么。',
{inputs.type === 8 && (
<>
<Banner
type='warning'
description={t(
'如果你对接的是上游One API或者New API等转发项目请使用OpenAI类型不要使用此类型除非你知道你在做什么。',
)}
className='!rounded-lg'
/>
<div>
<Form.Input
field='base_url'
label={t('完整的 Base URL支持变量{model}')}
placeholder={t(
'请输入完整的URL例如https://api.openai.com/v1/chat/completions',
)}
className='!rounded-lg'
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
<div>
<Form.Input
field='base_url'
label={t('完整的 Base URL支持变量{model}')}
placeholder={t(
'请输入完整的URL例如https://api.openai.com/v1/chat/completions',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
/>
</div>
</>
)}
</div>
</>
)}
{inputs.type === 37 && (
<Banner
@@ -2294,76 +2365,77 @@ const EditChannelModal = (props) => {
handleInputChange('base_url', value)
}
showClear
extraText={t(
'对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写',
)}
/>
</div>
)}
{inputs.type === 22 && (
<div>
<Form.Input
field='base_url'
label={t('私有部署地址')}
placeholder={t(
'请输入私有部署地址格式为https://fastgpt.run/api/openapi',
disabled={isIonetLocked}
extraText={t(
'对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
/>
</div>
)}
{inputs.type === 36 && (
<div>
<Form.Input
field='base_url'
label={t(
'注意非Chat API请务必填写正确的API地址否则可能导致无法使用',
)}
placeholder={t(
'请输入到 /suno 前的路径通常就是域名例如https://api.example.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
/>
</div>
)}
{inputs.type === 22 && (
<div>
<Form.Input
field='base_url'
label={t('私有部署地址')}
placeholder={t(
'请输入私有部署地址格式为https://fastgpt.run/api/openapi',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
)}
{inputs.type === 45 && !doubaoApiEditUnlocked && (
<div>
<Form.Select
field='base_url'
label={t('API地址')}
placeholder={t('请选择API地址')}
onChange={(value) =>
{inputs.type === 36 && (
<div>
<Form.Input
field='base_url'
label={t(
'注意非Chat API请务必填写正确的API地址否则可能导致无法使用',
)}
placeholder={t(
'请输入到 /suno 前的路径通常就是域名例如https://api.example.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
)}
{inputs.type === 45 && !doubaoApiEditUnlocked && (
<div>
<Form.Select
field='base_url'
label={t('API地址')}
placeholder={t('请选择API地址')}
onChange={(value) =>
handleInputChange('base_url', value)
}
optionList={[
{
value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com',
},
{
value:
'https://ark.ap-southeast.bytepluses.com',
label:
'https://ark.ap-southeast.bytepluses.com',
},
{
value: 'doubao-coding-plan',
}
optionList={[
{
value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com',
},
{
value: 'https://ark.ap-southeast.bytepluses.com',
label: 'https://ark.ap-southeast.bytepluses.com',
},
{
value: 'doubao-coding-plan',
label: 'Doubao Coding Plan',
},
]}
defaultValue='https://ark.cn-beijing.volces.com'
/>
</div>
)}
]}defaultValue='https://ark.cn-beijing.volces.com'
disabled={isIonetLocked}
/>
</div>
)}
</Card>
</div>
)}
@@ -2458,72 +2530,80 @@ const EditChannelModal = (props) => {
{t('获取模型列表')}
</Button>
)}
{inputs.type === 4 && isEdit && (
<Button
size='small'
type='warning'
onClick={() => handleInputChange('models', [])}
type='primary'
theme='light'
onClick={() => setOllamaModalVisible(true)}
>
{t('清除所有模型')}
{t('Ollama 模型管理')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
>
{t('复制所有模型')}
</Button>
{modelGroups &&
modelGroups.length > 0 &&
modelGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
let items = [];
try {
if (Array.isArray(group.items)) {
items = group.items;
} else if (
typeof group.items === 'string'
) {
const parsed = JSON.parse(
group.items || '[]',
);
if (Array.isArray(parsed)) items = parsed;
}
} catch {}
const current =
formApiRef.current?.getValue('models') ||
inputs.models ||
[];
const merged = Array.from(
new Set(
[...current, ...items]
.map((m) => (m || '').trim())
.filter(Boolean),
),
);
handleInputChange('models', merged);
}}
>
{group.name}
</Button>
))}
</Space>
}
/>
)}
<Button
size='small'
type='warning'
onClick={() => handleInputChange('models', [])}
>
{t('清除所有模型')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
>
{t('复制所有模型')}
</Button>
{modelGroups &&
modelGroups.length > 0 &&
modelGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
let items = [];
try {
if (Array.isArray(group.items)) {
items = group.items;
} else if (typeof group.items === 'string') {
const parsed = JSON.parse(
group.items || '[]',
);
if (Array.isArray(parsed)) items = parsed;
}
} catch {}
const current =
formApiRef.current?.getValue('models') ||
inputs.models ||
[];
const merged = Array.from(
new Set(
[...current, ...items]
.map((m) => (m || '').trim())
.filter(Boolean),
),
);
handleInputChange('models', merged);
}}
>
{group.name}
</Button>
))}
</Space>
}
/>
<Form.Input
field='custom_model'
@@ -3083,6 +3163,33 @@ const EditChannelModal = (props) => {
}}
onCancel={() => setModelModalVisible(false)}
/>
<OllamaModelModal
visible={ollamaModalVisible}
onCancel={() => setOllamaModalVisible(false)}
channelId={channelId}
channelInfo={inputs}
onModelsUpdate={(options = {}) => {
// 当模型更新后,重新获取模型列表以更新表单
fetchUpstreamModelList('models', { silent: !!options.silent });
}}
onApplyModels={({ mode, modelIds } = {}) => {
if (!Array.isArray(modelIds) || modelIds.length === 0) {
return;
}
const existingModels = Array.isArray(inputs.models)
? inputs.models.map(String)
: [];
const incoming = modelIds.map(String);
const nextModels = Array.from(new Set([...existingModels, ...incoming]));
handleInputChange('models', nextModels);
if (formApiRef.current) {
formApiRef.current.setValue('models', nextModels);
}
showSuccess(t('模型列表已追加更新'));
}}
/>
</>
);
};

View File

@@ -47,7 +47,20 @@ const ModelSelectModal = ({
onCancel,
}) => {
const { t } = useTranslation();
const [checkedList, setCheckedList] = useState(selected);
const getModelName = (model) => {
if (!model) return '';
if (typeof model === 'string') return model;
if (typeof model === 'object' && model.model_name) return model.model_name;
return String(model ?? '');
};
const normalizedSelected = useMemo(
() => (selected || []).map(getModelName),
[selected],
);
const [checkedList, setCheckedList] = useState(normalizedSelected);
const [keyword, setKeyword] = useState('');
const [activeTab, setActiveTab] = useState('new');
@@ -105,9 +118,9 @@ const ModelSelectModal = ({
// 同步外部选中值
useEffect(() => {
if (visible) {
setCheckedList(selected);
setCheckedList(normalizedSelected);
}
}, [visible, selected]);
}, [visible, normalizedSelected]);
// 当模型列表变化时设置默认tab
useEffect(() => {

View File

@@ -0,0 +1,806 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Button,
Typography,
Card,
List,
Space,
Input,
Spin,
Popconfirm,
Tag,
Avatar,
Empty,
Divider,
Row,
Col,
Progress,
Checkbox,
Radio,
} from '@douyinfe/semi-ui';
import {
IconClose,
IconDownload,
IconDelete,
IconRefresh,
IconSearch,
IconPlus,
IconServer,
} from '@douyinfe/semi-icons';
import {
API,
authHeader,
getUserIdFromLocalStorage,
showError,
showInfo,
showSuccess,
} from '../../../../helpers';
const { Text, Title } = Typography;
const CHANNEL_TYPE_OLLAMA = 4;
const parseMaybeJSON = (value) => {
if (!value) return null;
if (typeof value === 'object') return value;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (error) {
return null;
}
}
return null;
};
const resolveOllamaBaseUrl = (info) => {
if (!info) {
return '';
}
const direct = typeof info.base_url === 'string' ? info.base_url.trim() : '';
if (direct) {
return direct;
}
const alt =
typeof info.ollama_base_url === 'string'
? info.ollama_base_url.trim()
: '';
if (alt) {
return alt;
}
const parsed = parseMaybeJSON(info.other_info);
if (parsed && typeof parsed === 'object') {
const candidate =
(typeof parsed.base_url === 'string' && parsed.base_url.trim()) ||
(typeof parsed.public_url === 'string' && parsed.public_url.trim()) ||
(typeof parsed.api_url === 'string' && parsed.api_url.trim());
if (candidate) {
return candidate;
}
}
return '';
};
const normalizeModels = (items) => {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (!item) {
return null;
}
if (typeof item === 'string') {
return {
id: item,
owned_by: 'ollama',
};
}
if (typeof item === 'object') {
const candidateId = item.id || item.ID || item.name || item.model || item.Model;
if (!candidateId) {
return null;
}
const metadata = item.metadata || item.Metadata;
const normalized = {
...item,
id: candidateId,
owned_by: item.owned_by || item.ownedBy || 'ollama',
};
if (typeof item.size === 'number' && !normalized.size) {
normalized.size = item.size;
}
if (metadata && typeof metadata === 'object') {
if (typeof metadata.size === 'number' && !normalized.size) {
normalized.size = metadata.size;
}
if (!normalized.digest && typeof metadata.digest === 'string') {
normalized.digest = metadata.digest;
}
if (!normalized.modified_at && typeof metadata.modified_at === 'string') {
normalized.modified_at = metadata.modified_at;
}
if (metadata.details && !normalized.details) {
normalized.details = metadata.details;
}
}
return normalized;
}
return null;
})
.filter(Boolean);
};
const OllamaModelModal = ({
visible,
onCancel,
channelId,
channelInfo,
onModelsUpdate,
onApplyModels,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [models, setModels] = useState([]);
const [filteredModels, setFilteredModels] = useState([]);
const [searchValue, setSearchValue] = useState('');
const [pullModelName, setPullModelName] = useState('');
const [pullLoading, setPullLoading] = useState(false);
const [pullProgress, setPullProgress] = useState(null);
const [eventSource, setEventSource] = useState(null);
const [selectedModelIds, setSelectedModelIds] = useState([]);
const handleApplyAllModels = () => {
if (!onApplyModels || selectedModelIds.length === 0) {
return;
}
onApplyModels({ mode: 'append', modelIds: selectedModelIds });
};
const handleToggleModel = (modelId, checked) => {
if (!modelId) {
return;
}
setSelectedModelIds((prev) => {
if (checked) {
if (prev.includes(modelId)) {
return prev;
}
return [...prev, modelId];
}
return prev.filter((id) => id !== modelId);
});
};
const handleSelectAll = () => {
setSelectedModelIds(models.map((item) => item?.id).filter(Boolean));
};
const handleClearSelection = () => {
setSelectedModelIds([]);
};
// 获取模型列表
const fetchModels = async () => {
const channelType = Number(channelInfo?.type ?? CHANNEL_TYPE_OLLAMA);
const shouldTryLiveFetch = channelType === CHANNEL_TYPE_OLLAMA;
const resolvedBaseUrl = resolveOllamaBaseUrl(channelInfo);
setLoading(true);
let liveFetchSucceeded = false;
let fallbackSucceeded = false;
let lastError = '';
let nextModels = [];
try {
if (shouldTryLiveFetch && resolvedBaseUrl) {
try {
const payload = {
base_url: resolvedBaseUrl,
type: CHANNEL_TYPE_OLLAMA,
key: channelInfo?.key || '',
};
const res = await API.post('/api/channel/fetch_models', payload, {
skipErrorHandler: true,
});
if (res?.data?.success) {
nextModels = normalizeModels(res.data.data);
liveFetchSucceeded = true;
} else if (res?.data?.message) {
lastError = res.data.message;
}
} catch (error) {
const message = error?.response?.data?.message || error.message;
if (message) {
lastError = message;
}
}
} else if (shouldTryLiveFetch && !resolvedBaseUrl && !channelId) {
lastError = t('请先填写 Ollama API 地址');
}
if ((!liveFetchSucceeded || nextModels.length === 0) && channelId) {
try {
const res = await API.get(`/api/channel/fetch_models/${channelId}`, {
skipErrorHandler: true,
});
if (res?.data?.success) {
nextModels = normalizeModels(res.data.data);
fallbackSucceeded = true;
lastError = '';
} else if (res?.data?.message) {
lastError = res.data.message;
}
} catch (error) {
const message = error?.response?.data?.message || error.message;
if (message) {
lastError = message;
}
}
}
if (!liveFetchSucceeded && !fallbackSucceeded && lastError) {
showError(`${t('获取模型列表失败')}: ${lastError}`);
}
const normalized = nextModels;
setModels(normalized);
setFilteredModels(normalized);
setSelectedModelIds((prev) => {
if (!normalized || normalized.length === 0) {
return [];
}
if (!prev || prev.length === 0) {
return normalized.map((item) => item.id).filter(Boolean);
}
const available = prev.filter((id) =>
normalized.some((item) => item.id === id),
);
return available.length > 0
? available
: normalized.map((item) => item.id).filter(Boolean);
});
} finally {
setLoading(false);
}
};
// 拉取模型 (流式,支持进度)
const pullModel = async () => {
if (!pullModelName.trim()) {
showError(t('请输入模型名称'));
return;
}
setPullLoading(true);
setPullProgress({ status: 'starting', completed: 0, total: 0 });
let hasRefreshed = false;
const refreshModels = async () => {
if (hasRefreshed) return;
hasRefreshed = true;
await fetchModels();
if (onModelsUpdate) {
onModelsUpdate({ silent: true });
}
};
try {
// 关闭之前的连接
if (eventSource) {
eventSource.close();
setEventSource(null);
}
const controller = new AbortController();
const closable = {
close: () => controller.abort(),
};
setEventSource(closable);
// 使用 fetch 请求 SSE 流
const authHeaders = authHeader();
const userId = getUserIdFromLocalStorage();
const fetchHeaders = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'New-API-User': String(userId),
...authHeaders,
};
const response = await fetch('/api/channel/ollama/pull/stream', {
method: 'POST',
headers: fetchHeaders,
body: JSON.stringify({
channel_id: channelId,
model_name: pullModelName.trim(),
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 读取 SSE 流
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) {
continue;
}
try {
const eventData = line.substring(6);
if (eventData === '[DONE]') {
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
return;
}
const data = JSON.parse(eventData);
if (data.status) {
// 处理进度数据
setPullProgress(data);
} else if (data.error) {
// 处理错误
showError(data.error);
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
return;
} else if (data.message) {
// 处理成功消息
showSuccess(data.message);
setPullModelName('');
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
await fetchModels();
if (onModelsUpdate) {
onModelsUpdate({ silent: true });
}
await refreshModels();
return;
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
// 正常结束流
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
await refreshModels();
} catch (error) {
if (error?.name === 'AbortError') {
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
return;
}
console.error('Stream processing error:', error);
showError(t('数据传输中断'));
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
await refreshModels();
}
};
await processStream();
} catch (error) {
if (error?.name !== 'AbortError') {
showError(t('模型拉取失败: {{error}}', { error: error.message }));
}
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
await refreshModels();
}
};
// 删除模型
const deleteModel = async (modelName) => {
try {
const res = await API.delete('/api/channel/ollama/delete', {
data: {
channel_id: channelId,
model_name: modelName,
},
});
if (res.data.success) {
showSuccess(t('模型删除成功'));
await fetchModels(); // 重新获取模型列表
if (onModelsUpdate) {
onModelsUpdate({ silent: true }); // 通知父组件更新
}
} else {
showError(res.data.message || t('模型删除失败'));
}
} catch (error) {
showError(t('模型删除失败: {{error}}', { error: error.message }));
}
};
// 搜索过滤
useEffect(() => {
if (!searchValue) {
setFilteredModels(models);
} else {
const filtered = models.filter(model =>
model.id.toLowerCase().includes(searchValue.toLowerCase())
);
setFilteredModels(filtered);
}
}, [models, searchValue]);
useEffect(() => {
if (!visible) {
setSelectedModelIds([]);
setPullModelName('');
setPullProgress(null);
setPullLoading(false);
}
}, [visible]);
// 组件加载时获取模型列表
useEffect(() => {
if (!visible) {
return;
}
if (channelId || Number(channelInfo?.type) === CHANNEL_TYPE_OLLAMA) {
fetchModels();
}
}, [
visible,
channelId,
channelInfo?.type,
channelInfo?.base_url,
channelInfo?.other_info,
channelInfo?.ollama_base_url,
]);
// 组件卸载时清理 EventSource
useEffect(() => {
return () => {
if (eventSource) {
eventSource.close();
}
};
}, [eventSource]);
const formatModelSize = (size) => {
if (!size) return '-';
const gb = size / (1024 * 1024 * 1024);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(size / (1024 * 1024)).toFixed(0)} MB`;
};
return (
<Modal
title={
<div className='flex items-center'>
<Avatar
size='small'
color='blue'
className='mr-3 shadow-md'
>
<IconServer size={16} />
</Avatar>
<div>
<Title heading={4} className='m-0'>
{t('Ollama 模型管理')}
</Title>
<Text type='tertiary' size='small'>
{channelInfo?.name && `${channelInfo.name} - `}
{t('管理 Ollama 模型的拉取和删除')}
</Text>
</div>
</div>
}
visible={visible}
onCancel={onCancel}
width={800}
style={{ maxWidth: '95vw' }}
footer={
<div className='flex justify-end'>
<Button
theme='light'
type='primary'
onClick={onCancel}
icon={<IconClose />}
>
{t('关闭')}
</Button>
</div>
}
>
<div className='space-y-6'>
{/* 拉取新模型 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-4'>
<Avatar size='small' color='green' className='mr-2'>
<IconPlus size={16} />
</Avatar>
<Title heading={5} className='m-0'>
{t('拉取新模型')}
</Title>
</div>
<Row gutter={12} align='middle'>
<Col span={16}>
<Input
placeholder={t('请输入模型名称,例如: llama3.2, qwen2.5:7b')}
value={pullModelName}
onChange={(value) => setPullModelName(value)}
onEnterPress={pullModel}
disabled={pullLoading}
showClear
/>
</Col>
<Col span={8}>
<Button
theme='solid'
type='primary'
onClick={pullModel}
loading={pullLoading}
disabled={!pullModelName.trim()}
icon={<IconDownload />}
block
>
{pullLoading ? t('拉取中...') : t('拉取模型')}
</Button>
</Col>
</Row>
{/* 进度条显示 */}
{pullProgress && (() => {
const completedBytes = Number(pullProgress.completed) || 0;
const totalBytes = Number(pullProgress.total) || 0;
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
const safePercent = hasTotal
? Math.min(
100,
Math.max(0, Math.round((completedBytes / totalBytes) * 100)),
)
: null;
const percentText = hasTotal && safePercent !== null
? `${safePercent.toFixed(0)}%`
: pullProgress.status || t('处理中');
return (
<div className='mt-3 p-3 bg-gray-50 rounded-lg'>
<div className='flex items-center justify-between mb-2'>
<Text strong>{t('拉取进度')}</Text>
<Text type='tertiary' size='small'>{percentText}</Text>
</div>
{hasTotal && safePercent !== null ? (
<div>
<Progress
percent={safePercent}
showInfo={false}
stroke='#1890ff'
size='small'
/>
<div className='flex justify-between mt-1'>
<Text type='tertiary' size='small'>
{(completedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text>
<Text type='tertiary' size='small'>
{(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text>
</div>
</div>
) : (
<div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
<Spin size='small' />
<span>{t('准备中...')}</span>
</div>
)}
</div>
);
})()}
<Text type='tertiary' size='small' className='mt-2 block'>
{t('支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间')}
</Text>
</Card>
{/* 已有模型列表 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center justify-between mb-4'>
<div className='flex items-center'>
<Avatar size='small' color='purple' className='mr-2'>
<IconServer size={16} />
</Avatar>
<Title heading={5} className='m-0'>
{t('已有模型')}
{models.length > 0 && (
<Tag color='blue' className='ml-2'>
{models.length}
</Tag>
)}
</Title>
</div>
<Space wrap>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型...')}
value={searchValue}
onChange={(value) => setSearchValue(value)}
style={{ width: 200 }}
showClear
/>
<Button
size='small'
theme='borderless'
onClick={handleSelectAll}
disabled={models.length === 0}
>
{t('全选')}
</Button>
<Button
size='small'
theme='borderless'
onClick={handleClearSelection}
disabled={selectedModelIds.length === 0}
>
{t('清空')}
</Button>
<Button
theme='solid'
type='primary'
icon={<IconPlus />}
onClick={handleApplyAllModels}
disabled={selectedModelIds.length === 0}
size='small'
>
{t('加入渠道')}
</Button>
<Button
theme='light'
type='primary'
onClick={fetchModels}
loading={loading}
icon={<IconRefresh />}
size='small'
>
{t('刷新')}
</Button>
</Space>
</div>
<Spin spinning={loading}>
{filteredModels.length === 0 ? (
<Empty
image={<IconServer size={60} />}
title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
description={
searchValue
? t('请尝试其他搜索关键词')
: t('您可以在上方拉取需要的模型')
}
style={{ padding: '40px 0' }}
/>
) : (
<List
dataSource={filteredModels}
split={false}
renderItem={(model, index) => (
<List.Item
key={model.id}
className='hover:bg-gray-50 rounded-lg p-3 transition-colors'
>
<div className='flex items-center justify-between w-full'>
<div className='flex items-center flex-1 min-w-0 gap-3'>
<Checkbox
checked={selectedModelIds.includes(model.id)}
onChange={(checked) => handleToggleModel(model.id, checked)}
/>
<Avatar
size='small'
color='blue'
className='flex-shrink-0'
>
{model.id.charAt(0).toUpperCase()}
</Avatar>
<div className='flex-1 min-w-0'>
<Text strong className='block truncate'>
{model.id}
</Text>
<div className='flex items-center space-x-2 mt-1'>
<Tag color='cyan' size='small'>
{model.owned_by || 'ollama'}
</Tag>
{model.size && (
<Text type='tertiary' size='small'>
{formatModelSize(model.size)}
</Text>
)}
</div>
</div>
</div>
<div className='flex items-center space-x-2 ml-4'>
<Popconfirm
title={t('确认删除模型')}
content={t('删除后无法恢复,确定要删除模型 "{{name}}" 吗?', { name: model.id })}
onConfirm={() => deleteModel(model.id)}
okText={t('确认')}
cancelText={t('取消')}
>
<Button
theme='borderless'
type='danger'
size='small'
icon={<IconDelete />}
/>
</Popconfirm>
</div>
</div>
</List.Item>
)}
/>
)}
</Spin>
</Card>
</div>
</Modal>
);
};
export default OllamaModelModal;

View File

@@ -0,0 +1,109 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Button, Popconfirm } from '@douyinfe/semi-ui';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const DeploymentsActions = ({
selectedKeys,
setSelectedKeys,
setEditingDeployment,
setShowEdit,
batchDeleteDeployments,
compactMode,
setCompactMode,
showCreateModal,
setShowCreateModal,
t,
}) => {
const hasSelected = selectedKeys.length > 0;
const handleAddDeployment = () => {
if (setShowCreateModal) {
setShowCreateModal(true);
} else {
// Fallback to old behavior if setShowCreateModal is not provided
setEditingDeployment({ id: undefined });
setShowEdit(true);
}
};
const handleBatchDelete = () => {
batchDeleteDeployments();
};
const handleDeselectAll = () => {
setSelectedKeys([]);
};
return (
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
type='primary'
className='flex-1 md:flex-initial'
onClick={handleAddDeployment}
size='small'
>
{t('新建容器')}
</Button>
{hasSelected && (
<>
<Popconfirm
title={t('确认删除')}
content={`${t('确定要删除选中的')} ${selectedKeys.length} ${t('个部署吗?此操作不可逆。')}`}
okText={t('删除')}
cancelText={t('取消')}
okType='danger'
onConfirm={handleBatchDelete}
>
<Button
type='danger'
className='flex-1 md:flex-initial'
disabled={selectedKeys.length === 0}
size='small'
>
{t('批量删除')} ({selectedKeys.length})
</Button>
</Popconfirm>
<Button
type='tertiary'
className='flex-1 md:flex-initial'
onClick={handleDeselectAll}
size='small'
>
{t('取消选择')}
</Button>
</>
)}
{/* Compact Mode */}
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default DeploymentsActions;

View File

@@ -0,0 +1,672 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Button,
Dropdown,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
timestamp2string,
showSuccess,
showError,
} from '../../../helpers';
import { IconMore } from '@douyinfe/semi-icons';
import {
FaPlay,
FaTrash,
FaServer,
FaMemory,
FaMicrochip,
FaCheckCircle,
FaSpinner,
FaClock,
FaExclamationCircle,
FaBan,
FaTerminal,
FaPlus,
FaCog,
FaInfoCircle,
FaLink,
FaStop,
FaHourglassHalf,
FaGlobe,
} from 'react-icons/fa';
import {t} from "i18next";
const normalizeStatus = (status) =>
typeof status === 'string' ? status.trim().toLowerCase() : '';
const STATUS_TAG_CONFIG = {
running: {
color: 'green',
label: t('运行中'),
icon: <FaPlay size={12} className='text-green-600' />,
},
deploying: {
color: 'blue',
label: t('部署中'),
icon: <FaSpinner size={12} className='text-blue-600' />,
},
pending: {
color: 'orange',
label: t('待部署'),
icon: <FaClock size={12} className='text-orange-600' />,
},
stopped: {
color: 'grey',
label: t('已停止'),
icon: <FaStop size={12} className='text-gray-500' />,
},
error: {
color: 'red',
label: t('错误'),
icon: <FaExclamationCircle size={12} className='text-red-500' />,
},
failed: {
color: 'red',
label: t('失败'),
icon: <FaExclamationCircle size={12} className='text-red-500' />,
},
destroyed: {
color: 'red',
label: t('已销毁'),
icon: <FaBan size={12} className='text-red-500' />,
},
completed: {
color: 'green',
label: t('已完成'),
icon: <FaCheckCircle size={12} className='text-green-600' />,
},
'deployment requested': {
color: 'blue',
label: t('部署请求中'),
icon: <FaSpinner size={12} className='text-blue-600' />,
},
'termination requested': {
color: 'orange',
label: t('终止请求中'),
icon: <FaClock size={12} className='text-orange-600' />,
},
};
const DEFAULT_STATUS_CONFIG = {
color: 'grey',
label: null,
icon: <FaInfoCircle size={12} className='text-gray-500' />,
};
const parsePercentValue = (value) => {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const parsed = parseFloat(value.replace(/[^0-9.+-]/g, ''));
return Number.isFinite(parsed) ? parsed : null;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
return null;
};
const clampPercent = (value) => {
if (value === null || value === undefined) return null;
return Math.min(100, Math.max(0, Math.round(value)));
};
const formatRemainingMinutes = (minutes, t) => {
if (minutes === null || minutes === undefined) return null;
const numeric = Number(minutes);
if (!Number.isFinite(numeric)) return null;
const totalMinutes = Math.max(0, Math.round(numeric));
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const mins = totalMinutes % 60;
const parts = [];
if (days > 0) {
parts.push(`${days}${t('天')}`);
}
if (hours > 0) {
parts.push(`${hours}${t('小时')}`);
}
if (parts.length === 0 || mins > 0) {
parts.push(`${mins}${t('分钟')}`);
}
return parts.join(' ');
};
const getRemainingTheme = (percentRemaining) => {
if (percentRemaining === null) {
return {
iconColor: 'var(--semi-color-primary)',
tagColor: 'blue',
textColor: 'var(--semi-color-text-2)',
};
}
if (percentRemaining <= 10) {
return {
iconColor: '#ff5a5f',
tagColor: 'red',
textColor: '#ff5a5f',
};
}
if (percentRemaining <= 30) {
return {
iconColor: '#ffb400',
tagColor: 'orange',
textColor: '#ffb400',
};
}
return {
iconColor: '#2ecc71',
tagColor: 'green',
textColor: '#2ecc71',
};
};
const renderStatus = (status, t) => {
const normalizedStatus = normalizeStatus(status);
const config = STATUS_TAG_CONFIG[normalizedStatus] || DEFAULT_STATUS_CONFIG;
const statusText = typeof status === 'string' ? status : '';
const labelText = config.label ? t(config.label) : statusText || t('未知状态');
return (
<Tag
color={config.color}
shape='circle'
size='small'
prefixIcon={config.icon}
>
{labelText}
</Tag>
);
};
// Container Name Cell Component - to properly handle React hooks
const ContainerNameCell = ({ text, record, t }) => {
const handleCopyId = () => {
navigator.clipboard.writeText(record.id);
showSuccess(t('ID已复制到剪贴板'));
};
return (
<div className="flex flex-col gap-1">
<Typography.Text strong className="text-base">
{text}
</Typography.Text>
<Typography.Text
type="secondary"
size="small"
className="text-xs cursor-pointer hover:text-blue-600 transition-colors select-all"
onClick={handleCopyId}
title={t('点击复制ID')}
>
ID: {record.id}
</Typography.Text>
</div>
);
};
// Render resource configuration
const renderResourceConfig = (resource, t) => {
if (!resource) return '-';
const { cpu, memory, gpu } = resource;
return (
<div className="flex flex-col gap-1">
{cpu && (
<div className="flex items-center gap-1 text-xs">
<FaMicrochip className="text-blue-500" />
<span>CPU: {cpu}</span>
</div>
)}
{memory && (
<div className="flex items-center gap-1 text-xs">
<FaMemory className="text-green-500" />
<span>内存: {memory}</span>
</div>
)}
{gpu && (
<div className="flex items-center gap-1 text-xs">
<FaServer className="text-purple-500" />
<span>GPU: {gpu}</span>
</div>
)}
</div>
);
};
// Render instance count with status indicator
const renderInstanceCount = (count, record, t) => {
const normalizedStatus = normalizeStatus(record?.status);
const statusConfig = STATUS_TAG_CONFIG[normalizedStatus];
const countColor = statusConfig?.color ?? 'grey';
return (
<Tag color={countColor} size="small" shape='circle'>
{count || 0} {t('个实例')}
</Tag>
);
};
// Main function to get all deployment columns
export const getDeploymentsColumns = ({
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
// New handlers for enhanced operations
onViewLogs,
onExtendDuration,
onViewDetails,
onUpdateConfig,
onSyncToChannel,
}) => {
const columns = [
{
title: t('容器名称'),
dataIndex: 'container_name',
key: COLUMN_KEYS.container_name,
width: 300,
ellipsis: true,
render: (text, record) => (
<ContainerNameCell
text={text}
record={record}
t={t}
/>
),
},
{
title: t('状态'),
dataIndex: 'status',
key: COLUMN_KEYS.status,
width: 140,
render: (status) => (
<div className="flex items-center gap-2">
{renderStatus(status, t)}
</div>
),
},
{
title: t('服务商'),
dataIndex: 'provider',
key: COLUMN_KEYS.provider,
width: 140,
render: (provider) =>
provider ? (
<div
className="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
style={{
borderColor: 'rgba(59, 130, 246, 0.4)',
backgroundColor: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
}}
>
<FaGlobe className="text-[11px]" />
<span>{provider}</span>
</div>
) : (
<Typography.Text type="tertiary" size="small" className="text-xs text-gray-500">
{t('暂无')}
</Typography.Text>
),
},
{
title: t('剩余时间'),
dataIndex: 'time_remaining',
key: COLUMN_KEYS.time_remaining,
width: 140,
render: (text, record) => {
const normalizedStatus = normalizeStatus(record?.status);
const percentUsedRaw = parsePercentValue(record?.completed_percent);
const percentUsed = clampPercent(percentUsedRaw);
const percentRemaining =
percentUsed === null ? null : clampPercent(100 - percentUsed);
const theme = getRemainingTheme(percentRemaining);
const statusDisplayMap = {
completed: t('已完成'),
destroyed: t('已销毁'),
failed: t('失败'),
error: t('失败'),
stopped: t('已停止'),
pending: t('待部署'),
deploying: t('部署中'),
'deployment requested': t('部署请求中'),
'termination requested': t('终止中'),
};
const statusOverride = statusDisplayMap[normalizedStatus];
const baseTimeDisplay =
text && String(text).trim() !== '' ? text : t('计算中');
const timeDisplay = baseTimeDisplay;
const humanReadable = formatRemainingMinutes(
record.compute_minutes_remaining,
t,
);
const showProgress = !statusOverride && normalizedStatus === 'running';
const showExtraInfo = Boolean(humanReadable || percentUsed !== null);
const showRemainingMeta =
record.compute_minutes_remaining !== undefined &&
record.compute_minutes_remaining !== null &&
percentRemaining !== null;
return (
<div className="flex flex-col gap-1 leading-tight text-xs">
<div className="flex items-center gap-1.5">
<FaHourglassHalf
className="text-sm"
style={{ color: theme.iconColor }}
/>
<Typography.Text className="text-sm font-medium text-[var(--semi-color-text-0)]">
{timeDisplay}
</Typography.Text>
{showProgress && percentRemaining !== null ? (
<Tag size="small" color={theme.tagColor}>
{percentRemaining}%
</Tag>
) : statusOverride ? (
<Tag size="small" color="grey">
{statusOverride}
</Tag>
) : null}
</div>
{showExtraInfo && (
<div className="flex items-center gap-3 text-[var(--semi-color-text-2)]">
{humanReadable && (
<span className="flex items-center gap-1">
<FaClock className="text-[11px]" />
{t('约')} {humanReadable}
</span>
)}
{percentUsed !== null && (
<span className="flex items-center gap-1">
<FaCheckCircle className="text-[11px]" />
{t('已用')} {percentUsed}%
</span>
)}
</div>
)}
{showProgress && showRemainingMeta && (
<div className="text-[10px]" style={{ color: theme.textColor }}>
{t('剩余')} {record.compute_minutes_remaining} {t('分钟')}
</div>
)}
</div>
);
},
},
{
title: t('硬件配置'),
dataIndex: 'hardware_info',
key: COLUMN_KEYS.hardware_info,
width: 220,
ellipsis: true,
render: (text, record) => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded-md">
<FaServer className="text-green-600 text-xs" />
<span className="text-xs font-medium text-green-700">
{record.hardware_name}
</span>
</div>
<span className="text-xs text-gray-500 font-medium">x{record.hardware_quantity}</span>
</div>
),
},
{
title: t('创建时间'),
dataIndex: 'created_at',
key: COLUMN_KEYS.created_at,
width: 150,
render: (text) => (
<span className="text-sm text-gray-600">{timestamp2string(text)}</span>
),
},
{
title: t('操作'),
key: COLUMN_KEYS.actions,
fixed: 'right',
width: 120,
render: (_, record) => {
const { status, id } = record;
const normalizedStatus = normalizeStatus(status);
const isEnded = normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
const handleDelete = () => {
// Use enhanced confirmation dialog
onUpdateConfig?.(record, 'delete');
};
// Get primary action based on status
const getPrimaryAction = () => {
switch (normalizedStatus) {
case 'running':
return {
icon: <FaInfoCircle className="text-xs" />,
text: t('查看详情'),
onClick: () => onViewDetails?.(record),
type: 'secondary',
theme: 'borderless',
};
case 'failed':
case 'error':
return {
icon: <FaPlay className="text-xs" />,
text: t('重试'),
onClick: () => startDeployment(id),
type: 'primary',
theme: 'solid',
};
case 'stopped':
return {
icon: <FaPlay className="text-xs" />,
text: t('启动'),
onClick: () => startDeployment(id),
type: 'primary',
theme: 'solid',
};
case 'deployment requested':
case 'deploying':
return {
icon: <FaClock className="text-xs" />,
text: t('部署中'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'pending':
return {
icon: <FaClock className="text-xs" />,
text: t('待部署'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'termination requested':
return {
icon: <FaClock className="text-xs" />,
text: t('终止中'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'completed':
case 'destroyed':
default:
return {
icon: <FaInfoCircle className="text-xs" />,
text: t('已结束'),
onClick: () => {},
type: 'tertiary',
theme: 'borderless',
disabled: true,
};
}
};
const primaryAction = getPrimaryAction();
const primaryTheme = primaryAction.theme || 'solid';
const primaryType = primaryAction.type || 'primary';
if (isEnded) {
return (
<div className="flex w-full items-center justify-start gap-1 pr-2">
<Button
size="small"
type="tertiary"
theme="borderless"
onClick={() => onViewDetails?.(record)}
icon={<FaInfoCircle className="text-xs" />}
>
{t('查看详情')}
</Button>
</div>
);
}
// All actions dropdown with enhanced operations
const dropdownItems = [
<Dropdown.Item key="details" onClick={() => onViewDetails?.(record)} icon={<FaInfoCircle />}>
{t('查看详情')}
</Dropdown.Item>,
];
if (!isEnded) {
dropdownItems.push(
<Dropdown.Item key="logs" onClick={() => onViewLogs?.(record)} icon={<FaTerminal />}>
{t('查看日志')}
</Dropdown.Item>,
);
}
const managementItems = [];
if (normalizedStatus === 'running') {
if (onSyncToChannel) {
managementItems.push(
<Dropdown.Item key="sync-channel" onClick={() => onSyncToChannel(record)} icon={<FaLink />}>
{t('同步到渠道')}
</Dropdown.Item>,
);
}
}
if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
managementItems.push(
<Dropdown.Item key="retry" onClick={() => startDeployment(id)} icon={<FaPlay />}>
{t('重试')}
</Dropdown.Item>,
);
}
if (normalizedStatus === 'stopped') {
managementItems.push(
<Dropdown.Item key="start" onClick={() => startDeployment(id)} icon={<FaPlay />}>
{t('启动')}
</Dropdown.Item>,
);
}
if (managementItems.length > 0) {
dropdownItems.push(<Dropdown.Divider key="management-divider" />);
dropdownItems.push(...managementItems);
}
const configItems = [];
if (!isEnded && (normalizedStatus === 'running' || normalizedStatus === 'deployment requested')) {
configItems.push(
<Dropdown.Item key="extend" onClick={() => onExtendDuration?.(record)} icon={<FaPlus />}>
{t('延长时长')}
</Dropdown.Item>,
);
}
// if (!isEnded && normalizedStatus === 'running') {
// configItems.push(
// <Dropdown.Item key="update-config" onClick={() => onUpdateConfig?.(record)} icon={<FaCog />}>
// {t('更新配置')}
// </Dropdown.Item>,
// );
// }
if (configItems.length > 0) {
dropdownItems.push(<Dropdown.Divider key="config-divider" />);
dropdownItems.push(...configItems);
}
if (!isEnded) {
dropdownItems.push(<Dropdown.Divider key="danger-divider" />);
dropdownItems.push(
<Dropdown.Item key="delete" type="danger" onClick={handleDelete} icon={<FaTrash />}>
{t('销毁容器')}
</Dropdown.Item>,
);
}
const allActions = <Dropdown.Menu>{dropdownItems}</Dropdown.Menu>;
const hasDropdown = dropdownItems.length > 0;
return (
<div className="flex w-full items-center justify-start gap-1 pr-2">
<Button
size="small"
theme={primaryTheme}
type={primaryType}
icon={primaryAction.icon}
onClick={primaryAction.onClick}
className="px-2 text-xs"
disabled={primaryAction.disabled}
>
{primaryAction.text}
</Button>
{hasDropdown && (
<Dropdown
trigger="click"
position="bottomRight"
render={allActions}
>
<Button
size="small"
theme="light"
type="tertiary"
icon={<IconMore />}
className="px-1"
/>
</Dropdown>
)}
</div>
);
},
},
];
return columns;
};

View File

@@ -0,0 +1,130 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useRef } from 'react';
import { Form, Button } from '@douyinfe/semi-ui';
import { IconSearch, IconRefresh } from '@douyinfe/semi-icons';
const DeploymentsFilters = ({
formInitValues,
setFormApi,
searchDeployments,
loading,
searching,
setShowColumnSelector,
t,
}) => {
const formApiRef = useRef(null);
const handleSubmit = (values) => {
searchDeployments(values);
};
const handleReset = () => {
if (!formApiRef.current) return;
formApiRef.current.reset();
setTimeout(() => {
formApiRef.current.submitForm();
}, 0);
};
const statusOptions = [
{ label: t('全部状态'), value: '' },
{ label: t('运行中'), value: 'running' },
{ label: t('已完成'), value: 'completed' },
{ label: t('失败'), value: 'failed' },
{ label: t('部署请求中'), value: 'deployment requested' },
{ label: t('终止请求中'), value: 'termination requested' },
{ label: t('已销毁'), value: 'destroyed' },
];
return (
<Form
layout='horizontal'
onSubmit={handleSubmit}
initValues={formInitValues}
getFormApi={(formApi) => {
setFormApi(formApi);
formApiRef.current = formApi;
}}
className='w-full md:w-auto order-1 md:order-2'
>
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
<div className='w-full md:w-64'>
<Form.Input
field='searchKeyword'
placeholder={t('搜索部署名称')}
prefix={<IconSearch />}
showClear
size='small'
pure
/>
</div>
<div className='w-full md:w-48'>
<Form.Select
field='searchStatus'
placeholder={t('选择状态')}
optionList={statusOptions}
className='w-full'
showClear
size='small'
pure
/>
</div>
<div className='flex gap-2 w-full md:w-auto'>
<Button
htmlType='submit'
type='tertiary'
icon={<IconSearch />}
loading={searching}
disabled={loading}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('查询')}
</Button>
<Button
type='tertiary'
icon={<IconRefresh />}
onClick={handleReset}
disabled={loading || searching}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('重置')}
</Button>
<Button
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('列设置')}
</Button>
</div>
</div>
</Form>
);
};
export default DeploymentsFilters;

View File

@@ -0,0 +1,247 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo, useState } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getDeploymentsColumns } from './DeploymentsColumnDefs';
// Import all the new modals
import ViewLogsModal from './modals/ViewLogsModal';
import ExtendDurationModal from './modals/ExtendDurationModal';
import ViewDetailsModal from './modals/ViewDetailsModal';
import UpdateConfigModal from './modals/UpdateConfigModal';
import ConfirmationDialog from './modals/ConfirmationDialog';
const DeploymentsTable = (deploymentsData) => {
const {
deployments,
loading,
searching,
activePage,
pageSize,
deploymentCount,
compactMode,
visibleColumns,
setSelectedKeys,
handlePageChange,
handlePageSizeChange,
handleRow,
t,
COLUMN_KEYS,
// Column functions and data
startDeployment,
restartDeployment,
deleteDeployment,
syncDeploymentToChannel,
setEditingDeployment,
setShowEdit,
refresh,
} = deploymentsData;
// Modal states
const [selectedDeployment, setSelectedDeployment] = useState(null);
const [showLogsModal, setShowLogsModal] = useState(false);
const [showExtendModal, setShowExtendModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showConfigModal, setShowConfigModal] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [confirmOperation, setConfirmOperation] = useState('delete');
// Enhanced modal handlers
const handleViewLogs = (deployment) => {
setSelectedDeployment(deployment);
setShowLogsModal(true);
};
const handleExtendDuration = (deployment) => {
setSelectedDeployment(deployment);
setShowExtendModal(true);
};
const handleViewDetails = (deployment) => {
setSelectedDeployment(deployment);
setShowDetailsModal(true);
};
const handleUpdateConfig = (deployment, operation = 'update') => {
setSelectedDeployment(deployment);
if (operation === 'delete' || operation === 'destroy') {
setConfirmOperation(operation);
setShowConfirmDialog(true);
} else {
setShowConfigModal(true);
}
};
const handleConfirmAction = () => {
if (selectedDeployment && confirmOperation === 'delete') {
deleteDeployment(selectedDeployment.id);
}
setShowConfirmDialog(false);
setSelectedDeployment(null);
};
const handleModalSuccess = (updatedDeployment) => {
// Refresh the deployments list
refresh?.();
};
// Get all columns
const allColumns = useMemo(() => {
return getDeploymentsColumns({
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
// Enhanced handlers
onViewLogs: handleViewLogs,
onExtendDuration: handleExtendDuration,
onViewDetails: handleViewDetails,
onUpdateConfig: handleUpdateConfig,
onSyncToChannel: syncDeploymentToChannel,
});
}, [
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
syncDeploymentToChannel,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const visibleColumnsList = useMemo(() => {
return getVisibleColumns();
}, [visibleColumns, allColumns]);
const tableColumns = useMemo(() => {
if (compactMode) {
// In compact mode, remove fixed columns and adjust widths
return visibleColumnsList.map(({ fixed, width, ...rest }) => ({
...rest,
width: width ? Math.max(width * 0.8, 80) : undefined, // Reduce width by 20% but keep minimum
}));
}
return visibleColumnsList;
}, [compactMode, visibleColumnsList]);
return (
<>
<CardTable
columns={tableColumns}
dataSource={deployments}
scroll={compactMode ? { x: 800 } : { x: 1200 }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: deploymentCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
hidePagination={true}
expandAllRows={false}
onRow={handleRow}
rowSelection={{
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
}}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className='rounded-xl overflow-hidden'
size='middle'
loading={loading || searching}
/>
{/* Enhanced Modals */}
<ViewLogsModal
visible={showLogsModal}
onCancel={() => setShowLogsModal(false)}
deployment={selectedDeployment}
t={t}
/>
<ExtendDurationModal
visible={showExtendModal}
onCancel={() => setShowExtendModal(false)}
deployment={selectedDeployment}
onSuccess={handleModalSuccess}
t={t}
/>
<ViewDetailsModal
visible={showDetailsModal}
onCancel={() => setShowDetailsModal(false)}
deployment={selectedDeployment}
t={t}
/>
<UpdateConfigModal
visible={showConfigModal}
onCancel={() => setShowConfigModal(false)}
deployment={selectedDeployment}
onSuccess={handleModalSuccess}
t={t}
/>
<ConfirmationDialog
visible={showConfirmDialog}
onCancel={() => setShowConfirmDialog(false)}
onConfirm={handleConfirmAction}
title={t('确认操作')}
type="danger"
deployment={selectedDeployment}
operation={confirmOperation}
t={t}
/>
</>
);
};
export default DeploymentsTable;

View File

@@ -0,0 +1,147 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState } from 'react';
import CardPro from '../../common/ui/CardPro';
import DeploymentsTable from './DeploymentsTable';
import DeploymentsActions from './DeploymentsActions';
import DeploymentsFilters from './DeploymentsFilters';
import EditDeploymentModal from './modals/EditDeploymentModal';
import CreateDeploymentModal from './modals/CreateDeploymentModal';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import { useDeploymentsData } from '../../../hooks/model-deployments/useDeploymentsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
const DeploymentsPage = () => {
const deploymentsData = useDeploymentsData();
const isMobile = useIsMobile();
// Create deployment modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const {
// Edit state
showEdit,
editingDeployment,
closeEdit,
refresh,
// Actions state
selectedKeys,
setSelectedKeys,
setEditingDeployment,
setShowEdit,
batchDeleteDeployments,
// Filters state
formInitValues,
setFormApi,
searchDeployments,
loading,
searching,
// Column visibility
showColumnSelector,
setShowColumnSelector,
visibleColumns,
setVisibleColumns,
COLUMN_KEYS,
// Description state
compactMode,
setCompactMode,
// Translation
t,
} = deploymentsData;
return (
<>
{/* Modals */}
<EditDeploymentModal
refresh={refresh}
editingDeployment={editingDeployment}
visible={showEdit}
handleClose={closeEdit}
/>
<CreateDeploymentModal
visible={showCreateModal}
onCancel={() => setShowCreateModal(false)}
onSuccess={refresh}
t={t}
/>
<ColumnSelectorModal
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
visibleColumns={visibleColumns}
onVisibleColumnsChange={setVisibleColumns}
columnKeys={COLUMN_KEYS}
t={t}
/>
{/* Main Content */}
<CardPro
type='type3'
actionsArea={
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
<DeploymentsActions
selectedKeys={selectedKeys}
setSelectedKeys={setSelectedKeys}
setEditingDeployment={setEditingDeployment}
setShowEdit={setShowEdit}
batchDeleteDeployments={batchDeleteDeployments}
compactMode={compactMode}
setCompactMode={setCompactMode}
showCreateModal={showCreateModal}
setShowCreateModal={setShowCreateModal}
setShowColumnSelector={setShowColumnSelector}
t={t}
/>
<DeploymentsFilters
formInitValues={formInitValues}
setFormApi={setFormApi}
searchDeployments={searchDeployments}
loading={loading}
searching={searching}
setShowColumnSelector={setShowColumnSelector}
t={t}
/>
</div>
}
paginationArea={createCardProPagination({
currentPage: deploymentsData.activePage,
pageSize: deploymentsData.pageSize,
total: deploymentsData.deploymentCount,
onPageChange: deploymentsData.handlePageChange,
onPageSizeChange: deploymentsData.handlePageSizeChange,
isMobile: isMobile,
t: deploymentsData.t,
})}
t={deploymentsData.t}
>
<DeploymentsTable {...deploymentsData} />
</CardPro>
</>
);
};
export default DeploymentsPage;

View File

@@ -0,0 +1,127 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo } from 'react';
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
const ColumnSelectorModal = ({
visible,
onCancel,
visibleColumns,
onVisibleColumnsChange,
columnKeys,
t,
}) => {
const columnOptions = useMemo(
() => [
{ key: columnKeys.container_name, label: t('容器名称'), required: true },
{ key: columnKeys.status, label: t('状态') },
{ key: columnKeys.time_remaining, label: t('剩余时间') },
{ key: columnKeys.hardware_info, label: t('硬件配置') },
{ key: columnKeys.created_at, label: t('创建时间') },
{ key: columnKeys.actions, label: t('操作'), required: true },
],
[columnKeys, t],
);
const handleColumnVisibilityChange = (key, checked) => {
const column = columnOptions.find((option) => option.key === key);
if (column?.required) return;
onVisibleColumnsChange({
...visibleColumns,
[key]: checked,
});
};
const handleSelectAll = (checked) => {
const updated = { ...visibleColumns };
columnOptions.forEach(({ key, required }) => {
updated[key] = required ? true : checked;
});
onVisibleColumnsChange(updated);
};
const handleReset = () => {
const defaults = columnOptions.reduce((acc, { key }) => {
acc[key] = true;
return acc;
}, {});
onVisibleColumnsChange({
...visibleColumns,
...defaults,
});
};
const allSelected = columnOptions.every(
({ key, required }) => required || visibleColumns[key],
);
const indeterminate =
columnOptions.some(
({ key, required }) => !required && visibleColumns[key],
) && !allSelected;
const handleConfirm = () => onCancel();
return (
<Modal
title={t('列设置')}
visible={visible}
onCancel={onCancel}
footer={
<div className='flex justify-end gap-2'>
<Button onClick={handleReset}>{t('重置')}</Button>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button type='primary' onClick={handleConfirm}>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
style={{ border: '1px solid var(--semi-color-border)' }}
>
{columnOptions.map(({ key, label, required }) => (
<div key={key} className='w-1/2 mb-4 pr-2'>
<Checkbox
checked={!!visibleColumns[key]}
disabled={required}
onChange={(e) =>
handleColumnVisibilityChange(key, e.target.checked)
}
>
{label}
</Checkbox>
</div>
))}
</div>
</Modal>
);
};
export default ColumnSelectorModal;

View File

@@ -0,0 +1,99 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import { Modal, Typography, Input } from '@douyinfe/semi-ui';
const { Text } = Typography;
const ConfirmationDialog = ({
visible,
onCancel,
onConfirm,
title,
type = 'danger',
deployment,
t,
loading = false
}) => {
const [confirmText, setConfirmText] = useState('');
useEffect(() => {
if (!visible) {
setConfirmText('');
}
}, [visible]);
const requiredText = deployment?.container_name || deployment?.id || '';
const isConfirmed = Boolean(requiredText) && confirmText === requiredText;
const handleCancel = () => {
setConfirmText('');
onCancel();
};
const handleConfirm = () => {
if (isConfirmed) {
onConfirm();
handleCancel();
}
};
return (
<Modal
title={title}
visible={visible}
onCancel={handleCancel}
onOk={handleConfirm}
okText={t('确认')}
cancelText={t('取消')}
okButtonProps={{
disabled: !isConfirmed,
type: type === 'danger' ? 'danger' : 'primary',
loading
}}
width={480}
>
<div className="space-y-4">
<Text type="danger" strong>
{t('此操作具有风险,请确认要继续执行')}
</Text>
<Text>
{t('请输入部署名称以完成二次确认')}
<Text code className="ml-1">
{requiredText || t('未知部署')}
</Text>
</Text>
<Input
value={confirmText}
onChange={setConfirmText}
placeholder={t('再次输入部署名称')}
autoFocus
/>
{!isConfirmed && confirmText && (
<Text type="danger" size="small">
{t('部署名称不匹配,请检查后重新输入')}
</Text>
)}
</div>
</Modal>
);
};
export default ConfirmationDialog;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,241 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useRef } from 'react';
import {
SideSheet,
Form,
Button,
Space,
Spin,
Typography,
Card,
InputNumber,
Select,
Input,
Row,
Col,
Divider,
Tag,
} from '@douyinfe/semi-ui';
import { Save, X, Server } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text, Title } = Typography;
const EditDeploymentModal = ({
refresh,
editingDeployment,
visible,
handleClose,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [models, setModels] = useState([]);
const [loadingModels, setLoadingModels] = useState(false);
const formRef = useRef();
const isEdit = Boolean(editingDeployment?.id);
const title = t('重命名部署');
// Resource configuration options
const cpuOptions = [
{ label: '0.5 Core', value: '0.5' },
{ label: '1 Core', value: '1' },
{ label: '2 Cores', value: '2' },
{ label: '4 Cores', value: '4' },
{ label: '8 Cores', value: '8' },
];
const memoryOptions = [
{ label: '1GB', value: '1Gi' },
{ label: '2GB', value: '2Gi' },
{ label: '4GB', value: '4Gi' },
{ label: '8GB', value: '8Gi' },
{ label: '16GB', value: '16Gi' },
{ label: '32GB', value: '32Gi' },
];
const gpuOptions = [
{ label: t('无GPU'), value: '' },
{ label: '1 GPU', value: '1' },
{ label: '2 GPUs', value: '2' },
{ label: '4 GPUs', value: '4' },
];
// Load available models
const loadModels = async () => {
setLoadingModels(true);
try {
const res = await API.get('/api/models/?page_size=1000');
if (res.data.success) {
const items = res.data.data.items || res.data.data || [];
const modelOptions = items.map((model) => ({
label: `${model.model_name} (${model.vendor?.name || 'Unknown'})`,
value: model.model_name,
model_id: model.id,
}));
setModels(modelOptions);
}
} catch (error) {
console.error('Failed to load models:', error);
showError(t('加载模型列表失败'));
}
setLoadingModels(false);
};
// Form submission
const handleSubmit = async (values) => {
if (!isEdit || !editingDeployment?.id) {
showError(t('无效的部署信息'));
return;
}
setLoading(true);
try {
// Only handle name update for now
const res = await API.put(
`/api/deployments/${editingDeployment.id}/name`,
{
name: values.deployment_name,
},
);
if (res.data.success) {
showSuccess(t('部署名称更新成功'));
handleClose();
refresh();
} else {
showError(res.data.message || t('更新失败'));
}
} catch (error) {
console.error('Submit error:', error);
showError(t('更新失败,请检查输入信息'));
}
setLoading(false);
};
// Load models when modal opens
useEffect(() => {
if (visible) {
loadModels();
}
}, [visible]);
// Set form values when editing
useEffect(() => {
if (formRef.current && editingDeployment && visible && isEdit) {
formRef.current.setValues({
deployment_name: editingDeployment.deployment_name || '',
});
}
}, [editingDeployment, visible, isEdit]);
return (
<SideSheet
title={
<div className='flex items-center gap-2'>
<Server size={20} />
<span>{title}</span>
</div>
}
visible={visible}
onCancel={handleClose}
width={isMobile ? '100%' : 600}
bodyStyle={{ padding: 0 }}
maskClosable={false}
closeOnEsc={true}
>
<div className='p-6 h-full overflow-auto'>
<Spin spinning={loading} style={{ width: '100%' }}>
<Form
ref={formRef}
onSubmit={handleSubmit}
labelPosition='top'
style={{ width: '100%' }}
>
<Card>
<Title heading={5} style={{ marginBottom: 16 }}>
{t('修改部署名称')}
</Title>
<Row gutter={16}>
<Col span={24}>
<Form.Input
field='deployment_name'
label={t('部署名称')}
placeholder={t('请输入新的部署名称')}
rules={[
{ required: true, message: t('请输入部署名称') },
{
pattern: /^[a-zA-Z0-9-_\u4e00-\u9fa5]+$/,
message: t(
'部署名称只能包含字母、数字、横线、下划线和中文',
),
},
]}
/>
</Col>
</Row>
{isEdit && (
<div className='mt-4 p-3 bg-gray-50 rounded'>
<Text type='secondary'>{t('部署ID')}: </Text>
<Text code>{editingDeployment.id}</Text>
<br />
<Text type='secondary'>{t('当前状态')}: </Text>
<Tag
color={
editingDeployment.status === 'running' ? 'green' : 'grey'
}
>
{editingDeployment.status}
</Tag>
</div>
)}
</Card>
</Form>
</Spin>
</div>
<div className='p-4 border-t border-gray-200 bg-gray-50 flex justify-end'>
<Space>
<Button theme='outline' onClick={handleClose} disabled={loading}>
<X size={16} className='mr-1' />
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
onClick={() => formRef.current?.submitForm()}
>
<Save size={16} className='mr-1' />
{isEdit ? t('更新') : t('创建')}
</Button>
</Space>
</div>
</SideSheet>
);
};
export default EditDeploymentModal;

View File

@@ -0,0 +1,548 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useRef, useState } from 'react';
import {
Modal,
Form,
InputNumber,
Typography,
Card,
Space,
Divider,
Button,
Tag,
Banner,
Spin,
} from '@douyinfe/semi-ui';
import {
FaClock,
FaCalculator,
FaInfoCircle,
FaExclamationTriangle,
} from 'react-icons/fa';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
const ExtendDurationModal = ({
visible,
onCancel,
deployment,
onSuccess,
t,
}) => {
const formRef = useRef(null);
const [loading, setLoading] = useState(false);
const [durationHours, setDurationHours] = useState(1);
const [costLoading, setCostLoading] = useState(false);
const [priceEstimation, setPriceEstimation] = useState(null);
const [priceError, setPriceError] = useState(null);
const [detailsLoading, setDetailsLoading] = useState(false);
const [deploymentDetails, setDeploymentDetails] = useState(null);
const costRequestIdRef = useRef(0);
const resetState = () => {
costRequestIdRef.current += 1;
setDurationHours(1);
setPriceEstimation(null);
setPriceError(null);
setDeploymentDetails(null);
setCostLoading(false);
};
const fetchDeploymentDetails = async (deploymentId) => {
setDetailsLoading(true);
try {
const response = await API.get(`/api/deployments/${deploymentId}`);
if (response.data.success) {
const details = response.data.data;
setDeploymentDetails(details);
setPriceError(null);
return details;
}
const message = response.data.message || '';
const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
showError(errorMessage);
setDeploymentDetails(null);
setPriceEstimation(null);
setPriceError(errorMessage);
return null;
} catch (error) {
const message = error?.response?.data?.message || error.message || '';
const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
showError(errorMessage);
setDeploymentDetails(null);
setPriceEstimation(null);
setPriceError(errorMessage);
return null;
} finally {
setDetailsLoading(false);
}
};
const calculatePrice = async (hours, details) => {
if (!visible || !details) {
return;
}
const sanitizedHours = Number.isFinite(hours) ? Math.round(hours) : 0;
if (sanitizedHours <= 0) {
setPriceEstimation(null);
setPriceError(null);
return;
}
const hardwareId = Number(details?.hardware_id) || 0;
const totalGPUs = Number(details?.total_gpus) || 0;
const totalContainers = Number(details?.total_containers) || 0;
const baseGpusPerContainer = Number(details?.gpus_per_container) || 0;
const resolvedGpusPerContainer =
baseGpusPerContainer > 0
? baseGpusPerContainer
: totalContainers > 0 && totalGPUs > 0
? Math.max(1, Math.round(totalGPUs / totalContainers))
: 0;
const resolvedReplicaCount =
totalContainers > 0
? totalContainers
: resolvedGpusPerContainer > 0 && totalGPUs > 0
? Math.max(1, Math.round(totalGPUs / resolvedGpusPerContainer))
: 0;
const locationIds = Array.isArray(details?.locations)
? details.locations
.map((location) =>
Number(
location?.id ??
location?.location_id ??
location?.locationId,
),
)
.filter((id) => Number.isInteger(id) && id > 0)
: [];
if (
hardwareId <= 0 ||
resolvedGpusPerContainer <= 0 ||
resolvedReplicaCount <= 0 ||
locationIds.length === 0
) {
setPriceEstimation(null);
setPriceError(t('价格计算失败'));
return;
}
const requestId = Date.now();
costRequestIdRef.current = requestId;
setCostLoading(true);
setPriceError(null);
const payload = {
location_ids: locationIds,
hardware_id: hardwareId,
gpus_per_container: resolvedGpusPerContainer,
duration_hours: sanitizedHours,
replica_count: resolvedReplicaCount,
currency: 'usdc',
duration_type: 'hour',
duration_qty: sanitizedHours,
hardware_qty: resolvedGpusPerContainer,
};
try {
const response = await API.post(
'/api/deployments/price-estimation',
payload,
);
if (costRequestIdRef.current !== requestId) {
return;
}
if (response.data.success) {
setPriceEstimation(response.data.data);
} else {
const message = response.data.message || '';
setPriceEstimation(null);
setPriceError(
t('价格计算失败') + (message ? `: ${message}` : ''),
);
}
} catch (error) {
if (costRequestIdRef.current !== requestId) {
return;
}
const message = error?.response?.data?.message || error.message || '';
setPriceEstimation(null);
setPriceError(
t('价格计算失败') + (message ? `: ${message}` : ''),
);
} finally {
if (costRequestIdRef.current === requestId) {
setCostLoading(false);
}
}
};
useEffect(() => {
if (visible && deployment?.id) {
resetState();
if (formRef.current) {
formRef.current.setValue('duration_hours', 1);
}
fetchDeploymentDetails(deployment.id);
}
if (!visible) {
resetState();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, deployment?.id]);
useEffect(() => {
if (!visible) {
return;
}
if (!deploymentDetails) {
return;
}
calculatePrice(durationHours, deploymentDetails);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [durationHours, deploymentDetails, visible]);
const handleExtend = async () => {
try {
if (formRef.current) {
await formRef.current.validate();
}
setLoading(true);
const response = await API.post(
`/api/deployments/${deployment.id}/extend`,
{
duration_hours: Math.round(durationHours),
},
);
if (response.data.success) {
showSuccess(t('容器时长延长成功'));
onSuccess?.(response.data.data);
handleCancel();
}
} catch (error) {
showError(
t('延长时长失败') +
': ' +
(error?.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
if (formRef.current) {
formRef.current.reset();
}
resetState();
onCancel();
};
const currentRemainingTime = deployment?.time_remaining || '0分钟';
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
const priceData = priceEstimation || {};
const breakdown =
priceData.price_breakdown || priceData.PriceBreakdown || {};
const currencyLabel = (
priceData.currency || priceData.Currency || 'USDC'
)
.toString()
.toUpperCase();
const estimatedTotalCost =
typeof priceData.estimated_cost === 'number'
? priceData.estimated_cost
: typeof priceData.EstimatedCost === 'number'
? priceData.EstimatedCost
: typeof breakdown.total_cost === 'number'
? breakdown.total_cost
: breakdown.TotalCost;
const hourlyRate =
typeof breakdown.hourly_rate === 'number'
? breakdown.hourly_rate
: breakdown.HourlyRate;
const computeCost =
typeof breakdown.compute_cost === 'number'
? breakdown.compute_cost
: breakdown.ComputeCost;
const resolvedHardwareName =
deploymentDetails?.hardware_name || deployment?.hardware_name || '--';
const gpuCount =
deploymentDetails?.total_gpus || deployment?.hardware_quantity || 0;
const containers = deploymentDetails?.total_containers || 0;
return (
<Modal
title={
<div className='flex items-center gap-2'>
<FaClock className='text-blue-500' />
<span>{t('延长容器时长')}</span>
</div>
}
visible={visible}
onCancel={handleCancel}
onOk={handleExtend}
okText={t('确认延长')}
cancelText={t('取消')}
confirmLoading={loading}
okButtonProps={{
disabled:
!deployment?.id || detailsLoading || !durationHours || durationHours < 1,
}}
width={600}
className='extend-duration-modal'
>
<div className='space-y-4'>
<Card className='border-0 bg-gray-50'>
<div className='flex items-center justify-between'>
<div>
<Text strong className='text-base'>
{deployment?.container_name || deployment?.deployment_name}
</Text>
<div className='mt-1'>
<Text type='secondary' size='small'>
ID: {deployment?.id}
</Text>
</div>
</div>
<div className='text-right'>
<div className='flex items-center gap-2 mb-1'>
<Tag color='blue' size='small'>
{resolvedHardwareName}
{gpuCount ? ` x${gpuCount}` : ''}
</Tag>
</div>
<Text size='small' type='secondary'>
{t('当前剩余')}: <Text strong>{currentRemainingTime}</Text>
</Text>
</div>
</div>
</Card>
<Banner
type='warning'
icon={<FaExclamationTriangle />}
title={t('重要提醒')}
description={
<div className='space-y-2'>
<p>
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
</p>
<p>
{t('延长操作一旦确认无法撤销,费用将立即扣除。')}
</p>
</div>
}
/>
<Form
getFormApi={(api) => (formRef.current = api)}
layout='vertical'
onValueChange={(values) => {
if (values.duration_hours !== undefined) {
const numericValue = Number(values.duration_hours);
setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
}
}}
>
<Form.InputNumber
field='duration_hours'
label={t('延长时长(小时)')}
placeholder={t('请输入要延长的小时数')}
min={1}
max={720}
step={1}
initValue={1}
style={{ width: '100%' }}
suffix={t('小时')}
rules={[
{ required: true, message: t('请输入延长时长') },
{
type: 'number',
min: 1,
message: t('延长时长至少为1小时'),
},
{
type: 'number',
max: 720,
message: t('延长时长不能超过720小时30天'),
},
]}
/>
</Form>
<div className='space-y-2'>
<Text size='small' type='secondary'>
{t('快速选择')}:
</Text>
<Space wrap>
{[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => (
<Button
key={hours}
size='small'
theme={durationHours === hours ? 'solid' : 'borderless'}
type={durationHours === hours ? 'primary' : 'secondary'}
onClick={() => {
setDurationHours(hours);
if (formRef.current) {
formRef.current.setValue('duration_hours', hours);
}
}}
>
{hours < 24
? `${hours}${t('小时')}`
: `${hours / 24}${t('天')}`}
</Button>
))}
</Space>
</div>
<Divider />
<Card
title={
<div className='flex items-center gap-2'>
<FaCalculator className='text-green-500' />
<span>{t('费用预估')}</span>
</div>
}
className='border border-green-200'
>
{priceEstimation ? (
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Text>{t('延长时长')}:</Text>
<Text strong>
{Math.round(durationHours)} {t('小时')}
</Text>
</div>
<div className='flex items-center justify-between'>
<Text>{t('硬件配置')}:</Text>
<Text strong>
{resolvedHardwareName}
{gpuCount ? ` x${gpuCount}` : ''}
</Text>
</div>
{containers ? (
<div className='flex items-center justify-between'>
<Text>{t('容器数量')}:</Text>
<Text strong>{containers}</Text>
</div>
) : null}
<div className='flex items-center justify-between'>
<Text>{t('单GPU小时费率')}:</Text>
<Text strong>
{typeof hourlyRate === 'number'
? `${hourlyRate.toFixed(4)} ${currencyLabel}`
: '--'}
</Text>
</div>
{typeof computeCost === 'number' && (
<div className='flex items-center justify-between'>
<Text>{t('计算成本')}:</Text>
<Text strong>
{computeCost.toFixed(4)} {currencyLabel}
</Text>
</div>
)}
<Divider margin='12px' />
<div className='flex items-center justify-between'>
<Text strong className='text-lg'>
{t('预估总费用')}:
</Text>
<Text strong className='text-lg text-green-600'>
{typeof estimatedTotalCost === 'number'
? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}`
: '--'}
</Text>
</div>
<div className='bg-blue-50 p-3 rounded-lg'>
<div className='flex items-start gap-2'>
<FaInfoCircle className='text-blue-500 mt-0.5' />
<div>
<Text size='small' type='secondary'>
{t('延长后总时长')}: <Text strong>{newTotalTime}</Text>
</Text>
<br />
<Text size='small' type='secondary'>
{t('预估费用仅供参考,实际费用可能略有差异')}
</Text>
</div>
</div>
</div>
</div>
) : (
<div className='text-center text-gray-500 py-4'>
{costLoading ? (
<Space align='center' className='justify-center'>
<Spin size='small' />
<Text type='secondary'>{t('计算费用中...')}</Text>
</Space>
) : priceError ? (
<Text type='danger'>{priceError}</Text>
) : deploymentDetails ? (
<Text type='secondary'>{t('请输入延长时长')}</Text>
) : (
<Text type='secondary'>{t('加载详情中...')}</Text>
)}
</div>
)}
</Card>
<div className='bg-red-50 border border-red-200 rounded-lg p-3'>
<div className='flex items-start gap-2'>
<FaExclamationTriangle className='text-red-500 mt-0.5' />
<div>
<Text strong className='text-red-700'>
{t('确认延长容器时长')}
</Text>
<div className='mt-1'>
<Text size='small' className='text-red-600'>
{t('点击"确认延长"后将立即扣除费用并延长容器运行时间')}
</Text>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};
export default ExtendDurationModal;

View File

@@ -0,0 +1,475 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useRef } from 'react';
import {
Modal,
Form,
Input,
InputNumber,
Typography,
Card,
Space,
Divider,
Button,
Banner,
Tag,
Collapse,
TextArea,
Switch,
} from '@douyinfe/semi-ui';
import {
FaCog,
FaDocker,
FaKey,
FaTerminal,
FaNetworkWired,
FaExclamationTriangle,
FaPlus,
FaMinus
} from 'react-icons/fa';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text, Title } = Typography;
const UpdateConfigModal = ({
visible,
onCancel,
deployment,
onSuccess,
t
}) => {
const formRef = useRef(null);
const [loading, setLoading] = useState(false);
const [envVars, setEnvVars] = useState([]);
const [secretEnvVars, setSecretEnvVars] = useState([]);
// Initialize form data when modal opens
useEffect(() => {
if (visible && deployment) {
// Set initial form values based on deployment data
const initialValues = {
image_url: deployment.container_config?.image_url || '',
traffic_port: deployment.container_config?.traffic_port || null,
entrypoint: deployment.container_config?.entrypoint?.join(' ') || '',
registry_username: '',
registry_secret: '',
command: '',
};
if (formRef.current) {
formRef.current.setValues(initialValues);
}
// Initialize environment variables
const envVarsList = deployment.container_config?.env_variables
? Object.entries(deployment.container_config.env_variables).map(([key, value]) => ({
key, value: String(value)
}))
: [];
setEnvVars(envVarsList);
setSecretEnvVars([]);
}
}, [visible, deployment]);
const handleUpdate = async () => {
try {
const formValues = formRef.current ? await formRef.current.validate() : {};
setLoading(true);
// Prepare the update payload
const payload = {};
if (formValues.image_url) payload.image_url = formValues.image_url;
if (formValues.traffic_port) payload.traffic_port = formValues.traffic_port;
if (formValues.registry_username) payload.registry_username = formValues.registry_username;
if (formValues.registry_secret) payload.registry_secret = formValues.registry_secret;
if (formValues.command) payload.command = formValues.command;
// Process entrypoint
if (formValues.entrypoint) {
payload.entrypoint = formValues.entrypoint.split(' ').filter(cmd => cmd.trim());
}
// Process environment variables
if (envVars.length > 0) {
payload.env_variables = envVars.reduce((acc, env) => {
if (env.key && env.value !== undefined) {
acc[env.key] = env.value;
}
return acc;
}, {});
}
// Process secret environment variables
if (secretEnvVars.length > 0) {
payload.secret_env_variables = secretEnvVars.reduce((acc, env) => {
if (env.key && env.value !== undefined) {
acc[env.key] = env.value;
}
return acc;
}, {});
}
const response = await API.put(`/api/deployments/${deployment.id}`, payload);
if (response.data.success) {
showSuccess(t('容器配置更新成功'));
onSuccess?.(response.data.data);
handleCancel();
}
} catch (error) {
showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setLoading(false);
}
};
const handleCancel = () => {
if (formRef.current) {
formRef.current.reset();
}
setEnvVars([]);
setSecretEnvVars([]);
onCancel();
};
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '' }]);
};
const removeEnvVar = (index) => {
const newEnvVars = envVars.filter((_, i) => i !== index);
setEnvVars(newEnvVars);
};
const updateEnvVar = (index, field, value) => {
const newEnvVars = [...envVars];
newEnvVars[index][field] = value;
setEnvVars(newEnvVars);
};
const addSecretEnvVar = () => {
setSecretEnvVars([...secretEnvVars, { key: '', value: '' }]);
};
const removeSecretEnvVar = (index) => {
const newSecretEnvVars = secretEnvVars.filter((_, i) => i !== index);
setSecretEnvVars(newSecretEnvVars);
};
const updateSecretEnvVar = (index, field, value) => {
const newSecretEnvVars = [...secretEnvVars];
newSecretEnvVars[index][field] = value;
setSecretEnvVars(newSecretEnvVars);
};
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaCog className="text-blue-500" />
<span>{t('更新容器配置')}</span>
</div>
}
visible={visible}
onCancel={handleCancel}
onOk={handleUpdate}
okText={t('更新配置')}
cancelText={t('取消')}
confirmLoading={loading}
width={700}
className="update-config-modal"
>
<div className="space-y-4 max-h-[600px] overflow-y-auto">
{/* Container Info */}
<Card className="border-0 bg-gray-50">
<div className="flex items-center justify-between">
<div>
<Text strong className="text-base">
{deployment?.container_name}
</Text>
<div className="mt-1">
<Text type="secondary" size="small">
ID: {deployment?.id}
</Text>
</div>
</div>
<Tag color="blue">{deployment?.status}</Tag>
</div>
</Card>
{/* Warning Banner */}
<Banner
type="warning"
icon={<FaExclamationTriangle />}
title={t('重要提醒')}
description={
<div className="space-y-2">
<p>{t('更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。')}</p>
<p>{t('某些配置更改可能需要几分钟才能生效。')}</p>
</div>
}
/>
<Form
getFormApi={(api) => (formRef.current = api)}
layout="vertical"
>
<Collapse defaultActiveKey={['docker']}>
{/* Docker Configuration */}
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaDocker className="text-blue-600" />
<span>{t('Docker 配置')}</span>
</div>
}
itemKey="docker"
>
<div className="space-y-4">
<Form.Input
field="image_url"
label={t('镜像地址')}
placeholder={t('例如: nginx:latest')}
rules={[
{
type: 'string',
message: t('请输入有效的镜像地址')
}
]}
/>
<Form.Input
field="registry_username"
label={t('镜像仓库用户名')}
placeholder={t('如果镜像为私有,请填写用户名')}
/>
<Form.Input
field="registry_secret"
label={t('镜像仓库密码')}
mode="password"
placeholder={t('如果镜像为私有请填写密码或Token')}
/>
</div>
</Collapse.Panel>
{/* Network Configuration */}
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaNetworkWired className="text-green-600" />
<span>{t('网络配置')}</span>
</div>
}
itemKey="network"
>
<Form.InputNumber
field="traffic_port"
label={t('流量端口')}
placeholder={t('容器对外暴露的端口')}
min={1}
max={65535}
style={{ width: '100%' }}
rules={[
{
type: 'number',
min: 1,
max: 65535,
message: t('端口号必须在1-65535之间')
}
]}
/>
</Collapse.Panel>
{/* Startup Configuration */}
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaTerminal className="text-purple-600" />
<span>{t('启动配置')}</span>
</div>
}
itemKey="startup"
>
<div className="space-y-4">
<Form.Input
field="entrypoint"
label={t('启动命令 (Entrypoint)')}
placeholder={t('例如: /bin/bash -c "python app.py"')}
helpText={t('多个命令用空格分隔')}
/>
<Form.Input
field="command"
label={t('运行命令 (Command)')}
placeholder={t('容器启动后执行的命令')}
/>
</div>
</Collapse.Panel>
{/* Environment Variables */}
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaKey className="text-orange-600" />
<span>{t('环境变量')}</span>
<Tag size="small">{envVars.length}</Tag>
</div>
}
itemKey="env"
>
<div className="space-y-4">
{/* Regular Environment Variables */}
<div>
<div className="flex items-center justify-between mb-3">
<Text strong>{t('普通环境变量')}</Text>
<Button
size="small"
icon={<FaPlus />}
onClick={addEnvVar}
theme="borderless"
type="primary"
>
{t('添加')}
</Button>
</div>
{envVars.map((envVar, index) => (
<div key={index} className="flex items-end gap-2 mb-2">
<Input
placeholder={t('变量名')}
value={envVar.key}
onChange={(value) => updateEnvVar(index, 'key', value)}
style={{ flex: 1 }}
/>
<Text>=</Text>
<Input
placeholder={t('变量值')}
value={envVar.value}
onChange={(value) => updateEnvVar(index, 'value', value)}
style={{ flex: 2 }}
/>
<Button
size="small"
icon={<FaMinus />}
onClick={() => removeEnvVar(index)}
theme="borderless"
type="danger"
/>
</div>
))}
{envVars.length === 0 && (
<div className="text-center text-gray-500 py-4 border-2 border-dashed border-gray-300 rounded-lg">
<Text type="secondary">{t('暂无环境变量')}</Text>
</div>
)}
</div>
<Divider />
{/* Secret Environment Variables */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Text strong>{t('机密环境变量')}</Text>
<Tag size="small" type="danger">
{t('加密存储')}
</Tag>
</div>
<Button
size="small"
icon={<FaPlus />}
onClick={addSecretEnvVar}
theme="borderless"
type="danger"
>
{t('添加')}
</Button>
</div>
{secretEnvVars.map((envVar, index) => (
<div key={index} className="flex items-end gap-2 mb-2">
<Input
placeholder={t('变量名')}
value={envVar.key}
onChange={(value) => updateSecretEnvVar(index, 'key', value)}
style={{ flex: 1 }}
/>
<Text>=</Text>
<Input
mode="password"
placeholder={t('变量值')}
value={envVar.value}
onChange={(value) => updateSecretEnvVar(index, 'value', value)}
style={{ flex: 2 }}
/>
<Button
size="small"
icon={<FaMinus />}
onClick={() => removeSecretEnvVar(index)}
theme="borderless"
type="danger"
/>
</div>
))}
{secretEnvVars.length === 0 && (
<div className="text-center text-gray-500 py-4 border-2 border-dashed border-red-200 rounded-lg bg-red-50">
<Text type="secondary">{t('暂无机密环境变量')}</Text>
</div>
)}
<Banner
type="info"
title={t('机密环境变量说明')}
description={t('机密环境变量将被加密存储适用于存储密码、API密钥等敏感信息。')}
size="small"
/>
</div>
</div>
</Collapse.Panel>
</Collapse>
</Form>
{/* Final Warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<FaExclamationTriangle className="text-yellow-600 mt-0.5" />
<div>
<Text strong className="text-yellow-800">
{t('配置更新确认')}
</Text>
<div className="mt-1">
<Text size="small" className="text-yellow-700">
{t('更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。')}
</Text>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};
export default UpdateConfigModal;

View File

@@ -0,0 +1,517 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import {
Modal,
Typography,
Card,
Tag,
Progress,
Descriptions,
Spin,
Empty,
Button,
Badge,
Tooltip,
} from '@douyinfe/semi-ui';
import {
FaInfoCircle,
FaServer,
FaClock,
FaMapMarkerAlt,
FaDocker,
FaMoneyBillWave,
FaChartLine,
FaCopy,
FaLink,
} from 'react-icons/fa';
import { IconRefresh } from '@douyinfe/semi-icons';
import { API, showError, showSuccess, timestamp2string } from '../../../../helpers';
const { Text, Title } = Typography;
const ViewDetailsModal = ({
visible,
onCancel,
deployment,
t
}) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(false);
const [containers, setContainers] = useState([]);
const [containersLoading, setContainersLoading] = useState(false);
const fetchDetails = async () => {
if (!deployment?.id) return;
setLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}`);
if (response.data.success) {
setDetails(response.data.data);
}
} catch (error) {
showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setLoading(false);
}
};
const fetchContainers = async () => {
if (!deployment?.id) return;
setContainersLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers`);
if (response.data.success) {
setContainers(response.data.data?.containers || []);
}
} catch (error) {
showError(t('获取容器信息失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setContainersLoading(false);
}
};
useEffect(() => {
if (visible && deployment?.id) {
fetchDetails();
fetchContainers();
} else if (!visible) {
setDetails(null);
setContainers([]);
}
}, [visible, deployment?.id]);
const handleCopyId = () => {
navigator.clipboard.writeText(deployment?.id);
showSuccess(t('ID已复制到剪贴板'));
};
const handleRefresh = () => {
fetchDetails();
fetchContainers();
};
const getStatusConfig = (status) => {
const statusConfig = {
'running': { color: 'green', text: '运行中', icon: '🟢' },
'completed': { color: 'green', text: '已完成', icon: '✅' },
'deployment requested': { color: 'blue', text: '部署请求中', icon: '🔄' },
'termination requested': { color: 'orange', text: '终止请求中', icon: '⏸️' },
'destroyed': { color: 'red', text: '已销毁', icon: '🔴' },
'failed': { color: 'red', text: '失败', icon: '❌' }
};
return statusConfig[status] || { color: 'grey', text: status, icon: '❓' };
};
const statusConfig = getStatusConfig(deployment?.status);
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaInfoCircle className="text-blue-500" />
<span>{t('容器详情')}</span>
</div>
}
visible={visible}
onCancel={onCancel}
footer={
<div className="flex justify-between">
<Button
icon={<IconRefresh />}
onClick={handleRefresh}
loading={loading || containersLoading}
theme="borderless"
>
{t('刷新')}
</Button>
<Button onClick={onCancel}>
{t('关闭')}
</Button>
</div>
}
width={800}
className="deployment-details-modal"
>
{loading && !details ? (
<div className="flex items-center justify-center py-12">
<Spin size="large" tip={t('加载详情中...')} />
</div>
) : details ? (
<div className="space-y-4 max-h-[600px] overflow-y-auto">
{/* Basic Info */}
<Card
title={
<div className="flex items-center gap-2">
<FaServer className="text-blue-500" />
<span>{t('基本信息')}</span>
</div>
}
className="border-0 shadow-sm"
>
<Descriptions data={[
{
key: t('容器名称'),
value: (
<div className="flex items-center gap-2">
<Text strong className="text-base">
{details.deployment_name || details.id}
</Text>
<Button
size="small"
theme="borderless"
icon={<FaCopy />}
onClick={handleCopyId}
className="opacity-70 hover:opacity-100"
/>
</div>
)
},
{
key: t('容器ID'),
value: (
<Text type="secondary" className="font-mono text-sm">
{details.id}
</Text>
)
},
{
key: t('状态'),
value: (
<div className="flex items-center gap-2">
<span>{statusConfig.icon}</span>
<Tag color={statusConfig.color}>
{t(statusConfig.text)}
</Tag>
</div>
)
},
{
key: t('创建时间'),
value: timestamp2string(details.created_at)
}
]} />
</Card>
{/* Hardware & Performance */}
<Card
title={
<div className="flex items-center gap-2">
<FaChartLine className="text-green-500" />
<span>{t('硬件与性能')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="space-y-4">
<Descriptions data={[
{
key: t('硬件类型'),
value: (
<div className="flex items-center gap-2">
<Tag color="blue">{details.brand_name}</Tag>
<Text strong>{details.hardware_name}</Text>
</div>
)
},
{
key: t('GPU数量'),
value: (
<div className="flex items-center gap-2">
<Badge count={details.total_gpus} theme="solid" type="primary">
<FaServer className="text-purple-500" />
</Badge>
<Text>{t('总计')} {details.total_gpus} {t('个GPU')}</Text>
</div>
)
},
{
key: t('容器配置'),
value: (
<div className="space-y-1">
<div>{t('每容器GPU数')}: {details.gpus_per_container}</div>
<div>{t('容器总数')}: {details.total_containers}</div>
</div>
)
}
]} />
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Text strong>{t('完成进度')}</Text>
<Text>{details.completed_percent}%</Text>
</div>
<Progress
percent={details.completed_percent}
status={details.completed_percent === 100 ? 'success' : 'normal'}
strokeWidth={8}
showInfo={false}
/>
<div className="flex justify-between text-xs text-gray-500">
<span>{t('已服务')}: {details.compute_minutes_served} {t('分钟')}</span>
<span>{t('剩余')}: {details.compute_minutes_remaining} {t('分钟')}</span>
</div>
</div>
</div>
</Card>
{/* Container Configuration */}
{details.container_config && (
<Card
title={
<div className="flex items-center gap-2">
<FaDocker className="text-blue-600" />
<span>{t('容器配置')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="space-y-3">
<Descriptions data={[
{
key: t('镜像地址'),
value: (
<Text className="font-mono text-sm break-all">
{details.container_config.image_url || 'N/A'}
</Text>
)
},
{
key: t('流量端口'),
value: details.container_config.traffic_port || 'N/A'
},
{
key: t('启动命令'),
value: (
<Text className="font-mono text-sm">
{details.container_config.entrypoint ?
details.container_config.entrypoint.join(' ') : 'N/A'
}
</Text>
)
}
]} />
{/* Environment Variables */}
{details.container_config.env_variables &&
Object.keys(details.container_config.env_variables).length > 0 && (
<div className="mt-4">
<Text strong className="block mb-2">{t('环境变量')}:</Text>
<div className="bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto">
{Object.entries(details.container_config.env_variables).map(([key, value]) => (
<div key={key} className="flex gap-2 text-sm font-mono mb-1">
<span className="text-blue-600 font-medium">{key}=</span>
<span className="text-gray-700 break-all">{String(value)}</span>
</div>
))}
</div>
</div>
)}
</div>
</Card>
)}
{/* Containers List */}
<Card
title={
<div className="flex items-center gap-2">
<FaServer className="text-indigo-500" />
<span>{t('容器实例')}</span>
</div>
}
className="border-0 shadow-sm"
>
{containersLoading ? (
<div className="flex items-center justify-center py-6">
<Spin tip={t('加载容器信息中...')} />
</div>
) : containers.length === 0 ? (
<Empty description={t('暂无容器信息')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<div className="space-y-3">
{containers.map((ctr) => (
<Card
key={ctr.container_id}
className="bg-gray-50 border border-gray-100"
bodyStyle={{ padding: '12px 16px' }}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<Text strong className="font-mono text-sm">
{ctr.container_id}
</Text>
<Text size="small" type="secondary">
{t('设备')} {ctr.device_id || '--'} · {t('状态')} {ctr.status || '--'}
</Text>
<Text size="small" type="secondary">
{t('创建时间')}: {ctr.created_at ? timestamp2string(ctr.created_at) : '--'}
</Text>
</div>
<div className="flex flex-col items-end gap-2">
<Tag color="blue" size="small">
{t('GPU/容器')}: {ctr.gpus_per_container ?? '--'}
</Tag>
{ctr.public_url && (
<Tooltip content={ctr.public_url}>
<Button
icon={<FaLink />}
size="small"
theme="light"
onClick={() => window.open(ctr.public_url, '_blank', 'noopener,noreferrer')}
>
{t('访问容器')}
</Button>
</Tooltip>
)}
</div>
</div>
{ctr.events && ctr.events.length > 0 && (
<div className="mt-3 bg-white rounded-md border border-gray-100 p-3">
<Text size="small" type="secondary" className="block mb-2">
{t('最近事件')}
</Text>
<div className="space-y-2 max-h-32 overflow-y-auto">
{ctr.events.map((event, index) => (
<div key={`${ctr.container_id}-${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
<span className="text-gray-500 min-w-[140px]">
{event.time ? timestamp2string(event.time) : '--'}
</span>
<span className="text-gray-700 break-all flex-1">
{event.message || '--'}
</span>
</div>
))}
</div>
</div>
)}
</Card>
))}
</div>
)}
</Card>
{/* Location Information */}
{details.locations && details.locations.length > 0 && (
<Card
title={
<div className="flex items-center gap-2">
<FaMapMarkerAlt className="text-orange-500" />
<span>{t('部署位置')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="flex flex-wrap gap-2">
{details.locations.map((location) => (
<Tag key={location.id} color="orange" size="large">
<div className="flex items-center gap-1">
<span>🌍</span>
<span>{location.name} ({location.iso2})</span>
</div>
</Tag>
))}
</div>
</Card>
)}
{/* Cost Information */}
<Card
title={
<div className="flex items-center gap-2">
<FaMoneyBillWave className="text-green-500" />
<span>{t('费用信息')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<Text>{t('已支付金额')}</Text>
<Text strong className="text-lg text-green-600">
${details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'} USDC
</Text>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<Text type="secondary">{t('计费开始')}:</Text>
<Text>{details.started_at ? timestamp2string(details.started_at) : 'N/A'}</Text>
</div>
<div className="flex justify-between">
<Text type="secondary">{t('预计结束')}:</Text>
<Text>{details.finished_at ? timestamp2string(details.finished_at) : 'N/A'}</Text>
</div>
</div>
</div>
</Card>
{/* Time Information */}
<Card
title={
<div className="flex items-center gap-2">
<FaClock className="text-purple-500" />
<span>{t('时间信息')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Text type="secondary">{t('已运行时间')}:</Text>
<Text strong>
{Math.floor(details.compute_minutes_served / 60)}h {details.compute_minutes_served % 60}m
</Text>
</div>
<div className="flex items-center justify-between">
<Text type="secondary">{t('剩余时间')}:</Text>
<Text strong className="text-orange-600">
{Math.floor(details.compute_minutes_remaining / 60)}h {details.compute_minutes_remaining % 60}m
</Text>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Text type="secondary">{t('创建时间')}:</Text>
<Text>{timestamp2string(details.created_at)}</Text>
</div>
<div className="flex items-center justify-between">
<Text type="secondary">{t('最后更新')}:</Text>
<Text>{timestamp2string(details.updated_at)}</Text>
</div>
</div>
</div>
</Card>
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('无法获取容器详情')}
/>
)}
</Modal>
);
};
export default ViewDetailsModal;

View File

@@ -0,0 +1,660 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useRef } from 'react';
import {
Modal,
Button,
Typography,
Select,
Input,
Space,
Spin,
Card,
Tag,
Empty,
Switch,
Divider,
Tooltip,
Radio,
} from '@douyinfe/semi-ui';
import {
FaCopy,
FaSearch,
FaClock,
FaTerminal,
FaServer,
FaInfoCircle,
FaLink,
} from 'react-icons/fa';
import { IconRefresh, IconDownload } from '@douyinfe/semi-icons';
import { API, showError, showSuccess, copy, timestamp2string } from '../../../../helpers';
const { Text } = Typography;
const ALL_CONTAINERS = '__all__';
const ViewLogsModal = ({
visible,
onCancel,
deployment,
t
}) => {
const [logLines, setLogLines] = useState([]);
const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [following, setFollowing] = useState(false);
const [containers, setContainers] = useState([]);
const [containersLoading, setContainersLoading] = useState(false);
const [selectedContainerId, setSelectedContainerId] = useState(ALL_CONTAINERS);
const [containerDetails, setContainerDetails] = useState(null);
const [containerDetailsLoading, setContainerDetailsLoading] = useState(false);
const [streamFilter, setStreamFilter] = useState('stdout');
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const logContainerRef = useRef(null);
const autoRefreshRef = useRef(null);
// Auto scroll to bottom when new logs arrive
const scrollToBottom = () => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
};
const resolveStreamValue = (value) => {
if (typeof value === 'string') {
return value;
}
if (value && typeof value.value === 'string') {
return value.value;
}
if (value && value.target && typeof value.target.value === 'string') {
return value.target.value;
}
return '';
};
const handleStreamChange = (value) => {
const next = resolveStreamValue(value) || 'stdout';
setStreamFilter(next);
};
const fetchLogs = async (containerIdOverride = undefined) => {
if (!deployment?.id) return;
const containerId = typeof containerIdOverride === 'string' ? containerIdOverride : selectedContainerId;
if (!containerId || containerId === ALL_CONTAINERS) {
setLogLines([]);
setLastUpdatedAt(null);
setLoading(false);
return;
}
setLoading(true);
try {
const params = new URLSearchParams();
params.append('container_id', containerId);
const streamValue = resolveStreamValue(streamFilter) || 'stdout';
if (streamValue && streamValue !== 'all') {
params.append('stream', streamValue);
}
if (following) params.append('follow', 'true');
const response = await API.get(`/api/deployments/${deployment.id}/logs?${params}`);
if (response.data.success) {
const rawContent = typeof response.data.data === 'string' ? response.data.data : '';
const normalized = rawContent.replace(/\r\n?/g, '\n');
const lines = normalized ? normalized.split('\n') : [];
setLogLines(lines);
setLastUpdatedAt(new Date());
setTimeout(scrollToBottom, 100);
}
} catch (error) {
showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setLoading(false);
}
};
const fetchContainers = async () => {
if (!deployment?.id) return;
setContainersLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers`);
if (response.data.success) {
const list = response.data.data?.containers || [];
setContainers(list);
setSelectedContainerId((current) => {
if (current !== ALL_CONTAINERS && list.some(item => item.container_id === current)) {
return current;
}
return list.length > 0 ? list[0].container_id : ALL_CONTAINERS;
});
if (list.length === 0) {
setContainerDetails(null);
}
}
} catch (error) {
showError(t('获取容器列表失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setContainersLoading(false);
}
};
const fetchContainerDetails = async (containerId) => {
if (!deployment?.id || !containerId || containerId === ALL_CONTAINERS) {
setContainerDetails(null);
return;
}
setContainerDetailsLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers/${containerId}`);
if (response.data.success) {
setContainerDetails(response.data.data || null);
}
} catch (error) {
showError(t('获取容器详情失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setContainerDetailsLoading(false);
}
};
const handleContainerChange = (value) => {
const newValue = value || ALL_CONTAINERS;
setSelectedContainerId(newValue);
setLogLines([]);
setLastUpdatedAt(null);
};
const refreshContainerDetails = () => {
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
fetchContainerDetails(selectedContainerId);
}
};
const renderContainerStatusTag = (status) => {
if (!status) {
return (
<Tag color="grey" size="small">
{t('未知状态')}
</Tag>
);
}
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : '';
const statusMap = {
running: { color: 'green', label: '运行中' },
pending: { color: 'orange', label: '准备中' },
deployed: { color: 'blue', label: '已部署' },
failed: { color: 'red', label: '失败' },
destroyed: { color: 'red', label: '已销毁' },
stopping: { color: 'orange', label: '停止中' },
terminated: { color: 'grey', label: '已终止' },
};
const config = statusMap[normalized] || { color: 'grey', label: status };
return (
<Tag color={config.color} size="small">
{t(config.label)}
</Tag>
);
};
const currentContainer = selectedContainerId !== ALL_CONTAINERS
? containers.find((ctr) => ctr.container_id === selectedContainerId)
: null;
const refreshLogs = () => {
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
fetchContainerDetails(selectedContainerId);
}
fetchLogs();
};
const downloadLogs = () => {
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
if (sourceLogs.length === 0) {
showError(t('暂无日志可下载'));
return;
}
const logText = sourceLogs.join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeContainerId = selectedContainerId && selectedContainerId !== ALL_CONTAINERS
? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
: '';
const fileName = safeContainerId
? `deployment-${deployment.id}-container-${safeContainerId}-logs.txt`
: `deployment-${deployment.id}-logs.txt`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('日志已下载'));
};
const copyAllLogs = async () => {
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
if (sourceLogs.length === 0) {
showError(t('暂无日志可复制'));
return;
}
const logText = sourceLogs.join('\n');
const copied = await copy(logText);
if (copied) {
showSuccess(t('日志已复制到剪贴板'));
} else {
showError(t('复制失败,请手动选择文本复制'));
}
};
// Auto refresh functionality
useEffect(() => {
if (autoRefresh && visible) {
autoRefreshRef.current = setInterval(() => {
fetchLogs();
}, 5000);
} else {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
autoRefreshRef.current = null;
}
}
return () => {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
}
};
}, [autoRefresh, visible, selectedContainerId, streamFilter, following]);
useEffect(() => {
if (visible && deployment?.id) {
fetchContainers();
} else if (!visible) {
setContainers([]);
setSelectedContainerId(ALL_CONTAINERS);
setContainerDetails(null);
setStreamFilter('stdout');
setLogLines([]);
setLastUpdatedAt(null);
}
}, [visible, deployment?.id]);
useEffect(() => {
if (visible) {
setStreamFilter('stdout');
}
}, [selectedContainerId, visible]);
useEffect(() => {
if (visible && deployment?.id) {
fetchContainerDetails(selectedContainerId);
}
}, [visible, deployment?.id, selectedContainerId]);
// Initial load and cleanup
useEffect(() => {
if (visible && deployment?.id) {
fetchLogs();
}
return () => {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
}
};
}, [visible, deployment?.id, streamFilter, selectedContainerId, following]);
// Filter logs based on search term
const filteredLogs = logLines
.map((line) => line ?? '')
.filter((line) =>
!searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
);
const renderLogEntry = (line, index) => (
<div
key={`${index}-${line.slice(0, 20)}`}
className="py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words"
>
{line}
</div>
);
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaTerminal className="text-blue-500" />
<span>{t('容器日志')}</span>
<Text type="secondary" size="small">
- {deployment?.container_name || deployment?.id}
</Text>
</div>
}
visible={visible}
onCancel={onCancel}
footer={null}
width={1000}
height={700}
className="logs-modal"
style={{ top: 20 }}
>
<div className="flex flex-col h-full max-h-[600px]">
{/* Controls */}
<Card className="mb-4 border-0 shadow-sm">
<div className="flex items-center justify-between flex-wrap gap-3">
<Space wrap>
<Select
prefix={<FaServer />}
placeholder={t('选择容器')}
value={selectedContainerId}
onChange={handleContainerChange}
style={{ width: 240 }}
size="small"
loading={containersLoading}
dropdownStyle={{ maxHeight: 320, overflowY: 'auto' }}
>
<Select.Option value={ALL_CONTAINERS}>
{t('全部容器')}
</Select.Option>
{containers.map((ctr) => (
<Select.Option key={ctr.container_id} value={ctr.container_id}>
<div className="flex flex-col">
<span className="font-mono text-xs">{ctr.container_id}</span>
<span className="text-xs text-gray-500">
{ctr.brand_name || 'IO.NET'}
{ctr.hardware ? ` · ${ctr.hardware}` : ''}
</span>
</div>
</Select.Option>
))}
</Select>
<Input
prefix={<FaSearch />}
placeholder={t('搜索日志内容')}
value={searchTerm}
onChange={setSearchTerm}
style={{ width: 200 }}
size="small"
/>
<Space align="center" className="ml-2">
<Text size="small" type="secondary">
{t('日志流')}
</Text>
<Radio.Group
type="button"
size="small"
value={streamFilter}
onChange={handleStreamChange}
>
<Radio value="stdout">STDOUT</Radio>
<Radio value="stderr">STDERR</Radio>
</Radio.Group>
</Space>
<div className="flex items-center gap-2">
<Switch
checked={autoRefresh}
onChange={setAutoRefresh}
size="small"
/>
<Text size="small">{t('自动刷新')}</Text>
</div>
<div className="flex items-center gap-2">
<Switch
checked={following}
onChange={setFollowing}
size="small"
/>
<Text size="small">{t('跟随日志')}</Text>
</div>
</Space>
<Space>
<Tooltip content={t('刷新日志')}>
<Button
icon={<IconRefresh />}
onClick={refreshLogs}
loading={loading}
size="small"
theme="borderless"
/>
</Tooltip>
<Tooltip content={t('复制日志')}>
<Button
icon={<FaCopy />}
onClick={copyAllLogs}
size="small"
theme="borderless"
disabled={logLines.length === 0}
/>
</Tooltip>
<Tooltip content={t('下载日志')}>
<Button
icon={<IconDownload />}
onClick={downloadLogs}
size="small"
theme="borderless"
disabled={logLines.length === 0}
/>
</Tooltip>
</Space>
</div>
{/* Status Info */}
<Divider margin="12px" />
<div className="flex items-center justify-between">
<Space size="large">
<Text size="small" type="secondary">
{t('共 {{count}} 条日志', { count: logLines.length })}
</Text>
{searchTerm && (
<Text size="small" type="secondary">
{t('(筛选后显示 {{count}} 条)', { count: filteredLogs.length })}
</Text>
)}
{autoRefresh && (
<Tag color="green" size="small">
<FaClock className="mr-1" />
{t('自动刷新中')}
</Tag>
)}
</Space>
<Text size="small" type="secondary">
{t('状态')}: {deployment?.status || 'unknown'}
</Text>
</div>
{selectedContainerId !== ALL_CONTAINERS && (
<>
<Divider margin="12px" />
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Space>
<Tag color="blue" size="small">
{t('容器')}
</Tag>
<Text className="font-mono text-xs">
{selectedContainerId}
</Text>
{renderContainerStatusTag(containerDetails?.status || currentContainer?.status)}
</Space>
<Space>
{containerDetails?.public_url && (
<Tooltip content={containerDetails.public_url}>
<Button
icon={<FaLink />}
size="small"
theme="borderless"
onClick={() => window.open(containerDetails.public_url, '_blank')}
/>
</Tooltip>
)}
<Tooltip content={t('刷新容器信息')}>
<Button
icon={<IconRefresh />}
onClick={refreshContainerDetails}
size="small"
theme="borderless"
loading={containerDetailsLoading}
/>
</Tooltip>
</Space>
</div>
{containerDetailsLoading ? (
<div className="flex items-center justify-center py-6">
<Spin tip={t('加载容器详情中...')} />
</div>
) : containerDetails ? (
<div className="grid gap-4 md:grid-cols-2 text-sm">
<div className="flex items-center gap-2">
<FaInfoCircle className="text-blue-500" />
<Text type="secondary">{t('硬件')}</Text>
<Text>
{containerDetails?.brand_name || currentContainer?.brand_name || t('未知品牌')}
{(containerDetails?.hardware || currentContainer?.hardware) ? ` · ${containerDetails?.hardware || currentContainer?.hardware}` : ''}
</Text>
</div>
<div className="flex items-center gap-2">
<FaServer className="text-purple-500" />
<Text type="secondary">{t('GPU/容器')}</Text>
<Text>{containerDetails?.gpus_per_container ?? currentContainer?.gpus_per_container ?? 0}</Text>
</div>
<div className="flex items-center gap-2">
<FaClock className="text-orange-500" />
<Text type="secondary">{t('创建时间')}</Text>
<Text>
{containerDetails?.created_at
? timestamp2string(containerDetails.created_at)
: currentContainer?.created_at
? timestamp2string(currentContainer.created_at)
: t('未知')}
</Text>
</div>
<div className="flex items-center gap-2">
<FaInfoCircle className="text-green-500" />
<Text type="secondary">{t('运行时长')}</Text>
<Text>{containerDetails?.uptime_percent ?? currentContainer?.uptime_percent ?? 0}%</Text>
</div>
</div>
) : (
<Text size="small" type="secondary">
{t('暂无容器详情')}
</Text>
)}
{containerDetails?.events && containerDetails.events.length > 0 && (
<div className="bg-gray-50 rounded-lg p-3">
<Text size="small" type="secondary">
{t('最近事件')}
</Text>
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto">
{containerDetails.events.slice(0, 5).map((event, index) => (
<div key={`${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
<span className="text-gray-500">
{event.time ? timestamp2string(event.time) : '--'}
</span>
<span className="text-gray-700 break-all flex-1">
{event.message}
</span>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</Card>
{/* Log Content */}
<div className="flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden">
<div
ref={logContainerRef}
className="flex-1 overflow-y-auto bg-white"
style={{ maxHeight: '400px' }}
>
{loading && logLines.length === 0 ? (
<div className="flex items-center justify-center p-8">
<Spin tip={t('加载日志中...')} />
</div>
) : filteredLogs.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
searchTerm ? t('没有匹配的日志条目') : t('暂无日志')
}
style={{ padding: '60px 20px' }}
/>
) : (
<div>
{filteredLogs.map((log, index) => renderLogEntry(log, index))}
</div>
)}
</div>
{/* Footer status */}
{logLines.length > 0 && (
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500">
<span>
{following ? t('正在跟随最新日志') : t('日志已加载')}
</span>
<span>
{t('最后更新')}: {lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
</span>
</div>
)}
</div>
</div>
</Modal>
);
};
export default ViewLogsModal;

View File

@@ -73,6 +73,7 @@ import {
Settings,
CircleUser,
Package,
Server,
} from 'lucide-react';
// 获取侧边栏Lucide图标组件
@@ -114,6 +115,8 @@ export function getLucideIcon(key, selected = false) {
return <User {...commonProps} color={iconColor} />;
case 'models':
return <Package {...commonProps} color={iconColor} />;
case 'deployment':
return <Server {...commonProps} color={iconColor} />;
case 'setting':
return <Settings {...commonProps} color={iconColor} />;
default:

View File

@@ -26,6 +26,7 @@ import {
showSuccess,
loadChannelModels,
copy,
toBoolean,
} from '../../helpers';
import {
CHANNEL_OPTIONS,
@@ -34,7 +35,7 @@ import {
} from '../../constants';
import { useIsMobile } from '../common/useIsMobile';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { Modal } from '@douyinfe/semi-ui';
import { Modal, Button } from '@douyinfe/semi-ui';
export const useChannelsData = () => {
const { t } = useTranslation();
@@ -85,6 +86,26 @@ export const useChannelsData = () => {
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1);
const [selectedEndpointType, setSelectedEndpointType] = useState('');
const [globalPassThroughEnabled, setGlobalPassThroughEnabled] =
useState(false);
const fetchGlobalPassThroughEnabled = async () => {
try {
const res = await API.get('/api/option/');
const { success, data } = res?.data || {};
if (!success || !Array.isArray(data)) {
return;
}
const option = data.find(
(item) => item?.key === 'global.pass_through_request_enabled',
);
if (option) {
setGlobalPassThroughEnabled(toBoolean(option.value));
}
} catch (error) {
setGlobalPassThroughEnabled(false);
}
};
// 使用 ref 来避免闭包问题,类似旧版实现
const shouldStopBatchTestingRef = useRef(false);
@@ -140,6 +161,7 @@ export const useChannelsData = () => {
});
fetchGroups().then();
loadChannelModels().then();
fetchGlobalPassThroughEnabled().then();
}, []);
// Column visibility management
@@ -753,6 +775,67 @@ export const useChannelsData = () => {
}
};
const checkOllamaVersion = async (record) => {
try {
const res = await API.get(`/api/channel/ollama/version/${record.id}`);
const { success, message, data } = res.data;
if (success) {
const version = data?.version || '-';
const infoMessage = t('当前 Ollama 版本为 ${version}').replace(
'${version}',
version,
);
const handleCopyVersion = async () => {
if (!version || version === '-') {
showInfo(t('暂无可复制的版本信息'));
return;
}
const copied = await copy(version);
if (copied) {
showSuccess(t('已复制版本号'));
} else {
showError(t('复制失败,请手动复制'));
}
};
Modal.info({
title: t('Ollama 版本信息'),
content: infoMessage,
centered: true,
footer: (
<div className='flex justify-end gap-2'>
<Button type='tertiary' onClick={handleCopyVersion}>
{t('复制版本号')}
</Button>
<Button
type='primary'
theme='solid'
onClick={() => Modal.destroyAll()}
>
{t('关闭')}
</Button>
</div>
),
hasCancel: false,
hasOk: false,
closable: true,
maskClosable: true,
});
} else {
showError(message || t('获取 Ollama 版本失败'));
}
} catch (error) {
const errMsg =
error?.response?.data?.message ||
error?.message ||
t('获取 Ollama 版本失败');
showError(errMsg);
}
};
// Test channel - 单个模型测试,参考旧版实现
const testChannel = async (record, model, endpointType = '') => {
const testKey = `${record.id}-${model}`;
@@ -1026,6 +1109,7 @@ export const useChannelsData = () => {
enableBatchDelete,
statusFilter,
compactMode,
globalPassThroughEnabled,
// UI states
showEdit,
@@ -1109,6 +1193,7 @@ export const useChannelsData = () => {
updateAllChannelsBalance,
updateChannelBalance,
fixChannelsAbilities,
checkOllamaVersion,
testChannel,
batchTestModels,
handleCloseModal,

View File

@@ -61,6 +61,7 @@ export const useSidebar = () => {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,

View File

@@ -0,0 +1,266 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useCallback } from 'react';
import { API } from '../../helpers';
import { showError } from '../../helpers';
export const useDeploymentResources = () => {
const [hardwareTypes, setHardwareTypes] = useState([]);
const [hardwareTotalAvailable, setHardwareTotalAvailable] = useState(0);
const [locations, setLocations] = useState([]);
const [locationsTotalAvailable, setLocationsTotalAvailable] = useState(0);
const [availableReplicas, setAvailableReplicas] = useState([]);
const [priceEstimation, setPriceEstimation] = useState(null);
const [loadingHardware, setLoadingHardware] = useState(false);
const [loadingLocations, setLoadingLocations] = useState(false);
const [loadingReplicas, setLoadingReplicas] = useState(false);
const [loadingPrice, setLoadingPrice] = useState(false);
const fetchHardwareTypes = useCallback(async () => {
try {
setLoadingHardware(true);
const response = await API.get('/api/deployments/hardware-types');
if (response.data.success) {
const { hardware_types: hardwareList = [], total_available } = response.data.data || {};
const normalizedHardware = hardwareList.map((hardware) => {
const availableCountValue = Number(hardware.available_count);
const availableCount = Number.isNaN(availableCountValue) ? 0 : availableCountValue;
const availableBool =
typeof hardware.available === 'boolean'
? hardware.available
: availableCount > 0;
return {
...hardware,
available: availableBool,
available_count: availableCount,
};
});
const providedTotal = Number(total_available);
const fallbackTotal = normalizedHardware.reduce(
(acc, item) => acc + (Number.isNaN(item.available_count) ? 0 : item.available_count),
0,
);
const hasProvidedTotal =
total_available !== undefined &&
total_available !== null &&
total_available !== '' &&
!Number.isNaN(providedTotal);
setHardwareTypes(normalizedHardware);
setHardwareTotalAvailable(
hasProvidedTotal ? providedTotal : fallbackTotal,
);
return normalizedHardware;
} else {
showError('获取硬件类型失败: ' + response.data.message);
setHardwareTotalAvailable(0);
return [];
}
} catch (error) {
showError('获取硬件类型失败: ' + error.message);
setHardwareTotalAvailable(0);
return [];
} finally {
setLoadingHardware(false);
}
}, []);
const fetchLocations = useCallback(async () => {
try {
setLoadingLocations(true);
const response = await API.get('/api/deployments/locations');
if (response.data.success) {
const { locations: locationsList = [], total } = response.data.data || {};
const normalizedLocations = locationsList.map((location) => {
const iso2 = (location.iso2 || '').toString().toUpperCase();
const availableValue = Number(location.available);
const available = Number.isNaN(availableValue) ? 0 : availableValue;
return {
...location,
iso2,
available,
};
});
const providedTotal = Number(total);
const fallbackTotal = normalizedLocations.reduce(
(acc, item) => acc + (Number.isNaN(item.available) ? 0 : item.available),
0,
);
const hasProvidedTotal =
total !== undefined &&
total !== null &&
total !== '' &&
!Number.isNaN(providedTotal);
setLocations(normalizedLocations);
setLocationsTotalAvailable(
hasProvidedTotal ? providedTotal : fallbackTotal,
);
return normalizedLocations;
} else {
showError('获取部署位置失败: ' + response.data.message);
setLocationsTotalAvailable(0);
return [];
}
} catch (error) {
showError('获取部署位置失败: ' + error.message);
setLocationsTotalAvailable(0);
return [];
} finally {
setLoadingLocations(false);
}
}, []);
const fetchAvailableReplicas = useCallback(async (hardwareId, gpuCount = 1) => {
if (!hardwareId) {
setAvailableReplicas([]);
return [];
}
try {
setLoadingReplicas(true);
const response = await API.get(
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`
);
if (response.data.success) {
const replicas = response.data.data.replicas || [];
setAvailableReplicas(replicas);
return replicas;
} else {
showError('获取可用资源失败: ' + response.data.message);
setAvailableReplicas([]);
return [];
}
} catch (error) {
console.error('Load available replicas error:', error);
setAvailableReplicas([]);
return [];
} finally {
setLoadingReplicas(false);
}
}, []);
const calculatePrice = useCallback(async (params) => {
const {
locationIds,
hardwareId,
gpusPerContainer,
durationHours,
replicaCount
} = params;
if (!locationIds?.length || !hardwareId || !gpusPerContainer || !durationHours || !replicaCount) {
setPriceEstimation(null);
return null;
}
try {
setLoadingPrice(true);
const requestData = {
location_ids: locationIds,
hardware_id: hardwareId,
gpus_per_container: gpusPerContainer,
duration_hours: durationHours,
replica_count: replicaCount,
};
const response = await API.post('/api/deployments/price-estimation', requestData);
if (response.data.success) {
const estimation = response.data.data;
setPriceEstimation(estimation);
return estimation;
} else {
showError('价格计算失败: ' + response.data.message);
setPriceEstimation(null);
return null;
}
} catch (error) {
console.error('Price calculation error:', error);
setPriceEstimation(null);
return null;
} finally {
setLoadingPrice(false);
}
}, []);
const checkClusterNameAvailability = useCallback(async (name) => {
if (!name?.trim()) return false;
try {
const response = await API.get(`/api/deployments/check-name?name=${encodeURIComponent(name.trim())}`);
if (response.data.success) {
return response.data.data.available;
} else {
showError('检查名称可用性失败: ' + response.data.message);
return false;
}
} catch (error) {
console.error('Check cluster name availability error:', error);
return false;
}
}, []);
const createDeployment = useCallback(async (deploymentData) => {
try {
const response = await API.post('/api/deployments', deploymentData);
if (response.data.success) {
return response.data.data;
} else {
throw new Error(response.data.message || '创建部署失败');
}
} catch (error) {
throw error;
}
}, []);
return {
// Data
hardwareTypes,
hardwareTotalAvailable,
locations,
locationsTotalAvailable,
availableReplicas,
priceEstimation,
// Loading states
loadingHardware,
loadingLocations,
loadingReplicas,
loadingPrice,
// Functions
fetchHardwareTypes,
fetchLocations,
fetchAvailableReplicas,
calculatePrice,
checkClusterNameAvailability,
createDeployment,
// Clear functions
clearPriceEstimation: () => setPriceEstimation(null),
clearAvailableReplicas: () => setAvailableReplicas([]),
};
};
export default useDeploymentResources;

View File

@@ -0,0 +1,507 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useDeploymentsData = () => {
const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('deployments');
// State management
const [deployments, setDeployments] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searching, setSearching] = useState(false);
const [deploymentCount, setDeploymentCount] = useState(0);
// Modal states
const [showEdit, setShowEdit] = useState(false);
const [editingDeployment, setEditingDeployment] = useState({
id: undefined,
});
// Row selection
const [selectedKeys, setSelectedKeys] = useState([]);
const rowSelection = {
getCheckboxProps: (record) => ({
name: record.deployment_name,
}),
selectedRowKeys: selectedKeys.map((deployment) => deployment.id),
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
};
// Form initial values
const formInitValues = {
searchKeyword: '',
searchStatus: '',
};
// ---------- helpers ----------
// Safely extract array items from API payload
const extractItems = (payload) => {
const items = payload?.items || payload || [];
return Array.isArray(items) ? items : [];
};
// Form API reference
const [formApi, setFormApi] = useState(null);
// Get form values helper function
const getFormValues = () => formApi?.getValues() || formInitValues;
// Close edit modal
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingDeployment({ id: undefined });
}, 500);
};
// Set deployment format with key field
const setDeploymentFormat = (deployments) => {
for (let i = 0; i < deployments.length; i++) {
deployments[i].key = deployments[i].id;
}
setDeployments(deployments);
};
// Status tabs
const [activeStatusKey, setActiveStatusKey] = useState('all');
const [statusCounts, setStatusCounts] = useState({});
// Column visibility
const COLUMN_KEYS = useMemo(
() => ({
id: 'id',
status: 'status',
provider: 'provider',
container_name: 'container_name',
time_remaining: 'time_remaining',
hardware_info: 'hardware_info',
created_at: 'created_at',
actions: 'actions',
// Legacy keys for compatibility
deployment_name: 'deployment_name',
model_name: 'model_name',
instance_count: 'instance_count',
resource_config: 'resource_config',
updated_at: 'updated_at',
}),
[],
);
const ensureRequiredColumns = (columns = {}) => {
const normalized = {
...columns,
[COLUMN_KEYS.container_name]: true,
[COLUMN_KEYS.actions]: true,
};
if (normalized[COLUMN_KEYS.provider] === undefined) {
normalized[COLUMN_KEYS.provider] = true;
}
return normalized;
};
const [visibleColumns, setVisibleColumnsState] = useState(() => {
const saved = localStorage.getItem('deployments_visible_columns');
if (saved) {
try {
const parsed = JSON.parse(saved);
return ensureRequiredColumns(parsed);
} catch (e) {
console.error('Failed to parse saved column visibility:', e);
}
}
return ensureRequiredColumns({
[COLUMN_KEYS.container_name]: true,
[COLUMN_KEYS.status]: true,
[COLUMN_KEYS.provider]: true,
[COLUMN_KEYS.time_remaining]: true,
[COLUMN_KEYS.hardware_info]: true,
[COLUMN_KEYS.created_at]: true,
[COLUMN_KEYS.actions]: true,
// Legacy columns (hidden by default)
[COLUMN_KEYS.deployment_name]: false,
[COLUMN_KEYS.model_name]: false,
[COLUMN_KEYS.instance_count]: false,
[COLUMN_KEYS.resource_config]: false,
[COLUMN_KEYS.updated_at]: false,
});
});
// Column selector modal
const [showColumnSelector, setShowColumnSelector] = useState(false);
// Save column visibility to localStorage
const saveColumnVisibility = (newVisibleColumns) => {
const normalized = ensureRequiredColumns(newVisibleColumns);
localStorage.setItem('deployments_visible_columns', JSON.stringify(normalized));
setVisibleColumnsState(normalized);
};
// Load deployments data
const loadDeployments = async (
page = 1,
size = pageSize,
statusKey = activeStatusKey,
) => {
setLoading(true);
try {
let url = `/api/deployments/?p=${page}&page_size=${size}`;
if (statusKey && statusKey !== 'all') {
url = `/api/deployments/search?status=${statusKey}&p=${page}&page_size=${size}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
const newPageData = extractItems(data);
setActivePage(data.page || page);
setDeploymentCount(data.total || newPageData.length);
setDeploymentFormat(newPageData);
if (data.status_counts) {
const sumAll = Object.values(data.status_counts).reduce(
(acc, v) => acc + v,
0,
);
setStatusCounts({ ...data.status_counts, all: sumAll });
}
} else {
showError(message);
setDeployments([]);
}
} catch (error) {
console.error(error);
showError(t('获取部署列表失败'));
setDeployments([]);
}
setLoading(false);
};
// Search deployments
const searchDeployments = async (searchTerms) => {
setSearching(true);
try {
const { searchKeyword, searchStatus } = searchTerms;
const params = new URLSearchParams({
p: '1',
page_size: pageSize.toString(),
});
if (searchKeyword?.trim()) {
params.append('keyword', searchKeyword.trim());
}
if (searchStatus && searchStatus !== 'all') {
params.append('status', searchStatus);
}
const res = await API.get(`/api/deployments/search?${params}`);
const { success, message, data } = res.data;
if (success) {
const items = extractItems(data);
setActivePage(1);
setDeploymentCount(data.total || items.length);
setDeploymentFormat(items);
} else {
showError(message);
setDeployments([]);
}
} catch (error) {
console.error('Search error:', error);
showError(t('搜索失败'));
setDeployments([]);
}
setSearching(false);
};
// Refresh data
const refresh = async (page = activePage) => {
await loadDeployments(page, pageSize);
};
// Handle page change
const handlePageChange = (page) => {
setActivePage(page);
if (!searching) {
loadDeployments(page, pageSize);
}
};
// Handle page size change
const handlePageSizeChange = (size) => {
setPageSize(size);
setActivePage(1);
if (!searching) {
loadDeployments(1, size);
}
};
// Handle tab change
const handleTabChange = (statusKey) => {
setActiveStatusKey(statusKey);
setActivePage(1);
loadDeployments(1, pageSize, statusKey);
};
// Deployment operations
const startDeployment = async (deploymentId) => {
try {
const res = await API.post(`/api/deployments/${deploymentId}/start`);
if (res.data.success) {
showSuccess(t('部署启动成功'));
await refresh();
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('启动部署失败'));
}
};
const restartDeployment = async (deploymentId) => {
try {
const res = await API.post(`/api/deployments/${deploymentId}/restart`);
if (res.data.success) {
showSuccess(t('部署重启成功'));
await refresh();
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('重启部署失败'));
}
};
const deleteDeployment = async (deploymentId) => {
try {
const res = await API.delete(`/api/deployments/${deploymentId}`);
if (res.data.success) {
showSuccess(t('部署删除成功'));
await refresh();
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('删除部署失败'));
}
};
const syncDeploymentToChannel = async (deployment) => {
if (!deployment?.id) {
showError(t('同步渠道失败:缺少部署信息'));
return;
}
try {
const containersResp = await API.get(`/api/deployments/${deployment.id}/containers`);
if (!containersResp.data?.success) {
showError(containersResp.data?.message || t('获取容器信息失败'));
return;
}
const containers = containersResp.data?.data?.containers || [];
const activeContainer = containers.find((ctr) => ctr?.public_url);
if (!activeContainer?.public_url) {
showError(t('未找到可用的容器访问地址'));
return;
}
const rawUrl = String(activeContainer.public_url).trim();
const baseUrl = rawUrl.replace(/\/+$/, '');
if (!baseUrl) {
showError(t('容器访问地址无效'));
return;
}
const baseName = deployment.container_name || deployment.deployment_name || deployment.name || deployment.id;
const safeName = String(baseName || 'ionet').slice(0, 60);
const channelName = `[IO.NET] ${safeName}`;
let randomKey;
try {
randomKey = (typeof crypto !== 'undefined' && crypto.randomUUID)
? `ionet-${crypto.randomUUID().replace(/-/g, '')}`
: null;
} catch (err) {
randomKey = null;
}
if (!randomKey) {
randomKey = `ionet-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
}
const otherInfo = {
source: 'ionet',
deployment_id: deployment.id,
deployment_name: safeName,
container_id: activeContainer.container_id || null,
public_url: baseUrl,
};
const payload = {
mode: 'single',
channel: {
name: channelName,
type: 4,
key: randomKey,
base_url: baseUrl,
group: 'default',
tag: 'ionet',
remark: `[IO.NET] Auto-synced from deployment ${deployment.id}`,
other_info: JSON.stringify(otherInfo),
},
};
const createResp = await API.post('/api/channel/', payload);
if (createResp.data?.success) {
showSuccess(t('已同步到渠道'));
} else {
showError(createResp.data?.message || t('同步渠道失败'));
}
} catch (error) {
console.error(error);
showError(t('同步渠道失败'));
}
};
const updateDeploymentName = async (deploymentId, newName) => {
try {
const res = await API.put(`/api/deployments/${deploymentId}/name`, { name: newName });
if (res.data.success) {
showSuccess(t('部署名称更新成功'));
await refresh();
return true;
} else {
showError(res.data.message);
return false;
}
} catch (error) {
console.error(error);
showError(t('更新部署名称失败'));
return false;
}
};
// Batch operations
const batchDeleteDeployments = async () => {
if (selectedKeys.length === 0) return;
try {
const ids = selectedKeys.map(deployment => deployment.id);
const res = await API.post('/api/deployments/batch_delete', { ids });
if (res.data.success) {
showSuccess(t('批量删除成功'));
setSelectedKeys([]);
await refresh();
} else {
showError(res.data.message);
}
} catch (error) {
console.error(error);
showError(t('批量删除失败'));
}
};
// Table row click handler
const handleRow = (record) => ({
onClick: () => {
// Handle row click if needed
},
});
// Initial load
useEffect(() => {
loadDeployments();
}, []);
return {
// Data
deployments,
loading,
searching,
activePage,
pageSize,
deploymentCount,
statusCounts,
activeStatusKey,
compactMode,
setCompactMode,
// Selection
selectedKeys,
setSelectedKeys,
rowSelection,
// Modals
showEdit,
setShowEdit,
editingDeployment,
setEditingDeployment,
closeEdit,
// Column visibility
visibleColumns,
setVisibleColumns: saveColumnVisibility,
showColumnSelector,
setShowColumnSelector,
COLUMN_KEYS,
// Form
formInitValues,
formApi,
setFormApi,
getFormValues,
// Operations
loadDeployments,
searchDeployments,
refresh,
handlePageChange,
handlePageSizeChange,
handleTabChange,
handleRow,
// Deployment operations
startDeployment,
restartDeployment,
deleteDeployment,
updateDeploymentName,
syncDeploymentToChannel,
// Batch operations
batchDeleteDeployments,
// Translation
t,
};
};

View File

@@ -0,0 +1,249 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react';
import { API, showError, showSuccess } from '../../helpers';
export const useEnhancedDeploymentActions = (t) => {
const [loading, setLoading] = useState({});
// Set loading state for specific operation
const setOperationLoading = (operation, deploymentId, isLoading) => {
setLoading(prev => ({
...prev,
[`${operation}_${deploymentId}`]: isLoading
}));
};
// Get loading state for specific operation
const isOperationLoading = (operation, deploymentId) => {
return loading[`${operation}_${deploymentId}`] || false;
};
// Extend deployment duration
const extendDeployment = async (deploymentId, durationHours) => {
const operationKey = `extend_${deploymentId}`;
try {
setOperationLoading('extend', deploymentId, true);
const response = await API.post(`/api/deployments/${deploymentId}/extend`, {
duration_hours: durationHours
});
if (response.data.success) {
showSuccess(t('容器时长延长成功'));
return response.data.data;
}
} catch (error) {
showError(t('延长时长失败') + ': ' + (error.response?.data?.message || error.message));
throw error;
} finally {
setOperationLoading('extend', deploymentId, false);
}
};
// Get deployment details
const getDeploymentDetails = async (deploymentId) => {
try {
setOperationLoading('details', deploymentId, true);
const response = await API.get(`/api/deployments/${deploymentId}`);
if (response.data.success) {
return response.data.data;
}
} catch (error) {
showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message));
throw error;
} finally {
setOperationLoading('details', deploymentId, false);
}
};
// Get deployment logs
const getDeploymentLogs = async (deploymentId, options = {}) => {
try {
setOperationLoading('logs', deploymentId, true);
const params = new URLSearchParams();
if (options.containerId) params.append('container_id', options.containerId);
if (options.level) params.append('level', options.level);
if (options.limit) params.append('limit', options.limit.toString());
if (options.cursor) params.append('cursor', options.cursor);
if (options.follow) params.append('follow', 'true');
if (options.startTime) params.append('start_time', options.startTime);
if (options.endTime) params.append('end_time', options.endTime);
const response = await API.get(`/api/deployments/${deploymentId}/logs?${params}`);
if (response.data.success) {
return response.data.data;
}
} catch (error) {
showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message));
throw error;
} finally {
setOperationLoading('logs', deploymentId, false);
}
};
// Update deployment configuration
const updateDeploymentConfig = async (deploymentId, config) => {
try {
setOperationLoading('config', deploymentId, true);
const response = await API.put(`/api/deployments/${deploymentId}`, config);
if (response.data.success) {
showSuccess(t('容器配置更新成功'));
return response.data.data;
}
} catch (error) {
showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message));
throw error;
} finally {
setOperationLoading('config', deploymentId, false);
}
};
// Delete (destroy) deployment
const deleteDeployment = async (deploymentId) => {
try {
setOperationLoading('delete', deploymentId, true);
const response = await API.delete(`/api/deployments/${deploymentId}`);
if (response.data.success) {
showSuccess(t('容器销毁请求已提交'));
return response.data.data;
}
} catch (error) {
showError(t('销毁容器失败') + ': ' + (error.response?.data?.message || error.message));
throw error;
} finally {
setOperationLoading('delete', deploymentId, false);
}
};
// Update deployment name
const updateDeploymentName = async (deploymentId, newName) => {
try {
setOperationLoading('rename', deploymentId, true);
const response = await API.put(`/api/deployments/${deploymentId}/name`, {
name: newName
});
if (response.data.success) {
showSuccess(t('容器名称更新成功'));
return response.data.data;
}
} catch (error) {
showError(t('更新名称失败') + ': ' + (error.response?.data?.message || error.message));
throw error;
} finally {
setOperationLoading('rename', deploymentId, false);
}
};
// Batch operations
const batchDelete = async (deploymentIds) => {
try {
setOperationLoading('batch_delete', 'all', true);
const results = await Promise.allSettled(
deploymentIds.map(id => deleteDeployment(id))
);
const successful = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
if (successful > 0) {
showSuccess(t('批量操作完成: {{success}}个成功, {{failed}}个失败', {
success: successful,
failed: failed
}));
}
return { successful, failed };
} catch (error) {
showError(t('批量操作失败') + ': ' + error.message);
throw error;
} finally {
setOperationLoading('batch_delete', 'all', false);
}
};
// Export logs
const exportLogs = async (deploymentId, options = {}) => {
try {
setOperationLoading('export_logs', deploymentId, true);
const logs = await getDeploymentLogs(deploymentId, {
...options,
limit: 10000 // Get more logs for export
});
if (logs && logs.logs) {
const logText = logs.logs.map(log =>
`[${new Date(log.timestamp).toISOString()}] [${log.level}] ${log.source ? `[${log.source}] ` : ''}${log.message}`
).join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `deployment-${deploymentId}-logs-${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('日志导出成功'));
}
} catch (error) {
showError(t('导出日志失败') + ': ' + error.message);
throw error;
} finally {
setOperationLoading('export_logs', deploymentId, false);
}
};
return {
// Actions
extendDeployment,
getDeploymentDetails,
getDeploymentLogs,
updateDeploymentConfig,
deleteDeployment,
updateDeploymentName,
batchDelete,
exportLogs,
// Loading states
isOperationLoading,
loading,
// Utility
setOperationLoading
};
};
export default useEnhancedDeploymentActions;

View File

@@ -0,0 +1,143 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useState } from 'react';
import { API, toBoolean } from '../../helpers';
export const useModelDeploymentSettings = () => {
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState({
'model_deployment.ionet.enabled': false,
'model_deployment.ionet.api_key': '',
});
const [connectionState, setConnectionState] = useState({
loading: false,
ok: null,
error: null,
});
const getSettings = async () => {
try {
setLoading(true);
const res = await API.get('/api/option/');
const { success, data } = res.data;
if (success) {
const newSettings = {
'model_deployment.ionet.enabled': false,
'model_deployment.ionet.api_key': '',
};
data.forEach((item) => {
if (item.key.endsWith('enabled')) {
newSettings[item.key] = toBoolean(item.value);
} else if (newSettings.hasOwnProperty(item.key)) {
newSettings[item.key] = item.value || '';
}
});
setSettings(newSettings);
}
} catch (error) {
console.error('Failed to get model deployment settings:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getSettings();
}, []);
const apiKey = settings['model_deployment.ionet.api_key'];
const isIoNetEnabled = settings['model_deployment.ionet.enabled'] &&
apiKey &&
apiKey.trim() !== '';
const buildConnectionError = (rawMessage, fallbackMessage = 'Connection failed') => {
const message = (rawMessage || fallbackMessage).trim();
const normalized = message.toLowerCase();
if (normalized.includes('expired') || normalized.includes('expire')) {
return { type: 'expired', message };
}
if (normalized.includes('invalid') || normalized.includes('unauthorized') || normalized.includes('api key')) {
return { type: 'invalid', message };
}
if (normalized.includes('network') || normalized.includes('timeout')) {
return { type: 'network', message };
}
return { type: 'unknown', message };
};
const testConnection = useCallback(async (apiKey) => {
const key = (apiKey || '').trim();
if (key === '') {
setConnectionState({ loading: false, ok: null, error: null });
return;
}
setConnectionState({ loading: true, ok: null, error: null });
try {
const response = await API.post(
'/api/deployments/test-connection',
{ api_key: key },
{ skipErrorHandler: true },
);
if (response?.data?.success) {
setConnectionState({ loading: false, ok: true, error: null });
return;
}
const message = response?.data?.message || 'Connection failed';
setConnectionState({ loading: false, ok: false, error: buildConnectionError(message) });
} catch (error) {
if (error?.code === 'ERR_NETWORK') {
setConnectionState({
loading: false,
ok: false,
error: { type: 'network', message: 'Network connection failed' },
});
return;
}
const rawMessage = error?.response?.data?.message || error?.message || 'Unknown error';
setConnectionState({ loading: false, ok: false, error: buildConnectionError(rawMessage, 'Connection failed') });
}
}, []);
useEffect(() => {
if (!loading && isIoNetEnabled) {
testConnection(apiKey);
return;
}
setConnectionState({ loading: false, ok: null, error: null });
}, [loading, isIoNetEnabled, apiKey, testConnection]);
return {
loading,
settings,
apiKey,
isIoNetEnabled,
refresh: getSettings,
connectionLoading: connectionState.loading,
connectionOk: connectionState.ok,
connectionError: connectionState.error,
testConnection,
};
};

View File

@@ -42,6 +42,7 @@ i18n
vi: viTranslation,
},
fallbackLng: 'zh',
nsSeparator: false,
interpolation: {
escapeValue: false,
},

View File

@@ -153,6 +153,7 @@
"URL链接": "URL Link",
"USD (美元)": "USD (US Dollar)",
"User Info Endpoint": "User Info Endpoint",
"Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI does not support the functionResponse.id field. When enabled, this field will be automatically removed",
"Webhook 密钥": "Webhook Secret",
"Webhook 签名密钥": "Webhook Signature Key",
"Webhook地址": "Webhook URL",
@@ -839,6 +840,9 @@
"开启后对免费模型倍率为0或者价格为0的模型也会预消耗额度": "After enabling, free models (ratio 0 or price 0) will also pre-consume quota",
"开启后将定期发送ping数据保持连接活跃": "After enabling, ping data will be sent periodically to keep the connection active",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "When enabled, all requests will be directly forwarded to the upstream without any processing (redirects and channel adaptation will also be disabled). Please enable with caution.",
"该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Request pass-through is enabled for this channel. Built-in NewAPI features such as parameter overrides, model redirection, and channel adaptation will be disabled. This is not a best practice. If this causes issues, please do not submit an issue.",
"已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Global request pass-through is enabled. Built-in NewAPI features such as parameter overrides, model redirection, and channel adaptation will be disabled. This is not a best practice. If this causes issues, please do not submit an issue.",
"该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Request pass-through is enabled for this channel; built-in NewAPI features such as parameter overrides and model redirection will be disabled. This is not a best practice.",
"开启后不限制:必须设置模型倍率": "After enabling, no limit: must set model ratio",
"开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace",
"开启批量操作": "Enable batch selection",
@@ -1510,6 +1514,7 @@
"私有IP访问详细说明": "⚠️ Security Warning: Enabling this allows access to internal network resources (localhost, private networks). Only enable if you need to access internal services and understand the security implications.",
"私有部署地址": "Private Deployment Address",
"秒": "Second",
"移除 functionResponse.id 字段": "Remove functionResponse.id Field",
"移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Removal of One API copyright mark must first be authorized. Project maintenance requires a lot of effort. If this project is meaningful to you, please actively support it.",
"窗口处理": "window handling",
"窗口等待": "window wait",

View File

@@ -47,12 +47,12 @@
"API Key 模式下不支持批量创建": "Création en lot non prise en charge en mode clé API",
"API 地址和相关配置": "URL de l'API et configuration associée",
"API 密钥": "Clé API",
"API 文档": "Documentation de l'API",
"API 配置": "Configuration de l'API",
"API令牌管理": "Gestion des jetons d'API",
"API使用记录": "Enregistrements d'utilisation de l'API",
"API 文档": "Docs API",
"API 配置": "Config. API",
"API令牌管理": "Jetons API",
"API使用记录": "Journaux d'API",
"API信息": "Informations sur l'API",
"API信息管理可以配置多个API地址用于状态展示和负载均衡最多50个": "Gestion des informations de l'API, vous pouvez configurer plusieurs adresses d'API pour l'affichage de l'état et l'équilibrage de charge (maximum 50)",
"API信息管理可以配置多个API地址用于状态展示和负载均衡最多50个": "Infos API, vous pouvez configurer plusieurs adresses d'API pour l'affichage de l'état et l'équilibrage de charge (maximum 50)",
"API地址": "URL de base",
"API渠道配置": "Configuration du canal de l'API",
"API端点": "Points de terminaison de l'API",
@@ -112,7 +112,7 @@
"LinuxDO": "LinuxDO",
"LinuxDO ID": "ID LinuxDO",
"Logo 图片地址": "Adresse de l'image du logo",
"Midjourney 任务记录": "Enregistrements de tâches Midjourney",
"Midjourney 任务记录": "Tâches Midjourney",
"MIT许可证": "Licence MIT",
"New API项目仓库地址": "Adresse du référentiel du projet New API : ",
"OIDC": "OIDC",
@@ -136,7 +136,7 @@
"SMTP 访问凭证": "Informations d'identification d'accès SMTP",
"SMTP 账户": "Compte SMTP",
"SSRF防护开关详细说明": "L'interrupteur principal contrôle si la protection SSRF est activée. Lorsqu'elle est désactivée, toutes les vérifications SSRF sont contournées, autorisant l'accès à n'importe quelle URL. ⚠️ Ne désactivez cette fonctionnalité que dans des environnements entièrement fiables.",
"SSRF防护设置": "Paramètres de protection SSRF",
"SSRF防护设置": "Protection SSRF",
"SSRF防护详细说明": "La protection SSRF empêche les utilisateurs malveillants d'utiliser votre serveur pour accéder aux ressources du réseau interne. Configurez des listes blanches pour les domaines/IP de confiance et limitez les ports autorisés. S'applique aux téléchargements de fichiers, aux webhooks et aux notifications.",
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex",
"Stripe 设置": "Paramètres Stripe",
@@ -150,10 +150,11 @@
"Turnstile Site Key": "Clé du site Turnstile",
"Unix时间戳": "Horodatage Unix",
"Uptime Kuma地址": "Adresse Uptime Kuma",
"Uptime Kuma监控分类管理可以配置多个监控分类用于服务状态展示最多20个": "Gestion des catégories de surveillance Uptime Kuma, vous pouvez configurer plusieurs catégories de surveillance pour l'affichage de l'état du service (maximum 20)",
"Uptime Kuma监控分类管理可以配置多个监控分类用于服务状态展示最多20个": "Catégories de surveillance Uptime Kuma, vous pouvez configurer plusieurs catégories de surveillance pour l'affichage de l'état du service (maximum 20)",
"URL链接": "Lien URL",
"USD (美元)": "USD (Dollar US)",
"User Info Endpoint": "Point de terminaison des informations utilisateur",
"Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI ne prend pas en charge le champ functionResponse.id. Lorsqu'il est activé, ce champ sera automatiquement supprimé",
"Webhook 密钥": "Clé Webhook",
"Webhook 签名密钥": "Clé de signature Webhook",
"Webhook地址": "URL du Webhook",
@@ -203,9 +204,9 @@
"个": " individuel",
"个人中心": "Centre personnel",
"个人中心区域": "Zone du centre personnel",
"个人信息设置": "Paramètres des informations personnelles",
"个人设置": "Paramètres personnels",
"个性化设置": "Paramètres de personnalisation",
"个人信息设置": "Infos personnelles",
"个人设置": "Profil",
"个性化设置": "Personnalisation",
"个性化设置左侧边栏的显示内容": "Personnaliser le contenu affiché dans la barre latérale gauche",
"个未配置模型": "modèles non configurés",
"个模型": "modèles",
@@ -263,26 +264,26 @@
"令牌已重置并已复制到剪贴板": "Le jeton a été réinitialisé et copié dans le presse-papiers",
"令牌更新成功!": "Jeton mis à jour avec succès !",
"令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制": "Le quota du jeton est uniquement utilisé pour limiter l'utilisation maximale du quota du jeton lui-même, et l'utilisation réelle est limitée par le quota restant du compte",
"令牌管理": "Gestion des jetons",
"令牌管理": "Jetons",
"以下上游数据可能不可信:": "Les données en amont suivantes peuvent ne pas être fiables : ",
"以下文件解析失败,已忽略:{{list}}": "L'analyse des fichiers suivants a échoué, ignorés : {{list}}",
"以及": "et",
"仪表盘设置": "Paramètres du tableau de bord",
"仪表盘设置": "Tableau de bord",
"价格": "Tarifs",
"价格:${{price}} * {{ratioType}}{{ratio}}": "Prix : ${{price}} * {{ratioType}} : {{ratio}}",
"价格设置": "Paramètres de prix",
"价格设置": "Prix",
"价格设置方式": "Méthode de configuration des prix",
"任务 ID": "ID de la tâche",
"任务ID": "ID de la tâche",
"任务日志": "Journaux de tâches",
"任务日志": "Tâches",
"任务状态": "Statut de la tâche",
"任务记录": "Enregistrements de tâches",
"任务记录": "Tâches",
"企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选": "Les comptes d'entreprise ont un format de retour spécial et nécessitent un traitement particulier. Si ce n'est pas un compte d'entreprise, veuillez ne pas cocher cette case.",
"优先级": "Priorité",
"优惠": "Remise",
"低于此额度时将发送邮件提醒用户": "Un rappel par e-mail sera envoyé lorsque le quota tombera en dessous de ce seuil",
"余额": "Solde",
"余额充值管理": "Gestion de la recharge du solde",
"余额充值管理": "Recharge du solde",
"你似乎并没有修改什么": "Vous ne semblez rien avoir modifié",
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "Vous pouvez les ajouter manuellement dans « Noms de modèles personnalisés », cliquer sur Remplir puis soumettre, ou utiliser directement les actions ci-dessous pour les traiter automatiquement.",
"使用 Discord 继续": "Continuer avec Discord",
@@ -297,7 +298,7 @@
"使用 用户名 注册": "S'inscrire avec un nom d'utilisateur",
"使用 邮箱或用户名 登录": "Connectez-vous avec votre e-mail ou votre nom d'utilisateur",
"使用ID排序": "Trier par ID",
"使用日志": "Journaux d'utilisation",
"使用日志": "Journaux",
"使用模式": "Mode d'utilisation",
"使用统计": "Statistiques d'utilisation",
"使用认证器应用(如 Google Authenticator、Microsoft Authenticator扫描下方二维码": "Utilisez une application d'authentification (telle que Google Authenticator, Microsoft Authenticator) pour scanner le code QR ci-dessous :",
@@ -327,7 +328,7 @@
"供应商名称": "Nom du fournisseur",
"供应商图标": "Icône du fournisseur",
"供应商更新成功!": "Fournisseur mis à jour avec succès !",
"侧边栏管理(全局控制)": "Gestion de la barre latérale (contrôle global)",
"侧边栏管理(全局控制)": "Barre latérale (Global)",
"侧边栏设置保存成功": "Paramètres de la barre latérale enregistrés avec succès",
"保存": "Enregistrer",
"保存 Discord OAuth 设置": "Enregistrer les paramètres OAuth Discord",
@@ -401,7 +402,7 @@
"充值数量": "Quantité de recharge",
"充值数量,最低 ": "Quantité de recharge, minimum ",
"充值数量不能小于": "Le montant de la recharge ne peut pas être inférieur à",
"充值方式设置": "Paramètres de la méthode de recharge",
"充值方式设置": "Méthodes recharge",
"充值方式设置不是合法的 JSON 字符串": "Les paramètres de la méthode de recharge ne sont pas une chaîne JSON valide",
"充值确认": "Confirmation de la recharge",
"充值账单": "Factures de recharge",
@@ -417,8 +418,8 @@
"兑换码创建成功!": "Code d'échange créé avec succès !",
"兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "Le code d'échange sera téléchargé sous forme de fichier texte, le nom de fichier étant le nom du code d'échange.",
"兑换码更新成功!": "Code d'échange mis à jour avec succès !",
"兑换码生成管理": "Gestion de la génération de codes d'échange",
"兑换码管理": "Gestion des codes d'échange",
"兑换码生成管理": "Génération de codes",
"兑换码管理": "Codes d'échange",
"兑换额度": "Utiliser",
"全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用": "Contrôle global des zones et des fonctions de la barre latérale, les utilisateurs ne peuvent pas activer les fonctions masquées par les administrateurs",
"全局设置": "Paramètres globaux",
@@ -447,7 +448,7 @@
"共 {{total}} 项,当前显示 {{start}}-{{end}} 项": "Total {{total}} éléments, affichage actuel {{start}}-{{end}} éléments",
"关": "Fermer",
"关于": "À propos",
"关于我们": "À propos de nous",
"关于我们": "Nous",
"关于系统的详细信息": "Informations détaillées sur le système",
"关于项目": "À propos du projet",
"关键字(id或者名称)": "Mot-clé (id ou nom)",
@@ -459,7 +460,7 @@
"其他": "Autre",
"其他注册选项": "Autres options d'inscription",
"其他登录选项": "Autres options de connexion",
"其他设置": "Autres paramètres",
"其他设置": "Autres",
"其他详情": "Autres détails",
"内容": "Contenu",
"内容较大,已启用性能优化模式": "Le contenu est volumineux, le mode d'optimisation des performances a été activé",
@@ -471,14 +472,14 @@
"准备完成初始化": "Prêt à terminer l'initialisation",
"分类名称": "Nom de la catégorie",
"分组": "Groupe",
"分组与模型定价设置": "Paramètres de groupe et de tarification du modèle",
"分组与模型定价设置": "Groupe et tarification",
"分组价格": "Prix de groupe",
"分组倍率": "Ratio",
"分组倍率设置": "Paramètres de ratio de groupe",
"分组倍率设置": "Ratio de groupe",
"分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{\"vip\": 0.5, \"test\": 1},表示 vip 分组的倍率为 0.5test 分组的倍率为 1": "Paramètres de ratio de groupe, vous pouvez ajouter de nouveaux groupes ou modifier le ratio des groupes existants ici, au format de chaîne JSON, par exemple : {\"vip\": 0,5, \"test\": 1}, ce qui signifie que le ratio du groupe vip est 0,5 et celui du groupe test est 1",
"分组特殊倍率": "Ratio spécial de groupe",
"分组特殊可用分组": "Groupes spéciaux disponibles",
"分组设置": "Paramètres de groupe",
"分组设置": "Groupe",
"分组速率配置优先级高于全局速率限制。": "La priorité de configuration du taux de groupe est supérieure à la limite de taux globale.",
"分组速率限制": "Limitation du taux de groupe",
"分钟": "minutes",
@@ -491,7 +492,7 @@
"划转金额最低为": "Le montant minimum du virement est de",
"划转额度": "Montant du virement",
"列出的模型将不会自动添加或移除-thinking/-nothinking 后缀": "Les modèles listés ici n'ajouteront ni ne retireront automatiquement le suffixe -thinking/-nothinking.",
"列设置": "Paramètres de colonne",
"列设置": "Colonnes",
"创建令牌默认选择auto分组初始令牌也将设为auto否则留空为用户默认分组": "Lors de la création d'un jeton, le groupe auto est sélectionné par défaut, et le jeton initial sera également défini sur auto (sinon laisser vide, pour le groupe par défaut de l'utilisateur)",
"创建失败": "Échec de la création",
"创建成功": "Création réussie",
@@ -570,7 +571,7 @@
"可用端点类型": "Types de points de terminaison pris en charge",
"可用邀请额度": "Quota d'invitation disponible",
"可视化": "Visualisation",
"可视化倍率设置": "Paramètres de ratio de modèle visuel",
"可视化倍率设置": "Ratio visuel",
"可视化编辑": "Édition visuelle",
"可选,公告的补充说明": "Facultatif, informations supplémentaires pour l'avis",
"可选值": "Valeur facultative",
@@ -696,7 +697,7 @@
"字段透传控制": "Contrôle du passage des champs",
"存在重复的键名:": "Il existe des noms de clés en double :",
"安全提醒": "Rappel de sécurité",
"安全设置": "Paramètres de sécurité",
"安全设置": "Sécurité",
"安全验证": "Vérification de sécurité",
"安全验证级别": "Niveau de vérification de la sécurité",
"安装指南": "Guide d'installation",
@@ -719,7 +720,7 @@
"密码修改成功!": "Mot de passe changé avec succès !",
"密码已复制到剪贴板:": "Le mot de passe a été copié dans le presse-papiers : ",
"密码已重置并已复制到剪贴板:": "Le mot de passe a été réinitialisé et copié dans le presse-papiers : ",
"密码管理": "Gestion des mots de passe",
"密码管理": "Mots de passe",
"密码重置": "Réinitialisation du mot de passe",
"密码重置完成": "Réinitialisation du mot de passe terminée",
"密码重置确认": "Confirmation de la réinitialisation du mot de passe",
@@ -761,8 +762,8 @@
"小时": "Heure",
"尚未使用": "Pas encore utilisé",
"局部重绘-提交": "Varier la région",
"屏蔽词列表": "Liste des mots sensibles",
"屏蔽词过滤设置": "Paramètres de filtrage des mots sensibles",
"屏蔽词列表": "Mots sensibles",
"屏蔽词过滤设置": "Filtrage mots sensibles",
"展开": "Développer",
"展开更多": "Développer plus",
"展示价格": "Prix affiché",
@@ -847,6 +848,9 @@
"开启后对免费模型倍率为0或者价格为0的模型也会预消耗额度": "Après activation, les modèles gratuits (ratio 0 ou prix 0) préconsommeront également du quota",
"开启后将定期发送ping数据保持连接活跃": "Après activation, des données ping seront envoyées périodiquement pour maintenir la connexion active",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Après activation, toutes les requêtes seront directement transmises en amont sans aucun traitement (la redirection et l'adaptation de canal seront également désactivées), veuillez activer avec prudence",
"该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "La transmission des requêtes est activée pour ce canal. Les fonctionnalités intégrées de NewAPI (surcharge des paramètres, redirection de modèle, adaptation du canal, etc.) seront désactivées. Ce n'est pas une bonne pratique. Si cela cause des problèmes, merci de ne pas ouvrir d'issue.",
"已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "La transmission globale des requêtes est activée. Les fonctionnalités intégrées de NewAPI (surcharge des paramètres, redirection de modèle, adaptation du canal, etc.) seront désactivées. Ce n'est pas une bonne pratique. Si cela cause des problèmes, merci de ne pas ouvrir d'issue.",
"该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "La transmission des requêtes est activée pour ce canal ; les fonctionnalités intégrées de NewAPI (comme la surcharge des paramètres et la redirection de modèle) seront désactivées. Ce n'est pas une bonne pratique.",
"开启后不限制:必须设置模型倍率": "Après l'activation, aucune limite : le ratio de modèle doit être défini",
"开启后未登录用户无法访问模型广场": "Lorsqu'il est activé, les utilisateurs non authentifiés ne peuvent pas accéder à la place du marché des modèles",
"开启批量操作": "Activer la sélection par lots",
@@ -997,7 +1001,7 @@
"支付地址": "Adresse de paiement",
"支付宝": "Alipay",
"支付方式": "Mode de paiement",
"支付设置": "Paramètres de paiement",
"支付设置": "Paiement",
"支付请求失败": "Échec de la demande de paiement",
"支付金额": "Montant payé",
"支持6位TOTP验证码或8位备用码可到`个人设置-安全设置-两步验证设置`配置或查看。": "Prend en charge le code de vérification TOTP à 6 chiffres ou le code de sauvegarde à 8 chiffres, peut être configuré ou consulté dans `Paramètres personnels - Paramètres de sécurité - Paramètres d'authentification à deux facteurs`.",
@@ -1027,9 +1031,9 @@
"数据格式错误": "Erreur de format de données",
"数据看板": "Tableau de bord",
"数据看板更新间隔": "Intervalle de mise à jour du tableau de bord des données",
"数据看板设置": "Paramètres du tableau de bord des données",
"数据看板设置": "Tableau de bord",
"数据看板默认时间粒度": "Granularité temporelle par défaut du tableau de bord des données",
"数据管理和日志查看": "Gestion des données et affichage des journaux",
"数据管理和日志查看": "Données et journaux",
"文件上传": "Téléchargement de fichier",
"文件搜索价格:{{symbol}}{{price}} / 1K 次": "Prix de recherche de fichier : {{symbol}}{{price}} / 1K fois",
"文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}": "Invite texte {{input}} tokens / 1M tokens * {{symbol}}{{price}} + Complétion texte {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}",
@@ -1065,7 +1069,7 @@
"无限额度": "Quota illimité",
"日志清理失败:": "Échec du nettoyage des journaux :",
"日志类型": "Type de journal",
"日志设置": "Paramètres du journal",
"日志设置": "Config. journaux",
"日志详情": "Détails du journal",
"旧格式(直接覆盖):": "Ancien format (remplacement direct) :",
"旧格式模板": "Modèle d'ancien format",
@@ -1219,7 +1223,7 @@
"模型倍率值": "Valeur du ratio de modèle",
"模型倍率和补全倍率": "Ratio de modèle et ratio de complétion",
"模型倍率和补全倍率同时设置": "Le ratio de modèle et le ratio de complétion sont définis simultanément",
"模型倍率设置": "Paramètres de ratio de modèle",
"模型倍率设置": "Ratio modèle",
"模型关键字": "mot-clé du modèle",
"模型列表已复制到剪贴板": "Liste des modèles copiée dans le presse-papiers",
"模型列表已更新": "La liste des modèles a été mise à jour",
@@ -1229,7 +1233,7 @@
"模型固定价格": "Prix du modèle par appel",
"模型图标": "Icône du modèle",
"模型定价,需要登录访问": "Tarification du modèle, nécessite une connexion pour y accéder",
"模型广场": "Place du marché des modèles",
"模型广场": "Marché des modèles",
"模型支持的接口端点信息": "Informations sur les points de terminaison de l'API pris en charge par le modèle",
"模型数据分析": "Analyse des données du modèle",
"模型映射必须是合法的 JSON 格式!": "Le mappage de modèles doit être au format JSON valide !",
@@ -1241,7 +1245,7 @@
"模型的详细描述和基本特性": "Description détaillée et caractéristiques de base du modèle",
"模型相关设置": "Paramètres liés au modèle",
"模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "La communauté des modèles a besoin de la contribution de tous. Si vous trouvez des données incorrectes ou si vous souhaitez contribuer à de nouvelles données de modèle, veuillez visiter :",
"模型管理": "Gestion des modèles",
"模型管理": "Modèles",
"模型组": "Groupe de modèles",
"模型补全倍率(仅对自定义模型有效)": "Ratio d'achèvement de modèle (uniquement efficace pour les modèles personnalisés)",
"模型请求速率限制": "Limite de débit de requête de modèle",
@@ -1367,7 +1371,7 @@
"渠道的基本配置信息": "Informations de configuration de base du canal",
"渠道的模型测试": "Test de modèle de canal",
"渠道的高级配置选项": "Options de configuration avancées du canal",
"渠道管理": "Gestion des canaux",
"渠道管理": "Canaux",
"渠道额外设置": "Paramètres supplémentaires du canal",
"源地址": "Adresse source",
"演示站点": "Site de démonstration",
@@ -1410,7 +1414,7 @@
"用户信息": "Informations utilisateur",
"用户信息更新成功!": "Informations utilisateur mises à jour avec succès !",
"用户分组": "Votre groupe par défaut",
"用户分组和额度管理": "Gestion des groupes d'utilisateurs et des quotas",
"用户分组和额度管理": "Groupes et quotas",
"用户分组配置": "Configuration du groupe d'utilisateurs",
"用户协议": "Accord utilisateur",
"用户协议已更新": "L'accord utilisateur a été mis à jour",
@@ -1425,10 +1429,10 @@
"用户每周期最多请求次数": "Nombre maximal de requêtes utilisateur par période",
"用户注册时看到的网站名称,比如'我的网站'": "Nom du site Web que les utilisateurs voient lors de l'inscription, par exemple 'Mon site Web'",
"用户的基本账户信息": "Informations de base du compte utilisateur",
"用户管理": "Gestion des utilisateurs",
"用户管理": "Utilisateurs",
"用户组": "Groupe d'utilisateurs",
"用户账户创建成功!": "Compte utilisateur créé avec succès !",
"用户账户管理": "Gestion des comptes utilisateurs",
"用户账户管理": "Comptes utilisateurs",
"用时/首字": "Temps/premier mot",
"留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée",
"留空则使用默认端点;支持 {path, method}": "Laissez vide pour utiliser le point de terminaison par défaut ; prend en charge {path, method}",
@@ -1439,7 +1443,7 @@
"登录过期,请重新登录!": "Session expirée, veuillez vous reconnecter !",
"白名单": "Liste blanche",
"的前提下使用。": "doit être utilisé conformément aux conditions.",
"监控设置": "Paramètres de surveillance",
"监控设置": "Surveillance",
"目标用户:{{username}}": "Utilisateur cible : {{username}}",
"直接提交": "Soumettre directement",
"相关项目": "Projets connexes",
@@ -1520,6 +1524,7 @@
"私有IP访问详细说明": "⚠️ Avertissement de sécurité : l'activation de cette option autorise l'accès aux ressources du réseau interne (localhost, réseaux privés). N'activez cette option que si vous devez accéder à des services internes et que vous comprenez les implications en matière de sécurité.",
"私有部署地址": "Adresse de déploiement privée",
"秒": "Seconde",
"移除 functionResponse.id 字段": "Supprimer le champ functionResponse.id",
"移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "La suppression de la marque de copyright de One API doit d'abord être autorisée. La maintenance du projet demande beaucoup d'efforts. Si ce projet a du sens pour vous, veuillez le soutenir activement.",
"窗口处理": "gestion des fenêtres",
"窗口等待": "attente de la fenêtre",
@@ -1552,14 +1557,14 @@
"精确": "Exact",
"系统": "Système",
"系统令牌已复制到剪切板": "Le jeton système a été copié dans le presse-papiers",
"系统任务记录": "Enregistrements de tâches système",
"系统任务记录": "Tâches système",
"系统信息": "Informations système",
"系统公告": "Avis système",
"系统公告管理可以发布系统通知和重要消息最多100个前端显示最新20条": "Gestion des avis système, vous pouvez publier des avis système et des messages importants (maximum 100, afficher les 20 derniers sur le front-end)",
"系统公告管理可以发布系统通知和重要消息最多100个前端显示最新20条": "Avis système, vous pouvez publier des avis système et des messages importants (maximum 100, afficher les 20 derniers sur le front-end)",
"系统初始化": "Initialisation du système",
"系统初始化失败,请重试": "L'initialisation du système a échoué, veuillez réessayer",
"系统初始化成功,正在跳转...": "Initialisation du système réussie, redirection en cours...",
"系统参数配置": "Configuration des paramètres système",
"系统参数配置": "Paramètres système",
"系统名称": "Nom du système",
"系统名称已更新": "Nom du système mis à jour",
"系统名称更新失败": "Échec de la mise à jour du nom du système",
@@ -1570,7 +1575,7 @@
"系统文档和帮助信息": "Documentation système et informations d'aide",
"系统消息": "Messages système",
"系统管理功能": "Fonctions de gestion du système",
"系统设置": "Paramètres système",
"系统设置": "Système",
"系统访问令牌": "Jeton d'accès au système",
"约": "Environ",
"索引": "Index",
@@ -1589,9 +1594,9 @@
"结束时间": "Heure de fin",
"结果图片": "Résultat",
"绘图": "Dessin",
"绘图任务记录": "Enregistrements de tâches de dessin",
"绘图日志": "Journaux de dessin",
"绘图设置": "Paramètres de dessin",
"绘图任务记录": "Tâches dessin",
"绘图日志": "Dessins",
"绘图设置": "Dessin",
"统一的": "La Passerelle",
"统计Tokens": "Jetons statistiques",
"统计次数": "Nombre de statistiques",
@@ -1638,11 +1643,11 @@
"置信度": "Confiance",
"美元": "Dollar américain",
"聊天": "Discuter",
"聊天会话管理": "Gestion des sessions de discussion",
"聊天会话管理": "Sessions de discussion",
"聊天区域": "Zone de discussion",
"聊天应用名称": "Nom de l'application de discussion",
"聊天应用名称已存在,请使用其他名称": "Le nom de l'application de discussion existe déjà, veuillez utiliser un autre nom",
"聊天设置": "Paramètres de discussion",
"聊天设置": "Discussion",
"聊天配置": "Configuration de la discussion",
"聊天链接配置错误,请联系管理员": "Erreur de configuration du lien de discussion, veuillez contacter l'administrateur",
"联系我们": "Contactez-nous",
@@ -1986,19 +1991,19 @@
"输出价格": "Prix de sortie",
"输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})": "Prix de sortie : {{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (ratio d'achèvement : {{completionRatio}})",
"输出倍率 {{completionRatio}}": "Ratio de sortie {{completionRatio}}",
"边栏设置": "Paramètres de la barre latérale",
"边栏设置": "Barre latérale",
"过期时间": "Date d'expiration",
"过期时间不能早于当前时间!": "La date d'expiration ne peut pas être antérieure à l'heure actuelle !",
"过期时间快捷设置": "Paramètres rapides de la date d'expiration",
"过期时间格式错误!": "Erreur de format de la date d'expiration !",
"运营设置": "Paramètres de fonctionnement",
"运营设置": "Opérations",
"返回修改": "Revenir pour modifier",
"返回登录": "Retour à la connexion",
"这是重复键中的最后一个,其值将被使用": "Ceci est la dernière clé dupliquée, sa valeur sera utilisée",
"进度": "calendrier",
"进行中": "En cours",
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "Lors de cette opération, cela peut entraîner des erreurs d'accès au canal. Veuillez ne l'utiliser que lorsqu'il y a un problème avec la base de données.",
"连接保活设置": "Paramètres de maintien de connexion",
"连接保活设置": "Maintien connexion",
"连接已断开": "Connexion interrompue",
"追加到现有密钥": "Ajouter aux clés existantes",
"追加模式:将新密钥添加到现有密钥列表末尾": "Mode d'ajout : ajouter les nouvelles clés à la fin de la liste de clés existantes",
@@ -2030,7 +2035,7 @@
"选择过期时间(可选,留空为永久)": "Sélectionnez la date d'expiration (facultatif, laissez vide pour permanent)",
"透传请求体": "Corps de transmission",
"通义千问": "Qwen",
"通用设置": "Paramètres généraux",
"通用设置": "Général",
"通知": "Avis",
"通知、价格和隐私相关设置": "Paramètres de notification, de prix et de confidentialité",
"通知内容": "Contenu de la notification",
@@ -2039,13 +2044,13 @@
"通知标题": "Titre de la notification",
"通知类型 (quota_exceed: 额度预警)": "Type de notification (quota_exceed : avertissement de quota)",
"通知邮箱": "E-mail de notification",
"通知配置": "Configuration des notifications",
"通知配置": "Notifications",
"通过划转功能将奖励额度转入到您的账户余额中": "Transférez le montant de la récompense sur le solde de votre compte via la fonction de virement",
"通过密码注册时需要进行邮箱验证": "La vérification par e-mail est requise lors de l'inscription via mot de passe",
"通道 ${name} 余额更新成功!": "Le quota du canal ${name} a été mis à jour avec succès !",
"通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "Test du canal ${name} réussi, modèle ${model} a pris ${time.toFixed(2)} secondes.",
"通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Test du canal ${name} réussi, a pris ${time.toFixed(2)} secondes.",
"速率限制设置": "Paramètres de limitation de débit",
"速率限制设置": "Limitation débit",
"邀请": "Invitations",
"邀请人": "Inviteur",
"邀请人数": "Nombre de personnes invitées",
@@ -2107,7 +2112,7 @@
"重置邮件发送成功,请检查邮箱!": "L'e-mail de réinitialisation a été envoyé avec succès, veuillez vérifier votre e-mail !",
"重置配置": "Réinitialiser la configuration",
"重试": "Réessayer",
"钱包管理": "Gestion du portefeuille",
"钱包管理": "Portefeuille",
"链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "Le {key} dans le lien sera automatiquement remplacé par sk-xxxx, le {address} sera automatiquement remplacé par l'adresse du serveur dans les paramètres système, et la fin n'aura pas / et /v1",
"错误": "Erreur",
"键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5使用test分组时倍率为1": "La clé est le nom du groupe, la valeur est un autre objet JSON, la clé est le nom du groupe, la valeur est le ratio de groupe spécial des utilisateurs de ce groupe, par exemple : {\"vip\": {\"default\": 0.5, \"test\": 1}}, ce qui signifie que les utilisateurs du groupe vip ont un ratio de 0.5 lors de l'utilisation de jetons du groupe default et un ratio de 1 lors de l'utilisation du groupe test",
@@ -2125,7 +2130,7 @@
"隐私政策": "Politique de confidentialité",
"隐私政策已更新": "La politique de confidentialité a été mise à jour",
"隐私政策更新失败": "Échec de la mise à jour de la politique de confidentialité",
"隐私设置": "Paramètres de confidentialité",
"隐私设置": "Confidentialité",
"隐藏操作项": "Masquer les actions",
"隐藏调试": "Masquer le débogage",
"随机": "Aléatoire",
@@ -2146,7 +2151,7 @@
"音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Paramètres de ratio liés à l'achèvement de la sortie audio, la clé est le nom du modèle, la valeur est le ratio",
"页脚": "Pied de page",
"页面未找到,请检查您的浏览器地址是否正确": "Page non trouvée, veuillez vérifier si l'adresse de votre navigateur est correcte",
"顶栏管理": "Gestion de l'en-tête",
"顶栏管理": "En-tête",
"项目": "Élément",
"项目内容": "Contenu de l'élément",
"项目操作按钮组": "Groupe de boutons d'action du projet",
@@ -2161,7 +2166,7 @@
"额度必须大于0": "Le quota doit être supérieur à 0",
"额度提醒阈值": "Seuil de rappel de quota",
"额度查询接口返回令牌额度而非用户额度": "Affiche le quota de jetons au lieu du quota utilisateur",
"额度设置": "Paramètres de quota",
"额度设置": "Quota",
"额度预警阈值": "Seuil d'avertissement de quota",
"首尾生视频": "Vidéo de début et de fin",
"首页": "Accueil",

View File

@@ -136,6 +136,7 @@
"Uptime Kuma监控分类管理可以配置多个监控分类用于服务状态展示最多20个": "Uptime Kumaの監視分類管理サービスステータス表示用に、複数の監視分類を設定できます最大20個",
"URL链接": "URL",
"User Info Endpoint": "User Info Endpoint",
"Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AIはfunctionResponse.idフィールドをサポートしていません。有効にすると、このフィールドは自動的に削除されます",
"Webhook 签名密钥": "Webhook署名シークレット",
"Webhook地址": "Webhook URL",
"Webhook地址必须以https://开头": "Webhook URLは、https://で始まることが必須です",
@@ -795,6 +796,9 @@
"开启后,仅\"消费\"和\"错误\"日志将记录您的客户端IP地址": "有効にすると、「消費」と「エラー」のログにのみ、クライアントIPアドレスが記録されます",
"开启后将定期发送ping数据保持连接活跃": "有効にすると、接続をアクティブに保つためにpingデータが定期的に送信されます",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "有効にすると、すべてのリクエストは直接アップストリームにパススルーされ、いかなる処理も行われません(リダイレクトとチャネルの自動調整も無効になります)。有効にする際はご注意ください",
"该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "このチャネルではリクエストのパススルーが有効です。パラメータ上書き、モデルリダイレクト、チャネル適応などの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。これにより問題が発生しても issue を投稿しないでください。",
"已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "全体のリクエストパススルーが有効です。パラメータ上書き、モデルリダイレクト、チャネル適応などの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。これにより問題が発生しても issue を投稿しないでください。",
"该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "このチャネルではリクエストのパススルーが有効です。パラメータ上書きやモデルリダイレクトなどの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。",
"开启后不限制:必须设置模型倍率": "有効化後は制限なし:モデル倍率の設定が必須",
"开启后未登录用户无法访问模型广场": "有効にすると、ログインしていないユーザーはモデルマーケットプレイスにアクセスできなくなります",
"开启批量操作": "一括操作を有効にする",
@@ -1440,6 +1444,7 @@
"私有IP访问详细说明": "プライベートIPアクセスの詳細説明",
"私有部署地址": "プライベートデプロイ先URL",
"秒": "秒",
"移除 functionResponse.id 字段": "functionResponse.idフィールドを削除",
"移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "One APIの著作権表示を削除するには、事前の許可が必要です。プロジェクトの維持には多大な労力がかかります。もしこのプロジェクトがあなたにとって有意義でしたら、積極的なご支援をお願いいたします",
"窗口处理": "ウィンドウ処理",
"窗口等待": "ウィンドウ待機中",

View File

@@ -156,6 +156,7 @@
"URL链接": "URL ссылка",
"USD (美元)": "USD (доллар США)",
"User Info Endpoint": "Конечная точка информации о пользователе",
"Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI не поддерживает поле functionResponse.id. При включении это поле будет автоматически удалено",
"Webhook 密钥": "Секрет вебхука",
"Webhook 签名密钥": "Ключ подписи Webhook",
"Webhook地址": "Адрес Webhook",
@@ -856,6 +857,9 @@
"开启后对免费模型倍率为0或者价格为0的模型也会预消耗额度": "После включения бесплатные модели (коэффициент 0 или цена 0) тоже будут предварительно расходовать квоту",
"开启后将定期发送ping数据保持连接活跃": "После включения будет периодически отправляться ping-данные для поддержания активности соединения",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "После включения все запросы будут напрямую передаваться upstream без какой-либо обработки (перенаправление и адаптация каналов также будут отключены), включайте с осторожностью",
"该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Для этого канала включена сквозная передача запросов. Встроенные возможности NewAPI, такие как переопределение параметров, перенаправление моделей и адаптация канала, будут отключены. Это не является лучшей практикой. Если из-за этого возникнут проблемы, пожалуйста, не создавайте issue.",
"已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Глобальная сквозная передача запросов включена. Встроенные возможности NewAPI, такие как переопределение параметров, перенаправление моделей и адаптация канала, будут отключены. Это не является лучшей практикой. Если из-за этого возникнут проблемы, пожалуйста, не создавайте issue.",
"该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Для этого канала включена сквозная передача запросов; встроенные функции NewAPI, такие как переопределение параметров и перенаправление моделей, будут отключены. Это не является лучшей практикой.",
"开启后不限制:必须设置模型倍率": "После включения без ограничений: необходимо установить множители моделей",
"开启后未登录用户无法访问模型广场": "После включения незарегистрированные пользователи не смогут получить доступ к площади моделей",
"开启批量操作": "Включить пакетные операции",
@@ -1531,6 +1535,7 @@
"私有IP访问详细说明": "⚠️ Предупреждение безопасности: включение этой опции позволит доступ к ресурсам внутренней сети (localhost, частные сети). Включайте только при необходимости доступа к внутренним службам и понимании рисков безопасности.",
"私有部署地址": "Адрес частного развёртывания",
"秒": "секунда",
"移除 functionResponse.id 字段": "Удалить поле functionResponse.id",
"移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Удаление авторских знаков One API требует предварительного разрешения, поддержка проекта требует больших усилий, если этот проект важен для вас, пожалуйста, поддержите его",
"窗口处理": "Обработка окна",
"窗口等待": "Ожидание окна",

View File

@@ -136,6 +136,7 @@
"Uptime Kuma监控分类管理可以配置多个监控分类用于服务状态展示最多20个": "Quản lý danh mục giám sát Uptime Kuma, bạn có thể cấu hình nhiều danh mục giám sát để hiển thị trạng thái dịch vụ (tối đa 20)",
"URL链接": "Liên kết URL",
"User Info Endpoint": "User Info Endpoint",
"Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI không hỗ trợ trường functionResponse.id. Khi bật, trường này sẽ tự động bị xóa",
"Webhook 签名密钥": "Khóa chữ ký Webhook",
"Webhook地址": "URL Webhook",
"Webhook地址必须以https://开头": "URL Webhook phải bắt đầu bằng https://",
@@ -795,6 +796,9 @@
"开启后,仅\"消费\"和\"错误\"日志将记录您的客户端IP地址": "Sau khi bật, chỉ nhật ký \"tiêu thụ\" và \"lỗi\" sẽ ghi lại địa chỉ IP máy khách của bạn",
"开启后将定期发送ping数据保持连接活跃": "Sau khi bật, dữ liệu ping sẽ được gửi định kỳ để giữ kết nối hoạt động",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Khi bật, tất cả các yêu cầu sẽ được chuyển tiếp trực tiếp đến thượng nguồn mà không cần xử lý (chuyển hướng và thích ứng kênh cũng sẽ bị vô hiệu hóa). Vui lòng bật một cách thận trọng.",
"该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Kênh này đã bật truyền qua yêu cầu. Các tính năng tích hợp của NewAPI như ghi đè tham số, chuyển hướng mô hình và thích ứng kênh sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất. Nếu phát sinh vấn đề, vui lòng không gửi issue.",
"已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Đã bật truyền qua yêu cầu toàn cục. Các tính năng tích hợp của NewAPI như ghi đè tham số, chuyển hướng mô hình và thích ứng kênh sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất. Nếu phát sinh vấn đề, vui lòng không gửi issue.",
"该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Kênh này đã bật truyền qua yêu cầu; các tính năng tích hợp của NewAPI như ghi đè tham số và chuyển hướng mô hình sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất.",
"开启后不限制:必须设置模型倍率": "Sau khi bật, không giới hạn: phải đặt tỷ lệ mô hình",
"开启后未登录用户无法访问模型广场": "Khi bật, người dùng chưa xác thực không thể truy cập thị trường mô hình",
"开启批量操作": "Bật chọn hàng loạt",
@@ -2648,6 +2652,7 @@
"私有IP访问详细说明": "⚠️ Cảnh báo bảo mật: Bật tính năng này cho phép truy cập vào tài nguyên mạng nội bộ (localhost, mạng riêng). Chỉ bật nếu bạn cần truy cập các dịch vụ nội bộ và hiểu rõ các rủi ro bảo mật.",
"私有部署地址": "Địa chỉ triển khai riêng",
"秒": "Giây",
"移除 functionResponse.id 字段": "Xóa trường functionResponse.id",
"移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Việc xóa dấu bản quyền One API trước tiên phải được ủy quyền. Việc bảo trì dự án đòi hỏi rất nhiều nỗ lực. Nếu dự án này có ý nghĩa với bạn, vui lòng chủ động ủng hộ dự án này.",
"窗口处理": "xử lý cửa sổ",
"窗口等待": "chờ cửa sổ",

View File

@@ -150,6 +150,7 @@
"URL链接": "URL链接",
"USD (美元)": "USD (美元)",
"User Info Endpoint": "User Info Endpoint",
"Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段": "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段",
"Webhook 密钥": "Webhook 密钥",
"Webhook 签名密钥": "Webhook 签名密钥",
"Webhook地址": "Webhook地址",
@@ -829,6 +830,9 @@
"开启后对免费模型倍率为0或者价格为0的模型也会预消耗额度": "开启后对免费模型倍率为0或者价格为0的模型也会预消耗额度",
"开启后将定期发送ping数据保持连接活跃": "开启后将定期发送ping数据保持连接活跃",
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启",
"该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。",
"已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。",
"该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。",
"开启后不限制:必须设置模型倍率": "开启后不限制:必须设置模型倍率",
"开启后未登录用户无法访问模型广场": "开启后未登录用户无法访问模型广场",
"开启批量操作": "开启批量操作",
@@ -1498,6 +1502,7 @@
"私有IP访问详细说明": "⚠️ 安全警告:启用此选项将允许访问内网资源(本地主机、私有网络)。仅在需要访问内部服务且了解安全风险的情况下启用。",
"私有部署地址": "私有部署地址",
"秒": "秒",
"移除 functionResponse.id 字段": "移除 functionResponse.id 字段",
"移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目",
"窗口处理": "窗口处理",
"窗口等待": "窗口等待",

View File

@@ -0,0 +1,52 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import DeploymentsTable from '../../components/table/model-deployments';
import DeploymentAccessGuard from '../../components/model-deployments/DeploymentAccessGuard';
import { useModelDeploymentSettings } from '../../hooks/model-deployments/useModelDeploymentSettings';
const ModelDeploymentPage = () => {
const {
loading,
isIoNetEnabled,
connectionLoading,
connectionOk,
connectionError,
apiKey,
testConnection,
} = useModelDeploymentSettings();
return (
<DeploymentAccessGuard
loading={loading}
isEnabled={isIoNetEnabled}
connectionLoading={connectionLoading}
connectionOk={connectionOk}
connectionError={connectionError}
onRetry={() => testConnection(apiKey)}
>
<div className='mt-[60px] px-2'>
<DeploymentsTable />
</div>
</DeploymentAccessGuard>
);
};
export default ModelDeploymentPage;

View File

@@ -46,6 +46,7 @@ const DEFAULT_GEMINI_INPUTS = {
'gemini.thinking_adapter_enabled': false,
'gemini.thinking_adapter_budget_tokens_percentage': 0.6,
'gemini.function_call_thought_signature_enabled': true,
'gemini.remove_function_response_id_enabled': true,
};
export default function SettingGeminiModel(props) {
@@ -186,6 +187,23 @@ export default function SettingGeminiModel(props) {
/>
</Col>
</Row>
<Row>
<Col span={16}>
<Form.Switch
label={t('移除 functionResponse.id 字段')}
field={'gemini.remove_function_response_id_enabled'}
extraText={t(
'Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段',
)}
onChange={(value) =>
setInputs({
...inputs,
'gemini.remove_function_response_id_enabled': value,
})
}
/>
</Col>
</Row>
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.TextArea

View File

@@ -0,0 +1,334 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin, Card, Typography } from '@douyinfe/semi-ui';
import {
compareObjects,
API,
showError,
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { Server, Cloud, Zap, ArrowUpRight } from 'lucide-react';
const { Text } = Typography;
export default function SettingModelDeployment(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState({
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
});
const [testing, setTesting] = useState(false);
const testApiKey = async () => {
const apiKey = inputs['model_deployment.ionet.api_key'];
if (!apiKey || apiKey.trim() === '') {
showError(t('请先填写 API Key'));
return;
}
const getLocalizedMessage = (message) => {
switch (message) {
case 'invalid request payload':
return t('请求参数无效');
case 'api_key is required':
return t('请先填写 API Key');
case 'failed to validate api key':
return t('API Key 验证失败');
default:
return message;
}
};
setTesting(true);
try {
const response = await API.post(
'/api/deployments/test-connection',
{
api_key: apiKey.trim(),
},
{
skipErrorHandler: true,
},
);
if (response?.data?.success) {
showSuccess(t('API Key 验证成功!连接到 io.net 服务正常'));
} else {
const rawMessage = response?.data?.message;
const localizedMessage = rawMessage
? getLocalizedMessage(rawMessage)
: t('API Key 验证失败');
showError(localizedMessage);
}
} catch (error) {
console.error('io.net API test error:', error);
if (error?.code === 'ERR_NETWORK') {
showError(t('网络连接失败,请检查网络设置或稍后重试'));
} else {
const rawMessage =
error?.response?.data?.message ||
error?.message ||
'';
const localizedMessage = rawMessage
? getLocalizedMessage(rawMessage)
: t('未知错误');
showError(t('测试失败:') + localizedMessage);
}
} finally {
setTesting(false);
}
};
function onSubmit() {
// 前置校验:如果启用了 io.net 但没有填写 API Key
if (inputs['model_deployment.ionet.enabled'] &&
(!inputs['model_deployment.ionet.api_key'] || inputs['model_deployment.ionet.api_key'].trim() === '')) {
return showError(t('启用 io.net 部署时必须填写 API Key'));
}
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = String(inputs[item.key]);
return API.put('/api/option/', {
key: item.key,
value,
});
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError(t('部分保存失败,请重试'));
}
showSuccess(t('保存成功'));
// 更新 inputsRow 以反映已保存的状态
setInputsRow(structuredClone(inputs));
props.refresh();
})
.catch(() => {
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
}
useEffect(() => {
if (props.options) {
const defaultInputs = {
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
};
const currentInputs = {};
for (let key in defaultInputs) {
if (props.options.hasOwnProperty(key)) {
currentInputs[key] = props.options[key];
} else {
currentInputs[key] = defaultInputs[key];
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current?.setValues(currentInputs);
}
}, [props.options]);
return (
<>
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section
text={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{t('模型部署设置')}</span>
</div>
}
>
{/*<Text */}
{/* type="secondary" */}
{/* size="small"*/}
{/* style={{ */}
{/* display: 'block', */}
{/* marginBottom: '20px',*/}
{/* color: 'var(--semi-color-text-2)'*/}
{/* }}*/}
{/*>*/}
{/* {t('配置模型部署服务提供商的API密钥和启用状态')}*/}
{/*</Text>*/}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Cloud size={18} />
<span>io.net</span>
</div>
}
bodyStyle={{ padding: '20px' }}
style={{ marginBottom: '16px' }}
>
<Row gutter={24}>
<Col xs={24} lg={14}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<Form.Switch
label={t('启用 io.net 部署')}
field={'model_deployment.ionet.enabled'}
onChange={(value) =>
setInputs({
...inputs,
'model_deployment.ionet.enabled': value,
})
}
extraText={t('启用后可接入 io.net GPU 资源')}
/>
<Form.Input
label={t('API Key')}
field={'model_deployment.ionet.api_key'}
placeholder={t('请输入 io.net API Key')}
onChange={(value) =>
setInputs({
...inputs,
'model_deployment.ionet.api_key': value,
})
}
disabled={!inputs['model_deployment.ionet.enabled']}
extraText={t('请使用 Project 为 io.cloud 的密钥')}
mode="password"
/>
<div style={{ display: 'flex', gap: '12px' }}>
<Button
type="outline"
size="small"
icon={<Zap size={16} />}
onClick={testApiKey}
loading={testing}
disabled={
!inputs['model_deployment.ionet.enabled'] ||
!inputs['model_deployment.ionet.api_key'] ||
inputs['model_deployment.ionet.api_key'].trim() === ''
}
style={{
height: '32px',
fontSize: '13px',
borderRadius: '6px',
fontWeight: '500',
borderColor: testing
? 'var(--semi-color-primary)'
: 'var(--semi-color-border)',
color: testing
? 'var(--semi-color-primary)'
: 'var(--semi-color-text-0)',
}}
>
{testing ? t('连接测试中...') : t('测试连接')}
</Button>
</div>
</div>
</Col>
<Col xs={24} lg={10}>
<div
style={{
background: 'var(--semi-color-fill-0)',
padding: '16px',
borderRadius: '8px',
border: '1px solid var(--semi-color-border)',
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: '12px',
justifyContent: 'space-between',
}}
>
<div>
<Text strong style={{ display: 'block', marginBottom: '8px' }}>
{t('获取 io.net API Key')}
</Text>
<ul
style={{
margin: 0,
paddingLeft: '18px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
color: 'var(--semi-color-text-2)',
fontSize: '13px',
lineHeight: 1.6,
}}
>
<li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
<li>{t('创建或选择密钥时,将 Project 设置为 io.cloud')}</li>
<li>{t('复制生成的密钥并粘贴到此处')}</li>
</ul>
</div>
<Button
icon={<ArrowUpRight size={16} />}
type="primary"
theme="solid"
style={{ width: '100%' }}
onClick={() =>
window.open('https://ai.io.net/ai/api-keys', '_blank')
}
>
{t('前往 io.net API Keys')}
</Button>
</div>
</Col>
</Row>
</Card>
<Row>
<Button size='default' type="primary" onClick={onSubmit}>
{t('保存设置')}
</Button>
</Row>
</Form.Section>
</Form>
</Spin>
</>
);
}

View File

@@ -62,6 +62,7 @@ export default function SettingsSidebarModulesAdmin(props) {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
@@ -121,6 +122,7 @@ export default function SettingsSidebarModulesAdmin(props) {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
@@ -188,6 +190,7 @@ export default function SettingsSidebarModulesAdmin(props) {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
@@ -249,6 +252,7 @@ export default function SettingsSidebarModulesAdmin(props) {
modules: [
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
{ key: 'deployment', title: t('模型部署'), description: t('模型部署管理') },
{
key: 'redemption',
title: t('兑换码管理'),

View File

@@ -104,6 +104,7 @@ export default function SettingsSidebarModulesUser() {
enabled: true,
channel: isSidebarModuleAllowed('admin', 'channel'),
models: isSidebarModuleAllowed('admin', 'models'),
deployment: isSidebarModuleAllowed('admin', 'deployment'),
redemption: isSidebarModuleAllowed('admin', 'redemption'),
user: isSidebarModuleAllowed('admin', 'user'),
setting: isSidebarModuleAllowed('admin', 'setting'),

View File

@@ -32,6 +32,7 @@ import {
MessageSquare,
Palette,
CreditCard,
Server,
} from 'lucide-react';
import SystemSetting from '../../components/settings/SystemSetting';
@@ -45,6 +46,7 @@ import RatioSetting from '../../components/settings/RatioSetting';
import ChatsSetting from '../../components/settings/ChatsSetting';
import DrawingSetting from '../../components/settings/DrawingSetting';
import PaymentSetting from '../../components/settings/PaymentSetting';
import ModelDeploymentSetting from '../../components/settings/ModelDeploymentSetting';
const Setting = () => {
const { t } = useTranslation();
@@ -134,6 +136,16 @@ const Setting = () => {
content: <ModelSetting />,
itemKey: 'models',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<Server size={18} />
{t('模型部署设置')}
</span>
),
content: <ModelDeploymentSetting />,
itemKey: 'model-deployment',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>