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/.env.example b/.env.example index f43f7b211..ea9061fb3 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,14 @@ # ENABLE_PPROF=true # 启用调试模式 # DEBUG=true +# Pyroscope 配置 +# PYROSCOPE_URL=http://localhost:4040 +# PYROSCOPE_APP_NAME=new-api +# PYROSCOPE_BASIC_AUTH_USER=your-user +# PYROSCOPE_BASIC_AUTH_PASSWORD=your-password +# PYROSCOPE_MUTEX_RATE=5 +# PYROSCOPE_BLOCK_RATE=5 +# HOSTNAME=your-hostname # 数据库相关配置 # 数据库连接字符串 diff --git a/.gitignore b/.gitignore index 640e5ec6a..67ce02704 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ web/bun.lock electron/node_modules electron/dist data/ +.gomodcache/ +.gocache-temp +.gopath diff --git a/README.en.md b/README.en.md index 1e5ae9751..e222f4451 100644 --- a/README.en.md +++ b/README.en.md @@ -308,6 +308,13 @@ docker run --name new-api -d --restart always \ | `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Error log switch | `false` | +| `PYROSCOPE_URL` | Pyroscope server address | - | +| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` | +| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - | +| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` | +| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` | +| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` | 📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) diff --git a/README.fr.md b/README.fr.md index a3a02317f..76d8e6ffe 100644 --- a/README.fr.md +++ b/README.fr.md @@ -304,6 +304,13 @@ docker run --name new-api -d --restart always \ | `MAX_REQUEST_BODY_MB` | Taille maximale du corps de requête (Mo, comptée **après décompression** ; évite les requêtes énormes/zip bombs qui saturent la mémoire). Dépassement ⇒ `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` | +| `PYROSCOPE_URL` | Adresse du serveur Pyroscope | - | +| `PYROSCOPE_APP_NAME` | Nom de l'application Pyroscope | `new-api` | +| `PYROSCOPE_BASIC_AUTH_USER` | Utilisateur Basic Auth Pyroscope | - | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Mot de passe Basic Auth Pyroscope | - | +| `PYROSCOPE_MUTEX_RATE` | Taux d'échantillonnage mutex Pyroscope | `5` | +| `PYROSCOPE_BLOCK_RATE` | Taux d'échantillonnage block Pyroscope | `5` | +| `HOSTNAME` | Nom d'hôte tagué pour Pyroscope | `new-api` | 📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) diff --git a/README.ja.md b/README.ja.md index cae2f192d..c8a27dec0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -313,6 +313,13 @@ docker run --name new-api -d --restart always \ | `MAX_REQUEST_BODY_MB` | リクエストボディ最大サイズ(MB、**解凍後**に計測。巨大リクエスト/zip bomb によるメモリ枯渇を防止)。超過時は `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` | +| `PYROSCOPE_URL` | Pyroscopeサーバーのアドレス | - | +| `PYROSCOPE_APP_NAME` | Pyroscopeアプリ名 | `new-api` | +| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Authユーザー | - | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Authパスワード | - | +| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutexサンプリング率 | `5` | +| `PYROSCOPE_BLOCK_RATE` | Pyroscope blockサンプリング率 | `5` | +| `HOSTNAME` | Pyroscope用のホスト名タグ | `new-api` | 📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables) diff --git a/README.md b/README.md index 3ef081bb4..22559fc4a 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,13 @@ docker run --name new-api -d --restart always \ | `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | +| `PYROSCOPE_URL` | Pyroscope 服务地址 | - | +| `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` | +| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密码 | - | +| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex 采样率 | `5` | +| `PYROSCOPE_BLOCK_RATE` | Pyroscope block 采样率 | `5` | +| `HOSTNAME` | Pyroscope 标签里的主机名 | `new-api` | 📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) diff --git a/common/pyro.go b/common/pyro.go new file mode 100644 index 000000000..b798f2c77 --- /dev/null +++ b/common/pyro.go @@ -0,0 +1,56 @@ +package common + +import ( + "runtime" + + "github.com/grafana/pyroscope-go" +) + +func StartPyroScope() error { + + pyroscopeUrl := GetEnvOrDefaultString("PYROSCOPE_URL", "") + if pyroscopeUrl == "" { + return nil + } + + pyroscopeAppName := GetEnvOrDefaultString("PYROSCOPE_APP_NAME", "new-api") + pyroscopeBasicAuthUser := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_USER", "") + pyroscopeBasicAuthPassword := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_PASSWORD", "") + pyroscopeHostname := GetEnvOrDefaultString("HOSTNAME", "new-api") + + mutexRate := GetEnvOrDefault("PYROSCOPE_MUTEX_RATE", 5) + blockRate := GetEnvOrDefault("PYROSCOPE_BLOCK_RATE", 5) + + runtime.SetMutexProfileFraction(mutexRate) + runtime.SetBlockProfileRate(blockRate) + + _, err := pyroscope.Start(pyroscope.Config{ + ApplicationName: pyroscopeAppName, + + ServerAddress: pyroscopeUrl, + BasicAuthUser: pyroscopeBasicAuthUser, + BasicAuthPassword: pyroscopeBasicAuthPassword, + + Logger: nil, + + Tags: map[string]string{"hostname": pyroscopeHostname}, + + ProfileTypes: []pyroscope.ProfileType{ + pyroscope.ProfileCPU, + pyroscope.ProfileAllocObjects, + pyroscope.ProfileAllocSpace, + pyroscope.ProfileInuseObjects, + pyroscope.ProfileInuseSpace, + + pyroscope.ProfileGoroutines, + pyroscope.ProfileMutexCount, + pyroscope.ProfileMutexDuration, + pyroscope.ProfileBlockCount, + pyroscope.ProfileBlockDuration, + }, + }) + if err != nil { + return err + } + return nil +} diff --git a/controller/channel-test.go b/controller/channel-test.go index 1c77fb030..f9657edbd 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -97,6 +97,11 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { requestPath = "/v1/images/generations" } + + // responses-only models + if strings.Contains(strings.ToLower(testModel), "codex") { + requestPath = "/v1/responses" + } } c.Request = &http.Request{ @@ -176,7 +181,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } - request := buildTestRequest(testModel, endpointType) + request := buildTestRequest(testModel, endpointType, channel) info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) @@ -319,6 +324,16 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) httpResp = resp.(*http.Response) if httpResp.StatusCode != http.StatusOK { err := service.RelayErrorHandler(c.Request.Context(), httpResp, true) + common.SysError(fmt.Sprintf( + "channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v", + channel.Id, + channel.Name, + channel.Type, + testModel, + endpointType, + httpResp.StatusCode, + err, + )) return testResult{ context: c, localErr: err, @@ -389,7 +404,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } -func buildTestRequest(model string, endpointType string) dto.Request { +func buildTestRequest(model string, endpointType string, channel *model.Channel) dto.Request { // 根据端点类型构建不同的测试请求 if endpointType != "" { switch constant.EndpointType(endpointType) { @@ -423,7 +438,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { } case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: // 返回 GeneralOpenAIRequest - maxTokens := uint(10) + maxTokens := uint(16) if constant.EndpointType(endpointType) == constant.EndpointTypeGemini { maxTokens = 3000 } @@ -453,6 +468,14 @@ func buildTestRequest(model string, endpointType string) dto.Request { } } + // Responses-only models (e.g. codex series) + if strings.Contains(strings.ToLower(model), "codex") { + return &dto.OpenAIResponsesRequest{ + Model: model, + Input: json.RawMessage("\"hi\""), + } + } + // Chat/Completion 请求 - 返回 GeneralOpenAIRequest testRequest := &dto.GeneralOpenAIRequest{ Model: model, @@ -466,7 +489,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { } if strings.HasPrefix(model, "o") { - testRequest.MaxCompletionTokens = 10 + testRequest.MaxCompletionTokens = 16 } else if strings.Contains(model, "thinking") { if !strings.Contains(model, "claude") { testRequest.MaxTokens = 50 @@ -474,7 +497,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { } else if strings.Contains(model, "gemini") { testRequest.MaxTokens = 3000 } else { - testRequest.MaxTokens = 10 + testRequest.MaxTokens = 16 } return testRequest 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/controller/model_sync.go b/controller/model_sync.go index e321ee0c5..b2ac99da8 100644 --- a/controller/model_sync.go +++ b/controller/model_sync.go @@ -249,7 +249,9 @@ func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, v return 0 } -// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效 +// SyncUpstreamModels 同步上游模型与供应商: +// - 默认仅创建「未配置模型」 +// - 可通过 overwrite 选择性覆盖更新本地已有模型的字段(前提:sync_official <> 0) func SyncUpstreamModels(c *gin.Context) { var req syncRequest // 允许空体 @@ -260,12 +262,26 @@ func SyncUpstreamModels(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } - if len(missing) == 0 { - c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ - "created_models": 0, - "created_vendors": 0, - "skipped_models": []string{}, - }}) + + // 若既无缺失模型需要创建,也未指定覆盖更新字段,则无需请求上游数据,直接返回 + if len(missing) == 0 && len(req.Overwrite) == 0 { + modelsURL, vendorsURL := getUpstreamURLs(req.Locale) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "created_models": 0, + "created_vendors": 0, + "updated_models": 0, + "skipped_models": []string{}, + "created_list": []string{}, + "updated_list": []string{}, + "source": gin.H{ + "locale": req.Locale, + "models_url": modelsURL, + "vendors_url": vendorsURL, + }, + }, + }) return } @@ -315,9 +331,9 @@ func SyncUpstreamModels(c *gin.Context) { createdModels := 0 createdVendors := 0 updatedModels := 0 - var skipped []string - var createdList []string - var updatedList []string + skipped := make([]string, 0) + createdList := make([]string, 0) + updatedList := make([]string, 0) // 本地缓存:vendorName -> id vendorIDCache := make(map[string]int) 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/controller/user.go b/controller/user.go index ef4f0ddc0..1fc83c99e 100644 --- a/controller/user.go +++ b/controller/user.go @@ -110,18 +110,17 @@ func setupLogin(user *model.User, c *gin.Context) { }) return } - cleanUser := model.User{ - Id: user.Id, - Username: user.Username, - DisplayName: user.DisplayName, - Role: user.Role, - Status: user.Status, - Group: user.Group, - } c.JSON(http.StatusOK, gin.H{ "message": "", "success": true, - "data": cleanUser, + "data": map[string]any{ + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "role": user.Role, + "status": user.Status, + "group": user.Group, + }, }) } @@ -764,7 +763,10 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int if err != nil { return } - if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) { + + // 密码不为空,需要验证原密码 + // 支持第一次账号绑定时原密码为空的情况 + if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) && currentUser.Password != "" { err = fmt.Errorf("原密码错误") return } 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/dto/error.go b/dto/error.go index cf00d6772..78197765b 100644 --- a/dto/error.go +++ b/dto/error.go @@ -26,6 +26,7 @@ type GeneralErrorResponse struct { Msg string `json:"msg"` Err string `json:"err"` ErrorMsg string `json:"error_msg"` + Metadata json.RawMessage `json:"metadata,omitempty"` Header struct { Message string `json:"message"` } `json:"header"` diff --git a/dto/gemini.go b/dto/gemini.go index fd126dba1..02464ca17 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -22,6 +22,27 @@ type GeminiChatRequest struct { CachedContent string `json:"cachedContent,omitempty"` } +// UnmarshalJSON allows GeminiChatRequest to accept both snake_case and camelCase fields. +func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error { + type Alias GeminiChatRequest + var aux struct { + Alias + SystemInstructionSnake *GeminiChatContent `json:"system_instruction,omitempty"` + } + + if err := common.Unmarshal(data, &aux); err != nil { + return err + } + + *r = GeminiChatRequest(aux.Alias) + + if aux.SystemInstructionSnake != nil { + r.SystemInstructions = aux.SystemInstructionSnake + } + + return nil +} + type ToolConfig struct { FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"` RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"` 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/go.mod b/go.mod index 87af3c22e..f4f133973 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 + github.com/grafana/pyroscope-go v1.2.7 github.com/jfreymuth/oggvorbis v1.0.5 github.com/jinzhu/copier v0.4.0 github.com/joho/godotenv v1.5.1 @@ -36,6 +37,7 @@ require ( github.com/samber/lo v1.52.0 github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.4.0 + github.com/stretchr/testify v1.11.1 github.com/stripe/stripe-go/v81 v81.4.0 github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 github.com/thanhpk/randstr v1.0.6 @@ -62,6 +64,7 @@ require ( github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -77,11 +80,11 @@ require ( github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-webauthn/x v0.1.25 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-tpm v0.9.5 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -91,6 +94,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -101,7 +105,9 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect diff --git a/go.sum b/go.sum index 1138c747a..697a313d8 100644 --- a/go.sum +++ b/go.sum @@ -118,9 +118,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -132,6 +131,10 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= @@ -160,12 +163,15 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -214,14 +220,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= -github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= @@ -231,6 +234,7 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -288,12 +292,12 @@ golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= @@ -321,6 +325,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= @@ -350,19 +356,29 @@ gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBp gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index 481d0a600..4c0fc8c6e 100644 --- a/main.go +++ b/main.go @@ -124,6 +124,11 @@ func main() { common.SysLog("pprof enabled") } + err = common.StartPyroScope() + if err != nil { + common.SysError(fmt.Sprintf("start pyroscope error : %v", err)) + } + // Initialize HTTP server server := gin.New() server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { @@ -183,6 +188,7 @@ func InjectUmamiAnalytics() { analyticsInjectBuilder.WriteString(umamiSiteID) analyticsInjectBuilder.WriteString("\">") } + analyticsInjectBuilder.WriteString("\n") analyticsInject := analyticsInjectBuilder.String() indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject)) } @@ -204,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/middleware/auth.go b/middleware/auth.go index 1396b2d5a..a3b41b186 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -218,10 +218,14 @@ func TokenAuth() func(c *gin.Context) { } key := c.Request.Header.Get("Authorization") parts := make([]string, 0) - key = strings.TrimPrefix(key, "Bearer ") + if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") { + key = strings.TrimSpace(key[7:]) + } if key == "" || key == "midjourney-proxy" { key = c.Request.Header.Get("mj-api-secret") - key = strings.TrimPrefix(key, "Bearer ") + if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") { + key = strings.TrimSpace(key[7:]) + } key = strings.TrimPrefix(key, "sk-") parts = strings.Split(key, "-") key = parts[0] 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/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/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index e8b8212d6..d8616d2d9 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -13,6 +13,7 @@ import ( relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/reasoning" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -137,7 +138,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") - } else if baseModel, level := parseThinkingLevelSuffix(info.UpstreamModelName); level != "" { + } else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" { info.UpstreamModelName = baseModel } } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 498382936..7d565faa6 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -98,6 +98,7 @@ func clampThinkingBudget(modelName string, budget int) int { // "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens) // "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens) // "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens) +// "effort": "minimal" - Allocates a minimal portion of tokens (approximately 5% of max_tokens) func clampThinkingBudgetByEffort(modelName string, effort string) int { isNew25Pro := isNew25ProModel(modelName) is25FlashLite := is25FlashLiteModel(modelName) @@ -118,18 +119,12 @@ func clampThinkingBudgetByEffort(modelName string, effort string) int { maxBudget = maxBudget * 50 / 100 case "low": maxBudget = maxBudget * 20 / 100 + case "minimal": + maxBudget = maxBudget * 5 / 100 } return clampThinkingBudget(modelName, maxBudget) } -func parseThinkingLevelSuffix(modelName string) (string, string) { - base, level, ok := reasoning.TrimEffortSuffix(modelName) - if !ok { - return modelName, "" - } - return base, level -} - func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { modelName := info.UpstreamModelName @@ -186,7 +181,7 @@ func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.Rel ThinkingBudget: common.GetPointer(0), } } - } else if _, level := parseThinkingLevelSuffix(modelName); level != "" { + } else if _, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" { geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ IncludeThoughts: true, ThinkingLevel: level, @@ -379,7 +374,7 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i var system_content []string //shouldAddDummyModelMessage := false for _, message := range textRequest.Messages { - if message.Role == "system" { + if message.Role == "system" || message.Role == "developer" { system_content = append(system_content, message.StringContent()) continue } else if message.Role == "tool" || message.Role == "function" { 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/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 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, diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 4d2545ca0..475af002c 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 @@ -250,8 +250,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 @@ -262,8 +262,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 @@ -273,8 +273,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 @@ -285,8 +285,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 @@ -294,7 +294,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 @@ -335,7 +335,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). @@ -367,17 +367,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 { @@ -416,15 +424,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/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/service/convert.go b/service/convert.go index 7549b569d..7228db9a9 100644 --- a/service/convert.go +++ b/service/convert.go @@ -401,7 +401,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon }, }) } - + if len(toolCall.Function.Arguments) > 0 { claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Index: &idx, diff --git a/service/error.go b/service/error.go index 9e517e85a..889964beb 100644 --- a/service/error.go +++ b/service/error.go @@ -90,11 +90,17 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai } CloseResponseBodyGracefully(resp) var errResponse dto.GeneralErrorResponse + buildErrWithBody := func(message string) error { + if message == "" { + return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) + } + return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, string(responseBody)) + } err = common.Unmarshal(responseBody, &errResponse) if err != nil { if showBodyWhenFail { - newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) + newApiErr.Err = buildErrWithBody("") } else { logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode) @@ -107,10 +113,16 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai oaiError := errResponse.TryToOpenAIError() if oaiError != nil { newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode) + if showBodyWhenFail { + newApiErr.Err = buildErrWithBody(newApiErr.Error()) + } return } } newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + if showBodyWhenFail { + newApiErr.Err = buildErrWithBody(newApiErr.Error()) + } return } 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() } diff --git a/service/quota.go b/service/quota.go index 0da8dafd3..23ae60c1f 100644 --- a/service/quota.go +++ b/service/quota.go @@ -95,7 +95,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag return err } - token, err := model.GetTokenByKey(strings.TrimLeft(relayInfo.TokenKey, "sk-"), false) + token, err := model.GetTokenByKey(strings.TrimPrefix(relayInfo.TokenKey, "sk-"), false) if err != nil { return err } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index f21893f14..9728fef54 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -7,7 +7,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/setting/operation_setting" - "github.com/QuantumNous/new-api/setting/reasoning" ) // from songquanpeng/one-api @@ -893,10 +892,6 @@ func FormatMatchingModelName(name string) string { name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") } - if base, _, ok := reasoning.TrimEffortSuffix(name); ok { - name = base - } - if strings.HasPrefix(name, "gpt-4-gizmo") { name = "gpt-4-gizmo-*" } diff --git a/setting/reasoning/suffix.go b/setting/reasoning/suffix.go index 4cc74b612..da3bdc7d3 100644 --- a/setting/reasoning/suffix.go +++ b/setting/reasoning/suffix.go @@ -6,7 +6,7 @@ import ( "github.com/samber/lo" ) -var EffortSuffixes = []string{"-high", "-medium", "-low"} +var EffortSuffixes = []string{"-high", "-medium", "-low", "-minimal"} // TrimEffortSuffix -> modelName level(low) exists func TrimEffortSuffix(modelName string) (string, string, bool) { diff --git a/types/error.go b/types/error.go index 3bfd0399a..b060a9db6 100644 --- a/types/error.go +++ b/types/error.go @@ -1,6 +1,7 @@ package types import ( + "encoding/json" "errors" "fmt" "net/http" @@ -10,10 +11,11 @@ import ( ) type OpenAIError struct { - Message string `json:"message"` - Type string `json:"type"` - Param string `json:"param"` - Code any `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` + Code any `json:"code"` + Metadata json.RawMessage `json:"metadata,omitempty"` } type ClaudeError struct { @@ -92,6 +94,7 @@ type NewAPIError struct { errorType ErrorType errorCode ErrorCode StatusCode int + Metadata json.RawMessage } // Unwrap enables errors.Is / errors.As to work with NewAPIError by exposing the underlying error. @@ -301,6 +304,13 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIError Err: errors.New(openAIError.Message), errorCode: ErrorCode(code), } + // OpenRouter + if len(openAIError.Metadata) > 0 { + openAIError.Message = fmt.Sprintf("%s (%s)", openAIError.Message, openAIError.Metadata) + e.Metadata = openAIError.Metadata + e.RelayError = openAIError + e.Err = errors.New(openAIError.Message) + } for _, op := range ops { op(e) } diff --git a/types/price_data.go b/types/price_data.go index 9f942d3ec..83c81ecef 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -27,12 +27,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 { - 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, ImageOutputRatio: %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.ImageOutputRatio, p.AudioRatio, p.AudioCompletionRatio) +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) } 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/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 18d374801..6a889356d 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -314,10 +314,10 @@ const PersonalSetting = () => { }; const changePassword = async () => { - if (inputs.original_password === '') { - showError(t('请输入原密码!')); - return; - } + // if (inputs.original_password === '') { + // showError(t('请输入原密码!')); + // return; + // } if (inputs.set_new_password === '') { showError(t('请输入新密码!')); return; diff --git a/web/src/components/table/channels/ChannelsColumnDefs.jsx b/web/src/components/table/channels/ChannelsColumnDefs.jsx index 5b505baed..2c9f7498b 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/web/src/components/table/channels/ChannelsColumnDefs.jsx @@ -39,11 +39,16 @@ import { showError, } from '../../../helpers'; import { CHANNEL_OPTIONS } from '../../../constants'; -import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons'; +import { + IconTreeTriangleDown, + IconMore, + IconAlertTriangle, +} from '@douyinfe/semi-icons'; import { FaRandom } from 'react-icons/fa'; // Render functions -const renderType = (type, channelInfo = undefined, t) => { +const renderType = (type, record = {}, t) => { + const channelInfo = record?.channel_info; let type2label = new Map(); for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; @@ -67,11 +72,65 @@ const renderType = (type, channelInfo = undefined, t) => { ); } - return ( + const typeTag = ( {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) => { @@ -187,6 +246,28 @@ const renderResponseTime = (responseTime, t) => { } }; +const isRequestPassThroughEnabled = (record) => { + if (!record || record.children !== undefined) { + return false; + } + const settingValue = record.setting; + if (!settingValue) { + return false; + } + if (typeof settingValue === 'object') { + return settingValue.pass_through_body_enabled === true; + } + if (typeof settingValue !== 'string') { + return false; + } + try { + const parsed = JSON.parse(settingValue); + return parsed?.pass_through_body_enabled === true; + } catch (error) { + return false; + } +}; + export const getChannelsColumns = ({ t, COLUMN_KEYS, @@ -205,6 +286,7 @@ export const getChannelsColumns = ({ refresh, activePage, channels, + checkOllamaVersion, setShowMultiKeyManageModal, setCurrentMultiKeyChannel, }) => { @@ -219,8 +301,9 @@ export const getChannelsColumns = ({ title: t('名称'), dataIndex: 'name', render: (text, record, index) => { - if (record.remark && record.remark.trim() !== '') { - return ( + const passThroughEnabled = isRequestPassThroughEnabled(record); + const nameNode = + record.remark && record.remark.trim() !== '' ? ( @@ -250,9 +333,32 @@ export const getChannelsColumns = ({ > {text} + ) : ( + {text} ); + + if (!passThroughEnabled) { + return nameNode; } - return text; + + return ( + + {nameNode} + + + + + + + ); }, }, { @@ -280,12 +386,7 @@ export const getChannelsColumns = ({ dataIndex: 'type', render: (text, record, index) => { if (record.children === undefined) { - if (record.channel_info) { - if (record.channel_info.is_multi_key) { - return <>{renderType(text, record.channel_info, t)}; - } - } - return <>{renderType(text, undefined, t)}; + return <>{renderType(text, record, t)}; } else { return <>{renderTagType(t)}; } @@ -519,6 +620,15 @@ export const getChannelsColumns = ({ }, ]; + if (record.type === 4) { + moreMenuItems.unshift({ + node: 'item', + name: t('测活'), + type: 'tertiary', + onClick: () => checkOllamaVersion(record), + }); + } + return ( { 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/index.jsx b/web/src/components/table/channels/index.jsx index 466f04155..fa7850959 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.jsx @@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; +import { Banner } from '@douyinfe/semi-ui'; +import { IconAlertTriangle } from '@douyinfe/semi-icons'; import CardPro from '../../common/ui/CardPro'; import ChannelsTable from './ChannelsTable'; import ChannelsActions from './ChannelsActions'; @@ -63,6 +65,22 @@ const ChannelsPage = () => { /> {/* Main Content */} + {channelsData.globalPassThroughEnabled ? ( + + } + description={channelsData.t( + '已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。', + )} + style={{ marginBottom: 12 }} + /> + ) : null} } 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 55b6a78f1..e6a4c4c4d 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 f3f99f01e..415a34a5d 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -26,6 +26,7 @@ import { showSuccess, loadChannelModels, copy, + toBoolean, } from '../../helpers'; import { CHANNEL_OPTIONS, @@ -34,7 +35,7 @@ import { } from '../../constants'; import { useIsMobile } from '../common/useIsMobile'; import { useTableCompactMode } from '../common/useTableCompactMode'; -import { Modal } from '@douyinfe/semi-ui'; +import { Modal, Button } from '@douyinfe/semi-ui'; export const useChannelsData = () => { const { t } = useTranslation(); @@ -85,6 +86,26 @@ export const useChannelsData = () => { const [isBatchTesting, setIsBatchTesting] = useState(false); const [modelTablePage, setModelTablePage] = useState(1); const [selectedEndpointType, setSelectedEndpointType] = useState(''); + const [globalPassThroughEnabled, setGlobalPassThroughEnabled] = + useState(false); + + const fetchGlobalPassThroughEnabled = async () => { + try { + const res = await API.get('/api/option/'); + const { success, data } = res?.data || {}; + if (!success || !Array.isArray(data)) { + return; + } + const option = data.find( + (item) => item?.key === 'global.pass_through_request_enabled', + ); + if (option) { + setGlobalPassThroughEnabled(toBoolean(option.value)); + } + } catch (error) { + setGlobalPassThroughEnabled(false); + } + }; // 使用 ref 来避免闭包问题,类似旧版实现 const shouldStopBatchTestingRef = useRef(false); @@ -140,6 +161,7 @@ export const useChannelsData = () => { }); fetchGroups().then(); loadChannelModels().then(); + fetchGlobalPassThroughEnabled().then(); }, []); // Column visibility management @@ -753,6 +775,67 @@ export const useChannelsData = () => { } }; + const checkOllamaVersion = async (record) => { + try { + const res = await API.get(`/api/channel/ollama/version/${record.id}`); + const { success, message, data } = res.data; + + if (success) { + const version = data?.version || '-'; + const infoMessage = t('当前 Ollama 版本为 ${version}').replace( + '${version}', + version, + ); + + const handleCopyVersion = async () => { + if (!version || version === '-') { + showInfo(t('暂无可复制的版本信息')); + return; + } + + const copied = await copy(version); + if (copied) { + showSuccess(t('已复制版本号')); + } else { + showError(t('复制失败,请手动复制')); + } + }; + + Modal.info({ + title: t('Ollama 版本信息'), + content: infoMessage, + centered: true, + footer: ( +
+ + +
+ ), + hasCancel: false, + hasOk: false, + closable: true, + maskClosable: true, + }); + } else { + showError(message || t('获取 Ollama 版本失败')); + } + } catch (error) { + const errMsg = + error?.response?.data?.message || + error?.message || + t('获取 Ollama 版本失败'); + showError(errMsg); + } + }; + // Test channel - 单个模型测试,参考旧版实现 const testChannel = async (record, model, endpointType = '') => { const testKey = `${record.id}-${model}`; @@ -1026,6 +1109,7 @@ export const useChannelsData = () => { enableBatchDelete, statusFilter, compactMode, + globalPassThroughEnabled, // UI states showEdit, @@ -1109,6 +1193,7 @@ export const useChannelsData = () => { updateAllChannelsBalance, updateChannelBalance, fixChannelsAbilities, + checkOllamaVersion, testChannel, batchTestModels, handleCloseModal, 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/i18n/i18n.js b/web/src/i18n/i18n.js index ac441470d..161d0a215 100644 --- a/web/src/i18n/i18n.js +++ b/web/src/i18n/i18n.js @@ -42,6 +42,7 @@ i18n vi: viTranslation, }, fallbackLng: 'zh', + nsSeparator: false, interpolation: { escapeValue: false, }, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 4de684048..fb34544a4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -840,6 +840,9 @@ "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度": "After enabling, free models (ratio 0 or price 0) will also pre-consume quota", "开启后,将定期发送ping数据保持连接活跃": "After enabling, ping data will be sent periodically to keep the connection active", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "When enabled, all requests will be directly forwarded to the upstream without any processing (redirects and channel adaptation will also be disabled). Please enable with caution.", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Request pass-through is enabled for this channel. Built-in NewAPI features such as parameter overrides, model redirection, and channel adaptation will be disabled. This is not a best practice. If this causes issues, please do not submit an issue.", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Global request pass-through is enabled. Built-in NewAPI features such as parameter overrides, model redirection, and channel adaptation will be disabled. This is not a best practice. If this causes issues, please do not submit an issue.", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Request pass-through is enabled for this channel; built-in NewAPI features such as parameter overrides and model redirection will be disabled. This is not a best practice.", "开启后不限制:必须设置模型倍率": "After enabling, no limit: must set model ratio", "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace", "开启批量操作": "Enable batch selection", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index d05cdf569..39b06690e 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -47,12 +47,12 @@ "API Key 模式下不支持批量创建": "Création en lot non prise en charge en mode clé API", "API 地址和相关配置": "URL de l'API et configuration associée", "API 密钥": "Clé API", - "API 文档": "Documentation de l'API", - "API 配置": "Configuration de l'API", - "API令牌管理": "Gestion des jetons d'API", - "API使用记录": "Enregistrements d'utilisation de l'API", + "API 文档": "Docs API", + "API 配置": "Config. API", + "API令牌管理": "Jetons API", + "API使用记录": "Journaux d'API", "API信息": "Informations sur l'API", - "API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "Gestion des informations de l'API, vous pouvez configurer plusieurs adresses d'API pour l'affichage de l'état et l'équilibrage de charge (maximum 50)", + "API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "Infos API, vous pouvez configurer plusieurs adresses d'API pour l'affichage de l'état et l'équilibrage de charge (maximum 50)", "API地址": "URL de base", "API渠道配置": "Configuration du canal de l'API", "API端点": "Points de terminaison de l'API", @@ -112,7 +112,7 @@ "LinuxDO": "LinuxDO", "LinuxDO ID": "ID LinuxDO", "Logo 图片地址": "Adresse de l'image du logo", - "Midjourney 任务记录": "Enregistrements de tâches Midjourney", + "Midjourney 任务记录": "Tâches Midjourney", "MIT许可证": "Licence MIT", "New API项目仓库地址:": "Adresse du référentiel du projet New API : ", "OIDC": "OIDC", @@ -136,7 +136,7 @@ "SMTP 访问凭证": "Informations d'identification d'accès SMTP", "SMTP 账户": "Compte SMTP", "SSRF防护开关详细说明": "L'interrupteur principal contrôle si la protection SSRF est activée. Lorsqu'elle est désactivée, toutes les vérifications SSRF sont contournées, autorisant l'accès à n'importe quelle URL. ⚠️ Ne désactivez cette fonctionnalité que dans des environnements entièrement fiables.", - "SSRF防护设置": "Paramètres de protection SSRF", + "SSRF防护设置": "Protection SSRF", "SSRF防护详细说明": "La protection SSRF empêche les utilisateurs malveillants d'utiliser votre serveur pour accéder aux ressources du réseau interne. Configurez des listes blanches pour les domaines/IP de confiance et limitez les ports autorisés. S'applique aux téléchargements de fichiers, aux webhooks et aux notifications.", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex", "Stripe 设置": "Paramètres Stripe", @@ -150,7 +150,7 @@ "Turnstile Site Key": "Clé du site Turnstile", "Unix时间戳": "Horodatage Unix", "Uptime Kuma地址": "Adresse Uptime Kuma", - "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Gestion des catégories de surveillance Uptime Kuma, vous pouvez configurer plusieurs catégories de surveillance pour l'affichage de l'état du service (maximum 20)", + "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Catégories de surveillance Uptime Kuma, vous pouvez configurer plusieurs catégories de surveillance pour l'affichage de l'état du service (maximum 20)", "URL链接": "Lien URL", "USD (美元)": "USD (Dollar US)", "User Info Endpoint": "Point de terminaison des informations utilisateur", @@ -204,9 +204,9 @@ "个": " individuel", "个人中心": "Centre personnel", "个人中心区域": "Zone du centre personnel", - "个人信息设置": "Paramètres des informations personnelles", - "个人设置": "Paramètres personnels", - "个性化设置": "Paramètres de personnalisation", + "个人信息设置": "Infos personnelles", + "个人设置": "Profil", + "个性化设置": "Personnalisation", "个性化设置左侧边栏的显示内容": "Personnaliser le contenu affiché dans la barre latérale gauche", "个未配置模型": "modèles non configurés", "个模型": "modèles", @@ -264,26 +264,26 @@ "令牌已重置并已复制到剪贴板": "Le jeton a été réinitialisé et copié dans le presse-papiers", "令牌更新成功!": "Jeton mis à jour avec succès !", "令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制": "Le quota du jeton est uniquement utilisé pour limiter l'utilisation maximale du quota du jeton lui-même, et l'utilisation réelle est limitée par le quota restant du compte", - "令牌管理": "Gestion des jetons", + "令牌管理": "Jetons", "以下上游数据可能不可信:": "Les données en amont suivantes peuvent ne pas être fiables : ", "以下文件解析失败,已忽略:{{list}}": "L'analyse des fichiers suivants a échoué, ignorés : {{list}}", "以及": "et", - "仪表盘设置": "Paramètres du tableau de bord", + "仪表盘设置": "Tableau de bord", "价格": "Tarifs", "价格:${{price}} * {{ratioType}}:{{ratio}}": "Prix : ${{price}} * {{ratioType}} : {{ratio}}", - "价格设置": "Paramètres de prix", + "价格设置": "Prix", "价格设置方式": "Méthode de configuration des prix", "任务 ID": "ID de la tâche", "任务ID": "ID de la tâche", - "任务日志": "Journaux de tâches", + "任务日志": "Tâches", "任务状态": "Statut de la tâche", - "任务记录": "Enregistrements de tâches", + "任务记录": "Tâches", "企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选": "Les comptes d'entreprise ont un format de retour spécial et nécessitent un traitement particulier. Si ce n'est pas un compte d'entreprise, veuillez ne pas cocher cette case.", "优先级": "Priorité", "优惠": "Remise", "低于此额度时将发送邮件提醒用户": "Un rappel par e-mail sera envoyé lorsque le quota tombera en dessous de ce seuil", "余额": "Solde", - "余额充值管理": "Gestion de la recharge du solde", + "余额充值管理": "Recharge du solde", "你似乎并没有修改什么": "Vous ne semblez rien avoir modifié", "你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "Vous pouvez les ajouter manuellement dans « Noms de modèles personnalisés », cliquer sur Remplir puis soumettre, ou utiliser directement les actions ci-dessous pour les traiter automatiquement.", "使用 Discord 继续": "Continuer avec Discord", @@ -298,7 +298,7 @@ "使用 用户名 注册": "S'inscrire avec un nom d'utilisateur", "使用 邮箱或用户名 登录": "Connectez-vous avec votre e-mail ou votre nom d'utilisateur", "使用ID排序": "Trier par ID", - "使用日志": "Journaux d'utilisation", + "使用日志": "Journaux", "使用模式": "Mode d'utilisation", "使用统计": "Statistiques d'utilisation", "使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:": "Utilisez une application d'authentification (telle que Google Authenticator, Microsoft Authenticator) pour scanner le code QR ci-dessous :", @@ -328,7 +328,7 @@ "供应商名称": "Nom du fournisseur", "供应商图标": "Icône du fournisseur", "供应商更新成功!": "Fournisseur mis à jour avec succès !", - "侧边栏管理(全局控制)": "Gestion de la barre latérale (contrôle global)", + "侧边栏管理(全局控制)": "Barre latérale (Global)", "侧边栏设置保存成功": "Paramètres de la barre latérale enregistrés avec succès", "保存": "Enregistrer", "保存 Discord OAuth 设置": "Enregistrer les paramètres OAuth Discord", @@ -402,7 +402,7 @@ "充值数量": "Quantité de recharge", "充值数量,最低 ": "Quantité de recharge, minimum ", "充值数量不能小于": "Le montant de la recharge ne peut pas être inférieur à", - "充值方式设置": "Paramètres de la méthode de recharge", + "充值方式设置": "Méthodes recharge", "充值方式设置不是合法的 JSON 字符串": "Les paramètres de la méthode de recharge ne sont pas une chaîne JSON valide", "充值确认": "Confirmation de la recharge", "充值账单": "Factures de recharge", @@ -418,8 +418,8 @@ "兑换码创建成功!": "Code d'échange créé avec succès !", "兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "Le code d'échange sera téléchargé sous forme de fichier texte, le nom de fichier étant le nom du code d'échange.", "兑换码更新成功!": "Code d'échange mis à jour avec succès !", - "兑换码生成管理": "Gestion de la génération de codes d'échange", - "兑换码管理": "Gestion des codes d'échange", + "兑换码生成管理": "Génération de codes", + "兑换码管理": "Codes d'échange", "兑换额度": "Utiliser", "全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用": "Contrôle global des zones et des fonctions de la barre latérale, les utilisateurs ne peuvent pas activer les fonctions masquées par les administrateurs", "全局设置": "Paramètres globaux", @@ -448,7 +448,7 @@ "共 {{total}} 项,当前显示 {{start}}-{{end}} 项": "Total {{total}} éléments, affichage actuel {{start}}-{{end}} éléments", "关": "Fermer", "关于": "À propos", - "关于我们": "À propos de nous", + "关于我们": "Nous", "关于系统的详细信息": "Informations détaillées sur le système", "关于项目": "À propos du projet", "关键字(id或者名称)": "Mot-clé (id ou nom)", @@ -460,7 +460,7 @@ "其他": "Autre", "其他注册选项": "Autres options d'inscription", "其他登录选项": "Autres options de connexion", - "其他设置": "Autres paramètres", + "其他设置": "Autres", "其他详情": "Autres détails", "内容": "Contenu", "内容较大,已启用性能优化模式": "Le contenu est volumineux, le mode d'optimisation des performances a été activé", @@ -472,14 +472,14 @@ "准备完成初始化": "Prêt à terminer l'initialisation", "分类名称": "Nom de la catégorie", "分组": "Groupe", - "分组与模型定价设置": "Paramètres de groupe et de tarification du modèle", + "分组与模型定价设置": "Groupe et tarification", "分组价格": "Prix de groupe", "分组倍率": "Ratio", - "分组倍率设置": "Paramètres de ratio de groupe", + "分组倍率设置": "Ratio de groupe", "分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{\"vip\": 0.5, \"test\": 1},表示 vip 分组的倍率为 0.5,test 分组的倍率为 1": "Paramètres de ratio de groupe, vous pouvez ajouter de nouveaux groupes ou modifier le ratio des groupes existants ici, au format de chaîne JSON, par exemple : {\"vip\": 0,5, \"test\": 1}, ce qui signifie que le ratio du groupe vip est 0,5 et celui du groupe test est 1", "分组特殊倍率": "Ratio spécial de groupe", "分组特殊可用分组": "Groupes spéciaux disponibles", - "分组设置": "Paramètres de groupe", + "分组设置": "Groupe", "分组速率配置优先级高于全局速率限制。": "La priorité de configuration du taux de groupe est supérieure à la limite de taux globale.", "分组速率限制": "Limitation du taux de groupe", "分钟": "minutes", @@ -492,7 +492,7 @@ "划转金额最低为": "Le montant minimum du virement est de", "划转额度": "Montant du virement", "列出的模型将不会自动添加或移除-thinking/-nothinking 后缀": "Les modèles listés ici n'ajouteront ni ne retireront automatiquement le suffixe -thinking/-nothinking.", - "列设置": "Paramètres de colonne", + "列设置": "Colonnes", "创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)": "Lors de la création d'un jeton, le groupe auto est sélectionné par défaut, et le jeton initial sera également défini sur auto (sinon laisser vide, pour le groupe par défaut de l'utilisateur)", "创建失败": "Échec de la création", "创建成功": "Création réussie", @@ -571,7 +571,7 @@ "可用端点类型": "Types de points de terminaison pris en charge", "可用邀请额度": "Quota d'invitation disponible", "可视化": "Visualisation", - "可视化倍率设置": "Paramètres de ratio de modèle visuel", + "可视化倍率设置": "Ratio visuel", "可视化编辑": "Édition visuelle", "可选,公告的补充说明": "Facultatif, informations supplémentaires pour l'avis", "可选值": "Valeur facultative", @@ -697,7 +697,7 @@ "字段透传控制": "Contrôle du passage des champs", "存在重复的键名:": "Il existe des noms de clés en double :", "安全提醒": "Rappel de sécurité", - "安全设置": "Paramètres de sécurité", + "安全设置": "Sécurité", "安全验证": "Vérification de sécurité", "安全验证级别": "Niveau de vérification de la sécurité", "安装指南": "Guide d'installation", @@ -720,7 +720,7 @@ "密码修改成功!": "Mot de passe changé avec succès !", "密码已复制到剪贴板:": "Le mot de passe a été copié dans le presse-papiers : ", "密码已重置并已复制到剪贴板:": "Le mot de passe a été réinitialisé et copié dans le presse-papiers : ", - "密码管理": "Gestion des mots de passe", + "密码管理": "Mots de passe", "密码重置": "Réinitialisation du mot de passe", "密码重置完成": "Réinitialisation du mot de passe terminée", "密码重置确认": "Confirmation de la réinitialisation du mot de passe", @@ -762,8 +762,8 @@ "小时": "Heure", "尚未使用": "Pas encore utilisé", "局部重绘-提交": "Varier la région", - "屏蔽词列表": "Liste des mots sensibles", - "屏蔽词过滤设置": "Paramètres de filtrage des mots sensibles", + "屏蔽词列表": "Mots sensibles", + "屏蔽词过滤设置": "Filtrage mots sensibles", "展开": "Développer", "展开更多": "Développer plus", "展示价格": "Prix affiché", @@ -848,6 +848,9 @@ "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度": "Après activation, les modèles gratuits (ratio 0 ou prix 0) préconsommeront également du quota", "开启后,将定期发送ping数据保持连接活跃": "Après activation, des données ping seront envoyées périodiquement pour maintenir la connexion active", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Après activation, toutes les requêtes seront directement transmises en amont sans aucun traitement (la redirection et l'adaptation de canal seront également désactivées), veuillez activer avec prudence", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "La transmission des requêtes est activée pour ce canal. Les fonctionnalités intégrées de NewAPI (surcharge des paramètres, redirection de modèle, adaptation du canal, etc.) seront désactivées. Ce n'est pas une bonne pratique. Si cela cause des problèmes, merci de ne pas ouvrir d'issue.", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "La transmission globale des requêtes est activée. Les fonctionnalités intégrées de NewAPI (surcharge des paramètres, redirection de modèle, adaptation du canal, etc.) seront désactivées. Ce n'est pas une bonne pratique. Si cela cause des problèmes, merci de ne pas ouvrir d'issue.", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "La transmission des requêtes est activée pour ce canal ; les fonctionnalités intégrées de NewAPI (comme la surcharge des paramètres et la redirection de modèle) seront désactivées. Ce n'est pas une bonne pratique.", "开启后不限制:必须设置模型倍率": "Après l'activation, aucune limite : le ratio de modèle doit être défini", "开启后未登录用户无法访问模型广场": "Lorsqu'il est activé, les utilisateurs non authentifiés ne peuvent pas accéder à la place du marché des modèles", "开启批量操作": "Activer la sélection par lots", @@ -998,7 +1001,7 @@ "支付地址": "Adresse de paiement", "支付宝": "Alipay", "支付方式": "Mode de paiement", - "支付设置": "Paramètres de paiement", + "支付设置": "Paiement", "支付请求失败": "Échec de la demande de paiement", "支付金额": "Montant payé", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Prend en charge le code de vérification TOTP à 6 chiffres ou le code de sauvegarde à 8 chiffres, peut être configuré ou consulté dans `Paramètres personnels - Paramètres de sécurité - Paramètres d'authentification à deux facteurs`.", @@ -1028,9 +1031,9 @@ "数据格式错误": "Erreur de format de données", "数据看板": "Tableau de bord", "数据看板更新间隔": "Intervalle de mise à jour du tableau de bord des données", - "数据看板设置": "Paramètres du tableau de bord des données", + "数据看板设置": "Tableau de bord", "数据看板默认时间粒度": "Granularité temporelle par défaut du tableau de bord des données", - "数据管理和日志查看": "Gestion des données et affichage des journaux", + "数据管理和日志查看": "Données et journaux", "文件上传": "Téléchargement de fichier", "文件搜索价格:{{symbol}}{{price}} / 1K 次": "Prix de recherche de fichier : {{symbol}}{{price}} / 1K fois", "文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}": "Invite texte {{input}} tokens / 1M tokens * {{symbol}}{{price}} + Complétion texte {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}", @@ -1066,7 +1069,7 @@ "无限额度": "Quota illimité", "日志清理失败:": "Échec du nettoyage des journaux :", "日志类型": "Type de journal", - "日志设置": "Paramètres du journal", + "日志设置": "Config. journaux", "日志详情": "Détails du journal", "旧格式(直接覆盖):": "Ancien format (remplacement direct) :", "旧格式模板": "Modèle d'ancien format", @@ -1220,7 +1223,7 @@ "模型倍率值": "Valeur du ratio de modèle", "模型倍率和补全倍率": "Ratio de modèle et ratio de complétion", "模型倍率和补全倍率同时设置": "Le ratio de modèle et le ratio de complétion sont définis simultanément", - "模型倍率设置": "Paramètres de ratio de modèle", + "模型倍率设置": "Ratio modèle", "模型关键字": "mot-clé du modèle", "模型列表已复制到剪贴板": "Liste des modèles copiée dans le presse-papiers", "模型列表已更新": "La liste des modèles a été mise à jour", @@ -1230,7 +1233,7 @@ "模型固定价格": "Prix du modèle par appel", "模型图标": "Icône du modèle", "模型定价,需要登录访问": "Tarification du modèle, nécessite une connexion pour y accéder", - "模型广场": "Place du marché des modèles", + "模型广场": "Marché des modèles", "模型支持的接口端点信息": "Informations sur les points de terminaison de l'API pris en charge par le modèle", "模型数据分析": "Analyse des données du modèle", "模型映射必须是合法的 JSON 格式!": "Le mappage de modèles doit être au format JSON valide !", @@ -1242,7 +1245,7 @@ "模型的详细描述和基本特性": "Description détaillée et caractéristiques de base du modèle", "模型相关设置": "Paramètres liés au modèle", "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "La communauté des modèles a besoin de la contribution de tous. Si vous trouvez des données incorrectes ou si vous souhaitez contribuer à de nouvelles données de modèle, veuillez visiter :", - "模型管理": "Gestion des modèles", + "模型管理": "Modèles", "模型组": "Groupe de modèles", "模型补全倍率(仅对自定义模型有效)": "Ratio d'achèvement de modèle (uniquement efficace pour les modèles personnalisés)", "模型请求速率限制": "Limite de débit de requête de modèle", @@ -1368,7 +1371,7 @@ "渠道的基本配置信息": "Informations de configuration de base du canal", "渠道的模型测试": "Test de modèle de canal", "渠道的高级配置选项": "Options de configuration avancées du canal", - "渠道管理": "Gestion des canaux", + "渠道管理": "Canaux", "渠道额外设置": "Paramètres supplémentaires du canal", "源地址": "Adresse source", "演示站点": "Site de démonstration", @@ -1411,7 +1414,7 @@ "用户信息": "Informations utilisateur", "用户信息更新成功!": "Informations utilisateur mises à jour avec succès !", "用户分组": "Votre groupe par défaut", - "用户分组和额度管理": "Gestion des groupes d'utilisateurs et des quotas", + "用户分组和额度管理": "Groupes et quotas", "用户分组配置": "Configuration du groupe d'utilisateurs", "用户协议": "Accord utilisateur", "用户协议已更新": "L'accord utilisateur a été mis à jour", @@ -1426,10 +1429,10 @@ "用户每周期最多请求次数": "Nombre maximal de requêtes utilisateur par période", "用户注册时看到的网站名称,比如'我的网站'": "Nom du site Web que les utilisateurs voient lors de l'inscription, par exemple 'Mon site Web'", "用户的基本账户信息": "Informations de base du compte utilisateur", - "用户管理": "Gestion des utilisateurs", + "用户管理": "Utilisateurs", "用户组": "Groupe d'utilisateurs", "用户账户创建成功!": "Compte utilisateur créé avec succès !", - "用户账户管理": "Gestion des comptes utilisateurs", + "用户账户管理": "Comptes utilisateurs", "用时/首字": "Temps/premier mot", "留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée", "留空则使用默认端点;支持 {path, method}": "Laissez vide pour utiliser le point de terminaison par défaut ; prend en charge {path, method}", @@ -1440,7 +1443,7 @@ "登录过期,请重新登录!": "Session expirée, veuillez vous reconnecter !", "白名单": "Liste blanche", "的前提下使用。": "doit être utilisé conformément aux conditions.", - "监控设置": "Paramètres de surveillance", + "监控设置": "Surveillance", "目标用户:{{username}}": "Utilisateur cible : {{username}}", "直接提交": "Soumettre directement", "相关项目": "Projets connexes", @@ -1554,14 +1557,14 @@ "精确": "Exact", "系统": "Système", "系统令牌已复制到剪切板": "Le jeton système a été copié dans le presse-papiers", - "系统任务记录": "Enregistrements de tâches système", + "系统任务记录": "Tâches système", "系统信息": "Informations système", "系统公告": "Avis système", - "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "Gestion des avis système, vous pouvez publier des avis système et des messages importants (maximum 100, afficher les 20 derniers sur le front-end)", + "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "Avis système, vous pouvez publier des avis système et des messages importants (maximum 100, afficher les 20 derniers sur le front-end)", "系统初始化": "Initialisation du système", "系统初始化失败,请重试": "L'initialisation du système a échoué, veuillez réessayer", "系统初始化成功,正在跳转...": "Initialisation du système réussie, redirection en cours...", - "系统参数配置": "Configuration des paramètres système", + "系统参数配置": "Paramètres système", "系统名称": "Nom du système", "系统名称已更新": "Nom du système mis à jour", "系统名称更新失败": "Échec de la mise à jour du nom du système", @@ -1572,7 +1575,7 @@ "系统文档和帮助信息": "Documentation système et informations d'aide", "系统消息": "Messages système", "系统管理功能": "Fonctions de gestion du système", - "系统设置": "Paramètres système", + "系统设置": "Système", "系统访问令牌": "Jeton d'accès au système", "约": "Environ", "索引": "Index", @@ -1591,9 +1594,9 @@ "结束时间": "Heure de fin", "结果图片": "Résultat", "绘图": "Dessin", - "绘图任务记录": "Enregistrements de tâches de dessin", - "绘图日志": "Journaux de dessin", - "绘图设置": "Paramètres de dessin", + "绘图任务记录": "Tâches dessin", + "绘图日志": "Dessins", + "绘图设置": "Dessin", "统一的": "La Passerelle", "统计Tokens": "Jetons statistiques", "统计次数": "Nombre de statistiques", @@ -1640,11 +1643,11 @@ "置信度": "Confiance", "美元": "Dollar américain", "聊天": "Discuter", - "聊天会话管理": "Gestion des sessions de discussion", + "聊天会话管理": "Sessions de discussion", "聊天区域": "Zone de discussion", "聊天应用名称": "Nom de l'application de discussion", "聊天应用名称已存在,请使用其他名称": "Le nom de l'application de discussion existe déjà, veuillez utiliser un autre nom", - "聊天设置": "Paramètres de discussion", + "聊天设置": "Discussion", "聊天配置": "Configuration de la discussion", "聊天链接配置错误,请联系管理员": "Erreur de configuration du lien de discussion, veuillez contacter l'administrateur", "联系我们": "Contactez-nous", @@ -1988,19 +1991,19 @@ "输出价格": "Prix de sortie", "输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})": "Prix de sortie : {{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (ratio d'achèvement : {{completionRatio}})", "输出倍率 {{completionRatio}}": "Ratio de sortie {{completionRatio}}", - "边栏设置": "Paramètres de la barre latérale", + "边栏设置": "Barre latérale", "过期时间": "Date d'expiration", "过期时间不能早于当前时间!": "La date d'expiration ne peut pas être antérieure à l'heure actuelle !", "过期时间快捷设置": "Paramètres rapides de la date d'expiration", "过期时间格式错误!": "Erreur de format de la date d'expiration !", - "运营设置": "Paramètres de fonctionnement", + "运营设置": "Opérations", "返回修改": "Revenir pour modifier", "返回登录": "Retour à la connexion", "这是重复键中的最后一个,其值将被使用": "Ceci est la dernière clé dupliquée, sa valeur sera utilisée", "进度": "calendrier", "进行中": "En cours", "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "Lors de cette opération, cela peut entraîner des erreurs d'accès au canal. Veuillez ne l'utiliser que lorsqu'il y a un problème avec la base de données.", - "连接保活设置": "Paramètres de maintien de connexion", + "连接保活设置": "Maintien connexion", "连接已断开": "Connexion interrompue", "追加到现有密钥": "Ajouter aux clés existantes", "追加模式:将新密钥添加到现有密钥列表末尾": "Mode d'ajout : ajouter les nouvelles clés à la fin de la liste de clés existantes", @@ -2032,7 +2035,7 @@ "选择过期时间(可选,留空为永久)": "Sélectionnez la date d'expiration (facultatif, laissez vide pour permanent)", "透传请求体": "Corps de transmission", "通义千问": "Qwen", - "通用设置": "Paramètres généraux", + "通用设置": "Général", "通知": "Avis", "通知、价格和隐私相关设置": "Paramètres de notification, de prix et de confidentialité", "通知内容": "Contenu de la notification", @@ -2041,13 +2044,13 @@ "通知标题": "Titre de la notification", "通知类型 (quota_exceed: 额度预警)": "Type de notification (quota_exceed : avertissement de quota)", "通知邮箱": "E-mail de notification", - "通知配置": "Configuration des notifications", + "通知配置": "Notifications", "通过划转功能将奖励额度转入到您的账户余额中": "Transférez le montant de la récompense sur le solde de votre compte via la fonction de virement", "通过密码注册时需要进行邮箱验证": "La vérification par e-mail est requise lors de l'inscription via mot de passe", "通道 ${name} 余额更新成功!": "Le quota du canal ${name} a été mis à jour avec succès !", "通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "Test du canal ${name} réussi, modèle ${model} a pris ${time.toFixed(2)} secondes.", "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Test du canal ${name} réussi, a pris ${time.toFixed(2)} secondes.", - "速率限制设置": "Paramètres de limitation de débit", + "速率限制设置": "Limitation débit", "邀请": "Invitations", "邀请人": "Inviteur", "邀请人数": "Nombre de personnes invitées", @@ -2109,7 +2112,7 @@ "重置邮件发送成功,请检查邮箱!": "L'e-mail de réinitialisation a été envoyé avec succès, veuillez vérifier votre e-mail !", "重置配置": "Réinitialiser la configuration", "重试": "Réessayer", - "钱包管理": "Gestion du portefeuille", + "钱包管理": "Portefeuille", "链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "Le {key} dans le lien sera automatiquement remplacé par sk-xxxx, le {address} sera automatiquement remplacé par l'adresse du serveur dans les paramètres système, et la fin n'aura pas / et /v1", "错误": "Erreur", "键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1": "La clé est le nom du groupe, la valeur est un autre objet JSON, la clé est le nom du groupe, la valeur est le ratio de groupe spécial des utilisateurs de ce groupe, par exemple : {\"vip\": {\"default\": 0.5, \"test\": 1}}, ce qui signifie que les utilisateurs du groupe vip ont un ratio de 0.5 lors de l'utilisation de jetons du groupe default et un ratio de 1 lors de l'utilisation du groupe test", @@ -2127,7 +2130,7 @@ "隐私政策": "Politique de confidentialité", "隐私政策已更新": "La politique de confidentialité a été mise à jour", "隐私政策更新失败": "Échec de la mise à jour de la politique de confidentialité", - "隐私设置": "Paramètres de confidentialité", + "隐私设置": "Confidentialité", "隐藏操作项": "Masquer les actions", "隐藏调试": "Masquer le débogage", "随机": "Aléatoire", @@ -2148,7 +2151,7 @@ "音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Paramètres de ratio liés à l'achèvement de la sortie audio, la clé est le nom du modèle, la valeur est le ratio", "页脚": "Pied de page", "页面未找到,请检查您的浏览器地址是否正确": "Page non trouvée, veuillez vérifier si l'adresse de votre navigateur est correcte", - "顶栏管理": "Gestion de l'en-tête", + "顶栏管理": "En-tête", "项目": "Élément", "项目内容": "Contenu de l'élément", "项目操作按钮组": "Groupe de boutons d'action du projet", @@ -2163,7 +2166,7 @@ "额度必须大于0": "Le quota doit être supérieur à 0", "额度提醒阈值": "Seuil de rappel de quota", "额度查询接口返回令牌额度而非用户额度": "Affiche le quota de jetons au lieu du quota utilisateur", - "额度设置": "Paramètres de quota", + "额度设置": "Quota", "额度预警阈值": "Seuil d'avertissement de quota", "首尾生视频": "Vidéo de début et de fin", "首页": "Accueil", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 2b3ea9f02..22e7606dd 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -796,6 +796,9 @@ "开启后,仅\"消费\"和\"错误\"日志将记录您的客户端IP地址": "有効にすると、「消費」と「エラー」のログにのみ、クライアントIPアドレスが記録されます", "开启后,将定期发送ping数据保持连接活跃": "有効にすると、接続をアクティブに保つためにpingデータが定期的に送信されます", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "有効にすると、すべてのリクエストは直接アップストリームにパススルーされ、いかなる処理も行われません(リダイレクトとチャネルの自動調整も無効になります)。有効にする際はご注意ください", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "このチャネルではリクエストのパススルーが有効です。パラメータ上書き、モデルリダイレクト、チャネル適応などの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。これにより問題が発生しても issue を投稿しないでください。", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "全体のリクエストパススルーが有効です。パラメータ上書き、モデルリダイレクト、チャネル適応などの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。これにより問題が発生しても issue を投稿しないでください。", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "このチャネルではリクエストのパススルーが有効です。パラメータ上書きやモデルリダイレクトなどの NewAPI 内蔵機能は無効になります。ベストプラクティスではありません。", "开启后不限制:必须设置模型倍率": "有効化後は制限なし:モデル倍率の設定が必須", "开启后未登录用户无法访问模型广场": "有効にすると、ログインしていないユーザーはモデルマーケットプレイスにアクセスできなくなります", "开启批量操作": "一括操作を有効にする", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 76616cbdb..d71e12d1b 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -857,6 +857,9 @@ "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度": "После включения бесплатные модели (коэффициент 0 или цена 0) тоже будут предварительно расходовать квоту", "开启后,将定期发送ping数据保持连接活跃": "После включения будет периодически отправляться ping-данные для поддержания активности соединения", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "После включения все запросы будут напрямую передаваться upstream без какой-либо обработки (перенаправление и адаптация каналов также будут отключены), включайте с осторожностью", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Для этого канала включена сквозная передача запросов. Встроенные возможности NewAPI, такие как переопределение параметров, перенаправление моделей и адаптация канала, будут отключены. Это не является лучшей практикой. Если из-за этого возникнут проблемы, пожалуйста, не создавайте issue.", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Глобальная сквозная передача запросов включена. Встроенные возможности NewAPI, такие как переопределение параметров, перенаправление моделей и адаптация канала, будут отключены. Это не является лучшей практикой. Если из-за этого возникнут проблемы, пожалуйста, не создавайте issue.", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Для этого канала включена сквозная передача запросов; встроенные функции NewAPI, такие как переопределение параметров и перенаправление моделей, будут отключены. Это не является лучшей практикой.", "开启后不限制:必须设置模型倍率": "После включения без ограничений: необходимо установить множители моделей", "开启后未登录用户无法访问模型广场": "После включения незарегистрированные пользователи не смогут получить доступ к площади моделей", "开启批量操作": "Включить пакетные операции", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 556501da2..51113ff44 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -796,6 +796,9 @@ "开启后,仅\"消费\"和\"错误\"日志将记录您的客户端IP地址": "Sau khi bật, chỉ nhật ký \"tiêu thụ\" và \"lỗi\" sẽ ghi lại địa chỉ IP máy khách của bạn", "开启后,将定期发送ping数据保持连接活跃": "Sau khi bật, dữ liệu ping sẽ được gửi định kỳ để giữ kết nối hoạt động", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Khi bật, tất cả các yêu cầu sẽ được chuyển tiếp trực tiếp đến thượng nguồn mà không cần xử lý (chuyển hướng và thích ứng kênh cũng sẽ bị vô hiệu hóa). Vui lòng bật một cách thận trọng.", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Kênh này đã bật truyền qua yêu cầu. Các tính năng tích hợp của NewAPI như ghi đè tham số, chuyển hướng mô hình và thích ứng kênh sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất. Nếu phát sinh vấn đề, vui lòng không gửi issue.", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "Đã bật truyền qua yêu cầu toàn cục. Các tính năng tích hợp của NewAPI như ghi đè tham số, chuyển hướng mô hình và thích ứng kênh sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất. Nếu phát sinh vấn đề, vui lòng không gửi issue.", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "Kênh này đã bật truyền qua yêu cầu; các tính năng tích hợp của NewAPI như ghi đè tham số và chuyển hướng mô hình sẽ bị vô hiệu hóa. Đây không phải là thực hành tốt nhất.", "开启后不限制:必须设置模型倍率": "Sau khi bật, không giới hạn: phải đặt tỷ lệ mô hình", "开启后未登录用户无法访问模型广场": "Khi bật, người dùng chưa xác thực không thể truy cập thị trường mô hình", "开启批量操作": "Bật chọn hàng loạt", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a8d28acca..35ec62ba1 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -830,6 +830,9 @@ "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度": "开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度", "开启后,将定期发送ping数据保持连接活跃": "开启后,将定期发送ping数据保持连接活跃", "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启", + "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "该渠道已开启请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。", + "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。": "已开启全局请求透传:参数覆写、模型重定向、渠道适配等 NewAPI 内置功能将失效,非最佳实践;如因此产生问题,请勿提交 issue 反馈。", + "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。": "该渠道已开启请求透传,参数覆写、模型重定向等 NewAPI 内置功能将失效,非最佳实践。", "开启后不限制:必须设置模型倍率": "开启后不限制:必须设置模型倍率", "开启后未登录用户无法访问模型广场": "开启后未登录用户无法访问模型广场", "开启批量操作": "开启批量操作", 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: (