From 4993cf4a3687a6a8165d690515d9e9932a46c0e8 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Sat, 9 May 2026 14:40:51 +0800 Subject: [PATCH] feat(web,api): add provider selection, model form foundation, and validation Split from PR #2752 (part 1 of 3). Backend: - CRUD model endpoints (list/add/update/delete/set-default) - Provider metadata with default API bases and model provider options - Model ID validation and normalization - Anthropic default API base normalization Frontend: - Provider registry with metadata, labels, icons, and aliases - Provider combobox with backend option merging - Model field validation with provider-aware checks - Redesigned add/edit model sheets with provider selection - Dynamic imports for fetch/catalog/test dialogs (coming in PR2/PR3) - i18n support for model configuration UI --- web/backend/api/model_catalog.go | 161 --------- web/backend/api/model_status.go | 7 +- web/backend/api/models.go | 340 ------------------ .../src/components/models/add-model-sheet.tsx | 81 +++-- .../src/components/models/catalog-dialog.tsx | 333 +---------------- .../components/models/edit-model-sheet.tsx | 82 +++-- .../components/models/fetch-models-dialog.tsx | 226 +----------- .../src/components/models/models-page.tsx | 24 +- .../components/models/provider-combobox.tsx | 9 +- .../components/models/test-model-dialog.tsx | 197 +--------- web/frontend/src/i18n/locales/en.json | 3 +- web/frontend/src/i18n/locales/pt-br.json | 3 +- 12 files changed, 154 insertions(+), 1312 deletions(-) delete mode 100644 web/backend/api/model_catalog.go diff --git a/web/backend/api/model_catalog.go b/web/backend/api/model_catalog.go deleted file mode 100644 index da092e89e..000000000 --- a/web/backend/api/model_catalog.go +++ /dev/null @@ -1,161 +0,0 @@ -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/model_status.go b/web/backend/api/model_status.go index 302231d80..6cfda501d 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -434,8 +434,11 @@ func modelProbeAPIBase(m *config.ModelConfig) string { } protocol := modelProtocol(m) - if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { - return providers.DefaultAPIBaseForProtocol(protocol) + + // Resolve the default API base for any known protocol so that probes + // work even when the config stores only a provider without an explicit api_base. + if defaultBase := providers.DefaultAPIBaseForProtocol(protocol); defaultBase != "" { + return normalizeModelProbeAPIBase(defaultBase) } switch protocol { diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 95ec47a6b..8a66918f9 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "fmt" "io" @@ -9,7 +8,6 @@ import ( "strconv" "strings" "sync" - "time" "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/config" @@ -17,18 +15,6 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) -// fetchableProviders lists providers that support OpenAI-compatible /models listing. -var fetchableProviders = map[string]bool{ - "openai": true, "deepseek": true, "openrouter": true, - "qwen-portal": true, "qwen-intl": true, "moonshot": true, - "volcengine": true, "zhipu": true, "groq": true, - "mistral": true, "nvidia": true, "cerebras": true, - "venice": true, "shengsuanyun": true, "vivgrid": true, - "minimax": true, "longcat": true, "modelscope": true, - "mimo": true, "avian": true, "zai": true, "novita": true, - "litellm": true, "vllm": true, "lmstudio": true, "ollama": true, -} - // registerModelRoutes binds model list management endpoints to the ServeMux. func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/models", h.handleListModels) @@ -36,11 +22,6 @@ 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/test-inline", h.handleTestInlineModel) - 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. @@ -633,324 +614,3 @@ 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) -} - -// handleTestInlineModel tests connectivity using inline (unsaved) parameters. -// Unlike handleTestModel which only checks saved config, this endpoint performs -// a real network probe (e.g. GET /models) to verify the endpoint is reachable. -// -// POST /api/models/test-inline -func (h *Handler) handleTestInlineModel(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 - } - - var req struct { - Provider string `json:"provider"` - Model string `json:"model"` - APIBase string `json:"api_base"` - APIKey string `json:"api_key"` - AuthMethod string `json:"auth_method"` - ModelIndex *int `json:"model_index"` - } - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) - return - } - - m := &config.ModelConfig{ - Provider: strings.TrimSpace(req.Provider), - Model: strings.TrimSpace(req.Model), - APIBase: strings.TrimSpace(req.APIBase), - AuthMethod: strings.TrimSpace(req.AuthMethod), - } - if req.APIKey != "" { - m.SetAPIKey(req.APIKey) - } - - // When api_key is empty and model_index is provided, fall back to stored credentials. - // This lets the edit form test unsaved field changes while using the saved key. - if req.APIKey == "" && req.ModelIndex != nil { - cfg, err := config.LoadConfig(h.configPath) - if err == nil && *req.ModelIndex >= 0 && *req.ModelIndex < len(cfg.ModelList) { - stored := cfg.ModelList[*req.ModelIndex] - if stored.APIKey() != "" { - m.SetAPIKey(stored.APIKey()) - } - if m.APIBase == "" && stored.APIBase != "" { - m.APIBase = stored.APIBase - } - } - } - - // Check if configuration exists - if !hasModelConfiguration(m) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "latency_ms": 0, - "status": modelStatusUnconfigured, - "error": "API key not configured", - }) - return - } - - // Perform a real network probe - start := time.Now() - available := probeModelConnectivity(m) - latency := time.Since(start).Milliseconds() - - result := map[string]any{ - "success": available, - "latency_ms": latency, - } - if available { - result["status"] = modelStatusAvailable - } else { - result["status"] = modelStatusUnreachable - result["error"] = "Endpoint unreachable" - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -// probeModelConnectivity performs a real network probe to verify model endpoint reachability. -func probeModelConnectivity(m *config.ModelConfig) bool { - apiBase := modelProbeAPIBase(m) - protocol, modelID := splitModel(m) - - switch protocol { - case "ollama": - return probeOllamaModel(apiBase, modelID) - case "vllm", "lmstudio": - return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) - case "github-copilot", "copilot": - return probeTCPService(apiBase) - case "claude-cli", "claudecli": - return probeCommandAvailable("claude") - case "codex-cli", "codexcli": - return probeCommandAvailable("codex") - default: - // For remote providers (OpenAI, Anthropic, Gemini, DeepSeek, etc.), - // make a real GET /models request to verify connectivity and credentials. - if apiBase != "" { - return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) - } - return false - } -} - -// 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 - } - - if !fetchableProviders[strings.ToLower(req.Provider)] { - http.Error(w, fmt.Sprintf("provider %q does not support model listing", req.Provider), 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/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 1deaf9a06..06626441d 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -3,7 +3,7 @@ import { IconLoader2, IconPlugConnected, } from "@tabler/icons-react" -import { useCallback, useEffect, useRef, useState } from "react" +import { type ComponentType, useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { @@ -35,12 +35,10 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" -import { FetchModelsDialog } from "./fetch-models-dialog" import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry" -import { TestModelDialog } from "./test-model-dialog" interface AddForm { modelName: string @@ -142,6 +140,21 @@ export function AddModelSheet({ const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) + + // Dynamic imports for dialogs added in later PRs + const [FetchModelsDialogComp, setFetchModelsDialogComp] = useState void; onFill: (models: string[]) => void; + provider: string; apiKey: string; apiBase: string; + }> | null>(null) + const [TestModelDialogComp, setTestModelDialogComp] = useState void; + inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string }; + }> | null>(null) + useEffect(() => { + import("./fetch-models-dialog").then((m) => setFetchModelsDialogComp(() => m.FetchModelsDialog)).catch(() => {}) + import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) + }, []) + const apiKeyPlaceholder = maskedSecretPlaceholder( form.apiKey, t("models.field.apiKeyPlaceholder"), @@ -252,6 +265,11 @@ export function AddModelSheet({ if (form.model) { debouncedValidateModel(form.model, provider) } + // Clear setAsDefault if the new provider doesn't support being default + const allowed = providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? false + if (!allowed) { + setSetAsDefault(false) + } } const applyFix = () => { @@ -282,6 +300,9 @@ export function AddModelSheet({ const providerDef = PROVIDER_MAP.get(form.provider) const commonModels = providerDef?.commonModels || [] + const defaultModelAllowed = form.provider + ? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false) + : false const handleSave = async () => { if (!validate()) return @@ -505,6 +526,7 @@ export function AddModelSheet({ size="sm" className="h-7 text-xs" onClick={() => setFetchOpen(true)} + disabled={!FetchModelsDialogComp} > {t("models.fetch.title")} @@ -539,7 +561,7 @@ export function AddModelSheet({ variant="outline" size="sm" onClick={() => setTestOpen(true)} - disabled={!form.provider || !form.model} + disabled={!form.provider || !form.model || !TestModelDialogComp} > {t("models.test.testConnection")} @@ -548,9 +570,14 @@ export function AddModelSheet({ @@ -713,27 +740,31 @@ export function AddModelSheet({ - setFetchOpen(false)} - onFill={handleFetchFill} - provider={form.provider} - apiKey={form.apiKey} - apiBase={form.apiBase} - /> + {FetchModelsDialogComp && ( + setFetchOpen(false)} + onFill={handleFetchFill} + provider={form.provider} + apiKey={form.apiKey} + apiBase={form.apiBase} + /> + )} - setTestOpen(false)} - inlineParams={{ - provider: form.provider, - model: form.model, - apiBase: form.apiBase, - apiKey: form.apiKey, - authMethod: form.authMethod, - }} - /> + {TestModelDialogComp && ( + setTestOpen(false)} + inlineParams={{ + provider: form.provider, + model: form.model, + apiBase: form.apiBase, + apiKey: form.apiKey, + authMethod: form.authMethod, + }} + /> + )} ) diff --git a/web/frontend/src/components/models/catalog-dialog.tsx b/web/frontend/src/components/models/catalog-dialog.tsx index 9fe7283c4..49f79d56b 100644 --- a/web/frontend/src/components/models/catalog-dialog.tsx +++ b/web/frontend/src/components/models/catalog-dialog.tsx @@ -1,331 +1,4 @@ -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" -import { PROVIDER_MAP } from "./provider-registry" - -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 && ( -
- {PROVIDER_MAP.get(entry.provider)?.requiresApiKey !== - false && ( -
- {t("models.catalog.needApiKey")} -
- )} -
- -
-
- )} -
- )} -
- ) - })} -
-
- - - - -
-
- ) +// Placeholder: full implementation added in PR2 (Fetch Models & Saved Catalogs) +export function CatalogDialog() { + return null } diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index b05d61a65..0581fe057 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -3,7 +3,7 @@ import { IconLoader2, IconPlugConnected, } from "@tabler/icons-react" -import { useCallback, useEffect, useRef, useState } from "react" +import { type ComponentType, useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { @@ -36,12 +36,10 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" -import { FetchModelsDialog } from "./fetch-models-dialog" import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" import { FETCHABLE_PROVIDER_KEYS, PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry" -import { TestModelDialog } from "./test-model-dialog" interface EditForm { provider: string @@ -159,6 +157,21 @@ export function EditModelSheet({ const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) + + // Dynamic imports for dialogs added in later PRs + const [FetchModelsDialogComp, setFetchModelsDialogComp] = useState void; onFill: (models: string[]) => void; + provider: string; apiKey: string; apiBase: string; + }> | null>(null) + const [TestModelDialogComp, setTestModelDialogComp] = useState void; + inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string; modelIndex?: number }; + }> | null>(null) + useEffect(() => { + import("./fetch-models-dialog").then((m) => setFetchModelsDialogComp(() => m.FetchModelsDialog)).catch(() => {}) + import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) + }, []) + const initialForm = model ? buildInitialEditForm(model) : null const isDirty = model != null && @@ -222,6 +235,10 @@ export function EditModelSheet({ if (form.modelId) { debouncedValidateModel(form.modelId, provider) } + const allowed = providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? false + if (!allowed) { + setSetAsDefault(false) + } } const applyFix = () => { @@ -246,6 +263,9 @@ export function EditModelSheet({ const providerDef = PROVIDER_MAP.get(form.provider) const commonModels = providerDef?.commonModels || [] + const defaultModelAllowed = form.provider + ? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false) + : false const handleSave = async () => { if (!model) return @@ -457,6 +477,7 @@ export function EditModelSheet({ size="sm" className="h-7 text-xs" onClick={() => setFetchOpen(true)} + disabled={!FetchModelsDialogComp} > {t("models.fetch.title")} @@ -497,7 +518,7 @@ export function EditModelSheet({ variant="outline" size="sm" onClick={() => setTestOpen(true)} - disabled={!model} + disabled={!model || !TestModelDialogComp} > {t("models.test.testConnection")} @@ -506,9 +527,14 @@ export function EditModelSheet({ @@ -672,28 +698,32 @@ export function EditModelSheet({ - setTestOpen(false)} - inlineParams={{ - provider: form.provider, - model: form.modelId, - apiBase: form.apiBase, - apiKey: form.apiKey, - authMethod: form.authMethod, - modelIndex: model?.index, - }} - /> + {TestModelDialogComp && ( + setTestOpen(false)} + inlineParams={{ + provider: form.provider, + model: form.modelId, + apiBase: form.apiBase, + apiKey: form.apiKey, + authMethod: form.authMethod, + modelIndex: model?.index, + }} + /> + )} - setFetchOpen(false)} - onFill={handleFetchFill} - provider={form.provider} - apiKey={form.apiKey} - apiBase={form.apiBase} - /> + {FetchModelsDialogComp && ( + setFetchOpen(false)} + onFill={handleFetchFill} + provider={form.provider} + apiKey={form.apiKey} + apiBase={form.apiBase} + /> + )} ) } diff --git a/web/frontend/src/components/models/fetch-models-dialog.tsx b/web/frontend/src/components/models/fetch-models-dialog.tsx index 09b602e6d..8f7192e4a 100644 --- a/web/frontend/src/components/models/fetch-models-dialog.tsx +++ b/web/frontend/src/components/models/fetch-models-dialog.tsx @@ -1,224 +1,4 @@ -import { IconDownload, IconLoader2 } from "@tabler/icons-react" -import { useCallback, useEffect, useState } from "react" -import { useTranslation } from "react-i18next" - -import { type UpstreamModel, fetchUpstreamModels } 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 { PROVIDER_MAP } from "./provider-registry" - -interface FetchModelsDialogProps { - open: boolean - onClose: () => void - onFill: (models: string[]) => void - provider: string - apiKey: string - apiBase: string -} - -export function FetchModelsDialog({ - open, - onClose, - onFill, - provider, - apiKey, - apiBase, -}: FetchModelsDialogProps) { - const { t } = useTranslation() - const [fetching, setFetching] = useState(false) - const [models, setModels] = useState([]) - const [selected, setSelected] = useState>(new Set()) - const [error, setError] = useState("") - const [filter, setFilter] = useState("") - - const providerDef = PROVIDER_MAP.get(provider) - const needsKey = providerDef?.requiresApiKey !== false - - const handleFetch = useCallback(async () => { - setFetching(true) - setError("") - setModels([]) - setSelected(new Set()) - try { - const res = await fetchUpstreamModels({ - provider, - api_key: apiKey, - api_base: apiBase, - }) - setModels(res.models) - // Auto-select all by default - setSelected(new Set(res.models.map((m) => m.id))) - } catch (e) { - setError(e instanceof Error ? e.message : t("models.fetch.failed")) - } finally { - setFetching(false) - } - }, [provider, apiKey, apiBase, t]) - - // Auto-fetch when dialog opens (skip if provider requires API key but none is set) - useEffect(() => { - if (open && provider && !(needsKey && !apiKey)) { - handleFetch() - } - }, [open, provider, apiKey, needsKey, handleFetch]) - - const handleFill = () => { - onFill(Array.from(selected)) - handleClose() - } - - const handleClose = () => { - setModels([]) - setSelected(new Set()) - setError("") - setFilter("") - onClose() - } - - const toggleModel = (id: string) => { - setSelected((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } - - const toggleAll = () => { - const filtered = models - .map((m) => m.id) - .filter( - (id) => !filter || id.toLowerCase().includes(filter.toLowerCase()), - ) - if (filtered.every((id) => selected.has(id))) { - setSelected(new Set()) - } else { - setSelected(new Set(filtered)) - } - } - - const filteredModels = filter - ? models.filter((m) => m.id.toLowerCase().includes(filter.toLowerCase())) - : models - - return ( - !v && handleClose()}> - - - - - {t("models.fetch.title")} - - - {t("models.fetch.description")} - {provider && ( - - {t("models.fetch.providerLabel")} {provider} - {apiBase && ` | ${apiBase}`} - - )} - - - -
- {needsKey && !apiKey && ( -
- {t("models.fetch.needApiKey")} -
- )} - - {fetching && ( -
- - {t("models.fetch.fetching")} -
- )} - - {error && ( -
-
- {error} -
- -
- )} - - {models.length > 0 && ( - <> - setFilter(e.target.value)} - className="h-8" - /> -
- - {t("models.fetch.found", { count: models.length })} - {filter && - ` ${t("models.fetch.shown", { count: filteredModels.length })}`} - - -
-
- {filteredModels.map((m) => ( - - ))} -
- - )} -
- - - - {models.length > 0 && ( - - )} - -
-
- ) +// Placeholder: full implementation added in PR2 (Fetch Models & Saved Catalogs) +export function FetchModelsDialog() { + return null } diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 9c0c400db..238a51a5d 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -4,7 +4,7 @@ import { IconPlus, IconStar, } from "@tabler/icons-react" -import { useCallback, useEffect, useState } from "react" +import { type ComponentType, useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -20,7 +20,6 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import { AddModelSheet } from "./add-model-sheet" -import { CatalogDialog } from "./catalog-dialog" import { DeleteModelDialog } from "./delete-model-dialog" import { EditModelSheet } from "./edit-model-sheet" import { getProviderKey, getProviderLabel } from "./provider-label" @@ -52,6 +51,14 @@ export function ModelsPage() { null, ) + // Dynamic import for CatalogDialog (added in PR2) + const [CatalogDialogComp, setCatalogDialogComp] = useState void; onModelAdded: () => void; + }> | null>(null) + useEffect(() => { + import("./catalog-dialog").then((m) => setCatalogDialogComp(() => m.CatalogDialog)).catch(() => {}) + }, []) + const fetchModels = useCallback(async () => { try { const data = await getModels() @@ -149,6 +156,7 @@ export function ModelsPage() { size="sm" variant="outline" onClick={() => setCatalogOpen(true)} + disabled={!CatalogDialogComp} > {t("models.catalog.button")} @@ -226,11 +234,13 @@ export function ModelsPage() { onDeleted={fetchModels} /> - setCatalogOpen(false)} - onModelAdded={fetchModels} - /> + {CatalogDialogComp && ( + setCatalogOpen(false)} + onModelAdded={fetchModels} + /> + )} ) } diff --git a/web/frontend/src/components/models/provider-combobox.tsx b/web/frontend/src/components/models/provider-combobox.tsx index 3a6d3da30..1edc458f4 100644 --- a/web/frontend/src/components/models/provider-combobox.tsx +++ b/web/frontend/src/components/models/provider-combobox.tsx @@ -1,5 +1,5 @@ import { IconCheck, IconChevronDown } from "@tabler/icons-react" -import { useState } from "react" +import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { Button } from "@/components/ui/button" @@ -50,6 +50,11 @@ export function ProviderCombobox({ const [open, setOpen] = useState(false) const [customMode, setCustomMode] = useState(false) const [customValue, setCustomValue] = useState("") + const [containerEl, setContainerEl] = useState(null) + + useEffect(() => { + setContainerEl(containerRef?.current ?? null) + }, [containerRef]) const allProviders: MergedProvider[] = backendOptions ? mergeWithBackendOptions(backendOptions) @@ -122,7 +127,7 @@ export function ProviderCombobox({ - + {customMode ? (
void - inlineParams?: TestInlineParams -} - -interface TestResult { - success: boolean - latency_ms: number - status: string - error?: string -} - -export function TestModelDialog({ - model, - open, - onClose, - inlineParams, -}: TestModelDialogProps) { - const { t } = useTranslation() - const [testing, setTesting] = useState(false) - const [result, setResult] = useState(null) - - const handleTest = async () => { - setTesting(true) - setResult(null) - try { - let res: TestResult - if (inlineParams) { - const req: TestModelInlineRequest = { - provider: inlineParams.provider, - model: inlineParams.model, - api_base: inlineParams.apiBase || undefined, - api_key: inlineParams.apiKey || undefined, - auth_method: inlineParams.authMethod || undefined, - model_index: inlineParams.modelIndex, - } - res = await testModelInline(req) - } else if (model) { - res = await testModel(model.index) - } else { - return - } - setResult(res) - } catch (e) { - setResult({ - success: false, - latency_ms: 0, - status: "error", - error: e instanceof Error ? e.message : t("models.test.testFailed"), - }) - } finally { - setTesting(false) - } - } - - const handleClose = () => { - setResult(null) - onClose() - } - - // Display info: prefer inline params, fall back to saved model - const displayModelName = inlineParams?.model || model?.model_name || "" - const displayModel = inlineParams?.model || model?.model || "" - const displayApiBase = inlineParams?.apiBase || model?.api_base || "" - const canTest = !!(inlineParams || model) - - return ( - !v && handleClose()}> - - - - - {t("models.test.title")} - - {t("models.test.description")} - - - {canTest && ( -
-
-
- - {t("models.test.modelLabel")}{" "} - - {displayModelName} -
-
- - {t("models.test.identifierLabel")}{" "} - - {displayModel} -
- {displayApiBase && ( -
- - {t("models.test.endpointLabel")}{" "} - - {displayApiBase} -
- )} -
- - {!result && !testing && ( - - )} - - {testing && ( -
- - {t("models.test.testing")} -
- )} - - {result && ( -
- {result.success ? ( -
-
- {t("models.test.success")} -
-
- {t("models.test.responseTime", { ms: result.latency_ms })} -
-
- ) : ( -
-
- - {t("models.test.failed")} -
-
- {result.error || - t("models.test.status", { status: result.status })} -
-
- )} -
- )} -
- )} - - - - {result && ( - - )} - -
-
- ) +// Placeholder: full implementation added in PR3 (Test Connection) +export function TestModelDialog() { + return null } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index d6335d6dc..6ac5e7787 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -246,7 +246,8 @@ }, "defaultOnSave": { "label": "Default Model", - "description": "Automatically set this model as default after saving." + "description": "Automatically set this model as default after saving.", + "unsupportedProvider": "This provider can be saved in the model list but cannot be used as the default chat model." }, "add": { "button": "Add Model", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index c091625bb..89659f4cb 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -244,7 +244,8 @@ }, "defaultOnSave": { "label": "Modelo Padrão", - "description": "Definir automaticamente este modelo como padrão após salvar." + "description": "Definir automaticamente este modelo como padrão após salvar.", + "unsupportedProvider": "Este provedor pode ser salvo na lista de modelos, mas não pode ser usado como modelo de chat padrão." }, "add": { "button": "Adicionar Modelo",