From 681b37d104a93f3b4d0a4cae68c37f51d290f038 Mon Sep 17 00:00:00 2001 From: Papersnake Date: Mon, 8 Dec 2025 17:25:10 +0800 Subject: [PATCH 01/76] feat: support claude-haiku-4-5-20251001 on vertex --- relay/channel/vertex/adaptor.go | 1 + setting/ratio_setting/model_ratio.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 920041ce6..a0124ee2a 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -39,6 +39,7 @@ var claudeModelMap = map[string]string{ "claude-opus-4-20250514": "claude-opus-4@20250514", "claude-opus-4-1-20250805": "claude-opus-4-1@20250805", "claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929", + "claude-haiku-4-5-20251001": "claude-haiku-4-5@20251001", "claude-opus-4-5-20251101": "claude-opus-4-5@20251101", } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index bd533db5c..f6b0bacb3 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -144,6 +144,7 @@ var defaultModelRatio = map[string]float64{ "claude-3-7-sonnet-20250219-thinking": 1.5, "claude-sonnet-4-20250514": 1.5, "claude-sonnet-4-5-20250929": 1.5, + "claude-haiku-4-5-20251001": 0.5, "claude-opus-4-5-20251101": 2.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens "claude-opus-4-20250514": 7.5, @@ -560,7 +561,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { if strings.Contains(name, "claude-3") { return 5, true - } else if strings.Contains(name, "claude-sonnet-4") || strings.Contains(name, "claude-opus-4") { + } else if strings.Contains(name, "claude-sonnet-4") || strings.Contains(name, "claude-opus-4") || strings.Contains(name, "claude-haiku-4") { return 5, true } else if strings.Contains(name, "claude-instant-1") || strings.Contains(name, "claude-2") { return 3, true From 2a16c37aab248cd265f87f6e4b2da4916a2870e2 Mon Sep 17 00:00:00 2001 From: hackerxiao <2945294768@qq.com> Date: Fri, 12 Dec 2025 16:53:10 +0800 Subject: [PATCH 02/76] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=85?= =?UTF-8?q?=E4=BD=BF=E7=94=A8x-api-key=E8=8E=B7=E5=8F=96anthropic=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/middleware/auth.go b/middleware/auth.go index dc59df9af..40afabfc9 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -194,7 +194,7 @@ func TokenAuth() func(c *gin.Context) { c.Request.Header.Set("Authorization", "Bearer "+key) } // 检查path包含/v1/messages - if strings.Contains(c.Request.URL.Path, "/v1/messages") { + if strings.Contains(c.Request.URL.Path, "/v1/messages") || strings.Contains(c.Request.URL.Path, "/v1/models") { anthropicKey := c.Request.Header.Get("x-api-key") if anthropicKey != "" { c.Request.Header.Set("Authorization", "Bearer "+anthropicKey) From 8e629a2a11bd4a8627733de79a8f7b8dc3228fce Mon Sep 17 00:00:00 2001 From: hackerxiao <2945294768@qq.com> Date: Fri, 12 Dec 2025 17:27:24 +0800 Subject: [PATCH 03/76] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=85?= =?UTF-8?q?=E4=BD=BF=E7=94=A8x-api-key=E8=8E=B7=E5=8F=96anthropic=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E5=A2=9E=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/middleware/auth.go b/middleware/auth.go index 40afabfc9..68e73d185 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -193,7 +193,7 @@ func TokenAuth() func(c *gin.Context) { } c.Request.Header.Set("Authorization", "Bearer "+key) } - // 检查path包含/v1/messages + // 检查path包含/v1/messages 或 /v1/models if strings.Contains(c.Request.URL.Path, "/v1/messages") || strings.Contains(c.Request.URL.Path, "/v1/models") { anthropicKey := c.Request.Header.Get("x-api-key") if anthropicKey != "" { From 0217ed2f98e0e2931829d7de47092f8ed04d9632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=83=E8=92=99?= <1209103220@qq.com> Date: Mon, 15 Dec 2025 18:15:35 +0800 Subject: [PATCH 04/76] =?UTF-8?q?fix(task):=20=E4=BF=AE=E5=A4=8D=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E9=85=8D=E7=BD=AE=E5=A4=9A=E4=B8=AAkey=E6=97=B6?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E8=8E=B7=E5=8F=96=E4=BB=BB=E5=8A=A1=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/task_video.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/controller/task_video.go b/controller/task_video.go index 86095307d..d7c19e620 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -74,7 +74,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId)) return fmt.Errorf("task %s not found", taskId) } - resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{ + key := channel.Key + + privateData := task.PrivateData + if privateData.Key != "" { + key = privateData.Key + } + resp, err := adaptor.FetchTask(baseURL, key, map[string]any{ "task_id": taskId, "action": task.Action, }, proxy) From edbd5346e430ca162d3ae072d609569b4edaea9d Mon Sep 17 00:00:00 2001 From: papersnake Date: Fri, 26 Dec 2025 16:25:58 +0800 Subject: [PATCH 05/76] fix: dup ratio --- setting/ratio_setting/model_ratio.go | 1 - 1 file changed, 1 deletion(-) diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 90db449f8..df823516a 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -144,7 +144,6 @@ var defaultModelRatio = map[string]float64{ "claude-3-7-sonnet-20250219-thinking": 1.5, "claude-sonnet-4-20250514": 1.5, "claude-sonnet-4-5-20250929": 1.5, - "claude-haiku-4-5-20251001": 0.5, "claude-opus-4-5-20251101": 2.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens "claude-opus-4-20250514": 7.5, From 37a188279855b8350acc2713f106004ad136d27e Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 26 Dec 2025 16:35:01 +0800 Subject: [PATCH 06/76] fix: kling correct fail reason --- relay/channel/task/kling/adaptor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index 4c3c9d61b..5fb853481 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -346,7 +346,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e } taskInfo.Code = resPayload.Code taskInfo.TaskID = resPayload.Data.TaskId - taskInfo.Reason = resPayload.Message + taskInfo.Reason = resPayload.Data.TaskStatusMsg //任务状态,枚举值:submitted(已提交)、processing(处理中)、succeed(成功)、failed(失败) status := resPayload.Data.TaskStatus switch status { From 1de78f87491c0cf6193491243d177098db0833f9 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Sat, 27 Dec 2025 02:52:33 +0800 Subject: [PATCH 07/76] feat: map OpenAI developer role to Gemini system instructions --- relay/channel/gemini/relay-gemini.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index db5ea489c..f83709a5c 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -374,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" { From 725d61c5d356edbbf7419652471d7bce3ad9cc53 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:55:35 +0800 Subject: [PATCH 08/76] 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. --- .dockerignore | 3 +- .gitignore | 4 +- controller/channel.go | 350 +++- controller/deployment.go | 781 +++++++++ docs/ionet-client.md | 7 + go.mod | 4 + model/main.go | 82 +- pkg/ionet/client.go | 219 +++ pkg/ionet/container.go | 302 ++++ pkg/ionet/deployment.go | 377 +++++ pkg/ionet/hardware.go | 202 +++ pkg/ionet/jsonutil.go | 96 ++ pkg/ionet/types.go | 353 ++++ relay/channel/ollama/dto.go | 37 + relay/channel/ollama/relay-ollama.go | 245 +++ router/api-router.go | 44 + web/src/App.jsx | 9 + web/src/components/layout/SiderBar.jsx | 7 + .../layout/components/SkeletonWrapper.jsx | 23 +- .../DeploymentAccessGuard.jsx | 377 +++++ .../settings/ModelDeploymentSetting.jsx | 85 + .../table/channels/ChannelsColumnDefs.jsx | 76 +- .../table/channels/ChannelsTable.jsx | 3 + .../channels/modals/EditChannelModal.jsx | 683 ++++---- .../channels/modals/ModelSelectModal.jsx | 19 +- .../channels/modals/OllamaModelModal.jsx | 806 +++++++++ .../model-deployments/DeploymentsActions.jsx | 109 ++ .../DeploymentsColumnDefs.jsx | 672 ++++++++ .../model-deployments/DeploymentsFilters.jsx | 130 ++ .../model-deployments/DeploymentsTable.jsx | 247 +++ .../table/model-deployments/index.jsx | 147 ++ .../modals/ColumnSelectorModal.jsx | 127 ++ .../modals/ConfirmationDialog.jsx | 99 ++ .../modals/CreateDeploymentModal.jsx | 1462 +++++++++++++++++ .../modals/EditDeploymentModal.jsx | 241 +++ .../modals/ExtendDurationModal.jsx | 548 ++++++ .../modals/UpdateConfigModal.jsx | 475 ++++++ .../modals/ViewDetailsModal.jsx | 517 ++++++ .../modals/ViewLogsModal.jsx | 660 ++++++++ web/src/helpers/render.jsx | 3 + web/src/hooks/channels/useChannelsData.jsx | 64 +- web/src/hooks/common/useSidebar.js | 1 + .../useDeploymentResources.js | 266 +++ .../model-deployments/useDeploymentsData.jsx | 507 ++++++ .../useEnhancedDeploymentActions.jsx | 249 +++ .../useModelDeploymentSettings.js | 143 ++ web/src/pages/ModelDeployment/index.jsx | 52 + .../Setting/Model/SettingModelDeployment.jsx | 334 ++++ .../Operation/SettingsSidebarModulesAdmin.jsx | 4 + .../Personal/SettingsSidebarModulesUser.jsx | 1 + web/src/pages/Setting/index.jsx | 12 + 51 files changed, 11895 insertions(+), 369 deletions(-) create mode 100644 controller/deployment.go create mode 100644 docs/ionet-client.md create mode 100644 pkg/ionet/client.go create mode 100644 pkg/ionet/container.go create mode 100644 pkg/ionet/deployment.go create mode 100644 pkg/ionet/hardware.go create mode 100644 pkg/ionet/jsonutil.go create mode 100644 pkg/ionet/types.go create mode 100644 web/src/components/model-deployments/DeploymentAccessGuard.jsx create mode 100644 web/src/components/settings/ModelDeploymentSetting.jsx create mode 100644 web/src/components/table/channels/modals/OllamaModelModal.jsx create mode 100644 web/src/components/table/model-deployments/DeploymentsActions.jsx create mode 100644 web/src/components/table/model-deployments/DeploymentsColumnDefs.jsx create mode 100644 web/src/components/table/model-deployments/DeploymentsFilters.jsx create mode 100644 web/src/components/table/model-deployments/DeploymentsTable.jsx create mode 100644 web/src/components/table/model-deployments/index.jsx create mode 100644 web/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx create mode 100644 web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx create mode 100644 web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx create mode 100644 web/src/components/table/model-deployments/modals/EditDeploymentModal.jsx create mode 100644 web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx create mode 100644 web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx create mode 100644 web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx create mode 100644 web/src/components/table/model-deployments/modals/ViewLogsModal.jsx create mode 100644 web/src/hooks/model-deployments/useDeploymentResources.js create mode 100644 web/src/hooks/model-deployments/useDeploymentsData.jsx create mode 100644 web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx create mode 100644 web/src/hooks/model-deployments/useModelDeploymentSettings.js create mode 100644 web/src/pages/ModelDeployment/index.jsx create mode 100644 web/src/pages/Setting/Model/SettingModelDeployment.jsx diff --git a/.dockerignore b/.dockerignore index 781a7b550..53d001932 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ Makefile docs .eslintcache -.gocache \ No newline at end of file +.gocache +/web/node_modules \ No newline at end of file diff --git a/.gitignore b/.gitignore index 133f59090..67ce02704 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ web/bun.lock electron/node_modules electron/dist data/ -.gomodcache/ \ No newline at end of file +.gomodcache/ +.gocache-temp +.gopath diff --git a/controller/channel.go b/controller/channel.go index b2db2b777..ea2f47680 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -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, + }, + }) +} diff --git a/controller/deployment.go b/controller/deployment.go new file mode 100644 index 000000000..7530b4edf --- /dev/null +++ b/controller/deployment.go @@ -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) +} diff --git a/docs/ionet-client.md b/docs/ionet-client.md new file mode 100644 index 000000000..a4d40b171 --- /dev/null +++ b/docs/ionet-client.md @@ -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"} + diff --git a/go.mod b/go.mod index 4b5d63e49..f4f133973 100644 --- a/go.mod +++ b/go.mod @@ -37,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 @@ -63,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 @@ -103,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 diff --git a/model/main.go b/model/main.go index 04842f13f..8dcedad0b 100644 --- a/model/main.go +++ b/model/main.go @@ -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)) diff --git a/pkg/ionet/client.go b/pkg/ionet/client.go new file mode 100644 index 000000000..e53947570 --- /dev/null +++ b/pkg/ionet/client.go @@ -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 "" +} diff --git a/pkg/ionet/container.go b/pkg/ionet/container.go new file mode 100644 index 000000000..805a3b162 --- /dev/null +++ b/pkg/ionet/container.go @@ -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 +} diff --git a/pkg/ionet/deployment.go b/pkg/ionet/deployment.go new file mode 100644 index 000000000..36597399b --- /dev/null +++ b/pkg/ionet/deployment.go @@ -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 +} diff --git a/pkg/ionet/hardware.go b/pkg/ionet/hardware.go new file mode 100644 index 000000000..54ccdb886 --- /dev/null +++ b/pkg/ionet/hardware.go @@ -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 +} diff --git a/pkg/ionet/jsonutil.go b/pkg/ionet/jsonutil.go new file mode 100644 index 000000000..0b3219cfe --- /dev/null +++ b/pkg/ionet/jsonutil.go @@ -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 +} diff --git a/pkg/ionet/types.go b/pkg/ionet/types.go new file mode 100644 index 000000000..7912f360d --- /dev/null +++ b/pkg/ionet/types.go @@ -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"` +} diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go index 2434a4cbc..07aeb17a7 100644 --- a/relay/channel/ollama/dto.go +++ b/relay/channel/ollama/dto.go @@ -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"` +} diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index 9c05b1357..795e9c975 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -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 +} diff --git a/router/api-router.go b/router/api-router.go index fd204e7e6..e8266ef3f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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) + } } } diff --git a/web/src/App.jsx b/web/src/App.jsx index b0f281c45..995c64499 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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() { } /> + + + + } + /> {} }) => { to: '/console/models', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: t('模型部署'), + itemKey: 'deployment', + to: '/deployment', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('兑换码管理'), itemKey: 'redemption', diff --git a/web/src/components/layout/components/SkeletonWrapper.jsx b/web/src/components/layout/components/SkeletonWrapper.jsx index 7fbd588ca..eec4be9a7 100644 --- a/web/src/components/layout/components/SkeletonWrapper.jsx +++ b/web/src/components/layout/components/SkeletonWrapper.jsx @@ -52,7 +52,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -71,7 +70,7 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } />
@@ -80,7 +79,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -98,7 +96,6 @@ const SkeletonWrapper = ({ active placeholder={ @@ -113,7 +110,7 @@ const SkeletonWrapper = ({ } + placeholder={} /> ); }; @@ -125,7 +122,7 @@ const SkeletonWrapper = ({ } + placeholder={} />
); @@ -140,7 +137,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -164,7 +160,7 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } /> @@ -174,7 +170,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -191,10 +186,7 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } /> @@ -217,7 +209,6 @@ const SkeletonWrapper = ({ active placeholder={ @@ -231,7 +222,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -269,7 +259,6 @@ const SkeletonWrapper = ({ active placeholder={ @@ -329,7 +318,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -350,7 +338,6 @@ const SkeletonWrapper = ({ active placeholder={ } diff --git a/web/src/components/model-deployments/DeploymentAccessGuard.jsx b/web/src/components/model-deployments/DeploymentAccessGuard.jsx new file mode 100644 index 000000000..f771fa1c5 --- /dev/null +++ b/web/src/components/model-deployments/DeploymentAccessGuard.jsx @@ -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 . + +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 ( +
+ +
+ {t('加载设置中...')} +
+
+
+ ); + } + + if (!isEnabled) { + return ( +
+
+ + {/* 图标区域 */} +
+
+ +
+
+ + {/* 标题区域 */} +
+ + {t('模型部署服务未启用')} + + + {t('访问模型部署功能需要先启用 io.net 部署服务')} + +
+ + {/* 配置要求区域 */} +
+
+
+ +
+ + {t('需要配置的项目')} + +
+ +
+
+
+ + {t('启用 io.net 部署开关')} + +
+
+
+ + {t('配置有效的 io.net API Key')} + +
+
+
+ + {/* 操作链接区域 */} +
+
{ + 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'; + }} + > + + {t('前往设置页面')} +
+
+ + {/* 底部提示 */} + + {t('配置完成后刷新页面即可使用模型部署功能')} + +
+
+
+ ); + } + + if (connectionLoading || (connectionOk === null && !connectionError)) { + return ( +
+ +
+ {t('Checking io.net connection...')} +
+
+
+ ); + } + + 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 ( +
+
+ +
+
+ +
+
+ +
+ + {title} + + + {description} + + {detail ? ( + + {detail} + + ) : null} +
+ +
+ + {onRetry ? ( + + ) : null} +
+
+
+
+ ); + } + + return children; +}; + +export default DeploymentAccessGuard; diff --git a/web/src/components/settings/ModelDeploymentSetting.jsx b/web/src/components/settings/ModelDeploymentSetting.jsx new file mode 100644 index 000000000..941f640a4 --- /dev/null +++ b/web/src/components/settings/ModelDeploymentSetting.jsx @@ -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 . + +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 ( + <> + + + + + + + ); +}; + +export default ModelDeploymentSetting; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsColumnDefs.jsx b/web/src/components/table/channels/ChannelsColumnDefs.jsx index 643f3ffe6..2c9f7498b 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/web/src/components/table/channels/ChannelsColumnDefs.jsx @@ -47,7 +47,8 @@ import { 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]; @@ -71,11 +72,65 @@ const renderType = (type, channelInfo = undefined, t) => { ); } - return ( + const typeTag = ( {type2label[type]?.label} ); + + 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 ( + + {typeTag} + +
{t('来源于 IO.NET 部署')}
+ {ionetMeta?.deployment_id && ( +
+ {t('部署 ID')}: {ionetMeta.deployment_id} +
+ )} + + } + > + + + IO.NET + + +
+
+ ); }; const renderTagType = (t) => { @@ -231,6 +286,7 @@ export const getChannelsColumns = ({ refresh, activePage, channels, + checkOllamaVersion, setShowMultiKeyManageModal, setCurrentMultiKeyChannel, }) => { @@ -330,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)}; } @@ -569,6 +620,15 @@ export const getChannelsColumns = ({ }, ]; + if (record.type === 4) { + moreMenuItems.unshift({ + node: 'item', + name: t('测活'), + type: 'tertiary', + onClick: () => checkOllamaVersion(record), + }); + } + return ( { 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, ]); diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index e5cf66434..9e07a2bd1 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -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) => { - setChannelSearchValue(value)} - renderOptionItem={renderChannelOption} - onChange={(value) => handleInputChange('type', value)} - /> + {isIonetChannel && ( + + + {ionetMetadata?.deployment_id && ( + + )} + + + )} + + setChannelSearchValue(value)} + renderOptionItem={renderChannelOption} + onChange={(value) => handleInputChange('type', value)} + disabled={isIonetLocked} + /> {inputs.type === 20 && ( { autosize autoComplete='new-password' onChange={(value) => handleInputChange('key', value)} - extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( + disabled={isIonetLocked} + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
+ } + showClear + /> + ) + ) : ( + <> + {inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + <> + {!batch && ( +
+ + {t('密钥输入方式')} + + - )} - {batchExtra} + +
- } - showClear - /> - ) - ) : ( - <> - {inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - <> - {!batch && ( -
- - {t('密钥输入方式')} - - - - - -
- )} + )} {batch && ( { /> )} - {inputs.type === 3 && ( - <> - + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} /> -
- - handleInputChange('base_url', value) - } - showClear - /> -
-
- - handleInputChange('other', value) - } - showClear - /> -
-
- - handleChannelOtherSettingsChange( - 'azure_responses_version', - value, - ) - } - showClear - /> -
- - )} +
+
+ + handleInputChange('other', value) + } + showClear + /> +
+
+ + handleChannelOtherSettingsChange( + 'azure_responses_version', + value, + ) + } + showClear + /> +
+ + )} - {inputs.type === 8 && ( - <> - + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} /> -
- - handleInputChange('base_url', value) - } - showClear - /> -
- - )} +
+ + )} {inputs.type === 37 && ( { handleInputChange('base_url', value) } showClear - extraText={t( - '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', - )} - /> -
- )} - - {inputs.type === 22 && ( -
- - handleInputChange('base_url', value) - } - showClear />
)} - {inputs.type === 36 && ( -
- - handleInputChange('base_url', value) - } - showClear - /> -
- )} + {inputs.type === 22 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} - {inputs.type === 45 && !doubaoApiEditUnlocked && ( -
- + {inputs.type === 36 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} + + {inputs.type === 45 && !doubaoApiEditUnlocked && ( +
+ 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' - /> -
- )} + ]}defaultValue='https://ark.cn-beijing.volces.com' + disabled={isIonetLocked} + /> +
+ )} )} @@ -2458,72 +2530,80 @@ const EditChannelModal = (props) => { {t('获取模型列表')} )} + {inputs.type === 4 && isEdit && ( - - {modelGroups && - modelGroups.length > 0 && - modelGroups.map((group) => ( - - ))} -
- } - /> + )} + + + {modelGroups && + modelGroups.length > 0 && + modelGroups.map((group) => ( + + ))} + + } + /> { }} onCancel={() => setModelModalVisible(false)} /> + + 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('模型列表已追加更新')); + }} + /> ); }; diff --git a/web/src/components/table/channels/modals/ModelSelectModal.jsx b/web/src/components/table/channels/modals/ModelSelectModal.jsx index 21ac768c6..eda7f80b5 100644 --- a/web/src/components/table/channels/modals/ModelSelectModal.jsx +++ b/web/src/components/table/channels/modals/ModelSelectModal.jsx @@ -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(() => { diff --git a/web/src/components/table/channels/modals/OllamaModelModal.jsx b/web/src/components/table/channels/modals/OllamaModelModal.jsx new file mode 100644 index 000000000..8b1dfcce1 --- /dev/null +++ b/web/src/components/table/channels/modals/OllamaModelModal.jsx @@ -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 . + +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 ( + + + + +
+ + {t('Ollama 模型管理')} + + + {channelInfo?.name && `${channelInfo.name} - `} + {t('管理 Ollama 模型的拉取和删除')} + +
+ + } + visible={visible} + onCancel={onCancel} + width={800} + style={{ maxWidth: '95vw' }} + footer={ +
+ +
+ } + > +
+ {/* 拉取新模型 */} + +
+ + + + + {t('拉取新模型')} + +
+ + + + setPullModelName(value)} + onEnterPress={pullModel} + disabled={pullLoading} + showClear + /> + + + + + + + {/* 进度条显示 */} + {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 ( +
+
+ {t('拉取进度')} + {percentText} +
+ + {hasTotal && safePercent !== null ? ( +
+ +
+ + {(completedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB + + + {(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB + +
+
+ ) : ( +
+ + {t('准备中...')} +
+ )} +
+ ); + })()} + + + {t('支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间')} + +
+ + {/* 已有模型列表 */} + +
+
+ + + + + {t('已有模型')} + {models.length > 0 && ( + <Tag color='blue' className='ml-2'> + {models.length} + </Tag> + )} + +
+ + } + placeholder={t('搜索模型...')} + value={searchValue} + onChange={(value) => setSearchValue(value)} + style={{ width: 200 }} + showClear + /> + + + + + +
+ + + {filteredModels.length === 0 ? ( + } + title={searchValue ? t('未找到匹配的模型') : t('暂无模型')} + description={ + searchValue + ? t('请尝试其他搜索关键词') + : t('您可以在上方拉取需要的模型') + } + style={{ padding: '40px 0' }} + /> + ) : ( + ( + +
+
+ handleToggleModel(model.id, checked)} + /> + + {model.id.charAt(0).toUpperCase()} + +
+ + {model.id} + +
+ + {model.owned_by || 'ollama'} + + {model.size && ( + + {formatModelSize(model.size)} + + )} +
+
+
+
+ deleteModel(model.id)} + okText={t('确认')} + cancelText={t('取消')} + > +
+
+
+ )} + /> + )} +
+
+
+
+ ); +}; + +export default OllamaModelModal; diff --git a/web/src/components/table/model-deployments/DeploymentsActions.jsx b/web/src/components/table/model-deployments/DeploymentsActions.jsx new file mode 100644 index 000000000..f89ed4ace --- /dev/null +++ b/web/src/components/table/model-deployments/DeploymentsActions.jsx @@ -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 . + +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 ( +
+ + + {hasSelected && ( + <> + + + + + + + )} + + {/* Compact Mode */} + +
+ ); +}; + +export default DeploymentsActions; diff --git a/web/src/components/table/model-deployments/DeploymentsColumnDefs.jsx b/web/src/components/table/model-deployments/DeploymentsColumnDefs.jsx new file mode 100644 index 000000000..965ca7be7 --- /dev/null +++ b/web/src/components/table/model-deployments/DeploymentsColumnDefs.jsx @@ -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 . + +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: , + }, + deploying: { + color: 'blue', + label: t('部署中'), + icon: , + }, + pending: { + color: 'orange', + label: t('待部署'), + icon: , + }, + stopped: { + color: 'grey', + label: t('已停止'), + icon: , + }, + error: { + color: 'red', + label: t('错误'), + icon: , + }, + failed: { + color: 'red', + label: t('失败'), + icon: , + }, + destroyed: { + color: 'red', + label: t('已销毁'), + icon: , + }, + completed: { + color: 'green', + label: t('已完成'), + icon: , + }, + 'deployment requested': { + color: 'blue', + label: t('部署请求中'), + icon: , + }, + 'termination requested': { + color: 'orange', + label: t('终止请求中'), + icon: , + }, +}; + +const DEFAULT_STATUS_CONFIG = { + color: 'grey', + label: null, + icon: , +}; + +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 ( + + {labelText} + + ); +}; + +// 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 ( +
+ + {text} + + + ID: {record.id} + +
+ ); +}; + +// Render resource configuration +const renderResourceConfig = (resource, t) => { + if (!resource) return '-'; + + const { cpu, memory, gpu } = resource; + + return ( +
+ {cpu && ( +
+ + CPU: {cpu} +
+ )} + {memory && ( +
+ + 内存: {memory} +
+ )} + {gpu && ( +
+ + GPU: {gpu} +
+ )} +
+ ); +}; + +// 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 ( + + {count || 0} {t('个实例')} + + ); +}; + +// 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) => ( + + ), + }, + { + title: t('状态'), + dataIndex: 'status', + key: COLUMN_KEYS.status, + width: 140, + render: (status) => ( +
+ {renderStatus(status, t)} +
+ ), + }, + { + title: t('服务商'), + dataIndex: 'provider', + key: COLUMN_KEYS.provider, + width: 140, + render: (provider) => + provider ? ( +
+ + {provider} +
+ ) : ( + + {t('暂无')} + + ), + }, + { + 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 ( +
+
+ + + {timeDisplay} + + {showProgress && percentRemaining !== null ? ( + + {percentRemaining}% + + ) : statusOverride ? ( + + {statusOverride} + + ) : null} +
+ {showExtraInfo && ( +
+ {humanReadable && ( + + + {t('约')} {humanReadable} + + )} + {percentUsed !== null && ( + + + {t('已用')} {percentUsed}% + + )} +
+ )} + {showProgress && showRemainingMeta && ( +
+ {t('剩余')} {record.compute_minutes_remaining} {t('分钟')} +
+ )} +
+ ); + }, + }, + { + title: t('硬件配置'), + dataIndex: 'hardware_info', + key: COLUMN_KEYS.hardware_info, + width: 220, + ellipsis: true, + render: (text, record) => ( +
+
+ + + {record.hardware_name} + +
+ x{record.hardware_quantity} +
+ ), + }, + { + title: t('创建时间'), + dataIndex: 'created_at', + key: COLUMN_KEYS.created_at, + width: 150, + render: (text) => ( + {timestamp2string(text)} + ), + }, + { + 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: , + text: t('查看详情'), + onClick: () => onViewDetails?.(record), + type: 'secondary', + theme: 'borderless', + }; + case 'failed': + case 'error': + return { + icon: , + text: t('重试'), + onClick: () => startDeployment(id), + type: 'primary', + theme: 'solid', + }; + case 'stopped': + return { + icon: , + text: t('启动'), + onClick: () => startDeployment(id), + type: 'primary', + theme: 'solid', + }; + case 'deployment requested': + case 'deploying': + return { + icon: , + text: t('部署中'), + onClick: () => {}, + type: 'secondary', + theme: 'light', + disabled: true, + }; + case 'pending': + return { + icon: , + text: t('待部署'), + onClick: () => {}, + type: 'secondary', + theme: 'light', + disabled: true, + }; + case 'termination requested': + return { + icon: , + text: t('终止中'), + onClick: () => {}, + type: 'secondary', + theme: 'light', + disabled: true, + }; + case 'completed': + case 'destroyed': + default: + return { + icon: , + 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 ( +
+ +
+ ); + } + + // All actions dropdown with enhanced operations + const dropdownItems = [ + onViewDetails?.(record)} icon={}> + {t('查看详情')} + , + ]; + + if (!isEnded) { + dropdownItems.push( + onViewLogs?.(record)} icon={}> + {t('查看日志')} + , + ); + } + + const managementItems = []; + if (normalizedStatus === 'running') { + if (onSyncToChannel) { + managementItems.push( + onSyncToChannel(record)} icon={}> + {t('同步到渠道')} + , + ); + } + } + if (normalizedStatus === 'failed' || normalizedStatus === 'error') { + managementItems.push( + startDeployment(id)} icon={}> + {t('重试')} + , + ); + } + if (normalizedStatus === 'stopped') { + managementItems.push( + startDeployment(id)} icon={}> + {t('启动')} + , + ); + } + + if (managementItems.length > 0) { + dropdownItems.push(); + dropdownItems.push(...managementItems); + } + + const configItems = []; + if (!isEnded && (normalizedStatus === 'running' || normalizedStatus === 'deployment requested')) { + configItems.push( + onExtendDuration?.(record)} icon={}> + {t('延长时长')} + , + ); + } + // if (!isEnded && normalizedStatus === 'running') { + // configItems.push( + // onUpdateConfig?.(record)} icon={}> + // {t('更新配置')} + // , + // ); + // } + + if (configItems.length > 0) { + dropdownItems.push(); + dropdownItems.push(...configItems); + } + if (!isEnded) { + dropdownItems.push(); + dropdownItems.push( + }> + {t('销毁容器')} + , + ); + } + + const allActions = {dropdownItems}; + const hasDropdown = dropdownItems.length > 0; + + return ( +
+ + + {hasDropdown && ( + +
+ ); + }, + }, + ]; + + return columns; +}; diff --git a/web/src/components/table/model-deployments/DeploymentsFilters.jsx b/web/src/components/table/model-deployments/DeploymentsFilters.jsx new file mode 100644 index 000000000..b268f6832 --- /dev/null +++ b/web/src/components/table/model-deployments/DeploymentsFilters.jsx @@ -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 . + +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 ( +
{ + setFormApi(formApi); + formApiRef.current = formApi; + }} + className='w-full md:w-auto order-1 md:order-2' + > +
+
+ } + showClear + size='small' + pure + /> +
+ +
+ +
+ +
+ + + + + +
+
+
+ ); +}; + +export default DeploymentsFilters; diff --git a/web/src/components/table/model-deployments/DeploymentsTable.jsx b/web/src/components/table/model-deployments/DeploymentsTable.jsx new file mode 100644 index 000000000..3c5687e61 --- /dev/null +++ b/web/src/components/table/model-deployments/DeploymentsTable.jsx @@ -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 . + +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 ( + <> + { + setSelectedKeys(selectedRows); + }, + }} + empty={ + } + darkModeImage={ + + } + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className='rounded-xl overflow-hidden' + size='middle' + loading={loading || searching} + /> + + {/* Enhanced Modals */} + setShowLogsModal(false)} + deployment={selectedDeployment} + t={t} + /> + + setShowExtendModal(false)} + deployment={selectedDeployment} + onSuccess={handleModalSuccess} + t={t} + /> + + setShowDetailsModal(false)} + deployment={selectedDeployment} + t={t} + /> + + setShowConfigModal(false)} + deployment={selectedDeployment} + onSuccess={handleModalSuccess} + t={t} + /> + + setShowConfirmDialog(false)} + onConfirm={handleConfirmAction} + title={t('确认操作')} + type="danger" + deployment={selectedDeployment} + operation={confirmOperation} + t={t} + /> + + ); +}; + +export default DeploymentsTable; diff --git a/web/src/components/table/model-deployments/index.jsx b/web/src/components/table/model-deployments/index.jsx new file mode 100644 index 000000000..e3332027e --- /dev/null +++ b/web/src/components/table/model-deployments/index.jsx @@ -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 . + +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 */} + + + setShowCreateModal(false)} + onSuccess={refresh} + t={t} + /> + + setShowColumnSelector(false)} + visibleColumns={visibleColumns} + onVisibleColumnsChange={setVisibleColumns} + columnKeys={COLUMN_KEYS} + t={t} + /> + + {/* Main Content */} + + + + + } + 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} + > + + + + ); +}; + +export default DeploymentsPage; diff --git a/web/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx b/web/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx new file mode 100644 index 000000000..3589ec491 --- /dev/null +++ b/web/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx @@ -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 . + +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 ( + + + + + + } + > +
+ handleSelectAll(e.target.checked)} + > + {t('全选')} + +
+
+ {columnOptions.map(({ key, label, required }) => ( +
+ + handleColumnVisibilityChange(key, e.target.checked) + } + > + {label} + +
+ ))} +
+
+ ); +}; + +export default ColumnSelectorModal; diff --git a/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx b/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx new file mode 100644 index 000000000..f462292a3 --- /dev/null +++ b/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx @@ -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 . + +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 ( + +
+ + {t('此操作具有风险,请确认要继续执行')}。 + + + {t('请输入部署名称以完成二次确认')}: + + {requiredText || t('未知部署')} + + + + {!isConfirmed && confirmText && ( + + {t('部署名称不匹配,请检查后重新输入')} + + )} +
+
+ ); +}; + +export default ConfirmationDialog; diff --git a/web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx b/web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx new file mode 100644 index 000000000..e32f29524 --- /dev/null +++ b/web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx @@ -0,0 +1,1462 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { + Modal, + Form, + Input, + Select, + InputNumber, + Switch, + Collapse, + Card, + Divider, + Button, + Typography, + Space, + Spin, + Tag, + Row, + Col, + Tooltip, + Radio, +} from '@douyinfe/semi-ui'; +import { IconPlus, IconMinus, IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; +import { API } from '../../../../helpers'; +import { showError, showSuccess, copy } from '../../../../helpers'; + +const { Text, Title } = Typography; +const { Option } = Select; +const RadioGroup = Radio.Group; + +const BUILTIN_IMAGE = 'ollama/ollama:latest'; +const DEFAULT_TRAFFIC_PORT = 11434; + +const generateRandomKey = () => { + try { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `ionet-${crypto.randomUUID().replace(/-/g, '')}`; + } + } catch (error) { + // ignore + } + return `ionet-${Math.random().toString(36).slice(2)}${Math.random() + .toString(36) + .slice(2)}`; +}; + +const CreateDeploymentModal = ({ visible, onCancel, onSuccess, t }) => { + const [formApi, setFormApi] = useState(null); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + // Resource data states + const [hardwareTypes, setHardwareTypes] = useState([]); + const [hardwareTotalAvailable, setHardwareTotalAvailable] = useState(null); + const [locations, setLocations] = useState([]); + const [locationTotalAvailable, setLocationTotalAvailable] = useState(null); + const [availableReplicas, setAvailableReplicas] = useState([]); + const [priceEstimation, setPriceEstimation] = useState(null); + + // UI states + const [loadingHardware, setLoadingHardware] = useState(false); + const [loadingLocations, setLoadingLocations] = useState(false); + const [loadingReplicas, setLoadingReplicas] = useState(false); + const [loadingPrice, setLoadingPrice] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [envVariables, setEnvVariables] = useState([{ key: '', value: '' }]); + const [secretEnvVariables, setSecretEnvVariables] = useState([{ key: '', value: '' }]); + const [entrypoint, setEntrypoint] = useState(['']); + const [args, setArgs] = useState(['']); + const [imageMode, setImageMode] = useState('builtin'); + const [autoOllamaKey, setAutoOllamaKey] = useState(''); + const customSecretEnvRef = useRef(null); + const customEnvRef = useRef(null); + const customImageRef = useRef(''); + const customTrafficPortRef = useRef(null); + const prevImageModeRef = useRef('builtin'); + const basicSectionRef = useRef(null); + const priceSectionRef = useRef(null); + const advancedSectionRef = useRef(null); + const locationRequestIdRef = useRef(0); + const replicaRequestIdRef = useRef(0); + const [formDefaults, setFormDefaults] = useState({ + resource_private_name: '', + image_url: BUILTIN_IMAGE, + gpus_per_container: 1, + replica_count: 1, + duration_hours: 1, + traffic_port: DEFAULT_TRAFFIC_PORT, + location_ids: [], + }); + const [formKey, setFormKey] = useState(0); + const [priceCurrency, setPriceCurrency] = useState('usdc'); + const normalizeCurrencyValue = (value) => { + if (typeof value === 'string') return value.toLowerCase(); + if (value && typeof value === 'object') { + if (typeof value.value === 'string') return value.value.toLowerCase(); + if (typeof value.target?.value === 'string') { + return value.target.value.toLowerCase(); + } + } + return 'usdc'; + }; + + const handleCurrencyChange = (value) => { + const normalized = normalizeCurrencyValue(value); + setPriceCurrency(normalized); + }; + + const hardwareLabelMap = useMemo(() => { + const map = {}; + hardwareTypes.forEach((hardware) => { + const displayName = hardware.brand_name + ? `${hardware.brand_name} ${hardware.name}`.trim() + : hardware.name; + map[hardware.id] = displayName; + }); + return map; + }, [hardwareTypes]); + + const locationLabelMap = useMemo(() => { + const map = {}; + locations.forEach((location) => { + map[location.id] = location.name; + }); + return map; + }, [locations]); + + // Form values for price calculation + const [selectedHardwareId, setSelectedHardwareId] = useState(null); + const [selectedLocationIds, setSelectedLocationIds] = useState([]); + const [gpusPerContainer, setGpusPerContainer] = useState(1); + const [durationHours, setDurationHours] = useState(1); + const [replicaCount, setReplicaCount] = useState(1); + + // Load initial data when modal opens + useEffect(() => { + if (visible) { + loadHardwareTypes(); + resetFormState(); + } + }, [visible]); + + // Load available replicas when hardware or locations change + useEffect(() => { + if (!visible) { + return; + } + if (selectedHardwareId && gpusPerContainer > 0) { + loadAvailableReplicas(selectedHardwareId, gpusPerContainer); + } + }, [selectedHardwareId, gpusPerContainer, visible]); + + // Calculate price when relevant parameters change + useEffect(() => { + if (!visible) { + return; + } + if ( + selectedHardwareId && + selectedLocationIds.length > 0 && + gpusPerContainer > 0 && + durationHours > 0 && + replicaCount > 0 + ) { + calculatePrice(); + } else { + setPriceEstimation(null); + } + }, [ + selectedHardwareId, + selectedLocationIds, + gpusPerContainer, + durationHours, + replicaCount, + priceCurrency, + visible, + ]); + + useEffect(() => { + if (!visible) { + return; + } + const prevMode = prevImageModeRef.current; + if (prevMode === imageMode) { + return; + } + + if (imageMode === 'builtin') { + if (prevMode === 'custom') { + if (formApi) { + customImageRef.current = formApi.getValue('image_url') || customImageRef.current; + customTrafficPortRef.current = formApi.getValue('traffic_port') ?? customTrafficPortRef.current; + } + customSecretEnvRef.current = secretEnvVariables.map((item) => ({ ...item })); + customEnvRef.current = envVariables.map((item) => ({ ...item })); + } + const newKey = generateRandomKey(); + setAutoOllamaKey(newKey); + setSecretEnvVariables([{ key: 'OLLAMA_API_KEY', value: newKey }]); + setEnvVariables([{ key: '', value: '' }]); + if (formApi) { + formApi.setValue('image_url', BUILTIN_IMAGE); + formApi.setValue('traffic_port', DEFAULT_TRAFFIC_PORT); + } + } else { + const restoredSecrets = + customSecretEnvRef.current && customSecretEnvRef.current.length > 0 + ? customSecretEnvRef.current.map((item) => ({ ...item })) + : [{ key: '', value: '' }]; + const restoredEnv = + customEnvRef.current && customEnvRef.current.length > 0 + ? customEnvRef.current.map((item) => ({ ...item })) + : [{ key: '', value: '' }]; + setSecretEnvVariables(restoredSecrets); + setEnvVariables(restoredEnv); + if (formApi) { + const restoredImage = customImageRef.current || ''; + formApi.setValue('image_url', restoredImage); + if (customTrafficPortRef.current) { + formApi.setValue('traffic_port', customTrafficPortRef.current); + } + } + } + + prevImageModeRef.current = imageMode; + }, [imageMode, visible, secretEnvVariables, envVariables, formApi]); + + useEffect(() => { + if (!visible || !formApi) { + return; + } + if (imageMode === 'builtin') { + formApi.setValue('image_url', BUILTIN_IMAGE); + } + }, [formApi, imageMode, visible]); + + useEffect(() => { + if (!formApi) { + return; + } + if (selectedHardwareId !== null && selectedHardwareId !== undefined) { + formApi.setValue('hardware_id', selectedHardwareId); + } + }, [formApi, selectedHardwareId]); + + useEffect(() => { + if (!formApi) { + return; + } + formApi.setValue('location_ids', selectedLocationIds); + }, [formApi, selectedLocationIds]); + + useEffect(() => { + if (!visible) { + return; + } + if (selectedHardwareId) { + loadLocations(selectedHardwareId); + } else { + setLocations([]); + setSelectedLocationIds([]); + setAvailableReplicas([]); + setLocationTotalAvailable(null); + setLoadingLocations(false); + setLoadingReplicas(false); + locationRequestIdRef.current = 0; + replicaRequestIdRef.current = 0; + if (formApi) { + formApi.setValue('location_ids', []); + } + } + }, [selectedHardwareId, visible, formApi]); + + const resetFormState = () => { + const randomName = `deployment-${Math.random().toString(36).slice(2, 8)}`; + const generatedKey = generateRandomKey(); + + setSelectedHardwareId(null); + setSelectedLocationIds([]); + setGpusPerContainer(1); + setDurationHours(1); + setReplicaCount(1); + setPriceEstimation(null); + setAvailableReplicas([]); + setLocations([]); + setLocationTotalAvailable(null); + setHardwareTotalAvailable(null); + setEnvVariables([{ key: '', value: '' }]); + setSecretEnvVariables([{ key: 'OLLAMA_API_KEY', value: generatedKey }]); + setEntrypoint(['']); + setArgs(['']); + setShowAdvanced(false); + setImageMode('builtin'); + setAutoOllamaKey(generatedKey); + customSecretEnvRef.current = null; + customEnvRef.current = null; + customImageRef.current = ''; + customTrafficPortRef.current = DEFAULT_TRAFFIC_PORT; + prevImageModeRef.current = 'builtin'; + setFormDefaults({ + resource_private_name: randomName, + image_url: BUILTIN_IMAGE, + gpus_per_container: 1, + replica_count: 1, + duration_hours: 1, + traffic_port: DEFAULT_TRAFFIC_PORT, + location_ids: [], + }); + setFormKey((prev) => prev + 1); + setPriceCurrency('usdc'); + }; + + const arraysEqual = (a = [], b = []) => + a.length === b.length && a.every((value, index) => value === b[index]); + + const loadHardwareTypes = 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, + ); + } else { + showError(t('获取硬件类型失败: ') + response.data.message); + } + } catch (error) { + showError(t('获取硬件类型失败: ') + error.message); + } finally { + setLoadingHardware(false); + } + }; + + const loadLocations = async (hardwareId) => { + if (!hardwareId) { + setLocations([]); + setLocationTotalAvailable(null); + return; + } + + const requestId = Date.now(); + locationRequestIdRef.current = requestId; + setLoadingLocations(true); + setLocations([]); + setLocationTotalAvailable(null); + + try { + const response = await API.get('/api/deployments/locations', { + params: { hardware_id: hardwareId }, + }); + + if (locationRequestIdRef.current !== requestId) { + return; + } + + 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); + setLocationTotalAvailable( + hasProvidedTotal ? providedTotal : fallbackTotal, + ); + } else { + showError(t('获取部署位置失败: ') + response.data.message); + setLocations([]); + setLocationTotalAvailable(null); + } + } catch (error) { + if (locationRequestIdRef.current === requestId) { + showError(t('获取部署位置失败: ') + error.message); + setLocations([]); + setLocationTotalAvailable(null); + } + } finally { + if (locationRequestIdRef.current === requestId) { + setLoadingLocations(false); + } + } + }; + + const loadAvailableReplicas = async (hardwareId, gpuCount) => { + if (!hardwareId || !gpuCount) { + setAvailableReplicas([]); + setLocationTotalAvailable(null); + setLoadingReplicas(false); + return; + } + + const requestId = Date.now(); + replicaRequestIdRef.current = requestId; + setLoadingReplicas(true); + setAvailableReplicas([]); + + try { + const response = await API.get( + `/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`, + ); + + if (replicaRequestIdRef.current !== requestId) { + return; + } + + if (response.data.success) { + const replicasList = response.data.data?.replicas || []; + const filteredReplicas = replicasList.filter( + (replica) => (replica.available_count || 0) > 0, + ); + setAvailableReplicas(filteredReplicas); + const totalAvailableForHardware = filteredReplicas.reduce( + (total, replica) => total + (replica.available_count || 0), + 0, + ); + setLocationTotalAvailable(totalAvailableForHardware); + } else { + showError(t('获取可用资源失败: ') + response.data.message); + setAvailableReplicas([]); + setLocationTotalAvailable(null); + } + } catch (error) { + if (replicaRequestIdRef.current === requestId) { + console.error('Load available replicas error:', error); + setAvailableReplicas([]); + setLocationTotalAvailable(null); + } + } finally { + if (replicaRequestIdRef.current === requestId) { + setLoadingReplicas(false); + } + } + }; + + const calculatePrice = async () => { + try { + setLoadingPrice(true); + const requestData = { + location_ids: selectedLocationIds, + hardware_id: selectedHardwareId, + gpus_per_container: gpusPerContainer, + duration_hours: durationHours, + replica_count: replicaCount, + currency: priceCurrency?.toLowerCase?.() || priceCurrency, + duration_type: 'hour', + duration_qty: durationHours, + hardware_qty: gpusPerContainer, + }; + + const response = await API.post('/api/deployments/price-estimation', requestData); + if (response.data.success) { + setPriceEstimation(response.data.data); + } else { + showError(t('价格计算失败: ') + response.data.message); + setPriceEstimation(null); + } + } catch (error) { + console.error('Price calculation error:', error); + setPriceEstimation(null); + } finally { + setLoadingPrice(false); + } + }; + + const handleSubmit = async (values) => { + try { + setSubmitting(true); + + // Prepare environment variables + const envVars = {}; + envVariables.forEach(env => { + if (env.key && env.value) { + envVars[env.key] = env.value; + } + }); + + const secretEnvVars = {}; + secretEnvVariables.forEach(env => { + if (env.key && env.value) { + secretEnvVars[env.key] = env.value; + } + }); + + if (imageMode === 'builtin') { + if (!secretEnvVars.OLLAMA_API_KEY) { + const ensuredKey = autoOllamaKey || generateRandomKey(); + secretEnvVars.OLLAMA_API_KEY = ensuredKey; + setAutoOllamaKey(ensuredKey); + } + } + + // Prepare entrypoint and args + const cleanEntrypoint = entrypoint.filter(item => item.trim() !== ''); + const cleanArgs = args.filter(item => item.trim() !== ''); + + const resolvedImage = imageMode === 'builtin' ? BUILTIN_IMAGE : values.image_url; + const resolvedTrafficPort = + values.traffic_port || (imageMode === 'builtin' ? DEFAULT_TRAFFIC_PORT : undefined); + + const requestData = { + resource_private_name: values.resource_private_name, + duration_hours: values.duration_hours, + gpus_per_container: values.gpus_per_container, + hardware_id: values.hardware_id, + location_ids: values.location_ids, + container_config: { + replica_count: values.replica_count, + env_variables: envVars, + secret_env_variables: secretEnvVars, + entrypoint: cleanEntrypoint.length > 0 ? cleanEntrypoint : undefined, + args: cleanArgs.length > 0 ? cleanArgs : undefined, + traffic_port: resolvedTrafficPort, + }, + registry_config: { + image_url: resolvedImage, + registry_username: values.registry_username || undefined, + registry_secret: values.registry_secret || undefined, + }, + }; + + const response = await API.post('/api/deployments', requestData); + + if (response.data.success) { + showSuccess(t('容器创建成功')); + onSuccess?.(response.data.data); + onCancel(); + } else { + showError(t('容器创建失败: ') + response.data.message); + } + } catch (error) { + showError(t('容器创建失败: ') + error.message); + } finally { + setSubmitting(false); + } + }; + + const handleAddEnvVariable = (type) => { + if (type === 'env') { + setEnvVariables([...envVariables, { key: '', value: '' }]); + } else { + setSecretEnvVariables([...secretEnvVariables, { key: '', value: '' }]); + } + }; + + const handleRemoveEnvVariable = (index, type) => { + if (type === 'env') { + const newEnvVars = envVariables.filter((_, i) => i !== index); + setEnvVariables(newEnvVars.length > 0 ? newEnvVars : [{ key: '', value: '' }]); + } else { + const newSecretEnvVars = secretEnvVariables.filter((_, i) => i !== index); + setSecretEnvVariables(newSecretEnvVars.length > 0 ? newSecretEnvVars : [{ key: '', value: '' }]); + } + }; + + const handleEnvVariableChange = (index, field, value, type) => { + if (type === 'env') { + const newEnvVars = [...envVariables]; + newEnvVars[index][field] = value; + setEnvVariables(newEnvVars); + } else { + const newSecretEnvVars = [...secretEnvVariables]; + newSecretEnvVars[index][field] = value; + setSecretEnvVariables(newSecretEnvVars); + } + }; + + const handleArrayFieldChange = (index, value, type) => { + if (type === 'entrypoint') { + const newEntrypoint = [...entrypoint]; + newEntrypoint[index] = value; + setEntrypoint(newEntrypoint); + } else { + const newArgs = [...args]; + newArgs[index] = value; + setArgs(newArgs); + } + }; + + const handleAddArrayField = (type) => { + if (type === 'entrypoint') { + setEntrypoint([...entrypoint, '']); + } else { + setArgs([...args, '']); + } + }; + + const handleRemoveArrayField = (index, type) => { + if (type === 'entrypoint') { + const newEntrypoint = entrypoint.filter((_, i) => i !== index); + setEntrypoint(newEntrypoint.length > 0 ? newEntrypoint : ['']); + } else { + const newArgs = args.filter((_, i) => i !== index); + setArgs(newArgs.length > 0 ? newArgs : ['']); + } + }; + + useEffect(() => { + if (!visible) { + return; + } + + if (!selectedHardwareId) { + if (selectedLocationIds.length > 0) { + setSelectedLocationIds([]); + if (formApi) { + formApi.setValue('location_ids', []); + } + } + return; + } + + const validLocationIds = + availableReplicas.length > 0 + ? availableReplicas.map((item) => item.location_id) + : locations.map((location) => location.id); + + if (validLocationIds.length === 0) { + if (selectedLocationIds.length > 0) { + setSelectedLocationIds([]); + if (formApi) { + formApi.setValue('location_ids', []); + } + } + return; + } + + if (selectedLocationIds.length === 0) { + return; + } + + const filteredSelection = selectedLocationIds.filter((id) => + validLocationIds.includes(id), + ); + + if (!arraysEqual(selectedLocationIds, filteredSelection)) { + setSelectedLocationIds(filteredSelection); + if (formApi) { + formApi.setValue('location_ids', filteredSelection); + } + } + }, [ + availableReplicas, + locations, + selectedHardwareId, + selectedLocationIds, + visible, + formApi, + ]); + + const maxAvailableReplicas = useMemo(() => { + if (!selectedLocationIds.length) return 0; + + if (availableReplicas.length > 0) { + return availableReplicas + .filter((replica) => selectedLocationIds.includes(replica.location_id)) + .reduce((total, replica) => total + (replica.available_count || 0), 0); + } + + return locations + .filter((location) => selectedLocationIds.includes(location.id)) + .reduce((total, location) => { + const availableValue = Number(location.available); + return total + (Number.isNaN(availableValue) ? 0 : availableValue); + }, 0); + }, [availableReplicas, selectedLocationIds, locations]); + + const isPriceReady = useMemo( + () => + selectedHardwareId && + selectedLocationIds.length > 0 && + gpusPerContainer > 0 && + durationHours > 0 && + replicaCount > 0, + [ + selectedHardwareId, + selectedLocationIds, + gpusPerContainer, + durationHours, + replicaCount, + ], + ); + + const currencyLabel = (priceEstimation?.currency || priceCurrency || '').toUpperCase(); + const selectedHardwareLabel = selectedHardwareId + ? hardwareLabelMap[selectedHardwareId] + : ''; + const selectedLocationNames = selectedLocationIds + .map((id) => locationLabelMap[id]) + .filter(Boolean); + const totalGpuHours = + Number(gpusPerContainer || 0) * + Number(replicaCount || 0) * + Number(durationHours || 0); + const priceSummaryItems = [ + { + key: 'hardware', + label: t('硬件类型'), + value: selectedHardwareLabel || '--', + }, + { + key: 'locations', + label: t('部署位置'), + value: selectedLocationNames.length ? selectedLocationNames.join('、') : '--', + }, + { + key: 'replicas', + label: t('副本数量'), + value: (replicaCount ?? 0).toString(), + }, + { + key: 'gpus', + label: t('每容器GPU数量'), + value: (gpusPerContainer ?? 0).toString(), + }, + { + key: 'duration', + label: t('运行时长(小时)'), + value: durationHours ? durationHours.toString() : '0', + }, + { + key: 'gpu-hours', + label: t('总 GPU 小时'), + value: totalGpuHours > 0 ? totalGpuHours.toLocaleString() : '0', + }, + ]; + + const scrollToSection = (ref) => { + if (ref?.current && typeof ref.current.scrollIntoView === 'function') { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + + const priceUnavailableContent = ( +
+ {loadingPrice ? ( + + + + {t('价格计算中...')} + + + ) : ( + + {isPriceReady + ? t('价格暂时不可用,请稍后重试') + : t('完成硬件类型、部署位置、副本数量等配置后,将自动计算价格')} + + )} +
+ ); + + useEffect(() => { + if (!visible || !formApi) { + return; + } + if (maxAvailableReplicas > 0 && replicaCount > maxAvailableReplicas) { + setReplicaCount(maxAvailableReplicas); + formApi.setValue('replica_count', maxAvailableReplicas); + } + }, [maxAvailableReplicas, replicaCount, visible, formApi]); + + return ( + formApi?.submitForm()} + okText={t('创建')} + cancelText={t('取消')} + width={800} + confirmLoading={submitting} + style={{ top: 20 }} + > +
+ + + + + + +
+ + {t('部署配置')} + + + +
+ {t('镜像选择')} +
+ setImageMode(value?.target?.value ?? value)} + > + {t('内置 Ollama 镜像')} + {t('自定义镜像')} + +
+
+ + { + if (imageMode === 'custom') { + customImageRef.current = value; + } + }} + /> + + {imageMode === 'builtin' && ( + + + {t('系统已为该部署准备 Ollama 镜像与随机 API Key')} + + + + + )} + + + + { + setSelectedHardwareId(value); + setSelectedLocationIds([]); + if (formApi) { + formApi.setValue('location_ids', []); + } + }} + style={{ width: '100%' }} + dropdownStyle={{ maxHeight: 360, overflowY: 'auto' }} + renderSelectedItem={(optionNode) => + optionNode + ? hardwareLabelMap[optionNode?.value] || + optionNode?.label || + optionNode?.value || + '' + : '' + } + > + {hardwareTypes.map((hardware) => { + const displayName = hardware.brand_name + ? `${hardware.brand_name} ${hardware.name}`.trim() + : hardware.name; + const availableCount = + typeof hardware.available_count === 'number' + ? hardware.available_count + : 0; + const hasAvailability = availableCount > 0; + + return ( + + ); + })} + + + + h.id === selectedHardwareId)?.max_gpus : 8} + step={1} + innerButtons + rules={[{ required: true, message: t('请输入GPU数量') }]} + onChange={(value) => setGpusPerContainer(value)} + style={{ width: '100%' }} + /> + + + + {typeof hardwareTotalAvailable === 'number' && ( + + {t('全部硬件总可用资源')}: {hardwareTotalAvailable} + + )} + + + {t('部署位置')} + {loadingReplicas && } + + } + placeholder={ + !selectedHardwareId + ? t('请先选择硬件类型') + : loadingLocations || loadingReplicas + ? t('正在加载可用部署位置...') + : t('选择部署位置(可多选)') + } + multiple + loading={loadingLocations || loadingReplicas} + disabled={!selectedHardwareId || loadingLocations || loadingReplicas} + rules={[{ required: true, message: t('请选择至少一个部署位置') }]} + onChange={(value) => setSelectedLocationIds(value)} + style={{ width: '100%' }} + dropdownStyle={{ maxHeight: 360, overflowY: 'auto' }} + renderSelectedItem={(optionNode) => ({ + isRenderInTag: true, + content: + !optionNode + ? '' + : loadingLocations || loadingReplicas + ? t('部署位置加载中...') + : locationLabelMap[optionNode?.value] || + optionNode?.label || + optionNode?.value || + '', + })} + > + {locations.map((location) => { + const replicaEntry = availableReplicas.find( + (r) => r.location_id === location.id, + ); + const hasReplicaData = availableReplicas.length > 0; + const availableCount = hasReplicaData + ? replicaEntry?.available_count ?? 0 + : (() => { + const numeric = Number(location.available); + return Number.isNaN(numeric) ? 0 : numeric; + })(); + const locationLabel = + location.region || + location.country || + (location.iso2 ? location.iso2.toUpperCase() : '') || + location.code || + ''; + const disableOption = hasReplicaData + ? availableCount === 0 + : typeof location.available === 'number' + ? location.available === 0 + : false; + + return ( + + ); + })} + + + {typeof locationTotalAvailable === 'number' && ( + + {t('全部地区总可用资源')}: {locationTotalAvailable} + + )} + + + + setReplicaCount(value)} + style={{ width: '100%' }} + /> + {maxAvailableReplicas > 0 && ( + + {t('最大可用')}: {maxAvailableReplicas} + + )} + + + setDurationHours(value)} + style={{ width: '100%' }} + /> + + + + {t('流量端口')} + + + + + } + placeholder={DEFAULT_TRAFFIC_PORT} + min={1} + max={65535} + style={{ width: '100%' }} + disabled={imageMode === 'builtin'} + /> + + + +
+ + + + {t('镜像仓库配置')} + + + + + + + + + + + + + + {t('容器启动配置')} + +
+ {t('启动命令 (Entrypoint)')} + {entrypoint.map((cmd, index) => ( +
+ handleArrayFieldChange(index, value, 'entrypoint')} + style={{ flex: 1, marginRight: 8 }} + /> +
+ ))} + +
+ +
+ {t('启动参数 (Args)')} + {args.map((arg, index) => ( +
+ handleArrayFieldChange(index, value, 'args')} + style={{ flex: 1, marginRight: 8 }} + /> +
+ ))} + +
+
+ + + + + {t('环境变量')} + +
+ {t('普通环境变量')} + {envVariables.map((env, index) => ( + + + handleEnvVariableChange(index, 'key', value, 'env')} + /> + + + handleEnvVariableChange(index, 'value', value, 'env')} + /> + + + +
+ +
+ {t('密钥环境变量')} + {secretEnvVariables.map((env, index) => { + const isAutoSecret = + imageMode === 'builtin' && env.key === 'OLLAMA_API_KEY'; + return ( + + + handleEnvVariableChange(index, 'key', value, 'secret')} + disabled={isAutoSecret} + /> + + + handleEnvVariableChange(index, 'value', value, 'secret')} + disabled={isAutoSecret} + /> + + + +
+
+
+
+
+
+
+ +
+ +
+ + {t('价格预估')} + + + + {t('计价币种')} + + + USDC + IOCOIN + + + {currencyLabel} + + +
+ + {priceEstimation ? ( +
+
+
+ + {t('预估总费用')} + +
+ {typeof priceEstimation.estimated_cost === 'number' + ? `${priceEstimation.estimated_cost.toFixed(4)} ${currencyLabel}` + : '--'} +
+
+
+ + {t('小时费率')} + + + {typeof priceEstimation.price_breakdown?.hourly_rate === 'number' + ? `${priceEstimation.price_breakdown.hourly_rate.toFixed(4)} ${currencyLabel}/h` + : '--'} + +
+
+ + {t('计算成本')} + + + {typeof priceEstimation.price_breakdown?.compute_cost === 'number' + ? `${priceEstimation.price_breakdown.compute_cost.toFixed(4)} ${currencyLabel}` + : '--'} + +
+
+ +
+ {priceSummaryItems.map((item) => ( +
+ + {item.label} + + {item.value} +
+ ))} +
+
+ ) : ( + priceUnavailableContent + )} + + {priceEstimation && loadingPrice && ( + + + + {t('价格重新计算中...')} + + + )} +
+
+ +
+
+ ); +}; + +export default CreateDeploymentModal; diff --git a/web/src/components/table/model-deployments/modals/EditDeploymentModal.jsx b/web/src/components/table/model-deployments/modals/EditDeploymentModal.jsx new file mode 100644 index 000000000..4d95b91ac --- /dev/null +++ b/web/src/components/table/model-deployments/modals/EditDeploymentModal.jsx @@ -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 . + +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 ( + + + {title} + + } + visible={visible} + onCancel={handleClose} + width={isMobile ? '100%' : 600} + bodyStyle={{ padding: 0 }} + maskClosable={false} + closeOnEsc={true} + > +
+ +
+ + + {t('修改部署名称')} + + + + + + + + + {isEdit && ( +
+ {t('部署ID')}: + {editingDeployment.id} +
+ {t('当前状态')}: + + {editingDeployment.status} + +
+ )} +
+
+
+
+ +
+ + + + +
+
+ ); +}; + +export default EditDeploymentModal; diff --git a/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx b/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx new file mode 100644 index 000000000..3b357bc94 --- /dev/null +++ b/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx @@ -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 . + +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 ( + + + {t('延长容器时长')} + + } + 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' + > +
+ +
+
+ + {deployment?.container_name || deployment?.deployment_name} + +
+ + ID: {deployment?.id} + +
+
+
+
+ + {resolvedHardwareName} + {gpuCount ? ` x${gpuCount}` : ''} + +
+ + {t('当前剩余')}: {currentRemainingTime} + +
+
+
+ + } + title={t('重要提醒')} + description={ +
+

+ {t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')} +

+

+ {t('延长操作一旦确认无法撤销,费用将立即扣除。')} +

+
+ } + /> + +
(formRef.current = api)} + layout='vertical' + onValueChange={(values) => { + if (values.duration_hours !== undefined) { + const numericValue = Number(values.duration_hours); + setDurationHours(Number.isFinite(numericValue) ? numericValue : 0); + } + }} + > + + + +
+ + {t('快速选择')}: + + + {[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => ( + + ))} + +
+ + + + + + {t('费用预估')} +
+ } + className='border border-green-200' + > + {priceEstimation ? ( +
+
+ {t('延长时长')}: + + {Math.round(durationHours)} {t('小时')} + +
+ +
+ {t('硬件配置')}: + + {resolvedHardwareName} + {gpuCount ? ` x${gpuCount}` : ''} + +
+ + {containers ? ( +
+ {t('容器数量')}: + {containers} +
+ ) : null} + +
+ {t('单GPU小时费率')}: + + {typeof hourlyRate === 'number' + ? `${hourlyRate.toFixed(4)} ${currencyLabel}` + : '--'} + +
+ + {typeof computeCost === 'number' && ( +
+ {t('计算成本')}: + + {computeCost.toFixed(4)} {currencyLabel} + +
+ )} + + + +
+ + {t('预估总费用')}: + + + {typeof estimatedTotalCost === 'number' + ? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}` + : '--'} + +
+ +
+
+ +
+ + {t('延长后总时长')}: {newTotalTime} + +
+ + {t('预估费用仅供参考,实际费用可能略有差异')} + +
+
+
+
+ ) : ( +
+ {costLoading ? ( + + + {t('计算费用中...')} + + ) : priceError ? ( + {priceError} + ) : deploymentDetails ? ( + {t('请输入延长时长')} + ) : ( + {t('加载详情中...')} + )} +
+ )} + + +
+
+ +
+ + {t('确认延长容器时长')} + +
+ + {t('点击"确认延长"后将立即扣除费用并延长容器运行时间')} + +
+
+
+
+ +
+ ); +}; + +export default ExtendDurationModal; diff --git a/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx b/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx new file mode 100644 index 000000000..3b21b8b68 --- /dev/null +++ b/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx @@ -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 . + +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 ( + + + {t('更新容器配置')} + + } + visible={visible} + onCancel={handleCancel} + onOk={handleUpdate} + okText={t('更新配置')} + cancelText={t('取消')} + confirmLoading={loading} + width={700} + className="update-config-modal" + > +
+ {/* Container Info */} + +
+
+ + {deployment?.container_name} + +
+ + ID: {deployment?.id} + +
+
+ {deployment?.status} +
+
+ + {/* Warning Banner */} + } + title={t('重要提醒')} + description={ +
+

{t('更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。')}

+

{t('某些配置更改可能需要几分钟才能生效。')}

+
+ } + /> + +
(formRef.current = api)} + layout="vertical" + > + + {/* Docker Configuration */} + + + {t('Docker 配置')} +
+ } + itemKey="docker" + > +
+ + + + + +
+ + + {/* Network Configuration */} + + + {t('网络配置')} + + } + itemKey="network" + > + + + + {/* Startup Configuration */} + + + {t('启动配置')} + + } + itemKey="startup" + > +
+ + + +
+
+ + {/* Environment Variables */} + + + {t('环境变量')} + {envVars.length} + + } + itemKey="env" + > +
+ {/* Regular Environment Variables */} +
+
+ {t('普通环境变量')} + +
+ + {envVars.map((envVar, index) => ( +
+ updateEnvVar(index, 'key', value)} + style={{ flex: 1 }} + /> + = + updateEnvVar(index, 'value', value)} + style={{ flex: 2 }} + /> +
+ ))} + + {envVars.length === 0 && ( +
+ {t('暂无环境变量')} +
+ )} +
+ + + + {/* Secret Environment Variables */} +
+
+
+ {t('机密环境变量')} + + {t('加密存储')} + +
+ +
+ + {secretEnvVars.map((envVar, index) => ( +
+ updateSecretEnvVar(index, 'key', value)} + style={{ flex: 1 }} + /> + = + updateSecretEnvVar(index, 'value', value)} + style={{ flex: 2 }} + /> +
+ ))} + + {secretEnvVars.length === 0 && ( +
+ {t('暂无机密环境变量')} +
+ )} + + +
+
+
+ + + + {/* Final Warning */} +
+
+ +
+ + {t('配置更新确认')} + +
+ + {t('更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。')} + +
+
+
+
+ +
+ ); +}; + +export default UpdateConfigModal; \ No newline at end of file diff --git a/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx b/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx new file mode 100644 index 000000000..7967e96e5 --- /dev/null +++ b/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx @@ -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 . + +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 ( + + + {t('容器详情')} + + } + visible={visible} + onCancel={onCancel} + footer={ +
+ + +
+ } + width={800} + className="deployment-details-modal" + > + {loading && !details ? ( +
+ +
+ ) : details ? ( +
+ {/* Basic Info */} + + + {t('基本信息')} +
+ } + className="border-0 shadow-sm" + > + + + {details.deployment_name || details.id} + + + + )} + + + + {ctr.events && ctr.events.length > 0 && ( +
+ + {t('最近事件')} + +
+ {ctr.events.map((event, index) => ( +
+ + {event.time ? timestamp2string(event.time) : '--'} + + + {event.message || '--'} + +
+ ))} +
+
+ )} + + ))} + + )} + + + {/* Location Information */} + {details.locations && details.locations.length > 0 && ( + + + {t('部署位置')} + + } + className="border-0 shadow-sm" + > +
+ {details.locations.map((location) => ( + +
+ 🌍 + {location.name} ({location.iso2}) +
+
+ ))} +
+
+ )} + + {/* Cost Information */} + + + {t('费用信息')} + + } + className="border-0 shadow-sm" + > +
+
+ {t('已支付金额')} + + ${details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'} USDC + +
+ +
+
+ {t('计费开始')}: + {details.started_at ? timestamp2string(details.started_at) : 'N/A'} +
+
+ {t('预计结束')}: + {details.finished_at ? timestamp2string(details.finished_at) : 'N/A'} +
+
+
+
+ + {/* Time Information */} + + + {t('时间信息')} + + } + className="border-0 shadow-sm" + > +
+
+
+ {t('已运行时间')}: + + {Math.floor(details.compute_minutes_served / 60)}h {details.compute_minutes_served % 60}m + +
+
+ {t('剩余时间')}: + + {Math.floor(details.compute_minutes_remaining / 60)}h {details.compute_minutes_remaining % 60}m + +
+
+
+
+ {t('创建时间')}: + {timestamp2string(details.created_at)} +
+
+ {t('最后更新')}: + {timestamp2string(details.updated_at)} +
+
+
+
+ + ) : ( + + )} +
+ ); +}; + +export default ViewDetailsModal; diff --git a/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx b/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx new file mode 100644 index 000000000..18eb5535b --- /dev/null +++ b/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx @@ -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 . + +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 ( + + {t('未知状态')} + + ); + } + + 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 ( + + {t(config.label)} + + ); + }; + + 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) => ( +
+ {line} +
+ ); + + return ( + + + {t('容器日志')} + + - {deployment?.container_name || deployment?.id} + + + } + visible={visible} + onCancel={onCancel} + footer={null} + width={1000} + height={700} + className="logs-modal" + style={{ top: 20 }} + > +
+ {/* Controls */} + +
+ + + + } + placeholder={t('搜索日志内容')} + value={searchTerm} + onChange={setSearchTerm} + style={{ width: 200 }} + size="small" + /> + + + + {t('日志流')} + + + STDOUT + STDERR + + + +
+ + {t('自动刷新')} +
+ +
+ + {t('跟随日志')} +
+
+ + + +
+ + {/* Status Info */} + +
+ + + {t('共 {{count}} 条日志', { count: logLines.length })} + + {searchTerm && ( + + {t('(筛选后显示 {{count}} 条)', { count: filteredLogs.length })} + + )} + {autoRefresh && ( + + + {t('自动刷新中')} + + )} + + + + {t('状态')}: {deployment?.status || 'unknown'} + +
+ + {selectedContainerId !== ALL_CONTAINERS && ( + <> + +
+
+ + + {t('容器')} + + + {selectedContainerId} + + {renderContainerStatusTag(containerDetails?.status || currentContainer?.status)} + + + + {containerDetails?.public_url && ( + +
+ + {containerDetailsLoading ? ( +
+ +
+ ) : containerDetails ? ( +
+
+ + {t('硬件')} + + {containerDetails?.brand_name || currentContainer?.brand_name || t('未知品牌')} + {(containerDetails?.hardware || currentContainer?.hardware) ? ` · ${containerDetails?.hardware || currentContainer?.hardware}` : ''} + +
+
+ + {t('GPU/容器')} + {containerDetails?.gpus_per_container ?? currentContainer?.gpus_per_container ?? 0} +
+
+ + {t('创建时间')} + + {containerDetails?.created_at + ? timestamp2string(containerDetails.created_at) + : currentContainer?.created_at + ? timestamp2string(currentContainer.created_at) + : t('未知')} + +
+
+ + {t('运行时长')} + {containerDetails?.uptime_percent ?? currentContainer?.uptime_percent ?? 0}% +
+
+ ) : ( + + {t('暂无容器详情')} + + )} + + {containerDetails?.events && containerDetails.events.length > 0 && ( +
+ + {t('最近事件')} + +
+ {containerDetails.events.slice(0, 5).map((event, index) => ( +
+ + {event.time ? timestamp2string(event.time) : '--'} + + + {event.message} + +
+ ))} +
+
+ )} +
+ + )} +
+ + {/* Log Content */} +
+
+ {loading && logLines.length === 0 ? ( +
+ +
+ ) : filteredLogs.length === 0 ? ( + + ) : ( +
+ {filteredLogs.map((log, index) => renderLogEntry(log, index))} +
+ )} +
+ + {/* Footer status */} + {logLines.length > 0 && ( +
+ + {following ? t('正在跟随最新日志') : t('日志已加载')} + + + {t('最后更新')}: {lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'} + +
+ )} +
+
+
+ ); +}; + +export default ViewLogsModal; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 450c5799b..4be021866 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -73,6 +73,7 @@ import { Settings, CircleUser, Package, + Server, } from 'lucide-react'; // 获取侧边栏Lucide图标组件 @@ -114,6 +115,8 @@ export function getLucideIcon(key, selected = false) { return ; case 'models': return ; + case 'deployment': + return ; case 'setting': return ; default: diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index f3df1bcca..415a34a5d 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -35,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(); @@ -775,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: ( +
+ + +
+ ), + 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}`; @@ -1132,6 +1193,7 @@ export const useChannelsData = () => { updateAllChannelsBalance, updateChannelBalance, fixChannelsAbilities, + checkOllamaVersion, testChannel, batchTestModels, handleCloseModal, diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 76d74ac34..34f8f4aa0 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -61,6 +61,7 @@ export const useSidebar = () => { enabled: true, channel: true, models: true, + deployment: true, redemption: true, user: true, setting: true, diff --git a/web/src/hooks/model-deployments/useDeploymentResources.js b/web/src/hooks/model-deployments/useDeploymentResources.js new file mode 100644 index 000000000..277277719 --- /dev/null +++ b/web/src/hooks/model-deployments/useDeploymentResources.js @@ -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 . + +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; diff --git a/web/src/hooks/model-deployments/useDeploymentsData.jsx b/web/src/hooks/model-deployments/useDeploymentsData.jsx new file mode 100644 index 000000000..f5c49ad61 --- /dev/null +++ b/web/src/hooks/model-deployments/useDeploymentsData.jsx @@ -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 . + +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, + }; +}; diff --git a/web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx b/web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx new file mode 100644 index 000000000..215430ef2 --- /dev/null +++ b/web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx @@ -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 . + +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; \ No newline at end of file diff --git a/web/src/hooks/model-deployments/useModelDeploymentSettings.js b/web/src/hooks/model-deployments/useModelDeploymentSettings.js new file mode 100644 index 000000000..fee0f3be6 --- /dev/null +++ b/web/src/hooks/model-deployments/useModelDeploymentSettings.js @@ -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 . + +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, + }; +}; diff --git a/web/src/pages/ModelDeployment/index.jsx b/web/src/pages/ModelDeployment/index.jsx new file mode 100644 index 000000000..e45da6cf2 --- /dev/null +++ b/web/src/pages/ModelDeployment/index.jsx @@ -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 . + +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 ( + testConnection(apiKey)} + > +
+ +
+
+ ); +}; + +export default ModelDeploymentPage; diff --git a/web/src/pages/Setting/Model/SettingModelDeployment.jsx b/web/src/pages/Setting/Model/SettingModelDeployment.jsx new file mode 100644 index 000000000..28bc1db55 --- /dev/null +++ b/web/src/pages/Setting/Model/SettingModelDeployment.jsx @@ -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 . + +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 ( + <> + +
(refForm.current = formAPI)} + style={{ marginBottom: 15 }} + > + + {t('模型部署设置')} + + } + > + {/**/} + {/* {t('配置模型部署服务提供商的API密钥和启用状态')}*/} + {/**/} + + + + io.net + + } + bodyStyle={{ padding: '20px' }} + style={{ marginBottom: '16px' }} + > + + +
+ + setInputs({ + ...inputs, + 'model_deployment.ionet.enabled': value, + }) + } + extraText={t('启用后可接入 io.net GPU 资源')} + /> + + setInputs({ + ...inputs, + 'model_deployment.ionet.api_key': value, + }) + } + disabled={!inputs['model_deployment.ionet.enabled']} + extraText={t('请使用 Project 为 io.cloud 的密钥')} + mode="password" + /> +
+ +
+
+ + +
+
+ + {t('获取 io.net API Key')} + +
    +
  • {t('访问 io.net 控制台的 API Keys 页面')}
  • +
  • {t('创建或选择密钥时,将 Project 设置为 io.cloud')}
  • +
  • {t('复制生成的密钥并粘贴到此处')}
  • +
+
+ +
+ +
+
+ + + + +
+
+
+ + ); +} diff --git a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx index a46893b81..5f351c105 100644 --- a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx +++ b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx @@ -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('兑换码管理'), diff --git a/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx b/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx index 939e76cfc..3ea18521f 100644 --- a/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx +++ b/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx @@ -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'), diff --git a/web/src/pages/Setting/index.jsx b/web/src/pages/Setting/index.jsx index 1dc4fd828..058b355e9 100644 --- a/web/src/pages/Setting/index.jsx +++ b/web/src/pages/Setting/index.jsx @@ -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: , itemKey: 'models', }); + panes.push({ + tab: ( + + + {t('模型部署设置')} + + ), + content: , + itemKey: 'model-deployment', + }); panes.push({ tab: ( From 24d359cf4030043dd7c0ebd19caec1dad05fb191 Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 29 Dec 2025 14:13:33 +0800 Subject: [PATCH 09/76] feat: Add "wan2.6-i2v" video ratio configuration to Ali adaptor. --- relay/channel/task/ali/adaptor.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index eef699665..9d01d7ac9 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -192,6 +192,10 @@ func sizeToResolution(size string) (string, error) { func ProcessAliOtherRatios(aliReq *AliVideoRequest) (map[string]float64, error) { otherRatios := make(map[string]float64) aliRatios := map[string]map[string]float64{ + "wan2.6-i2v": { + "720P": 1, + "1080P": 1 / 0.6, + }, "wan2.5-t2v-preview": { "480P": 1, "720P": 2, From 8063897998918a1180447af928d699b468023f6f Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:41:15 +0800 Subject: [PATCH 10/76] fix: glm 4.7 finish reason (#2545) --- relay/channel/openai/helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 18cada8e0..08811a772 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -208,7 +208,6 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream helper.Done(c) case types.RelayFormatClaude: - info.ClaudeConvertInfo.Done = true var streamResponse dto.ChatCompletionsStreamResponse if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { common.SysLog("error unmarshalling stream response: " + err.Error()) @@ -221,6 +220,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream for _, resp := range claudeResponses { _ = helper.ClaudeData(c, *resp) } + info.ClaudeConvertInfo.Done = true case types.RelayFormatGemini: var streamResponse dto.ChatCompletionsStreamResponse From 48d358faecd0859a53c50bea71e1fc3ee46739cd Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 29 Dec 2025 22:58:32 +0800 Subject: [PATCH 11/76] =?UTF-8?q?feat(adaptor):=20=E6=96=B0=E9=80=82?= =?UTF-8?q?=E9=85=8D=E7=99=BE=E7=82=BC=E5=A4=9A=E7=A7=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wan2.6系列生图与编辑,适配多图生成计费 - wan2.5系列生图与编辑 - z-image-turbo生图,适配prompt_extend计费 --- controller/token.go | 37 +++++++- dto/openai_image.go | 6 +- dto/openai_request.go | 5 +- main.go | 2 + relay/audio_handler.go | 2 +- relay/channel/ali/adaptor.go | 60 +++++++++--- relay/channel/ali/dto.go | 116 ++++++++++++++++++---- relay/channel/ali/image.go | 169 ++++++++++++++++----------------- relay/channel/ali/image_wan.go | 14 ++- relay/compatible_handler.go | 46 +++++---- relay/embedding_handler.go | 2 +- relay/gemini_handler.go | 4 +- relay/image_handler.go | 12 ++- relay/rerank_handler.go | 2 +- relay/responses_handler.go | 2 +- types/price_data.go | 12 ++- 16 files changed, 336 insertions(+), 155 deletions(-) diff --git a/controller/token.go b/controller/token.go index efefea0eb..c5dc5ec42 100644 --- a/controller/token.go +++ b/controller/token.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "net/http" "strconv" "strings" @@ -149,6 +150,24 @@ func AddToken(c *gin.Context) { }) return } + // 非无限额度时,检查额度值是否超出有效范围 + if !token.UnlimitedQuota { + if token.RemainQuota < 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "额度值不能为负数", + }) + return + } + maxQuotaValue := int((1000000000 * common.QuotaPerUnit)) + if token.RemainQuota > maxQuotaValue { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue), + }) + return + } + } key, err := common.GenerateKey() if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -216,6 +235,23 @@ func UpdateToken(c *gin.Context) { }) return } + if !token.UnlimitedQuota { + if token.RemainQuota < 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "额度值不能为负数", + }) + return + } + maxQuotaValue := int((1000000000 * common.QuotaPerUnit)) + if token.RemainQuota > maxQuotaValue { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue), + }) + return + } + } cleanToken, err := model.GetTokenByIds(token.Id, userId) if err != nil { common.ApiError(c, err) @@ -261,7 +297,6 @@ func UpdateToken(c *gin.Context) { "message": "", "data": cleanToken, }) - return } type TokenBatch struct { diff --git a/dto/openai_image.go b/dto/openai_image.go index 130d1dde8..a19bb69d6 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -167,9 +167,9 @@ func (i *ImageRequest) SetModelName(modelName string) { } type ImageResponse struct { - Data []ImageData `json:"data"` - Created int64 `json:"created"` - Extra any `json:"extra,omitempty"` + Data []ImageData `json:"data"` + Created int64 `json:"created"` + Metadata json.RawMessage `json:"metadata,omitempty"` } type ImageData struct { Url string `json:"url"` diff --git a/dto/openai_request.go b/dto/openai_request.go index 5415e67f3..232a1ae1b 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -23,6 +23,8 @@ type FormatJsonSchema struct { Strict json.RawMessage `json:"strict,omitempty"` } +// GeneralOpenAIRequest represents a general request structure for OpenAI-compatible APIs. +// 参数增加规范:无引用的参数必须使用json.RawMessage类型,并添加omitempty标签 type GeneralOpenAIRequest struct { Model string `json:"model,omitempty"` Messages []Message `json:"messages,omitempty"` @@ -82,8 +84,9 @@ type GeneralOpenAIRequest struct { Reasoning json.RawMessage `json:"reasoning,omitempty"` // Ali Qwen Params VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` - EnableThinking any `json:"enable_thinking,omitempty"` + EnableThinking json.RawMessage `json:"enable_thinking,omitempty"` ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"` + EnableSearch json.RawMessage `json:"enable_search,omitempty"` // ollama Params Think json.RawMessage `json:"think,omitempty"` // baidu v2 diff --git a/main.go b/main.go index 8484257bf..4c0fc8c6e 100644 --- a/main.go +++ b/main.go @@ -188,6 +188,7 @@ func InjectUmamiAnalytics() { analyticsInjectBuilder.WriteString(umamiSiteID) analyticsInjectBuilder.WriteString("\">") } + analyticsInjectBuilder.WriteString("\n") analyticsInject := analyticsInjectBuilder.String() indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject)) } @@ -209,6 +210,7 @@ func InjectGoogleAnalytics() { analyticsInjectBuilder.WriteString("');") analyticsInjectBuilder.WriteString("") } + analyticsInjectBuilder.WriteString("\n") analyticsInject := analyticsInjectBuilder.String() indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject)) } diff --git a/relay/audio_handler.go b/relay/audio_handler.go index 39eb03d39..5c34b7923 100644 --- a/relay/audio_handler.go +++ b/relay/audio_handler.go @@ -70,7 +70,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 { service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") } else { - postConsumeQuota(c, info, usage.(*dto.Usage), "") + postConsumeQuota(c, info, usage.(*dto.Usage)) } return nil diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index adce01822..480c21371 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -19,6 +19,22 @@ import ( ) type Adaptor struct { + IsSyncImageModel bool +} + +var syncModels = []string{ + "z-image", + "qwen-image", + "wan2.6", +} + +func isSyncImageModel(modelName string) bool { + for _, m := range syncModels { + if strings.Contains(modelName, m) { + return true + } + } + return false } func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { @@ -45,10 +61,16 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { case constant.RelayModeRerank: fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl) case constant.RelayModeImagesGenerations: - fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) + if isSyncImageModel(info.OriginModelName) { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) + } else { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) + } case constant.RelayModeImagesEdits: - if isWanModel(info.OriginModelName) { + if isOldWanModel(info.OriginModelName) { fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl) + } else if isWanModel(info.OriginModelName) { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", info.ChannelBaseUrl) } else { fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) } @@ -72,7 +94,11 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel req.Set("X-DashScope-Plugin", c.GetString("plugin")) } if info.RelayMode == constant.RelayModeImagesGenerations { - req.Set("X-DashScope-Async", "enable") + if isSyncImageModel(info.OriginModelName) { + + } else { + req.Set("X-DashScope-Async", "enable") + } } if info.RelayMode == constant.RelayModeImagesEdits { if isWanModel(info.OriginModelName) { @@ -108,15 +134,25 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { if info.RelayMode == constant.RelayModeImagesGenerations { - aliRequest, err := oaiImage2Ali(request) + if isSyncImageModel(info.OriginModelName) { + a.IsSyncImageModel = true + } + aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel) if err != nil { - return nil, fmt.Errorf("convert image request failed: %w", err) + return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err) } return aliRequest, nil } else if info.RelayMode == constant.RelayModeImagesEdits { - if isWanModel(info.OriginModelName) { + if isOldWanModel(info.OriginModelName) { return oaiFormEdit2WanxImageEdit(c, info, request) } + if isSyncImageModel(info.OriginModelName) { + if isWanModel(info.OriginModelName) { + a.IsSyncImageModel = false + } else { + a.IsSyncImageModel = true + } + } // ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416 // 如果用户使用表单,则需要解析表单数据 if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { @@ -126,9 +162,9 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } return aliRequest, nil } else { - aliRequest, err := oaiImage2Ali(request) + aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel) if err != nil { - return nil, fmt.Errorf("convert image request failed: %w", err) + return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err) } return aliRequest, nil } @@ -169,13 +205,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom default: switch info.RelayMode { case constant.RelayModeImagesGenerations: - err, usage = aliImageHandler(c, resp, info) + err, usage = aliImageHandler(a, c, resp, info) case constant.RelayModeImagesEdits: - if isWanModel(info.OriginModelName) { - err, usage = aliImageHandler(c, resp, info) - } else { - err, usage = aliImageEditHandler(c, resp, info) - } + err, usage = aliImageHandler(a, c, resp, info) case constant.RelayModeRerank: err, usage = RerankHandler(c, resp, info) default: diff --git a/relay/channel/ali/dto.go b/relay/channel/ali/dto.go index 26f14a6c0..75be8ff79 100644 --- a/relay/channel/ali/dto.go +++ b/relay/channel/ali/dto.go @@ -1,6 +1,13 @@ package ali -import "github.com/QuantumNous/new-api/dto" +import ( + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) type AliMessage struct { Content any `json:"content"` @@ -65,6 +72,7 @@ type AliUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` + ImageCount int `json:"image_count,omitempty"` } type TaskResult struct { @@ -75,14 +83,78 @@ type TaskResult struct { } type AliOutput struct { - TaskId string `json:"task_id,omitempty"` - TaskStatus string `json:"task_status,omitempty"` - Text string `json:"text"` - FinishReason string `json:"finish_reason"` - Message string `json:"message,omitempty"` - Code string `json:"code,omitempty"` - Results []TaskResult `json:"results,omitempty"` - Choices []map[string]any `json:"choices,omitempty"` + TaskId string `json:"task_id,omitempty"` + TaskStatus string `json:"task_status,omitempty"` + Text string `json:"text"` + FinishReason string `json:"finish_reason"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Results []TaskResult `json:"results,omitempty"` + Choices []struct { + FinishReason string `json:"finish_reason,omitempty"` + Message struct { + Role string `json:"role,omitempty"` + Content []AliMediaContent `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + } `json:"message,omitempty"` + } `json:"choices,omitempty"` +} + +func (o *AliOutput) ChoicesToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData { + var imageData []dto.ImageData + if len(o.Choices) > 0 { + for _, choice := range o.Choices { + var data dto.ImageData + for _, content := range choice.Message.Content { + if content.Image != "" { + if strings.HasPrefix(content.Image, "http") { + var b64Json string + if responseFormat == "b64_json" { + _, b64, err := service.GetImageFromUrl(content.Image) + if err != nil { + logger.LogError(c, "get_image_data_failed: "+err.Error()) + continue + } + b64Json = b64 + } + data.Url = content.Image + data.B64Json = b64Json + } else { + data.B64Json = content.Image + } + } else if content.Text != "" { + data.RevisedPrompt = content.Text + } + } + imageData = append(imageData, data) + } + } + + return imageData +} + +func (o *AliOutput) ResultToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData { + var imageData []dto.ImageData + for _, data := range o.Results { + var b64Json string + if responseFormat == "b64_json" { + _, b64, err := service.GetImageFromUrl(data.Url) + if err != nil { + logger.LogError(c, "get_image_data_failed: "+err.Error()) + continue + } + b64Json = b64 + } else { + b64Json = data.B64Image + } + + imageData = append(imageData, dto.ImageData{ + Url: data.Url, + B64Json: b64Json, + RevisedPrompt: "", + }) + } + return imageData } type AliResponse struct { @@ -92,18 +164,26 @@ type AliResponse struct { } type AliImageRequest struct { - Model string `json:"model"` - Input any `json:"input"` - Parameters any `json:"parameters,omitempty"` - ResponseFormat string `json:"response_format,omitempty"` + Model string `json:"model"` + Input any `json:"input"` + Parameters AliImageParameters `json:"parameters,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` } type AliImageParameters struct { - Size string `json:"size,omitempty"` - N int `json:"n,omitempty"` - Steps string `json:"steps,omitempty"` - Scale string `json:"scale,omitempty"` - Watermark *bool `json:"watermark,omitempty"` + Size string `json:"size,omitempty"` + N int `json:"n,omitempty"` + Steps string `json:"steps,omitempty"` + Scale string `json:"scale,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + PromptExtend *bool `json:"prompt_extend,omitempty"` +} + +func (p *AliImageParameters) PromptExtendValue() bool { + if p != nil && p.PromptExtend != nil { + return *p.PromptExtend + } + return false } type AliImageInput struct { diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index 0e3fe1ea0..22aacf7d7 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -21,17 +21,25 @@ import ( "github.com/gin-gonic/gin" ) -func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) { +func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequest, isSync bool) (*AliImageRequest, error) { var imageRequest AliImageRequest imageRequest.Model = request.Model imageRequest.ResponseFormat = request.ResponseFormat logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra) + logger.LogDebug(context.Background(), "oaiImage2Ali request isSync: "+fmt.Sprintf("%v", isSync)) if request.Extra != nil { if val, ok := request.Extra["parameters"]; ok { err := common.Unmarshal(val, &imageRequest.Parameters) if err != nil { return nil, fmt.Errorf("invalid parameters field: %w", err) } + } else { + // 兼容没有parameters字段的情况,从openai标准字段中提取参数 + imageRequest.Parameters = AliImageParameters{ + Size: strings.Replace(request.Size, "x", "*", -1), + N: int(request.N), + Watermark: request.Watermark, + } } if val, ok := request.Extra["input"]; ok { err := common.Unmarshal(val, &imageRequest.Input) @@ -41,23 +49,44 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) { } } - if imageRequest.Parameters == nil { - imageRequest.Parameters = AliImageParameters{ - Size: strings.Replace(request.Size, "x", "*", -1), - N: int(request.N), - Watermark: request.Watermark, + if strings.Contains(request.Model, "z-image") { + // z-image 开启prompt_extend后,按2倍计费 + if imageRequest.Parameters.PromptExtendValue() { + info.PriceData.AddOtherRatio("prompt_extend", 2) } } - if imageRequest.Input == nil { - imageRequest.Input = AliImageInput{ - Prompt: request.Prompt, + // 检查n参数 + if imageRequest.Parameters.N != 0 { + info.PriceData.AddOtherRatio("n", float64(imageRequest.Parameters.N)) + } + + // 同步图片模型和异步图片模型请求格式不一样 + if isSync { + if imageRequest.Input == nil { + imageRequest.Input = AliImageInput{ + Messages: []AliMessage{ + { + Role: "user", + Content: []AliMediaContent{ + { + Text: request.Prompt, + }, + }, + }, + }, + } + } + } else { + if imageRequest.Input == nil { + imageRequest.Input = AliImageInput{ + Prompt: request.Prompt, + } } } return &imageRequest, nil } - func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) { mf := c.Request.MultipartForm if mf == nil { @@ -199,6 +228,8 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) ( var taskResponse AliResponse var responseBody []byte + time.Sleep(time.Duration(5) * time.Second) + for { logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)) step++ @@ -238,32 +269,17 @@ func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody [ Created: info.StartTime.Unix(), } - for _, data := range response.Output.Results { - var b64Json string - if responseFormat == "b64_json" { - _, b64, err := service.GetImageFromUrl(data.Url) - if err != nil { - logger.LogError(c, "get_image_data_failed: "+err.Error()) - continue - } - b64Json = b64 - } else { - b64Json = data.B64Image - } - - imageResponse.Data = append(imageResponse.Data, dto.ImageData{ - Url: data.Url, - B64Json: b64Json, - RevisedPrompt: "", - }) + if len(response.Output.Results) > 0 { + imageResponse.Data = response.Output.ResultToOpenAIImageDate(c, responseFormat) + } else if len(response.Output.Choices) > 0 { + imageResponse.Data = response.Output.ChoicesToOpenAIImageDate(c, responseFormat) } - var mapResponse map[string]any - _ = common.Unmarshal(originBody, &mapResponse) - imageResponse.Extra = mapResponse + + imageResponse.Metadata = originBody return &imageResponse } -func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { +func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { responseFormat := c.GetString("response_format") var aliTaskResponse AliResponse @@ -282,66 +298,49 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil } - aliResponse, originRespBody, err := asyncTaskWait(c, info, aliTaskResponse.Output.TaskId) - if err != nil { - return types.NewError(err, types.ErrorCodeBadResponse), nil - } + var ( + aliResponse *AliResponse + originRespBody []byte + ) - if aliResponse.Output.TaskStatus != "SUCCEEDED" { - return types.WithOpenAIError(types.OpenAIError{ - Message: aliResponse.Output.Message, - Type: "ali_error", - Param: "", - Code: aliResponse.Output.Code, - }, resp.StatusCode), nil - } - - fullTextResponse := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat) - jsonResponse, err := common.Marshal(fullTextResponse) - if err != nil { - return types.NewError(err, types.ErrorCodeBadResponseBody), nil - } - service.IOCopyBytesGracefully(c, resp, jsonResponse) - return nil, &dto.Usage{} -} - -func aliImageEditHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { - var aliResponse AliResponse - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil - } - - service.CloseResponseBodyGracefully(resp) - err = common.Unmarshal(responseBody, &aliResponse) - if err != nil { - return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil - } - - if aliResponse.Message != "" { - logger.LogError(c, "ali_task_failed: "+aliResponse.Message) - return types.NewError(errors.New(aliResponse.Message), types.ErrorCodeBadResponse), nil - } - var fullTextResponse dto.ImageResponse - if len(aliResponse.Output.Choices) > 0 { - fullTextResponse = dto.ImageResponse{ - Created: info.StartTime.Unix(), - Data: []dto.ImageData{ - { - Url: aliResponse.Output.Choices[0]["message"].(map[string]any)["content"].([]any)[0].(map[string]any)["image"].(string), - B64Json: "", - }, - }, + if a.IsSyncImageModel { + aliResponse = &aliTaskResponse + originRespBody = responseBody + } else { + // 异步图片模型需要轮询任务结果 + aliResponse, originRespBody, err = asyncTaskWait(c, info, aliTaskResponse.Output.TaskId) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponse), nil + } + if aliResponse.Output.TaskStatus != "SUCCEEDED" { + return types.WithOpenAIError(types.OpenAIError{ + Message: aliResponse.Output.Message, + Type: "ali_error", + Param: "", + Code: aliResponse.Output.Code, + }, resp.StatusCode), nil } } - var mapResponse map[string]any - _ = common.Unmarshal(responseBody, &mapResponse) - fullTextResponse.Extra = mapResponse - jsonResponse, err := common.Marshal(fullTextResponse) + //logger.LogDebug(c, "ali_async_task_result: "+string(originRespBody)) + if a.IsSyncImageModel { + logger.LogDebug(c, "ali_sync_image_result: "+string(originRespBody)) + } else { + logger.LogDebug(c, "ali_async_image_result: "+string(originRespBody)) + } + + imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat) + // 可能生成多张图片,修正计费数量n + if aliResponse.Usage.ImageCount != 0 { + info.PriceData.AddOtherRatio("n", float64(aliResponse.Usage.ImageCount)) + } else if len(imageResponses.Data) != 0 { + info.PriceData.AddOtherRatio("n", float64(len(imageResponses.Data))) + } + jsonResponse, err := common.Marshal(imageResponses) if err != nil { return types.NewError(err, types.ErrorCodeBadResponseBody), nil } service.IOCopyBytesGracefully(c, resp, jsonResponse) + return nil, &dto.Usage{} } diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go index 4bd1a2701..90ee48a0b 100644 --- a/relay/channel/ali/image_wan.go +++ b/relay/channel/ali/image_wan.go @@ -26,14 +26,22 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil { return nil, fmt.Errorf("get image base64s from form failed: %w", err) } - wanParams := WanImageParameters{ + //wanParams := WanImageParameters{ + // N: int(request.N), + //} + imageRequest.Input = wanInput + imageRequest.Parameters = AliImageParameters{ N: int(request.N), } - imageRequest.Input = wanInput - imageRequest.Parameters = wanParams + info.PriceData.AddOtherRatio("n", float64(imageRequest.Parameters.N)) + return &imageRequest, nil } +func isOldWanModel(modelName string) bool { + return strings.Contains(modelName, "wan") && !strings.Contains(modelName, "wan2.6") +} + func isWanModel(modelName string) bool { return strings.Contains(modelName, "wan") } diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index d92c990a7..97649ca96 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -184,19 +184,19 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 { service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") } else { - postConsumeQuota(c, info, usage.(*dto.Usage), "") + postConsumeQuota(c, info, usage.(*dto.Usage)) } return nil } -func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) { +func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) { if usage == nil { usage = &dto.Usage{ PromptTokens: relayInfo.GetEstimatePromptTokens(), CompletionTokens: 0, TotalTokens: relayInfo.GetEstimatePromptTokens(), } - extraContent += "(可能是请求出错)" + extraContent = append(extraContent, "上游无计费信息") } useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() promptTokens := usage.PromptTokens @@ -246,8 +246,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage dWebSearchQuota = decimal.NewFromFloat(webSearchPrice). Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))). Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) - extraContent += fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 %s", - webSearchTool.CallCount, webSearchTool.SearchContextSize, dWebSearchQuota.String()) + extraContent = append(extraContent, fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 %s", + webSearchTool.CallCount, webSearchTool.SearchContextSize, dWebSearchQuota.String())) } } else if strings.HasSuffix(modelName, "search-preview") { // search-preview 模型不支持 response api @@ -258,8 +258,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, searchContextSize) dWebSearchQuota = decimal.NewFromFloat(webSearchPrice). Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) - extraContent += fmt.Sprintf("Web Search 调用 1 次,上下文大小 %s,调用花费 %s", - searchContextSize, dWebSearchQuota.String()) + extraContent = append(extraContent, fmt.Sprintf("Web Search 调用 1 次,上下文大小 %s,调用花费 %s", + searchContextSize, dWebSearchQuota.String())) } // claude web search tool 计费 var dClaudeWebSearchQuota decimal.Decimal @@ -269,8 +269,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage claudeWebSearchPrice = operation_setting.GetClaudeWebSearchPricePerThousand() dClaudeWebSearchQuota = decimal.NewFromFloat(claudeWebSearchPrice). Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit).Mul(decimal.NewFromInt(int64(claudeWebSearchCallCount))) - extraContent += fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s", - claudeWebSearchCallCount, dClaudeWebSearchQuota.String()) + extraContent = append(extraContent, fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s", + claudeWebSearchCallCount, dClaudeWebSearchQuota.String())) } // file search tool 计费 var dFileSearchQuota decimal.Decimal @@ -281,8 +281,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice). Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))). Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) - extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", - fileSearchTool.CallCount, dFileSearchQuota.String()) + extraContent = append(extraContent, fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", + fileSearchTool.CallCount, dFileSearchQuota.String())) } } var dImageGenerationCallQuota decimal.Decimal @@ -290,7 +290,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage if ctx.GetBool("image_generation_call") { imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size")) dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit) - extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String()) + extraContent = append(extraContent, fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String())) } var quotaCalculateDecimal decimal.Decimal @@ -331,7 +331,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage // 重新计算 base tokens baseTokens = baseTokens.Sub(dAudioTokens) audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit) - extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String()) + extraContent = append(extraContent, fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())) } } promptQuota := baseTokens.Add(cachedTokensWithRatio). @@ -356,17 +356,25 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage // 添加 image generation call 计费 quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota) + if len(relayInfo.PriceData.OtherRatios) > 0 { + for key, otherRatio := range relayInfo.PriceData.OtherRatios { + dOtherRatio := decimal.NewFromFloat(otherRatio) + quotaCalculateDecimal = quotaCalculateDecimal.Mul(dOtherRatio) + extraContent = append(extraContent, fmt.Sprintf("其他倍率 %s: %f", key, otherRatio)) + } + } + quota := int(quotaCalculateDecimal.Round(0).IntPart()) totalTokens := promptTokens + completionTokens - var logContent string + //var logContent string // record all the consume log even if quota is 0 if totalTokens == 0 { // in this case, must be some error happened // we cannot just return, because we may have to return the pre-consumed quota quota = 0 - logContent += fmt.Sprintf("(可能是上游超时)") + extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)") logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota)) } else { @@ -405,15 +413,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage logModel := modelName if strings.HasPrefix(logModel, "gpt-4-gizmo") { logModel = "gpt-4-gizmo-*" - logContent += fmt.Sprintf(",模型 %s", modelName) + extraContent = append(extraContent, fmt.Sprintf("模型 %s", modelName)) } if strings.HasPrefix(logModel, "gpt-4o-gizmo") { logModel = "gpt-4o-gizmo-*" - logContent += fmt.Sprintf(",模型 %s", modelName) - } - if extraContent != "" { - logContent += ", " + extraContent + extraContent = append(extraContent, fmt.Sprintf("模型 %s", modelName)) } + logContent := strings.Join(extraContent, ", ") other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) if imageTokens != 0 { other["image"] = true diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index 740ca400e..2cedf02b5 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -82,6 +82,6 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } - postConsumeQuota(c, info, usage.(*dto.Usage), "") + postConsumeQuota(c, info, usage.(*dto.Usage)) return nil } diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index af13341bf..79ffba515 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -193,7 +193,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return openaiErr } - postConsumeQuota(c, info, usage.(*dto.Usage), "") + postConsumeQuota(c, info, usage.(*dto.Usage)) return nil } @@ -292,6 +292,6 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI return openaiErr } - postConsumeQuota(c, info, usage.(*dto.Usage), "") + postConsumeQuota(c, info, usage.(*dto.Usage)) return nil } diff --git a/relay/image_handler.go b/relay/image_handler.go index b58968402..f110f4e86 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -124,12 +124,18 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type quality = "hd" } - var logContent string + var logContent []string if len(request.Size) > 0 { - logContent = fmt.Sprintf("大小 %s, 品质 %s, 张数 %d", request.Size, quality, request.N) + logContent = append(logContent, fmt.Sprintf("大小 %s", request.Size)) + } + if len(quality) > 0 { + logContent = append(logContent, fmt.Sprintf("品质 %s", quality)) + } + if request.N > 0 { + logContent = append(logContent, fmt.Sprintf("生成数量 %d", request.N)) } - postConsumeQuota(c, info, usage.(*dto.Usage), logContent) + postConsumeQuota(c, info, usage.(*dto.Usage), logContent...) return nil } diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 3efc45079..9a50fd271 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -95,6 +95,6 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } - postConsumeQuota(c, info, usage.(*dto.Usage), "") + postConsumeQuota(c, info, usage.(*dto.Usage)) return nil } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 9460356d6..5c3d9a426 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -107,7 +107,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") { service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") } else { - postConsumeQuota(c, info, usage.(*dto.Usage), "") + postConsumeQuota(c, info, usage.(*dto.Usage)) } return nil } diff --git a/types/price_data.go b/types/price_data.go index 93044f865..3f7121b8c 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -26,12 +26,22 @@ type PriceData struct { GroupRatioInfo GroupRatioInfo } +func (p *PriceData) AddOtherRatio(key string, ratio float64) { + if p.OtherRatios == nil { + p.OtherRatios = make(map[string]float64) + } + if ratio <= 0 { + return + } + p.OtherRatios[key] = ratio +} + type PerCallPriceData struct { ModelPrice float64 Quota int GroupRatioInfo GroupRatioInfo } -func (p PriceData) ToSetting() string { +func (p *PriceData) ToSetting() string { return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) } From 04ea79c429317fd4eb81818e000a95decc796ce8 Mon Sep 17 00:00:00 2001 From: wwalt1a <98161201+wwalt1a@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:55:06 +0800 Subject: [PATCH 12/76] feat: support HTTP_PROXY environment variable for default HTTP client - Add Proxy: http.ProxyFromEnvironment to default transport - Allow users to set global proxy via Docker environment variables - Per-channel proxy settings still override global proxy - Fully backward compatible --- service/http_client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/service/http_client.go b/service/http_client.go index be89c73c0..3ae6a6761 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -38,6 +38,7 @@ func InitHttpClient() { MaxIdleConns: common.RelayMaxIdleConns, MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, ForceAttemptHTTP2: true, + Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars } if common.RelayTimeout == 0 { From ab81d6e444b6627ff88d4f0a56a8122b79196de6 Mon Sep 17 00:00:00 2001 From: John Chen Date: Tue, 30 Dec 2025 17:38:32 +0800 Subject: [PATCH 13/76] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=99=BA?= =?UTF-8?q?=E6=99=AE=E3=80=81Moonshot=E6=B8=A0=E9=81=93=E5=9C=A8stream=3Dt?= =?UTF-8?q?rue=E6=97=B6=E6=97=A0=E6=B3=95=E6=8B=BF=E5=88=B0cachePrompt?= =?UTF-8?q?=E7=9A=84=E7=BB=9F=E8=AE=A1=E6=95=B0=E6=8D=AE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根本原因: 1. 在OaiStreamHandler流式处理函数中,调用applyUsagePostProcessing(info, usage, nil)时传入的responseBody为nil,导致无法从响应体中提取缓存tokens。 2. 两个渠道的cached_tokens位置不同: - 智普:标准位置 usage.prompt_tokens_details.cached_tokens - Moonshot:非标准位置 choices[].usage.cached_tokens 处理方案: 1. 传递body信息到applyUsagePostProcessing中 2. 拆分智普和Moonshot的解析,并为Moonshot单独写一个解析方法。 --- relay/channel/openai/relay-openai.go | 47 ++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index ac44312eb..a4c6ef605 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -186,7 +186,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re usage.CompletionTokens += toolCount * 7 } - applyUsagePostProcessing(info, usage, nil) + applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData)) HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage) @@ -596,7 +596,8 @@ 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, constant.ChannelTypeMoonshot: + case constant.ChannelTypeZhipu_v4: + // 智普的cached_tokens在标准位置: usage.prompt_tokens_details.cached_tokens if usage.PromptTokensDetails.CachedTokens == 0 { if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 { usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens @@ -606,6 +607,19 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens } } + case constant.ChannelTypeMoonshot: + // Moonshot的cached_tokens在非标准位置: choices[].usage.cached_tokens + if usage.PromptTokensDetails.CachedTokens == 0 { + if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 { + usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens + } else if cachedTokens, ok := extractMoonshotCachedTokensFromBody(responseBody); ok { + usage.PromptTokensDetails.CachedTokens = cachedTokens + } else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok { + usage.PromptTokensDetails.CachedTokens = cachedTokens + } else if usage.PromptCacheHitTokens > 0 { + usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens + } + } } } @@ -639,3 +653,32 @@ func extractCachedTokensFromBody(body []byte) (int, bool) { } return 0, false } + +// extractMoonshotCachedTokensFromBody 从Moonshot的非标准位置提取cached_tokens +// Moonshot的流式响应格式: {"choices":[{"usage":{"cached_tokens":111}}]} +func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) { + if len(body) == 0 { + return 0, false + } + + var payload struct { + Choices []struct { + Usage struct { + CachedTokens *int `json:"cached_tokens"` + } `json:"usage"` + } `json:"choices"` + } + + if err := common.Unmarshal(body, &payload); err != nil { + return 0, false + } + + // 遍历choices查找cached_tokens + for _, choice := range payload.Choices { + if choice.Usage.CachedTokens != nil && *choice.Usage.CachedTokens > 0 { + return *choice.Usage.CachedTokens, true + } + } + + return 0, false +} From d474ed4778a28f0b12c5dd1b98e80438b3054620 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Tue, 30 Dec 2025 17:49:48 +0800 Subject: [PATCH 14/76] feat: flush response writer after copying body --- service/http.go | 1 + 1 file changed, 1 insertion(+) diff --git a/service/http.go b/service/http.go index 7bd54c4ac..f80f2c350 100644 --- a/service/http.go +++ b/service/http.go @@ -57,4 +57,5 @@ func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) { if err != nil { logger.LogError(c, fmt.Sprintf("failed to copy response body: %s", err.Error())) } + c.Writer.Flush() } From 2a5b2add9a3f1bf99837939eb69ba4e646e7cd8b Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 29 Dec 2025 23:09:15 +0800 Subject: [PATCH 15/76] refactor(image): remove unnecessary logging in oaiImage2Ali function --- relay/channel/ali/image.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index 22aacf7d7..cfd9a0fdd 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -1,7 +1,6 @@ package ali import ( - "context" "encoding/base64" "errors" "fmt" @@ -25,8 +24,6 @@ func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequ var imageRequest AliImageRequest imageRequest.Model = request.Model imageRequest.ResponseFormat = request.ResponseFormat - logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra) - logger.LogDebug(context.Background(), "oaiImage2Ali request isSync: "+fmt.Sprintf("%v", isSync)) if request.Extra != nil { if val, ok := request.Extra["parameters"]; ok { err := common.Unmarshal(val, &imageRequest.Parameters) From 23a68137ada0f4c052ba5c291ac03f0dd978efe8 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 31 Dec 2025 00:44:06 +0800 Subject: [PATCH 16/76] feat(adaptor): update resolution handling for wan2.6 model --- relay/channel/task/ali/adaptor.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index 9d01d7ac9..d55452c08 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -291,7 +291,9 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay aliReq.Parameters.Size = "1280*720" } } else { - if strings.HasPrefix(req.Model, "wan2.5") { + if strings.HasPrefix(req.Model, "wan2.6") { + aliReq.Parameters.Resolution = "1080P" + } else if strings.HasPrefix(req.Model, "wan2.5") { aliReq.Parameters.Resolution = "1080P" } else if strings.HasPrefix(req.Model, "wan2.2-i2v-flash") { aliReq.Parameters.Resolution = "720P" From b808b96cce14bf199c427ae36981b9d1f894c070 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 31 Dec 2025 00:44:12 +0800 Subject: [PATCH 17/76] fix(TaskLogs): use correct video URL for modal preview --- .../table/task-logs/TaskLogsColumnDefs.jsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx index 969977d17..367a098b1 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx @@ -371,18 +371,19 @@ export const getTaskLogsColumns = ({ const isSuccess = record.status === 'SUCCESS'; const isUrl = typeof text === 'string' && /^https?:\/\//.test(text); if (isSuccess && isVideoTask && isUrl) { - const videoUrl = `/v1/videos/${record.task_id}/content`; - return ( - { - e.preventDefault(); - openVideoModal(videoUrl); - }} - > - {t('点击预览视频')} - - ); + if (isSuccess && isVideoTask && isUrl) { + return ( + { + e.preventDefault(); + openVideoModal(text); + }} + > + {t('点击预览视频')} + + ); + } } if (!text) { return t('无'); From 8b790446ce5d074eae011f6e36bec2b27c56e0a3 Mon Sep 17 00:00:00 2001 From: PCCCCCCC Date: Wed, 31 Dec 2025 09:38:23 +0800 Subject: [PATCH 18/76] remove duplicate condition in TaskLogsColumnDefs --- .../table/task-logs/TaskLogsColumnDefs.jsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx index 367a098b1..db5228370 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx @@ -371,19 +371,17 @@ export const getTaskLogsColumns = ({ const isSuccess = record.status === 'SUCCESS'; const isUrl = typeof text === 'string' && /^https?:\/\//.test(text); if (isSuccess && isVideoTask && isUrl) { - if (isSuccess && isVideoTask && isUrl) { - return ( - { - e.preventDefault(); - openVideoModal(text); - }} - > - {t('点击预览视频')} - - ); - } + return ( + { + e.preventDefault(); + openVideoModal(text); + }} + > + {t('点击预览视频')} + + ); } if (!text) { return t('无'); From ddb40b1a6ec2d2e6690c8eb0c7747b540c8fd415 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 31 Dec 2025 18:09:21 +0800 Subject: [PATCH 19/76] fix: gemini request -> openai tool call --- dto/gemini.go | 2 +- service/convert.go | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/dto/gemini.go b/dto/gemini.go index 4d738c22a..7c5969efd 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -126,7 +126,7 @@ func (r *GeminiChatRequest) SetModelName(modelName string) { func (r *GeminiChatRequest) GetTools() []GeminiChatTool { var tools []GeminiChatTool - if strings.HasSuffix(string(r.Tools), "[") { + if strings.HasPrefix(string(r.Tools), "[") { // is array if err := common.Unmarshal(r.Tools, &tools); err != nil { logger.LogError(nil, "error_unmarshalling_tools: "+err.Error()) diff --git a/service/convert.go b/service/convert.go index 7228db9a9..f357f6842 100644 --- a/service/convert.go +++ b/service/convert.go @@ -674,20 +674,21 @@ func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycomm var tools []dto.ToolCallRequest for _, tool := range geminiRequest.GetTools() { if tool.FunctionDeclarations != nil { - // 将 Gemini 的 FunctionDeclarations 转换为 OpenAI 的 ToolCallRequest - functionDeclarations, ok := tool.FunctionDeclarations.([]dto.FunctionRequest) - if ok { - for _, function := range functionDeclarations { - openAITool := dto.ToolCallRequest{ - Type: "function", - Function: dto.FunctionRequest{ - Name: function.Name, - Description: function.Description, - Parameters: function.Parameters, - }, - } - tools = append(tools, openAITool) + functionDeclarations, err := common.Any2Type[[]dto.FunctionRequest](tool.FunctionDeclarations) + if err != nil { + common.SysError(fmt.Sprintf("failed to parse gemini function declarations: %v (type=%T)", err, tool.FunctionDeclarations)) + continue + } + for _, function := range functionDeclarations { + openAITool := dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: function.Name, + Description: function.Description, + Parameters: function.Parameters, + }, } + tools = append(tools, openAITool) } } } From b2d8ad7883fd6a66e5d23008d6a7430f235a5ab5 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 31 Dec 2025 21:15:37 +0800 Subject: [PATCH 20/76] feat(init): increase maximum file download size to 64MB --- common/init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/init.go b/common/init.go index 5c49da316..ce121b3be 100644 --- a/common/init.go +++ b/common/init.go @@ -115,7 +115,7 @@ func InitEnv() { func initConstantEnv() { constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300) constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true) - constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20) + constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64) constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64) // MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨 constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 64) From b1bb64ae119f2560707c2e6337dd3a0732ef3926 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 31 Dec 2025 21:22:33 +0800 Subject: [PATCH 21/76] feat(model): add audio ratios for new TTS models and adjust default values --- setting/ratio_setting/model_ratio.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index df823516a..017048ebd 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -311,6 +311,10 @@ var defaultAudioCompletionRatio = map[string]float64{ "gpt-4o-realtime": 2, "gpt-4o-mini-realtime": 2, "gpt-4o-mini-tts": 1, + "tts-1": 0, + "tts-1-hd": 0, + "tts-1-1106": 0, + "tts-1-hd-1106": 0, } var ( @@ -656,7 +660,7 @@ func GetAudioRatio(name string) float64 { if ratio, ok := audioRatioMap[name]; ok { return ratio } - return 20 + return 1 } func GetAudioCompletionRatio(name string) float64 { @@ -667,7 +671,7 @@ func GetAudioCompletionRatio(name string) float64 { return ratio } - return 2 + return 1 } func ModelRatio2JSONString() string { From d06915c30d4d377d71dd2943fb6ec583dc437302 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 31 Dec 2025 21:29:10 +0800 Subject: [PATCH 22/76] feat(ratio): add functions to check for audio ratios and clean up unused code --- relay/compatible_handler.go | 6 ++++- setting/ratio_setting/model_ratio.go | 36 +++++++++++++--------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 97649ca96..a536e165f 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -18,6 +18,7 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/shopspring/decimal" @@ -181,7 +182,10 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return newApiErr } - if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 { + var containAudioTokens = usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 + var containsAudioRatios = ratio_setting.ContainsAudioRatio(info.OriginModelName) || ratio_setting.ContainsAudioCompletionRatio(info.OriginModelName) + + if containAudioTokens && containsAudioRatios { service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") } else { postConsumeQuota(c, info, usage.(*dto.Usage)) diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 017048ebd..039d4a021 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -674,6 +674,22 @@ func GetAudioCompletionRatio(name string) float64 { return 1 } +func ContainsAudioRatio(name string) bool { + audioRatioMapMutex.RLock() + defer audioRatioMapMutex.RUnlock() + name = FormatMatchingModelName(name) + _, ok := audioRatioMap[name] + return ok +} + +func ContainsAudioCompletionRatio(name string) bool { + audioCompletionRatioMapMutex.RLock() + defer audioCompletionRatioMapMutex.RUnlock() + name = FormatMatchingModelName(name) + _, ok := audioCompletionRatioMap[name] + return ok +} + func ModelRatio2JSONString() string { modelRatioMapMutex.RLock() defer modelRatioMapMutex.RUnlock() @@ -749,16 +765,6 @@ func UpdateAudioRatioByJSONString(jsonStr string) error { return nil } -func GetAudioRatioCopy() map[string]float64 { - audioRatioMapMutex.RLock() - defer audioRatioMapMutex.RUnlock() - copyMap := make(map[string]float64, len(audioRatioMap)) - for k, v := range audioRatioMap { - copyMap[k] = v - } - return copyMap -} - func AudioCompletionRatio2JSONString() string { audioCompletionRatioMapMutex.RLock() defer audioCompletionRatioMapMutex.RUnlock() @@ -781,16 +787,6 @@ func UpdateAudioCompletionRatioByJSONString(jsonStr string) error { return nil } -func GetAudioCompletionRatioCopy() map[string]float64 { - audioCompletionRatioMapMutex.RLock() - defer audioCompletionRatioMapMutex.RUnlock() - copyMap := make(map[string]float64, len(audioCompletionRatioMap)) - for k, v := range audioCompletionRatioMap { - copyMap[k] = v - } - return copyMap -} - func GetModelRatioCopy() map[string]float64 { modelRatioMapMutex.RLock() defer modelRatioMapMutex.RUnlock() From a195e8889609361bbc9de830ec19ae5662bf30fe Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 1 Jan 2026 15:42:15 +0800 Subject: [PATCH 23/76] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20timestamp2str?= =?UTF-8?q?ing1=20=E8=B7=A8=E5=B9=B4=E6=98=BE=E7=A4=BA=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=8C=E4=BB=85=E5=9C=A8=E6=95=B0=E6=8D=AE=E8=B7=A8=E5=B9=B4?= =?UTF-8?q?=E6=97=B6=E6=98=BE=E7=A4=BA=E5=B9=B4=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/helpers/dashboard.jsx | 22 +++++++++++++++++----- web/src/helpers/utils.jsx | 22 +++++++++++++++------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/web/src/helpers/dashboard.jsx b/web/src/helpers/dashboard.jsx index 71278f495..8df375f11 100644 --- a/web/src/helpers/dashboard.jsx +++ b/web/src/helpers/dashboard.jsx @@ -26,6 +26,7 @@ import { import { timestamp2string, timestamp2string1, + isDataCrossYear, copy, showSuccess, } from './utils'; @@ -259,13 +260,16 @@ export const processRawData = ( timeCountMap: new Map(), }; + // 检查数据是否跨年 + const showYear = isDataCrossYear(data.map(item => item.created_at)); + data.forEach((item) => { result.uniqueModels.add(item.model_name); result.totalTokens += item.token_used; result.totalQuota += item.quota; result.totalTimes += item.count; - const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime); + const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear); if (!result.timePoints.includes(timeKey)) { result.timePoints.push(timeKey); } @@ -323,8 +327,11 @@ export const calculateTrendData = ( export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => { const aggregatedData = new Map(); + // 检查数据是否跨年 + const showYear = isDataCrossYear(data.map(item => item.created_at)); + data.forEach((item) => { - const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime); + const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear); const modelKey = item.model_name; const key = `${timeKey}-${modelKey}`; @@ -358,10 +365,15 @@ export const generateChartTimePoints = ( const lastTime = Math.max(...data.map((item) => item.created_at)); const interval = getTimeInterval(dataExportDefaultTime, true); - chartTimePoints = Array.from( + // 生成时间点数组,用于检查是否跨年 + const generatedTimestamps = Array.from( { length: DEFAULTS.MAX_TREND_POINTS }, - (_, i) => - timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime), + (_, i) => lastTime - (6 - i) * interval, + ); + const showYear = isDataCrossYear(generatedTimestamps); + + chartTimePoints = generatedTimestamps.map(ts => + timestamp2string1(ts, dataExportDefaultTime, showYear), ); } diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index 0cbdebd15..a54676e47 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -217,15 +217,12 @@ export function timestamp2string(timestamp) { ); } -export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour') { +export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', showYear = false) { let date = new Date(timestamp * 1000); - // let year = date.getFullYear().toString(); + let year = date.getFullYear(); let month = (date.getMonth() + 1).toString(); let day = date.getDate().toString(); let hour = date.getHours().toString(); - if (day === '24') { - console.log('timestamp', timestamp); - } if (month.length === 1) { month = '0' + month; } @@ -235,11 +232,13 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour') { if (hour.length === 1) { hour = '0' + hour; } - let str = month + '-' + day; + // 仅在跨年时显示年份 + let str = showYear ? year + '-' + month + '-' + day : month + '-' + day; if (dataExportDefaultTime === 'hour') { str += ' ' + hour + ':00'; } else if (dataExportDefaultTime === 'week') { let nextWeek = new Date(timestamp * 1000 + 6 * 24 * 60 * 60 * 1000); + let nextWeekYear = nextWeek.getFullYear(); let nextMonth = (nextWeek.getMonth() + 1).toString(); let nextDay = nextWeek.getDate().toString(); if (nextMonth.length === 1) { @@ -248,11 +247,20 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour') { if (nextDay.length === 1) { nextDay = '0' + nextDay; } - str += ' - ' + nextMonth + '-' + nextDay; + // 周视图结束日期也仅在跨年时显示年份 + let nextStr = showYear ? nextWeekYear + '-' + nextMonth + '-' + nextDay : nextMonth + '-' + nextDay; + str += ' - ' + nextStr; } return str; } +// 检查时间戳数组是否跨年 +export function isDataCrossYear(timestamps) { + if (!timestamps || timestamps.length === 0) return false; + const years = new Set(timestamps.map(ts => new Date(ts * 1000).getFullYear())); + return years.size > 1; +} + export function downloadTextAsFile(text, filename) { let blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); let url = URL.createObjectURL(blob); From 8abfbe372f2965dc50918b1472bf97fe0b649bac Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 2 Jan 2026 23:00:33 +0800 Subject: [PATCH 24/76] feat(checkin): add check-in functionality with status retrieval and user quota rewards --- controller/checkin.go | 72 ++++ controller/misc.go | 1 + model/checkin.go | 179 ++++++++++ model/main.go | 84 ++--- router/api-router.go | 4 + setting/operation_setting/checkin_setting.go | 37 ++ .../components/settings/OperationSetting.jsx | 10 +- .../components/settings/PersonalSetting.jsx | 8 + .../personal/cards/CheckinCalendar.jsx | 321 ++++++++++++++++++ web/src/i18n/locales/en.json | 25 +- web/src/i18n/locales/fr.json | 25 +- web/src/i18n/locales/ja.json | 25 +- web/src/i18n/locales/ru.json | 25 +- web/src/i18n/locales/vi.json | 25 +- web/src/i18n/locales/zh.json | 25 +- .../Setting/Operation/SettingsCheckin.jsx | 152 +++++++++ 16 files changed, 970 insertions(+), 48 deletions(-) create mode 100644 controller/checkin.go create mode 100644 model/checkin.go create mode 100644 setting/operation_setting/checkin_setting.go create mode 100644 web/src/components/settings/personal/cards/CheckinCalendar.jsx create mode 100644 web/src/pages/Setting/Operation/SettingsCheckin.jsx diff --git a/controller/checkin.go b/controller/checkin.go new file mode 100644 index 000000000..cc8bf4f96 --- /dev/null +++ b/controller/checkin.go @@ -0,0 +1,72 @@ +package controller + +import ( + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" +) + +// GetCheckinStatus 获取用户签到状态和历史记录 +func GetCheckinStatus(c *gin.Context) { + setting := operation_setting.GetCheckinSetting() + if !setting.Enabled { + common.ApiErrorMsg(c, "签到功能未启用") + return + } + userId := c.GetInt("id") + // 获取月份参数,默认为当前月份 + month := c.DefaultQuery("month", time.Now().Format("2006-01")) + + stats, err := model.GetUserCheckinStats(userId, month) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "enabled": setting.Enabled, + "min_quota": setting.MinQuota, + "max_quota": setting.MaxQuota, + "stats": stats, + }, + }) +} + +// DoCheckin 执行用户签到 +func DoCheckin(c *gin.Context) { + setting := operation_setting.GetCheckinSetting() + if !setting.Enabled { + common.ApiErrorMsg(c, "签到功能未启用") + return + } + + userId := c.GetInt("id") + + checkin, err := model.UserCheckin(userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("用户签到,获得额度 %s", logger.LogQuota(checkin.QuotaAwarded))) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "签到成功", + "data": gin.H{ + "quota_awarded": checkin.QuotaAwarded, + "checkin_date": checkin.CheckinDate}, + }) +} diff --git a/controller/misc.go b/controller/misc.go index 70415137a..4d299fc81 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -114,6 +114,7 @@ func GetStatus(c *gin.Context) { "setup": constant.Setup, "user_agreement_enabled": legalSetting.UserAgreement != "", "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", + "checkin_enabled": operation_setting.GetCheckinSetting().Enabled, } // 根据启用状态注入可选内容 diff --git a/model/checkin.go b/model/checkin.go new file mode 100644 index 000000000..71eb8eeae --- /dev/null +++ b/model/checkin.go @@ -0,0 +1,179 @@ +package model + +import ( + "errors" + "math/rand" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + "gorm.io/gorm" +) + +// Checkin 签到记录 +type Checkin struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_user_checkin_date"` + CheckinDate string `json:"checkin_date" gorm:"type:varchar(10);not null;uniqueIndex:idx_user_checkin_date"` // 格式: YYYY-MM-DD + QuotaAwarded int `json:"quota_awarded" gorm:"not null"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` +} + +// CheckinRecord 用于API返回的签到记录(不包含敏感字段) +type CheckinRecord struct { + CheckinDate string `json:"checkin_date"` + QuotaAwarded int `json:"quota_awarded"` +} + +func (Checkin) TableName() string { + return "checkins" +} + +// GetUserCheckinRecords 获取用户在指定日期范围内的签到记录 +func GetUserCheckinRecords(userId int, startDate, endDate string) ([]Checkin, error) { + var records []Checkin + err := DB.Where("user_id = ? AND checkin_date >= ? AND checkin_date <= ?", + userId, startDate, endDate). + Order("checkin_date DESC"). + Find(&records).Error + return records, err +} + +// HasCheckedInToday 检查用户今天是否已签到 +func HasCheckedInToday(userId int) (bool, error) { + today := time.Now().Format("2006-01-02") + var count int64 + err := DB.Model(&Checkin{}). + Where("user_id = ? AND checkin_date = ?", userId, today). + Count(&count).Error + return count > 0, err +} + +// UserCheckin 执行用户签到 +// MySQL 和 PostgreSQL 使用事务保证原子性 +// SQLite 不支持嵌套事务,使用顺序操作 + 手动回滚 +func UserCheckin(userId int) (*Checkin, error) { + setting := operation_setting.GetCheckinSetting() + if !setting.Enabled { + return nil, errors.New("签到功能未启用") + } + + // 检查今天是否已签到 + hasChecked, err := HasCheckedInToday(userId) + if err != nil { + return nil, err + } + if hasChecked { + return nil, errors.New("今日已签到") + } + + // 计算随机额度奖励 + quotaAwarded := setting.MinQuota + if setting.MaxQuota > setting.MinQuota { + quotaAwarded = setting.MinQuota + rand.Intn(setting.MaxQuota-setting.MinQuota+1) + } + + today := time.Now().Format("2006-01-02") + checkin := &Checkin{ + UserId: userId, + CheckinDate: today, + QuotaAwarded: quotaAwarded, + CreatedAt: time.Now().Unix(), + } + + // 根据数据库类型选择不同的策略 + if common.UsingSQLite { + // SQLite 不支持嵌套事务,使用顺序操作 + 手动回滚 + return userCheckinWithoutTransaction(checkin, userId, quotaAwarded) + } + + // MySQL 和 PostgreSQL 支持事务,使用事务保证原子性 + return userCheckinWithTransaction(checkin, userId, quotaAwarded) +} + +// userCheckinWithTransaction 使用事务执行签到(适用于 MySQL 和 PostgreSQL) +func userCheckinWithTransaction(checkin *Checkin, userId int, quotaAwarded int) (*Checkin, error) { + err := DB.Transaction(func(tx *gorm.DB) error { + // 步骤1: 创建签到记录 + // 数据库有唯一约束 (user_id, checkin_date),可以防止并发重复签到 + if err := tx.Create(checkin).Error; err != nil { + return errors.New("签到失败,请稍后重试") + } + + // 步骤2: 在事务中增加用户额度 + if err := tx.Model(&User{}).Where("id = ?", userId). + Update("quota", gorm.Expr("quota + ?", quotaAwarded)).Error; err != nil { + return errors.New("签到失败:更新额度出错") + } + + return nil + }) + + if err != nil { + return nil, err + } + + // 事务成功后,异步更新缓存 + go func() { + _ = cacheIncrUserQuota(userId, int64(quotaAwarded)) + }() + + return checkin, nil +} + +// userCheckinWithoutTransaction 不使用事务执行签到(适用于 SQLite) +func userCheckinWithoutTransaction(checkin *Checkin, userId int, quotaAwarded int) (*Checkin, error) { + // 步骤1: 创建签到记录 + // 数据库有唯一约束 (user_id, checkin_date),可以防止并发重复签到 + if err := DB.Create(checkin).Error; err != nil { + return nil, errors.New("签到失败,请稍后重试") + } + + // 步骤2: 增加用户额度 + // 使用 db=true 强制直接写入数据库,不使用批量更新 + if err := IncreaseUserQuota(userId, quotaAwarded, true); err != nil { + // 如果增加额度失败,需要回滚签到记录 + DB.Delete(checkin) + return nil, errors.New("签到失败:更新额度出错") + } + + return checkin, nil +} + +// GetUserCheckinStats 获取用户签到统计信息 +func GetUserCheckinStats(userId int, month string) (map[string]interface{}, error) { + // 获取指定月份的所有签到记录 + startDate := month + "-01" + endDate := month + "-31" + + records, err := GetUserCheckinRecords(userId, startDate, endDate) + if err != nil { + return nil, err + } + + // 转换为不包含敏感字段的记录 + checkinRecords := make([]CheckinRecord, len(records)) + for i, r := range records { + checkinRecords[i] = CheckinRecord{ + CheckinDate: r.CheckinDate, + QuotaAwarded: r.QuotaAwarded, + } + } + + // 检查今天是否已签到 + hasCheckedToday, _ := HasCheckedInToday(userId) + + // 获取用户所有时间的签到统计 + var totalCheckins int64 + var totalQuota int64 + DB.Model(&Checkin{}).Where("user_id = ?", userId).Count(&totalCheckins) + DB.Model(&Checkin{}).Where("user_id = ?", userId).Select("COALESCE(SUM(quota_awarded), 0)").Scan(&totalQuota) + + return map[string]interface{}{ + "total_quota": totalQuota, // 所有时间累计获得的额度 + "total_checkins": totalCheckins, // 所有时间累计签到次数 + "checkin_count": len(records), // 本月签到次数 + "checked_in_today": hasCheckedToday, // 今天是否已签到 + "records": checkinRecords, // 本月签到记录详情(不含id和user_id) + }, nil +} diff --git a/model/main.go b/model/main.go index 8dcedad0b..586eaa353 100644 --- a/model/main.go +++ b/model/main.go @@ -248,26 +248,27 @@ 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{}, + &Checkin{}, + ) if err != nil { return err } @@ -278,29 +279,30 @@ 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"}, + {&Checkin{}, "Checkin"}, + } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/router/api-router.go b/router/api-router.go index e8266ef3f..e02e1c3f3 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -93,6 +93,10 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/2fa/enable", controller.Enable2FA) selfRoute.POST("/2fa/disable", controller.Disable2FA) selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes) + + // Check-in routes + selfRoute.GET("/checkin", controller.GetCheckinStatus) + selfRoute.POST("/checkin", controller.DoCheckin) } adminRoute := userRoute.Group("/") diff --git a/setting/operation_setting/checkin_setting.go b/setting/operation_setting/checkin_setting.go new file mode 100644 index 000000000..dd4e35945 --- /dev/null +++ b/setting/operation_setting/checkin_setting.go @@ -0,0 +1,37 @@ +package operation_setting + +import "github.com/QuantumNous/new-api/setting/config" + +// CheckinSetting 签到功能配置 +type CheckinSetting struct { + Enabled bool `json:"enabled"` // 是否启用签到功能 + MinQuota int `json:"min_quota"` // 签到最小额度奖励 + MaxQuota int `json:"max_quota"` // 签到最大额度奖励 +} + +// 默认配置 +var checkinSetting = CheckinSetting{ + Enabled: false, // 默认关闭 + MinQuota: 1000, // 默认最小额度 1000 (约 0.002 USD) + MaxQuota: 10000, // 默认最大额度 10000 (约 0.02 USD) +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("checkin_setting", &checkinSetting) +} + +// GetCheckinSetting 获取签到配置 +func GetCheckinSetting() *CheckinSetting { + return &checkinSetting +} + +// IsCheckinEnabled 是否启用签到功能 +func IsCheckinEnabled() bool { + return checkinSetting.Enabled +} + +// GetCheckinQuotaRange 获取签到额度范围 +func GetCheckinQuotaRange() (min, max int) { + return checkinSetting.MinQuota, checkinSetting.MaxQuota +} diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 9f4f584a5..92591db45 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -26,6 +26,7 @@ import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensit import SettingsLog from '../../pages/Setting/Operation/SettingsLog'; import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring'; import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit'; +import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin'; import { API, showError, toBoolean } from '../../helpers'; const OperationSetting = () => { @@ -70,7 +71,10 @@ const OperationSetting = () => { AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', 'monitor_setting.auto_test_channel_enabled': false, - 'monitor_setting.auto_test_channel_minutes': 10, + 'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */, + 'checkin_setting.enabled': false, + 'checkin_setting.min_quota': 1000, + 'checkin_setting.max_quota': 10000, }); let [loading, setLoading] = useState(false); @@ -140,6 +144,10 @@ const OperationSetting = () => { + {/* 签到设置 */} + + + ); diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 6a889356d..e70b997cd 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -39,6 +39,7 @@ import { useTranslation } from 'react-i18next'; import UserInfoHeader from './personal/components/UserInfoHeader'; import AccountManagement from './personal/cards/AccountManagement'; import NotificationSettings from './personal/cards/NotificationSettings'; +import CheckinCalendar from './personal/cards/CheckinCalendar'; import EmailBindModal from './personal/modals/EmailBindModal'; import WeChatBindModal from './personal/modals/WeChatBindModal'; import AccountDeleteModal from './personal/modals/AccountDeleteModal'; @@ -447,6 +448,13 @@ const PersonalSetting = () => { {/* 顶部用户信息区域 */} + {/* 签到日历 - 仅在启用时显示 */} + {status?.checkin_enabled && ( +
+ +
+ )} + {/* 账户管理和其他设置 */}
{/* 左侧:账户管理设置 */} diff --git a/web/src/components/settings/personal/cards/CheckinCalendar.jsx b/web/src/components/settings/personal/cards/CheckinCalendar.jsx new file mode 100644 index 000000000..4b6266ee4 --- /dev/null +++ b/web/src/components/settings/personal/cards/CheckinCalendar.jsx @@ -0,0 +1,321 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useMemo } from 'react'; +import { + Card, + Calendar, + Button, + Typography, + Avatar, + Spin, + Tooltip, + Collapsible, +} from '@douyinfe/semi-ui'; +import { + CalendarCheck, + Gift, + Check, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { API, showError, showSuccess, renderQuota } from '../../../../helpers'; + +const CheckinCalendar = ({ t, status }) => { + const [loading, setLoading] = useState(false); + const [checkinLoading, setCheckinLoading] = useState(false); + const [checkinData, setCheckinData] = useState({ + enabled: false, + stats: { + checked_in_today: false, + total_checkins: 0, + total_quota: 0, + checkin_count: 0, + records: [], + }, + }); + const [currentMonth, setCurrentMonth] = useState( + new Date().toISOString().slice(0, 7), + ); + // 折叠状态:如果已签到则默认折叠 + const [isCollapsed, setIsCollapsed] = useState(true); + + // 创建日期到额度的映射,方便快速查找 + const checkinRecordsMap = useMemo(() => { + const map = {}; + const records = checkinData.stats?.records || []; + records.forEach((record) => { + map[record.checkin_date] = record.quota_awarded; + }); + return map; + }, [checkinData.stats?.records]); + + // 计算本月获得的额度 + const monthlyQuota = useMemo(() => { + const records = checkinData.stats?.records || []; + return records.reduce( + (sum, record) => sum + (record.quota_awarded || 0), + 0, + ); + }, [checkinData.stats?.records]); + + // 获取签到状态 + const fetchCheckinStatus = async (month) => { + setLoading(true); + try { + const res = await API.get(`/api/user/checkin?month=${month}`); + const { success, data, message } = res.data; + if (success) { + setCheckinData(data); + } else { + showError(message || t('获取签到状态失败')); + } + } catch (error) { + showError(t('获取签到状态失败')); + } finally { + setLoading(false); + } + }; + + // 执行签到 + const doCheckin = async () => { + setCheckinLoading(true); + try { + const res = await API.post('/api/user/checkin'); + const { success, data, message } = res.data; + if (success) { + showSuccess( + t('签到成功!获得') + ' ' + renderQuota(data.quota_awarded), + ); + // 刷新签到状态 + fetchCheckinStatus(currentMonth); + } else { + showError(message || t('签到失败')); + } + } catch (error) { + showError(t('签到失败')); + } finally { + setCheckinLoading(false); + } + }; + + useEffect(() => { + if (status?.checkin_enabled) { + fetchCheckinStatus(currentMonth); + } + }, [status?.checkin_enabled, currentMonth]); + + // 当签到状态加载完成后,根据是否已签到设置折叠状态 + useEffect(() => { + if (checkinData.stats?.checked_in_today) { + setIsCollapsed(true); + } else { + setIsCollapsed(false); + } + }, [checkinData.stats?.checked_in_today]); + + // 如果签到功能未启用,不显示组件 + if (!status?.checkin_enabled) { + return null; + } + + // 日期渲染函数 - 显示签到状态和获得的额度 + const dateRender = (dateString) => { + // Semi Calendar 传入的 dateString 是 Date.toString() 格式 + // 需要转换为 YYYY-MM-DD 格式来匹配后端数据 + const date = new Date(dateString); + if (isNaN(date.getTime())) { + return null; + } + // 使用本地时间格式化,避免时区问题 + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const formattedDate = `${year}-${month}-${day}`; // YYYY-MM-DD + const quotaAwarded = checkinRecordsMap[formattedDate]; + const isCheckedIn = quotaAwarded !== undefined; + + if (isCheckedIn) { + return ( + +
+
+ +
+
+ {renderQuota(quotaAwarded)} +
+
+
+ ); + } + return null; + }; + + // 处理月份变化 + const handleMonthChange = (date) => { + const month = date.toISOString().slice(0, 7); + setCurrentMonth(month); + }; + + return ( + + {/* 卡片头部 */} +
+
setIsCollapsed(!isCollapsed)} + > + + + +
+
+ + {t('每日签到')} + + {isCollapsed ? ( + + ) : ( + + )} +
+
+ {checkinData.stats?.checked_in_today + ? t('今日已签到,累计签到') + + ` ${checkinData.stats?.total_checkins || 0} ` + + t('天') + : t('每日签到可获得随机额度奖励')} +
+
+
+ +
+ + {/* 可折叠内容 */} + + {/* 签到统计 */} +
+
+
+ {checkinData.stats?.total_checkins || 0} +
+
{t('累计签到')}
+
+
+
+ {renderQuota(monthlyQuota, 6)} +
+
{t('本月获得')}
+
+
+
+ {renderQuota(checkinData.stats?.total_quota || 0, 6)} +
+
{t('累计获得')}
+
+
+ + {/* 签到日历 - 使用更紧凑的样式 */} + +
+ + dateRender(dateString)} + /> +
+
+ + {/* 签到说明 */} +
+ +
    +
  • {t('每日签到可获得随机额度奖励')}
  • +
  • {t('签到奖励将直接添加到您的账户余额')}
  • +
  • {t('每日仅可签到一次,请勿重复签到')}
  • +
+
+
+
+
+ ); +}; + +export default CheckinCalendar; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index fb34544a4..e8a79ead8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2185,6 +2185,29 @@ "默认补全倍率": "Default completion ratio", "跨分组重试": "Cross-group retry", "跨分组": "Cross-group", - "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "After enabling, when the current group channel fails, it will try the next group's channel in order" + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "After enabling, when the current group channel fails, it will try the next group's channel in order", + "每日签到": "Daily Check-in", + "今日已签到,累计签到": "Checked in today, total check-ins", + "天": "days", + "每日签到可获得随机额度奖励": "Daily check-in rewards random quota", + "今日已签到": "Checked in today", + "立即签到": "Check in now", + "获取签到状态失败": "Failed to get check-in status", + "签到成功!获得": "Check-in successful! Received", + "签到失败": "Check-in failed", + "获得": "Received", + "累计签到": "Total check-ins", + "本月获得": "This month", + "累计获得": "Total received", + "签到奖励将直接添加到您的账户余额": "Check-in rewards will be directly added to your account balance", + "每日仅可签到一次,请勿重复签到": "Only one check-in per day, please do not check in repeatedly", + "签到设置": "Check-in Settings", + "签到功能允许用户每日签到获取随机额度奖励": "Check-in feature allows users to check in daily to receive random quota rewards", + "启用签到功能": "Enable check-in feature", + "签到最小额度": "Minimum check-in quota", + "签到奖励的最小额度": "Minimum quota for check-in rewards", + "签到最大额度": "Maximum check-in quota", + "签到奖励的最大额度": "Maximum quota for check-in rewards", + "保存签到设置": "Save check-in settings" } } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 39b06690e..d8f32661a 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2234,6 +2234,29 @@ "默认补全倍率": "Taux de complétion par défaut", "跨分组重试": "Nouvelle tentative inter-groupes", "跨分组": "Inter-groupes", - "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Après activation, lorsque le canal du groupe actuel échoue, il essaiera le canal du groupe suivant dans l'ordre" + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Après activation, lorsque le canal du groupe actuel échoue, il essaiera le canal du groupe suivant dans l'ordre", + "每日签到": "Enregistrement quotidien", + "今日已签到,累计签到": "Enregistré aujourd'hui, total des enregistrements", + "天": "jours", + "每日签到可获得随机额度奖励": "L'enregistrement quotidien récompense un quota aléatoire", + "今日已签到": "Enregistré aujourd'hui", + "立即签到": "S'enregistrer maintenant", + "获取签到状态失败": "Échec de la récupération du statut d'enregistrement", + "签到成功!获得": "Enregistrement réussi ! Reçu", + "签到失败": "Échec de l'enregistrement", + "获得": "Reçu", + "累计签到": "Total des enregistrements", + "本月获得": "Ce mois-ci", + "累计获得": "Total reçu", + "签到奖励将直接添加到您的账户余额": "Les récompenses d'enregistrement seront directement ajoutées à votre solde de compte", + "每日仅可签到一次,请勿重复签到": "Un seul enregistrement par jour, veuillez ne pas vous enregistrer plusieurs fois", + "签到设置": "Paramètres d'enregistrement", + "签到功能允许用户每日签到获取随机额度奖励": "La fonction d'enregistrement permet aux utilisateurs de s'enregistrer quotidiennement pour recevoir des récompenses de quota aléatoires", + "启用签到功能": "Activer la fonction d'enregistrement", + "签到最小额度": "Quota minimum d'enregistrement", + "签到奖励的最小额度": "Quota minimum pour les récompenses d'enregistrement", + "签到最大额度": "Quota maximum d'enregistrement", + "签到奖励的最大额度": "Quota maximum pour les récompenses d'enregistrement", + "保存签到设置": "Enregistrer les paramètres d'enregistrement" } } diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 22e7606dd..073142256 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2133,6 +2133,29 @@ "随机种子 (留空为随机)": "ランダムシード(空欄でランダム)", "跨分组重试": "グループ間リトライ", "跨分组": "グループ間", - "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します" + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します", + "每日签到": "毎日のチェックイン", + "今日已签到,累计签到": "本日チェックイン済み、累計チェックイン", + "天": "日", + "每日签到可获得随机额度奖励": "毎日のチェックインでランダムなクォータ報酬を獲得できます", + "今日已签到": "本日チェックイン済み", + "立即签到": "今すぐチェックイン", + "获取签到状态失败": "チェックイン状態の取得に失敗しました", + "签到成功!获得": "チェックイン成功!獲得", + "签到失败": "チェックインに失敗しました", + "获得": "獲得", + "累计签到": "累計チェックイン", + "本月获得": "今月の獲得", + "累计获得": "累計獲得", + "签到奖励将直接添加到您的账户余额": "チェックイン報酬は直接アカウント残高に追加されます", + "每日仅可签到一次,请勿重复签到": "1日1回のみチェックイン可能です。重複チェックインはしないでください", + "签到设置": "チェックイン設定", + "签到功能允许用户每日签到获取随机额度奖励": "チェックイン機能により、ユーザーは毎日チェックインしてランダムなクォータ報酬を獲得できます", + "启用签到功能": "チェックイン機能を有効にする", + "签到最小额度": "チェックイン最小クォータ", + "签到奖励的最小额度": "チェックイン報酬の最小クォータ", + "签到最大额度": "チェックイン最大クォータ", + "签到奖励的最大额度": "チェックイン報酬の最大クォータ", + "保存签到设置": "チェックイン設定を保存" } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index d71e12d1b..5235440e0 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2244,6 +2244,29 @@ "随机种子 (留空为随机)": "Случайное зерно (оставьте пустым для случайного)", "跨分组重试": "Повторная попытка между группами", "跨分组": "Межгрупповой", - "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку" + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку", + "每日签到": "Ежедневная регистрация", + "今日已签到,累计签到": "Зарегистрирован сегодня, всего регистраций", + "天": "дней", + "每日签到可获得随机额度奖励": "Ежедневная регистрация награждает случайной квотой", + "今日已签到": "Зарегистрирован сегодня", + "立即签到": "Зарегистрироваться сейчас", + "获取签到状态失败": "Не удалось получить статус регистрации", + "签到成功!获得": "Регистрация успешна! Получено", + "签到失败": "Регистрация не удалась", + "获得": "Получено", + "累计签到": "Всего регистраций", + "本月获得": "В этом месяце", + "累计获得": "Всего получено", + "签到奖励将直接添加到您的账户余额": "Награды за регистрацию будут напрямую добавлены на баланс вашего счета", + "每日仅可签到一次,请勿重复签到": "Только одна регистрация в день, пожалуйста, не регистрируйтесь повторно", + "签到设置": "Настройки регистрации", + "签到功能允许用户每日签到获取随机额度奖励": "Функция регистрации позволяет пользователям регистрироваться ежедневно для получения случайных наград в виде квоты", + "启用签到功能": "Включить функцию регистрации", + "签到最小额度": "Минимальная квота регистрации", + "签到奖励的最小额度": "Минимальная квота для наград за регистрацию", + "签到最大额度": "Максимальная квота регистрации", + "签到奖励的最大额度": "Максимальная квота для наград за регистрацию", + "保存签到设置": "Сохранить настройки регистрации" } } diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 51113ff44..e37e30fa4 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -2744,6 +2744,29 @@ "随机种子 (留空为随机)": "Hạt giống ngẫu nhiên (để trống cho ngẫu nhiên)", "跨分组重试": "Thử lại giữa các nhóm", "跨分组": "Giữa các nhóm", - "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Sau khi bật, khi kênh nhóm hiện tại thất bại, nó sẽ thử kênh của nhóm tiếp theo theo thứ tự" + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Sau khi bật, khi kênh nhóm hiện tại thất bại, nó sẽ thử kênh của nhóm tiếp theo theo thứ tự", + "每日签到": "Đăng nhập hàng ngày", + "今日已签到,累计签到": "Đã đăng nhập hôm nay, tổng số lần đăng nhập", + "天": "ngày", + "每日签到可获得随机额度奖励": "Đăng nhập hàng ngày để nhận phần thưởng hạn mức ngẫu nhiên", + "今日已签到": "Đã đăng nhập hôm nay", + "立即签到": "Đăng nhập ngay", + "获取签到状态失败": "Không thể lấy trạng thái đăng nhập", + "签到成功!获得": "Đăng nhập thành công! Đã nhận", + "签到失败": "Đăng nhập thất bại", + "获得": "Đã nhận", + "累计签到": "Tổng số lần đăng nhập", + "本月获得": "Tháng này", + "累计获得": "Tổng đã nhận", + "签到奖励将直接添加到您的账户余额": "Phần thưởng đăng nhập sẽ được thêm trực tiếp vào số dư tài khoản của bạn", + "每日仅可签到一次,请勿重复签到": "Chỉ có thể đăng nhập một lần mỗi ngày, vui lòng không đăng nhập lặp lại", + "签到设置": "Cài đặt đăng nhập", + "签到功能允许用户每日签到获取随机额度奖励": "Tính năng đăng nhập cho phép người dùng đăng nhập hàng ngày để nhận phần thưởng hạn mức ngẫu nhiên", + "启用签到功能": "Bật tính năng đăng nhập", + "签到最小额度": "Hạn mức đăng nhập tối thiểu", + "签到奖励的最小额度": "Hạn mức tối thiểu cho phần thưởng đăng nhập", + "签到最大额度": "Hạn mức đăng nhập tối đa", + "签到奖励的最大额度": "Hạn mức tối đa cho phần thưởng đăng nhập", + "保存签到设置": "Lưu cài đặt đăng nhập" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 35ec62ba1..3347d8c3c 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2211,6 +2211,29 @@ "随机种子 (留空为随机)": "随机种子 (留空为随机)", "跨分组重试": "跨分组重试", "跨分组": "跨分组", - "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道" + "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道", + "每日签到": "每日签到", + "今日已签到,累计签到": "今日已签到,累计签到", + "天": "天", + "每日签到可获得随机额度奖励": "每日签到可获得随机额度奖励", + "今日已签到": "今日已签到", + "立即签到": "立即签到", + "获取签到状态失败": "获取签到状态失败", + "签到成功!获得": "签到成功!获得", + "签到失败": "签到失败", + "获得": "获得", + "累计签到": "累计签到", + "本月获得": "本月获得", + "累计获得": "累计获得", + "签到奖励将直接添加到您的账户余额": "签到奖励将直接添加到您的账户余额", + "每日仅可签到一次,请勿重复签到": "每日仅可签到一次,请勿重复签到", + "签到设置": "签到设置", + "签到功能允许用户每日签到获取随机额度奖励": "签到功能允许用户每日签到获取随机额度奖励", + "启用签到功能": "启用签到功能", + "签到最小额度": "签到最小额度", + "签到奖励的最小额度": "签到奖励的最小额度", + "签到最大额度": "签到最大额度", + "签到奖励的最大额度": "签到奖励的最大额度", + "保存签到设置": "保存签到设置" } } diff --git a/web/src/pages/Setting/Operation/SettingsCheckin.jsx b/web/src/pages/Setting/Operation/SettingsCheckin.jsx new file mode 100644 index 000000000..1ce5faa72 --- /dev/null +++ b/web/src/pages/Setting/Operation/SettingsCheckin.jsx @@ -0,0 +1,152 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState, useRef } from 'react'; +import { Button, Col, Form, Row, Spin, Typography } from '@douyinfe/semi-ui'; +import { + compareObjects, + API, + showError, + showSuccess, + showWarning, +} from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +export default function SettingsCheckin(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + 'checkin_setting.enabled': false, + 'checkin_setting.min_quota': 1000, + 'checkin_setting.max_quota': 10000, + }); + const refForm = useRef(); + const [inputsRow, setInputsRow] = useState(inputs); + + function handleFieldChange(fieldName) { + return (value) => { + setInputs((inputs) => ({ ...inputs, [fieldName]: value })); + }; + } + + function onSubmit() { + const updateArray = compareObjects(inputs, inputsRow); + if (!updateArray.length) return showWarning(t('你似乎并没有修改什么')); + const requestQueue = updateArray.map((item) => { + let value = ''; + if (typeof inputs[item.key] === 'boolean') { + value = String(inputs[item.key]); + } else { + 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('保存成功')); + props.refresh(); + }) + .catch(() => { + showError(t('保存失败,请重试')); + }) + .finally(() => { + setLoading(false); + }); + } + + useEffect(() => { + const currentInputs = {}; + for (let key in props.options) { + if (Object.keys(inputs).includes(key)) { + currentInputs[key] = props.options[key]; + } + } + setInputs(currentInputs); + setInputsRow(structuredClone(currentInputs)); + refForm.current.setValues(currentInputs); + }, [props.options]); + + return ( + <> + +
(refForm.current = formAPI)} + style={{ marginBottom: 15 }} + > + + + {t('签到功能允许用户每日签到获取随机额度奖励')} + + + + + + + + + + + + + + + + +
+
+ + ); +} From a0328b2f5e979a2211476153853fb0b146aeab51 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Sat, 3 Jan 2026 00:43:52 +0800 Subject: [PATCH 25/76] fix(checkin): prevent visual flicker when loading check-in component - Add initialLoaded state to track first data load completion - Set isCollapsed to null initially, determined after data loads - Show loading state on button and description text before data arrives - Remove auto-collapse effect that caused visual flicker - Add i18n translations for loading states (en/fr/ja/ru/vi/zh) Fixes issue where component would collapse/expand after data loads, causing visual flicker when navigating to personal settings page. --- .../personal/cards/CheckinCalendar.jsx | 55 +++++++++++-------- web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/fr.json | 2 + web/src/i18n/locales/ja.json | 2 + web/src/i18n/locales/ru.json | 2 + web/src/i18n/locales/vi.json | 2 + web/src/i18n/locales/zh.json | 2 + 7 files changed, 45 insertions(+), 22 deletions(-) diff --git a/web/src/components/settings/personal/cards/CheckinCalendar.jsx b/web/src/components/settings/personal/cards/CheckinCalendar.jsx index 4b6266ee4..d6f57cf69 100644 --- a/web/src/components/settings/personal/cards/CheckinCalendar.jsx +++ b/web/src/components/settings/personal/cards/CheckinCalendar.jsx @@ -53,8 +53,10 @@ const CheckinCalendar = ({ t, status }) => { const [currentMonth, setCurrentMonth] = useState( new Date().toISOString().slice(0, 7), ); - // 折叠状态:如果已签到则默认折叠 - const [isCollapsed, setIsCollapsed] = useState(true); + // 初始加载状态,用于避免折叠状态闪烁 + const [initialLoaded, setInitialLoaded] = useState(false); + // 折叠状态:null 表示未确定(等待首次加载) + const [isCollapsed, setIsCollapsed] = useState(null); // 创建日期到额度的映射,方便快速查找 const checkinRecordsMap = useMemo(() => { @@ -77,17 +79,31 @@ const CheckinCalendar = ({ t, status }) => { // 获取签到状态 const fetchCheckinStatus = async (month) => { + const isFirstLoad = !initialLoaded; setLoading(true); try { const res = await API.get(`/api/user/checkin?month=${month}`); const { success, data, message } = res.data; if (success) { setCheckinData(data); + // 首次加载时,根据签到状态设置折叠状态 + if (isFirstLoad) { + setIsCollapsed(data.stats?.checked_in_today ?? false); + setInitialLoaded(true); + } } else { showError(message || t('获取签到状态失败')); + if (isFirstLoad) { + setIsCollapsed(false); + setInitialLoaded(true); + } } } catch (error) { showError(t('获取签到状态失败')); + if (isFirstLoad) { + setIsCollapsed(false); + setInitialLoaded(true); + } } finally { setLoading(false); } @@ -121,15 +137,6 @@ const CheckinCalendar = ({ t, status }) => { } }, [status?.checkin_enabled, currentMonth]); - // 当签到状态加载完成后,根据是否已签到设置折叠状态 - useEffect(() => { - if (checkinData.stats?.checked_in_today) { - setIsCollapsed(true); - } else { - setIsCollapsed(false); - } - }, [checkinData.stats?.checked_in_today]); - // 如果签到功能未启用,不显示组件 if (!status?.checkin_enabled) { return null; @@ -200,11 +207,13 @@ const CheckinCalendar = ({ t, status }) => { )}
- {checkinData.stats?.checked_in_today - ? t('今日已签到,累计签到') + - ` ${checkinData.stats?.total_checkins || 0} ` + - t('天') - : t('每日签到可获得随机额度奖励')} + {!initialLoaded + ? t('正在加载签到状态...') + : checkinData.stats?.checked_in_today + ? t('今日已签到,累计签到') + + ` ${checkinData.stats?.total_checkins || 0} ` + + t('天') + : t('每日签到可获得随机额度奖励')}
@@ -213,18 +222,20 @@ const CheckinCalendar = ({ t, status }) => { theme='solid' icon={} onClick={doCheckin} - loading={checkinLoading} - disabled={checkinData.stats?.checked_in_today} + loading={checkinLoading || !initialLoaded} + disabled={!initialLoaded || checkinData.stats?.checked_in_today} className='!bg-green-600 hover:!bg-green-700' > - {checkinData.stats?.checked_in_today - ? t('今日已签到') - : t('立即签到')} + {!initialLoaded + ? t('加载中...') + : checkinData.stats?.checked_in_today + ? t('今日已签到') + : t('立即签到')} {/* 可折叠内容 */} - + {/* 签到统计 */}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e8a79ead8..ab923ce7a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2192,6 +2192,8 @@ "每日签到可获得随机额度奖励": "Daily check-in rewards random quota", "今日已签到": "Checked in today", "立即签到": "Check in now", + "加载中...": "Loading...", + "正在加载签到状态...": "Loading check-in status...", "获取签到状态失败": "Failed to get check-in status", "签到成功!获得": "Check-in successful! Received", "签到失败": "Check-in failed", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index d8f32661a..6c4b0601f 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2241,6 +2241,8 @@ "每日签到可获得随机额度奖励": "L'enregistrement quotidien récompense un quota aléatoire", "今日已签到": "Enregistré aujourd'hui", "立即签到": "S'enregistrer maintenant", + "加载中...": "Chargement...", + "正在加载签到状态...": "Chargement du statut d'enregistrement...", "获取签到状态失败": "Échec de la récupération du statut d'enregistrement", "签到成功!获得": "Enregistrement réussi ! Reçu", "签到失败": "Échec de l'enregistrement", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 073142256..cb64e9211 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2140,6 +2140,8 @@ "每日签到可获得随机额度奖励": "毎日のチェックインでランダムなクォータ報酬を獲得できます", "今日已签到": "本日チェックイン済み", "立即签到": "今すぐチェックイン", + "加载中...": "読み込み中...", + "正在加载签到状态...": "チェックイン状態を読み込み中...", "获取签到状态失败": "チェックイン状態の取得に失敗しました", "签到成功!获得": "チェックイン成功!獲得", "签到失败": "チェックインに失敗しました", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5235440e0..a790e6477 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2251,6 +2251,8 @@ "每日签到可获得随机额度奖励": "Ежедневная регистрация награждает случайной квотой", "今日已签到": "Зарегистрирован сегодня", "立即签到": "Зарегистрироваться сейчас", + "加载中...": "Загрузка...", + "正在加载签到状态...": "Загрузка статуса регистрации...", "获取签到状态失败": "Не удалось получить статус регистрации", "签到成功!获得": "Регистрация успешна! Получено", "签到失败": "Регистрация не удалась", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index e37e30fa4..8fe2861bb 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -2751,6 +2751,8 @@ "每日签到可获得随机额度奖励": "Đăng nhập hàng ngày để nhận phần thưởng hạn mức ngẫu nhiên", "今日已签到": "Đã đăng nhập hôm nay", "立即签到": "Đăng nhập ngay", + "加载中...": "Đang tải...", + "正在加载签到状态...": "Đang tải trạng thái đăng nhập...", "获取签到状态失败": "Không thể lấy trạng thái đăng nhập", "签到成功!获得": "Đăng nhập thành công! Đã nhận", "签到失败": "Đăng nhập thất bại", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3347d8c3c..0877286ea 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2218,6 +2218,8 @@ "每日签到可获得随机额度奖励": "每日签到可获得随机额度奖励", "今日已签到": "今日已签到", "立即签到": "立即签到", + "加载中...": "加载中...", + "正在加载签到状态...": "正在加载签到状态...", "获取签到状态失败": "获取签到状态失败", "签到成功!获得": "签到成功!获得", "签到失败": "签到失败", From be8e6445467b8839808a970af9fc870c776839f4 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Sat, 3 Jan 2026 00:55:08 +0800 Subject: [PATCH 26/76] fix: remove a duplicate key in i18n --- web/src/i18n/locales/en.json | 1 - web/src/i18n/locales/fr.json | 1 - web/src/i18n/locales/ja.json | 1 - web/src/i18n/locales/ru.json | 1 - web/src/i18n/locales/vi.json | 1 - web/src/i18n/locales/zh.json | 1 - 6 files changed, 6 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ab923ce7a..130c79165 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2192,7 +2192,6 @@ "每日签到可获得随机额度奖励": "Daily check-in rewards random quota", "今日已签到": "Checked in today", "立即签到": "Check in now", - "加载中...": "Loading...", "正在加载签到状态...": "Loading check-in status...", "获取签到状态失败": "Failed to get check-in status", "签到成功!获得": "Check-in successful! Received", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 6c4b0601f..0184cd069 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2241,7 +2241,6 @@ "每日签到可获得随机额度奖励": "L'enregistrement quotidien récompense un quota aléatoire", "今日已签到": "Enregistré aujourd'hui", "立即签到": "S'enregistrer maintenant", - "加载中...": "Chargement...", "正在加载签到状态...": "Chargement du statut d'enregistrement...", "获取签到状态失败": "Échec de la récupération du statut d'enregistrement", "签到成功!获得": "Enregistrement réussi ! Reçu", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index cb64e9211..183aecce9 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2140,7 +2140,6 @@ "每日签到可获得随机额度奖励": "毎日のチェックインでランダムなクォータ報酬を獲得できます", "今日已签到": "本日チェックイン済み", "立即签到": "今すぐチェックイン", - "加载中...": "読み込み中...", "正在加载签到状态...": "チェックイン状態を読み込み中...", "获取签到状态失败": "チェックイン状態の取得に失敗しました", "签到成功!获得": "チェックイン成功!獲得", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index a790e6477..1d489dc93 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2251,7 +2251,6 @@ "每日签到可获得随机额度奖励": "Ежедневная регистрация награждает случайной квотой", "今日已签到": "Зарегистрирован сегодня", "立即签到": "Зарегистрироваться сейчас", - "加载中...": "Загрузка...", "正在加载签到状态...": "Загрузка статуса регистрации...", "获取签到状态失败": "Не удалось получить статус регистрации", "签到成功!获得": "Регистрация успешна! Получено", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 8fe2861bb..217a96d97 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -2751,7 +2751,6 @@ "每日签到可获得随机额度奖励": "Đăng nhập hàng ngày để nhận phần thưởng hạn mức ngẫu nhiên", "今日已签到": "Đã đăng nhập hôm nay", "立即签到": "Đăng nhập ngay", - "加载中...": "Đang tải...", "正在加载签到状态...": "Đang tải trạng thái đăng nhập...", "获取签到状态失败": "Không thể lấy trạng thái đăng nhập", "签到成功!获得": "Đăng nhập thành công! Đã nhận", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 0877286ea..a55d6e29e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2218,7 +2218,6 @@ "每日签到可获得随机额度奖励": "每日签到可获得随机额度奖励", "今日已签到": "今日已签到", "立即签到": "立即签到", - "加载中...": "加载中...", "正在加载签到状态...": "正在加载签到状态...", "获取签到状态失败": "获取签到状态失败", "签到成功!获得": "签到成功!获得", From 817da8d73c1fbd527bc8405cdbd6b9ac6a338221 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 3 Jan 2026 10:27:16 +0800 Subject: [PATCH 27/76] feat: add parameter coverage for the operations: copy, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, and regex_replace --- relay/common/override.go | 121 +++++- relay/common/override_test.go | 791 ++++++++++++++++++++++++++++++++++ 2 files changed, 909 insertions(+), 3 deletions(-) create mode 100644 relay/common/override_test.go diff --git a/relay/common/override.go b/relay/common/override.go index 3850218c3..872c960ff 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -23,7 +23,7 @@ type ConditionOperation struct { type ParamOperation struct { Path string `json:"path"` - Mode string `json:"mode"` // delete, set, move, prepend, append + Mode string `json:"mode"` // delete, set, move, copy, prepend, append, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, regex_replace Value interface{} `json:"value"` KeepOrigin bool `json:"keep_origin"` From string `json:"from,omitempty"` @@ -330,8 +330,6 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte } // 处理路径中的负数索引 opPath := processNegativeIndex(result, op.Path) - opFrom := processNegativeIndex(result, op.From) - opTo := processNegativeIndex(result, op.To) switch op.Mode { case "delete": @@ -342,11 +340,38 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte } result, err = sjson.Set(result, opPath, op.Value) case "move": + opFrom := processNegativeIndex(result, op.From) + opTo := processNegativeIndex(result, op.To) result, err = moveValue(result, opFrom, opTo) + case "copy": + if op.From == "" || op.To == "" { + return "", fmt.Errorf("copy from/to is required") + } + opFrom := processNegativeIndex(result, op.From) + opTo := processNegativeIndex(result, op.To) + result, err = copyValue(result, opFrom, opTo) case "prepend": result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true) case "append": result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false) + case "trim_prefix": + result, err = trimStringValue(result, opPath, op.Value, true) + case "trim_suffix": + result, err = trimStringValue(result, opPath, op.Value, false) + case "ensure_prefix": + result, err = ensureStringAffix(result, opPath, op.Value, true) + case "ensure_suffix": + result, err = ensureStringAffix(result, opPath, op.Value, false) + case "trim_space": + result, err = transformStringValue(result, opPath, strings.TrimSpace) + case "to_lower": + result, err = transformStringValue(result, opPath, strings.ToLower) + case "to_upper": + result, err = transformStringValue(result, opPath, strings.ToUpper) + case "replace": + result, err = replaceStringValue(result, opPath, op.From, op.To) + case "regex_replace": + result, err = regexReplaceStringValue(result, opPath, op.From, op.To) default: return "", fmt.Errorf("unknown operation: %s", op.Mode) } @@ -369,6 +394,14 @@ func moveValue(jsonStr, fromPath, toPath string) (string, error) { return sjson.Delete(result, fromPath) } +func copyValue(jsonStr, fromPath, toPath string) (string, error) { + sourceValue := gjson.Get(jsonStr, fromPath) + if !sourceValue.Exists() { + return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath) + } + return sjson.Set(jsonStr, toPath, sourceValue.Value()) +} + func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) { current := gjson.Get(jsonStr, path) switch { @@ -422,6 +455,88 @@ func modifyString(jsonStr, path string, value interface{}, isPrepend bool) (stri return sjson.Set(jsonStr, path, newStr) } +func trimStringValue(jsonStr, path string, value interface{}, isPrefix bool) (string, error) { + current := gjson.Get(jsonStr, path) + if current.Type != gjson.String { + return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type) + } + + if value == nil { + return jsonStr, fmt.Errorf("trim value is required") + } + valueStr := fmt.Sprintf("%v", value) + + var newStr string + if isPrefix { + newStr = strings.TrimPrefix(current.String(), valueStr) + } else { + newStr = strings.TrimSuffix(current.String(), valueStr) + } + return sjson.Set(jsonStr, path, newStr) +} + +func ensureStringAffix(jsonStr, path string, value interface{}, isPrefix bool) (string, error) { + current := gjson.Get(jsonStr, path) + if current.Type != gjson.String { + return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type) + } + + if value == nil { + return jsonStr, fmt.Errorf("ensure value is required") + } + valueStr := fmt.Sprintf("%v", value) + if valueStr == "" { + return jsonStr, fmt.Errorf("ensure value is required") + } + + currentStr := current.String() + if isPrefix { + if strings.HasPrefix(currentStr, valueStr) { + return jsonStr, nil + } + return sjson.Set(jsonStr, path, valueStr+currentStr) + } + + if strings.HasSuffix(currentStr, valueStr) { + return jsonStr, nil + } + return sjson.Set(jsonStr, path, currentStr+valueStr) +} + +func transformStringValue(jsonStr, path string, transform func(string) string) (string, error) { + current := gjson.Get(jsonStr, path) + if current.Type != gjson.String { + return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type) + } + return sjson.Set(jsonStr, path, transform(current.String())) +} + +func replaceStringValue(jsonStr, path, from, to string) (string, error) { + current := gjson.Get(jsonStr, path) + if current.Type != gjson.String { + return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type) + } + if from == "" { + return jsonStr, fmt.Errorf("replace from is required") + } + return sjson.Set(jsonStr, path, strings.ReplaceAll(current.String(), from, to)) +} + +func regexReplaceStringValue(jsonStr, path, pattern, replacement string) (string, error) { + current := gjson.Get(jsonStr, path) + if current.Type != gjson.String { + return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type) + } + if pattern == "" { + return jsonStr, fmt.Errorf("regex pattern is required") + } + re, err := regexp.Compile(pattern) + if err != nil { + return jsonStr, err + } + return sjson.Set(jsonStr, path, re.ReplaceAllString(current.String(), replacement)) +} + func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (string, error) { current := gjson.Get(jsonStr, path) var currentMap, newMap map[string]interface{} diff --git a/relay/common/override_test.go b/relay/common/override_test.go new file mode 100644 index 000000000..021df3f60 --- /dev/null +++ b/relay/common/override_test.go @@ -0,0 +1,791 @@ +package common + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestApplyParamOverrideTrimPrefix(t *testing.T) { + // trim_prefix example: + // {"operations":[{"path":"model","mode":"trim_prefix","value":"openai/"}]} + input := []byte(`{"model":"openai/gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "trim_prefix", + "value": "openai/", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.7}`, string(out)) +} + +func TestApplyParamOverrideTrimSuffix(t *testing.T) { + // trim_suffix example: + // {"operations":[{"path":"model","mode":"trim_suffix","value":"-latest"}]} + input := []byte(`{"model":"gpt-4-latest","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "trim_suffix", + "value": "-latest", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.7}`, string(out)) +} + +func TestApplyParamOverrideTrimNoop(t *testing.T) { + // trim_prefix no-op example: + // {"operations":[{"path":"model","mode":"trim_prefix","value":"openai/"}]} + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "trim_prefix", + "value": "openai/", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.7}`, string(out)) +} + +func TestApplyParamOverrideTrimRequiresValue(t *testing.T) { + // trim_prefix requires value example: + // {"operations":[{"path":"model","mode":"trim_prefix"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "trim_prefix", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideReplace(t *testing.T) { + // replace example: + // {"operations":[{"path":"model","mode":"replace","from":"openai/","to":""}]} + input := []byte(`{"model":"openai/gpt-4o-mini","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "replace", + "from": "openai/", + "to": "", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4o-mini","temperature":0.7}`, string(out)) +} + +func TestApplyParamOverrideRegexReplace(t *testing.T) { + // regex_replace example: + // {"operations":[{"path":"model","mode":"regex_replace","from":"^gpt-","to":"openai/gpt-"}]} + input := []byte(`{"model":"gpt-4o-mini","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "regex_replace", + "from": "^gpt-", + "to": "openai/gpt-", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"openai/gpt-4o-mini","temperature":0.7}`, string(out)) +} + +func TestApplyParamOverrideReplaceRequiresFrom(t *testing.T) { + // replace requires from example: + // {"operations":[{"path":"model","mode":"replace"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "replace", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideRegexReplaceRequiresPattern(t *testing.T) { + // regex_replace requires from(pattern) example: + // {"operations":[{"path":"model","mode":"regex_replace"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "regex_replace", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideDelete(t *testing.T) { + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "delete", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("failed to unmarshal output JSON: %v", err) + } + if _, exists := got["temperature"]; exists { + t.Fatalf("expected temperature to be deleted") + } +} + +func TestApplyParamOverrideSet(t *testing.T) { + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out)) +} + +func TestApplyParamOverrideSetKeepOrigin(t *testing.T) { + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "keep_origin": true, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.7}`, string(out)) +} + +func TestApplyParamOverrideMove(t *testing.T) { + input := []byte(`{"model":"gpt-4","meta":{"x":1}}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "move", + "from": "model", + "to": "meta.model", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"meta":{"x":1,"model":"gpt-4"}}`, string(out)) +} + +func TestApplyParamOverrideMoveMissingSource(t *testing.T) { + input := []byte(`{"meta":{"x":1}}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "move", + "from": "model", + "to": "meta.model", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverridePrependAppendString(t *testing.T) { + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "prepend", + "value": "openai/", + }, + map[string]interface{}{ + "path": "model", + "mode": "append", + "value": "-latest", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"openai/gpt-4-latest"}`, string(out)) +} + +func TestApplyParamOverridePrependAppendArray(t *testing.T) { + input := []byte(`{"arr":[1,2]}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "arr", + "mode": "prepend", + "value": 0, + }, + map[string]interface{}{ + "path": "arr", + "mode": "append", + "value": []interface{}{3, 4}, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"arr":[0,1,2,3,4]}`, string(out)) +} + +func TestApplyParamOverrideAppendObjectMergeKeepOrigin(t *testing.T) { + input := []byte(`{"obj":{"a":1}}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "obj", + "mode": "append", + "keep_origin": true, + "value": map[string]interface{}{ + "a": 2, + "b": 3, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"obj":{"a":1,"b":3}}`, string(out)) +} + +func TestApplyParamOverrideAppendObjectMergeOverride(t *testing.T) { + input := []byte(`{"obj":{"a":1}}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "obj", + "mode": "append", + "value": map[string]interface{}{ + "a": 2, + "b": 3, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"obj":{"a":2,"b":3}}`, string(out)) +} + +func TestApplyParamOverrideConditionORDefault(t *testing.T) { + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "conditions": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "prefix", + "value": "gpt", + }, + map[string]interface{}{ + "path": "model", + "mode": "prefix", + "value": "claude", + }, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out)) +} + +func TestApplyParamOverrideConditionAND(t *testing.T) { + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "logic": "AND", + "conditions": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "prefix", + "value": "gpt", + }, + map[string]interface{}{ + "path": "temperature", + "mode": "gt", + "value": 0.5, + }, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out)) +} + +func TestApplyParamOverrideConditionInvert(t *testing.T) { + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "conditions": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "prefix", + "value": "gpt", + "invert": true, + }, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","temperature":0.7}`, string(out)) +} + +func TestApplyParamOverrideConditionPassMissingKey(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "conditions": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "prefix", + "value": "gpt", + "pass_missing_key": true, + }, + }, + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.1}`, string(out)) +} + +func TestApplyParamOverrideConditionFromContext(t *testing.T) { + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "temperature", + "mode": "set", + "value": 0.1, + "conditions": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "prefix", + "value": "gpt", + }, + }, + }, + }, + } + ctx := map[string]interface{}{ + "model": "gpt-4", + } + + out, err := ApplyParamOverride(input, override, ctx) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"temperature":0.1}`, string(out)) +} + +func TestApplyParamOverrideNegativeIndexPath(t *testing.T) { + input := []byte(`{"arr":[{"model":"a"},{"model":"b"}]}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "arr.-1.model", + "mode": "set", + "value": "c", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"arr":[{"model":"a"},{"model":"c"}]}`, string(out)) +} + +func TestApplyParamOverrideRegexReplaceInvalidPattern(t *testing.T) { + // regex_replace invalid pattern example: + // {"operations":[{"path":"model","mode":"regex_replace","from":"(","to":"x"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "regex_replace", + "from": "(", + "to": "x", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideCopy(t *testing.T) { + // copy example: + // {"operations":[{"mode":"copy","from":"model","to":"original_model"}]} + input := []byte(`{"model":"gpt-4","temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy", + "from": "model", + "to": "original_model", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4","original_model":"gpt-4","temperature":0.7}`, string(out)) +} + +func TestApplyParamOverrideCopyMissingSource(t *testing.T) { + // copy missing source example: + // {"operations":[{"mode":"copy","from":"model","to":"original_model"}]} + input := []byte(`{"temperature":0.7}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy", + "from": "model", + "to": "original_model", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideCopyRequiresFromTo(t *testing.T) { + // copy requires from/to example: + // {"operations":[{"mode":"copy"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "copy", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideEnsurePrefix(t *testing.T) { + // ensure_prefix example: + // {"operations":[{"path":"model","mode":"ensure_prefix","value":"openai/"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "ensure_prefix", + "value": "openai/", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"openai/gpt-4"}`, string(out)) +} + +func TestApplyParamOverrideEnsurePrefixNoop(t *testing.T) { + // ensure_prefix no-op example: + // {"operations":[{"path":"model","mode":"ensure_prefix","value":"openai/"}]} + input := []byte(`{"model":"openai/gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "ensure_prefix", + "value": "openai/", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"openai/gpt-4"}`, string(out)) +} + +func TestApplyParamOverrideEnsureSuffix(t *testing.T) { + // ensure_suffix example: + // {"operations":[{"path":"model","mode":"ensure_suffix","value":"-latest"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "ensure_suffix", + "value": "-latest", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4-latest"}`, string(out)) +} + +func TestApplyParamOverrideEnsureSuffixNoop(t *testing.T) { + // ensure_suffix no-op example: + // {"operations":[{"path":"model","mode":"ensure_suffix","value":"-latest"}]} + input := []byte(`{"model":"gpt-4-latest"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "ensure_suffix", + "value": "-latest", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4-latest"}`, string(out)) +} + +func TestApplyParamOverrideEnsureRequiresValue(t *testing.T) { + // ensure_prefix requires value example: + // {"operations":[{"path":"model","mode":"ensure_prefix"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "ensure_prefix", + }, + }, + } + + _, err := ApplyParamOverride(input, override, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestApplyParamOverrideTrimSpace(t *testing.T) { + // trim_space example: + // {"operations":[{"path":"model","mode":"trim_space"}]} + input := []byte("{\"model\":\" gpt-4 \\n\"}") + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "trim_space", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4"}`, string(out)) +} + +func TestApplyParamOverrideToLower(t *testing.T) { + // to_lower example: + // {"operations":[{"path":"model","mode":"to_lower"}]} + input := []byte(`{"model":"GPT-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "to_lower", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"gpt-4"}`, string(out)) +} + +func TestApplyParamOverrideToUpper(t *testing.T) { + // to_upper example: + // {"operations":[{"path":"model","mode":"to_upper"}]} + input := []byte(`{"model":"gpt-4"}`) + override := map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "model", + "mode": "to_upper", + }, + }, + } + + out, err := ApplyParamOverride(input, override, nil) + if err != nil { + t.Fatalf("ApplyParamOverride returned error: %v", err) + } + assertJSONEqual(t, `{"model":"GPT-4"}`, string(out)) +} + +func assertJSONEqual(t *testing.T, want, got string) { + t.Helper() + + var wantObj interface{} + var gotObj interface{} + + if err := json.Unmarshal([]byte(want), &wantObj); err != nil { + t.Fatalf("failed to unmarshal want JSON: %v", err) + } + if err := json.Unmarshal([]byte(got), &gotObj); err != nil { + t.Fatalf("failed to unmarshal got JSON: %v", err) + } + + if !reflect.DeepEqual(wantObj, gotObj) { + t.Fatalf("json not equal\nwant: %s\ngot: %s", want, got) + } +} From c682e413382ee4a4731bcfa209b7a8157551c1b0 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Sat, 3 Jan 2026 10:43:16 +0800 Subject: [PATCH 28/76] fix: CrossGroupRetry default false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除gorm:"default:false",避免每次 AutoMigrate时都执行ALTER TABLE `tokens` MODIFY COLUMN `cross_group_retry` boolean DEFAULT false 且bool默认false不影响原有功能 --- model/token.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/token.go b/model/token.go index 7c629b33c..b68fc0cfb 100644 --- a/model/token.go +++ b/model/token.go @@ -26,7 +26,7 @@ type Token struct { AllowIps *string `json:"allow_ips" gorm:"default:''"` UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota Group string `json:"group" gorm:"default:''"` - CrossGroupRetry bool `json:"cross_group_retry" gorm:"default:false"` // 跨分组重试,仅auto分组有效 + CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效 DeletedAt gorm.DeletedAt `gorm:"index"` } From c33ac97c7195af2e41d8377cefa91702da4db591 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 3 Jan 2026 11:08:26 +0800 Subject: [PATCH 29/76] feat: check-in feature integrates Turnstile security check --- router/api-router.go | 2 +- .../components/settings/PersonalSetting.jsx | 7 ++- .../personal/cards/CheckinCalendar.jsx | 62 +++++++++++++++++-- web/src/services/secureVerification.js | 12 ++-- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/router/api-router.go b/router/api-router.go index e02e1c3f3..800c5c657 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -96,7 +96,7 @@ func SetApiRouter(router *gin.Engine) { // Check-in routes selfRoute.GET("/checkin", controller.GetCheckinStatus) - selfRoute.POST("/checkin", controller.DoCheckin) + selfRoute.POST("/checkin", middleware.TurnstileCheck(), controller.DoCheckin) } adminRoute := userRoute.Group("/") diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index e70b997cd..657d9b4ff 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -451,7 +451,12 @@ const PersonalSetting = () => { {/* 签到日历 - 仅在启用时显示 */} {status?.checkin_enabled && (
- +
)} diff --git a/web/src/components/settings/personal/cards/CheckinCalendar.jsx b/web/src/components/settings/personal/cards/CheckinCalendar.jsx index d6f57cf69..2298ae94d 100644 --- a/web/src/components/settings/personal/cards/CheckinCalendar.jsx +++ b/web/src/components/settings/personal/cards/CheckinCalendar.jsx @@ -27,6 +27,7 @@ import { Spin, Tooltip, Collapsible, + Modal, } from '@douyinfe/semi-ui'; import { CalendarCheck, @@ -35,11 +36,14 @@ import { ChevronDown, ChevronUp, } from 'lucide-react'; +import Turnstile from 'react-turnstile'; import { API, showError, showSuccess, renderQuota } from '../../../../helpers'; -const CheckinCalendar = ({ t, status }) => { +const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => { const [loading, setLoading] = useState(false); const [checkinLoading, setCheckinLoading] = useState(false); + const [turnstileModalVisible, setTurnstileModalVisible] = useState(false); + const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0); const [checkinData, setCheckinData] = useState({ enabled: false, stats: { @@ -109,11 +113,23 @@ const CheckinCalendar = ({ t, status }) => { } }; - // 执行签到 - const doCheckin = async () => { + const postCheckin = async (token) => { + const url = token + ? `/api/user/checkin?turnstile=${encodeURIComponent(token)}` + : '/api/user/checkin'; + return API.post(url); + }; + + const shouldTriggerTurnstile = (message) => { + if (!turnstileEnabled) return false; + if (typeof message !== 'string') return true; + return message.includes('Turnstile'); + }; + + const doCheckin = async (token) => { setCheckinLoading(true); try { - const res = await API.post('/api/user/checkin'); + const res = await postCheckin(token); const { success, data, message } = res.data; if (success) { showSuccess( @@ -121,7 +137,19 @@ const CheckinCalendar = ({ t, status }) => { ); // 刷新签到状态 fetchCheckinStatus(currentMonth); + setTurnstileModalVisible(false); } else { + if (!token && shouldTriggerTurnstile(message)) { + if (!turnstileSiteKey) { + showError('Turnstile is enabled but site key is empty.'); + return; + } + setTurnstileModalVisible(true); + return; + } + if (token && shouldTriggerTurnstile(message)) { + setTurnstileWidgetKey((v) => v + 1); + } showError(message || t('签到失败')); } } catch (error) { @@ -186,6 +214,30 @@ const CheckinCalendar = ({ t, status }) => { return ( + { + setTurnstileModalVisible(false); + setTurnstileWidgetKey((v) => v + 1); + }} + > +
+ { + doCheckin(token); + }} + onExpire={() => { + setTurnstileWidgetKey((v) => v + 1); + }} + /> +
+
+ {/* 卡片头部 */}
{ type='primary' theme='solid' icon={} - onClick={doCheckin} + onClick={() => doCheckin()} loading={checkinLoading || !initialLoaded} disabled={!initialLoaded || checkinData.stats?.checked_in_today} className='!bg-green-600 hover:!bg-green-700' diff --git a/web/src/services/secureVerification.js b/web/src/services/secureVerification.js index 51f871a96..97b9c0229 100644 --- a/web/src/services/secureVerification.js +++ b/web/src/services/secureVerification.js @@ -42,12 +42,12 @@ export class SecureVerificationService { isPasskeySupported(), ]); - console.log('=== DEBUGGING VERIFICATION METHODS ==='); - console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2)); - console.log( - 'Passkey Response:', - JSON.stringify(passkeyResponse, null, 2), - ); + // console.log('=== DEBUGGING VERIFICATION METHODS ==='); + // console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2)); + // console.log( + // 'Passkey Response:', + // JSON.stringify(passkeyResponse, null, 2), + // ); const has2FA = twoFAResponse.data?.success && From 67ba913b44c9b07c3af5f04d220973e0c6990814 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:35:35 +0800 Subject: [PATCH 30/76] feat: add support for Doubao /v1/responses (#2567) * feat: add support for Doubao /v1/responses --- relay/channel/ali/adaptor.go | 2 +- relay/channel/volcengine/adaptor.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 480c21371..751a4538b 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -186,7 +186,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - // TODO implement me + //TODO implement me return nil, errors.New("not implemented") } diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index 1d1199991..9e09d9d8d 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -270,6 +270,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { // return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil case constant.RelayModeRerank: return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil + case constant.RelayModeResponses: + return fmt.Sprintf("%s/api/v3/responses", baseUrl), nil case constant.RelayModeAudioSpeech: if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] { return "wss://openspeech.bytedance.com/api/v1/tts/ws_binary", nil @@ -323,7 +325,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - return nil, errors.New("not implemented") + return request, nil } func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { From 22d0b73d21537b7a4625e4c4e9ca591830dbb49f Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:37:50 +0800 Subject: [PATCH 31/76] fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556) * fix: fix model deployment style issues, lint problems, and i18n gaps. * fix: adjust the key not to be displayed on the frontend, tested via the backend. * fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined. --- controller/deployment.go | 37 +- controller/option.go | 6 +- router/api-router.go | 18 +- web/i18next.config.js | 4 +- .../DeploymentAccessGuard.jsx | 251 ++- .../personal/cards/NotificationSettings.jsx | 44 +- .../channels/modals/OllamaModelModal.jsx | 250 +-- .../model-deployments/DeploymentsActions.jsx | 4 +- .../DeploymentsColumnDefs.jsx | 244 ++- .../model-deployments/DeploymentsTable.jsx | 16 +- .../table/model-deployments/index.jsx | 11 +- .../modals/CreateDeploymentModal.jsx | 987 +++++---- .../modals/UpdateConfigModal.jsx | 264 +-- .../modals/ViewDetailsModal.jsx | 544 +++-- .../modals/ViewLogsModal.jsx | 343 +-- web/src/hooks/common/useSidebar.js | 87 +- .../useDeploymentResources.js | 154 +- .../model-deployments/useDeploymentsData.jsx | 209 +- .../useEnhancedDeploymentActions.jsx | 141 +- .../useModelDeploymentSettings.js | 44 +- web/src/i18n/locales/en.json | 401 +++- web/src/i18n/locales/fr.json | 439 +++- web/src/i18n/locales/ja.json | 540 ++++- web/src/i18n/locales/ru.json | 445 +++- web/src/i18n/locales/vi.json | 1922 ++++++++++------- web/src/i18n/locales/zh.json | 437 +++- web/src/pages/ModelDeployment/index.jsx | 3 +- .../Setting/Model/SettingModelDeployment.jsx | 22 +- .../Personal/SettingsSidebarModulesUser.jsx | 44 +- 29 files changed, 5258 insertions(+), 2653 deletions(-) diff --git a/controller/deployment.go b/controller/deployment.go index 7530b4edf..a2ffedc66 100644 --- a/controller/deployment.go +++ b/controller/deployment.go @@ -1,6 +1,8 @@ package controller import ( + "bytes" + "encoding/json" "fmt" "strconv" "strings" @@ -23,6 +25,20 @@ func getIoAPIKey(c *gin.Context) (string, bool) { return apiKey, true } +func GetModelDeploymentSettings(c *gin.Context) { + common.OptionMapRWMutex.RLock() + enabled := common.OptionMap["model_deployment.ionet.enabled"] == "true" + hasAPIKey := strings.TrimSpace(common.OptionMap["model_deployment.ionet.api_key"]) != "" + common.OptionMapRWMutex.RUnlock() + + common.ApiSuccess(c, gin.H{ + "provider": "io.net", + "enabled": enabled, + "configured": hasAPIKey, + "can_connect": enabled && hasAPIKey, + }) +} + func getIoClient(c *gin.Context) (*ionet.Client, bool) { apiKey, ok := getIoAPIKey(c) if !ok { @@ -44,15 +60,28 @@ func TestIoNetConnection(c *gin.Context) { APIKey string `json:"api_key"` } - if err := c.ShouldBindJSON(&req); err != nil { - common.ApiErrorMsg(c, "invalid request payload") + rawBody, err := c.GetRawData() + if err != nil { + common.ApiError(c, err) return } + if len(bytes.TrimSpace(rawBody)) > 0 { + if err := json.Unmarshal(rawBody, &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 + common.OptionMapRWMutex.RLock() + storedKey := strings.TrimSpace(common.OptionMap["model_deployment.ionet.api_key"]) + common.OptionMapRWMutex.RUnlock() + if storedKey == "" { + common.ApiErrorMsg(c, "api_key is required") + return + } + apiKey = storedKey } client := ionet.NewEnterpriseClient(apiKey) diff --git a/controller/option.go b/controller/option.go index 89b2fc4d5..4d5b4e8d2 100644 --- a/controller/option.go +++ b/controller/option.go @@ -20,7 +20,11 @@ func GetOptions(c *gin.Context) { var options []*model.Option common.OptionMapRWMutex.Lock() for k, v := range common.OptionMap { - if strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "Key") { + if strings.HasSuffix(k, "Token") || + strings.HasSuffix(k, "Secret") || + strings.HasSuffix(k, "Key") || + strings.HasSuffix(k, "secret") || + strings.HasSuffix(k, "api_key") { continue } options = append(options, &model.Option{ diff --git a/router/api-router.go b/router/api-router.go index 800c5c657..9b2bd0615 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -269,24 +269,18 @@ func SetApiRouter(router *gin.Engine) { deploymentsRoute := apiRouter.Group("/deployments") deploymentsRoute.Use(middleware.AdminAuth()) { - // List and search deployments + deploymentsRoute.GET("/settings", controller.GetModelDeploymentSettings) + deploymentsRoute.POST("/settings/test-connection", controller.TestIoNetConnection) 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) @@ -295,14 +289,6 @@ func SetApiRouter(router *gin.Engine) { 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) } } } diff --git a/web/i18next.config.js b/web/i18next.config.js index ca6b4a5f3..4e138bdfc 100644 --- a/web/i18next.config.js +++ b/web/i18next.config.js @@ -25,7 +25,9 @@ export default defineConfig({ "zh", "en", "fr", - "ru" + "ru", + "ja", + "vi" ], extract: { input: [ diff --git a/web/src/components/model-deployments/DeploymentAccessGuard.jsx b/web/src/components/model-deployments/DeploymentAccessGuard.jsx index f771fa1c5..eef17b364 100644 --- a/web/src/components/model-deployments/DeploymentAccessGuard.jsx +++ b/web/src/components/model-deployments/DeploymentAccessGuard.jsx @@ -46,7 +46,7 @@ const DeploymentAccessGuard = ({
- {t('加载设置中...')} + {t('加载设置中...')}
@@ -55,21 +55,21 @@ const DeploymentAccessGuard = ({ if (!isEnabled) { return ( -
-
{/* 图标区域 */}
-
- +
+
{/* 标题区域 */}
- {t('模型部署服务未启用')} - {t('访问模型部署功能需要先启用 io.net 部署服务')} @@ -124,75 +128,99 @@ const DeploymentAccessGuard = ({
{/* 配置要求区域 */} -
-
-
- + gap: '12px', + marginBottom: '16px', + }} + > +
+
- {t('需要配置的项目')}
- -
-
-
- + +
+
+
+ {t('启用 io.net 部署开关')}
-
-
- +
+
+ {t('配置有效的 io.net API Key')}
@@ -201,9 +229,9 @@ const DeploymentAccessGuard = ({ {/* 操作链接区域 */}
-
{ - 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)'; + e.currentTarget.style.background = 'var(--semi-color-fill-1)'; + e.currentTarget.style.transform = 'translateY(-1px)'; + e.currentTarget.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'; + e.currentTarget.style.background = 'var(--semi-color-fill-0)'; + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = 'none'; }} > @@ -235,12 +264,12 @@ const DeploymentAccessGuard = ({
{/* 底部提示 */} - {t('配置完成后刷新页面即可使用模型部署功能')} @@ -256,7 +285,7 @@ const DeploymentAccessGuard = ({
- {t('Checking io.net connection...')} + {t('正在检查 io.net 连接...')}
@@ -265,12 +294,10 @@ const DeploymentAccessGuard = ({ if (connectionOk === false) { const isExpired = connectionError?.type === 'expired'; - const title = isExpired - ? t('API key expired') - : t('io.net connection unavailable'); + const title = isExpired ? t('接口密钥已过期') : t('无法连接 io.net'); 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.'); + ? t('当前 API 密钥已过期,请在设置中更新。') + : t('当前配置无法连接到 io.net。'); const detail = connectionError?.message || ''; return ( @@ -297,7 +324,8 @@ const DeploymentAccessGuard = ({ 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%)', + background: + 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)', }} >
@@ -309,12 +337,13 @@ const DeploymentAccessGuard = ({ 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%)', + 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', }} > - +
@@ -342,7 +371,7 @@ const DeploymentAccessGuard = ({
{detail ? ( -
- {onRetry ? ( - ) : null}
diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index c19084a51..0c51d239f 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -44,7 +44,10 @@ import CodeViewer from '../../../playground/CodeViewer'; import { StatusContext } from '../../../../context/Status'; import { UserContext } from '../../../../context/User'; import { useUserPermissions } from '../../../../hooks/common/useUserPermissions'; -import { useSidebar } from '../../../../hooks/common/useSidebar'; +import { + mergeAdminConfig, + useSidebar, +} from '../../../../hooks/common/useSidebar'; const NotificationSettings = ({ t, @@ -82,6 +85,7 @@ const NotificationSettings = ({ enabled: true, channel: true, models: true, + deployment: true, redemption: true, user: true, setting: true, @@ -164,6 +168,7 @@ const NotificationSettings = ({ enabled: true, channel: true, models: true, + deployment: true, redemption: true, user: true, setting: true, @@ -178,14 +183,27 @@ const NotificationSettings = ({ try { // 获取管理员全局配置 if (statusState?.status?.SidebarModulesAdmin) { - const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin); - setAdminConfig(adminConf); + try { + const adminConf = JSON.parse( + statusState.status.SidebarModulesAdmin, + ); + setAdminConfig(mergeAdminConfig(adminConf)); + } catch (error) { + setAdminConfig(mergeAdminConfig(null)); + } + } else { + setAdminConfig(mergeAdminConfig(null)); } // 获取用户个人配置 const userRes = await API.get('/api/user/self'); if (userRes.data.success && userRes.data.data.sidebar_modules) { - const userConf = JSON.parse(userRes.data.data.sidebar_modules); + let userConf; + if (typeof userRes.data.data.sidebar_modules === 'string') { + userConf = JSON.parse(userRes.data.data.sidebar_modules); + } else { + userConf = userRes.data.data.sidebar_modules; + } setSidebarModulesUser(userConf); } } catch (error) { @@ -273,6 +291,11 @@ const NotificationSettings = ({ 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('兑换码管理'), @@ -812,7 +835,9 @@ const NotificationSettings = ({
@@ -835,7 +860,8 @@ const NotificationSettings = ({ >
diff --git a/web/src/components/table/channels/modals/OllamaModelModal.jsx b/web/src/components/table/channels/modals/OllamaModelModal.jsx index 8b1dfcce1..684d2eb46 100644 --- a/web/src/components/table/channels/modals/OllamaModelModal.jsx +++ b/web/src/components/table/channels/modals/OllamaModelModal.jsx @@ -30,30 +30,24 @@ import { 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'; @@ -85,9 +79,7 @@ const resolveOllamaBaseUrl = (info) => { } const alt = - typeof info.ollama_base_url === 'string' - ? info.ollama_base_url.trim() - : ''; + typeof info.ollama_base_url === 'string' ? info.ollama_base_url.trim() : ''; if (alt) { return alt; } @@ -125,7 +117,8 @@ const normalizeModels = (items) => { } if (typeof item === 'object') { - const candidateId = item.id || item.ID || item.name || item.model || item.Model; + const candidateId = + item.id || item.ID || item.name || item.model || item.Model; if (!candidateId) { return null; } @@ -147,7 +140,10 @@ const normalizeModels = (items) => { if (!normalized.digest && typeof metadata.digest === 'string') { normalized.digest = metadata.digest; } - if (!normalized.modified_at && typeof metadata.modified_at === 'string') { + if ( + !normalized.modified_at && + typeof metadata.modified_at === 'string' + ) { normalized.modified_at = metadata.modified_at; } if (metadata.details && !normalized.details) { @@ -440,7 +436,6 @@ const OllamaModelModal = ({ }; await processStream(); - } catch (error) { if (error?.name !== 'AbortError') { showError(t('模型拉取失败: {{error}}', { error: error.message })); @@ -461,7 +456,7 @@ const OllamaModelModal = ({ model_name: modelName, }, }); - + if (res.data.success) { showSuccess(t('模型删除成功')); await fetchModels(); // 重新获取模型列表 @@ -481,8 +476,8 @@ const OllamaModelModal = ({ if (!searchValue) { setFilteredModels(models); } else { - const filtered = models.filter(model => - model.id.toLowerCase().includes(searchValue.toLowerCase()) + const filtered = models.filter((model) => + model.id.toLowerCase().includes(searchValue.toLowerCase()), ); setFilteredModels(filtered); } @@ -527,60 +522,38 @@ const OllamaModelModal = ({ 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 gb >= 1 + ? `${gb.toFixed(1)} GB` + : `${(size / (1024 * 1024)).toFixed(0)} MB`; }; return ( - - - -
- - {t('Ollama 模型管理')} - - - {channelInfo?.name && `${channelInfo.name} - `} - {t('管理 Ollama 模型的拉取和删除')} - -
-
- } + title={t('Ollama 模型管理')} visible={visible} onCancel={onCancel} - width={800} + width={720} style={{ maxWidth: '95vw' }} footer={ -
- -
+ } > -
+ +
+ + {channelInfo?.name ? `${channelInfo.name} - ` : ''} + {t('管理 Ollama 模型的拉取和删除')} + +
+ {/* 拉取新模型 */} - -
- - - - - {t('拉取新模型')} - -
- + + + {t('拉取新模型')} + + - + {/* 进度条显示 */} - {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('处理中'); + {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 ( -
-
- {t('拉取进度')} - {percentText} -
+ return ( +
+
+ {t('拉取进度')} + + {percentText} + +
- {hasTotal && safePercent !== null ? ( -
- -
- - {(completedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB - - - {(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB - + {hasTotal && safePercent !== null ? ( +
+ +
+ + {(completedBytes / (1024 * 1024 * 1024)).toFixed(2)}{' '} + GB + + + {(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB + +
-
- ) : ( -
- - {t('准备中...')} -
- )} -
- ); - })()} - + ) : ( +
+ + {t('准备中...')} +
+ )} +
+ ); + })()} + - {t('支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间')} + {t( + '支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间', + )} {/* 已有模型列表 */} - -
-
- - - - + <Card> + <div className='flex items-center justify-between mb-3'> + <div className='flex items-center gap-2'> + <Title heading={6} className='m-0'> {t('已有模型')} - {models.length > 0 && ( - <Tag color='blue' className='ml-2'> - {models.length} - </Tag> - )} + {models.length > 0 ? ( + {models.length} + ) : null}
@@ -558,14 +555,22 @@ export const getDeploymentsColumns = ({ // All actions dropdown with enhanced operations const dropdownItems = [ - onViewDetails?.(record)} icon={}> + onViewDetails?.(record)} + icon={} + > {t('查看详情')} , ]; if (!isEnded) { dropdownItems.push( - onViewLogs?.(record)} icon={}> + onViewLogs?.(record)} + icon={} + > {t('查看日志')} , ); @@ -575,7 +580,11 @@ export const getDeploymentsColumns = ({ if (normalizedStatus === 'running') { if (onSyncToChannel) { managementItems.push( - onSyncToChannel(record)} icon={}> + onSyncToChannel(record)} + icon={} + > {t('同步到渠道')} , ); @@ -583,28 +592,44 @@ export const getDeploymentsColumns = ({ } if (normalizedStatus === 'failed' || normalizedStatus === 'error') { managementItems.push( - startDeployment(id)} icon={}> + startDeployment(id)} + icon={} + > {t('重试')} , ); } if (normalizedStatus === 'stopped') { managementItems.push( - startDeployment(id)} icon={}> + startDeployment(id)} + icon={} + > {t('启动')} , ); } if (managementItems.length > 0) { - dropdownItems.push(); + dropdownItems.push(); dropdownItems.push(...managementItems); } const configItems = []; - if (!isEnded && (normalizedStatus === 'running' || normalizedStatus === 'deployment requested')) { + if ( + !isEnded && + (normalizedStatus === 'running' || + normalizedStatus === 'deployment requested') + ) { configItems.push( - onExtendDuration?.(record)} icon={}> + onExtendDuration?.(record)} + icon={} + > {t('延长时长')} , ); @@ -618,13 +643,18 @@ export const getDeploymentsColumns = ({ // } if (configItems.length > 0) { - dropdownItems.push(); + dropdownItems.push(); dropdownItems.push(...configItems); } if (!isEnded) { - dropdownItems.push(); + dropdownItems.push(); dropdownItems.push( - }> + } + > {t('销毁容器')} , ); @@ -634,31 +664,31 @@ export const getDeploymentsColumns = ({ const hasDropdown = dropdownItems.length > 0; return ( -
+
- + {hasDropdown && (
- ))} - -
+ + + -
- {t('启动参数 (Args)')} - {args.map((arg, index) => ( -
- handleArrayFieldChange(index, value, 'args')} - style={{ flex: 1, marginRight: 8 }} - /> -
- ))} - -
- + - + + {t('容器启动配置')} - - {t('环境变量')} - -
- {t('普通环境变量')} - {envVariables.map((env, index) => ( - - +
+ {t('启动命令 (Entrypoint)')} + {entrypoint.map((cmd, index) => ( +
handleEnvVariableChange(index, 'key', value, 'env')} + value={cmd} + placeholder={t('例如:/bin/bash')} + onChange={(value) => + handleArrayFieldChange(index, value, 'entrypoint') + } + style={{ flex: 1, marginRight: 8 }} /> - - - handleEnvVariableChange(index, 'value', value, 'env')} - /> - - -
+
+ ))} + +
-
- {t('密钥环境变量')} - {secretEnvVariables.map((env, index) => { - const isAutoSecret = - imageMode === 'builtin' && env.key === 'OLLAMA_API_KEY'; - return ( +
+ {t('启动参数 (Args)')} + {args.map((arg, index) => ( +
+ + handleArrayFieldChange(index, value, 'args') + } + style={{ flex: 1, marginRight: 8 }} + /> +
+ ))} + +
+ + + + + + {t('环境变量')} + +
+ {t('普通环境变量')} + {envVariables.map((env, index) => ( handleEnvVariableChange(index, 'key', value, 'secret')} - disabled={isAutoSecret} + onChange={(value) => + handleEnvVariableChange( + index, + 'key', + value, + 'env', + ) + } /> handleEnvVariableChange(index, 'value', value, 'secret')} - disabled={isAutoSecret} + onChange={(value) => + handleEnvVariableChange( + index, + 'value', + value, + 'env', + ) + } /> -
-
- - -
-
-
- -
- -
- - {t('价格预估')} - - - - {t('计价币种')} - - - USDC - IOCOIN - - - {currencyLabel} - - -
- - {priceEstimation ? ( -
-
-
- - {t('预估总费用')} - -
} + onClick={() => handleAddEnvVariable('env')} + style={{ marginTop: 8 }} > - {typeof priceEstimation.estimated_cost === 'number' - ? `${priceEstimation.estimated_cost.toFixed(4)} ${currencyLabel}` - : '--'} -
+ {t('添加环境变量')} +
-
- - {t('小时费率')} - - - {typeof priceEstimation.price_breakdown?.hourly_rate === 'number' - ? `${priceEstimation.price_breakdown.hourly_rate.toFixed(4)} ${currencyLabel}/h` - : '--'} - -
-
- - {t('计算成本')} - - - {typeof priceEstimation.price_breakdown?.compute_cost === 'number' - ? `${priceEstimation.price_breakdown.compute_cost.toFixed(4)} ${currencyLabel}` - : '--'} - -
-
-
- {priceSummaryItems.map((item) => ( -
+ {t('密钥环境变量')} + {secretEnvVariables.map((env, index) => { + const isAutoSecret = + imageMode === 'builtin' && + env.key === 'OLLAMA_API_KEY'; + return ( + + + + handleEnvVariableChange( + index, + 'key', + value, + 'secret', + ) + } + disabled={isAutoSecret} + /> + + + + handleEnvVariableChange( + index, + 'value', + value, + 'secret', + ) + } + disabled={isAutoSecret} + /> + + +
- ))} -
-
- ) : ( - priceUnavailableContent - )} - - {priceEstimation && loadingPrice && ( - - - - {t('价格重新计算中...')} - - - )} + {t('添加密钥环境变量')} + +
+
+ + +
+
+ +
+ + {t('价格预估')} + + + + {t('计价币种')} + + + USDC + IOCOIN + + + {currencyLabel} + + +
+ + {priceEstimation ? ( +
+
+
+ + {t('预估总费用')} + +
+ {typeof priceEstimation.estimated_cost === 'number' + ? `${priceEstimation.estimated_cost.toFixed(4)} ${currencyLabel}` + : '--'} +
+
+
+ + {t('小时费率')} + + + {typeof priceEstimation.price_breakdown?.hourly_rate === + 'number' + ? `${priceEstimation.price_breakdown.hourly_rate.toFixed(4)} ${currencyLabel}/h` + : '--'} + +
+
+ + {t('计算成本')} + + + {typeof priceEstimation.price_breakdown?.compute_cost === + 'number' + ? `${priceEstimation.price_breakdown.compute_cost.toFixed(4)} ${currencyLabel}` + : '--'} + +
+
+ +
+ {priceSummaryItems.map((item) => ( +
+ + {item.label} + + {item.value} +
+ ))} +
+
+ ) : ( + priceUnavailableContent + )} + + {priceEstimation && loadingPrice && ( + + + + {t('价格重新计算中...')} + + + )} +
+
); diff --git a/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx b/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx index 3b21b8b68..8d30415db 100644 --- a/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx +++ b/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx @@ -34,27 +34,21 @@ import { TextArea, Switch, } from '@douyinfe/semi-ui'; -import { - FaCog, +import { + FaCog, FaDocker, FaKey, FaTerminal, FaNetworkWired, FaExclamationTriangle, FaPlus, - FaMinus + FaMinus, } from 'react-icons/fa'; import { API, showError, showSuccess } from '../../../../helpers'; const { Text, Title } = Typography; -const UpdateConfigModal = ({ - visible, - onCancel, - deployment, - onSuccess, - t -}) => { +const UpdateConfigModal = ({ visible, onCancel, deployment, onSuccess, t }) => { const formRef = useRef(null); const [loading, setLoading] = useState(false); const [envVars, setEnvVars] = useState([]); @@ -72,18 +66,21 @@ const UpdateConfigModal = ({ 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) - })) + const envVarsList = deployment.container_config?.env_variables + ? Object.entries(deployment.container_config.env_variables).map( + ([key, value]) => ({ + key, + value: String(value), + }), + ) : []; - + setEnvVars(envVarsList); setSecretEnvVars([]); } @@ -91,23 +88,30 @@ const UpdateConfigModal = ({ const handleUpdate = async () => { try { - const formValues = formRef.current ? await formRef.current.validate() : {}; + 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.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()); + payload.entrypoint = formValues.entrypoint + .split(' ') + .filter((cmd) => cmd.trim()); } - + // Process environment variables if (envVars.length > 0) { payload.env_variables = envVars.reduce((acc, env) => { @@ -117,7 +121,7 @@ const UpdateConfigModal = ({ return acc; }, {}); } - + // Process secret environment variables if (secretEnvVars.length > 0) { payload.secret_env_variables = secretEnvVars.reduce((acc, env) => { @@ -128,7 +132,10 @@ const UpdateConfigModal = ({ }, {}); } - const response = await API.put(`/api/deployments/${deployment.id}`, payload); + const response = await API.put( + `/api/deployments/${deployment.id}`, + payload, + ); if (response.data.success) { showSuccess(t('容器配置更新成功')); @@ -136,7 +143,11 @@ const UpdateConfigModal = ({ handleCancel(); } } catch (error) { - showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message)); + showError( + t('更新配置失败') + + ': ' + + (error.response?.data?.message || error.message), + ); } finally { setLoading(false); } @@ -184,8 +195,8 @@ const UpdateConfigModal = ({ return ( - +
+ {t('更新容器配置')}
} @@ -196,130 +207,131 @@ const UpdateConfigModal = ({ cancelText={t('取消')} confirmLoading={loading} width={700} - className="update-config-modal" + className='update-config-modal' > -
+
{/* Container Info */} - -
+ +
- + {deployment?.container_name} -
- +
+ ID: {deployment?.id}
- {deployment?.status} + {deployment?.status}
{/* Warning Banner */} } title={t('重要提醒')} description={ -
-

{t('更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。')}

+
+

+ {t( + '更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。', + )} +

{t('某些配置更改可能需要几分钟才能生效。')}

} /> -
(formRef.current = api)} - layout="vertical" - > + (formRef.current = api)} layout='vertical'> {/* Docker Configuration */} - - - {t('Docker 配置')} +
+ + {t('镜像配置')}
} - itemKey="docker" + itemKey='docker' > -
+
{/* Network Configuration */} - - +
+ {t('网络配置')}
} - itemKey="network" + itemKey='network' >
{/* Startup Configuration */} - - +
+ {t('启动配置')}
} - itemKey="startup" + itemKey='startup' > -
+
@@ -327,34 +339,34 @@ const UpdateConfigModal = ({ {/* Environment Variables */} - - +
+ {t('环境变量')} - {envVars.length} + {envVars.length}
} - itemKey="env" + itemKey='env' > -
+
{/* Regular Environment Variables */}
-
+
{t('普通环境变量')}
- + {envVars.map((envVar, index) => ( -
+
updateEnvVar(index, 'value', value)} + onChange={(value) => + updateEnvVar(index, 'value', value) + } style={{ flex: 2 }} />
))} - + {envVars.length === 0 && ( -
- {t('暂无环境变量')} +
+ {t('暂无环境变量')}
)}
@@ -389,61 +403,67 @@ const UpdateConfigModal = ({ {/* Secret Environment Variables */}
-
-
+
+
{t('机密环境变量')} - + {t('加密存储')}
- + {secretEnvVars.map((envVar, index) => ( -
+
updateSecretEnvVar(index, 'key', value)} + onChange={(value) => + updateSecretEnvVar(index, 'key', value) + } style={{ flex: 1 }} /> = updateSecretEnvVar(index, 'value', value)} + onChange={(value) => + updateSecretEnvVar(index, 'value', value) + } style={{ flex: 2 }} />
))} - + {secretEnvVars.length === 0 && ( -
- {t('暂无机密环境变量')} +
+ {t('暂无机密环境变量')}
)} - +
@@ -452,16 +472,18 @@ const UpdateConfigModal = ({ {/* Final Warning */} -
-
- +
+
+
- + {t('配置更新确认')} -
- - {t('更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。')} +
+ + {t( + '更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。', + )}
@@ -472,4 +494,4 @@ const UpdateConfigModal = ({ ); }; -export default UpdateConfigModal; \ No newline at end of file +export default UpdateConfigModal; diff --git a/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx b/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx index 7967e96e5..f004fe54e 100644 --- a/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx +++ b/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx @@ -31,8 +31,8 @@ import { Badge, Tooltip, } from '@douyinfe/semi-ui'; -import { - FaInfoCircle, +import { + FaInfoCircle, FaServer, FaClock, FaMapMarkerAlt, @@ -43,16 +43,16 @@ import { FaLink, } from 'react-icons/fa'; import { IconRefresh } from '@douyinfe/semi-icons'; -import { API, showError, showSuccess, timestamp2string } from '../../../../helpers'; +import { + API, + showError, + showSuccess, + timestamp2string, +} from '../../../../helpers'; const { Text, Title } = Typography; -const ViewDetailsModal = ({ - visible, - onCancel, - deployment, - t -}) => { +const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => { const [details, setDetails] = useState(null); const [loading, setLoading] = useState(false); const [containers, setContainers] = useState([]); @@ -60,7 +60,7 @@ const ViewDetailsModal = ({ const fetchDetails = async () => { if (!deployment?.id) return; - + setLoading(true); try { const response = await API.get(`/api/deployments/${deployment.id}`); @@ -68,7 +68,11 @@ const ViewDetailsModal = ({ setDetails(response.data.data); } } catch (error) { - showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message)); + showError( + t('获取详情失败') + + ': ' + + (error.response?.data?.message || error.message), + ); } finally { setLoading(false); } @@ -79,12 +83,18 @@ const ViewDetailsModal = ({ setContainersLoading(true); try { - const response = await API.get(`/api/deployments/${deployment.id}/containers`); + 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)); + showError( + t('获取容器信息失败') + + ': ' + + (error.response?.data?.message || error.message), + ); } finally { setContainersLoading(false); } @@ -102,7 +112,7 @@ const ViewDetailsModal = ({ const handleCopyId = () => { navigator.clipboard.writeText(deployment?.id); - showSuccess(t('ID已复制到剪贴板')); + showSuccess(t('已复制 ID 到剪贴板')); }; const handleRefresh = () => { @@ -112,12 +122,16 @@ const ViewDetailsModal = ({ const getStatusConfig = (status) => { const statusConfig = { - 'running': { color: 'green', text: '运行中', icon: '🟢' }, - 'completed': { color: 'green', text: '已完成', icon: '✅' }, + 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: '❌' } + 'termination requested': { + color: 'orange', + text: '终止请求中', + icon: '⏸️', + }, + destroyed: { color: 'red', text: '已销毁', icon: '🔴' }, + failed: { color: 'red', text: '失败', icon: '❌' }, }; return statusConfig[status] || { color: 'grey', text: status, icon: '❓' }; }; @@ -127,149 +141,167 @@ const ViewDetailsModal = ({ return ( - +
+ {t('容器详情')}
} visible={visible} onCancel={onCancel} footer={ -
- - +
} width={800} - className="deployment-details-modal" + className='deployment-details-modal' > {loading && !details ? ( -
- +
+
) : details ? ( -
+
{/* Basic Info */} - - +
+ {t('基本信息')}
} - className="border-0 shadow-sm" + className='border-0 shadow-sm' > - - - {details.deployment_name || details.id} + + + {details.deployment_name || details.id} + +
+ ), + }, + { + key: t('容器ID'), + value: ( + + {details.id} -
- ) - }, - { - key: t('容器ID'), - value: ( - - {details.id} - - ) - }, - { - key: t('状态'), - value: ( -
- {statusConfig.icon} - - {t(statusConfig.text)} - -
- ) - }, - { - key: t('创建时间'), - value: timestamp2string(details.created_at) - } - ]} /> + ), + }, + { + key: t('状态'), + value: ( +
+ {statusConfig.icon} + + {t(statusConfig.text)} + +
+ ), + }, + { + key: t('创建时间'), + value: timestamp2string(details.created_at), + }, + ]} + /> {/* Hardware & Performance */} - - +
+ {t('硬件与性能')}
} - className="border-0 shadow-sm" + className='border-0 shadow-sm' > -
- - {details.brand_name} - {details.hardware_name} -
- ) - }, - { - key: t('GPU数量'), - value: ( -
- - - - {t('总计')} {details.total_gpus} {t('个GPU')} -
- ) - }, - { - key: t('容器配置'), - value: ( -
-
{t('每容器GPU数')}: {details.gpus_per_container}
-
{t('容器总数')}: {details.total_containers}
-
- ) - } - ]} /> +
+ + {details.brand_name} + {details.hardware_name} +
+ ), + }, + { + key: t('GPU数量'), + value: ( +
+ + + + + {t('总计')} {details.total_gpus} {t('个GPU')} + +
+ ), + }, + { + key: t('容器配置'), + value: ( +
+
+ {t('每容器GPU数')}: {details.gpus_per_container} +
+
+ {t('容器总数')}: {details.total_containers} +
+
+ ), + }, + ]} + /> {/* Progress Bar */} -
-
+
+
{t('完成进度')} {details.completed_percent}%
-
- {t('已服务')}: {details.compute_minutes_served} {t('分钟')} - {t('剩余')}: {details.compute_minutes_remaining} {t('分钟')} +
+ + {t('已服务')}: {details.compute_minutes_served} {t('分钟')} + + + {t('剩余')}: {details.compute_minutes_remaining} {t('分钟')} +
@@ -277,56 +309,70 @@ const ViewDetailsModal = ({ {/* Container Configuration */} {details.container_config && ( - - +
+ {t('容器配置')}
} - className="border-0 shadow-sm" + className='border-0 shadow-sm' > -
- - {details.container_config.image_url || 'N/A'} - - ) - }, - { - key: t('流量端口'), - value: details.container_config.traffic_port || 'N/A' - }, - { - key: t('启动命令'), - value: ( - - {details.container_config.entrypoint ? - details.container_config.entrypoint.join(' ') : 'N/A' - } - - ) - } - ]} /> +
+ + {details.container_config.image_url || 'N/A'} + + ), + }, + { + key: t('流量端口'), + value: details.container_config.traffic_port || 'N/A', + }, + { + key: t('启动命令'), + value: ( + + {details.container_config.entrypoint + ? details.container_config.entrypoint.join(' ') + : 'N/A'} + + ), + }, + ]} + /> {/* Environment Variables */} - {details.container_config.env_variables && - Object.keys(details.container_config.env_variables).length > 0 && ( -
- {t('环境变量')}: -
- {Object.entries(details.container_config.env_variables).map(([key, value]) => ( -
- {key}= - {String(value)} -
- ))} + {details.container_config.env_variables && + Object.keys(details.container_config.env_variables).length > + 0 && ( +
+ + {t('环境变量')}: + +
+ {Object.entries( + details.container_config.env_variables, + ).map(([key, value]) => ( +
+ + {key}= + + + {String(value)} + +
+ ))} +
-
- )} + )}
)} @@ -334,50 +380,63 @@ const ViewDetailsModal = ({ {/* Containers List */} - +
+ {t('容器实例')}
} - className="border-0 shadow-sm" + className='border-0 shadow-sm' > {containersLoading ? ( -
+
) : containers.length === 0 ? ( - + ) : ( -
+
{containers.map((ctr) => ( -
-
- +
+
+ {ctr.container_id} - - {t('设备')} {ctr.device_id || '--'} · {t('状态')} {ctr.status || '--'} + + {t('设备')} {ctr.device_id || '--'} · {t('状态')}{' '} + {ctr.status || '--'} - - {t('创建时间')}: {ctr.created_at ? timestamp2string(ctr.created_at) : '--'} + + {t('创建时间')}:{' '} + {ctr.created_at + ? timestamp2string(ctr.created_at) + : '--'}
-
- +
+ {t('GPU/容器')}: {ctr.gpus_per_container ?? '--'} {ctr.public_url && ( @@ -387,17 +446,26 @@ const ViewDetailsModal = ({
{ctr.events && ctr.events.length > 0 && ( -
- +
+ {t('最近事件')} -
+
{ctr.events.map((event, index) => ( -
- - {event.time ? timestamp2string(event.time) : '--'} +
+ + {event.time + ? timestamp2string(event.time) + : '--'} - + {event.message || '--'}
@@ -413,21 +481,23 @@ const ViewDetailsModal = ({ {/* Location Information */} {details.locations && details.locations.length > 0 && ( - - +
+ {t('部署位置')}
} - className="border-0 shadow-sm" + className='border-0 shadow-sm' > -
+
{details.locations.map((location) => ( - -
+ +
🌍 - {location.name} ({location.iso2}) + + {location.name} ({location.iso2}) +
))} @@ -436,68 +506,82 @@ const ViewDetailsModal = ({ )} {/* Cost Information */} - - +
+ {t('费用信息')}
} - className="border-0 shadow-sm" + className='border-0 shadow-sm' > -
-
+
+
{t('已支付金额')} - - ${details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'} USDC + + $ + {details.amount_paid + ? details.amount_paid.toFixed(2) + : '0.00'}{' '} + USDC
- -
-
- {t('计费开始')}: - {details.started_at ? timestamp2string(details.started_at) : 'N/A'} + +
+
+ {t('计费开始')}: + + {details.started_at + ? timestamp2string(details.started_at) + : 'N/A'} +
-
- {t('预计结束')}: - {details.finished_at ? timestamp2string(details.finished_at) : 'N/A'} +
+ {t('预计结束')}: + + {details.finished_at + ? timestamp2string(details.finished_at) + : 'N/A'} +
{/* Time Information */} - - +
+ {t('时间信息')}
} - className="border-0 shadow-sm" + className='border-0 shadow-sm' > -
-
-
- {t('已运行时间')}: +
+
+
+ {t('已运行时间')}: - {Math.floor(details.compute_minutes_served / 60)}h {details.compute_minutes_served % 60}m + {Math.floor(details.compute_minutes_served / 60)}h{' '} + {details.compute_minutes_served % 60}m
-
- {t('剩余时间')}: - - {Math.floor(details.compute_minutes_remaining / 60)}h {details.compute_minutes_remaining % 60}m +
+ {t('剩余时间')}: + + {Math.floor(details.compute_minutes_remaining / 60)}h{' '} + {details.compute_minutes_remaining % 60}m
-
-
- {t('创建时间')}: +
+
+ {t('创建时间')}: {timestamp2string(details.created_at)}
-
- {t('最后更新')}: +
+ {t('最后更新')}: {timestamp2string(details.updated_at)}
@@ -505,7 +589,7 @@ const ViewDetailsModal = ({
) : ( - diff --git a/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx b/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx index 18eb5535b..3d0446aea 100644 --- a/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx +++ b/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx @@ -44,18 +44,19 @@ import { FaLink, } from 'react-icons/fa'; import { IconRefresh, IconDownload } from '@douyinfe/semi-icons'; -import { API, showError, showSuccess, copy, timestamp2string } from '../../../../helpers'; +import { + API, + showError, + showSuccess, + copy, + timestamp2string, +} from '../../../../helpers'; const { Text } = Typography; const ALL_CONTAINERS = '__all__'; -const ViewLogsModal = ({ - visible, - onCancel, - deployment, - t -}) => { +const ViewLogsModal = ({ visible, onCancel, deployment, t }) => { const [logLines, setLogLines] = useState([]); const [loading, setLoading] = useState(false); const [autoRefresh, setAutoRefresh] = useState(false); @@ -63,12 +64,13 @@ const ViewLogsModal = ({ const [following, setFollowing] = useState(false); const [containers, setContainers] = useState([]); const [containersLoading, setContainersLoading] = useState(false); - const [selectedContainerId, setSelectedContainerId] = useState(ALL_CONTAINERS); + 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); @@ -100,7 +102,10 @@ const ViewLogsModal = ({ const fetchLogs = async (containerIdOverride = undefined) => { if (!deployment?.id) return; - const containerId = typeof containerIdOverride === 'string' ? containerIdOverride : selectedContainerId; + const containerId = + typeof containerIdOverride === 'string' + ? containerIdOverride + : selectedContainerId; if (!containerId || containerId === ALL_CONTAINERS) { setLogLines([]); @@ -120,10 +125,13 @@ const ViewLogsModal = ({ } if (following) params.append('follow', 'true'); - const response = await API.get(`/api/deployments/${deployment.id}/logs?${params}`); + 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 rawContent = + typeof response.data.data === 'string' ? response.data.data : ''; const normalized = rawContent.replace(/\r\n?/g, '\n'); const lines = normalized ? normalized.split('\n') : []; @@ -133,7 +141,11 @@ const ViewLogsModal = ({ setTimeout(scrollToBottom, 100); } } catch (error) { - showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message)); + showError( + t('获取日志失败') + + ': ' + + (error.response?.data?.message || error.message), + ); } finally { setLoading(false); } @@ -144,14 +156,19 @@ const ViewLogsModal = ({ setContainersLoading(true); try { - const response = await API.get(`/api/deployments/${deployment.id}/containers`); + 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)) { + if ( + current !== ALL_CONTAINERS && + list.some((item) => item.container_id === current) + ) { return current; } @@ -163,7 +180,11 @@ const ViewLogsModal = ({ } } } catch (error) { - showError(t('获取容器列表失败') + ': ' + (error.response?.data?.message || error.message)); + showError( + t('获取容器列表失败') + + ': ' + + (error.response?.data?.message || error.message), + ); } finally { setContainersLoading(false); } @@ -177,13 +198,19 @@ const ViewLogsModal = ({ setContainerDetailsLoading(true); try { - const response = await API.get(`/api/deployments/${deployment.id}/containers/${containerId}`); + 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)); + showError( + t('获取容器详情失败') + + ': ' + + (error.response?.data?.message || error.message), + ); } finally { setContainerDetailsLoading(false); } @@ -205,13 +232,14 @@ const ViewLogsModal = ({ const renderContainerStatusTag = (status) => { if (!status) { return ( - + {t('未知状态')} ); } - const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''; + const normalized = + typeof status === 'string' ? status.trim().toLowerCase() : ''; const statusMap = { running: { color: 'green', label: '运行中' }, pending: { color: 'orange', label: '准备中' }, @@ -225,15 +253,16 @@ const ViewLogsModal = ({ const config = statusMap[normalized] || { color: 'grey', label: status }; return ( - + {t(config.label)} ); }; - const currentContainer = selectedContainerId !== ALL_CONTAINERS - ? containers.find((ctr) => ctr.container_id === selectedContainerId) - : null; + const currentContainer = + selectedContainerId !== ALL_CONTAINERS + ? containers.find((ctr) => ctr.container_id === selectedContainerId) + : null; const refreshLogs = () => { if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) { @@ -254,9 +283,10 @@ const ViewLogsModal = ({ 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 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`; @@ -265,7 +295,7 @@ const ViewLogsModal = ({ a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - + showSuccess(t('日志已下载')); }; @@ -346,14 +376,15 @@ const ViewLogsModal = ({ // Filter logs based on search term const filteredLogs = logLines .map((line) => line ?? '') - .filter((line) => - !searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()), + .filter( + (line) => + !searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()), ); const renderLogEntry = (line, index) => (
{line}
@@ -362,10 +393,10 @@ const ViewLogsModal = ({ return ( - +
+ {t('容器日志')} - + - {deployment?.container_name || deployment?.id}
@@ -375,13 +406,13 @@ const ViewLogsModal = ({ footer={null} width={1000} height={700} - className="logs-modal" + className='logs-modal' style={{ top: 20 }} > -
+
{/* Controls */} - -
+ +
setInput(value)} + placeholder={t('请粘贴完整回调 URL(包含 code 与 state)')} + showClear + /> + + + {t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')} + + + + ); +}; + +export default CodexOAuthModal; diff --git a/web/src/components/table/channels/modals/CodexUsageModal.jsx b/web/src/components/table/channels/modals/CodexUsageModal.jsx new file mode 100644 index 000000000..df5e2c98b --- /dev/null +++ b/web/src/components/table/channels/modals/CodexUsageModal.jsx @@ -0,0 +1,190 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, Button, Progress, Tag, Typography } from '@douyinfe/semi-ui'; + +const { Text } = Typography; + +const clampPercent = (value) => { + const v = Number(value); + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(100, v)); +}; + +const pickStrokeColor = (percent) => { + const p = clampPercent(percent); + if (p >= 95) return '#ef4444'; + if (p >= 80) return '#f59e0b'; + return '#3b82f6'; +}; + +const formatDurationSeconds = (seconds, t) => { + const tt = typeof t === 'function' ? t : (v) => v; + const s = Number(seconds); + if (!Number.isFinite(s) || s <= 0) return '-'; + const total = Math.floor(s); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (hours > 0) return `${hours}${tt('小时')} ${minutes}${tt('分钟')}`; + if (minutes > 0) return `${minutes}${tt('分钟')} ${secs}${tt('秒')}`; + return `${secs}${tt('秒')}`; +}; + +const formatUnixSeconds = (unixSeconds) => { + const v = Number(unixSeconds); + if (!Number.isFinite(v) || v <= 0) return '-'; + try { + return new Date(v * 1000).toLocaleString(); + } catch (error) { + return String(unixSeconds); + } +}; + +const RateLimitWindowCard = ({ t, title, windowData }) => { + const tt = typeof t === 'function' ? t : (v) => v; + const percent = clampPercent(windowData?.used_percent ?? 0); + const resetAt = windowData?.reset_at; + const resetAfterSeconds = windowData?.reset_after_seconds; + const limitWindowSeconds = windowData?.limit_window_seconds; + + return ( +
+
+
{title}
+ + {tt('重置时间:')} + {formatUnixSeconds(resetAt)} + +
+ +
+ +
+ +
+
+ {tt('已使用:')} + {percent}% +
+
+ {tt('距离重置:')} + {formatDurationSeconds(resetAfterSeconds, tt)} +
+
+ {tt('窗口:')} + {formatDurationSeconds(limitWindowSeconds, tt)} +
+
+
+ ); +}; + +export const openCodexUsageModal = ({ t, record, payload, onCopy }) => { + const tt = typeof t === 'function' ? t : (v) => v; + const data = payload?.data ?? null; + const rateLimit = data?.rate_limit ?? {}; + + const primary = rateLimit?.primary_window ?? null; + const secondary = rateLimit?.secondary_window ?? null; + + const allowed = !!rateLimit?.allowed; + const limitReached = !!rateLimit?.limit_reached; + const upstreamStatus = payload?.upstream_status; + + const statusTag = + allowed && !limitReached ? ( + {tt('可用')} + ) : ( + {tt('受限')} + ); + + const rawText = + typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2); + + Modal.info({ + title: ( +
+ {tt('Codex 用量')} + {statusTag} +
+ ), + centered: true, + width: 900, + style: { maxWidth: '95vw' }, + content: ( +
+
+ + {tt('渠道:')} + {record?.name || '-'} ({tt('编号:')} + {record?.id || '-'}) + + + {tt('上游状态码:')} + {upstreamStatus ?? '-'} + +
+ +
+ + +
+ +
+
+
{tt('原始 JSON')}
+ +
+
+            {rawText}
+          
+
+
+ ), + footer: ( +
+ +
+ ), + }); +}; diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 722c1e8a9..0c813fc0f 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -56,6 +56,7 @@ import { } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import OllamaModelModal from './OllamaModelModal'; +import CodexOAuthModal from './CodexOAuthModal'; import JSONEditor from '../../../common/ui/JSONEditor'; import SecureVerificationModal from '../../../common/modals/SecureVerificationModal'; import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; @@ -114,6 +115,8 @@ function type2secretPrompt(type) { return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey'; case 51: return '按照如下格式输入: AccessKey|SecretAccessKey'; + case 57: + return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id)'; default: return '请输入渠道对应的鉴权密钥'; } @@ -212,6 +215,9 @@ const EditChannelModal = (props) => { }, [inputs.model_mapping]); const [isIonetChannel, setIsIonetChannel] = useState(false); const [ionetMetadata, setIonetMetadata] = useState(null); + const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false); + const [codexCredentialRefreshing, setCodexCredentialRefreshing] = + useState(false); // 密钥显示状态 const [keyDisplayState, setKeyDisplayState] = useState({ @@ -499,6 +505,18 @@ const EditChannelModal = (props) => { // 重置手动输入模式状态 setUseManualInput(false); + + if (value === 57) { + setBatch(false); + setMultiToSingle(false); + setMultiKeyMode('random'); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } + setInputs((prev) => ({ ...prev, vertex_files: [] })); + } } //setAutoBan }; @@ -822,6 +840,32 @@ const EditChannelModal = (props) => { } }; + const handleCodexOAuthGenerated = (key) => { + handleInputChange('key', key); + formatJsonField('key'); + }; + + const handleRefreshCodexCredential = async () => { + if (!isEdit) return; + + setCodexCredentialRefreshing(true); + try { + const res = await API.post( + `/api/channel/${channelId}/codex/refresh`, + {}, + { skipErrorHandler: true }, + ); + if (!res?.data?.success) { + throw new Error(res?.data?.message || 'Failed to refresh credential'); + } + showSuccess(t('凭证已刷新')); + } catch (error) { + showError(error.message || t('刷新失败')); + } finally { + setCodexCredentialRefreshing(false); + } + }; + useEffect(() => { if (inputs.type !== 45) { doubaoApiClickCountRef.current = 0; @@ -1070,6 +1114,47 @@ const EditChannelModal = (props) => { const formValues = formApiRef.current ? formApiRef.current.getValues() : {}; let localInputs = { ...formValues }; + if (localInputs.type === 57) { + if (batch) { + showInfo(t('Codex 渠道不支持批量创建')); + return; + } + + const rawKey = (localInputs.key || '').trim(); + if (!isEdit && rawKey === '') { + showInfo(t('请输入密钥!')); + return; + } + + if (rawKey !== '') { + if (!verifyJSON(rawKey)) { + showInfo(t('密钥必须是合法的 JSON 格式!')); + return; + } + try { + const parsed = JSON.parse(rawKey); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + showInfo(t('密钥必须是 JSON 对象')); + return; + } + const accessToken = String(parsed.access_token || '').trim(); + const accountId = String(parsed.account_id || '').trim(); + if (!accessToken) { + showInfo(t('密钥 JSON 必须包含 access_token')); + return; + } + if (!accountId) { + showInfo(t('密钥 JSON 必须包含 account_id')); + return; + } + localInputs.key = JSON.stringify(parsed); + } catch (error) { + showInfo(t('密钥必须是合法的 JSON 格式!')); + return; + } + } + } + if (localInputs.type === 41) { const keyType = localInputs.vertex_key_type || 'json'; if (keyType === 'api_key') { @@ -1401,7 +1486,7 @@ const EditChannelModal = (props) => { } }; - const batchAllowed = !isEdit || isMultiKeyChannel; + const batchAllowed = (!isEdit || isMultiKeyChannel) && inputs.type !== 57; const batchExtra = batchAllowed ? ( {!isEdit && ( @@ -1884,8 +1969,94 @@ const EditChannelModal = (props) => { ) ) : ( <> - {inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( + {inputs.type === 57 ? ( + <> + handleInputChange('key', value)} + disabled={isIonetLocked} + extraText={ +
+ + {t( + '仅支持 JSON 对象,必须包含 access_token 与 account_id', + )} + + + + + {isEdit && ( + + )} + + {isEdit && ( + + )} + {batchExtra} + +
+ } + autosize + showClear + /> + + setCodexOAuthModalVisible(false)} + onSuccess={handleCodexOAuthGenerated} + /> + + ) : inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( <> {!batch && (
diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 0d487958e..ce2f6cd47 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -184,6 +184,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'Replicate', }, + { + value: 57, + color: 'blue', + label: 'Codex (OpenAI OAuth)', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 4be021866..36aa7cbe9 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -301,6 +301,7 @@ export function getChannelIcon(channelType) { switch (channelType) { case 1: // OpenAI case 3: // Azure OpenAI + case 57: // Codex return ; case 2: // Midjourney Proxy case 5: // Midjourney Proxy Plus diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 415a34a5d..5e1feb162 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -36,6 +36,7 @@ import { import { useIsMobile } from '../common/useIsMobile'; import { useTableCompactMode } from '../common/useTableCompactMode'; import { Modal, Button } from '@douyinfe/semi-ui'; +import { openCodexUsageModal } from '../../components/table/channels/modals/CodexUsageModal'; export const useChannelsData = () => { const { t } = useTranslation(); @@ -745,6 +746,32 @@ export const useChannelsData = () => { }; const updateChannelBalance = async (record) => { + if (record?.type === 57) { + try { + const res = await API.get(`/api/channel/${record.id}/codex/usage`, { + skipErrorHandler: true, + }); + if (!res?.data?.success) { + console.error('Codex usage fetch failed:', res?.data?.message); + showError(t('获取用量失败')); + } + openCodexUsageModal({ + t, + record, + payload: res?.data, + onCopy: async (text) => { + const ok = await copy(text); + if (ok) showSuccess(t('已复制')); + else showError(t('复制失败')); + }, + }); + } catch (error) { + console.error('Codex usage fetch error:', error); + showError(t('获取用量失败')); + } + return; + } + const res = await API.get(`/api/channel/update_balance/${record.id}/`); const { success, message, balance } = res.data; if (success) { From ea802f2297afb8bc88b2551972d73c48b38179f3 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 15 Jan 2026 13:25:17 +0800 Subject: [PATCH 55/76] fix: openAI function to gemini function field adjusted to whitelist mode --- relay/channel/gemini/relay-gemini.go | 209 ++++++++++++++++++--------- 1 file changed, 138 insertions(+), 71 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 65e5aa985..24ff01217 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -655,102 +655,84 @@ func getSupportedMimeTypesList() []string { return keys } +var geminiOpenAPISchemaAllowedFields = map[string]struct{}{ + "anyOf": {}, + "default": {}, + "description": {}, + "enum": {}, + "example": {}, + "format": {}, + "items": {}, + "maxItems": {}, + "maxLength": {}, + "maxProperties": {}, + "maximum": {}, + "minItems": {}, + "minLength": {}, + "minProperties": {}, + "minimum": {}, + "nullable": {}, + "pattern": {}, + "properties": {}, + "propertyOrdering": {}, + "required": {}, + "title": {}, + "type": {}, +} + +const geminiFunctionSchemaMaxDepth = 64 + // cleanFunctionParameters recursively removes unsupported fields from Gemini function parameters. func cleanFunctionParameters(params interface{}) interface{} { + return cleanFunctionParametersWithDepth(params, 0) +} + +func cleanFunctionParametersWithDepth(params interface{}, depth int) interface{} { if params == nil { return nil } + if depth >= geminiFunctionSchemaMaxDepth { + return cleanFunctionParametersShallow(params) + } + switch v := params.(type) { case map[string]interface{}: - // Create a copy to avoid modifying the original - cleanedMap := make(map[string]interface{}) + // Keep only Gemini-supported OpenAPI schema subset fields (per official SDK Schema). + cleanedMap := make(map[string]interface{}, len(v)) for k, val := range v { - cleanedMap[k] = val - } - - // Remove unsupported root-level fields - delete(cleanedMap, "default") - delete(cleanedMap, "exclusiveMaximum") - delete(cleanedMap, "exclusiveMinimum") - delete(cleanedMap, "$schema") - delete(cleanedMap, "additionalProperties") - delete(cleanedMap, "propertyNames") - - // Check and clean 'format' for string types - if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" { - if formatValue, formatExists := cleanedMap["format"].(string); formatExists { - if formatValue != "enum" && formatValue != "date-time" { - delete(cleanedMap, "format") - } + if _, ok := geminiOpenAPISchemaAllowedFields[k]; ok { + cleanedMap[k] = val } } + normalizeGeminiSchemaTypeAndNullable(cleanedMap) + // Clean properties if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil { cleanedProps := make(map[string]interface{}) for propName, propValue := range props { - cleanedProps[propName] = cleanFunctionParameters(propValue) + cleanedProps[propName] = cleanFunctionParametersWithDepth(propValue, depth+1) } cleanedMap["properties"] = cleanedProps } // Recursively clean items in arrays if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil { - cleanedMap["items"] = cleanFunctionParameters(items) + cleanedMap["items"] = cleanFunctionParametersWithDepth(items, depth+1) } - // Also handle items if it's an array of schemas - if itemsArray, ok := cleanedMap["items"].([]interface{}); ok { - cleanedItemsArray := make([]interface{}, len(itemsArray)) - for i, item := range itemsArray { - cleanedItemsArray[i] = cleanFunctionParameters(item) - } - cleanedMap["items"] = cleanedItemsArray + // OpenAPI tuple-style items is not supported by Gemini SDK Schema; keep first to avoid API rejection. + if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 { + cleanedMap["items"] = cleanFunctionParametersWithDepth(itemsArray[0], depth+1) } - // Recursively clean other schema composition keywords - for _, field := range []string{"allOf", "anyOf", "oneOf"} { - if nested, ok := cleanedMap[field].([]interface{}); ok { - cleanedNested := make([]interface{}, len(nested)) - for i, item := range nested { - cleanedNested[i] = cleanFunctionParameters(item) - } - cleanedMap[field] = cleanedNested - } - } - - // Recursively clean patternProperties - if patternProps, ok := cleanedMap["patternProperties"].(map[string]interface{}); ok { - cleanedPatternProps := make(map[string]interface{}) - for pattern, schema := range patternProps { - cleanedPatternProps[pattern] = cleanFunctionParameters(schema) - } - cleanedMap["patternProperties"] = cleanedPatternProps - } - - // Recursively clean definitions - if definitions, ok := cleanedMap["definitions"].(map[string]interface{}); ok { - cleanedDefinitions := make(map[string]interface{}) - for defName, defSchema := range definitions { - cleanedDefinitions[defName] = cleanFunctionParameters(defSchema) - } - cleanedMap["definitions"] = cleanedDefinitions - } - - // Recursively clean $defs (newer JSON Schema draft) - if defs, ok := cleanedMap["$defs"].(map[string]interface{}); ok { - cleanedDefs := make(map[string]interface{}) - for defName, defSchema := range defs { - cleanedDefs[defName] = cleanFunctionParameters(defSchema) - } - cleanedMap["$defs"] = cleanedDefs - } - - // Clean conditional keywords - for _, field := range []string{"if", "then", "else", "not"} { - if nested, ok := cleanedMap[field]; ok { - cleanedMap[field] = cleanFunctionParameters(nested) + // Recursively clean anyOf + if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil { + cleanedNested := make([]interface{}, len(nested)) + for i, item := range nested { + cleanedNested[i] = cleanFunctionParametersWithDepth(item, depth+1) } + cleanedMap["anyOf"] = cleanedNested } return cleanedMap @@ -759,7 +741,7 @@ func cleanFunctionParameters(params interface{}) interface{} { // Handle arrays of schemas cleanedArray := make([]interface{}, len(v)) for i, item := range v { - cleanedArray[i] = cleanFunctionParameters(item) + cleanedArray[i] = cleanFunctionParametersWithDepth(item, depth+1) } return cleanedArray @@ -769,6 +751,91 @@ func cleanFunctionParameters(params interface{}) interface{} { } } +func cleanFunctionParametersShallow(params interface{}) interface{} { + switch v := params.(type) { + case map[string]interface{}: + cleanedMap := make(map[string]interface{}, len(v)) + for k, val := range v { + if _, ok := geminiOpenAPISchemaAllowedFields[k]; ok { + cleanedMap[k] = val + } + } + normalizeGeminiSchemaTypeAndNullable(cleanedMap) + // Stop recursion and avoid retaining huge nested structures. + delete(cleanedMap, "properties") + delete(cleanedMap, "items") + delete(cleanedMap, "anyOf") + return cleanedMap + case []interface{}: + // Prefer an empty list over deep recursion on attacker-controlled inputs. + return []interface{}{} + default: + return params + } +} + +func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) { + rawType, ok := schema["type"] + if !ok || rawType == nil { + return + } + + normalize := func(t string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(t)) { + case "object": + return "OBJECT", false + case "array": + return "ARRAY", false + case "string": + return "STRING", false + case "integer": + return "INTEGER", false + case "number": + return "NUMBER", false + case "boolean": + return "BOOLEAN", false + case "null": + return "", true + default: + return t, false + } + } + + switch t := rawType.(type) { + case string: + normalized, isNull := normalize(t) + if isNull { + schema["nullable"] = true + delete(schema, "type") + return + } + schema["type"] = normalized + case []interface{}: + nullable := false + var chosen string + for _, item := range t { + if s, ok := item.(string); ok { + normalized, isNull := normalize(s) + if isNull { + nullable = true + continue + } + if chosen == "" { + chosen = normalized + } + } + } + if nullable { + schema["nullable"] = true + } + if chosen != "" { + schema["type"] = chosen + } else { + delete(schema, "type") + } + } +} + func removeAdditionalPropertiesWithDepth(schema interface{}, depth int) interface{} { if depth >= 5 { return schema From af2d6ad8d2fcfac391e5db7af4d4817aa9bb157b Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 15 Jan 2026 14:43:53 +0800 Subject: [PATCH 56/76] feat: TLS_INSECURE_SKIP_VERIFY env --- .env.example | 3 +++ common/constants.go | 4 ++++ common/init.go | 11 +++++++++++ controller/model_sync.go | 17 +++++++++++++++-- controller/ratio_sync.go | 4 ++++ service/http_client.go | 39 ++++++++++++++++++++++++--------------- 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index ea9061fb3..f4b9d02ee 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,9 @@ # 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值 # STREAMING_TIMEOUT=300 +# TLS / HTTP 跳过验证设置 +# TLS_INSECURE_SKIP_VERIFY=false + # Gemini 识别图片 最大图片数量 # GEMINI_VISION_MAX_IMAGE_NUM=16 diff --git a/common/constants.go b/common/constants.go index e33a64b22..51b798dbc 100644 --- a/common/constants.go +++ b/common/constants.go @@ -1,6 +1,7 @@ package common import ( + "crypto/tls" //"os" //"strconv" "sync" @@ -73,6 +74,9 @@ var MemoryCacheEnabled bool var LogConsumeEnabled = true +var TLSInsecureSkipVerify bool +var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true} + var SMTPServer = "" var SMTPPort = 587 var SMTPSSLEnabled = false diff --git a/common/init.go b/common/init.go index 0789f8cc2..9501ce3be 100644 --- a/common/init.go +++ b/common/init.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "log" + "net/http" "os" "path/filepath" "strconv" @@ -81,6 +82,16 @@ func InitEnv() { DebugEnabled = os.Getenv("DEBUG") == "true" MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true" IsMasterNode = os.Getenv("NODE_TYPE") != "slave" + TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false) + if TLSInsecureSkipVerify { + if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil { + if tr.TLSClientConfig != nil { + tr.TLSClientConfig.InsecureSkipVerify = true + } else { + tr.TLSClientConfig = InsecureTLSConfig + } + } + } // Parse requestInterval and set RequestInterval requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) diff --git a/controller/model_sync.go b/controller/model_sync.go index b2ac99da8..737f92d40 100644 --- a/controller/model_sync.go +++ b/controller/model_sync.go @@ -99,6 +99,9 @@ func newHTTPClient() *http.Client { ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second, } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { host, _, err := net.SplitHostPort(addr) if err != nil { @@ -115,7 +118,17 @@ func newHTTPClient() *http.Client { return &http.Client{Transport: transport} } -var httpClient = newHTTPClient() +var ( + httpClientOnce sync.Once + httpClient *http.Client +) + +func getHTTPClient() *http.Client { + httpClientOnce.Do(func() { + httpClient = newHTTPClient() + }) + return httpClient +} func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error { var lastErr error @@ -138,7 +151,7 @@ func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) } cacheMutex.RUnlock() - resp, err := httpClient.Do(req) + resp, err := getHTTPClient().Do(req) if err != nil { lastErr = err // backoff with jitter diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index b8224b816..0b6a6dff0 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/dto" @@ -110,6 +111,9 @@ func FetchUpstreamRatios(c *gin.Context) { dialer := &net.Dialer{Timeout: 10 * time.Second} transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second} + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { host, _, err := net.SplitHostPort(addr) if err != nil { diff --git a/service/http_client.go b/service/http_client.go index 783aac899..2c3168f24 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -40,6 +40,9 @@ func InitHttpClient() { ForceAttemptHTTP2: true, Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } if common.RelayTimeout == 0 { httpClient = &http.Client{ @@ -102,13 +105,17 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { switch parsedURL.Scheme { case "http", "https": + transport := &http.Transport{ + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + Proxy: http.ProxyURL(parsedURL), + } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } client := &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, - Proxy: http.ProxyURL(parsedURL), - }, + Transport: transport, CheckRedirect: checkRedirect, } client.Timeout = time.Duration(common.RelayTimeout) * time.Second @@ -137,17 +144,19 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { return nil, err } - client := &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return dialer.Dial(network, addr) - }, + transport := &http.Transport{ + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) }, - CheckRedirect: checkRedirect, } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } + + client := &http.Client{Transport: transport, CheckRedirect: checkRedirect} client.Timeout = time.Duration(common.RelayTimeout) * time.Second proxyClientLock.Lock() proxyClients[proxyURL] = client From 1d8a11b37a9434d24830bfdc021472765f0708d3 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 15 Jan 2026 15:28:02 +0800 Subject: [PATCH 57/76] fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently. --- relay/compatible_handler.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 6c36f83d2..1a534f588 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -335,6 +335,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage var audioInputQuota decimal.Decimal var audioInputPrice float64 + isClaudeUsageSemantic := relayInfo.ChannelType == constant.ChannelTypeAnthropic if !relayInfo.PriceData.UsePrice { baseTokens := dPromptTokens // 减去 cached tokens @@ -342,14 +343,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage // OpenAI/OpenRouter 等 API 的 prompt_tokens 包含缓存 tokens,需要减去 var cachedTokensWithRatio decimal.Decimal if !dCacheTokens.IsZero() { - if relayInfo.ChannelType != constant.ChannelTypeAnthropic { + if !isClaudeUsageSemantic { baseTokens = baseTokens.Sub(dCacheTokens) } cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) } var dCachedCreationTokensWithRatio decimal.Decimal if !dCachedCreationTokens.IsZero() { - if relayInfo.ChannelType != constant.ChannelTypeAnthropic { + if !isClaudeUsageSemantic { baseTokens = baseTokens.Sub(dCachedCreationTokens) } dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio) @@ -459,6 +460,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage } logContent := strings.Join(extraContent, ", ") other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) + // For chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently. + if isClaudeUsageSemantic { + other["claude"] = true + other["usage_semantic"] = "anthropic" + } if imageTokens != 0 { other["image"] = true other["image_ratio"] = imageRatio From f96615110da72c23c039af69cb406d5b38f462aa Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 15 Jan 2026 23:19:51 +0800 Subject: [PATCH 58/76] fix: the login method cannot be displayed under the aff link. --- web/src/components/auth/LoginForm.jsx | 22 +++++++++++++------ web/src/components/auth/RegisterForm.jsx | 28 ++++++++++++++---------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index d87fc349a..5111f1f68 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -17,9 +17,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { UserContext } from '../../context/User'; +import { StatusContext } from '../../context/Status'; import { API, getLogo, @@ -73,6 +74,7 @@ const LoginForm = () => { const [searchParams, setSearchParams] = useSearchParams(); const [submitted, setSubmitted] = useState(false); const [userState, userDispatch] = useContext(UserContext); + const [statusState] = useContext(StatusContext); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); @@ -108,20 +110,26 @@ const LoginForm = () => { localStorage.setItem('aff', affCode); } - const [status] = useState(() => { + const status = useMemo(() => { + if (statusState?.status) return statusState.status; const savedStatus = localStorage.getItem('status'); - return savedStatus ? JSON.parse(savedStatus) : {}; - }); + if (!savedStatus) return {}; + try { + return JSON.parse(savedStatus) || {}; + } catch (err) { + return {}; + } + }, [statusState?.status]); useEffect(() => { - if (status.turnstile_check) { + if (status?.turnstile_check) { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } // 从 status 获取用户协议和隐私政策的启用状态 - setHasUserAgreement(status.user_agreement_enabled || false); - setHasPrivacyPolicy(status.privacy_policy_enabled || false); + setHasUserAgreement(status?.user_agreement_enabled || false); + setHasPrivacyPolicy(status?.privacy_policy_enabled || false); }, [status]); useEffect(() => { diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 6dabb516d..7bdc40c6e 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { API, @@ -51,6 +51,7 @@ import LinuxDoIcon from '../common/logo/LinuxDoIcon'; import WeChatIcon from '../common/logo/WeChatIcon'; import TelegramLoginButton from 'react-telegram-login/src'; import { UserContext } from '../../context/User'; +import { StatusContext } from '../../context/Status'; import { useTranslation } from 'react-i18next'; import { SiDiscord } from 'react-icons/si'; @@ -72,6 +73,7 @@ const RegisterForm = () => { }); const { username, password, password2 } = inputs; const [userState, userDispatch] = useContext(UserContext); + const [statusState] = useContext(StatusContext); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); @@ -106,25 +108,29 @@ const RegisterForm = () => { localStorage.setItem('aff', affCode); } - const [status] = useState(() => { + const status = useMemo(() => { + if (statusState?.status) return statusState.status; const savedStatus = localStorage.getItem('status'); - return savedStatus ? JSON.parse(savedStatus) : {}; - }); + if (!savedStatus) return {}; + try { + return JSON.parse(savedStatus) || {}; + } catch (err) { + return {}; + } + }, [statusState?.status]); - const [showEmailVerification, setShowEmailVerification] = useState(() => { - return status.email_verification ?? false; - }); + const [showEmailVerification, setShowEmailVerification] = useState(false); useEffect(() => { - setShowEmailVerification(status.email_verification); - if (status.turnstile_check) { + setShowEmailVerification(!!status?.email_verification); + if (status?.turnstile_check) { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } // 从 status 获取用户协议和隐私政策的启用状态 - setHasUserAgreement(status.user_agreement_enabled || false); - setHasPrivacyPolicy(status.privacy_policy_enabled || false); + setHasUserAgreement(status?.user_agreement_enabled || false); + setHasPrivacyPolicy(status?.privacy_policy_enabled || false); }, [status]); useEffect(() => { From 76164e951ec5f34959befb681daa66fc4a2e6a12 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 17 Jan 2026 21:42:28 +0800 Subject: [PATCH 59/76] fix: codex Unsupported parameter: max_output_tokens --- relay/channel/codex/adaptor.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index 92d855a55..76b7d0736 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -91,6 +91,8 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo // codex: store must be false request.Store = json.RawMessage("false") + // rm max_output_tokens + request.MaxOutputTokens = 0 return request, nil } From 575574f06853cbf3aef2c82e941cb6ab5a55ce50 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 19 Jan 2026 10:47:55 +0800 Subject: [PATCH 60/76] fix: jimeng i2v support multi image by metadata --- relay/channel/task/jimeng/adaptor.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go index 91d3f2361..1522a967f 100644 --- a/relay/channel/task/jimeng/adaptor.go +++ b/relay/channel/task/jimeng/adaptor.go @@ -17,6 +17,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/samber/lo" "github.com/gin-gonic/gin" "github.com/pkg/errors" @@ -409,14 +410,15 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* // 即梦视频3.0 ReqKey转换 // https://www.volcengine.com/docs/85621/1792707 + imageLen := lo.Max([]int{len(req.Images), len(r.BinaryDataBase64), len(r.ImageUrls)}) if strings.Contains(r.ReqKey, "jimeng_v30") { if r.ReqKey == "jimeng_v30_pro" { // 3.0 pro只有固定的jimeng_ti2v_v30_pro r.ReqKey = "jimeng_ti2v_v30_pro" - } else if len(req.Images) > 1 { + } else if imageLen > 1 { // 多张图片:首尾帧生成 r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1), "p") - } else if len(req.Images) == 1 { + } else if imageLen == 1 { // 单张图片:图生视频 r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1), "p") } else { From 3b01cb3f419ba15e7c88db021aa60ff48d4a7e42 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 19 Jan 2026 12:57:51 +0800 Subject: [PATCH 61/76] fix: update warning threshold label from '5$' to '2$' --- .../components/settings/personal/cards/NotificationSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 0c51d239f..d658bd7a1 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -440,7 +440,7 @@ const NotificationSettings = ({ data={[ { value: 100000, label: '0.2$' }, { value: 500000, label: '1$' }, - { value: 1000000, label: '5$' }, + { value: 1000000, label: '2$' }, { value: 5000000, label: '10$' }, ]} onChange={(val) => handleFormChange('warningThreshold', val)} From fac4a5ffddb97b68c215c8bcfbc86bb428d26020 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 19 Jan 2026 13:59:51 +0800 Subject: [PATCH 62/76] fix: video content api Priority use url field --- controller/video_proxy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controller/video_proxy.go b/controller/video_proxy.go index f102baae4..4815394a1 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/samber/lo" "github.com/gin-gonic/gin" ) @@ -134,8 +135,7 @@ func VideoProxy(c *gin.Context) { videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID) req.Header.Set("Authorization", "Bearer "+channel.Key) default: - // Video URL is directly in task.FailReason - videoURL = task.FailReason + videoURL = lo.Ternary(task.Url != "", task.Url, task.FailReason) } req.URL, err = url.Parse(videoURL) From d2df342f4ecb7f2a22254b5bdafa33ba82f719d3 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 19 Jan 2026 17:35:22 +0800 Subject: [PATCH 63/76] fix: update abortWithOpenAiMessage function to use types.ErrorCode --- middleware/auth.go | 5 +++-- middleware/distributor.go | 4 ++-- middleware/utils.go | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/middleware/auth.go b/middleware/auth.go index 85c46e282..a5d283d26 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -13,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -195,7 +196,7 @@ func TokenAuth() func(c *gin.Context) { } c.Request.Header.Set("Authorization", "Bearer "+key) } - // 检查path包含/v1/messages 或 /v1/models + // 检查path包含/v1/messages 或 /v1/models if strings.Contains(c.Request.URL.Path, "/v1/messages") || strings.Contains(c.Request.URL.Path, "/v1/models") { anthropicKey := c.Request.Header.Get("x-api-key") if anthropicKey != "" { @@ -256,7 +257,7 @@ func TokenAuth() func(c *gin.Context) { return } if common.IsIpInCIDRList(ip, allowIps) == false { - abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") + abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中", types.ErrorCodeAccessDenied) return } logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp) diff --git a/middleware/distributor.go b/middleware/distributor.go index a33404726..95fa64a30 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -114,11 +114,11 @@ func Distribute() func(c *gin.Context) { // common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) // message = "数据库一致性已被破坏,请联系管理员" //} - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, string(types.ErrorCodeModelNotFound)) + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, types.ErrorCodeModelNotFound) return } if channel == nil { - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", usingGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound)) + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", usingGroup, modelRequest.Model), types.ErrorCodeModelNotFound) return } } diff --git a/middleware/utils.go b/middleware/utils.go index 24caa83c7..f198af81f 100644 --- a/middleware/utils.go +++ b/middleware/utils.go @@ -5,13 +5,14 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) -func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...string) { +func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...types.ErrorCode) { codeStr := "" if len(code) > 0 { - codeStr = code[0] + codeStr = string(code[0]) } userId := c.GetInt("id") c.JSON(statusCode, gin.H{ From f538336f7f4718065dc2a9b52e26b72ff53a645e Mon Sep 17 00:00:00 2001 From: daggeryu <997411652@qq.com> Date: Tue, 20 Jan 2026 10:08:56 +0800 Subject: [PATCH 64/76] =?UTF-8?q?fix=C2=A0request=20pass-through=20aws=20c?= =?UTF-8?q?hannels=20can't=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit common.GetRequestBody(c) read bod is null --- controller/channel-test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 2ae8b0ef6..8ebfbdf64 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -332,7 +332,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } requestBody := bytes.NewBuffer(jsonData) - c.Request.Body = io.NopCloser(requestBody) + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData)) resp, err := adaptor.DoRequest(c, info, requestBody) if err != nil { return testResult{ From c149c9cfcfcd790c58bdb0fe6fabe4fc7b4d63ac Mon Sep 17 00:00:00 2001 From: Bliod Date: Tue, 20 Jan 2026 04:29:56 +0000 Subject: [PATCH 65/76] fix: fix email send --- web/src/components/auth/RegisterForm.jsx | 28 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 6dabb516d..5f60a5f15 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -31,7 +31,15 @@ import { onDiscordOAuthClicked, } from '../../helpers'; import Turnstile from 'react-turnstile'; -import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui'; +import { + Button, + Card, + Checkbox, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import { @@ -121,7 +129,7 @@ const RegisterForm = () => { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } - + // 从 status 获取用户协议和隐私政策的启用状态 setHasUserAgreement(status.user_agreement_enabled || false); setHasPrivacyPolicy(status.privacy_policy_enabled || false); @@ -235,7 +243,7 @@ const RegisterForm = () => { setVerificationCodeLoading(true); try { const res = await API.get( - `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, + `/api/verification?email=${encodeURIComponent(inputs.email)}&turnstile=${turnstileToken}`, ); const { success, message } = res.data; if (success) { @@ -405,7 +413,15 @@ const RegisterForm = () => { theme='outline' className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors' type='tertiary' - icon={} + icon={ + + } onClick={handleDiscordClick} loading={discordLoading} > @@ -619,7 +635,9 @@ const RegisterForm = () => { htmlType='submit' onClick={handleSubmit} loading={registerLoading} - disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms} + disabled={ + (hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms + } > {t('注册')} From 809a80815e1c842562ede8ccba91693d3f6a2720 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 20 Jan 2026 22:03:19 +0800 Subject: [PATCH 66/76] fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0 --- relay/channel/gemini/relay-gemini.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 65e5aa985..c76b5c247 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1141,6 +1141,8 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * id := helper.GetResponseID(c) createAt := common.GetTimestamp() finishReason := constant.FinishReasonStop + toolCallIndexByChoice := make(map[int]map[string]int) + nextToolCallIndexByChoice := make(map[int]int) usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse) @@ -1148,6 +1150,28 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * response.Id = id response.Created = createAt response.Model = info.UpstreamModelName + for choiceIdx := range response.Choices { + choiceKey := response.Choices[choiceIdx].Index + for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls { + tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx] + if tool.ID == "" { + continue + } + m := toolCallIndexByChoice[choiceKey] + if m == nil { + m = make(map[string]int) + toolCallIndexByChoice[choiceKey] = m + } + if idx, ok := m[tool.ID]; ok { + tool.SetIndex(idx) + continue + } + idx := nextToolCallIndexByChoice[choiceKey] + nextToolCallIndexByChoice[choiceKey] = idx + 1 + m[tool.ID] = idx + tool.SetIndex(idx) + } + } logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount)) if info.SendResponseCount == 0 { From 63921912ddfddd290770e8f5fd0419dce3167ced Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 20 Jan 2026 22:36:36 +0800 Subject: [PATCH 67/76] fix: replace Alibaba's Claude-compatible interface with the new interface --- relay/channel/ali/adaptor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 751a4538b..50fe16905 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -53,7 +53,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { var fullRequestURL string switch info.RelayFormat { case types.RelayFormatClaude: - fullRequestURL = fmt.Sprintf("%s/api/v2/apps/claude-code-proxy/v1/messages", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl) default: switch info.RelayMode { case constant.RelayModeEmbeddings: From 9037d992be0d46bffcf7f5469d6d3e1176b9d17f Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 20 Jan 2026 22:56:02 +0800 Subject: [PATCH 68/76] fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. --- relay/channel/ali/adaptor.go | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 50fe16905..d9108c6a1 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -13,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/openai" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -22,6 +23,11 @@ type Adaptor struct { IsSyncImageModel bool } +func supportsAliAnthropicMessages(modelName string) bool { + // Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. + return strings.Contains(strings.ToLower(modelName), "qwen") +} + var syncModels = []string{ "z-image", "qwen-image", @@ -43,7 +49,18 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt } func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { - return req, nil + if supportsAliAnthropicMessages(info.UpstreamModelName) { + return req, nil + } + + oaiReq, err := service.ClaudeToOpenAIRequest(*req, info) + if err != nil { + return nil, err + } + if info.SupportStreamOptions && info.IsStream { + oaiReq.StreamOptions = &dto.StreamOptions{IncludeUsage: true} + } + return a.ConvertOpenAIRequest(c, info, oaiReq) } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { @@ -53,7 +70,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { var fullRequestURL string switch info.RelayFormat { case types.RelayFormatClaude: - fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl) + if supportsAliAnthropicMessages(info.UpstreamModelName) { + fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl) + } else { + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl) + } default: switch info.RelayMode { case constant.RelayModeEmbeddings: @@ -197,11 +218,16 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { switch info.RelayFormat { case types.RelayFormatClaude: - if info.IsStream { - return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) - } else { + if supportsAliAnthropicMessages(info.UpstreamModelName) { + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) } + + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) default: switch info.RelayMode { case constant.RelayModeImagesGenerations: From d4582ede980d8aec330e0e963eeebef281e8be06 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 20 Jan 2026 23:43:29 +0800 Subject: [PATCH 69/76] feat: log shows request conversion --- model/log.go | 3 +- relay/chat_completions_via_responses.go | 2 + relay/claude_handler.go | 1 + relay/common/relay_info.go | 77 +++++++++++++++---- relay/common/request_conversion.go | 40 ++++++++++ relay/compatible_handler.go | 1 + relay/embedding_handler.go | 1 + relay/gemini_handler.go | 1 + relay/image_handler.go | 1 + relay/rerank_handler.go | 1 + relay/responses_handler.go | 1 + service/log_info_generate.go | 21 +++++ web/src/hooks/usage-logs/useUsageLogsData.jsx | 13 +++- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/zh.json | 3 + 15 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 relay/common/request_conversion.go diff --git a/model/log.go b/model/log.go index 7495d647d..f8940c150 100644 --- a/model/log.go +++ b/model/log.go @@ -56,8 +56,9 @@ func formatUserLogs(logs []*Log) { var otherMap map[string]interface{} otherMap, _ = common.StrToMap(logs[i].Other) if otherMap != nil { - // delete admin + // Remove admin-only debug fields. delete(otherMap, "admin_info") + delete(otherMap, "request_conversion") } logs[i].Other = common.MapToJsonStr(otherMap) logs[i].Id = logs[i].Id % 1024 diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 4b369440d..38dae3c56 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -97,6 +97,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad if err != nil { return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } + info.AppendRequestConversion(types.RelayFormatOpenAIResponses) savedRelayMode := info.RelayMode savedRequestURLPath := info.RequestURLPath @@ -112,6 +113,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad if err != nil { return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 7a18c1737..7e05116da 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -110,6 +110,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 4665573dd..5c24ce57a 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -121,6 +121,10 @@ type RelayInfo struct { Request dto.Request + // RequestConversionChain records request format conversions in order, e.g. + // ["openai", "openai_responses"] or ["openai", "claude"]. + RequestConversionChain []types.RelayFormat + ThinkingContentInfo TokenCountMeta *ClaudeConvertInfo @@ -448,38 +452,83 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { } func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) { + var info *RelayInfo + var err error switch relayFormat { case types.RelayFormatOpenAI: - return GenRelayInfoOpenAI(c, request), nil + info = GenRelayInfoOpenAI(c, request) case types.RelayFormatOpenAIAudio: - return GenRelayInfoOpenAIAudio(c, request), nil + info = GenRelayInfoOpenAIAudio(c, request) case types.RelayFormatOpenAIImage: - return GenRelayInfoImage(c, request), nil + info = GenRelayInfoImage(c, request) case types.RelayFormatOpenAIRealtime: - return GenRelayInfoWs(c, ws), nil + info = GenRelayInfoWs(c, ws) case types.RelayFormatClaude: - return GenRelayInfoClaude(c, request), nil + info = GenRelayInfoClaude(c, request) case types.RelayFormatRerank: if request, ok := request.(*dto.RerankRequest); ok { - return GenRelayInfoRerank(c, request), nil + info = GenRelayInfoRerank(c, request) + break } - return nil, errors.New("request is not a RerankRequest") + err = errors.New("request is not a RerankRequest") case types.RelayFormatGemini: - return GenRelayInfoGemini(c, request), nil + info = GenRelayInfoGemini(c, request) case types.RelayFormatEmbedding: - return GenRelayInfoEmbedding(c, request), nil + info = GenRelayInfoEmbedding(c, request) case types.RelayFormatOpenAIResponses: if request, ok := request.(*dto.OpenAIResponsesRequest); ok { - return GenRelayInfoResponses(c, request), nil + info = GenRelayInfoResponses(c, request) + break } - return nil, errors.New("request is not a OpenAIResponsesRequest") + err = errors.New("request is not a OpenAIResponsesRequest") case types.RelayFormatTask: - return genBaseRelayInfo(c, nil), nil + info = genBaseRelayInfo(c, nil) case types.RelayFormatMjProxy: - return genBaseRelayInfo(c, nil), nil + info = genBaseRelayInfo(c, nil) default: - return nil, errors.New("invalid relay format") + err = errors.New("invalid relay format") } + + if err != nil { + return nil, err + } + if info == nil { + return nil, errors.New("failed to build relay info") + } + + info.InitRequestConversionChain() + return info, nil +} + +func (info *RelayInfo) InitRequestConversionChain() { + if info == nil { + return + } + if len(info.RequestConversionChain) > 0 { + return + } + if info.RelayFormat == "" { + return + } + info.RequestConversionChain = []types.RelayFormat{info.RelayFormat} +} + +func (info *RelayInfo) AppendRequestConversion(format types.RelayFormat) { + if info == nil { + return + } + if format == "" { + return + } + if len(info.RequestConversionChain) == 0 { + info.RequestConversionChain = []types.RelayFormat{format} + return + } + last := info.RequestConversionChain[len(info.RequestConversionChain)-1] + if last == format { + return + } + info.RequestConversionChain = append(info.RequestConversionChain, format) } //func (info *RelayInfo) SetPromptTokens(promptTokens int) { diff --git a/relay/common/request_conversion.go b/relay/common/request_conversion.go new file mode 100644 index 000000000..96b728d21 --- /dev/null +++ b/relay/common/request_conversion.go @@ -0,0 +1,40 @@ +package common + +import ( + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/types" +) + +func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) { + switch req.(type) { + case *dto.GeneralOpenAIRequest, dto.GeneralOpenAIRequest: + return types.RelayFormatOpenAI, true + case *dto.OpenAIResponsesRequest, dto.OpenAIResponsesRequest: + return types.RelayFormatOpenAIResponses, true + case *dto.ClaudeRequest, dto.ClaudeRequest: + return types.RelayFormatClaude, true + case *dto.GeminiChatRequest, dto.GeminiChatRequest: + return types.RelayFormatGemini, true + case *dto.EmbeddingRequest, dto.EmbeddingRequest: + return types.RelayFormatEmbedding, true + case *dto.RerankRequest, dto.RerankRequest: + return types.RelayFormatRerank, true + case *dto.ImageRequest, dto.ImageRequest: + return types.RelayFormatOpenAIImage, true + case *dto.AudioRequest, dto.AudioRequest: + return types.RelayFormatOpenAIAudio, true + default: + return "", false + } +} + +func AppendRequestConversionFromRequest(info *RelayInfo, req any) { + if info == nil { + return + } + format, ok := GuessRelayFormatFromRequest(req) + if !ok { + return + } + info.AppendRequestConversion(format) +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 1a534f588..eab5052d7 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -113,6 +113,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) if info.ChannelSetting.SystemPrompt != "" { // 如果有系统提示,则将其添加到请求中 diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index 2cedf02b5..1a41756b8 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -45,6 +45,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := json.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 79ffba515..779670b9e 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -149,6 +149,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/image_handler.go b/relay/image_handler.go index f110f4e86..1ee790b74 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -57,6 +57,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) switch convertedRequest.(type) { case *bytes.Buffer: diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 9a50fd271..35c66a291 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -53,6 +53,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 5c3d9a426..769437a1d 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -53,6 +53,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 1bd7df673..8018396d0 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -70,9 +70,30 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m other["admin_info"] = adminInfo appendRequestPath(ctx, relayInfo, other) + appendRequestConversionChain(relayInfo, other) return other } +func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { + if relayInfo == nil || other == nil { + return + } + if len(relayInfo.RequestConversionChain) == 0 { + return + } + chain := make([]string, 0, len(relayInfo.RequestConversionChain)) + for _, f := range relayInfo.RequestConversionChain { + if f == "" { + continue + } + chain = append(chain, string(f)) + } + if len(chain) == 0 { + return + } + other["request_conversion"] = chain +} + func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} { info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio) info["ws"] = true diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 2d0ed3249..18a8dbc7a 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -476,10 +476,17 @@ export const useLogsData = () => { }); } } - if (other?.request_path) { + if (isAdminUser) { + const requestConversionChain = other?.request_conversion; + const chain = Array.isArray(requestConversionChain) + ? requestConversionChain.filter(Boolean) + : []; expandDataLocal.push({ - key: t('请求路径'), - value: other.request_path, + key: t('请求转换'), + value: + chain.length > 1 + ? `${chain.join(' -> ')}` + : t('原生格式'), }); } if (isAdminUser) { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f6d55544d..a49598bf6 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2091,6 +2091,9 @@ "请求结束后多退少补": "Adjust after request completion", "请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login", "请求路径": "Request path", + "请求转换": "Request conversion", + "原生格式": "Native format", + "转换": "Convert", "请求预扣费额度": "Pre-deduction quota for requests", "请点击我": "Please click me", "请确认以下设置信息,点击\"初始化系统\"开始配置": "Please confirm the following settings information, click \"Initialize system\" to start configuration", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e91f50a4e..e7579c591 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2077,6 +2077,9 @@ "请求结束后多退少补": "请求结束后多退少补", "请求超时,请刷新页面后重新发起 GitHub 登录": "请求超时,请刷新页面后重新发起 GitHub 登录", "请求路径": "请求路径", + "请求转换": "请求转换", + "原生格式": "原生格式", + "转换": "转换", "请求预扣费额度": "请求预扣费额度", "请点击我": "请点击我", "请确认以下设置信息,点击\"初始化系统\"开始配置": "请确认以下设置信息,点击\"初始化系统\"开始配置", From 6582020c805ffaacac6f8cbd97fe965e08332882 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 21 Jan 2026 00:01:36 +0800 Subject: [PATCH 70/76] feat: optimized display --- service/log_info_generate.go | 14 +++++++++++--- web/src/hooks/usage-logs/useUsageLogsData.jsx | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 8018396d0..71a6bd32a 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -83,10 +83,18 @@ func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[st } chain := make([]string, 0, len(relayInfo.RequestConversionChain)) for _, f := range relayInfo.RequestConversionChain { - if f == "" { - continue + switch f { + case types.RelayFormatOpenAI: + chain = append(chain, "OpenAI Compatible") + case types.RelayFormatClaude: + chain = append(chain, "Claude Messages") + case types.RelayFormatGemini: + chain = append(chain, "Google Gemini") + case types.RelayFormatOpenAIResponses: + chain = append(chain, "OpenAI Responses") + default: + chain = append(chain, string(f)) } - chain = append(chain, string(f)) } if len(chain) == 0 { return diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 18a8dbc7a..f2bc7988c 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -306,6 +306,16 @@ export const useLogsData = () => { // Format logs data const setLogsFormat = (logs) => { + const requestConversionDisplayValue = (conversionChain) => { + const chain = Array.isArray(conversionChain) + ? conversionChain.filter(Boolean) + : []; + if (chain.length <= 1) { + return t('原生格式'); + } + return chain.join(' -> '); + }; + let expandDatesLocal = {}; for (let i = 0; i < logs.length; i++) { logs[i].timestamp2string = timestamp2string(logs[i].created_at); @@ -477,16 +487,9 @@ export const useLogsData = () => { } } if (isAdminUser) { - const requestConversionChain = other?.request_conversion; - const chain = Array.isArray(requestConversionChain) - ? requestConversionChain.filter(Boolean) - : []; expandDataLocal.push({ key: t('请求转换'), - value: - chain.length > 1 - ? `${chain.join(' -> ')}` - : t('原生格式'), + value: requestConversionDisplayValue(other?.request_conversion), }); } if (isAdminUser) { From 3728fbdbf5016791ff6c3c9077e1154a558d32e0 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 21 Jan 2026 00:12:41 +0800 Subject: [PATCH 71/76] feat: optimized display --- web/src/hooks/usage-logs/useUsageLogsData.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index f2bc7988c..11959d51d 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -313,7 +313,7 @@ export const useLogsData = () => { if (chain.length <= 1) { return t('原生格式'); } - return chain.join(' -> '); + return `${t('转换')} ${chain.join(' -> ')}`; }; let expandDatesLocal = {}; @@ -486,6 +486,12 @@ export const useLogsData = () => { }); } } + if (other?.request_path) { + expandDataLocal.push({ + key: t('请求路径'), + value: other.request_path, + }); + } if (isAdminUser) { expandDataLocal.push({ key: t('请求转换'), From 5c01b773574c80e421ab1dfd944bf6355dd22e1f Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 21 Jan 2026 00:17:20 +0800 Subject: [PATCH 72/76] feat: optimized display --- web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 11959d51d..ee34e1af5 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -313,7 +313,7 @@ export const useLogsData = () => { if (chain.length <= 1) { return t('原生格式'); } - return `${t('转换')} ${chain.join(' -> ')}`; + return `${chain.join(' -> ')}`; }; let expandDatesLocal = {}; From 46aae7358fe03488fc56b965cee3a444fa93e214 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 21 Jan 2026 23:22:31 +0800 Subject: [PATCH 73/76] fix: codex rm Temperature --- relay/channel/codex/adaptor.go | 1 + 1 file changed, 1 insertion(+) diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index 76b7d0736..ab61dfac7 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -93,6 +93,7 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo request.Store = json.RawMessage("false") // rm max_output_tokens request.MaxOutputTokens = 0 + request.Temperature = nil return request, nil } From fdaa573c112a6c61ebd7150a9749281e253df747 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:38:47 +0800 Subject: [PATCH 74/76] Revert "fix: video content api Priority use url field" --- controller/video_proxy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controller/video_proxy.go b/controller/video_proxy.go index 4815394a1..f102baae4 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -12,7 +12,6 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" - "github.com/samber/lo" "github.com/gin-gonic/gin" ) @@ -135,7 +134,8 @@ func VideoProxy(c *gin.Context) { videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID) req.Header.Set("Authorization", "Bearer "+channel.Key) default: - videoURL = lo.Ternary(task.Url != "", task.Url, task.FailReason) + // Video URL is directly in task.FailReason + videoURL = task.FailReason } req.URL, err = url.Parse(videoURL) From 151d7bedae1cd6ca3d6738f0a019c3e2a507add2 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 22 Jan 2026 08:58:23 +0800 Subject: [PATCH 75/76] feat: requestId time string use UTC --- common/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/utils.go b/common/utils.go index f63df857b..b67fe1c5f 100644 --- a/common/utils.go +++ b/common/utils.go @@ -263,7 +263,7 @@ func GetTimestamp() int64 { } func GetTimeString() string { - now := time.Now() + now := time.Now().UTC() return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) } From cf745623f8b561a27a035b613721edb7b7dc7862 Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Thu, 22 Jan 2026 17:25:49 +0800 Subject: [PATCH 76/76] feat(qwen): support qwen image sync image model config --- relay/channel/ali/adaptor.go | 15 ++++++----- setting/model_setting/qwen.go | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 setting/model_setting/qwen.go diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index d9108c6a1..23ef5f4be 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -13,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/openai" 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/service" "github.com/QuantumNous/new-api/types" @@ -23,6 +24,13 @@ type Adaptor struct { IsSyncImageModel bool } +/* + var syncModels = []string{ + "z-image", + "qwen-image", + "wan2.6", + } +*/ func supportsAliAnthropicMessages(modelName string) bool { // Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. return strings.Contains(strings.ToLower(modelName), "qwen") @@ -35,12 +43,7 @@ var syncModels = []string{ } func isSyncImageModel(modelName string) bool { - for _, m := range syncModels { - if strings.Contains(modelName, m) { - return true - } - } - return false + return model_setting.IsSyncImageModel(modelName) } func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { diff --git a/setting/model_setting/qwen.go b/setting/model_setting/qwen.go new file mode 100644 index 000000000..ccab57594 --- /dev/null +++ b/setting/model_setting/qwen.go @@ -0,0 +1,50 @@ +package model_setting + +import ( + "strings" + + "github.com/QuantumNous/new-api/setting/config" +) + +// QwenSettings defines Qwen model configuration. 注意bool要以enabled结尾才可以生效编辑 +type QwenSettings struct { + SyncImageModels []string `json:"sync_image_models"` +} + +// 默认配置 +var defaultQwenSettings = QwenSettings{ + SyncImageModels: []string{ + "z-image", + "qwen-image", + "wan2.6", + "qwen-image-edit", + "qwen-image-edit-max", + "qwen-image-edit-max-2026-01-16", + "qwen-image-edit-plus", + "qwen-image-edit-plus-2025-12-15", + "qwen-image-edit-plus-2025-10-30", + }, +} + +// 全局实例 +var qwenSettings = defaultQwenSettings + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("qwen", &qwenSettings) +} + +// GetQwenSettings +func GetQwenSettings() *QwenSettings { + return &qwenSettings +} + +// IsSyncImageModel +func IsSyncImageModel(model string) bool { + for _, m := range qwenSettings.SyncImageModels { + if strings.Contains(model, m) { + return true + } + } + return false +}