From ee5cf2b88a3b47a85915e66b12be8a15cd334306 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Wed, 6 May 2026 16:29:37 +0800 Subject: [PATCH 1/7] feat: improve model configuration workflows Add model catalog browsing, provider registry with form validation, model fetch/test dialogs, and enhanced model management UI. - Add model catalog API and catalog-dialog component for browsing saved models - Add provider-registry with auto-populated form fields per provider - Add provider-combobox, fetch-models-dialog, test-model-dialog components - Add model-validation for provider-aware model ID validation - Add command and popover UI components - Enhance edit-model-sheet with tool schema transform support - Add anthropic to protocolMetaByName for correct default API base - Apply NormalizeBaseURL to anthropic provider for consistent URL handling - Add i18n keys for new model management features (en/zh) --- pkg/config/config.go | 15 + pkg/providers/factory_provider.go | 8 +- web/backend/api/model_catalog.go | 161 ++++ web/backend/api/models.go | 214 +++++ web/frontend/package.json | 2 + web/frontend/pnpm-lock.yaml | 24 + web/frontend/src/api/models.ts | 80 +- .../src/components/models/add-model-sheet.tsx | 432 ++++++--- .../src/components/models/catalog-dialog.tsx | 322 +++++++ .../components/models/edit-model-sheet.tsx | 833 ++++++++++-------- .../components/models/fetch-models-dialog.tsx | 223 +++++ .../src/components/models/model-validation.ts | 115 +++ .../src/components/models/models-page.tsx | 38 +- .../components/models/provider-combobox.tsx | 192 ++++ .../src/components/models/provider-icon.tsx | 58 +- .../src/components/models/provider-label.ts | 141 +-- .../components/models/provider-registry.ts | 446 ++++++++++ .../components/models/provider-section.tsx | 2 +- .../components/models/test-model-dialog.tsx | 152 ++++ web/frontend/src/components/ui/command.tsx | 149 ++++ web/frontend/src/components/ui/popover.tsx | 29 + web/frontend/src/i18n/locales/en.json | 91 +- web/frontend/src/i18n/locales/zh.json | 77 +- 23 files changed, 3078 insertions(+), 726 deletions(-) create mode 100644 web/backend/api/model_catalog.go create mode 100644 web/frontend/src/components/models/catalog-dialog.tsx create mode 100644 web/frontend/src/components/models/fetch-models-dialog.tsx create mode 100644 web/frontend/src/components/models/model-validation.ts create mode 100644 web/frontend/src/components/models/provider-combobox.tsx create mode 100644 web/frontend/src/components/models/provider-registry.ts create mode 100644 web/frontend/src/components/models/test-model-dialog.tsx create mode 100644 web/frontend/src/components/ui/command.tsx create mode 100644 web/frontend/src/components/ui/popover.tsx diff --git a/pkg/config/config.go b/pkg/config/config.go index acceee4d5..13a2b2b5e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -603,6 +603,21 @@ func (c *ModelConfig) Validate() error { if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil { return err } + + // Reject whitespace in model identifier + if strings.ContainsAny(c.Model, " \t\n\r") { + return fmt.Errorf("model identifier contains whitespace") + } + + // Reject leading slash + if strings.HasPrefix(c.Model, "/") { + return fmt.Errorf("model identifier must not start with /") + } + + // Reject consecutive slashes + if strings.Contains(c.Model, "//") { + return fmt.Errorf("model identifier must not contain //") + } return nil } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index aa99d6d38..e9e0e6e98 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -15,6 +15,7 @@ import ( anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" "github.com/sipeed/picoclaw/pkg/providers/azure" "github.com/sipeed/picoclaw/pkg/providers/bedrock" + "github.com/sipeed/picoclaw/pkg/providers/common" ) type protocolMeta struct { @@ -60,6 +61,8 @@ var protocolMetaByName = map[string]protocolMeta{ "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"}, "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"}, "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"}, + "anthropic": {defaultAPIBase: "https://api.anthropic.com/v1"}, + "anthropic-messages": {defaultAPIBase: "https://api.anthropic.com/v1"}, } // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. @@ -318,10 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return finalizeProviderFromConfig(provider, modelID, cfg) } // Use API key with HTTP API - apiBase := cfg.APIBase - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } + apiBase := common.NormalizeBaseURL(cfg.APIBase, "https://api.anthropic.com/v1", true) if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } diff --git a/web/backend/api/model_catalog.go b/web/backend/api/model_catalog.go new file mode 100644 index 000000000..da092e89e --- /dev/null +++ b/web/backend/api/model_catalog.go @@ -0,0 +1,161 @@ +package api + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +// CatalogModel represents a single model entry in a saved catalog. +type CatalogModel struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +// CatalogEntry is a saved list of upstream models fetched for a specific provider+key combination. +type CatalogEntry struct { + ID string `json:"id"` + Provider string `json:"provider"` + APIBase string `json:"api_base"` + APIKeyMask string `json:"api_key_mask"` + Models []CatalogModel `json:"models"` + FetchedAt string `json:"fetched_at"` +} + +// CatalogStore holds all saved model catalogs. +type CatalogStore struct { + Entries map[string]*CatalogEntry `json:"entries"` +} + +func catalogFilePath() string { + return filepath.Join(config.GetHome(), "model_catalogs.json") +} + +// generateCatalogKey creates a deterministic key for a provider+base+key combination. +func generateCatalogKey(provider, apiBase, apiKey string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") + hash := sha256.Sum256([]byte(apiKey)) + return fmt.Sprintf("%s|%s|%x", provider, apiBase, hash[:6]) +} + +// maskAPIKeyValue masks an API key for display, keeping first 4 and last 4 chars. +func maskAPIKeyValue(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + if len(key) <= 8 { + return "****" + } + return key[:4] + "****" + key[len(key)-4:] +} + +func loadCatalogs() (*CatalogStore, error) { + path := catalogFilePath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &CatalogStore{Entries: make(map[string]*CatalogEntry)}, nil + } + return nil, err + } + var store CatalogStore + if err := json.Unmarshal(data, &store); err != nil { + return nil, err + } + if store.Entries == nil { + store.Entries = make(map[string]*CatalogEntry) + } + return &store, nil +} + +func saveCatalogs(store *CatalogStore) error { + path := catalogFilePath() + data, err := json.MarshalIndent(store, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(path, data, 0o600) +} + +// SaveCatalog persists a fetched model list for a given provider+key combination. +// If a catalog with the same key already exists, it is updated. +func SaveCatalog(provider, apiBase, apiKey string, models []CatalogModel) error { + store, err := loadCatalogs() + if err != nil { + return err + } + key := generateCatalogKey(provider, apiBase, apiKey) + store.Entries[key] = &CatalogEntry{ + ID: key, + Provider: strings.ToLower(strings.TrimSpace(provider)), + APIBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"), + APIKeyMask: maskAPIKeyValue(apiKey), + Models: models, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + } + return saveCatalogs(store) +} + +// handleListCatalogs returns all saved model catalogs. +// +// GET /api/models/catalog +func (h *Handler) handleListCatalogs(w http.ResponseWriter, r *http.Request) { + store, err := loadCatalogs() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load catalogs: %v", err), http.StatusInternalServerError) + return + } + + entries := make([]*CatalogEntry, 0, len(store.Entries)) + for _, e := range store.Entries { + entries = append(entries, e) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "entries": entries, + "total": len(entries), + }) +} + +// handleDeleteCatalog deletes a saved model catalog by ID. +// +// DELETE /api/models/catalog/{id} +func (h *Handler) handleDeleteCatalog(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "id is required", http.StatusBadRequest) + return + } + + store, err := loadCatalogs() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load catalogs: %v", err), http.StatusInternalServerError) + return + } + + if _, ok := store.Entries[id]; !ok { + http.Error(w, "catalog not found", http.StatusNotFound) + return + } + + delete(store.Entries, id) + if err := saveCatalogs(store); err != nil { + http.Error(w, fmt.Sprintf("Failed to save catalogs: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 8a66918f9..59f95a8e5 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "io" @@ -8,6 +9,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/config" @@ -22,6 +24,10 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel) mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel) mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel) + mux.HandleFunc("POST /api/models/{index}/test", h.handleTestModel) + mux.HandleFunc("POST /api/models/fetch", h.handleFetchModels) + mux.HandleFunc("GET /api/models/catalog", h.handleListCatalogs) + mux.HandleFunc("DELETE /api/models/catalog/{id}", h.handleDeleteCatalog) } // modelResponse is the JSON structure returned for each model in the list. @@ -614,3 +620,211 @@ func maskAPIKey(key string) string { // Show first 3 chars and last 4 chars return key[:3] + "****" + key[len(key)-4:] } + +// handleTestModel tests connectivity to a model endpoint. +// +// POST /api/models/{index}/test +func (h *Handler) handleTestModel(w http.ResponseWriter, r *http.Request) { + idx, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + http.Error(w, "Invalid index", http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + if idx < 0 || idx >= len(cfg.ModelList) { + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) + return + } + + m := cfg.ModelList[idx] + start := time.Now() + summary := modelConfigurationStatus(m) + latency := time.Since(start).Milliseconds() + + result := map[string]any{ + "success": summary.Available, + "latency_ms": latency, + "status": summary.Status, + } + + if !summary.Available { + if summary.Status == modelStatusUnconfigured { + result["error"] = "API key not configured" + } else { + result["error"] = "Endpoint unreachable" + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// handleFetchModels fetches available models from an upstream provider. +// +// POST /api/models/fetch +func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + Provider string `json:"provider"` + APIKey string `json:"api_key"` + APIBase string `json:"api_base"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if req.Provider == "" { + http.Error(w, "provider is required", http.StatusBadRequest) + return + } + + apiBase := strings.TrimSpace(req.APIBase) + if apiBase == "" { + apiBase = providers.DefaultAPIBaseForProtocol(req.Provider) + } + if apiBase == "" { + http.Error(w, fmt.Sprintf("No default API base for provider %q", req.Provider), http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + models, err := fetchUpstreamModels(ctx, req.Provider, apiBase, req.APIKey) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to fetch models: %v", err), http.StatusBadGateway) + return + } + + // Auto-save fetched models to catalog + catalogModels := make([]CatalogModel, len(models)) + for i, m := range models { + catalogModels[i] = CatalogModel{ID: m.ID, OwnedBy: m.OwnedBy} + } + if saveErr := SaveCatalog(req.Provider, apiBase, req.APIKey, catalogModels); saveErr != nil { + // Log but don't fail the request — saving catalog is non-critical + logger.Warnf("Failed to save model catalog: %v", saveErr) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "models": models, + "total": len(models), + }) +} + +type upstreamModel struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by,omitempty"` +} + +func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string) ([]upstreamModel, error) { + apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") + + var fetchURL string + switch strings.ToLower(provider) { + case "ollama": + // Strip /v1 suffix if present to get the Ollama root + root := apiBase + if strings.HasSuffix(root, "/v1") { + root = root[:len(root)-3] + } + root = strings.TrimRight(root, "/") + fetchURL = root + "/api/tags" + return fetchOllamaModels(ctx, fetchURL) + default: + // OpenAI-compatible: /v1/models + fetchURL = apiBase + "/models" + return fetchOpenAICompatibleModels(ctx, fetchURL, apiKey) + } +} + +func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) ([]upstreamModel, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) + if err != nil { + return nil, err + } + if apiKey = strings.TrimSpace(apiKey); apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode) + } + + var parsed struct { + Data []struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + + models := make([]upstreamModel, 0, len(parsed.Data)) + for _, m := range parsed.Data { + if m.ID != "" { + models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy}) + } + } + return models, nil +} + +func fetchOllamaModels(ctx context.Context, fetchURL string) ([]upstreamModel, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ollama returned status %d", resp.StatusCode) + } + + var parsed struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + + models := make([]upstreamModel, 0, len(parsed.Models)) + for _, m := range parsed.Models { + id := m.Name + if id == "" { + id = m.Model + } + if id != "" { + models = append(models, upstreamModel{ID: id}) + } + } + return models, nil +} diff --git a/web/frontend/package.json b/web/frontend/package.json index bf3e7921b..07d4f8e98 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", + "@radix-ui/react-popover": "^1.1.15", "@tabler/icons-react": "^3.40.0", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.99.0", @@ -25,6 +26,7 @@ "@tanstack/react-router-devtools": "^1.166.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "dayjs": "^1.11.20", "highlight.js": "^11.11.1", "i18next": "^26.0.8", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 78639de19..0adb2ca26 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tabler/icons-react': specifier: ^3.40.0 version: 3.41.1(react@19.2.5) @@ -32,6 +35,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) dayjs: specifier: ^1.11.20 version: 1.11.20 @@ -2033,6 +2039,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -5958,6 +5970,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + code-block-writer@13.0.3: {} color-convert@2.0.1: diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 5bb275fde..c9f70db00 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -23,6 +23,7 @@ export interface ModelInfo { extra_body?: Record custom_headers?: Record // Meta + enabled: boolean available: boolean status: "available" | "unconfigured" | "unreachable" is_default: boolean @@ -58,7 +59,13 @@ const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { - throw new Error(`API error: ${res.status} ${res.statusText}`) + let detail = "" + try { + detail = await res.text() + } catch { + // ignore + } + throw new Error(detail || `API error: ${res.status} ${res.statusText}`) } return res.json() as Promise } @@ -107,4 +114,75 @@ export async function setDefaultModel( return response } +export interface TestModelResponse { + success: boolean + latency_ms: number + status: string + error?: string +} + +export async function testModel(index: number): Promise { + return request(`/api/models/${index}/test`, { + method: "POST", + }) +} + +export interface UpstreamModel { + id: string + owned_by?: string +} + +export interface FetchModelsRequest { + provider: string + api_key?: string + api_base?: string +} + +export interface FetchModelsResponse { + models: UpstreamModel[] + total: number +} + +export async function fetchUpstreamModels( + req: FetchModelsRequest, +): Promise { + return request("/api/models/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }) +} + +// --- Model Catalog API --- + +export interface CatalogModel { + id: string + owned_by?: string + extra?: Record +} + +export interface CatalogEntry { + id: string + provider: string + api_base: string + api_key_mask: string + models: CatalogModel[] + fetched_at: string +} + +interface CatalogListResponse { + entries: CatalogEntry[] + total: number +} + +export async function getCatalogs(): Promise { + return request("/api/models/catalog") +} + +export async function deleteCatalog(id: string): Promise { + await request>(`/api/models/catalog/${encodeURIComponent(id)}`, { + method: "DELETE", + }) +} + export type { ModelsListResponse, ModelActionResponse } diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index e0f51596a..807fe32cd 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -1,12 +1,8 @@ -import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useMemo, useState } from "react" +import { IconDownload, IconLoader2 } from "@tabler/icons-react" +import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { - type ModelProviderOption, - addModel, - setDefaultModel, -} from "@/api/models" +import { addModel, getCatalogs, setDefaultModel } from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -15,15 +11,9 @@ import { KeyInput, SwitchCardField, } from "@/components/shared-form" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { Sheet, SheetContent, @@ -36,14 +26,14 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { FetchModelsDialog } from "./fetch-models-dialog" import { - findProviderOption, - getProviderDefaultAPIBase, - getProviderDefaultAuthMethod, - getProviderLabel, - getSortedProviderOptions, - isProviderAuthMethodLocked, -} from "./provider-label" + type FieldValidation, + validateModelField, +} from "./model-validation" +import { ProviderCombobox } from "./provider-combobox" +import { getProviderKey } from "./provider-label" +import { PROVIDER_MAP } from "./provider-registry" interface AddForm { modelName: string @@ -66,7 +56,7 @@ interface AddForm { const EMPTY_ADD_FORM: AddForm = { modelName: "", - provider: "openai", + provider: "", model: "", apiBase: "", apiKey: "", @@ -83,12 +73,41 @@ const EMPTY_ADD_FORM: AddForm = { customHeaders: "", } +function normalizeApiBase(value: string): string { + return value.trim().replace(/\/+$/, "") +} + +function getNextApiBaseForProviderChange( + currentApiBase: string, + currentProvider: string, + nextProvider: string, +): string { + const normalizedCurrentApiBase = normalizeApiBase(currentApiBase) + const currentDefaultApiBase = normalizeApiBase( + PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "", + ) + const nextDefaultApiBase = PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? "" + + if (!normalizedCurrentApiBase) { + return nextDefaultApiBase + } + + if ( + normalizedCurrentApiBase && + currentDefaultApiBase && + normalizedCurrentApiBase === currentDefaultApiBase + ) { + return nextDefaultApiBase + } + + return currentApiBase +} + interface AddModelSheetProps { open: boolean onClose: () => void onSaved: () => void existingModelNames: string[] - providerOptions: ModelProviderOption[] } export function AddModelSheet({ @@ -96,7 +115,6 @@ export function AddModelSheet({ onClose, onSaved, existingModelNames, - providerOptions, }: AddModelSheetProps) { const { t } = useTranslation() const [form, setForm] = useState(EMPTY_ADD_FORM) @@ -106,41 +124,15 @@ export function AddModelSheet({ Partial> >({}) const [serverError, setServerError] = useState("") + const [modelValidation, setModelValidation] = useState(null) + const [fetchOpen, setFetchOpen] = useState(false) + const [fetchedModels, setFetchedModels] = useState([]) + const [catalogModels, setCatalogModels] = useState([]) + const debounceRef = useRef>(undefined) const apiKeyPlaceholder = maskedSecretPlaceholder( form.apiKey, t("models.field.apiKeyPlaceholder"), ) - const sortedProviderOptions = useMemo( - () => getSortedProviderOptions(providerOptions), - [providerOptions], - ) - const creatableProviderOptions = useMemo( - () => sortedProviderOptions.filter((option) => option.create_allowed), - [sortedProviderOptions], - ) - const selectedProviderOption = findProviderOption( - form.provider, - providerOptions, - ) - const authMethodLocked = isProviderAuthMethodLocked( - form.provider, - providerOptions, - ) - const defaultAuthMethod = getProviderDefaultAuthMethod( - form.provider, - providerOptions, - ) - const effectiveAuthMethod = ( - authMethodLocked ? defaultAuthMethod : form.authMethod - ) - .trim() - .toLowerCase() - const isOAuth = effectiveAuthMethod === "oauth" - const defaultModelAllowed = - selectedProviderOption?.default_model_allowed !== false - const apiBasePlaceholder = - getProviderDefaultAPIBase(form.provider, providerOptions) || - "https://api.example.com/v1" const isDirty = JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault @@ -150,9 +142,37 @@ export function AddModelSheet({ setSetAsDefault(false) setFieldErrors({}) setServerError("") + setModelValidation(null) + setFetchedModels([]) + setCatalogModels([]) } }, [open]) + // Load catalog models when provider or apiBase changes + useEffect(() => { + const providerKey = getProviderKey(form.provider || undefined) + const apiBase = form.apiBase.trim().replace(/\/+$/, "") + if (!form.provider.trim()) { + setCatalogModels([]) + return + } + let cancelled = false + getCatalogs() + .then((res) => { + if (cancelled) return + const matched = (res.entries || []).filter((e) => { + const ep = getProviderKey(e.provider || undefined) + const eb = (e.api_base ?? "").trim().replace(/\/+$/, "") + return ep === providerKey && eb === apiBase + }) + const ids = matched.flatMap((e) => e.models.map((m) => m.id)) + const unique = [...new Set(ids)] + setCatalogModels(unique) + }) + .catch(() => {}) + return () => { cancelled = true } + }, [form.provider, form.apiBase]) + const validate = (): boolean => { const errors: Partial> = {} const modelName = form.modelName.trim() @@ -161,10 +181,10 @@ export function AddModelSheet({ } else if (existingModelNames.some((name) => name.trim() === modelName)) { errors.modelName = t("models.add.errorDuplicateModelName") } - if (!selectedProviderOption) { - errors.provider = t("models.field.providerInvalid") - } if (!form.model.trim()) errors.model = t("models.add.errorRequired") + if (modelValidation?.level === "error") { + errors.model = t(modelValidation.messageKey, modelValidation.messageParams) + } setFieldErrors(errors) return Object.keys(errors).length === 0 } @@ -178,47 +198,109 @@ export function AddModelSheet({ } } - const setProvider = (value: string) => { - setForm((f) => { - const previousOption = findProviderOption(f.provider, providerOptions) - const nextOption = findProviderOption(value, providerOptions) - let authMethod = f.authMethod - if (nextOption?.auth_method_locked) { - authMethod = nextOption.default_auth_method ?? "" - } else if ( - previousOption?.auth_method_locked && - f.authMethod === (previousOption.default_auth_method ?? "") - ) { - authMethod = "" - } - return { ...f, provider: value, authMethod } - }) - const nextOption = findProviderOption(value, providerOptions) - if (nextOption?.default_model_allowed === false) { - setSetAsDefault(false) + const debouncedValidateModel = useCallback( + (value: string, provider: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + const result = validateModelField(value, provider || undefined) + setModelValidation(result) + }, 300) + }, + [], + ) + + const handleModelChange = (e: React.ChangeEvent) => { + const value = e.target.value + setForm((f) => ({ ...f, model: value })) + if (fieldErrors.model) { + setFieldErrors((prev) => ({ ...prev, model: undefined })) } - if (fieldErrors.provider) { - setFieldErrors((prev) => ({ ...prev, provider: undefined })) + debouncedValidateModel(value, form.provider) + } + + const handleProviderChange = (provider: string) => { + setForm((f) => { + return { + ...f, + provider, + apiBase: getNextApiBaseForProviderChange( + f.apiBase, + f.provider, + provider, + ), + } + }) + // Re-validate model with new provider context + if (form.model) { + debouncedValidateModel(form.model, provider) } } + const applyFix = () => { + if (modelValidation?.fix) { + setForm((f) => ({ ...f, model: modelValidation.fix! })) + setModelValidation(null) + } + } + + const handleCommonModel = (modelId: string) => { + setForm((f) => ({ ...f, model: modelId })) + setModelValidation(null) + if (fieldErrors.model) { + setFieldErrors((prev) => ({ ...prev, model: undefined })) + } + } + + const handleFetchFill = (models: string[]) => { + setFetchedModels(models) + if (models.length >= 1) { + setForm((f) => ({ ...f, model: models[0] })) + setModelValidation(null) + if (fieldErrors.model) { + setFieldErrors((prev) => ({ ...prev, model: undefined })) + } + } + } + + const providerDef = PROVIDER_MAP.get(form.provider) + const commonModels = providerDef?.commonModels || [] + const handleSave = async () => { if (!validate()) return + + let extraBody: Record | undefined + let customHeaders: Record | undefined + try { + if (form.extraBody.trim()) { + extraBody = JSON.parse(form.extraBody.trim()) + } + } catch { + setServerError(t("models.field.extraBody") + ": " + t("models.field.invalidJson")) + return + } + try { + if (form.customHeaders.trim()) { + customHeaders = JSON.parse(form.customHeaders.trim()) + } + } catch { + setServerError(t("models.field.customHeaders") + ": " + t("models.field.invalidJson")) + return + } + setSaving(true) setServerError("") try { const modelName = form.modelName.trim() + const provider = form.provider.trim() const modelId = form.model.trim() await addModel({ model_name: modelName, - provider: form.provider.trim(), + provider: provider || undefined, model: modelId, api_base: form.apiBase.trim() || undefined, api_key: form.apiKey.trim() || undefined, proxy: form.proxy.trim() || undefined, - auth_method: authMethodLocked - ? defaultAuthMethod || undefined - : form.authMethod.trim() || undefined, + auth_method: form.authMethod.trim() || undefined, connect_mode: form.connectMode.trim() || undefined, workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -228,12 +310,8 @@ export function AddModelSheet({ : undefined, thinking_level: form.thinkingLevel.trim() || undefined, tool_schema_transform: form.toolSchemaTransform.trim() || undefined, - extra_body: form.extraBody.trim() - ? JSON.parse(form.extraBody.trim()) - : undefined, - custom_headers: form.customHeaders.trim() - ? JSON.parse(form.customHeaders.trim()) - : undefined, + extra_body: extraBody, + custom_headers: customHeaders, }) if (setAsDefault) { await setDefaultModel(modelName) @@ -255,6 +333,7 @@ export function AddModelSheet({ } return ( + <> !v && onClose()}> - + - {fieldErrors.model && ( + {modelValidation && modelValidation.messageKey && ( +
+ {t(modelValidation.messageKey, modelValidation.messageParams)} + {modelValidation.fix && ( + + )} +
+ )} + {fieldErrors.model && !modelValidation && (

{fieldErrors.model}

)} + {commonModels.length > 0 && ( +
+ {commonModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} + {catalogModels.length > 0 && ( +
+ {catalogModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} + {fetchedModels.length > 0 && ( +
+ {fetchedModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} +
+ + {!form.provider && ( + + {t("models.field.selectProviderFirst")} + + )} +
- {!isOAuth && ( - - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} - /> - - )} + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} + /> + - + @@ -378,17 +516,12 @@ export function AddModelSheet({ @@ -517,12 +650,25 @@ export function AddModelSheet({ -
+ + setFetchOpen(false)} + onFill={handleFetchFill} + provider={form.provider} + apiKey={form.apiKey} + apiBase={form.apiBase} + />
+ ) } diff --git a/web/frontend/src/components/models/catalog-dialog.tsx b/web/frontend/src/components/models/catalog-dialog.tsx new file mode 100644 index 000000000..6fabb0fad --- /dev/null +++ b/web/frontend/src/components/models/catalog-dialog.tsx @@ -0,0 +1,322 @@ +import { + IconChevronDown, + IconChevronRight, + IconLoader2, + IconTrash, +} from "@tabler/icons-react" +import { useCallback, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + type CatalogEntry, + type CatalogModel, + addModel, + deleteCatalog, + getCatalogs, +} from "@/api/models" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { refreshGatewayState } from "@/store/gateway" + +import { getProviderLabel } from "./provider-label" + +interface CatalogDialogProps { + open: boolean + onClose: () => void + onModelAdded: () => void +} + +export function CatalogDialog({ + open, + onClose, + onModelAdded, +}: CatalogDialogProps) { + const { t } = useTranslation() + const [loading, setLoading] = useState(false) + const [entries, setEntries] = useState([]) + const [expandedId, setExpandedId] = useState(null) + const [selected, setSelected] = useState>>(new Map()) + const [adding, setAdding] = useState(false) + const [filter, setFilter] = useState("") + + const loadCatalogs = useCallback(async () => { + setLoading(true) + try { + const res = await getCatalogs() + setEntries(res.entries || []) + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to load catalogs") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (open) { + loadCatalogs() + setExpandedId(null) + setSelected(new Map()) + setFilter("") + } + }, [open, loadCatalogs]) + + const toggleExpand = (id: string) => { + setExpandedId((prev) => (prev === id ? null : id)) + } + + const toggleModel = (catalogId: string, modelId: string) => { + setSelected((prev) => { + const next = new Map(prev) + const set = new Set(next.get(catalogId) || []) + if (set.has(modelId)) set.delete(modelId) + else set.add(modelId) + next.set(catalogId, set) + return next + }) + } + + const toggleAll = (catalogId: string, models: CatalogModel[]) => { + setSelected((prev) => { + const next = new Map(prev) + const current = next.get(catalogId) || new Set() + const filtered = filter + ? models.filter((m) => m.id.toLowerCase().includes(filter.toLowerCase())) + : models + if (filtered.every((m) => current.has(m.id))) { + next.set(catalogId, new Set()) + } else { + next.set(catalogId, new Set(filtered.map((m) => m.id))) + } + return next + }) + } + + const handleDelete = async (id: string) => { + try { + await deleteCatalog(id) + setEntries((prev) => prev.filter((e) => e.id !== id)) + setSelected((prev) => { + const next = new Map(prev) + next.delete(id) + return next + }) + if (expandedId === id) setExpandedId(null) + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to delete catalog") + } + } + + const handleAddSelected = async (entry: CatalogEntry) => { + const catalogSelected = selected.get(entry.id) || new Set() + if (catalogSelected.size === 0) return + + setAdding(true) + try { + const modelsToAdd = entry.models.filter((m) => catalogSelected.has(m.id)) + for (const model of modelsToAdd) { + await addModel({ + model_name: model.id, + provider: entry.provider || undefined, + model: model.id, + api_base: entry.api_base || undefined, + }) + } + await refreshGatewayState({ force: true }) + toast.success( + t("models.catalog.addSuccess", { count: modelsToAdd.length }), + ) + onModelAdded() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to add models") + } finally { + setAdding(false) + } + } + + const getFilteredModels = (models: CatalogModel[]) => + filter + ? models.filter((m) => m.id.toLowerCase().includes(filter.toLowerCase())) + : models + + return ( + !v && onClose()}> + + + {t("models.catalog.title")} + + {t("models.catalog.description")} + + + +
+ {loading && ( +
+ + {t("models.catalog.loading")} +
+ )} + + {!loading && entries.length === 0 && ( +
+ {t("models.catalog.empty")} +
+ )} + + {entries.length > 0 && ( + setFilter(e.target.value)} + className="h-8" + /> + )} + +
+ {entries.map((entry) => { + const isExpanded = expandedId === entry.id + const entrySelected = selected.get(entry.id) || new Set() + const filteredModels = getFilteredModels(entry.models) + + return ( +
+
toggleExpand(entry.id)} + > + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {getProviderLabel(entry.provider)} + + + {entry.api_key_mask} + +
+
+ + {entry.models.length} {t("models.catalog.models")} + + {entry.api_base && ( + <> + | + {entry.api_base} + + )} + {entry.fetched_at && ( + <> + | + + {t("models.catalog.fetchedAt")}{" "} + {new Date(entry.fetched_at).toLocaleDateString()} + + + )} +
+
+
+ +
+
+ + {isExpanded && ( +
+
+ + {t("models.catalog.found", { + count: filteredModels.length, + })} + + +
+
+ {filteredModels.map((m) => ( + + ))} +
+ {entrySelected.size > 0 && ( +
+ +
+ )} +
+ )} +
+ ) + })} +
+
+ + + + +
+
+ ) +} diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 82d3cf97f..95c574758 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -1,13 +1,12 @@ -import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useMemo, useState } from "react" +import { + IconDownload, + IconLoader2, + IconPlugConnected, +} from "@tabler/icons-react" +import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { - type ModelInfo, - type ModelProviderOption, - setDefaultModel, - updateModel, -} from "@/api/models" +import { type ModelInfo, getCatalogs, setDefaultModel, updateModel } from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -16,15 +15,9 @@ import { KeyInput, SwitchCardField, } from "@/components/shared-form" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { Sheet, SheetContent, @@ -37,14 +30,16 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { FetchModelsDialog } from "./fetch-models-dialog" import { - findProviderOption, - getProviderDefaultAPIBase, - getProviderDefaultAuthMethod, - getProviderLabel, - getSortedProviderOptions, - isProviderAuthMethodLocked, -} from "./provider-label" + type FieldValidation, + validateModelField, +} from "./model-validation" +import { ProviderCombobox } from "./provider-combobox" +import { getProviderKey } from "./provider-label" +import { PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry" + +import { TestModelDialog } from "./test-model-dialog" interface EditForm { provider: string @@ -66,12 +61,41 @@ interface EditForm { interface EditModelSheetProps { model: ModelInfo | null - providerOptions: ModelProviderOption[] open: boolean onClose: () => void onSaved: () => void } +function normalizeApiBase(value: string): string { + return value.trim().replace(/\/+$/, "") +} + +function getNextApiBaseForProviderChange( + currentApiBase: string, + currentProvider: string, + nextProvider: string, +): string { + const normalizedCurrentApiBase = normalizeApiBase(currentApiBase) + const currentDefaultApiBase = normalizeApiBase( + PROVIDER_API_BASES[currentProvider] || "", + ) + const nextDefaultApiBase = PROVIDER_API_BASES[nextProvider] || "" + + if (!normalizedCurrentApiBase) { + return nextDefaultApiBase + } + + if ( + normalizedCurrentApiBase && + currentDefaultApiBase && + normalizedCurrentApiBase === currentDefaultApiBase + ) { + return nextDefaultApiBase + } + + return currentApiBase +} + function buildInitialEditForm(model: ModelInfo): EditForm { return { provider: model.provider ?? "", @@ -84,7 +108,9 @@ function buildInitialEditForm(model: ModelInfo): EditForm { workspace: model.workspace ?? "", rpm: model.rpm ? String(model.rpm) : "", maxTokensField: model.max_tokens_field ?? "", - requestTimeout: model.request_timeout ? String(model.request_timeout) : "", + requestTimeout: model.request_timeout + ? String(model.request_timeout) + : "", thinkingLevel: model.thinking_level ?? "", toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA extraBody: model.extra_body @@ -98,7 +124,6 @@ function buildInitialEditForm(model: ModelInfo): EditForm { export function EditModelSheet({ model, - providerOptions, open, onClose, onSaved, @@ -124,43 +149,13 @@ export function EditModelSheet({ const [saving, setSaving] = useState(false) const [setAsDefault, setSetAsDefault] = useState(false) const [error, setError] = useState("") + const [modelValidation, setModelValidation] = useState(null) + const [testOpen, setTestOpen] = useState(false) + const [fetchOpen, setFetchOpen] = useState(false) + const [fetchedModels, setFetchedModels] = useState([]) + const [catalogModels, setCatalogModels] = useState([]) + const debounceRef = useRef>(undefined) const initialForm = model ? buildInitialEditForm(model) : null - const sortedProviderOptions = useMemo( - () => getSortedProviderOptions(providerOptions), - [providerOptions], - ) - const currentProviderID = model - ? (findProviderOption(model.provider, providerOptions)?.id ?? - model.provider?.trim().toLowerCase() ?? - "") - : "" - const selectedProviderOption = findProviderOption( - form.provider, - providerOptions, - ) - const authMethodLocked = isProviderAuthMethodLocked( - form.provider, - providerOptions, - ) - const defaultAuthMethod = getProviderDefaultAuthMethod( - form.provider, - providerOptions, - ) - const effectiveAuthMethod = ( - authMethodLocked ? defaultAuthMethod : form.authMethod - ) - .trim() - .toLowerCase() - const providerError = selectedProviderOption - ? "" - : t("models.field.providerInvalid") - const defaultModelAllowed = - selectedProviderOption?.default_model_allowed !== false - const willClearDefaultOnSave = - model?.is_default === true && defaultModelAllowed === false - const apiBasePlaceholder = - getProviderDefaultAPIBase(form.provider, providerOptions) || - "https://api.example.com/v1" const isDirty = model != null && (JSON.stringify(form) !== JSON.stringify(initialForm) || @@ -168,73 +163,126 @@ export function EditModelSheet({ useEffect(() => { if (model) { - const initialForm = buildInitialEditForm(model) - const option = findProviderOption(initialForm.provider, providerOptions) - if (option?.auth_method_locked && !initialForm.authMethod) { - initialForm.authMethod = option.default_auth_method ?? "" - } - setForm(initialForm) - setSetAsDefault(model.is_default && model.default_model_allowed !== false) + setForm(buildInitialEditForm(model)) + setSetAsDefault(model.is_default) setError("") + setModelValidation(null) + setFetchedModels([]) + setCatalogModels([]) + // Load matching catalog models + const providerKey = getProviderKey(model.provider || undefined) + const apiBase = (model.api_base ?? "").trim().replace(/\/+$/, "") + getCatalogs() + .then((res) => { + const matched = (res.entries || []).filter((e) => { + const ep = getProviderKey(e.provider || undefined) + const eb = (e.api_base ?? "").trim().replace(/\/+$/, "") + return ep === providerKey && eb === apiBase + }) + const ids = matched.flatMap((e) => e.models.map((m) => m.id)) + const unique = [...new Set(ids)] + if (unique.length > 0) setCatalogModels(unique) + }) + .catch(() => {}) } - }, [model, providerOptions]) + }, [model]) const setField = (key: keyof EditForm) => - (e: React.ChangeEvent) => { - if (error) { - setError("") - } + (e: React.ChangeEvent) => setForm((f) => ({ ...f, [key]: e.target.value })) - } - const setProvider = (value: string) => { - if (error) { - setError("") - } - setForm((f) => { - const previousOption = findProviderOption(f.provider, providerOptions) - const nextOption = findProviderOption(value, providerOptions) - let authMethod = f.authMethod - if (nextOption?.auth_method_locked) { - authMethod = nextOption.default_auth_method ?? "" - } else if ( - previousOption?.auth_method_locked && - f.authMethod === (previousOption.default_auth_method ?? "") - ) { - authMethod = "" - } - return { ...f, provider: value, authMethod } - }) - const nextOption = findProviderOption(value, providerOptions) - if (nextOption?.default_model_allowed === false) { - setSetAsDefault(false) + const debouncedValidateModel = useCallback( + (value: string, provider: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + const result = validateModelField(value, provider || undefined) + setModelValidation(result) + }, 300) + }, + [], + ) + + const handleModelChange = (e: React.ChangeEvent) => { + const value = e.target.value + setForm((f) => ({ ...f, modelId: value })) + debouncedValidateModel(value, form.provider) + } + + const handleProviderChange = (provider: string) => { + setForm((f) => ({ + ...f, + provider, + apiBase: getNextApiBaseForProviderChange(f.apiBase, f.provider, provider), + })) + if (form.modelId) { + debouncedValidateModel(form.modelId, provider) } } + const applyFix = () => { + if (modelValidation?.fix) { + setForm((f) => ({ ...f, modelId: modelValidation.fix! })) + setModelValidation(null) + } + } + + const handleCommonModel = (modelId: string) => { + setForm((f) => ({ ...f, modelId })) + setModelValidation(null) + } + + const handleFetchFill = (models: string[]) => { + setFetchedModels(models) + if (models.length >= 1) { + setForm((f) => ({ ...f, modelId: models[0] })) + setModelValidation(null) + } + } + + const providerDef = PROVIDER_MAP.get(form.provider) + const commonModels = providerDef?.commonModels || [] + const handleSave = async () => { if (!model) return - if (!selectedProviderOption) { - setError(providerError) - return - } if (!form.modelId.trim()) { setError(t("models.add.errorRequired")) return } + if (modelValidation?.level === "error") return + + let extraBody: Record | undefined + let customHeaders: Record | undefined + try { + if (form.extraBody.trim()) { + extraBody = JSON.parse(form.extraBody.trim()) + } + } catch { + setError(t("models.field.extraBody") + ": " + t("models.field.invalidJson")) + return + } + try { + if (form.customHeaders.trim()) { + customHeaders = JSON.parse(form.customHeaders.trim()) + } + } catch { + setError(t("models.field.customHeaders") + ": " + t("models.field.invalidJson")) + return + } + setSaving(true) setError("") try { + const modelId = form.modelId.trim() + const provider = form.provider.trim() await updateModel(model.index, { model_name: model.model_name, - provider: form.provider.trim(), - model: form.modelId.trim(), + provider: provider, + model: modelId, api_base: form.apiBase || undefined, api_key: form.apiKey || undefined, proxy: form.proxy || undefined, - auth_method: authMethodLocked - ? defaultAuthMethod || undefined - : form.authMethod || undefined, + auth_method: form.authMethod || undefined, connect_mode: form.connectMode || undefined, workspace: form.workspace || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -244,12 +292,8 @@ export function EditModelSheet({ : undefined, thinking_level: form.thinkingLevel || undefined, tool_schema_transform: form.toolSchemaTransform.trim() || undefined, - extra_body: form.extraBody.trim() - ? JSON.parse(form.extraBody.trim()) - : {}, - custom_headers: form.customHeaders.trim() - ? JSON.parse(form.customHeaders.trim()) - : {}, + extra_body: extraBody, + custom_headers: customHeaders, }) if (setAsDefault && !model.is_default) { await setDefaultModel(model.model_name) @@ -270,7 +314,7 @@ export function EditModelSheet({ } } - const isOAuth = effectiveAuthMethod === "oauth" + const isOAuth = model?.auth_method === "oauth" const hasSavedAPIKey = Boolean(model?.api_key) const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( @@ -280,267 +324,350 @@ export function EditModelSheet({ : t("models.field.apiKeyPlaceholder") return ( - !v && onClose()}> - - - - {t("models.edit.title", { name: model?.model_name })} - - - {model?.model} - - + <> + !v && onClose()}> + + + + {t("models.edit.title", { name: model?.model_name })} + + + {model?.model} + + -
-
- - + {modelValidation && modelValidation.messageKey && ( +
+ {t(modelValidation.messageKey, modelValidation.messageParams)} + {modelValidation.fix && ( + + )} +
+ )} + {commonModels.length > 0 && ( +
+ {commonModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} + {catalogModels.length > 0 && ( +
+ {catalogModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} + {fetchedModels.length > 0 && ( +
+ {fetchedModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} +
+ +
+
+ + {!isOAuth && ( + - setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} /> - - - {sortedProviderOptions.map((option) => ( - - {getProviderLabel(option.id)} - - ))} - - - + + )} - - + + + +
+ +
+ + - - {!isOAuth && ( - - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} - /> - - )} + + + + - - - + + + - + + + - - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + +