feat: Optimize application upgrade logic (#13549)

* feat: Optimize application upgrade logic

* feat: Optimize application upgrade logic

* feat: Optimize application upgrade logic
This commit is contained in:
CityFun
2026-08-13 18:28:59 +08:00
committed by GitHub
parent c13793c445
commit d402f67fc3
24 changed files with 99 additions and 60 deletions

View File

@@ -40,20 +40,21 @@ type Meta struct {
var catalog = map[string]Meta{
"custom": {
Key: "custom", DisplayName: "Custom", Sort: 10, DefaultAPIType: "openai-completions", EnvKey: "CUSTOM_API_KEY",
APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"),
APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "openai-embeddings"),
},
"ollama": {
Key: "ollama", DisplayName: "Ollama", Sort: 15, DefaultAPIType: "openai-responses",
APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions"),
APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions", "openai-embeddings"),
},
"vllm": {
Key: "vllm", DisplayName: "vLLM", Sort: 20, DefaultAPIType: "openai-completions", EnvKey: "VLLM_API_KEY",
APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"),
APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images", "openai-embeddings"),
},
"deepseek": {
Key: "deepseek", DisplayName: "DeepSeek", Sort: 25, DefaultAPIType: "openai-completions", EnvKey: "DEEPSEEK_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://api.deepseek.com"},
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
},
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
@@ -131,6 +132,10 @@ var catalog = map[string]Meta{
{APIType: "openai-responses", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-completions", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-images", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-embeddings", BaseURL: "https://api.openai.com/v1", Models: []Model{
{ID: "text-embedding-3-small", Name: "text-embedding-3-small"},
{ID: "text-embedding-3-large", Name: "text-embedding-3-large"},
}},
},
Models: []Model{{ID: "gpt-5.4", Name: "gpt-5.4"}, {ID: "gpt-5.4-pro", Name: "gpt-5.4-pro"}, {ID: "gpt-5.4-mini", Name: "gpt-5.4-mini"}, {ID: "gpt-5.4-nano", Name: "gpt-5.4-nano"}},
},
@@ -296,7 +301,7 @@ func DefaultModels(key, apiType string) []Model {
if len(config.Models) > 0 {
return append([]Model(nil), config.Models...)
}
if IsImageAPIType(config.APIType) {
if IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType) {
return nil
}
break
@@ -359,7 +364,7 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) {
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid base url")
}
if key == "custom" && IsImageAPIType(config.APIType) {
if key == "custom" && (IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType)) {
return baseURL, nil
}
parsed.Path = normalizeEndpointPath(config.APIType, parsed.Path)
@@ -381,6 +386,8 @@ func normalizeEndpointPath(apiType, value string) string {
suffixes = []string{"/responses"}
case "anthropic-messages":
suffixes = []string{"/v1/messages", "/messages"}
case "openai-embeddings":
suffixes = []string{"/v1/embeddings", "/embeddings"}
}
for _, suffix := range suffixes {
if strings.HasSuffix(strings.ToLower(path), suffix) {
@@ -390,6 +397,10 @@ func normalizeEndpointPath(apiType, value string) string {
return path
}
func IsEmbeddingAPIType(apiType string) bool {
return apiType == "openai-embeddings"
}
func IsImageAPIType(apiType string) bool {
switch apiType {
case "openai-images", "dashscope-images", "minimax-images", "openrouter-images":

View File

@@ -23,7 +23,7 @@ func BuildOpenClawProviderPatch(provider, modelName, apiType, authMode, baseURL,
if _, ok := FindAPIConfig(provider, resolvedAPIType); !ok {
resolvedAPIType = DefaultAPIType(provider)
}
if IsImageAPIType(resolvedAPIType) {
if IsImageAPIType(resolvedAPIType) || IsEmbeddingAPIType(resolvedAPIType) {
return nil, fmt.Errorf("api type %s does not support text generation", resolvedAPIType)
}
resolvedAuthMode, err := ResolveAuthMode(provider, resolvedAPIType, authMode)

View File

@@ -81,6 +81,10 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
}
switch apiType {
case "openai-embeddings":
request.URL = embeddingVerifyURL(baseURL)
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "input": "ping"})
case "openai-images":
request.URL = imageVerifyURL(provider, baseURL, "/images/generations")
headers["Authorization"] = "Bearer " + apiKey
@@ -131,6 +135,17 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
return request
}
func embeddingVerifyURL(baseURL string) string {
lowerBaseURL := strings.ToLower(baseURL)
if strings.HasSuffix(lowerBaseURL, "/embeddings") {
return baseURL
}
if strings.HasSuffix(lowerBaseURL, "/v1") {
return baseURL + "/embeddings"
}
return baseURL + "/v1/embeddings"
}
func imageVerifyURL(provider, baseURL, endpoint string) string {
if provider == "custom" || strings.HasSuffix(strings.ToLower(baseURL), endpoint) {
return baseURL

View File

@@ -86,7 +86,7 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
Model(&model.AgentAccount{}).
Select("provider, COUNT(*) as count").
Where("provider IN ?", normalizedProviders).
Where("api_type NOT LIKE ?", "%-images").
Scopes(WithTextAPIType()).
Group("provider").
Scan(&rows).Error; err != nil {
return nil, err

View File

@@ -108,7 +108,7 @@ func WithByAPIType(apiType string) DBOption {
func WithTextAPIType() DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("api_type NOT LIKE ?", "%-images")
return g.Where("api_type NOT LIKE ? AND api_type <> ?", "%-images", "openai-embeddings")
}
}

View File

@@ -116,7 +116,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
if validateAvailability && (imageAPI || !providercatalog.SkipVerification(provider)) {
if validateAvailability && (imageAPI || providercatalog.IsEmbeddingAPIType(resolvedAPIType) || !providercatalog.SkipVerification(provider)) {
if err := providercatalog.VerifyAccount(provider, resolvedAPIType, resolvedAuthMode, resolvedBaseURL, resolvedAPIKey, modelID); err != nil {
return resolvedAgentAccountInput{}, err
}

View File

@@ -426,7 +426,7 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
t.LogStart(logStr)
if out, upErr := compose.UpWithoutPull(u.original.GetComposePath()); upErr != nil {
if out, upErr := compose.UpWithoutBuild(u.original.GetComposePath()); upErr != nil {
if out != "" {
upErr = fmt.Errorf("%s: %w", out, upErr)
}
@@ -545,7 +545,7 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
return u.finishRollback()
}
if u.phase < appUpgradeMutated {
if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil {
if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
@@ -563,14 +563,14 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if u.backupFile != "" {
_ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
_, _ = compose.UpWithoutPull(u.original.GetComposePath())
_, _ = compose.UpWithoutBuild(u.original.GetComposePath())
return errors.Join(rollbackErr, err)
}
} else {
if err := u.restoreManagedFiles(); err != nil {
return errors.Join(rollbackErr, err)
}
if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil {
if out, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}

View File

@@ -232,7 +232,7 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
}
defer func() {
if isRollback {
_, _ = compose.UpWithoutPull(install.GetComposePath())
_, _ = compose.UpWithoutBuild(install.GetComposePath())
} else {
_, _ = compose.Up(install.GetComposePath())
}

View File

@@ -48,7 +48,7 @@ func Up(filePath string, projectName ...string) (string, error) {
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...)
}
func UpWithoutPull(filePath string, projectName ...string) (string, error) {
func UpWithoutBuild(filePath string, projectName ...string) (string, error) {
if err := checkCmd(); err != nil {
return "", err
}
@@ -58,11 +58,11 @@ func UpWithoutPull(filePath string, projectName ...string) (string, error) {
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...)
}
func upArgs(filePath string, withoutPull bool) []string {
func upArgs(filePath string, withoutBuild bool) []string {
args := loadFiles(filePath)
args = append(args, "up", "-d")
if withoutPull {
args = append(args, "--pull", "never", "--no-build")
if withoutBuild {
args = append(args, "--no-build")
}
return args
}

View File

@@ -1,14 +1,15 @@
package docker
import (
"bufio"
"bytes"
"context"
"fmt"
"path"
"strings"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/template"
"github.com/compose-spec/compose-go/v2/types"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
@@ -91,7 +92,7 @@ func (e *Environment) UnmarshalYAML(value *yaml.Node) error {
}
func GetImagesFromDockerCompose(env, yml []byte) ([]string, error) {
envVars, err := loadEnvFile(env)
envVars, err := dotenv.Parse(bytes.NewReader(env))
if err != nil {
return nil, fmt.Errorf("load env failed: %v", err)
}
@@ -104,43 +105,16 @@ func GetImagesFromDockerCompose(env, yml []byte) ([]string, error) {
var images []string
for _, service := range compose.Services {
if service.Image != "" {
resolvedImage := replaceEnvVars(service.Image, envVars)
resolvedImage, err := template.Substitute(service.Image, func(key string) (string, bool) {
value, ok := envVars[key]
return value, ok
})
if err != nil {
return nil, fmt.Errorf("resolve image failed: %v", err)
}
images = append(images, resolvedImage)
}
}
return images, nil
}
func loadEnvFile(env []byte) (map[string]string, error) {
envVars := make(map[string]string)
scanner := bufio.NewScanner(bytes.NewReader(env))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
value = strings.Trim(value, `"'`)
envVars[key] = value
}
}
return envVars, scanner.Err()
}
func replaceEnvVars(input string, envVars map[string]string) string {
return re.GetRegex(re.ComposeEnvVarPattern).ReplaceAllStringFunc(input, func(match string) string {
varName := match[2 : len(match)-1]
if value, exists := envVars[varName]; exists {
return value
}
return match
})
}

View File

@@ -1,9 +1,12 @@
<template>
<el-tag
size="small"
effect="dark"
effect="light"
:type="tagType"
:class="{ 'api-type-tag--responses': apiType === 'openai-responses' }"
:class="{
'api-type-tag--responses': apiType === 'openai-responses',
'api-type-tag--embedding': apiType === 'openai-embeddings',
}"
>
{{ apiType || '-' }}
</el-tag>
@@ -37,8 +40,14 @@ const tagType = computed<TagType>(() => {
<style scoped lang="scss">
.api-type-tag--responses {
--el-tag-bg-color: #7c3aed;
--el-tag-border-color: #7c3aed;
--el-tag-text-color: #ffffff;
--el-tag-bg-color: #f3e8ff;
--el-tag-border-color: #d8b4fe;
--el-tag-text-color: #7e22ce;
}
.api-type-tag--embedding {
--el-tag-bg-color: #e6f4f1;
--el-tag-border-color: #a7d9d1;
--el-tag-text-color: #0f766e;
}
</style>

View File

@@ -1159,8 +1159,10 @@ const message = {
imageGeneration: 'Image Generation',
textModels: 'Text Models',
textToImageModels: 'Text-to-Image Models',
embeddingModels: 'Embedding Models',
unauthorized: 'Unauthorized',
imageCount: 'Image Count',
requestExample: 'Request Example',
availableModels: 'Available Models',
modelGroupModels: 'Request Models',
modelGroupModelsPlaceholder: 'Select or enter request model names',

View File

@@ -1165,8 +1165,10 @@ const message = {
imageGeneration: 'Generación de imágenes',
textModels: 'Modelos de texto',
textToImageModels: 'Modelos de texto a imagen',
embeddingModels: 'Modelos de embeddings',
unauthorized: 'Sin autorización',
imageCount: 'Cantidad de imágenes',
requestExample: 'Ejemplo de solicitud',
availableModels: 'Modelos disponibles',
modelGroupModels: 'Modelos solicitados',
modelGroupModelsPlaceholder: 'Seleccione o ingrese nombres de modelos solicitados',

View File

@@ -1145,8 +1145,10 @@ const message = {
imageGeneration: 'تولید تصویر',
textModels: 'مدل‌های متنی',
textToImageModels: 'مدل‌های متن به تصویر',
embeddingModels: 'مدل‌های امبدینگ',
unauthorized: 'بدون مجوز',
imageCount: 'تعداد تصاویر',
requestExample: 'نمونه درخواست',
availableModels: 'مدل‌های موجود',
modelGroupModels: 'مدل‌های درخواست',
modelGroupModelsPlaceholder: 'نام‌های مدل درخواست را انتخاب یا وارد کنید',

View File

@@ -1148,8 +1148,10 @@ const message = {
imageGeneration: '画像生成',
textModels: 'テキストモデル',
textToImageModels: 'テキスト画像生成モデル',
embeddingModels: 'Embeddingモデル',
unauthorized: '未承認',
imageCount: '画像数',
requestExample: 'リクエスト例',
availableModels: '利用可能なモデル',
modelGroupModels: 'リクエストモデル',
modelGroupModelsPlaceholder: 'リクエストモデル名を選択または入力してください',

View File

@@ -1137,8 +1137,10 @@ const message = {
imageGeneration: '이미지 생성',
textModels: '텍스트 모델',
textToImageModels: '텍스트-이미지 모델',
embeddingModels: '임베딩 모델',
unauthorized: '권한 없음',
imageCount: '이미지 수',
requestExample: '요청 예시',
availableModels: '사용 가능한 모델',
modelGroupModels: '요청 모델',
modelGroupModelsPlaceholder: '요청 모델 이름을 선택하거나 입력하세요',

View File

@@ -1138,8 +1138,10 @@ const message = {
imageGeneration: 'ການສ້າງຮູບພາບ',
textModels: 'ໂມເດວຂໍ້ຄວາມ',
textToImageModels: 'ໂມເດວສ້າງຮູບຈາກຂໍ້ຄວາມ',
embeddingModels: 'ໂມເດວ Embedding',
unauthorized: 'ບໍ່ມີສິດ',
imageCount: 'ຈຳນວນຮູບ',
requestExample: 'ຕົວຢ່າງຄຳຂໍ',
availableModels: 'ໂມເດວທີ່ມີໃຫ້ໃຊ້',
modelGroupModels: 'ໂມເດວທີ່ຮ້ອງຂໍ',
modelGroupModelsPlaceholder: 'ເລືອກ ຫຼື ປ້ອນຊື່ໂມເດວທີ່ຮ້ອງຂໍ',

View File

@@ -1170,8 +1170,10 @@ const message = {
imageGeneration: 'Penjanaan Imej',
textModels: 'Model Teks',
textToImageModels: 'Model Teks-ke-Imej',
embeddingModels: 'Model Embedding',
unauthorized: 'Tidak dibenarkan',
imageCount: 'Bilangan Imej',
requestExample: 'Contoh Permintaan',
availableModels: 'Model Tersedia',
modelGroupModels: 'Model Permintaan',
modelGroupModelsPlaceholder: 'Pilih atau masukkan nama model permintaan',

View File

@@ -1167,8 +1167,10 @@ const message = {
imageGeneration: 'Geração de imagens',
textModels: 'Modelos de texto',
textToImageModels: 'Modelos de texto para imagem',
embeddingModels: 'Modelos de embedding',
unauthorized: 'Não autorizado',
imageCount: 'Quantidade de imagens',
requestExample: 'Exemplo de solicitação',
availableModels: 'Modelos disponíveis',
modelGroupModels: 'Modelos solicitados',
modelGroupModelsPlaceholder: 'Selecione ou informe nomes de modelos solicitados',

View File

@@ -1158,8 +1158,10 @@ const message = {
imageGeneration: 'Генерация изображений',
textModels: 'Текстовые модели',
textToImageModels: 'Модели генерации изображений по тексту',
embeddingModels: 'Модели эмбеддингов',
unauthorized: 'Нет доступа',
imageCount: 'Количество изображений',
requestExample: 'Пример запроса',
availableModels: 'Доступные модели',
modelGroupModels: 'Запрошенные модели',
modelGroupModelsPlaceholder: 'Выберите или введите имена запрошенных моделей',

View File

@@ -1167,8 +1167,10 @@ const message = {
imageGeneration: 'Görsel Üretimi',
textModels: 'Metin Modelleri',
textToImageModels: 'Metinden Görsele Modeller',
embeddingModels: 'Embedding Modelleri',
unauthorized: 'Yetkisiz',
imageCount: 'Görsel Sayısı',
requestExample: 'İstek Örneği',
availableModels: 'Kullanılabilir Modeller',
modelGroupModels: 'İstek Modelleri',
modelGroupModelsPlaceholder: 'İstek modeli adlarını seçin veya girin',

View File

@@ -1102,8 +1102,10 @@ const message = {
imageGeneration: '圖片生成',
textModels: '文字模型',
textToImageModels: '文生圖模型',
embeddingModels: 'Embedding 模型',
unauthorized: '未授權',
imageCount: '圖片數量',
requestExample: '請求範例',
availableModels: '可用模型',
modelGroupModels: '請求模型',
modelGroupModelsPlaceholder: '請選擇或輸入請求模型名稱',

View File

@@ -1116,8 +1116,10 @@ const message = {
imageGeneration: '图片生成',
textModels: '文本模型',
textToImageModels: '文生图模型',
embeddingModels: 'Embedding 模型',
unauthorized: '未授权',
imageCount: '图片数量',
requestExample: '请求示例',
availableModels: '可用模型',
modelGroupModels: '请求模型',
modelGroupModelsPlaceholder: '请选择或输入请求模型名',

View File

@@ -299,6 +299,7 @@ const selectedAPIConfig = computed(() =>
selectedProvider.value?.apiTypes.find((item) => item.apiType === form.apiType),
);
const isImageAPIType = computed(() => form.apiType.endsWith('-images'));
const isEmbeddingAPIType = computed(() => form.apiType === 'openai-embeddings');
const supportsModelDiscovery = computed(() => !form.id && Boolean(selectedAPIConfig.value?.supportsModelDiscovery));
const defaultModels = computed(() => {
if (selectedAPIConfig.value?.models.length) {
@@ -314,7 +315,7 @@ const showInitialModel = computed(
!showModelDiscovery.value,
);
const requiresVerifyModel = computed(
() => form.validateAvailability && !isAgentAccountVerificationSkipped(form.provider),
() => form.validateAvailability && (isEmbeddingAPIType.value || !isAgentAccountVerificationSkipped(form.provider)),
);
const verifyModelOptions = computed(() => (form.id ? editModels.value : defaultModels.value));
const showVerifyModelSelect = computed(
@@ -361,6 +362,11 @@ const apiTypeURLHints: Record<string, { requestPath: string; example: string; en
example: 'https://openrouter.ai',
endpointSuffixes: ['/api/v1/images'],
},
'openai-embeddings': {
requestPath: '/v1/embeddings',
example: 'http://127.0.0.1:8000/v1',
endpointSuffixes: ['/v1/embeddings', '/embeddings'],
},
};
const showAPITypeBaseURLTips = computed(() => Boolean(selectedAPIConfig.value?.editableBaseUrl));
const apiTypeBaseURLHelper = computed(() => {