feat: OpenClaw supports configuring backup models. (#12386)

This commit is contained in:
CityFun
2026-04-01 11:18:09 +00:00
committed by GitHub
parent bc022a4d21
commit 01fb9a461b
35 changed files with 432 additions and 73 deletions
+21
View File
@@ -111,6 +111,27 @@ func (b *BaseApi) UpdateAgentRemark(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent model config
// @Accept json
// @Param request body dto.AgentIDReq true "request"
// @Success 200 {object} dto.AgentModelConfig
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/model/get [post]
func (b *BaseApi) GetAgentModelConfig(c *gin.Context) {
var req dto.AgentIDReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := agentService.GetModelConfig(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags AI
// @Summary Update Agent model config
// @Accept json
+10 -3
View File
@@ -71,9 +71,16 @@ type AgentRemarkUpdateReq struct {
}
type AgentModelConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"`
Model string `json:"model" validate:"required"`
AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"`
Model string `json:"model" validate:"required"`
Fallbacks []string `json:"fallbacks"`
}
type AgentModelConfig struct {
AccountID uint `json:"accountId"`
Model string `json:"model"`
Fallbacks []string `json:"fallbacks"`
}
type AgentOverviewReq struct {
+32 -2
View File
@@ -30,6 +30,7 @@ type IAgentService interface {
Delete(req dto.AgentDeleteReq) error
ResetToken(req dto.AgentTokenResetReq) error
UpdateRemark(req dto.AgentRemarkUpdateReq) error
GetModelConfig(req dto.AgentIDReq) (*dto.AgentModelConfig, error)
UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
GetOverview(req dto.AgentOverviewReq) (*dto.AgentOverview, error)
GetProviders() ([]dto.ProviderInfo, error)
@@ -345,6 +346,30 @@ func (a AgentService) UpdateRemark(req dto.AgentRemarkUpdateReq) error {
return agentRepo.Save(agent)
}
func (a AgentService) GetModelConfig(req dto.AgentIDReq) (*dto.AgentModelConfig, error) {
agent, _, conf, err := a.loadOpenclawAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID))
if err != nil {
return nil, err
}
models, err := loadAgentAccountModels(account)
if err != nil {
return nil, err
}
model := extractOpenclawPrimaryModelID(conf, account, models)
if model == "" {
model = agent.Model
}
return &dto.AgentModelConfig{
AccountID: agent.AccountID,
Model: model,
Fallbacks: extractOpenclawFallbackModelIDs(conf, account, models, model),
}, nil
}
func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error {
agent, err := loadOpenclawAgentByID(req.AgentID)
if err != nil {
@@ -361,7 +386,7 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
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 {
if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil, req.Fallbacks); err != nil {
return err
}
agent.Provider = account.Provider
@@ -875,13 +900,18 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
} else {
selectedAccountModel = accountModels[0]
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
fallbacks := extractOpenclawFallbackModelIDs(conf, account, accountModels, selectedAccountModel.ID)
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 {
if err := writeOpenclawConfig(confDir, account, modelName, agent.Token, nil, fallbacks); err != nil {
return err
}
agent.BaseURL = baseURL
+131 -32
View File
@@ -339,10 +339,7 @@ func setOtherConfig(conf map[string]interface{}, config dto.AgentOtherConfig) {
}
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
}
agentType := agent.AgentType
item := dto.AgentItem{
ID: agent.ID,
Name: agent.Name,
@@ -660,7 +657,8 @@ type agentDefaults struct {
}
type modelRef struct {
Primary string `json:"primary"`
Primary string `json:"primary"`
Fallbacks []string `json:"fallbacks,omitempty"`
}
type modelsConfig struct {
@@ -701,7 +699,7 @@ type browserConfig struct {
DefaultProfile string `json:"defaultProfile"`
}
func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string) error {
func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string, fallbacks []string) error {
if strings.TrimSpace(confDir) == "" {
return fmt.Errorf("config dir is required")
}
@@ -724,6 +722,10 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
if err != nil {
return err
}
resolvedFallbacks, err := resolveOpenclawFallbackModels(account, modelName, fallbacks)
if err != nil {
return err
}
cfg := openclawConfig{
Gateway: gatewayConfig{
@@ -743,7 +745,7 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
Agents: agentsConfig{
Defaults: agentDefaults{
UserTimezone: resolveServerTimezone(),
Model: modelRef{Primary: primaryModel},
Model: modelRef{Primary: primaryModel, Fallbacks: resolvedFallbacks},
Models: defaultsModels,
},
},
@@ -811,6 +813,11 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
}
modelMap := ensureChildMap(defaultsMap, "model")
modelMap["primary"] = cfg.Agents.Defaults.Model.Primary
if len(cfg.Agents.Defaults.Model.Fallbacks) > 0 {
modelMap["fallbacks"] = cfg.Agents.Defaults.Model.Fallbacks
} else {
delete(modelMap, "fallbacks")
}
if cfg.Agents.Defaults.Models != nil {
defaultsMap["models"] = cfg.Agents.Defaults.Models
}
@@ -847,12 +854,128 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
return fileOp.SaveFile(envPath, content, 0600)
}
func resolveOpenclawFallbackModels(account *model.AgentAccount, primaryModel string, fallbackIDs []string) ([]string, error) {
accountModels, err := loadAgentAccountModels(account)
if err != nil {
return nil, err
}
return resolveOpenclawFallbackModelsFromPool(account, accountModels, primaryModel, fallbackIDs, true)
}
func resolveOpenclawFallbackModelsFromPool(account *model.AgentAccount, accountModels []dto.AgentAccountModel, primaryModel string, fallbackIDs []string, strict bool) ([]string, error) {
if len(fallbackIDs) == 0 {
return nil, nil
}
result := make([]string, 0, len(fallbackIDs))
seen := make(map[string]struct{}, len(fallbackIDs))
for _, item := range fallbackIDs {
target := strings.TrimSpace(item)
if target == "" {
continue
}
accountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, target)
if err != nil {
if strict {
return nil, err
}
continue
}
if sameProviderModelID(account.Provider, accountModel.ID, primaryModel) {
if strict {
return nil, buserr.New("ErrAgentFallbackModelPrimary")
}
continue
}
resolvedPrimary, _, _, _, err := buildOpenclawCatalogModel(account, accountModel)
if err != nil {
return nil, err
}
if _, ok := seen[resolvedPrimary]; ok {
if strict {
return nil, buserr.New("ErrAgentFallbackModelDuplicate")
}
continue
}
seen[resolvedPrimary] = struct{}{}
result = append(result, resolvedPrimary)
}
return result, nil
}
func extractOpenclawPrimaryModelID(conf map[string]interface{}, account *model.AgentAccount, accountModels []dto.AgentAccountModel) string {
modelMap := openclawModelRefMap(conf)
primary := strings.TrimSpace(fmt.Sprintf("%v", modelMap["primary"]))
if primary == "" {
return ""
}
resolved, err := buildOpenclawResolvedModelIDMap(account, accountModels)
if err != nil {
return ""
}
return resolved[primary]
}
func extractOpenclawFallbackModelIDs(conf map[string]interface{}, account *model.AgentAccount, accountModels []dto.AgentAccountModel, primaryModel string) []string {
modelMap := openclawModelRefMap(conf)
raw, ok := modelMap["fallbacks"].([]interface{})
if !ok || len(raw) == 0 {
return nil
}
resolved, err := buildOpenclawResolvedModelIDMap(account, accountModels)
if err != nil {
return nil
}
result := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
target := strings.TrimSpace(fmt.Sprintf("%v", item))
modelID, ok := resolved[target]
if !ok || sameProviderModelID(account.Provider, modelID, primaryModel) {
continue
}
if _, ok := seen[modelID]; ok {
continue
}
seen[modelID] = struct{}{}
result = append(result, modelID)
}
return result
}
func openclawModelRefMap(conf map[string]interface{}) map[string]interface{} {
agentsMap, ok := conf["agents"].(map[string]interface{})
if !ok {
return nil
}
defaultsMap, ok := agentsMap["defaults"].(map[string]interface{})
if !ok {
return nil
}
modelMap, ok := defaultsMap["model"].(map[string]interface{})
if !ok {
return nil
}
return modelMap
}
func buildOpenclawResolvedModelIDMap(account *model.AgentAccount, accountModels []dto.AgentAccountModel) (map[string]string, error) {
result := make(map[string]string, len(accountModels))
for _, item := range accountModels {
resolvedPrimary, _, _, _, err := buildOpenclawCatalogModel(account, item)
if err != nil {
return nil, err
}
result[resolvedPrimary] = item.ID
}
return result, nil
}
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 {
if err := writeOpenclawConfig(confDir, account, modelName, token, allowedOrigins, nil); err != nil {
return err
}
dataDir := path.Join(appInstall.GetPath(), "data")
@@ -1176,12 +1299,6 @@ func replacePersistedAgentAccountModelsWithTx(tx *gorm.DB, accountID uint, model
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) ([]dto.AgentAccountModel, error) {
requested := append([]dto.AgentAccountModel(nil), models...)
if len(requested) == 0 {
@@ -1534,24 +1651,6 @@ func normalizeCustomModel(modelName string) string {
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":
+2
View File
@@ -49,6 +49,8 @@ ErrAgentAccountNotVerified: 'Agent account not verified'
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'
ErrAgentFallbackModelDuplicate: 'Fallback models cannot be duplicated'
ErrAgentFallbackModelPrimary: 'Fallback model cannot match the primary model'
ErrAgentModelInUse: 'A bound agent is still using this model: {{ .name }}'
ErrAgentBaseURLRequired: 'Base URL is required'
ErrAgentApiKeyRequired: 'API key is required'
+2
View File
@@ -44,6 +44,8 @@ ErrAgentAccountNotVerified: 'Cuenta de agente no verificada'
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'
ErrAgentFallbackModelDuplicate: 'Los modelos de respaldo no pueden duplicarse'
ErrAgentFallbackModelPrimary: 'El modelo de respaldo no puede ser igual al modelo principal'
ErrAgentModelInUse: 'Un agente vinculado todavía usa este modelo: {{ .name }}'
ErrAgentBaseURLRequired: 'URL base requerida'
ErrAgentApiKeyRequired: 'Clave API requerida'
+2
View File
@@ -44,6 +44,8 @@ ErrAgentAccountNotVerified: 'アカウント未検証です'
ErrAgentAccountModelsRequired: '少なくとも 1 つのモデルを設定してください'
ErrAgentAccountSingleInitialModel: 'アカウント作成時に設定できる初期モデルは 1 つだけです'
ErrAgentModelNotInAccount: '選択したモデルはこのアカウントに設定されていません'
ErrAgentFallbackModelDuplicate: 'フォールバックモデルは重複できません'
ErrAgentFallbackModelPrimary: 'フォールバックモデルは主モデルと同じにできません'
ErrAgentModelInUse: 'このモデルはまだエージェントで使用中です: {{ .name }}'
ErrAgentBaseURLRequired: 'ベースURLが必要です'
ErrAgentApiKeyRequired: 'APIキーが必要です'
+2
View File
@@ -44,6 +44,8 @@ ErrAgentAccountNotVerified: '에이전트 계정 미확인'
ErrAgentAccountModelsRequired: '최소 한 개의 모델을 구성해 주세요'
ErrAgentAccountSingleInitialModel: '계정 생성 시 초기 모델은 하나만 구성할 수 있습니다'
ErrAgentModelNotInAccount: '선택한 모델이 현재 계정에 구성되어 있지 않습니다'
ErrAgentFallbackModelDuplicate: '폴백 모델은 중복될 수 없습니다'
ErrAgentFallbackModelPrimary: '폴백 모델은 기본 모델과 같을 수 없습니다'
ErrAgentModelInUse: '이 모델을 아직 사용 중인 에이전트가 있습니다: {{ .name }}'
ErrAgentBaseURLRequired: '기본 URL 필요'
ErrAgentApiKeyRequired: 'API 키 필요'
+2
View File
@@ -44,6 +44,8 @@ ErrAgentAccountNotVerified: 'Akaun ejen belum disahkan'
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'
ErrAgentFallbackModelDuplicate: 'Model fallback tidak boleh berulang'
ErrAgentFallbackModelPrimary: 'Model fallback tidak boleh sama dengan model utama'
ErrAgentModelInUse: 'Masih ada ejen terikat yang menggunakan model ini: {{ .name }}'
ErrAgentBaseURLRequired: 'URL asas diperlukan'
ErrAgentApiKeyRequired: 'Kunci API diperlukan'
+2
View File
@@ -44,6 +44,8 @@ ErrAgentAccountNotVerified: 'Conta de agente não verificada'
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'
ErrAgentFallbackModelDuplicate: 'Os modelos de fallback não podem ser duplicados'
ErrAgentFallbackModelPrimary: 'O modelo de fallback não pode ser igual ao modelo principal'
ErrAgentModelInUse: 'Ainda há um agente vinculado usando este modelo: {{ .name }}'
ErrAgentBaseURLRequired: 'URL base obrigatória'
ErrAgentApiKeyRequired: 'Chave API obrigatória'
+2
View File
@@ -44,6 +44,8 @@ ErrAgentAccountNotVerified: 'Аккаунт не подтверждён'
ErrAgentAccountModelsRequired: 'Настройте хотя бы одну модель'
ErrAgentAccountSingleInitialModel: 'При создании аккаунта можно указать только одну начальную модель'
ErrAgentModelNotInAccount: 'Модель не настроена в выбранном аккаунте'
ErrAgentFallbackModelDuplicate: 'Резервные модели не могут дублироваться'
ErrAgentFallbackModelPrimary: 'Резервная модель не может совпадать с основной'
ErrAgentModelInUse: 'Эта модель всё ещё используется агентом: {{ .name }}'
ErrAgentBaseURLRequired: 'Нужен базовый URL'
ErrAgentApiKeyRequired: 'Нужен API-ключ'
+2
View File
@@ -44,6 +44,8 @@ ErrAgentAccountNotVerified: 'Ajans hesabı doğrulanmadı'
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ı'
ErrAgentFallbackModelDuplicate: 'Yedek modeller tekrar edemez'
ErrAgentFallbackModelPrimary: 'Yedek model birincil modelle aynı olamaz'
ErrAgentModelInUse: 'Bu modeli hâlâ kullanan bağlı bir ajan var: {{ .name }}'
ErrAgentBaseURLRequired: 'Temel URL gerekli'
ErrAgentApiKeyRequired: 'API anahtarı gerekli'
+2
View File
@@ -44,6 +44,8 @@ ErrAgentAccountNotVerified: '帳號未驗證通過,請重試'
ErrAgentAccountModelsRequired: '請至少配置一個模型'
ErrAgentAccountSingleInitialModel: '建立帳號時只能配置一個初始模型'
ErrAgentModelNotInAccount: '所選模型未配置在當前帳號中,請重試'
ErrAgentFallbackModelDuplicate: '備用模型不能重複'
ErrAgentFallbackModelPrimary: '備用模型不能與主模型相同'
ErrAgentModelInUse: '仍有智能體正在使用該模型:{{ .name }}'
ErrAgentBaseURLRequired: 'Base URL 不可為空,請重試'
ErrAgentApiKeyRequired: 'API Key 不可為空,請重試'
+2
View File
@@ -49,6 +49,8 @@ ErrAgentAccountNotVerified: "账号未通过验证"
ErrAgentAccountModelsRequired: "请至少配置一个模型"
ErrAgentAccountSingleInitialModel: "创建账号时只能配置一个初始模型"
ErrAgentModelNotInAccount: "所选模型未配置在当前账号中"
ErrAgentFallbackModelDuplicate: "备用模型不能重复"
ErrAgentFallbackModelPrimary: "备用模型不能与主模型相同"
ErrAgentModelInUse: "仍有智能体正在使用该模型: {{ .name }}"
ErrAgentBaseURLRequired: "Base URL 不能为空"
ErrAgentApiKeyRequired: "API Key 不能为空"
+1
View File
@@ -45,6 +45,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/delete", baseApi.DeleteAgent)
aiToolsRouter.POST("/agents/token/reset", baseApi.ResetAgentToken)
aiToolsRouter.POST("/agents/remark", baseApi.UpdateAgentRemark)
aiToolsRouter.POST("/agents/model/get", baseApi.GetAgentModelConfig)
aiToolsRouter.POST("/agents/model/update", baseApi.UpdateAgentModelConfig)
aiToolsRouter.POST("/agents/overview", baseApi.GetAgentOverview)
aiToolsRouter.GET("/agents/providers", baseApi.GetAgentProviders)
+11
View File
@@ -308,12 +308,23 @@ export namespace AI {
agentId: number;
accountId: number;
model: string;
fallbacks: string[];
}
export interface AgentModelConfig {
accountId: number;
model: string;
fallbacks: string[];
}
export interface AgentOverviewReq {
agentId: number;
}
export interface AgentIDReq {
agentId: number;
}
export interface AgentRoleCreateReq {
agentId: number;
name: string;
+4
View File
@@ -113,6 +113,10 @@ export const updateAgentRemark = (req: AI.AgentRemarkUpdateReq) => {
return http.post(`/ai/agents/remark`, req);
};
export const getAgentModelConfig = (req: AI.AgentIDReq) => {
return http.post<AI.AgentModelConfig>(`/ai/agents/model/get`, req);
};
export const updateAgentModelConfig = (req: AI.AgentModelConfigUpdateReq) => {
return http.post(`/ai/agents/model/update`, req);
};
+4
View File
@@ -688,6 +688,9 @@ const message = {
accountModelsRequired: 'Configure at least one model',
accountModelsDuplicate: 'Duplicate models exist in the catalog',
modelPool: 'Model Pool',
fallbackModels: 'Fallback Models',
addFallbackModel: 'Add Fallback Model',
fallbackModelsEmpty: 'No fallback models configured',
modelInputTypes: 'Input Types',
reasoning: 'Reasoning Model',
manualModel: 'Manual input',
@@ -760,6 +763,7 @@ const message = {
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
primaryModel: 'Primary Model',
defaultModel: 'Default Model',
channelCount: 'Configured Channels',
skillCount: 'Skills',
+4
View File
@@ -697,6 +697,9 @@ const message = {
accountModelsRequired: 'Configure al menos un modelo',
accountModelsDuplicate: 'Existen modelos duplicados en el catálogo',
modelPool: 'Pool de modelos',
fallbackModels: 'Modelos de respaldo',
addFallbackModel: 'Agregar modelo de respaldo',
fallbackModelsEmpty: 'No hay modelos de respaldo configurados',
modelInputTypes: 'Tipos de entrada',
reasoning: 'Modelo de razonamiento',
manualModel: 'Entrada manual de modelo',
@@ -772,6 +775,7 @@ const message = {
configFileRestartHelper:
'Guardar el archivo de configuración requiere reiniciar inmediatamente el contenedor para surtir efecto.',
overviewSnapshot: 'Resumen',
primaryModel: 'Modelo principal',
defaultModel: 'Modelo predeterminado',
channelCount: 'Canales configurados',
skillCount: 'Habilidades',
+4
View File
@@ -689,6 +689,9 @@ const message = {
accountModelsRequired: 'Configure at least one model',
accountModelsDuplicate: 'カタログに重複したモデルがあります',
modelPool: 'モデルプール',
fallbackModels: 'フォールバックモデル',
addFallbackModel: 'フォールバックモデルを追加',
fallbackModelsEmpty: 'フォールバックモデルは未設定です',
modelInputTypes: '入力タイプ',
reasoning: '推論モデル',
manualModel: '手動入力',
@@ -763,6 +766,7 @@ const message = {
'現在の markdown ファイルをすべて保存するには、反映のためコンテナの再起動が必要です。今すぐ再起動するか後で再起動するかを選択してください。',
configFileRestartHelper: '設定ファイルを保存するには、反映のため直ちにコンテナを再起動する必要があります。',
overviewSnapshot: '概要',
primaryModel: 'メインモデル',
defaultModel: 'デフォルトモデル',
channelCount: '設定済みチャンネル数',
skillCount: '技能数',
+4
View File
@@ -681,6 +681,9 @@ const message = {
accountModelsRequired: 'Configure at least one model',
accountModelsDuplicate: '카탈로그에 중복된 모델이 있습니다',
modelPool: '모델 풀',
fallbackModels: '폴백 모델',
addFallbackModel: '폴백 모델 추가',
fallbackModelsEmpty: '구성된 폴백 모델이 없습니다',
modelInputTypes: '입력 유형',
reasoning: '추론 모델',
manualModel: '수동 입력',
@@ -749,6 +752,7 @@ const message = {
'현재 markdown 파일을 모두 저장하려면 적용을 위해 컨테이너를 재시작해야 합니다. 지금 재시작할지 나중에 재시작할지 선택하세요.',
configFileRestartHelper: '설정 파일을 저장하려면 즉시 컨테이너를 재시작해야 적용됩니다.',
overviewSnapshot: '개요',
primaryModel: '주 모델',
defaultModel: '기본 모델',
channelCount: '설정된 채널 수',
skillCount: '기술 수',
+4
View File
@@ -696,6 +696,9 @@ const message = {
accountModelsRequired: 'Configure at least one model',
accountModelsDuplicate: 'Duplicate models exist in the catalog',
modelPool: 'Model Pool',
fallbackModels: 'Model fallback',
addFallbackModel: 'Tambah model fallback',
fallbackModelsEmpty: 'Tiada model fallback dikonfigurasikan',
modelInputTypes: 'Jenis input',
reasoning: 'Model penaakulan',
manualModel: 'Input manual',
@@ -768,6 +771,7 @@ const message = {
configFileRestartHelper:
'Menyimpan fail konfigurasi memerlukan kontena dimulakan semula serta-merta untuk berkuat kuasa.',
overviewSnapshot: 'Ringkasan',
primaryModel: 'Model utama',
defaultModel: 'Model lalai',
channelCount: 'Bilangan saluran dikonfigurasi',
skillCount: 'Bilangan kemahiran',
+4
View File
@@ -691,6 +691,9 @@ const message = {
accountModelsRequired: 'Configure at least one model',
accountModelsDuplicate: 'Duplicate models exist in the catalog',
modelPool: 'Model Pool',
fallbackModels: 'Modelos de fallback',
addFallbackModel: 'Adicionar modelo de fallback',
fallbackModelsEmpty: 'Nenhum modelo de fallback configurado',
modelInputTypes: 'Tipos de entrada',
reasoning: 'Modelo de raciocínio',
manualModel: 'Entrada manual',
@@ -766,6 +769,7 @@ const message = {
configFileRestartHelper:
'Salvar o arquivo de configuração exige reiniciar imediatamente o contêiner para entrar em vigor.',
overviewSnapshot: 'Visão geral',
primaryModel: 'Modelo principal',
defaultModel: 'Modelo padrão',
channelCount: 'Canais configurados',
skillCount: 'Habilidades',
+4
View File
@@ -688,6 +688,9 @@ const message = {
accountModelsRequired: 'Configure at least one model',
accountModelsDuplicate: 'Duplicate models exist in the catalog',
modelPool: 'Model Pool',
fallbackModels: 'Резервные модели',
addFallbackModel: 'Добавить резервную модель',
fallbackModelsEmpty: 'Резервные модели не настроены',
modelInputTypes: 'Input Types',
reasoning: 'Reasoning Model',
manualModel: 'Ручной ввод',
@@ -760,6 +763,7 @@ const message = {
configFileRestartHelper:
'Сохранение файла конфигурации требует немедленного перезапуска контейнера для применения.',
overviewSnapshot: 'Сводка',
primaryModel: 'Основная модель',
defaultModel: 'Модель по умолчанию',
channelCount: 'Настроенные каналы',
skillCount: 'Навыки',
+4
View File
@@ -692,6 +692,9 @@ const message = {
accountModelsRequired: 'Configure at least one model',
accountModelsDuplicate: 'Duplicate models exist in the catalog',
modelPool: 'Model Pool',
fallbackModels: 'Yedek modeller',
addFallbackModel: 'Yedek model ekle',
fallbackModelsEmpty: 'Yapılandırılmış yedek model yok',
modelInputTypes: 'Input Types',
reasoning: 'Reasoning Model',
manualModel: 'Manuel giriş',
@@ -767,6 +770,7 @@ const message = {
configFileRestartHelper:
'Yapılandırma dosyasını kaydetmek, etkin olması için konteynerin hemen yeniden başlatılmasını gerektirir.',
overviewSnapshot: 'Özet',
primaryModel: 'Birincil model',
defaultModel: 'Varsayılan model',
channelCount: 'Yapılandırılmış kanallar',
skillCount: 'Yetenekler',
+4
View File
@@ -656,6 +656,9 @@ const message = {
accountModelsRequired: '請至少配置一個模型',
accountModelsDuplicate: '模型池中存在重複模型,請檢查後重試',
modelPool: '模型池',
fallbackModels: '備用模型',
addFallbackModel: '新增備用模型',
fallbackModelsEmpty: '尚未配置備用模型',
modelInputTypes: '輸入類型',
reasoning: '推理模型',
manualModel: '手動輸入模型',
@@ -714,6 +717,7 @@ const message = {
roleMarkdownRestartHelper: '保存當前全部 MD 檔案後,需要重新啟動容器才能生效。請選擇立即重啟或稍後重啟。',
configFileRestartHelper: '保存配置檔後需要立即重新啟動容器才能生效。',
overviewSnapshot: '狀態概覽',
primaryModel: '主模型',
defaultModel: '預設模型',
channelCount: '已配置頻道數量',
skillCount: '技能數量',
+4
View File
@@ -655,6 +655,9 @@ const message = {
accountModelsRequired: '请至少配置一个模型',
accountModelsDuplicate: '模型池中存在重复模型,请检查后重试',
modelPool: '模型池',
fallbackModels: '备用模型',
addFallbackModel: '新增备用模型',
fallbackModelsEmpty: '尚未配置备用模型',
modelInputTypes: '输入类型',
reasoning: '推理模型',
manualModel: '手动输入模型',
@@ -709,6 +712,7 @@ const message = {
roleMarkdownRestartHelper: '保存当前全部 MD 文件后,需要重启容器才能生效。请选择立即重启或稍后重启。',
configFileRestartHelper: '保存配置文件后需要重启容器才能生效。',
overviewSnapshot: '状态概览',
primaryModel: '主模型',
defaultModel: '默认模型',
channelCount: '已配置频道数量',
skillCount: '技能数量',
@@ -68,8 +68,6 @@ const loadModel = async () => {
await nextTick();
await modelRef.value?.load({
agentId: agentId.value,
accountId: accountId.value,
model: model.value,
});
};
@@ -214,7 +214,7 @@ const props = defineProps({
const emit = defineEmits<{
(e: 'update:bots', bots: ChannelBotItem[]): void;
(e: 'approve', bot: ChannelBotItem): void;
(e: 'save'): void;
(e: 'save', action?: 'delete' | 'save'): void;
}>();
const { t } = useI18n();
@@ -306,7 +306,7 @@ const saveBot = async () => {
}
emitBots(nextBots);
dialogVisible.value = false;
emit('save');
emit('save', 'save');
};
const removeBot = (index: number) => {
@@ -316,7 +316,7 @@ const removeBot = (index: number) => {
return;
}
emitBots(nextBots);
emit('save');
emit('save', 'delete');
};
const setDefault = (accountId: string) => {
@@ -325,14 +325,14 @@ const setDefault = (accountId: string) => {
isDefault: bot.accountId === accountId,
}));
emitBots(nextBots);
emit('save');
emit('save', 'save');
};
const updateEnabled = (index: number, enabled: boolean | string | number) => {
const nextBots = props.bots.map((bot) => ({ ...bot }));
nextBots[index].enabled = Boolean(enabled);
emitBots(nextBots);
emit('save');
emit('save', 'save');
};
</script>
@@ -253,7 +253,7 @@ const reload = async () => {
await load(agentId.value);
};
const saveChannel = async () => {
const saveChannel = async (action: 'delete' | 'save' = 'save') => {
if (!supported.value || !agentId.value || !formRef.value) {
return;
}
@@ -278,7 +278,7 @@ const saveChannel = async () => {
ackText: form.ackText,
bots: form.bots,
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
MsgSuccess(action === 'delete' ? t('commons.msg.deleteSuccess') : t('aiTools.agents.saveSuccess'));
} finally {
saving.value = false;
}
@@ -115,7 +115,7 @@ const load = async (id: number) => {
form.bots = res.data?.bots || [];
};
const saveChannel = async () => {
const saveChannel = async (action: 'delete' | 'save' = 'save') => {
if (!agentId.value || !formRef.value) {
return;
}
@@ -135,7 +135,7 @@ const saveChannel = async () => {
defaultAccount: form.defaultAccount,
bots: form.bots,
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
MsgSuccess(action === 'delete' ? t('commons.msg.deleteSuccess') : t('aiTools.agents.saveSuccess'));
} finally {
saving.value = false;
}
@@ -283,7 +283,7 @@ const validateBots = () => {
return true;
};
const saveChannel = async () => {
const saveChannel = async (action: 'delete' | 'save' = 'save') => {
if (!agentId.value || !formRef.value) {
return;
}
@@ -308,7 +308,7 @@ const saveChannel = async () => {
groupAllowFrom: parseTextList(form.groupAllowFromText),
bots: buildPayloadBots(),
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
MsgSuccess(action === 'delete' ? t('commons.msg.deleteSuccess') : t('aiTools.agents.saveSuccess'));
} finally {
saving.value = false;
}
@@ -162,7 +162,7 @@ const reload = async () => {
await load(agentId.value);
};
const saveChannel = async () => {
const saveChannel = async (action: 'delete' | 'save' = 'save') => {
if (!agentId.value) {
return;
}
@@ -186,7 +186,7 @@ const saveChannel = async () => {
systemPrompt: bot.systemPrompt,
})),
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
MsgSuccess(action === 'delete' ? t('commons.msg.deleteSuccess') : t('aiTools.agents.saveSuccess'));
} finally {
saving.value = false;
}
@@ -244,7 +244,7 @@ const load = async (id: number) => {
form.bots = res.data?.bots || [];
};
const saveChannel = async () => {
const saveChannel = async (action: 'delete' | 'save' = 'save') => {
if (!agentId.value || !formRef.value) {
return;
}
@@ -267,7 +267,7 @@ const saveChannel = async () => {
defaultAccount: form.defaultAccount,
bots: form.bots,
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
MsgSuccess(action === 'delete' ? t('commons.msg.deleteSuccess') : t('aiTools.agents.saveSuccess'));
} finally {
saving.value = false;
}
@@ -5,12 +5,56 @@
<el-option v-for="item in accountOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('aiTools.model.model')" prop="model">
<el-select v-model="form.model" filterable>
<el-form-item :label="t('aiTools.agents.primaryModel')" prop="model">
<el-select v-model="form.model" filterable @change="handleModelChange">
<el-option v-for="item in modelOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<span class="input-help">{{ t('aiTools.agents.accountModelsHelper') }}</span>
</el-form-item>
<el-form-item :label="t('aiTools.agents.fallbackModels')">
<div class="fallback-editor">
<div class="fallback-toolbar">
<el-select v-model="fallbackCandidate" filterable clearable class="fallback-select">
<el-option
v-for="item in fallbackCandidateOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-button type="primary" plain :disabled="!fallbackCandidate" @click="addFallback">
{{ t('aiTools.agents.addFallbackModel') }}
</el-button>
</div>
<el-table :data="fallbackRows" size="small">
<el-table-column :label="t('aiTools.model.model')" min-width="220">
<template #default="{ row }">
{{ row.name }}
</template>
</el-table-column>
<el-table-column :label="t('commons.table.operate')" width="180" fixed="right">
<template #default="{ $index }">
<el-button
link
:icon="ArrowUp"
:disabled="$index === 0"
@click="moveFallback($index, -1)"
/>
<el-button
link
:icon="ArrowDown"
:disabled="$index === fallbackRows.length - 1"
@click="moveFallback($index, 1)"
/>
<el-button link :icon="Delete" @click="removeFallback($index)" />
</template>
</el-table-column>
<template #empty>
<div class="fallback-empty">{{ t('aiTools.agents.fallbackModelsEmpty') }}</div>
</template>
</el-table>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveModel">
{{ t('commons.button.save') }}
@@ -20,11 +64,12 @@
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { computed, reactive, ref } from 'vue';
import type { FormInstance } from 'element-plus';
import { ArrowDown, ArrowUp, Delete } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
import { AI } from '@/api/interface/ai';
import { pageAgentAccounts, updateAgentModelConfig } from '@/api/modules/ai';
import { getAgentModelConfig, pageAgentAccounts, updateAgentModelConfig } from '@/api/modules/ai';
import { Rules } from '@/global/form-rules';
import { MsgSuccess } from '@/utils/message';
import { useGlobalStore } from '@/composables/useGlobalStore';
@@ -44,13 +89,12 @@ const modelOptions = ref<AI.AgentAccountModel[]>([]);
interface AgentModelLoadParams {
agentId: number;
accountId: number;
model: string;
}
const form = reactive({
accountId: undefined as unknown as number,
model: '',
fallbacks: [] as string[],
});
const rules = reactive({
@@ -58,45 +102,107 @@ const rules = reactive({
model: [Rules.requiredSelect],
});
const loadAccounts = async () => {
const res = await pageAgentAccounts({
page: 1,
pageSize: 200,
provider: '',
name: '',
});
const items = res.data.items || [];
accountOptions.value = isIntl.value ? items.filter((item) => !blockedProviders.has(item.provider)) : items;
};
const fallbackCandidate = ref('');
const fallbackRows = computed(() =>
form.fallbacks
.map((id) => modelOptions.value.find((item) => item.id === id))
.filter((item): item is AI.AgentAccountModel => item !== undefined),
);
const fallbackCandidateOptions = computed(() =>
modelOptions.value.filter((item) => item.id !== form.model && !form.fallbacks.includes(item.id)),
);
const setModelOptionsByAccount = (accountId: number) => {
const selected = accountOptions.value.find((item) => item.id === accountId);
modelOptions.value = selected?.models || [];
};
const ensureSelectedModel = (preferred?: string) => {
if (preferred && modelOptions.value.some((item) => item.id === preferred)) {
form.model = preferred;
return;
}
if (!modelOptions.value.some((item) => item.id === form.model)) {
form.model = modelOptions.value[0]?.id || '';
}
};
const syncFallbacks = (fallbacks?: string[]) => {
const current = fallbacks || form.fallbacks;
form.fallbacks = current.filter(
(id, index) =>
id !== form.model &&
modelOptions.value.some((item) => item.id === id) &&
current.findIndex((item) => item === id) === index,
);
if (!fallbackCandidateOptions.value.some((item) => item.id === fallbackCandidate.value)) {
fallbackCandidate.value = '';
}
};
const handleAccountChange = () => {
setModelOptionsByAccount(form.accountId);
ensureSelectedModel();
form.fallbacks = [];
fallbackCandidate.value = '';
};
const handleModelChange = () => {
syncFallbacks();
};
const addFallback = () => {
if (!fallbackCandidate.value) {
return;
}
syncFallbacks([...form.fallbacks, fallbackCandidate.value]);
};
const moveFallback = (index: number, offset: number) => {
const target = index + offset;
if (target < 0 || target >= form.fallbacks.length) {
return;
}
const next = [...form.fallbacks];
[next[index], next[target]] = [next[target], next[index]];
form.fallbacks = next;
};
const removeFallback = (index: number) => {
form.fallbacks = form.fallbacks.filter((_, current) => current !== index);
};
const load = async (params: AgentModelLoadParams) => {
loading.value = true;
try {
agentId.value = params.agentId;
await loadAccounts();
const [accountsRes, configRes] = await Promise.all([
pageAgentAccounts({
page: 1,
pageSize: 200,
provider: '',
name: '',
}),
getAgentModelConfig({ agentId: params.agentId }),
]);
const items = accountsRes.data.items || [];
accountOptions.value = isIntl.value ? items.filter((item) => !blockedProviders.has(item.provider)) : items;
if (accountOptions.value.length === 0) {
form.accountId = undefined as unknown as number;
form.model = '';
form.fallbacks = [];
modelOptions.value = [];
return;
}
const config = configRes.data;
const currentAccount =
accountOptions.value.find((item) => item.id === params.accountId) || accountOptions.value[0];
accountOptions.value.find((item) => item.id === config.accountId) || accountOptions.value[0];
form.accountId = currentAccount.id;
form.model = params.model || currentAccount.models?.[0]?.id || '';
setModelOptionsByAccount(currentAccount.id);
ensureSelectedModel(config.model);
syncFallbacks(config.fallbacks || []);
} finally {
loading.value = false;
}
@@ -113,6 +219,7 @@ const saveModel = async () => {
agentId: agentId.value,
accountId: form.accountId,
model: form.model,
fallbacks: form.fallbacks,
});
MsgSuccess(t('aiTools.agents.switchModelSuccess'));
emit('updated');
@@ -125,3 +232,24 @@ defineExpose({
load,
});
</script>
<style scoped lang="scss">
.fallback-editor {
width: 100%;
}
.fallback-toolbar {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.fallback-select {
width: 360px;
}
.fallback-empty {
padding: 12px 0;
color: var(--el-text-color-secondary);
}
</style>