diff --git a/agent/app/dto/agents.go b/agent/app/dto/agents.go index 7d3e19a5a..fb60daecc 100644 --- a/agent/app/dto/agents.go +++ b/agent/app/dto/agents.go @@ -8,15 +8,9 @@ type AgentCreateReq struct { WebUIPort int `json:"webUIPort" validate:"required"` BridgePort int `json:"bridgePort"` AllowedOrigins []string `json:"allowedOrigins"` - AgentType string `json:"agentType"` - Provider string `json:"provider"` + AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw"` Model string `json:"model"` - APIType string `json:"apiType"` - MaxTokens int `json:"maxTokens"` - ContextWindow int `json:"contextWindow"` AccountID uint `json:"accountId"` - APIKey string `json:"apiKey"` - BaseURL string `json:"baseURL"` Token string `json:"token"` TaskID string `json:"taskID"` Advanced bool `json:"advanced"` @@ -110,27 +104,20 @@ type AgentAccountCreateReq struct { APIKey string `json:"apiKey" validate:"required"` RememberAPIKey bool `json:"rememberApiKey"` BaseURL string `json:"baseURL"` - Model string `json:"model"` Models []AgentAccountModel `json:"models"` APIType string `json:"apiType"` - MaxTokens int `json:"maxTokens"` - ContextWindow int `json:"contextWindow"` Remark string `json:"remark"` } type AgentAccountUpdateReq struct { - ID uint `json:"id" validate:"required"` - Name string `json:"name" validate:"required"` - APIKey string `json:"apiKey" validate:"required"` - RememberAPIKey bool `json:"rememberApiKey"` - BaseURL string `json:"baseURL"` - Model string `json:"model"` - Models []AgentAccountModel `json:"models"` - APIType string `json:"apiType"` - MaxTokens int `json:"maxTokens"` - ContextWindow int `json:"contextWindow"` - Remark string `json:"remark"` - SyncAgents bool `json:"syncAgents"` + ID uint `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` + APIKey string `json:"apiKey" validate:"required"` + RememberAPIKey bool `json:"rememberApiKey"` + BaseURL string `json:"baseURL"` + APIType string `json:"apiType"` + Remark string `json:"remark"` + SyncAgents bool `json:"syncAgents"` } type AgentAccountVerifyReq struct { @@ -157,11 +144,8 @@ type AgentAccountInfo struct { APIKey string `json:"apiKey"` RememberAPIKey bool `json:"rememberApiKey"` BaseURL string `json:"baseUrl"` - Model string `json:"model"` Models []AgentAccountModel `json:"models"` APIType string `json:"apiType"` - MaxTokens int `json:"maxTokens"` - ContextWindow int `json:"contextWindow"` Verified bool `json:"verified"` Remark string `json:"remark"` CreatedAt time.Time `json:"createdAt"` diff --git a/agent/app/model/agent_account.go b/agent/app/model/agent_account.go index e2d9dd5e1..463e76edd 100644 --- a/agent/app/model/agent_account.go +++ b/agent/app/model/agent_account.go @@ -6,11 +6,7 @@ type AgentAccount struct { Name string `json:"name"` APIKey string `json:"apiKey"` BaseURL string `json:"baseUrl"` - Model string `json:"model"` - Models string `json:"models" gorm:"type:text"` APIType string `json:"apiType"` - MaxTokens int `json:"maxTokens"` - ContextWindow int `json:"contextWindow"` RememberAPIKey bool `json:"rememberApiKey"` Verified bool `json:"verified"` Remark string `json:"remark"` diff --git a/agent/app/provider/catalog.go b/agent/app/provider/catalog.go index 9f1d729e2..3358a6906 100644 --- a/agent/app/provider/catalog.go +++ b/agent/app/provider/catalog.go @@ -20,7 +20,6 @@ type Meta struct { DefaultBaseURL string EnvKey string Models []Model - Enabled bool } var catalog = map[string]Meta{ @@ -30,14 +29,12 @@ var catalog = map[string]Meta{ Sort: 10, DefaultBaseURL: "", EnvKey: "CUSTOM_API_KEY", - Enabled: true, Models: []Model{}, }, "ollama": { Key: "ollama", DisplayName: "Ollama", Sort: 15, - Enabled: true, }, "vllm": { Key: "vllm", @@ -45,7 +42,6 @@ var catalog = map[string]Meta{ Sort: 20, DefaultBaseURL: "", EnvKey: "VLLM_API_KEY", - Enabled: true, Models: []Model{}, }, "deepseek": { @@ -54,7 +50,6 @@ var catalog = map[string]Meta{ Sort: 25, DefaultBaseURL: "https://api.deepseek.com/v1", EnvKey: "DEEPSEEK_API_KEY", - Enabled: true, Models: []Model{ {ID: "deepseek/deepseek-chat", Name: "DeepSeek Chat"}, {ID: "deepseek/deepseek-reasoner", Name: "DeepSeek Reasoner"}, @@ -67,7 +62,6 @@ var catalog = map[string]Meta{ Sort: 30, DefaultBaseURL: "https://coding.dashscope.aliyuncs.com/v1", EnvKey: "QWEN_API_KEY", - Enabled: true, Models: []Model{ {ID: "bailian-coding-plan/qwen3.5-plus", Name: "Qwen3.5-Plus"}, {ID: "bailian-coding-plan/qwen3-max", Name: "Qwen3-Max"}, @@ -85,7 +79,6 @@ var catalog = map[string]Meta{ Sort: 35, DefaultBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3", EnvKey: "ARK_API_KEY", - Enabled: true, Models: []Model{ {ID: "ark-coding-plan/doubao-seed-2.0-code", Name: "Doubao-Seed-2.0-Code"}, {ID: "ark-coding-plan/doubao-seed-code", Name: "Doubao-Seed-Code"}, @@ -101,7 +94,6 @@ var catalog = map[string]Meta{ Sort: 40, DefaultBaseURL: "https://open.bigmodel.cn/api/paas/v4", EnvKey: "ZAI_API_KEY", - Enabled: true, Models: []Model{ {ID: "zai/glm-5", Name: "GLM-5"}, {ID: "zai/glm-4.7", Name: "GLM-4.7"}, @@ -115,7 +107,6 @@ var catalog = map[string]Meta{ Sort: 45, DefaultBaseURL: "https://api.minimaxi.com/anthropic", EnvKey: "MINIMAX_API_KEY", - Enabled: true, Models: []Model{ {ID: "minimax/MiniMax-M2.5", Name: "MiniMax M2.5"}, {ID: "minimax/MiniMax-M2.5-highspeed", Name: "MiniMax M2.5 highspeed"}, @@ -127,7 +118,6 @@ var catalog = map[string]Meta{ Sort: 50, DefaultBaseURL: "https://api.moonshot.cn/v1", EnvKey: "KIMI_API_KEY", - Enabled: true, Models: []Model{ {ID: "kimi/kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, @@ -140,7 +130,6 @@ var catalog = map[string]Meta{ Sort: 51, DefaultBaseURL: "https://api.kimi.com/coding/", EnvKey: "KIMI_API_KEY", - Enabled: true, Models: []Model{ {ID: "kimi-coding/k2p5", Name: "Kimi K2.5"}, }, @@ -151,7 +140,6 @@ var catalog = map[string]Meta{ Sort: 55, DefaultBaseURL: "https://api.openai.com/v1", EnvKey: "OPENAI_API_KEY", - Enabled: true, Models: []Model{ {ID: "openai/codex-mini-latest", Name: "Codex Mini"}, {ID: "openai/gpt-4.1", Name: "GPT-4.1"}, @@ -167,7 +155,6 @@ var catalog = map[string]Meta{ Sort: 56, DefaultBaseURL: "https://openrouter.ai/api/v1", EnvKey: "OPENROUTER_API_KEY", - Enabled: true, Models: []Model{ {ID: "openrouter/free", Name: "openrouter/free"}, {ID: "openrouter/auto", Name: "openrouter/auto"}, @@ -179,7 +166,6 @@ var catalog = map[string]Meta{ Sort: 60, DefaultBaseURL: "https://api.anthropic.com", EnvKey: "ANTHROPIC_API_KEY", - Enabled: true, Models: []Model{ {ID: "anthropic/claude-3-haiku-20240307", Name: "Claude 3 Haiku"}, {ID: "anthropic/claude-3-5-haiku-latest", Name: "Claude 3.5 Haiku"}, @@ -194,7 +180,6 @@ var catalog = map[string]Meta{ Sort: 65, DefaultBaseURL: "https://generativelanguage.googleapis.com", EnvKey: "GEMINI_API_KEY", - Enabled: true, Models: []Model{ {ID: "google/gemini-3-flash-preview", Name: "Gemini 3 Flash Preview"}, {ID: "google/gemini-flash-latest", Name: "Gemini Flash Latest"}, @@ -207,7 +192,6 @@ var catalog = map[string]Meta{ Sort: 70, DefaultBaseURL: "https://api.moonshot.ai/v1", EnvKey: "MOONSHOT_API_KEY", - Enabled: true, Models: []Model{ {ID: "moonshot/kimi-k2.5", Name: "Kimi K2.5"}, {ID: "moonshot/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, @@ -232,11 +216,6 @@ func All() map[string]Meta { return result } -func IsEnabled(key string) bool { - meta, ok := catalog[strings.ToLower(strings.TrimSpace(key))] - return ok && meta.Enabled -} - func DefaultBaseURL(key string) (string, bool) { meta, ok := catalog[strings.ToLower(strings.TrimSpace(key))] if !ok || strings.TrimSpace(meta.DefaultBaseURL) == "" { diff --git a/agent/app/provider/verify.go b/agent/app/provider/verify.go index fa4bf3cb2..863ca1e95 100644 --- a/agent/app/provider/verify.go +++ b/agent/app/provider/verify.go @@ -18,6 +18,10 @@ type VerifyRequest struct { Body []byte } +const ( + defaultVerifyTimeout = 30 * time.Second +) + func SkipVerification(key string) bool { switch strings.ToLower(strings.TrimSpace(key)) { case "custom", "vllm", "ollama", "kimi-coding": @@ -42,7 +46,7 @@ func VerifyAccount(provider, baseURL, apiKey string) error { for key, value := range req.Headers { httpReq.Header.Set(key, value) } - resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(httpReq) + resp, err := (&http.Client{Timeout: verifyTimeout()}).Do(httpReq) if err != nil { return buserr.WithErr("ErrAgentAccountUnavailable", err) } @@ -53,6 +57,10 @@ func VerifyAccount(provider, baseURL, apiKey string) error { return nil } +func verifyTimeout() time.Duration { + return defaultVerifyTimeout +} + func BuildVerifyRequest(provider, baseURL, apiKey string) VerifyRequest { provider = strings.ToLower(strings.TrimSpace(provider)) base := strings.TrimRight(strings.TrimSpace(baseURL), "/") diff --git a/agent/app/service/agents.go b/agent/app/service/agents.go index 90b66b38c..12391dd6d 100644 --- a/agent/app/service/agents.go +++ b/agent/app/service/agents.go @@ -2,38 +2,26 @@ package service import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" - "net/http" - "net/url" "path" "sort" - "strconv" "strings" "time" "github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/model" - providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider" "github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/utils/cmd" - "github.com/1Panel-dev/1Panel/agent/utils/common" - "github.com/1Panel-dev/1Panel/agent/utils/files" - openclawutil "github.com/1Panel-dev/1Panel/agent/utils/openclaw" - "github.com/1Panel-dev/1Panel/agent/utils/req_helper" "github.com/1Panel-dev/1Panel/agent/utils/xpack" "gorm.io/gorm" ) -type AgentService struct{} - type IAgentService interface { Create(req dto.AgentCreateReq) (*dto.AgentItem, error) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, error) @@ -43,7 +31,7 @@ type IAgentService interface { GetProviders() ([]dto.ProviderInfo, error) CreateAccount(req dto.AgentAccountCreateReq) error UpdateAccount(req dto.AgentAccountUpdateReq) error - SyncAgentsByAccountID(accountID uint) error + SyncAgentsByAccount(account *model.AgentAccount) error PageAccounts(req dto.AgentAccountSearch) (int64, []dto.AgentAccountInfo, error) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error) CreateAccountModel(req dto.AgentAccountModelCreateReq) error @@ -70,10 +58,6 @@ type IAgentService interface { ApproveChannelPairing(req dto.AgentChannelPairingApproveReq) error } -func NewIAgentService() IAgentService { - return &AgentService{} -} - const ( defaultBrowserExecutablePath = "/home/node/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome" defaultBrowserProfile = "openclaw" @@ -89,10 +73,7 @@ const ( ) func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { - agentType := normalizeAgentType(req.AgentType) - if !isSupportedAgentType(agentType) { - return nil, fmt.Errorf("agent type is invalid") - } + agentType := req.AgentType if err := checkPortExist(req.WebUIPort); err != nil { return nil, err } @@ -111,11 +92,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { return nil, buserr.WithMap("ErrAgentLimitReached", map[string]interface{}{"max": maxCommunityAIAgents}, nil) } } - appKey := constant.AppOpenclaw - if agentType == constant.AppCopaw { - appKey = constant.AppCopaw - } - app, err := appRepo.GetFirst(appRepo.WithKey(appKey)) + app, err := appRepo.GetFirst(appRepo.WithKey(agentType)) if err != nil || app.ID == 0 { return nil, buserr.New("ErrRecordNotFound") } @@ -136,6 +113,8 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { configPath := "" storedModel := "" var allowedOrigins []string + var account *model.AgentAccount + var installHooks *appInstallHooks if agentType == constant.AppOpenclaw { var err error @@ -146,71 +125,38 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { if len(allowedOrigins) == 0 { return nil, fmt.Errorf("allowed origins is required") } - provider = strings.ToLower(strings.TrimSpace(req.Provider)) - if !isSupportedAgentProvider(provider) { - return nil, buserr.New("ErrAgentProviderNotSupported") - } if req.AccountID == 0 { return nil, buserr.New("ErrAgentAccountRequired") } - account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID)) + account, err = agentAccountRepo.GetFirst(repo.WithByID(req.AccountID)) if err != nil { return nil, err } - if !account.Verified && !providercatalog.SkipVerification(account.Provider) { + if !account.Verified { return nil, buserr.New("ErrAgentAccountNotVerified") } - if account.Provider != "" && provider != "" && account.Provider != provider { - return nil, buserr.New("ErrAgentProviderMismatch") - } - provider = strings.ToLower(strings.TrimSpace(account.Provider)) + provider = account.Provider baseURL = strings.TrimSpace(account.BaseURL) - if baseURL == "" { - if defaultURL, ok := providerDefaultBaseURL(provider); ok { - baseURL = defaultURL - } - } - if provider == "ollama" && baseURL == "" { - return nil, buserr.New("ErrAgentBaseURLRequired") - } - if provider != "ollama" && strings.TrimSpace(account.APIKey) == "" { - return nil, buserr.New("ErrAgentApiKeyRequired") - } - accountModels, err := loadAgentAccountModels(account) - if err != nil { - return nil, err - } - storedModel = strings.TrimSpace(req.Model) - if storedModel == "" { - storedModel = strings.TrimSpace(account.Model) - } - if storedModel == "" && len(accountModels) > 0 { - storedModel = strings.TrimSpace(accountModels[0].ID) - } - if storedModel == "" { - return nil, buserr.New("ErrAgentModelNotInAccount") - } - selectedAccountModel, ok := findAgentAccountModelForProvider(provider, accountModels, storedModel) - if !ok { - return nil, buserr.New("ErrAgentModelNotInAccount") - } - storedModel = strings.TrimSpace(selectedAccountModel.ID) - apiType, maxTokens, contextWindow = resolveRuntimeParams( - provider, - account.APIType, - selectedAccountModel.MaxTokens, - selectedAccountModel.ContextWindow, - ) - runtimeModel, err = buildOpenclawPrimaryModel(account, storedModel) + resolvedRuntime, err := resolveOpenclawAccountModelRuntimeByID(account, req.Model) if err != nil { return nil, err } + storedModel = resolvedRuntime.StoredModel + apiType = resolvedRuntime.APIType + maxTokens = resolvedRuntime.MaxTokens + contextWindow = resolvedRuntime.ContextWindow + runtimeModel = resolvedRuntime.PrimaryModel apiKey = account.APIKey accountID = account.ID token = strings.TrimSpace(req.Token) if token == "" { token = generateToken() } + installHooks = &appInstallHooks{ + AfterCopyData: func(appInstall *model.AppInstall) error { + return prepareOpenclawInstallFiles(appInstall, account, storedModel, token, allowedOrigins) + }, + } } params := map[string]interface{}{ @@ -257,7 +203,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { DockerCompose: req.DockerCompose, }, } - appInstall, err := NewIAppService().Install(installReq, false) + appInstall, err := AppService{}.installWithHooks(installReq, false, installHooks) if err != nil { return nil, err } @@ -284,16 +230,6 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { if err := agentRepo.Create(agent); err != nil { return nil, err } - if agentType == constant.AppOpenclaw { - go a.writeConfigWithRetry( - appInstall, - accountID, - storedModel, - token, - agent.ID, - allowedOrigins, - ) - } item := buildAgentItem(agent, appInstall, nil) return &item, nil @@ -320,9 +256,6 @@ func (a AgentService) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, erro } func (a AgentService) Delete(req dto.AgentDeleteReq) error { - if req.ID == 0 { - return buserr.New("ErrAgentIDRequired") - } agent, err := agentRepo.GetFirst(repo.WithByID(req.ID)) if err != nil { return err @@ -339,7 +272,6 @@ func (a AgentService) Delete(req dto.AgentDeleteReq) error { if err := NewIAppInstalledService().Operate(operate); err != nil { return err } - go a.waitAndDeleteAgent(agent.ID, agent.AppInstallID) return nil } @@ -348,21 +280,10 @@ func (a AgentService) ResetToken(req dto.AgentTokenResetReq) error { if err != nil { return err } - if normalizeAgentType(agent.AgentType) == constant.AppCopaw { + if agent.AgentType == constant.AppCopaw { return fmt.Errorf("copaw does not support token") } - configPath := strings.TrimSpace(agent.ConfigPath) - if configPath == "" && agent.AppInstallID > 0 { - install, err := appInstallRepo.GetFirst(repo.WithByID(agent.AppInstallID)) - if err != nil { - return err - } - configPath = path.Join(install.GetPath(), "data", "conf", "openclaw.json") - } - if configPath == "" { - return buserr.New("ErrRecordNotFound") - } - conf, err := readOpenclawConfig(configPath) + conf, err := readOpenclawConfig(agent.ConfigPath) if err != nil { return err } @@ -376,13 +297,10 @@ func (a AgentService) ResetToken(req dto.AgentTokenResetReq) error { authMap["mode"] = "token" } authMap["token"] = newToken - if err := writeOpenclawConfigRaw(configPath, conf); err != nil { + if err := writeOpenclawConfigRaw(agent.ConfigPath, conf); err != nil { return err } agent.Token = newToken - if agent.ConfigPath == "" { - agent.ConfigPath = configPath - } return agentRepo.Save(agent) } @@ -391,75 +309,29 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error if err != nil { return err } - if normalizeAgentType(agent.AgentType) == constant.AppCopaw { + if agent.AgentType == constant.AppCopaw { return fmt.Errorf("copaw does not support model config") } account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID)) if err != nil { return err } - if !account.Verified && !providercatalog.SkipVerification(account.Provider) { - return buserr.New("ErrAgentAccountNotVerified") - } - provider := strings.ToLower(strings.TrimSpace(account.Provider)) - if !isSupportedAgentProvider(provider) { - return buserr.New("ErrAgentProviderNotSupported") - } - modelName := strings.TrimSpace(req.Model) - if modelName == "" { - return buserr.New("ErrAgentProviderMismatch") - } - if provider != "custom" && provider != "vllm" && !modelMatchesProvider(provider, modelName) { - return buserr.New("ErrAgentProviderMismatch") - } - baseURL := strings.TrimSpace(account.BaseURL) - if baseURL == "" { - if defaultURL, ok := providerDefaultBaseURL(provider); ok { - baseURL = defaultURL - } - } - if provider == "ollama" && baseURL == "" { - return buserr.New("ErrAgentBaseURLRequired") - } - if provider != "ollama" && strings.TrimSpace(account.APIKey) == "" { - return buserr.New("ErrAgentApiKeyRequired") - } - accountModels, err := loadAgentAccountModels(account) + resolvedRuntime, err := resolveOpenclawAccountModelRuntimeByID(account, req.Model) if err != nil { return err } - selectedAccountModel, ok := findAgentAccountModelForProvider(provider, accountModels, modelName) - if !ok { - return buserr.New("ErrAgentModelNotInAccount") - } - modelName = strings.TrimSpace(selectedAccountModel.ID) - apiType, maxTokens, contextWindow := resolveRuntimeParams( - provider, - account.APIType, - selectedAccountModel.MaxTokens, - selectedAccountModel.ContextWindow, - ) - confDir := "" - if agent.ConfigPath != "" { - confDir = path.Dir(agent.ConfigPath) - } else if agent.AppInstallID > 0 { - install, errGet := appInstallRepo.GetFirst(repo.WithByID(agent.AppInstallID)) - if errGet == nil { - confDir = path.Join(install.GetPath(), "data", "conf") - } - } - if confDir == "" { - return buserr.New("ErrRecordNotFound") - } + modelName := resolvedRuntime.StoredModel + apiType, maxTokens, contextWindow := resolvedRuntime.APIType, resolvedRuntime.MaxTokens, resolvedRuntime.ContextWindow + confDir := path.Dir(agent.ConfigPath) if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil); err != nil { return err } - agent.Provider = provider + agent.Provider = account.Provider agent.Model = modelName agent.APIType = apiType agent.MaxTokens = maxTokens agent.ContextWindow = contextWindow - agent.BaseURL = baseURL + agent.BaseURL = account.BaseURL agent.APIKey = account.APIKey agent.AccountID = account.ID return agentRepo.Save(agent) @@ -484,80 +356,38 @@ func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) { } func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error { - provider := strings.ToLower(strings.TrimSpace(req.Provider)) - if !isSupportedAgentProvider(provider) { - return buserr.New("ErrAgentProviderNotSupported") - } - apiKey := strings.TrimSpace(req.APIKey) - if apiKey == "" { - return buserr.New("ErrAgentApiKeyRequired") - } - baseURL := strings.TrimSpace(req.BaseURL) - if fixedURL, ok := fixedProviderBaseURL(provider); ok { - baseURL = fixedURL - } - if (provider == "custom" || provider == "vllm") && baseURL == "" { - return buserr.New("ErrAgentBaseURLRequired") - } - if provider != "custom" && provider != "vllm" && baseURL == "" { - if defaultURL, ok := providerDefaultBaseURL(provider); ok { - baseURL = defaultURL - } - } - if provider == "ollama" && baseURL == "" { - return buserr.New("ErrAgentBaseURLRequired") - } + provider := req.Provider if exist, _ := agentAccountRepo.GetFirst(repo.WithByProvider(provider), repo.WithByName(req.Name)); exist != nil && exist.ID > 0 { return buserr.New("ErrRecordExist") } - modelName := strings.TrimSpace(req.Model) - apiType := normalizeAPIType(req.APIType) - if provider == "custom" || provider == "vllm" { - if !isSupportedAPIType(apiType) { - return fmt.Errorf("apiType is invalid") - } - } - if provider == "ollama" { - if !isSupportedOllamaAPIType(apiType) { - return fmt.Errorf("apiType is invalid") - } - } - if err := a.VerifyAccount(dto.AgentAccountVerifyReq{Provider: provider, BaseURL: baseURL, APIKey: apiKey}); err != nil { + resolvedInput, err := resolveAgentAccountInput(provider, req.APIKey, req.BaseURL, req.APIType, "") + if err != nil { return err } - verified := !providercatalog.SkipVerification(provider) - _, maxTokens, contextWindow := resolveRuntimeParams(provider, apiType, req.MaxTokens, req.ContextWindow) account := &model.AgentAccount{ - Provider: provider, + Provider: resolvedInput.Provider, Name: req.Name, - APIKey: apiKey, + APIKey: resolvedInput.APIKey, RememberAPIKey: req.RememberAPIKey, - BaseURL: baseURL, - Model: "", - Models: "", - APIType: apiType, - MaxTokens: 0, - ContextWindow: 0, - Verified: verified, + BaseURL: resolvedInput.BaseURL, + APIType: resolvedInput.APIType, + Verified: true, Remark: req.Remark, } - if provider == "custom" || provider == "vllm" || provider == "ollama" { - account.MaxTokens = maxTokens - account.ContextWindow = contextWindow - } - if err := agentAccountRepo.Create(account); err != nil { - return err - } - initialModels, err := buildInitialAgentAccountModels(account, req.Models, modelName) + initialModels, err := buildInitialAgentAccountModels(account, req.Models) if err != nil { - _ = agentAccountRepo.DeleteByID(account.ID) return err } - if len(initialModels) > 0 { - if err := replacePersistedAgentAccountModels(account.ID, initialModels); err != nil { - _ = agentAccountRepo.DeleteByID(account.ID) + if err := global.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(account).Error; err != nil { return err } + if len(initialModels) == 0 { + return nil + } + return replacePersistedAgentAccountModelsWithTx(tx, account.ID, initialModels) + }); err != nil { + return err } asyncReportAIProviderInstall(provider) return nil @@ -568,93 +398,22 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error { if err != nil { return err } - provider := strings.ToLower(strings.TrimSpace(account.Provider)) - baseURL := strings.TrimSpace(req.BaseURL) - if fixedURL, ok := fixedProviderBaseURL(provider); ok { - baseURL = fixedURL - } - if (provider == "custom" || provider == "vllm") && baseURL == "" { - return buserr.New("ErrAgentBaseURLRequired") - } - if provider != "custom" && provider != "vllm" && baseURL == "" { - if defaultURL, ok := providerDefaultBaseURL(provider); ok { - baseURL = defaultURL - } - } - if provider == "ollama" && baseURL == "" { - return buserr.New("ErrAgentBaseURLRequired") - } - apiType := normalizeAPIType(req.APIType) - rawAPIType := strings.TrimSpace(req.APIType) - if (provider == "custom" || provider == "vllm") && !isSupportedAPIType(apiType) { - return fmt.Errorf("apiType is invalid") - } - if provider == "ollama" { - if rawAPIType == "" { - apiType = normalizeAPIType(account.APIType) - if !isSupportedOllamaAPIType(apiType) { - apiType = "openai-responses" - } - } else if !isSupportedOllamaAPIType(apiType) { - return fmt.Errorf("apiType is invalid") - } - } - if provider != "custom" && provider != "vllm" && provider != "ollama" { - apiType = normalizeAPIType(account.APIType) - } - _, maxTokens, contextWindow := resolveRuntimeParams(provider, apiType, req.MaxTokens, req.ContextWindow) - if err := a.VerifyAccount(dto.AgentAccountVerifyReq{Provider: provider, BaseURL: baseURL, APIKey: req.APIKey}); err != nil { + provider := account.Provider + resolvedInput, err := resolveAgentAccountInput(provider, req.APIKey, req.BaseURL, req.APIType, account.APIType) + if err != nil { return err } - verified := !providercatalog.SkipVerification(provider) - account.Provider = provider - account.APIKey = req.APIKey - account.BaseURL = baseURL - account.APIType = apiType - account.MaxTokens = 0 - account.ContextWindow = 0 - if provider == "custom" || provider == "vllm" || provider == "ollama" { - account.MaxTokens = maxTokens - account.ContextWindow = contextWindow - } account.Name = req.Name - account.APIKey = req.APIKey + account.APIKey = resolvedInput.APIKey account.RememberAPIKey = req.RememberAPIKey - account.BaseURL = baseURL - account.APIType = apiType + account.BaseURL = resolvedInput.BaseURL + account.APIType = resolvedInput.APIType account.Remark = req.Remark - account.Verified = verified + account.Verified = true - var nextAccountModels []dto.AgentAccountModel - if len(req.Models) > 0 || strings.TrimSpace(req.Model) != "" { - accountModels, _, err := normalizeAgentAccountModels(account, req.Models, req.Model, true) - if err != nil { - return err - } - nextAccountModels = accountModels - if err := ensureAccountModelsNotBound(account, nextAccountModels); err != nil { - return err - } - } - if err := agentAccountRepo.Save(account); err != nil { + if err := global.DB.Save(account).Error; err != nil { return err } - if len(nextAccountModels) > 0 { - if err := replacePersistedAgentAccountModels(account.ID, nextAccountModels); err != nil { - return err - } - } else if shouldRefreshAccountModelRuntimeLimits(provider) { - accountModels, err := loadAgentAccountModels(account) - if err != nil { - return err - } - if len(accountModels) > 0 { - accountModels = refreshAccountModelRuntimeLimits(account, accountModels) - if err := replacePersistedAgentAccountModels(account.ID, accountModels); err != nil { - return err - } - } - } if req.SyncAgents { if err := a.syncAgentsByAccount(account); err != nil { return err @@ -689,11 +448,8 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age APIKey: apiKey, RememberAPIKey: item.RememberAPIKey, BaseURL: item.BaseURL, - Model: "", Models: nil, APIType: item.APIType, - MaxTokens: item.MaxTokens, - ContextWindow: item.ContextWindow, Verified: item.Verified, Remark: item.Remark, CreatedAt: item.CreatedAt, @@ -837,48 +593,22 @@ func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) err return a.syncAgentsByAccount(account) } -func (a AgentService) SyncAgentsByAccountID(accountID uint) error { - if accountID == 0 { +func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error { + if account == nil || account.ID == 0 { return nil } - account, err := agentAccountRepo.GetFirst(repo.WithByID(accountID)) - if err != nil { - return err - } return a.syncAgentsByAccount(account) } func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error { - provider := strings.ToLower(strings.TrimSpace(req.Provider)) - if !isSupportedAgentProvider(provider) { - return buserr.New("ErrAgentProviderNotSupported") + resolvedVerification, err := resolveAgentAccountVerification(req.Provider, req.APIKey, req.BaseURL) + if err != nil { + return err } - apiKey := strings.TrimSpace(req.APIKey) - if apiKey == "" { - return buserr.New("ErrAgentApiKeyRequired") - } - baseURL := strings.TrimSpace(req.BaseURL) - if fixedURL, ok := fixedProviderBaseURL(provider); ok { - baseURL = fixedURL - } - if baseURL == "" { - if defaultURL, ok := providerDefaultBaseURL(provider); ok { - baseURL = defaultURL - } - } - if provider == "ollama" && baseURL == "" { - return buserr.New("ErrAgentBaseURLRequired") - } - if providercatalog.SkipVerification(provider) { - return nil - } - return providercatalog.VerifyAccount(provider, baseURL, apiKey) + return verifyResolvedAgentAccount(resolvedVerification) } func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error { - if req.ID == 0 { - return buserr.New("ErrAgentAccountIDRequired") - } if exists, _ := agentRepo.GetFirst(repo.WithByAccountID(req.ID)); exists != nil && exists.ID > 0 { return buserr.New("ErrAgentAccountBound") } @@ -899,13 +629,9 @@ func (a AgentService) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentF func (a AgentService) UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error { return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error { - dmPolicy := req.DmPolicy - if dmPolicy == "" { - dmPolicy = "pairing" - } setFeishuConfig(conf, dto.AgentFeishuConfig{ Enabled: req.Enabled, - DmPolicy: dmPolicy, + DmPolicy: req.DmPolicy, BotName: req.BotName, AppID: req.AppID, AppSecret: req.AppSecret, @@ -926,13 +652,9 @@ func (a AgentService) GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.Ag func (a AgentService) UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error { return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error { - dmPolicy := req.DmPolicy - if dmPolicy == "" { - dmPolicy = "pairing" - } setTelegramConfig(conf, dto.AgentTelegramConfig{ Enabled: req.Enabled, - DmPolicy: dmPolicy, + DmPolicy: req.DmPolicy, BotToken: req.BotToken, Proxy: req.Proxy, }) @@ -951,18 +673,10 @@ func (a AgentService) GetDiscordConfig(req dto.AgentDiscordConfigReq) (*dto.Agen func (a AgentService) UpdateDiscordConfig(req dto.AgentDiscordConfigUpdateReq) error { return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error { - dmPolicy := req.DmPolicy - if dmPolicy == "" { - dmPolicy = "pairing" - } - groupPolicy := req.GroupPolicy - if groupPolicy == "" { - groupPolicy = "open" - } setDiscordConfig(conf, dto.AgentDiscordConfig{ Enabled: req.Enabled, - DmPolicy: dmPolicy, - GroupPolicy: groupPolicy, + DmPolicy: req.DmPolicy, + GroupPolicy: req.GroupPolicy, Token: req.Token, Proxy: req.Proxy, }) @@ -1057,7 +771,7 @@ func (a AgentService) GetSecurityConfig(req dto.AgentSecurityConfigReq) (*dto.Ag if err != nil { return nil, err } - if normalizeAgentType(agent.AgentType) == constant.AppCopaw { + if agent.AgentType == constant.AppCopaw { return nil, fmt.Errorf("copaw does not support security config") } conf, err := readOpenclawConfig(agent.ConfigPath) @@ -1073,7 +787,7 @@ func (a AgentService) UpdateSecurityConfig(req dto.AgentSecurityConfigUpdateReq) if err != nil { return err } - if normalizeAgentType(agent.AgentType) == constant.AppCopaw { + if agent.AgentType == constant.AppCopaw { return fmt.Errorf("copaw does not support security config") } allowedOrigins, err := normalizeAllowedOrigins(req.AllowedOrigins) @@ -1134,13 +848,7 @@ func (a AgentService) ApproveChannelPairing(req dto.AgentChannelPairingApproveRe if err != nil { return err } - channelType := strings.ToLower(strings.TrimSpace(req.Type)) - if channelType == "" { - channelType = "feishu" - } - if channelType != "feishu" && channelType != "telegram" && channelType != "discord" && channelType != "wecom" { - return fmt.Errorf("unsupported channel type: %s", channelType) - } + channelType := req.Type if err := cmd.RunDefaultBashCf( "docker exec %s openclaw pairing approve %s %q", install.ContainerName, @@ -1190,522 +898,6 @@ func (a AgentService) mutateAgentConfig(agentID uint, fn func(agent *model.Agent return writeOpenclawConfigRaw(agent.ConfigPath, conf) } -func readOpenclawConfig(configPath string) (map[string]interface{}, error) { - if strings.TrimSpace(configPath) == "" { - return nil, buserr.New("ErrRecordNotFound") - } - fileOp := files.NewFileOp() - content, err := fileOp.GetContent(configPath) - if err != nil { - return nil, err - } - conf := map[string]interface{}{} - if err := json.Unmarshal(content, &conf); err != nil { - return nil, err - } - return conf, nil -} - -func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error { - ensureGatewaySecurityDefaults(conf) - ensureOpenclawUpdateDefaults(conf) - payload, err := json.MarshalIndent(conf, "", " ") - if err != nil { - return err - } - fileOp := files.NewFileOp() - return fileOp.SaveFile(configPath, string(payload), 0600) -} - -func normalizeAllowedOrigins(origins []string) ([]string, error) { - if len(origins) == 0 { - return nil, nil - } - result := make([]string, 0, len(origins)) - seen := make(map[string]struct{}, len(origins)) - for _, origin := range origins { - origin = strings.TrimSpace(origin) - if origin == "" { - continue - } - normalized, err := normalizeAllowedOrigin(origin) - if err != nil { - return nil, err - } - if _, ok := seen[normalized]; ok { - continue - } - seen[normalized] = struct{}{} - result = append(result, normalized) - } - return result, nil -} - -func normalizeAllowedOrigin(origin string) (string, error) { - parsed, err := url.Parse(strings.TrimSpace(origin)) - if err != nil { - return "", fmt.Errorf("invalid allowed origin: %s", origin) - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", fmt.Errorf("invalid allowed origin: %s", origin) - } - if parsed.User != nil || parsed.Host == "" || parsed.Hostname() == "" { - return "", fmt.Errorf("invalid allowed origin: %s", origin) - } - if parsed.RawQuery != "" || parsed.Fragment != "" { - return "", fmt.Errorf("invalid allowed origin: %s", origin) - } - if pathValue := strings.TrimSpace(parsed.EscapedPath()); pathValue != "" && pathValue != "/" { - return "", fmt.Errorf("invalid allowed origin: %s", origin) - } - host := parsed.Hostname() - if strings.Contains(host, ":") { - host = "[" + host + "]" - } - normalized := parsed.Scheme + "://" + host - if parsed.Port() != "" { - normalized += ":" + parsed.Port() - } - return normalized, nil -} - -func extractSecurityConfig(conf map[string]interface{}) dto.AgentSecurityConfig { - result := dto.AgentSecurityConfig{AllowedOrigins: []string{}} - gateway, ok := conf["gateway"].(map[string]interface{}) - if !ok { - return result - } - controlUi, ok := gateway["controlUi"].(map[string]interface{}) - if !ok { - return result - } - switch values := controlUi["allowedOrigins"].(type) { - case []interface{}: - for _, value := range values { - if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { - result.AllowedOrigins = append(result.AllowedOrigins, strings.TrimSpace(text)) - } - } - case []string: - for _, value := range values { - if strings.TrimSpace(value) != "" { - result.AllowedOrigins = append(result.AllowedOrigins, strings.TrimSpace(value)) - } - } - } - return result -} - -func setSecurityConfig(conf map[string]interface{}, config dto.AgentSecurityConfig) { - ensureGatewaySecurityDefaults(conf) - gateway := ensureChildMap(conf, "gateway") - controlUi := ensureChildMap(gateway, "controlUi") - allowedOrigins := append([]string(nil), config.AllowedOrigins...) - if len(allowedOrigins) > 0 { - controlUi["allowedOrigins"] = allowedOrigins - } else { - delete(controlUi, "allowedOrigins") - } -} - -func ensureGatewaySecurityDefaults(conf map[string]interface{}) { - gateway := ensureChildMap(conf, "gateway") - controlUi := ensureChildMap(gateway, "controlUi") - if _, ok := controlUi["dangerouslyDisableDeviceAuth"]; !ok { - controlUi["dangerouslyDisableDeviceAuth"] = true - } - delete(controlUi, "dangerouslyAllowHostHeaderOriginFallback") - setTrustedProxies(gateway) -} - -func ensureOpenclawUpdateDefaults(conf map[string]interface{}) { - update := ensureChildMap(conf, "update") - if _, ok := update["checkOnStart"]; !ok { - update["checkOnStart"] = false - } -} - -func setTrustedProxies(gateway map[string]interface{}) { - proxies := make([]string, 0, 4) - seen := map[string]struct{}{} - switch values := gateway["trustedProxies"].(type) { - case []interface{}: - for _, value := range values { - text := strings.TrimSpace(fmt.Sprintf("%v", value)) - if text == "" { - continue - } - if _, ok := seen[text]; ok { - continue - } - seen[text] = struct{}{} - proxies = append(proxies, text) - } - case []string: - for _, value := range values { - text := strings.TrimSpace(value) - if text == "" { - continue - } - if _, ok := seen[text]; ok { - continue - } - seen[text] = struct{}{} - proxies = append(proxies, text) - } - } - if _, ok := seen[openclawTrustedProxyLoopback]; !ok { - proxies = append(proxies, openclawTrustedProxyLoopback) - } - gateway["trustedProxies"] = proxies -} - -func extractFeishuConfig(conf map[string]interface{}) dto.AgentFeishuConfig { - result := dto.AgentFeishuConfig{Enabled: true, DmPolicy: "pairing"} - channels, ok := conf["channels"].(map[string]interface{}) - if !ok { - return result - } - feishu, ok := channels["feishu"].(map[string]interface{}) - if !ok { - return result - } - if enabled, ok := feishu["enabled"].(bool); ok { - result.Enabled = enabled - } - if dmPolicy, ok := feishu["dmPolicy"].(string); ok && strings.TrimSpace(dmPolicy) != "" { - result.DmPolicy = dmPolicy - } - accounts, ok := feishu["accounts"].(map[string]interface{}) - if !ok { - return result - } - main, ok := accounts["main"].(map[string]interface{}) - if !ok { - return result - } - if appID, ok := main["appId"].(string); ok { - result.AppID = appID - } - if appSecret, ok := main["appSecret"].(string); ok { - result.AppSecret = appSecret - } - if botName, ok := main["botName"].(string); ok { - result.BotName = botName - } - return result -} - -func setFeishuConfig(conf map[string]interface{}, config dto.AgentFeishuConfig) { - channels := ensureChildMap(conf, "channels") - feishu := ensureChildMap(channels, "feishu") - feishu["enabled"] = config.Enabled - feishu["dmPolicy"] = config.DmPolicy - - accounts := ensureChildMap(feishu, "accounts") - main := ensureChildMap(accounts, "main") - main["appId"] = config.AppID - main["appSecret"] = config.AppSecret - main["botName"] = config.BotName - - if strings.EqualFold(config.DmPolicy, "open") { - feishu["allowFrom"] = []string{"*"} - } -} - -func setFeishuPluginEnabled(conf map[string]interface{}, enabled bool) { - plugins := ensureChildMap(conf, "plugins") - entries := ensureChildMap(plugins, "entries") - feishu := ensureChildMap(entries, "feishu") - feishu["enabled"] = enabled -} - -func extractTelegramConfig(conf map[string]interface{}) dto.AgentTelegramConfig { - result := dto.AgentTelegramConfig{Enabled: true, DmPolicy: "pairing"} - channels, ok := conf["channels"].(map[string]interface{}) - if !ok { - return result - } - telegram, ok := channels["telegram"].(map[string]interface{}) - if !ok { - return result - } - if enabled, ok := telegram["enabled"].(bool); ok { - result.Enabled = enabled - } - if dmPolicy, ok := telegram["dmPolicy"].(string); ok && strings.TrimSpace(dmPolicy) != "" { - result.DmPolicy = dmPolicy - } - if botToken, ok := telegram["botToken"].(string); ok { - result.BotToken = botToken - } - if proxy, ok := telegram["proxy"].(string); ok { - result.Proxy = proxy - } - return result -} - -func setTelegramConfig(conf map[string]interface{}, config dto.AgentTelegramConfig) { - channels := ensureChildMap(conf, "channels") - telegram := map[string]interface{}{ - "enabled": config.Enabled, - "dmPolicy": config.DmPolicy, - "botToken": config.BotToken, - } - if strings.EqualFold(config.DmPolicy, "open") { - telegram["allowFrom"] = []string{"*"} - } - if strings.TrimSpace(config.Proxy) != "" { - telegram["proxy"] = strings.TrimSpace(config.Proxy) - } - channels["telegram"] = telegram -} - -func extractDiscordConfig(conf map[string]interface{}) dto.AgentDiscordConfig { - result := dto.AgentDiscordConfig{Enabled: true, DmPolicy: "pairing", GroupPolicy: "open"} - channels, ok := conf["channels"].(map[string]interface{}) - if !ok { - return result - } - discord, ok := channels["discord"].(map[string]interface{}) - if !ok { - return result - } - if enabled, ok := discord["enabled"].(bool); ok { - result.Enabled = enabled - } - if token, ok := discord["token"].(string); ok { - result.Token = token - } - if groupPolicy, ok := discord["groupPolicy"].(string); ok && strings.TrimSpace(groupPolicy) != "" { - result.GroupPolicy = groupPolicy - } - if proxy, ok := discord["proxy"].(string); ok { - result.Proxy = proxy - } - if policy, ok := discord["dmPolicy"].(string); ok && strings.TrimSpace(policy) != "" { - result.DmPolicy = policy - return result - } - // backward compatibility: old nested style - dm, ok := discord["dm"].(map[string]interface{}) - if ok { - if policy, ok := dm["policy"].(string); ok && strings.TrimSpace(policy) != "" { - result.DmPolicy = policy - } - } - return result -} - -func setDiscordConfig(conf map[string]interface{}, config dto.AgentDiscordConfig) { - channels := ensureChildMap(conf, "channels") - discord := ensureChildMap(channels, "discord") - discord["enabled"] = config.Enabled - discord["token"] = config.Token - discord["dmPolicy"] = config.DmPolicy - discord["groupPolicy"] = config.GroupPolicy - if strings.EqualFold(config.DmPolicy, "open") { - discord["allowFrom"] = []string{"*"} - } else { - delete(discord, "allowFrom") - } - if strings.TrimSpace(config.Proxy) != "" { - discord["proxy"] = strings.TrimSpace(config.Proxy) - } else { - delete(discord, "proxy") - } - delete(discord, "dm") -} - -func extractBrowserConfig(conf map[string]interface{}) browserConfig { - result := browserConfig{ - Enabled: true, - ExecutablePath: defaultBrowserExecutablePath, - Headless: true, - NoSandbox: true, - DefaultProfile: defaultBrowserProfile, - } - browser, ok := conf["browser"].(map[string]interface{}) - if !ok { - return result - } - if enabled, ok := browser["enabled"].(bool); ok { - result.Enabled = enabled - } - if executablePath, ok := browser["executablePath"].(string); ok && strings.TrimSpace(executablePath) != "" { - result.ExecutablePath = executablePath - } - if headless, ok := browser["headless"].(bool); ok { - result.Headless = headless - } - if noSandbox, ok := browser["noSandbox"].(bool); ok { - result.NoSandbox = noSandbox - } - if defaultProfile, ok := browser["defaultProfile"].(string); ok && strings.TrimSpace(defaultProfile) != "" { - result.DefaultProfile = defaultProfile - } - return result -} - -func setBrowserConfig(conf map[string]interface{}, config browserConfig) { - browser := ensureChildMap(conf, "browser") - browser["enabled"] = config.Enabled - browser["executablePath"] = defaultBrowserExecutablePath - browser["headless"] = config.Headless - browser["noSandbox"] = config.NoSandbox - if strings.TrimSpace(config.DefaultProfile) == "" { - browser["defaultProfile"] = defaultBrowserProfile - } else { - browser["defaultProfile"] = strings.TrimSpace(config.DefaultProfile) - } -} - -func extractQQBotConfig(conf map[string]interface{}) dto.AgentQQBotConfig { - result := dto.AgentQQBotConfig{Enabled: true} - channels, ok := conf["channels"].(map[string]interface{}) - if !ok { - return result - } - qqbot, ok := channels["qqbot"].(map[string]interface{}) - if !ok { - return result - } - if enabled, ok := qqbot["enabled"].(bool); ok { - result.Enabled = enabled - } - if appID, ok := qqbot["appId"].(string); ok { - result.AppID = appID - } - if clientSecret, ok := qqbot["clientSecret"].(string); ok { - result.ClientSecret = clientSecret - } - return result -} - -func extractWecomConfig(conf map[string]interface{}) dto.AgentWecomConfig { - result := dto.AgentWecomConfig{Enabled: true, DmPolicy: "pairing"} - channels, ok := conf["channels"].(map[string]interface{}) - if !ok { - return result - } - wecom, ok := channels["wecom"].(map[string]interface{}) - if !ok { - return result - } - if enabled, ok := wecom["enabled"].(bool); ok { - result.Enabled = enabled - } - if dmPolicy, ok := wecom["dmPolicy"].(string); ok && strings.TrimSpace(dmPolicy) != "" { - result.DmPolicy = strings.TrimSpace(dmPolicy) - } - if botID, ok := wecom["botId"].(string); ok { - result.BotID = botID - } - if secret, ok := wecom["secret"].(string); ok { - result.Secret = secret - } - return result -} - -func setWecomConfig(conf map[string]interface{}, config dto.AgentWecomConfig) { - channels := ensureChildMap(conf, "channels") - wecom := ensureChildMap(channels, "wecom") - wecom["enabled"] = config.Enabled - wecom["botId"] = strings.TrimSpace(config.BotID) - wecom["secret"] = strings.TrimSpace(config.Secret) - wecom["dmPolicy"] = strings.TrimSpace(config.DmPolicy) - if strings.EqualFold(config.DmPolicy, "open") { - wecom["allowFrom"] = []string{"*"} - } else { - wecom["allowFrom"] = []string{} - } - - plugins := ensureChildMap(conf, "plugins") - entries := ensureChildMap(plugins, "entries") - wecomEntry := ensureChildMap(entries, "wecom-openclaw-plugin") - wecomEntry["enabled"] = config.Enabled -} - -func setQQBotConfig(conf map[string]interface{}, config dto.AgentQQBotConfig) { - channels := ensureChildMap(conf, "channels") - qqbot := ensureChildMap(channels, "qqbot") - qqbot["enabled"] = config.Enabled - qqbot["allowFrom"] = []string{"*"} - qqbot["appId"] = strings.TrimSpace(config.AppID) - qqbot["clientSecret"] = strings.TrimSpace(config.ClientSecret) - - plugins := ensureChildMap(conf, "plugins") - entries := ensureChildMap(plugins, "entries") - qqbotEntry := ensureChildMap(entries, "qqbot") - qqbotEntry["enabled"] = config.Enabled -} - -func resolvePluginMeta(pluginType string) (string, string, error) { - switch strings.ToLower(strings.TrimSpace(pluginType)) { - case "qqbot": - return "@sliverp/qqbot@latest", "qqbot", nil - case "wecom": - return "@wecom/wecom-openclaw-plugin", "wecom-openclaw-plugin", nil - default: - return "", "", fmt.Errorf("unsupported plugin type") - } -} - -func checkPluginInstalled(containerName, pluginType string) (bool, error) { - _, pluginDir, err := resolvePluginMeta(pluginType) - if err != nil { - return false, err - } - if strings.TrimSpace(containerName) == "" { - return false, buserr.New("ErrRecordNotFound") - } - pluginPath := path.Join(openclawPluginBaseDir, pluginDir) - mgr := cmd.NewCommandMgr(cmd.WithTimeout(20 * time.Second)) - if err := mgr.RunBashCf("docker exec %s test -d %s", containerName, pluginPath); err != nil { - return false, nil - } - return true, nil -} - -func extractOtherConfig(conf map[string]interface{}) dto.AgentOtherConfig { - result := dto.AgentOtherConfig{UserTimezone: resolveServerTimezone(), BrowserEnabled: true} - agents, ok := conf["agents"].(map[string]interface{}) - if !ok { - browser := extractBrowserConfig(conf) - result.BrowserEnabled = browser.Enabled - return result - } - defaults, ok := agents["defaults"].(map[string]interface{}) - if !ok { - browser := extractBrowserConfig(conf) - result.BrowserEnabled = browser.Enabled - return result - } - if timezone, ok := defaults["userTimezone"].(string); ok && strings.TrimSpace(timezone) != "" { - result.UserTimezone = strings.TrimSpace(timezone) - } - browser := extractBrowserConfig(conf) - result.BrowserEnabled = browser.Enabled - return result -} - -func setOtherConfig(conf map[string]interface{}, config dto.AgentOtherConfig) { - agents := ensureChildMap(conf, "agents") - defaults := ensureChildMap(agents, "defaults") - timezone := strings.TrimSpace(config.UserTimezone) - if timezone == "" { - timezone = resolveServerTimezone() - } - defaults["userTimezone"] = timezone - setBrowserConfig(conf, browserConfig{ - Enabled: config.BrowserEnabled, - ExecutablePath: defaultBrowserExecutablePath, - Headless: true, - NoSandbox: true, - DefaultProfile: defaultBrowserProfile, - }) -} - func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error { agents, err := agentRepo.List(repo.WithByAccountID(account.ID)) if err != nil { @@ -1720,42 +912,23 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error { } baseURL := resolveAccountBaseURL(account) for _, agent := range agents { - confDir := "" - if agent.ConfigPath != "" { - confDir = path.Dir(agent.ConfigPath) - } else if agent.AppInstallID > 0 { - install, err := appInstallRepo.GetFirst(repo.WithByID(agent.AppInstallID)) - if err == nil { - confDir = path.Join(install.GetPath(), "data", "conf") - } - } - if confDir == "" { - continue - } + confDir := path.Dir(agent.ConfigPath) modelName := strings.TrimSpace(agent.Model) var selectedAccountModel dto.AgentAccountModel - var ok bool if modelName != "" { - selectedAccountModel, ok = findAgentAccountModelForProvider(account.Provider, accountModels, modelName) - if !ok { + selectedAccountModel, err = requireAgentAccountModelForProvider(account.Provider, accountModels, modelName) + if err != nil { return buserr.WithName("ErrAgentModelInUse", agent.Name) } } else { - modelName = strings.TrimSpace(account.Model) - if modelName != "" { - selectedAccountModel, ok = findAgentAccountModelForProvider(account.Provider, accountModels, modelName) - } - if !ok { - selectedAccountModel = accountModels[0] - } + selectedAccountModel = accountModels[0] } - modelName = strings.TrimSpace(selectedAccountModel.ID) - apiType, maxTokens, contextWindow := resolveRuntimeParams( - account.Provider, - account.APIType, - selectedAccountModel.MaxTokens, - selectedAccountModel.ContextWindow, - ) + resolvedRuntime, err := buildOpenclawAccountModelRuntime(account, selectedAccountModel) + if err != nil { + return err + } + modelName = resolvedRuntime.StoredModel + apiType, maxTokens, contextWindow := resolvedRuntime.APIType, resolvedRuntime.MaxTokens, resolvedRuntime.ContextWindow if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil); err != nil { return err } @@ -1770,1559 +943,3 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error { } return nil } - -func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem { - agentType := normalizeAgentType(agent.AgentType) - if appInstall != nil && appInstall.ID > 0 && appInstall.App.Key == constant.AppCopaw { - agentType = constant.AppCopaw - } - item := dto.AgentItem{ - ID: agent.ID, - Name: agent.Name, - AgentType: agentType, - Provider: agent.Provider, - ProviderName: providerDisplayName(agent.Provider), - Model: agent.Model, - APIType: agent.APIType, - MaxTokens: agent.MaxTokens, - ContextWindow: agent.ContextWindow, - BaseURL: agent.BaseURL, - APIKey: maskKey(agent.APIKey), - Token: agent.Token, - Status: agent.Status, - Message: agent.Message, - AppInstallID: agent.AppInstallID, - AccountID: agent.AccountID, - ConfigPath: agent.ConfigPath, - CreatedAt: agent.CreatedAt, - } - if appInstall != nil && appInstall.ID > 0 { - item.Container = appInstall.ContainerName - item.AppVersion = appInstall.Version - if agentType == constant.AppOpenclaw { - if isOpenclawHTTPSVersion(appInstall.Version) { - item.WebUIPort = appInstall.HttpsPort - } else { - item.WebUIPort = appInstall.HttpPort - } - } else { - item.WebUIPort = appInstall.HttpPort - } - item.Path = appInstall.GetPath() - item.Status = appInstall.Status - item.Message = appInstall.Message - if envMap != nil { - if bridge, ok := envMap["PANEL_APP_PORT_BRIDGE"]; ok { - item.BridgePort = toInt(bridge) - } - } - } - return item -} - -func isOpenclawHTTPSVersion(version string) bool { - target := strings.TrimSpace(strings.ToLower(version)) - if target == "" || target == "latest" { - return true - } - if !strings.ContainsAny(target, "0123456789") { - return true - } - return common.CompareAppVersion(target, openclawHTTPSVersion) -} - -func shouldMigrateOpenclawHTTPSUpgrade(install *model.AppInstall, fromVersion, toVersion string) bool { - if install == nil || install.App.Key != constant.AppOpenclaw { - return false - } - return !isOpenclawHTTPSVersion(fromVersion) && isOpenclawHTTPSVersion(toVersion) -} - -func migrateOpenclawHTTPSUpgrade(install *model.AppInstall, fromVersion, toVersion string) error { - systemIP, _ := settingRepo.GetValueByKey("SystemIP") - return migrateOpenclawHTTPSUpgradeWithSystemIP(install, fromVersion, toVersion, systemIP) -} - -func migrateOpenclawHTTPSUpgradeWithSystemIP(install *model.AppInstall, fromVersion, toVersion, systemIP string) error { - if !shouldMigrateOpenclawHTTPSUpgrade(install, fromVersion, toVersion) { - return nil - } - migrateOpenclawInstallPorts(install) - if err := openclawutil.WriteCatchAllCaddyfile(install.GetPath()); err != nil { - return err - } - configPath := path.Join(install.GetPath(), "data", "conf", "openclaw.json") - var allowedOrigins []string - if conf, err := readOpenclawConfig(configPath); err == nil { - allowedOrigins = extractSecurityConfig(conf).AllowedOrigins - } - originHost := strings.TrimSpace(systemIP) - if originHost == "" { - originHost = openclawAllowedOriginHost - } - if install.HttpsPort > 0 { - allowedOrigin, err := buildOpenclawAllowedOrigin(originHost, install.HttpsPort) - if err == nil { - conf, err := readOpenclawConfig(configPath) - if err != nil { - return err - } - allowedOrigins = []string{allowedOrigin} - setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins}) - if err := writeOpenclawConfigRaw(configPath, conf); err != nil { - return err - } - } - } - return migrateOpenclawInstallEnv(install, allowedOrigins) -} - -func migrateOpenclawInstallPorts(install *model.AppInstall) { - if install == nil { - return - } - if install.HttpsPort == 0 && install.HttpPort > 0 { - install.HttpsPort = install.HttpPort - } - if install.HttpPort > 0 { - install.HttpPort = 0 - } -} - -func migrateOpenclawInstallEnv(install *model.AppInstall, allowedOrigins []string) error { - if install == nil { - return nil - } - envMap := make(map[string]interface{}) - if strings.TrimSpace(install.Env) != "" { - if err := json.Unmarshal([]byte(install.Env), &envMap); err != nil { - return err - } - } - if install.HttpsPort > 0 { - envMap["PANEL_APP_PORT_HTTPS"] = install.HttpsPort - } - if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" { - envMap["ALLOWED_ORIGIN"] = allowedOrigin - } - delete(envMap, "PANEL_APP_PORT_HTTP") - payload, err := json.Marshal(envMap) - if err != nil { - return err - } - install.Env = string(payload) - return nil -} - -func syncOpenclawAllowedOriginEnv(install *model.AppInstall, allowedOrigins []string) error { - if install == nil { - return nil - } - envMap := make(map[string]interface{}) - if strings.TrimSpace(install.Env) != "" { - if err := json.Unmarshal([]byte(install.Env), &envMap); err != nil { - return err - } - } - if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" { - envMap["ALLOWED_ORIGIN"] = allowedOrigin - } else { - delete(envMap, "ALLOWED_ORIGIN") - } - payload, err := json.Marshal(envMap) - if err != nil { - return err - } - install.Env = string(payload) - return nil -} - -func firstAllowedOrigin(allowedOrigins []string) string { - for _, origin := range allowedOrigins { - trimmed := strings.TrimSpace(origin) - if trimmed != "" { - return trimmed - } - } - return "" -} - -func buildOpenclawAllowedOrigin(host string, port int) (string, error) { - host = strings.TrimSpace(host) - if host == "" || port <= 0 { - return "", fmt.Errorf("invalid openclaw allowed origin") - } - if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") && strings.Count(host, ":") > 1 { - host = "[" + host + "]" - } - return normalizeAllowedOrigin(fmt.Sprintf("https://%s:%d", host, port)) -} - -func checkAgentUpgradable(install model.AppInstall) bool { - if install.ID == 0 || install.Version == "" || install.Version == "latest" { - return false - } - if install.App.ID == 0 { - return false - } - details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(install.App.ID)) - if err != nil || len(details) == 0 { - return false - } - versions := make([]string, 0, len(details)) - for _, item := range details { - ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(item.ID), appIgnoreUpgradeRepo.WithScope("version")) - if len(ignores) > 0 { - continue - } - if common.IsCrossVersion(install.Version, item.Version) && !install.App.CrossVersionUpdate { - continue - } - versions = append(versions, item.Version) - } - if len(versions) == 0 { - return false - } - versions = common.GetSortedVersions(versions) - lastVersion := versions[0] - if common.IsCrossVersion(install.Version, lastVersion) { - return install.App.CrossVersionUpdate - } - return common.CompareVersion(lastVersion, install.Version) -} - -func (a AgentService) waitAndDeleteAgent(agentID uint, appInstallID uint) { - if appInstallID == 0 { - _ = agentRepo.DeleteByID(agentID) - return - } - for i := 0; i < 180; i++ { - _, err := appInstallRepo.GetFirst(repo.WithByID(appInstallID)) - if err != nil { - _ = agentRepo.DeleteByID(agentID) - return - } - time.Sleep(2 * time.Second) - } -} - -func (a AgentService) writeConfigWithRetry(appInstall *model.AppInstall, accountID uint, modelName, token string, agentID uint, allowedOrigins []string) { - if appInstall == nil { - return - } - fileOp := files.NewFileOp() - composePath := appInstall.GetComposePath() - for i := 0; i < 60; i++ { - if fileOp.Stat(composePath) { - break - } - time.Sleep(time.Second) - } - confDir := path.Join(appInstall.GetPath(), "data", "conf") - account, err := agentAccountRepo.GetFirst(repo.WithByID(accountID)) - if err != nil { - global.LOG.Errorf("load agent account failed: %v", err) - return - } - if err := writeOpenclawConfig(confDir, account, modelName, token, allowedOrigins); err != nil { - global.LOG.Errorf("write openclaw config failed: %v", err) - agent, errGet := agentRepo.GetFirst(repo.WithByID(agentID)) - if errGet == nil && agent != nil { - agent.Message = err.Error() - agent.Status = constant.StatusError - _ = agentRepo.Save(agent) - } - return - } - dataDir := path.Join(appInstall.GetPath(), "data") - for i := 0; i < 60; i++ { - if fileOp.Stat(dataDir) { - if err := fileOp.ChownR(dataDir, "1000", "1000", true); err != nil { - global.LOG.Errorf("chown data dir failed: %v", err) - agent, errGet := agentRepo.GetFirst(repo.WithByID(agentID)) - if errGet == nil && agent != nil { - agent.Message = err.Error() - agent.Status = constant.StatusError - _ = agentRepo.Save(agent) - } - } - break - } - time.Sleep(time.Second) - } -} - -type openclawConfig struct { - Gateway gatewayConfig `json:"gateway"` - Agents agentsConfig `json:"agents"` - Browser browserConfig `json:"browser"` - Tools toolsConfig `json:"tools"` - Update updateConfig `json:"update"` - Models *modelsConfig `json:"models,omitempty"` -} - -type toolsConfig struct { - Profile string `json:"profile,omitempty"` - Sessions toolSessionsConfig `json:"sessions,omitempty"` -} - -type toolSessionsConfig struct { - Visibility string `json:"visibility,omitempty"` -} - -type updateConfig struct { - CheckOnStart bool `json:"checkOnStart"` -} - -type gatewayConfig struct { - Mode string `json:"mode"` - Bind string `json:"bind"` - Port int `json:"port"` - Auth gatewayAuth `json:"auth"` - ControlUi gatewayControlUi `json:"controlUi"` - TrustedProxies []string `json:"trustedProxies,omitempty"` -} - -type gatewayControlUi struct { - DangerouslyDisableDeviceAuth bool `json:"dangerouslyDisableDeviceAuth"` - AllowedOrigins []string `json:"allowedOrigins,omitempty"` -} - -type gatewayAuth struct { - Mode string `json:"mode"` - Token string `json:"token"` -} - -type agentsConfig struct { - Defaults agentDefaults `json:"defaults"` -} - -type agentDefaults struct { - UserTimezone string `json:"userTimezone,omitempty"` - Model modelRef `json:"model"` - Models map[string]map[string]interface{} `json:"models,omitempty"` -} - -type modelRef struct { - Primary string `json:"primary"` -} - -type modelsConfig struct { - Mode string `json:"mode,omitempty"` - Providers map[string]modelProvider `json:"providers,omitempty"` -} - -type modelProvider struct { - ApiKey string `json:"apiKey,omitempty"` - BaseUrl string `json:"baseUrl,omitempty"` - Api string `json:"api,omitempty"` - Models []modelEntry `json:"models,omitempty"` -} - -type modelEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Reasoning bool `json:"reasoning"` - Input []string `json:"input"` - ContextWindow int `json:"contextWindow"` - MaxTokens int `json:"maxTokens"` - Cost modelCost `json:"cost"` -} - -type modelCost struct { - Input float64 `json:"input"` - Output float64 `json:"output"` - CacheRead float64 `json:"cacheRead"` - CacheWrite float64 `json:"cacheWrite"` -} - -type browserConfig struct { - Enabled bool `json:"enabled"` - ExecutablePath string `json:"executablePath"` - Headless bool `json:"headless"` - NoSandbox bool `json:"noSandbox"` - DefaultProfile string `json:"defaultProfile"` -} - -func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string) error { - if strings.TrimSpace(confDir) == "" { - return fmt.Errorf("config dir is required") - } - if account == nil { - return fmt.Errorf("account is required") - } - if strings.TrimSpace(modelName) == "" { - return fmt.Errorf("model is required") - } - if strings.TrimSpace(token) == "" { - return fmt.Errorf("gateway token is required") - } - fileOp := files.NewFileOp() - if !fileOp.Stat(confDir) { - if err := fileOp.CreateDir(confDir, constant.DirPerm); err != nil { - return err - } - } - primaryModel, defaultsModels, models, err := buildOpenclawModelsFromAccount(account, modelName) - if err != nil { - return err - } - - cfg := openclawConfig{ - Gateway: gatewayConfig{ - Mode: "local", - Bind: "loopback", - Port: openclawGatewayPort, - Auth: gatewayAuth{ - Mode: "token", - Token: token, - }, - ControlUi: gatewayControlUi{ - DangerouslyDisableDeviceAuth: true, - AllowedOrigins: append([]string(nil), allowedOrigins...), - }, - TrustedProxies: []string{openclawTrustedProxyLoopback}, - }, - Agents: agentsConfig{ - Defaults: agentDefaults{ - UserTimezone: resolveServerTimezone(), - Model: modelRef{Primary: primaryModel}, - Models: defaultsModels, - }, - }, - Browser: browserConfig{ - Enabled: true, - ExecutablePath: defaultBrowserExecutablePath, - Headless: true, - NoSandbox: true, - DefaultProfile: defaultBrowserProfile, - }, - Tools: toolsConfig{ - Profile: defaultToolsProfile, - Sessions: toolSessionsConfig{ - Visibility: defaultToolsSessionVisibility, - }, - }, - Update: updateConfig{ - CheckOnStart: false, - }, - Models: models, - } - - configPath := path.Join(confDir, "openclaw.json") - conf := map[string]interface{}{} - if fileOp.Stat(configPath) { - existing, err := readOpenclawConfig(configPath) - if err != nil { - return err - } - conf = existing - } - if len(conf) == 0 { - initial, err := structToMap(cfg) - if err != nil { - return err - } - conf = initial - } else { - if cfg.Models != nil { - modelsMap, err := structToMap(cfg.Models) - if err != nil { - return err - } - conf["models"] = modelsMap - } - if _, ok := conf["browser"]; !ok { - browserMap, err := structToMap(cfg.Browser) - if err != nil { - return err - } - conf["browser"] = browserMap - } - toolsMap := ensureChildMap(conf, "tools") - if profile, ok := toolsMap["profile"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", profile)) == "" { - toolsMap["profile"] = defaultToolsProfile - } - sessionsMap := ensureChildMap(toolsMap, "sessions") - if visibility, ok := sessionsMap["visibility"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", visibility)) == "" { - sessionsMap["visibility"] = defaultToolsSessionVisibility - } - agentsMap := ensureChildMap(conf, "agents") - defaultsMap := ensureChildMap(agentsMap, "defaults") - if tz, ok := defaultsMap["userTimezone"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", tz)) == "" { - defaultsMap["userTimezone"] = resolveServerTimezone() - } - modelMap := ensureChildMap(defaultsMap, "model") - modelMap["primary"] = cfg.Agents.Defaults.Model.Primary - if cfg.Agents.Defaults.Models != nil { - defaultsMap["models"] = cfg.Agents.Defaults.Models - } - - ensureGatewaySecurityDefaults(conf) - gatewayMap := ensureChildMap(conf, "gateway") - if _, ok := gatewayMap["mode"]; !ok { - gatewayMap["mode"] = "local" - } - if _, ok := gatewayMap["bind"]; !ok { - gatewayMap["bind"] = "loopback" - } - if _, ok := gatewayMap["port"]; !ok { - gatewayMap["port"] = openclawGatewayPort - } - authMap := ensureChildMap(gatewayMap, "auth") - if _, ok := authMap["mode"]; !ok { - authMap["mode"] = "token" - } - authMap["token"] = token - } - if allowedOrigins != nil { - setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins}) - } - if err := writeOpenclawConfigRaw(configPath, conf); err != nil { - return err - } - envPath := path.Join(confDir, ".env") - lines := []string{fmt.Sprintf("OPENCLAW_GATEWAY_TOKEN=%s", token)} - if envKey := providerEnvKey(account.Provider); envKey != "" && strings.TrimSpace(account.APIKey) != "" { - lines = append(lines, fmt.Sprintf("%s=%s", envKey, account.APIKey)) - } - content := strings.Join(lines, "\n") + "\n" - return fileOp.SaveFile(envPath, content, 0600) -} - -func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel string) (string, map[string]map[string]interface{}, *modelsConfig, error) { - accountModels, err := loadAgentAccountModels(account) - if err != nil { - return "", nil, nil, err - } - if len(accountModels) == 0 { - return "", nil, nil, fmt.Errorf("model is required") - } - selectedModel = strings.TrimSpace(selectedModel) - if selectedModel == "" { - selectedModel = strings.TrimSpace(account.Model) - } - if selectedModel == "" { - selectedModel = strings.TrimSpace(accountModels[0].ID) - } - if selectedModel == "" { - return "", nil, nil, fmt.Errorf("model is required") - } - selectedAccountModel, ok := findAgentAccountModelForProvider(account.Provider, accountModels, selectedModel) - if !ok { - return "", nil, nil, buserr.New("ErrAgentModelNotInAccount") - } - selectedModel = strings.TrimSpace(selectedAccountModel.ID) - - providerKey := "" - providerCfg := modelProvider{} - entries := make([]modelEntry, 0, len(accountModels)) - primaryModel := "" - defaultsModels := make(map[string]map[string]interface{}, len(accountModels)) - for _, item := range accountModels { - resolvedPrimary, entry, key, baseCfg, err := buildOpenclawCatalogModel(account, item) - if err != nil { - return "", nil, nil, err - } - if providerKey == "" { - providerKey = key - providerCfg.ApiKey = baseCfg.ApiKey - providerCfg.BaseUrl = baseCfg.BaseUrl - providerCfg.Api = baseCfg.Api - } - entries = append(entries, entry) - defaultsModels[resolvedPrimary] = map[string]interface{}{} - if sameProviderModelID(account.Provider, item.ID, selectedModel) { - primaryModel = resolvedPrimary - } - } - if primaryModel == "" { - return "", nil, nil, buserr.New("ErrAgentModelNotInAccount") - } - providerCfg.Models = entries - return primaryModel, defaultsModels, &modelsConfig{ - Mode: "merge", - Providers: map[string]modelProvider{ - providerKey: providerCfg, - }, - }, nil -} - -func buildOpenclawCatalogModel(account *model.AgentAccount, model dto.AgentAccountModel) (string, modelEntry, string, modelProvider, error) { - primaryModel, inferredEntry, providerKey, providerCfg, err := inferOpenclawCatalogModel(account, model.ID, model.MaxTokens, model.ContextWindow) - if err != nil { - return "", modelEntry{}, "", modelProvider{}, err - } - if strings.TrimSpace(model.Name) != "" { - inferredEntry.Name = strings.TrimSpace(model.Name) - } - if len(model.Input) > 0 { - inferredEntry.Input = sanitizeAgentAccountModelInputs(model.Input) - } - inferredEntry.Reasoning = model.Reasoning - if model.ContextWindow > 0 { - inferredEntry.ContextWindow = model.ContextWindow - } - if model.MaxTokens > 0 { - inferredEntry.MaxTokens = model.MaxTokens - } - return primaryModel, inferredEntry, providerKey, providerCfg, nil -} - -func buildOpenclawPrimaryModel(account *model.AgentAccount, modelID string) (string, error) { - models, err := loadAgentAccountModels(account) - if err != nil { - return "", err - } - item, ok := findAgentAccountModelForProvider(account.Provider, models, modelID) - if !ok { - return "", buserr.New("ErrAgentModelNotInAccount") - } - primaryModel, _, _, _, err := inferOpenclawCatalogModel(account, item.ID, item.MaxTokens, item.ContextWindow) - if err != nil { - return "", err - } - return primaryModel, nil -} - -func inferOpenclawCatalogModel(account *model.AgentAccount, modelID string, maxTokens, contextWindow int) (string, modelEntry, string, modelProvider, error) { - baseURL := resolveAccountBaseURL(account) - resolvedAPIType, resolvedMaxTokens, resolvedContextWindow := resolveRuntimeParams(account.Provider, account.APIType, maxTokens, contextWindow) - patch, err := providercatalog.BuildOpenClawPatch(account.Provider, modelID, resolvedAPIType, resolvedMaxTokens, resolvedContextWindow, baseURL, account.APIKey) - if err != nil { - return "", modelEntry{}, "", modelProvider{}, err - } - if patch.Models == nil { - return "", modelEntry{}, "", modelProvider{}, fmt.Errorf("models patch is required") - } - modelsCfg, err := mapToModelsConfig(patch.Models) - if err != nil { - return "", modelEntry{}, "", modelProvider{}, err - } - for key, providerCfg := range modelsCfg.Providers { - if len(providerCfg.Models) == 0 { - continue - } - return patch.PrimaryModel, providerCfg.Models[0], key, modelProvider{ - ApiKey: providerCfg.ApiKey, - BaseUrl: providerCfg.BaseUrl, - Api: providerCfg.Api, - }, nil - } - return "", modelEntry{}, "", modelProvider{}, fmt.Errorf("models patch is invalid") -} - -func resolveAccountBaseURL(account *model.AgentAccount) string { - baseURL := strings.TrimSpace(account.BaseURL) - if baseURL == "" { - if defaultURL, ok := providerDefaultBaseURL(account.Provider); ok { - baseURL = defaultURL - } - } - return baseURL -} - -func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto.AgentAccountModel, legacyModel string) ([]dto.AgentAccountModel, error) { - if account == nil { - return nil, fmt.Errorf("account is required") - } - if len(requested) > 0 || strings.TrimSpace(legacyModel) != "" { - models, _, err := normalizeAgentAccountModels(account, requested, legacyModel, true) - if err != nil { - return nil, err - } - return models, nil - } - meta, ok := providercatalog.Get(account.Provider) - if !ok || len(meta.Models) == 0 { - return nil, nil - } - requested = make([]dto.AgentAccountModel, 0, len(meta.Models)) - for _, item := range meta.Models { - requested = append(requested, dto.AgentAccountModel{ - ID: item.ID, - Name: item.Name, - ContextWindow: item.ContextWindow, - MaxTokens: item.MaxTokens, - Reasoning: item.Reasoning, - Input: append([]string(nil), item.Input...), - }) - } - models, _, err := normalizeAgentAccountModels(account, requested, "", true) - if err != nil { - return nil, err - } - return models, nil -} - -func compactPersistedAgentAccountModelSortOrder(accountID uint) error { - rows, err := agentAccountModelRepo.List(repo.WithByAccountID(accountID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id")) - if err != nil { - return err - } - for index := range rows { - order := index + 1 - if rows[index].SortOrder == order { - continue - } - rows[index].SortOrder = order - if err := agentAccountModelRepo.Save(&rows[index]); err != nil { - return err - } - } - return nil -} - -func loadAgentAccountModels(account *model.AgentAccount) ([]dto.AgentAccountModel, error) { - models, err := ensurePersistedAgentAccountModels(account) - if err != nil { - return nil, err - } - return models, nil -} - -func LoadLegacyAgentAccountModelsForMigration(account *model.AgentAccount) ([]dto.AgentAccountModel, error) { - if account == nil { - return nil, fmt.Errorf("account is required") - } - if !hasLegacyAgentAccountModels(account) { - return nil, nil - } - models, _, err := loadLegacyAgentAccountModelCatalog(account) - if err != nil { - if strings.TrimSpace(err.Error()) == "model is required" { - return nil, nil - } - return nil, err - } - return models, nil -} - -func MergeCatalogAgentAccountModelsForMigration(account *model.AgentAccount, existing []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) { - if account == nil { - return nil, fmt.Errorf("account is required") - } - meta, ok := providercatalog.Get(account.Provider) - if !ok || len(meta.Models) == 0 { - return append([]dto.AgentAccountModel(nil), existing...), nil - } - requested := append([]dto.AgentAccountModel(nil), existing...) - seen := make(map[string]struct{}, len(existing)) - for _, item := range existing { - if strings.TrimSpace(item.ID) == "" { - continue - } - seen[strings.TrimSpace(item.ID)] = struct{}{} - } - for _, item := range meta.Models { - if _, ok := seen[strings.TrimSpace(item.ID)]; ok { - continue - } - requested = append(requested, dto.AgentAccountModel{ - ID: item.ID, - Name: item.Name, - ContextWindow: item.ContextWindow, - MaxTokens: item.MaxTokens, - Reasoning: item.Reasoning, - Input: append([]string(nil), item.Input...), - }) - } - if len(requested) == len(existing) { - return append([]dto.AgentAccountModel(nil), existing...), nil - } - normalized, _, err := normalizeAgentAccountModels(account, requested, "", true) - if err != nil { - return nil, err - } - return normalized, nil -} - -func hasLegacyAgentAccountModels(account *model.AgentAccount) bool { - if account == nil { - return false - } - if strings.TrimSpace(account.Models) != "" || strings.TrimSpace(account.Model) != "" { - return true - } - if account.ID > 0 { - if agents, err := agentRepo.List(repo.WithByAccountID(account.ID)); err == nil { - for _, agent := range agents { - if strings.TrimSpace(agent.Model) != "" { - return true - } - } - } - } - if definitions, ok := providerDefinitions()[strings.ToLower(strings.TrimSpace(account.Provider))]; ok { - return len(definitions.Models) > 0 - } - return false -} - -func ensurePersistedAgentAccountModels(account *model.AgentAccount) ([]dto.AgentAccountModel, error) { - if account == nil { - return nil, fmt.Errorf("account is required") - } - models, err := listPersistedAgentAccountModels(account.ID) - if err != nil { - return nil, err - } - if len(models) > 0 { - return models, nil - } - if !hasLegacyAgentAccountModels(account) { - return nil, nil - } - legacyModels, _, err := loadLegacyAgentAccountModelCatalog(account) - if err != nil { - if strings.TrimSpace(err.Error()) == "model is required" { - return nil, nil - } - return nil, err - } - if len(legacyModels) == 0 { - return nil, nil - } - if account.ID == 0 { - return legacyModels, nil - } - if err := replacePersistedAgentAccountModels(account.ID, legacyModels); err != nil { - return nil, err - } - return listPersistedAgentAccountModels(account.ID) -} - -func listPersistedAgentAccountModels(accountID uint) ([]dto.AgentAccountModel, error) { - if accountID == 0 { - return nil, nil - } - rows, err := agentAccountModelRepo.List(repo.WithByAccountID(accountID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id")) - if err != nil { - return nil, err - } - result := make([]dto.AgentAccountModel, 0, len(rows)) - for _, row := range rows { - inputs := []string{} - if strings.TrimSpace(row.Input) != "" { - _ = json.Unmarshal([]byte(row.Input), &inputs) - } - result = append(result, dto.AgentAccountModel{ - RecordID: row.ID, - ID: strings.TrimSpace(row.Model), - Name: strings.TrimSpace(row.Name), - ContextWindow: row.ContextWindow, - MaxTokens: row.MaxTokens, - Reasoning: row.Reasoning, - Input: sanitizeAgentAccountModelInputs(inputs), - }) - } - return result, nil -} - -func replacePersistedAgentAccountModels(accountID uint, models []dto.AgentAccountModel) error { - return global.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("account_id = ?", accountID).Delete(&model.AgentAccountModel{}).Error; err != nil { - return err - } - for index, item := range models { - inputPayload, err := json.Marshal(sanitizeAgentAccountModelInputs(item.Input)) - if err != nil { - return err - } - record := &model.AgentAccountModel{ - AccountID: accountID, - Model: strings.TrimSpace(item.ID), - Name: strings.TrimSpace(item.Name), - ContextWindow: item.ContextWindow, - MaxTokens: item.MaxTokens, - Reasoning: item.Reasoning, - Input: string(inputPayload), - SortOrder: index + 1, - } - if err := tx.Create(record).Error; err != nil { - return err - } - } - return nil - }) -} - -func loadLegacyAgentAccountModelCatalog(account *model.AgentAccount) ([]dto.AgentAccountModel, string, error) { - if account == nil { - return nil, "", fmt.Errorf("account is required") - } - models, err := parseAgentAccountModels(account.Models) - if err != nil { - return nil, "", err - } - normalized, _, err := normalizeAgentAccountModels(account, models, account.Model, true) - if err != nil { - return nil, "", err - } - _, defaultModel, err := normalizeAgentAccountModels(account, normalized, account.Model, true) - if err != nil { - return nil, "", err - } - return normalized, defaultModel, nil -} - -func normalizeAgentAccountModels(account *model.AgentAccount, models []dto.AgentAccountModel, defaultModel string, allowFallbackDefault bool) ([]dto.AgentAccountModel, string, error) { - requested := append([]dto.AgentAccountModel(nil), models...) - if len(requested) == 0 { - if strings.TrimSpace(defaultModel) != "" { - requested = []dto.AgentAccountModel{{ID: defaultModel}} - } else { - requested = buildLegacyAgentAccountModels(account) - } - } - normalized := make([]dto.AgentAccountModel, 0, len(requested)) - seen := make(map[string]struct{}, len(requested)) - for _, item := range requested { - normalizedItem, err := normalizeAgentAccountModel(account, item) - if err != nil { - return nil, "", err - } - if strings.TrimSpace(normalizedItem.ID) == "" { - continue - } - if _, ok := seen[normalizedItem.ID]; ok { - continue - } - seen[normalizedItem.ID] = struct{}{} - normalized = append(normalized, normalizedItem) - } - if len(normalized) == 0 { - return nil, "", fmt.Errorf("model is required") - } - - resolvedDefault := strings.TrimSpace(defaultModel) - if resolvedDefault != "" { - defaultItem, err := normalizeAgentAccountModel(account, dto.AgentAccountModel{ID: resolvedDefault}) - if err == nil { - resolvedDefault = defaultItem.ID - } - } - if resolvedDefault == "" && allowFallbackDefault { - resolvedDefault = normalized[0].ID - } - if _, ok := findAgentAccountModelForProvider(account.Provider, normalized, resolvedDefault); !ok { - if allowFallbackDefault { - resolvedDefault = normalized[0].ID - } else { - return nil, "", buserr.New("ErrAgentModelNotInAccount") - } - } - return normalized, resolvedDefault, nil -} - -func normalizeAgentAccountModel(account *model.AgentAccount, model dto.AgentAccountModel) (dto.AgentAccountModel, error) { - modelID := strings.TrimSpace(model.ID) - if modelID == "" { - return dto.AgentAccountModel{}, fmt.Errorf("model is required") - } - primaryModel, inferredEntry, _, _, err := inferOpenclawCatalogModel(account, modelID, model.MaxTokens, model.ContextWindow) - if err != nil { - return dto.AgentAccountModel{}, err - } - name := strings.TrimSpace(model.Name) - if name == "" { - name = strings.TrimSpace(inferredEntry.Name) - } - reasoning := model.Reasoning - if !model.Reasoning && model.Name == "" && model.MaxTokens == 0 && model.ContextWindow == 0 && len(model.Input) == 0 { - reasoning = inferredEntry.Reasoning - } - inputs := sanitizeAgentAccountModelInputs(model.Input) - if len(inputs) == 0 { - inputs = sanitizeAgentAccountModelInputs(inferredEntry.Input) - } - contextWindow := model.ContextWindow - if contextWindow <= 0 { - contextWindow = inferredEntry.ContextWindow - } - maxTokens := model.MaxTokens - if maxTokens <= 0 { - maxTokens = inferredEntry.MaxTokens - } - return dto.AgentAccountModel{ - ID: normalizeAgentAccountModelID(account.Provider, primaryModel, modelID), - Name: name, - ContextWindow: contextWindow, - MaxTokens: maxTokens, - Reasoning: reasoning, - Input: inputs, - }, nil -} - -func normalizeAgentAccountModelID(provider, primaryModel, requestedID string) string { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "custom", "vllm": - target := requestedID - if strings.TrimSpace(target) == "" { - target = primaryModel - } - return normalizeCustomModel(target) - case "ollama": - target := strings.TrimSpace(primaryModel) - if strings.HasPrefix(target, "ollama/") { - return target - } - target = strings.TrimSpace(requestedID) - if strings.HasPrefix(target, "ollama/") { - return target - } - target = strings.TrimLeft(strings.TrimSpace(target), "/") - if target == "" { - target = strings.TrimLeft(strings.TrimSpace(primaryModel), "/") - } - if target == "" { - return "" - } - return "ollama/" + target - default: - target := strings.TrimSpace(requestedID) - if target == "" { - target = strings.TrimSpace(primaryModel) - } - if target == "" { - return "" - } - prefix := poolModelPrefix(provider) - if strings.Contains(target, "/") { - parts := strings.SplitN(target, "/", 2) - targetPrefix := strings.ToLower(strings.TrimSpace(parts[0])) - targetModel := strings.TrimSpace(parts[1]) - if targetModel == "" { - return strings.TrimSpace(target) - } - for _, item := range supportedProviderModelPrefixes(provider) { - if item == targetPrefix { - if prefix != "" { - return prefix + "/" + targetModel - } - return strings.TrimSpace(target) - } - } - return strings.TrimSpace(target) - } - target = strings.TrimLeft(strings.TrimSpace(target), "/") - if prefix == "" { - return target - } - return prefix + "/" + target - } -} - -func buildLegacyAgentAccountModels(account *model.AgentAccount) []dto.AgentAccountModel { - modelIDs := make([]string, 0, 4) - seen := make(map[string]struct{}, 4) - appendModel := func(value string) { - target := strings.TrimSpace(value) - if target == "" { - return - } - if _, ok := seen[target]; ok { - return - } - seen[target] = struct{}{} - modelIDs = append(modelIDs, target) - } - appendModel(account.Model) - if account.ID > 0 { - if agents, err := agentRepo.List(repo.WithByAccountID(account.ID)); err == nil { - for _, agent := range agents { - appendModel(agent.Model) - } - } - } - if definitions, ok := providerDefinitions()[strings.ToLower(strings.TrimSpace(account.Provider))]; ok && len(definitions.Models) > 0 { - for _, item := range definitions.Models { - appendModel(item.ID) - } - } - models := make([]dto.AgentAccountModel, 0, len(modelIDs)) - for _, modelID := range modelIDs { - models = append(models, dto.AgentAccountModel{ID: modelID}) - } - return models -} - -func parseAgentAccountModels(value string) ([]dto.AgentAccountModel, error) { - trim := strings.TrimSpace(value) - if trim == "" { - return nil, nil - } - var models []dto.AgentAccountModel - if err := json.Unmarshal([]byte(trim), &models); err != nil { - return nil, err - } - return models, nil -} - -func marshalAgentAccountModels(models []dto.AgentAccountModel) (string, error) { - payload, err := json.Marshal(models) - if err != nil { - return "", err - } - return string(payload), nil -} - -func sanitizeAgentAccountModelInputs(values []string) []string { - result := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - normalized := strings.ToLower(strings.TrimSpace(value)) - if normalized != "text" && normalized != "image" { - continue - } - if _, ok := seen[normalized]; ok { - continue - } - seen[normalized] = struct{}{} - result = append(result, normalized) - } - if len(result) == 0 { - return []string{"text"} - } - return result -} - -func shouldRefreshAccountModelRuntimeLimits(provider string) bool { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "custom", "vllm", "ollama": - return true - default: - return false - } -} - -func refreshAccountModelRuntimeLimits(account *model.AgentAccount, models []dto.AgentAccountModel) []dto.AgentAccountModel { - refreshed := make([]dto.AgentAccountModel, 0, len(models)) - for _, item := range models { - next := item - next.MaxTokens = account.MaxTokens - next.ContextWindow = account.ContextWindow - refreshed = append(refreshed, next) - } - return refreshed -} - -func normalizeComparableProviderModelID(provider, modelID string) string { - target := strings.TrimSpace(modelID) - if target == "" { - return "" - } - if !strings.Contains(target, "/") { - return target - } - parts := strings.SplitN(target, "/", 2) - prefix := strings.ToLower(strings.TrimSpace(parts[0])) - model := strings.TrimSpace(parts[1]) - if model == "" { - return target - } - for _, item := range supportedProviderModelPrefixes(provider) { - if item == prefix { - return model - } - } - return target -} - -func sameProviderModelID(provider, left, right string) bool { - leftTrimmed := strings.TrimSpace(left) - rightTrimmed := strings.TrimSpace(right) - if leftTrimmed == rightTrimmed { - return true - } - leftComparable := normalizeComparableProviderModelID(provider, leftTrimmed) - rightComparable := normalizeComparableProviderModelID(provider, rightTrimmed) - return leftComparable != "" && leftComparable == rightComparable -} - -func findAgentAccountModelForProvider(provider string, models []dto.AgentAccountModel, modelID string) (dto.AgentAccountModel, bool) { - for _, item := range models { - if sameProviderModelID(provider, item.ID, modelID) { - return item, true - } - } - return dto.AgentAccountModel{}, false -} - -func ensureAccountModelsNotBound(account *model.AgentAccount, models []dto.AgentAccountModel) error { - if account == nil || account.ID == 0 { - return nil - } - agents, err := agentRepo.List(repo.WithByAccountID(account.ID)) - if err != nil { - return err - } - for _, agent := range agents { - if strings.TrimSpace(agent.Model) == "" { - continue - } - if _, ok := findAgentAccountModelForProvider(account.Provider, models, agent.Model); !ok { - return buserr.WithName("ErrAgentModelInUse", agent.Name) - } - } - return nil -} - -func resolveServerTimezone() string { - timezone := strings.TrimSpace(common.LoadTimeZoneByCmd()) - if timezone == "" { - return defaultUserTimezone - } - if _, err := time.LoadLocation(timezone); err != nil { - return defaultUserTimezone - } - return timezone -} - -func ensureChildMap(parent map[string]interface{}, key string) map[string]interface{} { - if child, ok := parent[key].(map[string]interface{}); ok { - return child - } - child := map[string]interface{}{} - parent[key] = child - return child -} - -func structToMap(value interface{}) (map[string]interface{}, error) { - payload, err := json.Marshal(value) - if err != nil { - return nil, err - } - result := map[string]interface{}{} - if err := json.Unmarshal(payload, &result); err != nil { - return nil, err - } - return result, nil -} - -func mapToModelsConfig(value map[string]interface{}) (*modelsConfig, error) { - payload, err := json.Marshal(value) - if err != nil { - return nil, err - } - result := &modelsConfig{} - if err := json.Unmarshal(payload, result); err != nil { - return nil, err - } - return result, nil -} - -func providerEnvKey(provider string) string { - return providercatalog.EnvKey(provider) -} - -type providerDefinition struct { - Sort uint - DisplayName string - BaseURL string - Models []dto.ProviderModelInfo -} - -func providerDefinitions() map[string]providerDefinition { - definitions := map[string]providerDefinition{} - for key, meta := range providercatalog.All() { - if !meta.Enabled { - continue - } - models := make([]dto.ProviderModelInfo, 0, len(meta.Models)) - for _, m := range meta.Models { - models = append(models, dto.ProviderModelInfo{ - ID: m.ID, - Name: m.Name, - ContextWindow: m.ContextWindow, - MaxTokens: m.MaxTokens, - Reasoning: m.Reasoning, - Input: append([]string(nil), m.Input...), - }) - } - definitions[key] = providerDefinition{ - Sort: meta.Sort, - DisplayName: meta.DisplayName, - BaseURL: meta.DefaultBaseURL, - Models: models, - } - } - return definitions -} - -func providerDefaultBaseURL(provider string) (string, bool) { - return providercatalog.DefaultBaseURL(provider) -} - -func fixedProviderBaseURL(provider string) (string, bool) { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "bailian-coding-plan": - return providerDefaultBaseURL(provider) - case "ark-coding-plan": - return providerDefaultBaseURL(provider) - default: - return "", false - } -} - -func isSupportedAgentProvider(provider string) bool { - return providercatalog.IsEnabled(provider) -} - -func providerDisplayName(provider string) string { - return providercatalog.DisplayName(provider) -} - -func readInstallEnv(envStr string) map[string]interface{} { - if strings.TrimSpace(envStr) == "" { - return nil - } - data := map[string]interface{}{} - if err := json.Unmarshal([]byte(envStr), &data); err != nil { - return nil - } - return data -} - -func maskKey(value string) string { - trim := strings.TrimSpace(value) - if len(trim) <= 6 { - return trim - } - return fmt.Sprintf("%s****%s", trim[:3], trim[len(trim)-3:]) -} - -func toInt(value interface{}) int { - switch v := value.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - case string: - if v == "" { - return 0 - } - parsed, _ := strconv.Atoi(v) - return parsed - default: - return 0 - } -} - -func normalizeCustomModel(modelName string) string { - trim := strings.TrimSpace(modelName) - trim = strings.TrimLeft(trim, "/") - if parts := strings.SplitN(trim, "/", 2); len(parts) == 2 { - if strings.EqualFold(parts[0], "custom") { - return strings.TrimLeft(strings.TrimSpace(parts[1]), "/") - } - } - return trim -} - -func normalizeBailianCodingPlanModelID(modelID string) string { - trim := strings.TrimSpace(modelID) - switch strings.ToLower(trim) { - case "minimax-m2.5", "minimax m2.5", "minimax/minimax-m2.5", "minimax/minimax m2.5": - return "MiniMax/MiniMax-M2.5" - default: - return trim - } -} - -func normalizeArkCodingPlanModelID(modelID string) string { - return strings.ToLower(strings.TrimSpace(modelID)) -} - -func zaiModelDisplayName(modelID string) string { - switch strings.ToLower(strings.TrimSpace(modelID)) { - case "glm-5": - return "GLM-5" - case "glm-4.7": - return "GLM-4.7" - case "glm-4.7-flash": - return "GLM-4.7-Flash" - case "glm-4.7-flashx": - return "GLM-4.7-FlashX" - default: - return strings.TrimSpace(modelID) - } -} - -func bailianPrimaryModelID(modelID string) string { - trim := strings.TrimSpace(modelID) - if trim == "" { - return "" - } - parts := strings.Split(trim, "/") - for i := len(parts) - 1; i >= 0; i-- { - part := strings.TrimSpace(parts[i]) - if part != "" { - return part - } - } - return trim -} - -func normalizeAgentType(agentType string) string { - trim := strings.ToLower(strings.TrimSpace(agentType)) - if trim == "" { - return constant.AppOpenclaw - } - return trim -} - -func modelMatchesProvider(provider, modelName string) bool { - target := strings.TrimSpace(modelName) - for _, prefix := range supportedProviderModelPrefixes(provider) { - if prefix != "" && strings.HasPrefix(target, prefix+"/") { - return true - } - } - return false -} - -func runtimeProviderModelPrefix(provider string) string { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "gemini": - return "google" - case "minimax": - return "minimax-portal" - case "kimi": - return "moonshot" - default: - return strings.ToLower(strings.TrimSpace(provider)) - } -} - -func poolModelPrefix(provider string) string { - target := strings.ToLower(strings.TrimSpace(provider)) - if definitions, ok := providerDefinitions()[target]; ok && len(definitions.Models) > 0 { - parts := strings.SplitN(strings.TrimSpace(definitions.Models[0].ID), "/", 2) - if len(parts) == 2 && strings.TrimSpace(parts[0]) != "" { - return strings.ToLower(strings.TrimSpace(parts[0])) - } - } - return target -} - -func supportedProviderModelPrefixes(provider string) []string { - values := []string{poolModelPrefix(provider), runtimeProviderModelPrefix(provider)} - result := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - target := strings.ToLower(strings.TrimSpace(value)) - if target == "" { - continue - } - if _, ok := seen[target]; ok { - continue - } - seen[target] = struct{}{} - result = append(result, target) - } - return result -} - -func isSupportedAgentType(agentType string) bool { - switch normalizeAgentType(agentType) { - case constant.AppOpenclaw, constant.AppCopaw: - return true - default: - return false - } -} - -func normalizeAPIType(apiType string) string { - trim := strings.ToLower(strings.TrimSpace(apiType)) - if trim == "" { - return "openai-completions" - } - return trim -} - -func isSupportedAPIType(apiType string) bool { - switch normalizeAPIType(apiType) { - case "openai-completions", "openai-responses", "anthropic-messages": - return true - default: - return false - } -} - -func isSupportedOllamaAPIType(apiType string) bool { - switch normalizeAPIType(apiType) { - case "openai-completions", "openai-responses": - return true - default: - return false - } -} - -func resolveRuntimeParams(provider, apiType string, maxTokens, contextWindow int) (string, int, int) { - resolvedAPI := normalizeAPIType(apiType) - if provider == "ollama" && !isSupportedOllamaAPIType(resolvedAPI) { - resolvedAPI = "openai-responses" - } - resolvedMaxTokens := maxTokens - resolvedContextWindow := contextWindow - if resolvedMaxTokens <= 0 { - switch provider { - case "deepseek": - resolvedMaxTokens = 8192 - case "zai": - resolvedMaxTokens = 131072 - case "openrouter": - resolvedMaxTokens = 8192 - case "minimax", "kimi-coding", "custom": - resolvedMaxTokens = 8192 - default: - resolvedMaxTokens = 8192 - } - } - if resolvedContextWindow <= 0 { - switch provider { - case "deepseek": - resolvedContextWindow = 128000 - case "zai": - resolvedContextWindow = 204800 - case "openrouter": - resolvedContextWindow = 128000 - case "minimax", "kimi-coding": - resolvedContextWindow = 200000 - case "custom", "vllm": - resolvedContextWindow = 128000 - default: - resolvedContextWindow = 256000 - } - } - return resolvedAPI, resolvedMaxTokens, resolvedContextWindow -} - -func generateToken() string { - bytes := make([]byte, 24) - if _, err := rand.Read(bytes); err != nil { - return "" - } - return hex.EncodeToString(bytes) -} - -func asyncReportAIProviderInstall(provider string) { - if global.CONF.Base.Mode != "stable" { - return - } - provider = strings.ToLower(strings.TrimSpace(provider)) - if provider == "" { - return - } - go func(provider string) { - query := url.Values{} - query.Set("product", "ai-provider") - query.Set("type", "install") - query.Set("version", provider) - reqURL := "https://community.fit2cloud.com/installation-analytics?" + query.Encode() - _, _, _ = req_helper.HandleRequest(reqURL, http.MethodGet, constant.TimeOut5s) - }(provider) -} diff --git a/agent/app/service/agents_utils.go b/agent/app/service/agents_utils.go new file mode 100644 index 000000000..b234164e9 --- /dev/null +++ b/agent/app/service/agents_utils.go @@ -0,0 +1,2006 @@ +package service + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/model" + providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider" + "github.com/1Panel-dev/1Panel/agent/app/repo" + "github.com/1Panel-dev/1Panel/agent/buserr" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/utils/cmd" + "github.com/1Panel-dev/1Panel/agent/utils/common" + "github.com/1Panel-dev/1Panel/agent/utils/files" + openclawutil "github.com/1Panel-dev/1Panel/agent/utils/openclaw" + "github.com/1Panel-dev/1Panel/agent/utils/req_helper" + "gorm.io/gorm" +) + +type AgentService struct{} + +func NewIAgentService() IAgentService { + return &AgentService{} +} + +type resolvedAgentAccountVerification struct { + Provider string + APIKey string + BaseURL string +} + +type resolvedAgentAccountInput struct { + Provider string + APIKey string + BaseURL string + APIType string +} + +func resolveAgentAccountVerification(provider, apiKey, baseURL string) (resolvedAgentAccountVerification, error) { + resolvedAPIKey := strings.TrimSpace(apiKey) + if resolvedAPIKey == "" { + return resolvedAgentAccountVerification{}, buserr.New("ErrAgentApiKeyRequired") + } + resolvedBaseURL := strings.TrimSpace(baseURL) + if fixedURL, ok := fixedProviderBaseURL(provider); ok { + resolvedBaseURL = fixedURL + } + if (provider == "custom" || provider == "vllm") && resolvedBaseURL == "" { + return resolvedAgentAccountVerification{}, buserr.New("ErrAgentBaseURLRequired") + } + if provider != "custom" && provider != "vllm" && resolvedBaseURL == "" { + if defaultURL, ok := providerDefaultBaseURL(provider); ok { + resolvedBaseURL = defaultURL + } + } + if provider == "ollama" && resolvedBaseURL == "" { + return resolvedAgentAccountVerification{}, buserr.New("ErrAgentBaseURLRequired") + } + return resolvedAgentAccountVerification{ + Provider: provider, + APIKey: resolvedAPIKey, + BaseURL: resolvedBaseURL, + }, nil +} + +func verifyResolvedAgentAccount(input resolvedAgentAccountVerification) error { + if providercatalog.SkipVerification(input.Provider) { + return nil + } + return providercatalog.VerifyAccount(input.Provider, input.BaseURL, input.APIKey) +} + +func resolveAgentAccountAPIType(provider, apiType, fallbackAPIType string) (string, error) { + resolvedAPIType := normalizeAPIType(apiType) + if provider == "custom" || provider == "vllm" { + if !isSupportedAPIType(resolvedAPIType) { + return "", fmt.Errorf("apiType is invalid") + } + return resolvedAPIType, nil + } + if provider == "ollama" { + if apiType == "" && fallbackAPIType != "" { + resolvedAPIType = normalizeAPIType(fallbackAPIType) + if !isSupportedOllamaAPIType(resolvedAPIType) { + resolvedAPIType = "openai-responses" + } + return resolvedAPIType, nil + } + if !isSupportedOllamaAPIType(resolvedAPIType) { + return "", fmt.Errorf("apiType is invalid") + } + return resolvedAPIType, nil + } + if fallbackAPIType != "" { + return normalizeAPIType(fallbackAPIType), nil + } + return resolvedAPIType, nil +} + +func resolveAgentAccountInput(provider, apiKey, baseURL, apiType, fallbackAPIType string) (resolvedAgentAccountInput, error) { + resolvedVerification, err := resolveAgentAccountVerification(provider, apiKey, baseURL) + if err != nil { + return resolvedAgentAccountInput{}, err + } + if err := verifyResolvedAgentAccount(resolvedVerification); err != nil { + return resolvedAgentAccountInput{}, err + } + resolvedAPIType, err := resolveAgentAccountAPIType(provider, apiType, fallbackAPIType) + if err != nil { + return resolvedAgentAccountInput{}, err + } + return resolvedAgentAccountInput{ + Provider: provider, + APIKey: resolvedVerification.APIKey, + BaseURL: resolvedVerification.BaseURL, + APIType: resolvedAPIType, + }, nil +} + +func readOpenclawConfig(configPath string) (map[string]interface{}, error) { + if strings.TrimSpace(configPath) == "" { + return nil, buserr.New("ErrRecordNotFound") + } + fileOp := files.NewFileOp() + content, err := fileOp.GetContent(configPath) + if err != nil { + return nil, err + } + conf := map[string]interface{}{} + if err := json.Unmarshal(content, &conf); err != nil { + return nil, err + } + return conf, nil +} + +func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error { + ensureGatewaySecurityDefaults(conf) + ensureOpenclawUpdateDefaults(conf) + payload, err := json.MarshalIndent(conf, "", " ") + if err != nil { + return err + } + fileOp := files.NewFileOp() + return fileOp.SaveFile(configPath, string(payload), 0600) +} + +func normalizeAllowedOrigins(origins []string) ([]string, error) { + if len(origins) == 0 { + return nil, nil + } + result := make([]string, 0, len(origins)) + seen := make(map[string]struct{}, len(origins)) + for _, origin := range origins { + origin = strings.TrimSpace(origin) + if origin == "" { + continue + } + normalized, err := normalizeAllowedOrigin(origin) + if err != nil { + return nil, err + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + return result, nil +} + +func normalizeAllowedOrigin(origin string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(origin)) + if err != nil { + return "", fmt.Errorf("invalid allowed origin: %s", origin) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("invalid allowed origin: %s", origin) + } + if parsed.User != nil || parsed.Host == "" || parsed.Hostname() == "" { + return "", fmt.Errorf("invalid allowed origin: %s", origin) + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("invalid allowed origin: %s", origin) + } + if pathValue := strings.TrimSpace(parsed.EscapedPath()); pathValue != "" && pathValue != "/" { + return "", fmt.Errorf("invalid allowed origin: %s", origin) + } + host := parsed.Hostname() + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + normalized := parsed.Scheme + "://" + host + if parsed.Port() != "" { + normalized += ":" + parsed.Port() + } + return normalized, nil +} + +func extractSecurityConfig(conf map[string]interface{}) dto.AgentSecurityConfig { + result := dto.AgentSecurityConfig{AllowedOrigins: []string{}} + gateway, ok := conf["gateway"].(map[string]interface{}) + if !ok { + return result + } + controlUi, ok := gateway["controlUi"].(map[string]interface{}) + if !ok { + return result + } + switch values := controlUi["allowedOrigins"].(type) { + case []interface{}: + for _, value := range values { + if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + result.AllowedOrigins = append(result.AllowedOrigins, strings.TrimSpace(text)) + } + } + case []string: + for _, value := range values { + if strings.TrimSpace(value) != "" { + result.AllowedOrigins = append(result.AllowedOrigins, strings.TrimSpace(value)) + } + } + } + return result +} + +func setSecurityConfig(conf map[string]interface{}, config dto.AgentSecurityConfig) { + ensureGatewaySecurityDefaults(conf) + gateway := ensureChildMap(conf, "gateway") + controlUi := ensureChildMap(gateway, "controlUi") + allowedOrigins := append([]string(nil), config.AllowedOrigins...) + if len(allowedOrigins) > 0 { + controlUi["allowedOrigins"] = allowedOrigins + } else { + delete(controlUi, "allowedOrigins") + } +} + +func ensureGatewaySecurityDefaults(conf map[string]interface{}) { + gateway := ensureChildMap(conf, "gateway") + controlUi := ensureChildMap(gateway, "controlUi") + if _, ok := controlUi["dangerouslyDisableDeviceAuth"]; !ok { + controlUi["dangerouslyDisableDeviceAuth"] = true + } + delete(controlUi, "dangerouslyAllowHostHeaderOriginFallback") + setTrustedProxies(gateway) +} + +func ensureOpenclawUpdateDefaults(conf map[string]interface{}) { + update := ensureChildMap(conf, "update") + if _, ok := update["checkOnStart"]; !ok { + update["checkOnStart"] = false + } +} + +func setTrustedProxies(gateway map[string]interface{}) { + proxies := make([]string, 0, 4) + seen := map[string]struct{}{} + switch values := gateway["trustedProxies"].(type) { + case []interface{}: + for _, value := range values { + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text == "" { + continue + } + if _, ok := seen[text]; ok { + continue + } + seen[text] = struct{}{} + proxies = append(proxies, text) + } + case []string: + for _, value := range values { + text := strings.TrimSpace(value) + if text == "" { + continue + } + if _, ok := seen[text]; ok { + continue + } + seen[text] = struct{}{} + proxies = append(proxies, text) + } + } + if _, ok := seen[openclawTrustedProxyLoopback]; !ok { + proxies = append(proxies, openclawTrustedProxyLoopback) + } + gateway["trustedProxies"] = proxies +} + +func extractFeishuConfig(conf map[string]interface{}) dto.AgentFeishuConfig { + result := dto.AgentFeishuConfig{Enabled: true, DmPolicy: "pairing"} + channels, ok := conf["channels"].(map[string]interface{}) + if !ok { + return result + } + feishu, ok := channels["feishu"].(map[string]interface{}) + if !ok { + return result + } + if enabled, ok := feishu["enabled"].(bool); ok { + result.Enabled = enabled + } + if dmPolicy, ok := feishu["dmPolicy"].(string); ok && strings.TrimSpace(dmPolicy) != "" { + result.DmPolicy = dmPolicy + } + accounts, ok := feishu["accounts"].(map[string]interface{}) + if !ok { + return result + } + main, ok := accounts["main"].(map[string]interface{}) + if !ok { + return result + } + if appID, ok := main["appId"].(string); ok { + result.AppID = appID + } + if appSecret, ok := main["appSecret"].(string); ok { + result.AppSecret = appSecret + } + if botName, ok := main["botName"].(string); ok { + result.BotName = botName + } + return result +} + +func setFeishuConfig(conf map[string]interface{}, config dto.AgentFeishuConfig) { + channels := ensureChildMap(conf, "channels") + feishu := ensureChildMap(channels, "feishu") + feishu["enabled"] = config.Enabled + feishu["dmPolicy"] = config.DmPolicy + + accounts := ensureChildMap(feishu, "accounts") + main := ensureChildMap(accounts, "main") + main["appId"] = config.AppID + main["appSecret"] = config.AppSecret + main["botName"] = config.BotName + + if strings.EqualFold(config.DmPolicy, "open") { + feishu["allowFrom"] = []string{"*"} + } +} + +func setFeishuPluginEnabled(conf map[string]interface{}, enabled bool) { + plugins := ensureChildMap(conf, "plugins") + entries := ensureChildMap(plugins, "entries") + feishu := ensureChildMap(entries, "feishu") + feishu["enabled"] = enabled +} + +func extractTelegramConfig(conf map[string]interface{}) dto.AgentTelegramConfig { + result := dto.AgentTelegramConfig{Enabled: true, DmPolicy: "pairing"} + channels, ok := conf["channels"].(map[string]interface{}) + if !ok { + return result + } + telegram, ok := channels["telegram"].(map[string]interface{}) + if !ok { + return result + } + if enabled, ok := telegram["enabled"].(bool); ok { + result.Enabled = enabled + } + if dmPolicy, ok := telegram["dmPolicy"].(string); ok && strings.TrimSpace(dmPolicy) != "" { + result.DmPolicy = dmPolicy + } + if botToken, ok := telegram["botToken"].(string); ok { + result.BotToken = botToken + } + if proxy, ok := telegram["proxy"].(string); ok { + result.Proxy = proxy + } + return result +} + +func setTelegramConfig(conf map[string]interface{}, config dto.AgentTelegramConfig) { + channels := ensureChildMap(conf, "channels") + telegram := map[string]interface{}{ + "enabled": config.Enabled, + "dmPolicy": config.DmPolicy, + "botToken": config.BotToken, + } + if strings.EqualFold(config.DmPolicy, "open") { + telegram["allowFrom"] = []string{"*"} + } + if strings.TrimSpace(config.Proxy) != "" { + telegram["proxy"] = strings.TrimSpace(config.Proxy) + } + channels["telegram"] = telegram +} + +func extractDiscordConfig(conf map[string]interface{}) dto.AgentDiscordConfig { + result := dto.AgentDiscordConfig{Enabled: true, DmPolicy: "pairing", GroupPolicy: "open"} + channels, ok := conf["channels"].(map[string]interface{}) + if !ok { + return result + } + discord, ok := channels["discord"].(map[string]interface{}) + if !ok { + return result + } + if enabled, ok := discord["enabled"].(bool); ok { + result.Enabled = enabled + } + if token, ok := discord["token"].(string); ok { + result.Token = token + } + if groupPolicy, ok := discord["groupPolicy"].(string); ok && strings.TrimSpace(groupPolicy) != "" { + result.GroupPolicy = groupPolicy + } + if proxy, ok := discord["proxy"].(string); ok { + result.Proxy = proxy + } + if policy, ok := discord["dmPolicy"].(string); ok && strings.TrimSpace(policy) != "" { + result.DmPolicy = policy + return result + } + // backward compatibility: old nested style + dm, ok := discord["dm"].(map[string]interface{}) + if ok { + if policy, ok := dm["policy"].(string); ok && strings.TrimSpace(policy) != "" { + result.DmPolicy = policy + } + } + return result +} + +func setDiscordConfig(conf map[string]interface{}, config dto.AgentDiscordConfig) { + channels := ensureChildMap(conf, "channels") + discord := ensureChildMap(channels, "discord") + discord["enabled"] = config.Enabled + discord["token"] = config.Token + discord["dmPolicy"] = config.DmPolicy + discord["groupPolicy"] = config.GroupPolicy + if strings.EqualFold(config.DmPolicy, "open") { + discord["allowFrom"] = []string{"*"} + } else { + delete(discord, "allowFrom") + } + if strings.TrimSpace(config.Proxy) != "" { + discord["proxy"] = strings.TrimSpace(config.Proxy) + } else { + delete(discord, "proxy") + } + delete(discord, "dm") +} + +func extractBrowserConfig(conf map[string]interface{}) browserConfig { + result := browserConfig{ + Enabled: true, + ExecutablePath: defaultBrowserExecutablePath, + Headless: true, + NoSandbox: true, + DefaultProfile: defaultBrowserProfile, + } + browser, ok := conf["browser"].(map[string]interface{}) + if !ok { + return result + } + if enabled, ok := browser["enabled"].(bool); ok { + result.Enabled = enabled + } + if executablePath, ok := browser["executablePath"].(string); ok && strings.TrimSpace(executablePath) != "" { + result.ExecutablePath = executablePath + } + if headless, ok := browser["headless"].(bool); ok { + result.Headless = headless + } + if noSandbox, ok := browser["noSandbox"].(bool); ok { + result.NoSandbox = noSandbox + } + if defaultProfile, ok := browser["defaultProfile"].(string); ok && strings.TrimSpace(defaultProfile) != "" { + result.DefaultProfile = defaultProfile + } + return result +} + +func setBrowserConfig(conf map[string]interface{}, config browserConfig) { + browser := ensureChildMap(conf, "browser") + browser["enabled"] = config.Enabled + browser["executablePath"] = defaultBrowserExecutablePath + browser["headless"] = config.Headless + browser["noSandbox"] = config.NoSandbox + if strings.TrimSpace(config.DefaultProfile) == "" { + browser["defaultProfile"] = defaultBrowserProfile + } else { + browser["defaultProfile"] = strings.TrimSpace(config.DefaultProfile) + } +} + +func extractQQBotConfig(conf map[string]interface{}) dto.AgentQQBotConfig { + result := dto.AgentQQBotConfig{Enabled: true} + channels, ok := conf["channels"].(map[string]interface{}) + if !ok { + return result + } + qqbot, ok := channels["qqbot"].(map[string]interface{}) + if !ok { + return result + } + if enabled, ok := qqbot["enabled"].(bool); ok { + result.Enabled = enabled + } + if appID, ok := qqbot["appId"].(string); ok { + result.AppID = appID + } + if clientSecret, ok := qqbot["clientSecret"].(string); ok { + result.ClientSecret = clientSecret + } + return result +} + +func extractWecomConfig(conf map[string]interface{}) dto.AgentWecomConfig { + result := dto.AgentWecomConfig{Enabled: true, DmPolicy: "pairing"} + channels, ok := conf["channels"].(map[string]interface{}) + if !ok { + return result + } + wecom, ok := channels["wecom"].(map[string]interface{}) + if !ok { + return result + } + if enabled, ok := wecom["enabled"].(bool); ok { + result.Enabled = enabled + } + if dmPolicy, ok := wecom["dmPolicy"].(string); ok && strings.TrimSpace(dmPolicy) != "" { + result.DmPolicy = strings.TrimSpace(dmPolicy) + } + if botID, ok := wecom["botId"].(string); ok { + result.BotID = botID + } + if secret, ok := wecom["secret"].(string); ok { + result.Secret = secret + } + return result +} + +func setWecomConfig(conf map[string]interface{}, config dto.AgentWecomConfig) { + channels := ensureChildMap(conf, "channels") + wecom := ensureChildMap(channels, "wecom") + wecom["enabled"] = config.Enabled + wecom["botId"] = strings.TrimSpace(config.BotID) + wecom["secret"] = strings.TrimSpace(config.Secret) + wecom["dmPolicy"] = strings.TrimSpace(config.DmPolicy) + if strings.EqualFold(config.DmPolicy, "open") { + wecom["allowFrom"] = []string{"*"} + } else { + wecom["allowFrom"] = []string{} + } + + plugins := ensureChildMap(conf, "plugins") + entries := ensureChildMap(plugins, "entries") + wecomEntry := ensureChildMap(entries, "wecom-openclaw-plugin") + wecomEntry["enabled"] = config.Enabled +} + +func setQQBotConfig(conf map[string]interface{}, config dto.AgentQQBotConfig) { + channels := ensureChildMap(conf, "channels") + qqbot := ensureChildMap(channels, "qqbot") + qqbot["enabled"] = config.Enabled + qqbot["allowFrom"] = []string{"*"} + qqbot["appId"] = strings.TrimSpace(config.AppID) + qqbot["clientSecret"] = strings.TrimSpace(config.ClientSecret) + + plugins := ensureChildMap(conf, "plugins") + entries := ensureChildMap(plugins, "entries") + qqbotEntry := ensureChildMap(entries, "qqbot") + qqbotEntry["enabled"] = config.Enabled +} + +func resolvePluginMeta(pluginType string) (string, string, error) { + switch pluginType { + case "qqbot": + return "@sliverp/qqbot@latest", "qqbot", nil + case "wecom": + return "@wecom/wecom-openclaw-plugin", "wecom-openclaw-plugin", nil + default: + return "", "", fmt.Errorf("unsupported plugin type") + } +} + +func checkPluginInstalled(containerName, pluginType string) (bool, error) { + _, pluginDir, err := resolvePluginMeta(pluginType) + if err != nil { + return false, err + } + if strings.TrimSpace(containerName) == "" { + return false, buserr.New("ErrRecordNotFound") + } + pluginPath := path.Join(openclawPluginBaseDir, pluginDir) + mgr := cmd.NewCommandMgr(cmd.WithTimeout(20 * time.Second)) + if err := mgr.RunBashCf("docker exec %s test -d %s", containerName, pluginPath); err != nil { + return false, nil + } + return true, nil +} + +func extractOtherConfig(conf map[string]interface{}) dto.AgentOtherConfig { + result := dto.AgentOtherConfig{UserTimezone: resolveServerTimezone(), BrowserEnabled: true} + agents, ok := conf["agents"].(map[string]interface{}) + if !ok { + browser := extractBrowserConfig(conf) + result.BrowserEnabled = browser.Enabled + return result + } + defaults, ok := agents["defaults"].(map[string]interface{}) + if !ok { + browser := extractBrowserConfig(conf) + result.BrowserEnabled = browser.Enabled + return result + } + if timezone, ok := defaults["userTimezone"].(string); ok && strings.TrimSpace(timezone) != "" { + result.UserTimezone = strings.TrimSpace(timezone) + } + browser := extractBrowserConfig(conf) + result.BrowserEnabled = browser.Enabled + return result +} + +func setOtherConfig(conf map[string]interface{}, config dto.AgentOtherConfig) { + agents := ensureChildMap(conf, "agents") + defaults := ensureChildMap(agents, "defaults") + timezone := strings.TrimSpace(config.UserTimezone) + if timezone == "" { + timezone = resolveServerTimezone() + } + defaults["userTimezone"] = timezone + setBrowserConfig(conf, browserConfig{ + Enabled: config.BrowserEnabled, + ExecutablePath: defaultBrowserExecutablePath, + Headless: true, + NoSandbox: true, + DefaultProfile: defaultBrowserProfile, + }) +} + +func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem { + agentType := normalizeAgentType(agent.AgentType) + if appInstall != nil && appInstall.ID > 0 && appInstall.App.Key == constant.AppCopaw { + agentType = constant.AppCopaw + } + item := dto.AgentItem{ + ID: agent.ID, + Name: agent.Name, + AgentType: agentType, + Provider: agent.Provider, + ProviderName: providerDisplayName(agent.Provider), + Model: agent.Model, + APIType: agent.APIType, + MaxTokens: agent.MaxTokens, + ContextWindow: agent.ContextWindow, + BaseURL: agent.BaseURL, + APIKey: maskKey(agent.APIKey), + Token: agent.Token, + Status: agent.Status, + Message: agent.Message, + AppInstallID: agent.AppInstallID, + AccountID: agent.AccountID, + ConfigPath: agent.ConfigPath, + CreatedAt: agent.CreatedAt, + } + if appInstall != nil && appInstall.ID > 0 { + item.Container = appInstall.ContainerName + item.AppVersion = appInstall.Version + if agentType == constant.AppOpenclaw { + if isOpenclawHTTPSVersion(appInstall.Version) { + item.WebUIPort = appInstall.HttpsPort + } else { + item.WebUIPort = appInstall.HttpPort + } + } else { + item.WebUIPort = appInstall.HttpPort + } + item.Path = appInstall.GetPath() + item.Status = appInstall.Status + item.Message = appInstall.Message + if envMap != nil { + if bridge, ok := envMap["PANEL_APP_PORT_BRIDGE"]; ok { + item.BridgePort = toInt(bridge) + } + } + } + return item +} + +func isOpenclawHTTPSVersion(version string) bool { + target := strings.TrimSpace(strings.ToLower(version)) + if target == "" || target == "latest" { + return true + } + if !strings.ContainsAny(target, "0123456789") { + return true + } + return common.CompareAppVersion(target, openclawHTTPSVersion) +} + +func shouldMigrateOpenclawHTTPSUpgrade(install *model.AppInstall, fromVersion, toVersion string) bool { + if install == nil || install.App.Key != constant.AppOpenclaw { + return false + } + return !isOpenclawHTTPSVersion(fromVersion) && isOpenclawHTTPSVersion(toVersion) +} + +func migrateOpenclawHTTPSUpgrade(install *model.AppInstall, fromVersion, toVersion string) error { + systemIP, _ := settingRepo.GetValueByKey("SystemIP") + return migrateOpenclawHTTPSUpgradeWithSystemIP(install, fromVersion, toVersion, systemIP) +} + +func migrateOpenclawHTTPSUpgradeWithSystemIP(install *model.AppInstall, fromVersion, toVersion, systemIP string) error { + if !shouldMigrateOpenclawHTTPSUpgrade(install, fromVersion, toVersion) { + return nil + } + migrateOpenclawInstallPorts(install) + if err := openclawutil.WriteCatchAllCaddyfile(install.GetPath()); err != nil { + return err + } + configPath := path.Join(install.GetPath(), "data", "conf", "openclaw.json") + var allowedOrigins []string + if conf, err := readOpenclawConfig(configPath); err == nil { + allowedOrigins = extractSecurityConfig(conf).AllowedOrigins + } + originHost := strings.TrimSpace(systemIP) + if originHost == "" { + originHost = openclawAllowedOriginHost + } + if install.HttpsPort > 0 { + allowedOrigin, err := buildOpenclawAllowedOrigin(originHost, install.HttpsPort) + if err == nil { + conf, err := readOpenclawConfig(configPath) + if err != nil { + return err + } + allowedOrigins = []string{allowedOrigin} + setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins}) + if err := writeOpenclawConfigRaw(configPath, conf); err != nil { + return err + } + } + } + return migrateOpenclawInstallEnv(install, allowedOrigins) +} + +func migrateOpenclawInstallPorts(install *model.AppInstall) { + if install == nil { + return + } + if install.HttpsPort == 0 && install.HttpPort > 0 { + install.HttpsPort = install.HttpPort + } + if install.HttpPort > 0 { + install.HttpPort = 0 + } +} + +func migrateOpenclawInstallEnv(install *model.AppInstall, allowedOrigins []string) error { + if install == nil { + return nil + } + envMap := make(map[string]interface{}) + if strings.TrimSpace(install.Env) != "" { + if err := json.Unmarshal([]byte(install.Env), &envMap); err != nil { + return err + } + } + if install.HttpsPort > 0 { + envMap["PANEL_APP_PORT_HTTPS"] = install.HttpsPort + } + if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" { + envMap["ALLOWED_ORIGIN"] = allowedOrigin + } + delete(envMap, "PANEL_APP_PORT_HTTP") + payload, err := json.Marshal(envMap) + if err != nil { + return err + } + install.Env = string(payload) + return nil +} + +func syncOpenclawAllowedOriginEnv(install *model.AppInstall, allowedOrigins []string) error { + if install == nil { + return nil + } + envMap := make(map[string]interface{}) + if strings.TrimSpace(install.Env) != "" { + if err := json.Unmarshal([]byte(install.Env), &envMap); err != nil { + return err + } + } + if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" { + envMap["ALLOWED_ORIGIN"] = allowedOrigin + } else { + delete(envMap, "ALLOWED_ORIGIN") + } + payload, err := json.Marshal(envMap) + if err != nil { + return err + } + install.Env = string(payload) + return nil +} + +func firstAllowedOrigin(allowedOrigins []string) string { + for _, origin := range allowedOrigins { + trimmed := strings.TrimSpace(origin) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func buildOpenclawAllowedOrigin(host string, port int) (string, error) { + host = strings.TrimSpace(host) + if host == "" || port <= 0 { + return "", fmt.Errorf("invalid openclaw allowed origin") + } + if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") && strings.Count(host, ":") > 1 { + host = "[" + host + "]" + } + return normalizeAllowedOrigin(fmt.Sprintf("https://%s:%d", host, port)) +} + +func checkAgentUpgradable(install model.AppInstall) bool { + if install.ID == 0 || install.Version == "" || install.Version == "latest" { + return false + } + if install.App.ID == 0 { + return false + } + details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(install.App.ID)) + if err != nil || len(details) == 0 { + return false + } + versions := make([]string, 0, len(details)) + for _, item := range details { + ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(item.ID), appIgnoreUpgradeRepo.WithScope("version")) + if len(ignores) > 0 { + continue + } + if common.IsCrossVersion(install.Version, item.Version) && !install.App.CrossVersionUpdate { + continue + } + versions = append(versions, item.Version) + } + if len(versions) == 0 { + return false + } + versions = common.GetSortedVersions(versions) + lastVersion := versions[0] + if common.IsCrossVersion(install.Version, lastVersion) { + return install.App.CrossVersionUpdate + } + return common.CompareVersion(lastVersion, install.Version) +} + +type openclawConfig struct { + Gateway gatewayConfig `json:"gateway"` + Agents agentsConfig `json:"agents"` + Browser browserConfig `json:"browser"` + Tools toolsConfig `json:"tools"` + Update updateConfig `json:"update"` + Models *modelsConfig `json:"models,omitempty"` +} + +type toolsConfig struct { + Profile string `json:"profile,omitempty"` + Sessions toolSessionsConfig `json:"sessions,omitempty"` +} + +type toolSessionsConfig struct { + Visibility string `json:"visibility,omitempty"` +} + +type updateConfig struct { + CheckOnStart bool `json:"checkOnStart"` +} + +type gatewayConfig struct { + Mode string `json:"mode"` + Bind string `json:"bind"` + Port int `json:"port"` + Auth gatewayAuth `json:"auth"` + ControlUi gatewayControlUi `json:"controlUi"` + TrustedProxies []string `json:"trustedProxies,omitempty"` +} + +type gatewayControlUi struct { + DangerouslyDisableDeviceAuth bool `json:"dangerouslyDisableDeviceAuth"` + AllowedOrigins []string `json:"allowedOrigins,omitempty"` +} + +type gatewayAuth struct { + Mode string `json:"mode"` + Token string `json:"token"` +} + +type agentsConfig struct { + Defaults agentDefaults `json:"defaults"` +} + +type agentDefaults struct { + UserTimezone string `json:"userTimezone,omitempty"` + Model modelRef `json:"model"` + Models map[string]map[string]interface{} `json:"models,omitempty"` +} + +type modelRef struct { + Primary string `json:"primary"` +} + +type modelsConfig struct { + Mode string `json:"mode,omitempty"` + Providers map[string]modelProvider `json:"providers,omitempty"` +} + +type modelProvider struct { + ApiKey string `json:"apiKey,omitempty"` + BaseUrl string `json:"baseUrl,omitempty"` + Api string `json:"api,omitempty"` + Models []modelEntry `json:"models,omitempty"` +} + +type modelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Reasoning bool `json:"reasoning"` + Input []string `json:"input"` + ContextWindow int `json:"contextWindow"` + MaxTokens int `json:"maxTokens"` + Cost modelCost `json:"cost"` +} + +type modelCost struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead float64 `json:"cacheRead"` + CacheWrite float64 `json:"cacheWrite"` +} + +type browserConfig struct { + Enabled bool `json:"enabled"` + ExecutablePath string `json:"executablePath"` + Headless bool `json:"headless"` + NoSandbox bool `json:"noSandbox"` + DefaultProfile string `json:"defaultProfile"` +} + +func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string) error { + if strings.TrimSpace(confDir) == "" { + return fmt.Errorf("config dir is required") + } + if account == nil { + return fmt.Errorf("account is required") + } + if strings.TrimSpace(modelName) == "" { + return fmt.Errorf("model is required") + } + if strings.TrimSpace(token) == "" { + return fmt.Errorf("gateway token is required") + } + fileOp := files.NewFileOp() + if !fileOp.Stat(confDir) { + if err := fileOp.CreateDir(confDir, constant.DirPerm); err != nil { + return err + } + } + primaryModel, defaultsModels, models, err := buildOpenclawModelsFromAccount(account, modelName) + if err != nil { + return err + } + + cfg := openclawConfig{ + Gateway: gatewayConfig{ + Mode: "local", + Bind: "loopback", + Port: openclawGatewayPort, + Auth: gatewayAuth{ + Mode: "token", + Token: token, + }, + ControlUi: gatewayControlUi{ + DangerouslyDisableDeviceAuth: true, + AllowedOrigins: append([]string(nil), allowedOrigins...), + }, + TrustedProxies: []string{openclawTrustedProxyLoopback}, + }, + Agents: agentsConfig{ + Defaults: agentDefaults{ + UserTimezone: resolveServerTimezone(), + Model: modelRef{Primary: primaryModel}, + Models: defaultsModels, + }, + }, + Browser: browserConfig{ + Enabled: true, + ExecutablePath: defaultBrowserExecutablePath, + Headless: true, + NoSandbox: true, + DefaultProfile: defaultBrowserProfile, + }, + Tools: toolsConfig{ + Profile: defaultToolsProfile, + Sessions: toolSessionsConfig{ + Visibility: defaultToolsSessionVisibility, + }, + }, + Update: updateConfig{ + CheckOnStart: false, + }, + Models: models, + } + + configPath := path.Join(confDir, "openclaw.json") + conf := map[string]interface{}{} + if fileOp.Stat(configPath) { + existing, err := readOpenclawConfig(configPath) + if err != nil { + return err + } + conf = existing + } + if len(conf) == 0 { + initial, err := structToMap(cfg) + if err != nil { + return err + } + conf = initial + } else { + if cfg.Models != nil { + modelsMap, err := structToMap(cfg.Models) + if err != nil { + return err + } + conf["models"] = modelsMap + } + if _, ok := conf["browser"]; !ok { + browserMap, err := structToMap(cfg.Browser) + if err != nil { + return err + } + conf["browser"] = browserMap + } + toolsMap := ensureChildMap(conf, "tools") + if profile, ok := toolsMap["profile"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", profile)) == "" { + toolsMap["profile"] = defaultToolsProfile + } + sessionsMap := ensureChildMap(toolsMap, "sessions") + if visibility, ok := sessionsMap["visibility"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", visibility)) == "" { + sessionsMap["visibility"] = defaultToolsSessionVisibility + } + agentsMap := ensureChildMap(conf, "agents") + defaultsMap := ensureChildMap(agentsMap, "defaults") + if tz, ok := defaultsMap["userTimezone"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", tz)) == "" { + defaultsMap["userTimezone"] = resolveServerTimezone() + } + modelMap := ensureChildMap(defaultsMap, "model") + modelMap["primary"] = cfg.Agents.Defaults.Model.Primary + if cfg.Agents.Defaults.Models != nil { + defaultsMap["models"] = cfg.Agents.Defaults.Models + } + + ensureGatewaySecurityDefaults(conf) + gatewayMap := ensureChildMap(conf, "gateway") + if _, ok := gatewayMap["mode"]; !ok { + gatewayMap["mode"] = "local" + } + if _, ok := gatewayMap["bind"]; !ok { + gatewayMap["bind"] = "loopback" + } + if _, ok := gatewayMap["port"]; !ok { + gatewayMap["port"] = openclawGatewayPort + } + authMap := ensureChildMap(gatewayMap, "auth") + if _, ok := authMap["mode"]; !ok { + authMap["mode"] = "token" + } + authMap["token"] = token + } + if allowedOrigins != nil { + setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins}) + } + if err := writeOpenclawConfigRaw(configPath, conf); err != nil { + return err + } + envPath := path.Join(confDir, ".env") + lines := []string{fmt.Sprintf("OPENCLAW_GATEWAY_TOKEN=%s", token)} + if envKey := providerEnvKey(account.Provider); envKey != "" && strings.TrimSpace(account.APIKey) != "" { + lines = append(lines, fmt.Sprintf("%s=%s", envKey, account.APIKey)) + } + content := strings.Join(lines, "\n") + "\n" + return fileOp.SaveFile(envPath, content, 0600) +} + +func prepareOpenclawInstallFiles(appInstall *model.AppInstall, account *model.AgentAccount, modelName, token string, allowedOrigins []string) error { + if appInstall == nil { + return fmt.Errorf("app install is required") + } + confDir := path.Join(appInstall.GetPath(), "data", "conf") + if err := writeOpenclawConfig(confDir, account, modelName, token, allowedOrigins); err != nil { + return err + } + dataDir := path.Join(appInstall.GetPath(), "data") + return files.NewFileOp().ChownR(dataDir, "1000", "1000", true) +} + +func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel string) (string, map[string]map[string]interface{}, *modelsConfig, error) { + accountModels, err := loadAgentAccountModels(account) + if err != nil { + return "", nil, nil, err + } + if len(accountModels) == 0 { + return "", nil, nil, fmt.Errorf("model is required") + } + selectedModel = strings.TrimSpace(selectedModel) + if selectedModel == "" { + selectedModel = strings.TrimSpace(accountModels[0].ID) + } + if selectedModel == "" { + return "", nil, nil, fmt.Errorf("model is required") + } + selectedAccountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, selectedModel) + if err != nil { + return "", nil, nil, err + } + selectedModel = selectedAccountModel.ID + + providerKey := "" + providerCfg := modelProvider{} + entries := make([]modelEntry, 0, len(accountModels)) + primaryModel := "" + defaultsModels := make(map[string]map[string]interface{}, len(accountModels)) + for _, item := range accountModels { + resolvedPrimary, entry, key, baseCfg, err := buildOpenclawCatalogModel(account, item) + if err != nil { + return "", nil, nil, err + } + if providerKey == "" { + providerKey = key + providerCfg.ApiKey = baseCfg.ApiKey + providerCfg.BaseUrl = baseCfg.BaseUrl + providerCfg.Api = baseCfg.Api + } + entries = append(entries, entry) + defaultsModels[resolvedPrimary] = map[string]interface{}{} + if sameProviderModelID(account.Provider, item.ID, selectedModel) { + primaryModel = resolvedPrimary + } + } + if primaryModel == "" { + return "", nil, nil, buserr.New("ErrAgentModelNotInAccount") + } + providerCfg.Models = entries + return primaryModel, defaultsModels, &modelsConfig{ + Mode: "merge", + Providers: map[string]modelProvider{ + providerKey: providerCfg, + }, + }, nil +} + +func buildOpenclawCatalogModel(account *model.AgentAccount, model dto.AgentAccountModel) (string, modelEntry, string, modelProvider, error) { + primaryModel, inferredEntry, providerKey, providerCfg, err := inferOpenclawCatalogModel(account, model.ID, model.MaxTokens, model.ContextWindow) + if err != nil { + return "", modelEntry{}, "", modelProvider{}, err + } + if strings.TrimSpace(model.Name) != "" { + inferredEntry.Name = strings.TrimSpace(model.Name) + } + if len(model.Input) > 0 { + inferredEntry.Input = sanitizeAgentAccountModelInputs(model.Input) + } + inferredEntry.Reasoning = model.Reasoning + if model.ContextWindow > 0 { + inferredEntry.ContextWindow = model.ContextWindow + } + if model.MaxTokens > 0 { + inferredEntry.MaxTokens = model.MaxTokens + } + return primaryModel, inferredEntry, providerKey, providerCfg, nil +} + +type openclawAccountModelRuntime struct { + StoredModel string + PrimaryModel string + APIType string + MaxTokens int + ContextWindow int +} + +func buildOpenclawAccountModelRuntime(account *model.AgentAccount, model dto.AgentAccountModel) (openclawAccountModelRuntime, error) { + apiType, maxTokens, contextWindow := resolveRuntimeParams( + account.Provider, + account.APIType, + model.MaxTokens, + model.ContextWindow, + ) + primaryModel, _, _, _, err := buildOpenclawCatalogModel(account, model) + if err != nil { + return openclawAccountModelRuntime{}, err + } + return openclawAccountModelRuntime{ + StoredModel: model.ID, + PrimaryModel: primaryModel, + APIType: apiType, + MaxTokens: maxTokens, + ContextWindow: contextWindow, + }, nil +} + +func resolveOpenclawAccountModelRuntimeByID(account *model.AgentAccount, modelID string) (openclawAccountModelRuntime, error) { + accountModels, err := loadAgentAccountModels(account) + if err != nil { + return openclawAccountModelRuntime{}, err + } + selectedAccountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, modelID) + if err != nil { + return openclawAccountModelRuntime{}, err + } + return buildOpenclawAccountModelRuntime(account, selectedAccountModel) +} + +func inferOpenclawCatalogModel(account *model.AgentAccount, modelID string, maxTokens, contextWindow int) (string, modelEntry, string, modelProvider, error) { + baseURL := resolveAccountBaseURL(account) + resolvedAPIType, resolvedMaxTokens, resolvedContextWindow := resolveRuntimeParams(account.Provider, account.APIType, maxTokens, contextWindow) + patch, err := providercatalog.BuildOpenClawPatch(account.Provider, modelID, resolvedAPIType, resolvedMaxTokens, resolvedContextWindow, baseURL, account.APIKey) + if err != nil { + return "", modelEntry{}, "", modelProvider{}, err + } + if patch.Models == nil { + return "", modelEntry{}, "", modelProvider{}, fmt.Errorf("models patch is required") + } + modelsCfg, err := mapToModelsConfig(patch.Models) + if err != nil { + return "", modelEntry{}, "", modelProvider{}, err + } + for key, providerCfg := range modelsCfg.Providers { + if len(providerCfg.Models) == 0 { + continue + } + return patch.PrimaryModel, providerCfg.Models[0], key, modelProvider{ + ApiKey: providerCfg.ApiKey, + BaseUrl: providerCfg.BaseUrl, + Api: providerCfg.Api, + }, nil + } + return "", modelEntry{}, "", modelProvider{}, fmt.Errorf("models patch is invalid") +} + +func resolveAccountBaseURL(account *model.AgentAccount) string { + baseURL := strings.TrimSpace(account.BaseURL) + if baseURL == "" { + if defaultURL, ok := providerDefaultBaseURL(account.Provider); ok { + baseURL = defaultURL + } + } + return baseURL +} + +func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) { + if account == nil { + return nil, fmt.Errorf("account is required") + } + if requiresInitialAgentAccountModels(account.Provider) && len(requested) > 1 { + return nil, buserr.New("ErrAgentAccountSingleInitialModel") + } + if len(requested) > 0 { + models, _, err := normalizeAgentAccountModels(account, requested, "", true) + if err != nil { + return nil, err + } + return models, nil + } + meta, ok := providercatalog.Get(account.Provider) + if !ok || len(meta.Models) == 0 { + if requiresInitialAgentAccountModels(account.Provider) { + return nil, buserr.New("ErrAgentAccountModelsRequired") + } + return nil, nil + } + requested = make([]dto.AgentAccountModel, 0, len(meta.Models)) + for _, item := range meta.Models { + requested = append(requested, dto.AgentAccountModel{ + ID: item.ID, + Name: item.Name, + ContextWindow: item.ContextWindow, + MaxTokens: item.MaxTokens, + Reasoning: item.Reasoning, + Input: append([]string(nil), item.Input...), + }) + } + models, _, err := normalizeAgentAccountModels(account, requested, "", true) + if err != nil { + return nil, err + } + return models, nil +} + +func compactPersistedAgentAccountModelSortOrder(accountID uint) error { + rows, err := agentAccountModelRepo.List(repo.WithByAccountID(accountID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id")) + if err != nil { + return err + } + for index := range rows { + order := index + 1 + if rows[index].SortOrder == order { + continue + } + rows[index].SortOrder = order + if err := agentAccountModelRepo.Save(&rows[index]); err != nil { + return err + } + } + return nil +} + +func loadAgentAccountModels(account *model.AgentAccount) ([]dto.AgentAccountModel, error) { + if account == nil { + return nil, fmt.Errorf("account is required") + } + return listPersistedAgentAccountModels(account.ID) +} + +func MergeCatalogAgentAccountModelsForMigration(account *model.AgentAccount, existing []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) { + if account == nil { + return nil, fmt.Errorf("account is required") + } + meta, ok := providercatalog.Get(account.Provider) + if !ok || len(meta.Models) == 0 { + return append([]dto.AgentAccountModel(nil), existing...), nil + } + requested := append([]dto.AgentAccountModel(nil), existing...) + seen := make(map[string]struct{}, len(existing)) + for _, item := range existing { + if strings.TrimSpace(item.ID) == "" { + continue + } + seen[strings.TrimSpace(item.ID)] = struct{}{} + } + for _, item := range meta.Models { + if _, ok := seen[strings.TrimSpace(item.ID)]; ok { + continue + } + requested = append(requested, dto.AgentAccountModel{ + ID: item.ID, + Name: item.Name, + ContextWindow: item.ContextWindow, + MaxTokens: item.MaxTokens, + Reasoning: item.Reasoning, + Input: append([]string(nil), item.Input...), + }) + } + if len(requested) == len(existing) { + return append([]dto.AgentAccountModel(nil), existing...), nil + } + normalized, _, err := normalizeAgentAccountModels(account, requested, "", true) + if err != nil { + return nil, err + } + return normalized, nil +} + +func listPersistedAgentAccountModels(accountID uint) ([]dto.AgentAccountModel, error) { + if accountID == 0 { + return nil, nil + } + rows, err := agentAccountModelRepo.List(repo.WithByAccountID(accountID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id")) + if err != nil { + return nil, err + } + result := make([]dto.AgentAccountModel, 0, len(rows)) + for _, row := range rows { + inputs := []string{} + if strings.TrimSpace(row.Input) != "" { + _ = json.Unmarshal([]byte(row.Input), &inputs) + } + result = append(result, dto.AgentAccountModel{ + RecordID: row.ID, + ID: strings.TrimSpace(row.Model), + Name: strings.TrimSpace(row.Name), + ContextWindow: row.ContextWindow, + MaxTokens: row.MaxTokens, + Reasoning: row.Reasoning, + Input: sanitizeAgentAccountModelInputs(inputs), + }) + } + return result, nil +} + +func replacePersistedAgentAccountModelsWithTx(tx *gorm.DB, accountID uint, models []dto.AgentAccountModel) error { + if err := tx.Where("account_id = ?", accountID).Delete(&model.AgentAccountModel{}).Error; err != nil { + return err + } + for index, item := range models { + inputPayload, err := json.Marshal(sanitizeAgentAccountModelInputs(item.Input)) + if err != nil { + return err + } + record := &model.AgentAccountModel{ + AccountID: accountID, + Model: strings.TrimSpace(item.ID), + Name: strings.TrimSpace(item.Name), + ContextWindow: item.ContextWindow, + MaxTokens: item.MaxTokens, + Reasoning: item.Reasoning, + Input: string(inputPayload), + SortOrder: index + 1, + } + if err := tx.Create(record).Error; err != nil { + return err + } + } + return nil +} + +func replacePersistedAgentAccountModels(accountID uint, models []dto.AgentAccountModel) error { + return global.DB.Transaction(func(tx *gorm.DB) error { + return replacePersistedAgentAccountModelsWithTx(tx, accountID, models) + }) +} + +func normalizeAgentAccountModels(account *model.AgentAccount, models []dto.AgentAccountModel, defaultModel string, allowFallbackDefault bool) ([]dto.AgentAccountModel, string, error) { + requested := append([]dto.AgentAccountModel(nil), models...) + if len(requested) == 0 { + if strings.TrimSpace(defaultModel) != "" { + requested = []dto.AgentAccountModel{{ID: defaultModel}} + } else { + requested = buildLegacyAgentAccountModels(account) + } + } + normalized := make([]dto.AgentAccountModel, 0, len(requested)) + seen := make(map[string]struct{}, len(requested)) + for _, item := range requested { + normalizedItem, err := normalizeAgentAccountModel(account, item) + if err != nil { + return nil, "", err + } + if strings.TrimSpace(normalizedItem.ID) == "" { + continue + } + if _, ok := seen[normalizedItem.ID]; ok { + continue + } + seen[normalizedItem.ID] = struct{}{} + normalized = append(normalized, normalizedItem) + } + if len(normalized) == 0 { + return nil, "", fmt.Errorf("model is required") + } + + resolvedDefault := strings.TrimSpace(defaultModel) + if resolvedDefault != "" { + defaultItem, err := normalizeAgentAccountModel(account, dto.AgentAccountModel{ID: resolvedDefault}) + if err == nil { + resolvedDefault = defaultItem.ID + } + } + if resolvedDefault == "" && allowFallbackDefault { + resolvedDefault = normalized[0].ID + } + if _, ok := findAgentAccountModelForProvider(account.Provider, normalized, resolvedDefault); !ok { + if allowFallbackDefault { + resolvedDefault = normalized[0].ID + } else { + return nil, "", buserr.New("ErrAgentModelNotInAccount") + } + } + return normalized, resolvedDefault, nil +} + +func normalizeAgentAccountModel(account *model.AgentAccount, model dto.AgentAccountModel) (dto.AgentAccountModel, error) { + modelID := strings.TrimSpace(model.ID) + if modelID == "" { + return dto.AgentAccountModel{}, fmt.Errorf("model is required") + } + primaryModel, inferredEntry, _, _, err := inferOpenclawCatalogModel(account, modelID, model.MaxTokens, model.ContextWindow) + if err != nil { + return dto.AgentAccountModel{}, err + } + name := strings.TrimSpace(model.Name) + if name == "" { + name = strings.TrimSpace(inferredEntry.Name) + } + reasoning := model.Reasoning + if !model.Reasoning && model.Name == "" && model.MaxTokens == 0 && model.ContextWindow == 0 && len(model.Input) == 0 { + reasoning = inferredEntry.Reasoning + } + inputs := sanitizeAgentAccountModelInputs(model.Input) + if len(inputs) == 0 { + inputs = sanitizeAgentAccountModelInputs(inferredEntry.Input) + } + contextWindow := model.ContextWindow + if contextWindow <= 0 { + contextWindow = inferredEntry.ContextWindow + } + maxTokens := model.MaxTokens + if maxTokens <= 0 { + maxTokens = inferredEntry.MaxTokens + } + return dto.AgentAccountModel{ + ID: normalizeAgentAccountModelID(account.Provider, primaryModel, modelID), + Name: name, + ContextWindow: contextWindow, + MaxTokens: maxTokens, + Reasoning: reasoning, + Input: inputs, + }, nil +} + +func normalizeAgentAccountModelID(provider, primaryModel, requestedID string) string { + switch provider { + case "custom", "vllm": + target := requestedID + if strings.TrimSpace(target) == "" { + target = primaryModel + } + return normalizeCustomModel(target) + case "ollama": + target := strings.TrimSpace(primaryModel) + if strings.HasPrefix(target, "ollama/") { + return target + } + target = strings.TrimSpace(requestedID) + if strings.HasPrefix(target, "ollama/") { + return target + } + target = strings.TrimLeft(strings.TrimSpace(target), "/") + if target == "" { + target = strings.TrimLeft(strings.TrimSpace(primaryModel), "/") + } + if target == "" { + return "" + } + return "ollama/" + target + default: + target := strings.TrimSpace(requestedID) + if target == "" { + target = strings.TrimSpace(primaryModel) + } + if target == "" { + return "" + } + prefix := poolModelPrefix(provider) + if strings.Contains(target, "/") { + parts := strings.SplitN(target, "/", 2) + targetPrefix := parts[0] + targetModel := strings.TrimSpace(parts[1]) + if targetModel == "" { + return strings.TrimSpace(target) + } + for _, item := range supportedProviderModelPrefixes(provider) { + if item == targetPrefix { + if prefix != "" { + return prefix + "/" + targetModel + } + return strings.TrimSpace(target) + } + } + return strings.TrimSpace(target) + } + target = strings.TrimLeft(strings.TrimSpace(target), "/") + if prefix == "" { + return target + } + return prefix + "/" + target + } +} + +func buildLegacyAgentAccountModels(account *model.AgentAccount) []dto.AgentAccountModel { + modelIDs := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) + appendModel := func(value string) { + target := strings.TrimSpace(value) + if target == "" { + return + } + if _, ok := seen[target]; ok { + return + } + seen[target] = struct{}{} + modelIDs = append(modelIDs, target) + } + if account.ID > 0 { + if agents, err := agentRepo.List(repo.WithByAccountID(account.ID)); err == nil { + for _, agent := range agents { + appendModel(agent.Model) + } + } + } + if definitions, ok := providerDefinitions()[account.Provider]; ok && len(definitions.Models) > 0 { + for _, item := range definitions.Models { + appendModel(item.ID) + } + } + models := make([]dto.AgentAccountModel, 0, len(modelIDs)) + for _, modelID := range modelIDs { + models = append(models, dto.AgentAccountModel{ID: modelID}) + } + return models +} + +func sanitizeAgentAccountModelInputs(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := value + if normalized != "text" && normalized != "image" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + if len(result) == 0 { + return []string{"text"} + } + return result +} + +func requiresInitialAgentAccountModels(provider string) bool { + switch provider { + case "custom", "vllm", "ollama": + return true + default: + return false + } +} + +func normalizeComparableProviderModelID(provider, modelID string) string { + target := strings.TrimSpace(modelID) + if target == "" { + return "" + } + if !strings.Contains(target, "/") { + return target + } + parts := strings.SplitN(target, "/", 2) + prefix := parts[0] + model := strings.TrimSpace(parts[1]) + if model == "" { + return target + } + for _, item := range supportedProviderModelPrefixes(provider) { + if item == prefix { + return model + } + } + return target +} + +func sameProviderModelID(provider, left, right string) bool { + leftTrimmed := strings.TrimSpace(left) + rightTrimmed := strings.TrimSpace(right) + if leftTrimmed == rightTrimmed { + return true + } + leftComparable := normalizeComparableProviderModelID(provider, leftTrimmed) + rightComparable := normalizeComparableProviderModelID(provider, rightTrimmed) + return leftComparable != "" && leftComparable == rightComparable +} + +func findAgentAccountModelForProvider(provider string, models []dto.AgentAccountModel, modelID string) (dto.AgentAccountModel, bool) { + for _, item := range models { + if sameProviderModelID(provider, item.ID, modelID) { + return item, true + } + } + return dto.AgentAccountModel{}, false +} + +func requireAgentAccountModelForProvider(provider string, models []dto.AgentAccountModel, modelID string) (dto.AgentAccountModel, error) { + selectedAccountModel, ok := findAgentAccountModelForProvider(provider, models, modelID) + if !ok { + return dto.AgentAccountModel{}, buserr.New("ErrAgentModelNotInAccount") + } + return selectedAccountModel, nil +} + +func ensureAccountModelsNotBound(account *model.AgentAccount, models []dto.AgentAccountModel) error { + if account == nil || account.ID == 0 { + return nil + } + agents, err := agentRepo.List(repo.WithByAccountID(account.ID)) + if err != nil { + return err + } + for _, agent := range agents { + if strings.TrimSpace(agent.Model) == "" { + continue + } + if _, ok := findAgentAccountModelForProvider(account.Provider, models, agent.Model); !ok { + return buserr.WithName("ErrAgentModelInUse", agent.Name) + } + } + return nil +} + +func resolveServerTimezone() string { + timezone := strings.TrimSpace(common.LoadTimeZoneByCmd()) + if timezone == "" { + return defaultUserTimezone + } + if _, err := time.LoadLocation(timezone); err != nil { + return defaultUserTimezone + } + return timezone +} + +func ensureChildMap(parent map[string]interface{}, key string) map[string]interface{} { + if child, ok := parent[key].(map[string]interface{}); ok { + return child + } + child := map[string]interface{}{} + parent[key] = child + return child +} + +func structToMap(value interface{}) (map[string]interface{}, error) { + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + result := map[string]interface{}{} + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return result, nil +} + +func mapToModelsConfig(value map[string]interface{}) (*modelsConfig, error) { + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + result := &modelsConfig{} + if err := json.Unmarshal(payload, result); err != nil { + return nil, err + } + return result, nil +} + +func providerEnvKey(provider string) string { + return providercatalog.EnvKey(provider) +} + +type providerDefinition struct { + Sort uint + DisplayName string + BaseURL string + Models []dto.ProviderModelInfo +} + +func providerDefinitions() map[string]providerDefinition { + definitions := map[string]providerDefinition{} + for key, meta := range providercatalog.All() { + models := make([]dto.ProviderModelInfo, 0, len(meta.Models)) + for _, m := range meta.Models { + models = append(models, dto.ProviderModelInfo{ + ID: m.ID, + Name: m.Name, + ContextWindow: m.ContextWindow, + MaxTokens: m.MaxTokens, + Reasoning: m.Reasoning, + Input: append([]string(nil), m.Input...), + }) + } + definitions[key] = providerDefinition{ + Sort: meta.Sort, + DisplayName: meta.DisplayName, + BaseURL: meta.DefaultBaseURL, + Models: models, + } + } + return definitions +} + +func providerDefaultBaseURL(provider string) (string, bool) { + return providercatalog.DefaultBaseURL(provider) +} + +func fixedProviderBaseURL(provider string) (string, bool) { + switch provider { + case "bailian-coding-plan": + return providerDefaultBaseURL(provider) + case "ark-coding-plan": + return providerDefaultBaseURL(provider) + default: + return "", false + } +} + +func providerDisplayName(provider string) string { + return providercatalog.DisplayName(provider) +} + +func readInstallEnv(envStr string) map[string]interface{} { + if strings.TrimSpace(envStr) == "" { + return nil + } + data := map[string]interface{}{} + if err := json.Unmarshal([]byte(envStr), &data); err != nil { + return nil + } + return data +} + +func maskKey(value string) string { + trim := strings.TrimSpace(value) + if len(trim) <= 6 { + return trim + } + return fmt.Sprintf("%s****%s", trim[:3], trim[len(trim)-3:]) +} + +func toInt(value interface{}) int { + switch v := value.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + if v == "" { + return 0 + } + parsed, _ := strconv.Atoi(v) + return parsed + default: + return 0 + } +} + +func normalizeCustomModel(modelName string) string { + trim := strings.TrimSpace(modelName) + trim = strings.TrimLeft(trim, "/") + if parts := strings.SplitN(trim, "/", 2); len(parts) == 2 { + if strings.EqualFold(parts[0], "custom") { + return strings.TrimLeft(strings.TrimSpace(parts[1]), "/") + } + } + return trim +} + +func normalizeAgentType(agentType string) string { + trim := agentType + if trim == "" { + return constant.AppOpenclaw + } + return trim +} + +func modelMatchesProvider(provider, modelName string) bool { + target := strings.TrimSpace(modelName) + for _, prefix := range supportedProviderModelPrefixes(provider) { + if prefix != "" && strings.HasPrefix(target, prefix+"/") { + return true + } + } + return false +} + +func runtimeProviderModelPrefix(provider string) string { + switch provider { + case "gemini": + return "google" + case "minimax": + return "minimax-portal" + case "kimi": + return "moonshot" + default: + return provider + } +} + +func poolModelPrefix(provider string) string { + target := provider + if definitions, ok := providerDefinitions()[target]; ok && len(definitions.Models) > 0 { + parts := strings.SplitN(strings.TrimSpace(definitions.Models[0].ID), "/", 2) + if len(parts) == 2 && strings.TrimSpace(parts[0]) != "" { + return parts[0] + } + } + return target +} + +func supportedProviderModelPrefixes(provider string) []string { + values := []string{poolModelPrefix(provider), runtimeProviderModelPrefix(provider)} + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + target := value + if target == "" { + continue + } + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + result = append(result, target) + } + return result +} + +func normalizeAPIType(apiType string) string { + trim := apiType + if trim == "" { + return "openai-completions" + } + return trim +} + +func isSupportedAPIType(apiType string) bool { + switch normalizeAPIType(apiType) { + case "openai-completions", "openai-responses", "anthropic-messages": + return true + default: + return false + } +} + +func isSupportedOllamaAPIType(apiType string) bool { + switch normalizeAPIType(apiType) { + case "openai-completions", "openai-responses": + return true + default: + return false + } +} + +func resolveRuntimeParams(provider, apiType string, maxTokens, contextWindow int) (string, int, int) { + resolvedAPI := normalizeAPIType(apiType) + if provider == "ollama" && !isSupportedOllamaAPIType(resolvedAPI) { + resolvedAPI = "openai-responses" + } + resolvedMaxTokens := maxTokens + resolvedContextWindow := contextWindow + if resolvedMaxTokens <= 0 { + switch provider { + case "deepseek": + resolvedMaxTokens = 8192 + case "zai": + resolvedMaxTokens = 131072 + case "openrouter": + resolvedMaxTokens = 8192 + case "minimax", "kimi-coding", "custom": + resolvedMaxTokens = 8192 + default: + resolvedMaxTokens = 8192 + } + } + if resolvedContextWindow <= 0 { + switch provider { + case "deepseek": + resolvedContextWindow = 128000 + case "zai": + resolvedContextWindow = 204800 + case "openrouter": + resolvedContextWindow = 128000 + case "minimax", "kimi-coding": + resolvedContextWindow = 200000 + case "custom", "vllm": + resolvedContextWindow = 128000 + default: + resolvedContextWindow = 256000 + } + } + return resolvedAPI, resolvedMaxTokens, resolvedContextWindow +} + +func generateToken() string { + bytes := make([]byte, 24) + if _, err := rand.Read(bytes); err != nil { + return "" + } + return hex.EncodeToString(bytes) +} + +func asyncReportAIProviderInstall(provider string) { + if global.CONF.Base.Mode != "stable" { + return + } + provider = provider + if provider == "" { + return + } + go func(provider string) { + query := url.Values{} + query.Set("product", "ai-provider") + query.Set("type", "install") + query.Set("version", provider) + reqURL := "https://community.fit2cloud.com/installation-analytics?" + query.Encode() + _, _, _ = req_helper.HandleRequest(reqURL, http.MethodGet, constant.TimeOut5s) + }(provider) +} diff --git a/agent/app/service/app.go b/agent/app/service/app.go index 4f0e32fb0..cc8d77932 100644 --- a/agent/app/service/app.go +++ b/agent/app/service/app.go @@ -57,6 +57,10 @@ type IAppService interface { GetAppDetailByKey(appKey, version string) (response.AppDetailSimpleDTO, error) } +type appInstallHooks struct { + AfterCopyData func(appInstall *model.AppInstall) error +} + func NewIAppService() IAppService { return &AppService{} } @@ -340,6 +344,10 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) { } func (a AppService) Install(req request.AppInstallCreate, executeScript bool) (appInstall *model.AppInstall, err error) { + return a.installWithHooks(req, executeScript, nil) +} + +func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript bool, hooks *appInstallHooks) (appInstall *model.AppInstall, err error) { if err = docker.CreateDefaultDockerNetwork(); err != nil { err = buserr.WithDetail("Err1PanelNetworkFailed", err.Error(), nil) return @@ -537,6 +545,11 @@ func (a AppService) Install(req request.AppInstallCreate, executeScript bool) (a if err = copyData(t, app, appDetail, appInstall, req); err != nil { return err } + if hooks != nil && hooks.AfterCopyData != nil { + if err = hooks.AfterCopyData(appInstall); err != nil { + return err + } + } if executeScript { if err = runScript(t, appInstall, "init"); err != nil { return err diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 4d29312b3..519802aa3 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -44,7 +44,8 @@ ErrAgentAccountUnavailable: 'Account connection unavailable: {{ .err }}' ErrAgentProviderNotSupported: 'Unsupported agent provider' ErrAgentAccountRequired: 'Select an agent account first' ErrAgentAccountNotVerified: 'Agent account not verified' -ErrAgentProviderMismatch: 'Agent provider mismatch' +ErrAgentAccountModelsRequired: 'Please configure at least one model' +ErrAgentAccountSingleInitialModel: 'Only one initial model can be configured during account creation' ErrAgentModelNotInAccount: 'Model is not configured in the selected account' ErrAgentModelInUse: 'A bound agent is still using this model: {{ .name }}' ErrAgentBaseURLRequired: 'Base URL is required' diff --git a/agent/i18n/lang/es-ES.yaml b/agent/i18n/lang/es-ES.yaml index ce03ef564..972bbb02c 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -39,7 +39,8 @@ ErrAgentAccountUnavailable: 'Conexión de cuenta no disponible: {{ .err }}' ErrAgentProviderNotSupported: 'Proveedor de agente no soportado' ErrAgentAccountRequired: 'Elige una cuenta de agente primero' ErrAgentAccountNotVerified: 'Cuenta de agente no verificada' -ErrAgentProviderMismatch: 'Proveedor de agente no coincide' +ErrAgentAccountModelsRequired: 'Configure al menos un modelo' +ErrAgentAccountSingleInitialModel: 'Solo se puede configurar un modelo inicial al crear la cuenta' ErrAgentModelNotInAccount: 'El modelo no está configurado en la cuenta seleccionada' ErrAgentModelInUse: 'Un agente vinculado todavía usa este modelo: {{ .name }}' ErrAgentBaseURLRequired: 'URL base requerida' diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index b43bd405f..3738bb6dd 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -39,7 +39,8 @@ ErrAgentAccountUnavailable: 'アカウント接続不可: {{ .err }}' ErrAgentProviderNotSupported: 'エージェントプロバイダ非対応' ErrAgentAccountRequired: 'まずエージェントアカウントを選択' ErrAgentAccountNotVerified: 'アカウント未検証です' -ErrAgentProviderMismatch: 'エージェントプロバイダが一致しません' +ErrAgentAccountModelsRequired: '少なくとも 1 つのモデルを設定してください' +ErrAgentAccountSingleInitialModel: 'アカウント作成時に設定できる初期モデルは 1 つだけです' ErrAgentModelNotInAccount: '選択したモデルはこのアカウントに設定されていません' ErrAgentModelInUse: 'このモデルはまだエージェントで使用中です: {{ .name }}' ErrAgentBaseURLRequired: 'ベースURLが必要です' diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index 9a9d4336c..8ca73c112 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -39,7 +39,8 @@ ErrAgentAccountUnavailable: '계정 연결 불가: {{ .err }}' ErrAgentProviderNotSupported: '지원되지 않는 에이전트 공급자' ErrAgentAccountRequired: '먼저 에이전트 계정을 선택하세요' ErrAgentAccountNotVerified: '에이전트 계정 미확인' -ErrAgentProviderMismatch: '에이전트 공급자 불일치' +ErrAgentAccountModelsRequired: '최소 한 개의 모델을 구성해 주세요' +ErrAgentAccountSingleInitialModel: '계정 생성 시 초기 모델은 하나만 구성할 수 있습니다' ErrAgentModelNotInAccount: '선택한 모델이 현재 계정에 구성되어 있지 않습니다' ErrAgentModelInUse: '이 모델을 아직 사용 중인 에이전트가 있습니다: {{ .name }}' ErrAgentBaseURLRequired: '기본 URL 필요' diff --git a/agent/i18n/lang/ms.yaml b/agent/i18n/lang/ms.yaml index 09f1a1fb5..2cbeeb005 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -39,7 +39,8 @@ ErrAgentAccountUnavailable: 'Sambungan akaun tidak tersedia: {{ .err }}' ErrAgentProviderNotSupported: 'Penyedia ejen tidak disokong' ErrAgentAccountRequired: 'Pilih akaun ejen terlebih dahulu' ErrAgentAccountNotVerified: 'Akaun ejen belum disahkan' -ErrAgentProviderMismatch: 'Penyedia ejen tidak sepadan' +ErrAgentAccountModelsRequired: 'Sila konfigur sekurang-kurangnya satu model' +ErrAgentAccountSingleInitialModel: 'Hanya satu model awal boleh dikonfigurasi semasa mencipta akaun' ErrAgentModelNotInAccount: 'Model tidak dikonfigurasikan dalam akaun yang dipilih' ErrAgentModelInUse: 'Masih ada ejen terikat yang menggunakan model ini: {{ .name }}' ErrAgentBaseURLRequired: 'URL asas diperlukan' diff --git a/agent/i18n/lang/pt-BR.yaml b/agent/i18n/lang/pt-BR.yaml index 13fa48cbe..12aea75d7 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -39,7 +39,8 @@ ErrAgentAccountUnavailable: 'Conexão da conta indisponível: {{ .err }}' ErrAgentProviderNotSupported: 'Provedor de agente não suportado' ErrAgentAccountRequired: 'Selecione uma conta de agente primeiro' ErrAgentAccountNotVerified: 'Conta de agente não verificada' -ErrAgentProviderMismatch: 'Provedor de agente diferente' +ErrAgentAccountModelsRequired: 'Configure pelo menos um modelo' +ErrAgentAccountSingleInitialModel: 'Apenas um modelo inicial pode ser configurado ao criar a conta' ErrAgentModelNotInAccount: 'O modelo não está configurado na conta selecionada' ErrAgentModelInUse: 'Ainda há um agente vinculado usando este modelo: {{ .name }}' ErrAgentBaseURLRequired: 'URL base obrigatória' diff --git a/agent/i18n/lang/ru.yaml b/agent/i18n/lang/ru.yaml index 95bb6814d..66e516dc0 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -39,7 +39,8 @@ ErrAgentAccountUnavailable: 'Связь с аккаунтом недоступн ErrAgentProviderNotSupported: 'Провайдер агента не поддерживается' ErrAgentAccountRequired: 'Выберите аккаунт агента' ErrAgentAccountNotVerified: 'Аккаунт не подтверждён' -ErrAgentProviderMismatch: 'Провайдер не совпадает' +ErrAgentAccountModelsRequired: 'Настройте хотя бы одну модель' +ErrAgentAccountSingleInitialModel: 'При создании аккаунта можно указать только одну начальную модель' ErrAgentModelNotInAccount: 'Модель не настроена в выбранном аккаунте' ErrAgentModelInUse: 'Эта модель всё ещё используется агентом: {{ .name }}' ErrAgentBaseURLRequired: 'Нужен базовый URL' diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index 7e460c93e..d65dec112 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -39,7 +39,8 @@ ErrAgentAccountUnavailable: 'Hesap bağlantısı yok: {{ .err }}' ErrAgentProviderNotSupported: 'Ajans sağlayıcısı desteklenmiyor' ErrAgentAccountRequired: 'Önce bir ajans hesabı seçin' ErrAgentAccountNotVerified: 'Ajans hesabı doğrulanmadı' -ErrAgentProviderMismatch: 'Ajans sağlayıcısı eşleşmiyor' +ErrAgentAccountModelsRequired: 'Lütfen en az bir model yapılandırın' +ErrAgentAccountSingleInitialModel: 'Hesap oluştururken yalnızca bir başlangıç modeli yapılandırılabilir' ErrAgentModelNotInAccount: 'Model seçilen hesapta yapılandırılmadı' ErrAgentModelInUse: 'Bu modeli hâlâ kullanan bağlı bir ajan var: {{ .name }}' ErrAgentBaseURLRequired: 'Temel URL gerekli' diff --git a/agent/i18n/lang/zh-Hant.yaml b/agent/i18n/lang/zh-Hant.yaml index 2bc157695..144e80a82 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -39,7 +39,8 @@ ErrAgentAccountUnavailable: '帳號連線資訊不可用,錯誤:{{ .err }} ErrAgentProviderNotSupported: '暫不支援該智能體提供商,請重試' ErrAgentAccountRequired: '請選擇智能體帳號後重試' ErrAgentAccountNotVerified: '帳號未驗證通過,請重試' -ErrAgentProviderMismatch: '帳號提供商不符,請重試' +ErrAgentAccountModelsRequired: '請至少配置一個模型' +ErrAgentAccountSingleInitialModel: '建立帳號時只能配置一個初始模型' ErrAgentModelNotInAccount: '所選模型未配置在當前帳號中,請重試' ErrAgentModelInUse: '仍有智能體正在使用該模型:{{ .name }}' ErrAgentBaseURLRequired: 'Base URL 不可為空,請重試' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 57e2dc4d4..8b816b6e7 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -44,7 +44,8 @@ ErrAgentAccountUnavailable: "账号连接信息不可用: {{ .err }}" ErrAgentProviderNotSupported: "不支持该智能体提供商" ErrAgentAccountRequired: "请选择智能体账号后重试" ErrAgentAccountNotVerified: "账号未通过验证" -ErrAgentProviderMismatch: "账号提供商不匹配" +ErrAgentAccountModelsRequired: "请至少配置一个模型" +ErrAgentAccountSingleInitialModel: "创建账号时只能配置一个初始模型" ErrAgentModelNotInAccount: "所选模型未配置在当前账号中" ErrAgentModelInUse: "仍有智能体正在使用该模型: {{ .name }}" ErrAgentBaseURLRequired: "Base URL 不能为空" diff --git a/agent/init/migration/migrations/utils/agent_account_model_pool.go b/agent/init/migration/migrations/utils/agent_account_model_pool.go index 1904bb584..a5f88f93f 100644 --- a/agent/init/migration/migrations/utils/agent_account_model_pool.go +++ b/agent/init/migration/migrations/utils/agent_account_model_pool.go @@ -11,9 +11,15 @@ import ( "gorm.io/gorm" ) +type legacyAgentAccountModelPoolSource struct { + model.AgentAccount + LegacyModel string `gorm:"column:model"` + LegacyModels string `gorm:"column:models"` +} + func MigrateAgentAccountModelPool(tx *gorm.DB) error { - var accounts []model.AgentAccount - if err := tx.Find(&accounts).Error; err != nil { + var accounts []legacyAgentAccountModelPoolSource + if err := tx.Table(model.AgentAccount{}.TableName()).Find(&accounts).Error; err != nil { return err } for _, account := range accounts { @@ -54,13 +60,14 @@ func MigrateAgentAccountModelPool(tx *gorm.DB) error { return nil } -func buildMigratedAgentAccountModels(tx *gorm.DB, account *model.AgentAccount) ([]dto.AgentAccountModel, error) { +func buildMigratedAgentAccountModels(tx *gorm.DB, account *legacyAgentAccountModelPoolSource) ([]dto.AgentAccountModel, error) { if account == nil { return nil, nil } + baseAccount := account.AgentAccount requested := make([]dto.AgentAccountModel, 0) - if strings.TrimSpace(account.Models) != "" { - if err := json.Unmarshal([]byte(account.Models), &requested); err != nil { + if strings.TrimSpace(account.LegacyModels) != "" { + if err := json.Unmarshal([]byte(account.LegacyModels), &requested); err != nil { return nil, err } } @@ -83,7 +90,7 @@ func buildMigratedAgentAccountModels(tx *gorm.DB, account *model.AgentAccount) ( seen[target] = struct{}{} requested = append(requested, dto.AgentAccountModel{ID: target}) } - appendModel(account.Model) + appendModel(account.LegacyModel) if account.ID > 0 { var agents []model.Agent if err := tx.Where("account_id = ?", account.ID).Find(&agents).Error; err != nil { @@ -93,7 +100,7 @@ func buildMigratedAgentAccountModels(tx *gorm.DB, account *model.AgentAccount) ( appendModel(agent.Model) } } - models, err := service.MergeCatalogAgentAccountModelsForMigration(account, requested) + models, err := service.MergeCatalogAgentAccountModelsForMigration(&baseAccount, requested) if err != nil { if strings.TrimSpace(err.Error()) == "model is required" { return nil, nil diff --git a/frontend/src/api/interface/ai.ts b/frontend/src/api/interface/ai.ts index 355338fa3..d2f0d606b 100644 --- a/frontend/src/api/interface/ai.ts +++ b/frontend/src/api/interface/ai.ts @@ -243,14 +243,8 @@ export namespace AI { bridgePort?: number; allowedOrigins?: string[]; agentType: 'openclaw' | 'copaw'; - provider?: string; model?: string; - apiType?: string; - maxTokens?: number; - contextWindow?: number; accountId?: number; - apiKey?: string; - baseURL?: string; token?: string; taskID: string; advanced: boolean; @@ -361,8 +355,7 @@ export namespace AI { rememberApiKey: boolean; baseURL: string; apiType: string; - maxTokens: number; - contextWindow: number; + models?: AgentAccountModel[]; remark: string; } @@ -373,8 +366,6 @@ export namespace AI { rememberApiKey: boolean; baseURL: string; apiType: string; - maxTokens: number; - contextWindow: number; remark: string; syncAgents: boolean; } @@ -394,11 +385,8 @@ export namespace AI { apiKey: string; rememberApiKey: boolean; baseUrl: string; - model: string; models: AgentAccountModel[]; apiType: string; - maxTokens: number; - contextWindow: number; verified: boolean; remark: string; createdAt: string; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 8e66db8cc..6d464197e 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -692,8 +692,6 @@ const message = { accountModelsHelper: 'Configure the models this account exposes to OpenClaw for switching and settings', accountModelsRequired: 'Configure at least one model', accountModelsDuplicate: 'Duplicate models exist in the catalog', - accountCreateHelper: - 'After creating the model account, you can continue managing its model pool. If the provider catalog already defines Models, they will be imported automatically.', modelPool: 'Model Pool', modelPoolHelper: 'Manage the models exposed by this account here. Agent creation and OpenClaw model switching both use this pool.', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index fed373ff0..bb381a49e 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -700,8 +700,6 @@ const message = { accountModelsHelper: 'Configure the models this account exposes to OpenClaw for switching and settings', accountModelsRequired: 'Configure at least one model', accountModelsDuplicate: 'Duplicate models exist in the catalog', - accountCreateHelper: - 'After creating the model account, you can continue managing its model pool. If the provider catalog already defines Models, they will be imported automatically.', modelPool: 'Model Pool', modelPoolHelper: 'Manage the models exposed by this account here. Agent creation and OpenClaw model switching both use this pool.', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 372c2ff63..9b4ebec2a 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -693,8 +693,6 @@ const message = { accountModelsHelper: 'Configure the models this account exposes to OpenClaw for switching and settings', accountModelsRequired: 'Configure at least one model', accountModelsDuplicate: 'Duplicate models exist in the catalog', - accountCreateHelper: - 'After creating the model account, you can continue managing its model pool. If the provider catalog already defines Models, they will be imported automatically.', modelPool: 'Model Pool', modelPoolHelper: 'Manage the models exposed by this account here. Agent creation and OpenClaw model switching both use this pool.', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index f4a399d4f..2f259c72c 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -685,8 +685,6 @@ const message = { accountModelsHelper: 'Configure the models this account exposes to OpenClaw for switching and settings', accountModelsRequired: 'Configure at least one model', accountModelsDuplicate: 'Duplicate models exist in the catalog', - accountCreateHelper: - 'After creating the model account, you can continue managing its model pool. If the provider catalog already defines Models, they will be imported automatically.', modelPool: 'Model Pool', modelPoolHelper: 'Manage the models exposed by this account here. Agent creation and OpenClaw model switching both use this pool.', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index dc79e0235..fe1d73737 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -700,8 +700,6 @@ const message = { accountModelsHelper: 'Configure the models this account exposes to OpenClaw for switching and settings', accountModelsRequired: 'Configure at least one model', accountModelsDuplicate: 'Duplicate models exist in the catalog', - accountCreateHelper: - 'After creating the model account, you can continue managing its model pool. If the provider catalog already defines Models, they will be imported automatically.', modelPool: 'Model Pool', modelPoolHelper: 'Manage the models exposed by this account here. Agent creation and OpenClaw model switching both use this pool.', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index d40361cb5..c548dbd6c 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -695,8 +695,6 @@ const message = { accountModelsHelper: 'Configure the models this account exposes to OpenClaw for switching and settings', accountModelsRequired: 'Configure at least one model', accountModelsDuplicate: 'Duplicate models exist in the catalog', - accountCreateHelper: - 'After creating the model account, you can continue managing its model pool. If the provider catalog already defines Models, they will be imported automatically.', modelPool: 'Model Pool', modelPoolHelper: 'Manage the models exposed by this account here. Agent creation and OpenClaw model switching both use this pool.', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 3432d7902..1287edaca 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -692,8 +692,6 @@ const message = { accountModelsHelper: 'Configure the models this account exposes to OpenClaw for switching and settings', accountModelsRequired: 'Configure at least one model', accountModelsDuplicate: 'Duplicate models exist in the catalog', - accountCreateHelper: - 'After creating the model account, you can continue managing its model pool. If the provider catalog already defines Models, they will be imported automatically.', modelPool: 'Model Pool', modelPoolHelper: 'Manage the models exposed by this account here. Agent creation and OpenClaw model switching both use this pool.', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 7e70532ed..b27f75f0f 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -696,8 +696,6 @@ const message = { accountModelsHelper: 'Configure the models this account exposes to OpenClaw for switching and settings', accountModelsRequired: 'Configure at least one model', accountModelsDuplicate: 'Duplicate models exist in the catalog', - accountCreateHelper: - 'After creating the model account, you can continue managing its model pool. If the provider catalog already defines Models, they will be imported automatically.', modelPool: 'Model Pool', modelPoolHelper: 'Manage the models exposed by this account here. Agent creation and OpenClaw model switching both use this pool.', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 9800b1eac..8838e7f24 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -660,7 +660,6 @@ const message = { accountModelsHelper: '配置該帳號可提供給 OpenClaw 使用與切換的模型列表', accountModelsRequired: '請至少配置一個模型', accountModelsDuplicate: '模型池中存在重複模型,請檢查後重試', - accountCreateHelper: '建立模型帳號後可繼續修改模型池;如果供應商 catalog 已提供 Models,將自動匯入模型池', modelPool: '模型池', modelPoolHelper: '在此維護該模型帳號的模型池,建立智能體與 OpenClaw 模型切換都會使用這些模型', modelInputTypes: '輸入類型', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index b835dd327..4e8d771b1 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -659,7 +659,6 @@ const message = { accountModelsHelper: '配置账号可提供给 OpenClaw 使用和切换的模型列表', accountModelsRequired: '请至少配置一个模型', accountModelsDuplicate: '模型池中存在重复模型,请检查后重试', - accountCreateHelper: '创建模型账号后可继续修改模型池;如果供应商 catalog 已提供 Models,将自动导入模型池', modelPool: '模型池', modelPoolHelper: '在这里维护该模型账号的模型池,智能体创建和 OpenClaw 模型切换都会使用这些模型', modelInputTypes: '输入类型', diff --git a/frontend/src/views/ai/agents/agent/add/index.vue b/frontend/src/views/ai/agents/agent/add/index.vue index 3e5217457..41c53c7ce 100644 --- a/frontend/src/views/ai/agents/agent/add/index.vue +++ b/frontend/src/views/ai/agents/agent/add/index.vue @@ -146,10 +146,6 @@ const form = reactive({ provider: 'deepseek', accountId: undefined as unknown as number, model: '', - apiType: 'openai-completions', - maxTokens: 8192, - contextWindow: 128000, - apiKey: '', baseURL: '', token: '', advanced: true, @@ -300,7 +296,6 @@ const loadAccounts = async () => { handleAccountChange(); } else { form.accountId = undefined as unknown as number; - form.apiKey = ''; form.baseURL = ''; } }; @@ -310,7 +305,6 @@ const handleProviderChange = () => { return; } form.model = ''; - form.apiKey = ''; form.baseURL = ''; form.accountId = undefined as unknown as number; loadAccounts(); @@ -324,9 +318,7 @@ const handleAgentTypeChange = async () => { form.model = ''; form.provider = 'deepseek'; form.accountId = undefined as unknown as number; - form.apiKey = ''; form.baseURL = ''; - form.apiType = 'openai-completions'; if (form.agentType === 'openclaw') { await loadSystemIP(); allowedOriginsAutoFilled.value = true; @@ -349,10 +341,6 @@ const handleAccountChange = () => { const selected = accountOptions.value.find((item) => item.id === form.accountId); if (selected) { form.baseURL = selected.baseUrl || ''; - form.apiKey = selected.apiKey || ''; - form.apiType = selected.apiType || 'openai-completions'; - form.maxTokens = selected.maxTokens || 8192; - form.contextWindow = selected.contextWindow || 128000; if (!selected.models?.some((item) => item.id === form.model)) { form.model = selected.models?.[0]?.id || ''; } @@ -394,14 +382,8 @@ const submit = async () => { webUIPort: form.webUIPort, allowedOrigins: form.agentType === 'openclaw' ? parseAllowedOriginsInput(form.allowedOrigins) : undefined, agentType: form.agentType, - provider: form.agentType === 'openclaw' ? form.provider : undefined, model: form.agentType === 'openclaw' ? form.model : undefined, - apiType: form.agentType === 'openclaw' ? form.apiType : undefined, - maxTokens: form.agentType === 'openclaw' ? form.maxTokens : undefined, - contextWindow: form.agentType === 'openclaw' ? form.contextWindow : undefined, accountId: form.agentType === 'openclaw' ? form.accountId : undefined, - apiKey: form.agentType === 'openclaw' ? form.apiKey : undefined, - baseURL: form.agentType === 'openclaw' ? form.baseURL : undefined, token: form.agentType === 'openclaw' ? form.token : undefined, taskID: taskID, advanced: form.advanced, diff --git a/frontend/src/views/ai/agents/model/add/index.vue b/frontend/src/views/ai/agents/model/add/index.vue index ddaeb88a8..9ab29380c 100644 --- a/frontend/src/views/ai/agents/model/add/index.vue +++ b/frontend/src/views/ai/agents/model/add/index.vue @@ -26,13 +26,6 @@ - @@ -44,12 +37,30 @@ /> - - - - - - + @@ -72,6 +83,7 @@