From 46b8d3bd8020dfda0cd7e711bb8790d4c4a8a31d Mon Sep 17 00:00:00 2001 From: CityFun <31820853+zhengkunwang223@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:51:03 +0800 Subject: [PATCH] feat: add model switching functionality for AI agent (#11839) --- agent/app/api/v2/agents.go | 20 ++ agent/app/dto/agents.go | 7 + agent/app/service/agents.go | 199 +++++++++++++++++- agent/init/migration/migrations/init.go | 6 + agent/router/ro_ai.go | 1 + frontend/src/api/interface/ai.ts | 8 +- frontend/src/api/modules/ai.ts | 4 + frontend/src/lang/modules/en.ts | 1 + frontend/src/lang/modules/es-es.ts | 1 + frontend/src/lang/modules/ja.ts | 1 + frontend/src/lang/modules/ko.ts | 1 + frontend/src/lang/modules/ms.ts | 1 + frontend/src/lang/modules/pt-br.ts | 1 + frontend/src/lang/modules/ru.ts | 1 + frontend/src/lang/modules/tr.ts | 1 + frontend/src/lang/modules/zh-Hant.ts | 1 + frontend/src/lang/modules/zh.ts | 1 + .../src/views/ai/agents/agent/add/index.vue | 3 + .../views/ai/agents/agent/config/index.vue | 31 ++- .../ai/agents/agent/config/tabs/model.vue | 160 ++++++++++++++ frontend/src/views/ai/agents/agent/index.vue | 5 +- .../src/views/ai/agents/model/add/index.vue | 3 + frontend/src/views/ai/agents/model/index.vue | 3 + 23 files changed, 443 insertions(+), 17 deletions(-) create mode 100644 frontend/src/views/ai/agents/agent/config/tabs/model.vue diff --git a/agent/app/api/v2/agents.go b/agent/app/api/v2/agents.go index 29160903f..e59050a9b 100644 --- a/agent/app/api/v2/agents.go +++ b/agent/app/api/v2/agents.go @@ -71,6 +71,26 @@ func (b *BaseApi) DeleteAgent(c *gin.Context) { helper.Success(c) } +// @Tags AI +// @Summary Update Agent model config +// @Accept json +// @Param request body dto.AgentModelConfigUpdateReq true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /ai/agents/model/update [post] +func (b *BaseApi) UpdateAgentModelConfig(c *gin.Context) { + var req dto.AgentModelConfigUpdateReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := agentService.UpdateModelConfig(req); err != nil { + helper.BadRequest(c, err) + return + } + helper.Success(c) +} + // @Tags AI // @Summary Get Providers // @Success 200 {object} []dto.ProviderInfo diff --git a/agent/app/dto/agents.go b/agent/app/dto/agents.go index 9734f5056..921a6a0ed 100644 --- a/agent/app/dto/agents.go +++ b/agent/app/dto/agents.go @@ -38,6 +38,7 @@ type AgentItem struct { Status string `json:"status"` Message string `json:"message"` AppInstallID uint `json:"appInstallId"` + AccountID uint `json:"accountId"` AppVersion string `json:"appVersion"` Container string `json:"containerName"` WebUIPort int `json:"webUIPort"` @@ -54,6 +55,12 @@ type AgentDeleteReq struct { ForceDelete bool `json:"forceDelete"` } +type AgentModelConfigUpdateReq struct { + AgentID uint `json:"agentId" validate:"required"` + AccountID uint `json:"accountId" validate:"required"` + Model string `json:"model" validate:"required"` +} + type AgentAccountCreateReq struct { Provider string `json:"provider" validate:"required"` Name string `json:"name" validate:"required"` diff --git a/agent/app/service/agents.go b/agent/app/service/agents.go index b6126f3ec..63dc9bcec 100644 --- a/agent/app/service/agents.go +++ b/agent/app/service/agents.go @@ -32,6 +32,7 @@ type IAgentService interface { Create(req dto.AgentCreateReq) (*dto.AgentItem, error) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, error) Delete(req dto.AgentDeleteReq) error + UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error GetProviders() ([]dto.ProviderInfo, error) CreateAccount(req dto.AgentAccountCreateReq) error UpdateAccount(req dto.AgentAccountUpdateReq) error @@ -209,6 +210,64 @@ func (a AgentService) Delete(req dto.AgentDeleteReq) error { return nil } +func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error { + agent, err := agentRepo.GetFirst(repo.WithByID(req.AgentID)) + if err != nil { + return err + } + account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID)) + if err != nil { + return err + } + if !account.Verified { + 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 !strings.HasPrefix(modelName, provider+"/") { + return buserr.New("ErrAgentProviderMismatch") + } + baseURL := strings.TrimSpace(account.BaseURL) + if provider != "ollama" { + 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") + } + 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") + } + if err := writeOpenclawConfig(confDir, provider, modelName, baseURL, account.APIKey, agent.Token); err != nil { + return err + } + agent.Provider = provider + agent.Model = modelName + agent.BaseURL = baseURL + agent.APIKey = account.APIKey + agent.AccountID = account.ID + return agentRepo.Save(agent) +} + func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) { definitions := providerDefinitions() providers := make([]dto.ProviderInfo, 0, len(definitions)) @@ -612,6 +671,7 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map Status: agent.Status, Message: agent.Message, AppInstallID: agent.AppInstallID, + AccountID: agent.AccountID, ConfigPath: agent.ConfigPath, CreatedAt: agent.CreatedAt, } @@ -768,12 +828,20 @@ type modelProvider struct { } 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"` + 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"` } func writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token string) error { @@ -814,16 +882,28 @@ func writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token st } provider = strings.ToLower(strings.TrimSpace(provider)) + modelID := modelName + if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 { + modelID = parts[1] + } + configProvider := provider + primaryModel := modelName + if provider == "kimi" { + configProvider = "moonshot" + primaryModel = "moonshot/" + modelID + } if provider == "deepseek" { + cfg.Agents.Defaults.Model.Primary = modelName base := baseURL if base == "" { base = "https://api.deepseek.com/v1" } + plainKey := strings.TrimSpace(apiKey) cfg.Models = &modelsConfig{ Mode: "merge", Providers: map[string]modelProvider{ "deepseek": { - ApiKey: "${DEEPSEEK_API_KEY}", + ApiKey: plainKey, BaseUrl: base, Api: "openai-completions", Models: []modelEntry{ @@ -834,16 +914,44 @@ func writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token st Input: []string{"text"}, ContextWindow: 128000, MaxTokens: 8192, + Cost: modelCost{}, + }, + }, + }, + }, + } + } else if provider == "moonshot" || provider == "kimi" { + cfg.Agents.Defaults.Model.Primary = primaryModel + base := baseURL + if base == "" { + if defaultURL, ok := providerDefaultBaseURL(provider); ok { + base = defaultURL + } + } + plainKey := strings.TrimSpace(apiKey) + cfg.Models = &modelsConfig{ + Mode: "merge", + Providers: map[string]modelProvider{ + configProvider: { + ApiKey: plainKey, + BaseUrl: base, + Api: "openai-completions", + Models: []modelEntry{ + { + ID: modelID, + Name: modelID, + Reasoning: strings.Contains(modelID, "thinking"), + Input: []string{"text"}, + ContextWindow: 256000, + MaxTokens: 8192, + Cost: modelCost{}, }, }, }, }, } } else if provider == "ollama" { - modelID := modelName - if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 { - modelID = parts[1] - } + cfg.Agents.Defaults.Model.Primary = modelName cfg.Models = &modelsConfig{ Mode: "merge", Providers: map[string]modelProvider{ @@ -859,6 +967,37 @@ func writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token st Input: []string{"text"}, ContextWindow: 160000, MaxTokens: 8192, + Cost: modelCost{}, + }, + }, + }, + }, + } + } else if provider == "kimi-coding" { + cfg.Agents.Defaults.Model.Primary = modelName + base := baseURL + if base == "" { + if defaultURL, ok := providerDefaultBaseURL(provider); ok { + base = defaultURL + } + } + plainKey := strings.TrimSpace(apiKey) + cfg.Models = &modelsConfig{ + Mode: "merge", + Providers: map[string]modelProvider{ + "kimi-coding": { + ApiKey: plainKey, + BaseUrl: base, + Api: "anthropic-messages", + Models: []modelEntry{ + { + ID: modelID, + Name: modelID, + Reasoning: true, + Input: []string{"text"}, + ContextWindow: 200000, + MaxTokens: 8192, + Cost: modelCost{}, }, }, }, @@ -896,6 +1035,12 @@ func providerEnvKey(provider string) string { return "MINIMAX_API_KEY" case "deepseek": return "DEEPSEEK_API_KEY" + case "moonshot": + return "MOONSHOT_API_KEY" + case "kimi": + return "KIMI_API_KEY" + case "kimi-coding": + return "KIMI_API_KEY" case "qwen": return "QWEN_API_KEY" case "ollama": @@ -969,6 +1114,31 @@ func providerDefinitions() map[string]providerDefinition { {ID: "minimax/Minimax-M2.1", Name: "Minimax M2.1"}, }, }, + "moonshot": { + Sort: 7, + BaseURL: "https://api.moonshot.ai/v1", + Models: []dto.ProviderModelInfo{ + {ID: "moonshot/kimi-k2.5", Name: "Kimi K2.5"}, + {ID: "moonshot/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, + {ID: "moonshot/kimi-k2-thinking", Name: "Kimi K2 Thinking"}, + }, + }, + "kimi": { + Sort: 8, + BaseURL: "https://api.moonshot.cn/v1", + Models: []dto.ProviderModelInfo{ + {ID: "kimi/kimi-k2.5", Name: "Kimi K2.5"}, + {ID: "kimi/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, + {ID: "kimi/kimi-k2-thinking", Name: "Kimi K2 Thinking"}, + }, + }, + "kimi-coding": { + Sort: 9, + BaseURL: "https://api.moonshot.cn/anthropic/v1", + Models: []dto.ProviderModelInfo{ + {ID: "kimi-coding/k2p5", Name: "Kimi K2.5"}, + }, + }, } } @@ -999,6 +1169,13 @@ func buildVerifyRequest(provider, baseURL, apiKey string) (string, map[string]st return base + "/models", headers } return base + "/v1/models", headers + case "kimi-coding": + headers["x-api-key"] = apiKey + headers["anthropic-version"] = "2023-06-01" + if strings.Contains(base, "/v1") { + return base + "/models", headers + } + return base + "/v1/models", headers case "gemini": if strings.Contains(base, "/v1beta") { return fmt.Sprintf("%s/models?key=%s", base, apiKey), headers diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index bf0cbd11b..18079faff 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -948,6 +948,12 @@ func defaultBaseURL(provider string) (string, bool) { return "https://api.minimax.chat/v1", true case "deepseek": return "https://api.deepseek.com/v1", true + case "moonshot": + return "https://api.moonshot.ai/v1", true + case "kimi": + return "https://api.moonshot.cn/v1", true + case "kimi-coding": + return "https://api.moonshot.cn/anthropic/v1", true case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1", true default: diff --git a/agent/router/ro_ai.go b/agent/router/ro_ai.go index affc89093..1e76d4007 100644 --- a/agent/router/ro_ai.go +++ b/agent/router/ro_ai.go @@ -43,6 +43,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) { aiToolsRouter.POST("/agents", baseApi.CreateAgent) aiToolsRouter.POST("/agents/search", baseApi.PageAgents) aiToolsRouter.POST("/agents/delete", baseApi.DeleteAgent) + aiToolsRouter.POST("/agents/model/update", baseApi.UpdateAgentModelConfig) aiToolsRouter.GET("/agents/providers", baseApi.GetAgentProviders) aiToolsRouter.POST("/agents/accounts", baseApi.CreateAgentAccount) aiToolsRouter.POST("/agents/accounts/update", baseApi.UpdateAgentAccount) diff --git a/frontend/src/api/interface/ai.ts b/frontend/src/api/interface/ai.ts index 4d5a063ba..8c7996fbc 100644 --- a/frontend/src/api/interface/ai.ts +++ b/frontend/src/api/interface/ai.ts @@ -272,6 +272,7 @@ export namespace AI { status: string; message: string; appInstallId: number; + accountId: number; appVersion: string; containerName: string; webUIPort: number; @@ -282,13 +283,18 @@ export namespace AI { createdAt: string; } - export interface AgentDeleteReq { id: number; taskID: string; forceDelete: boolean; } + export interface AgentModelConfigUpdateReq { + agentId: number; + accountId: number; + model: string; + } + export interface ProviderModelInfo { id: string; name: string; diff --git a/frontend/src/api/modules/ai.ts b/frontend/src/api/modules/ai.ts index 7c9e63be3..d5cba785c 100644 --- a/frontend/src/api/modules/ai.ts +++ b/frontend/src/api/modules/ai.ts @@ -104,6 +104,10 @@ export const deleteAgent = (req: AI.AgentDeleteReq) => { return http.post(`/ai/agents/delete`, req); }; +export const updateAgentModelConfig = (req: AI.AgentModelConfigUpdateReq) => { + return http.post(`/ai/agents/model/update`, req); +}; + export const getAgentProviders = () => { return http.get(`/ai/agents/providers`); }; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 5699814b9..60a4c1fed 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -696,6 +696,7 @@ const message = { manualModel: 'Manual input', verified: 'Verified', configTitle: 'Configuration', + switchModelSuccess: 'Model switched successfully', channelsTab: 'Channels', feishu: 'Feishu', dmPolicy: 'DM Policy', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 1bb00e412..0ad078ad2 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -692,6 +692,7 @@ const message = { manualModel: 'Entrada manual de modelo', verified: 'Verificado', configTitle: 'Configuration', + switchModelSuccess: 'Model switched successfully', channelsTab: 'Channels', feishu: 'Feishu', dmPolicy: 'DM Policy', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index fdc81a214..360058001 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -681,6 +681,7 @@ const message = { manualModel: '手動入力', verified: '検証済み', configTitle: 'Configuration', + switchModelSuccess: 'Model switched successfully', channelsTab: 'Channels', feishu: 'Feishu', dmPolicy: 'DM Policy', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 8ab98ecf2..dc80f5e64 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -678,6 +678,7 @@ const message = { manualModel: '수동 입력', verified: '검증됨', configTitle: 'Configuration', + switchModelSuccess: 'Model switched successfully', channelsTab: 'Channels', feishu: 'Feishu', dmPolicy: 'DM Policy', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index ce1e2ecc7..db28be98e 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -693,6 +693,7 @@ const message = { manualModel: 'Input manual', verified: 'Disahkan', configTitle: 'Configuration', + switchModelSuccess: 'Model switched successfully', channelsTab: 'Channels', feishu: 'Feishu', dmPolicy: 'DM Policy', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 28ca1acf1..ece63d61e 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -690,6 +690,7 @@ const message = { manualModel: 'Entrada manual', verified: 'Verificado', configTitle: 'Configuration', + switchModelSuccess: 'Model switched successfully', channelsTab: 'Channels', feishu: 'Feishu', dmPolicy: 'DM Policy', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 918d06ccd..8a72886c2 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -686,6 +686,7 @@ const message = { manualModel: 'Ручной ввод', verified: 'Проверено', configTitle: 'Configuration', + switchModelSuccess: 'Model switched successfully', channelsTab: 'Channels', feishu: 'Feishu', dmPolicy: 'DM Policy', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index be827ac4e..d05d2a806 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -700,6 +700,7 @@ const message = { manualModel: 'Manuel giriş', verified: 'Doğrulandı', configTitle: 'Configuration', + switchModelSuccess: 'Model switched successfully', channelsTab: 'Channels', feishu: 'Feishu', dmPolicy: 'DM Policy', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 4baecd9f4..f602ec7f9 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -668,6 +668,7 @@ const message = { manualModel: '手動輸入模型', verified: '驗證狀態', configTitle: '配置', + switchModelSuccess: '模型切換成功', channelsTab: '聊天渠道', feishu: '飛書', dmPolicy: '私聊策略', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index ae7c3797e..0fdedd64b 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -670,6 +670,7 @@ const message = { manualModel: '手动输入模型', verified: '验证状态', configTitle: '配置', + switchModelSuccess: '模型切换成功', channelsTab: '聊天渠道', feishu: '飞书', dmPolicy: '私聊策略', diff --git a/frontend/src/views/ai/agents/agent/add/index.vue b/frontend/src/views/ai/agents/agent/add/index.vue index 62012b77a..dc40a2a55 100644 --- a/frontend/src/views/ai/agents/agent/add/index.vue +++ b/frontend/src/views/ai/agents/agent/add/index.vue @@ -174,6 +174,9 @@ const providerLabelMap: Record = { openai: 'OpenAI', ollama: 'Ollama', minimax: 'MiniMax', + moonshot: 'Moonshot', + kimi: 'Kimi', + 'kimi-coding': 'Kimi Coding', qwen: 'Qwen', deepseek: 'DeepSeek', anthropic: 'Anthropic', diff --git a/frontend/src/views/ai/agents/agent/config/index.vue b/frontend/src/views/ai/agents/agent/config/index.vue index 1847b1e5c..98ff20798 100644 --- a/frontend/src/views/ai/agents/agent/config/index.vue +++ b/frontend/src/views/ai/agents/agent/config/index.vue @@ -1,6 +1,9 @@