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)
This commit is contained in:
parent
81a050555d
commit
ee5cf2b88a
23 changed files with 3078 additions and 726 deletions
|
|
@ -603,6 +603,21 @@ func (c *ModelConfig) Validate() error {
|
||||||
if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil {
|
if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
|
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/azure"
|
"github.com/sipeed/picoclaw/pkg/providers/azure"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
|
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type protocolMeta struct {
|
type protocolMeta struct {
|
||||||
|
|
@ -60,6 +61,8 @@ var protocolMetaByName = map[string]protocolMeta{
|
||||||
"longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
|
"longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
|
||||||
"modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
|
"modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
|
||||||
"mimo": {defaultAPIBase: "https://api.xiaomimimo.com/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.
|
// 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)
|
return finalizeProviderFromConfig(provider, modelID, cfg)
|
||||||
}
|
}
|
||||||
// Use API key with HTTP API
|
// Use API key with HTTP API
|
||||||
apiBase := cfg.APIBase
|
apiBase := common.NormalizeBaseURL(cfg.APIBase, "https://api.anthropic.com/v1", true)
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.anthropic.com/v1"
|
|
||||||
}
|
|
||||||
if cfg.APIKey() == "" {
|
if cfg.APIKey() == "" {
|
||||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
161
web/backend/api/model_catalog.go
Normal file
161
web/backend/api/model_catalog.go
Normal file
|
|
@ -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"})
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -8,6 +9,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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("POST /api/models/default", h.handleSetDefaultModel)
|
||||||
mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel)
|
mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel)
|
||||||
mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel)
|
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.
|
// 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
|
// Show first 3 chars and last 4 chars
|
||||||
return key[:3] + "****" + key[len(key)-4:]
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@tabler/icons-react": "^3.40.0",
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"@tanstack/react-router-devtools": "^1.166.13",
|
"@tanstack/react-router-devtools": "^1.166.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
|
|
|
||||||
24
web/frontend/pnpm-lock.yaml
generated
24
web/frontend/pnpm-lock.yaml
generated
|
|
@ -11,6 +11,9 @@ importers:
|
||||||
'@fontsource-variable/inter':
|
'@fontsource-variable/inter':
|
||||||
specifier: ^5.2.8
|
specifier: ^5.2.8
|
||||||
version: 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':
|
'@tabler/icons-react':
|
||||||
specifier: ^3.40.0
|
specifier: ^3.40.0
|
||||||
version: 3.41.1(react@19.2.5)
|
version: 3.41.1(react@19.2.5)
|
||||||
|
|
@ -32,6 +35,9 @@ importers:
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
dayjs:
|
||||||
specifier: ^1.11.20
|
specifier: ^1.11.20
|
||||||
version: 1.11.20
|
version: 1.11.20
|
||||||
|
|
@ -2033,6 +2039,12 @@ packages:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
code-block-writer@13.0.3:
|
||||||
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
||||||
|
|
||||||
|
|
@ -5958,6 +5970,18 @@ snapshots:
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
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: {}
|
code-block-writer@13.0.3: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface ModelInfo {
|
||||||
extra_body?: Record<string, unknown>
|
extra_body?: Record<string, unknown>
|
||||||
custom_headers?: Record<string, string>
|
custom_headers?: Record<string, string>
|
||||||
// Meta
|
// Meta
|
||||||
|
enabled: boolean
|
||||||
available: boolean
|
available: boolean
|
||||||
status: "available" | "unconfigured" | "unreachable"
|
status: "available" | "unconfigured" | "unreachable"
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
|
|
@ -58,7 +59,13 @@ const BASE_URL = ""
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||||
if (!res.ok) {
|
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<T>
|
return res.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
@ -107,4 +114,75 @@ export async function setDefaultModel(
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TestModelResponse {
|
||||||
|
success: boolean
|
||||||
|
latency_ms: number
|
||||||
|
status: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testModel(index: number): Promise<TestModelResponse> {
|
||||||
|
return request<TestModelResponse>(`/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<FetchModelsResponse> {
|
||||||
|
return request<FetchModelsResponse>("/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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CatalogListResponse> {
|
||||||
|
return request<CatalogListResponse>("/api/models/catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCatalog(id: string): Promise<void> {
|
||||||
|
await request<Record<string, never>>(`/api/models/catalog/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type { ModelsListResponse, ModelActionResponse }
|
export type { ModelsListResponse, ModelActionResponse }
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import { IconDownload, IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import {
|
import { addModel, getCatalogs, setDefaultModel } from "@/api/models"
|
||||||
type ModelProviderOption,
|
|
||||||
addModel,
|
|
||||||
setDefaultModel,
|
|
||||||
} from "@/api/models"
|
|
||||||
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,15 +11,9 @@ import {
|
||||||
KeyInput,
|
KeyInput,
|
||||||
SwitchCardField,
|
SwitchCardField,
|
||||||
} from "@/components/shared-form"
|
} from "@/components/shared-form"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -36,14 +26,14 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
import { FetchModelsDialog } from "./fetch-models-dialog"
|
||||||
import {
|
import {
|
||||||
findProviderOption,
|
type FieldValidation,
|
||||||
getProviderDefaultAPIBase,
|
validateModelField,
|
||||||
getProviderDefaultAuthMethod,
|
} from "./model-validation"
|
||||||
getProviderLabel,
|
import { ProviderCombobox } from "./provider-combobox"
|
||||||
getSortedProviderOptions,
|
import { getProviderKey } from "./provider-label"
|
||||||
isProviderAuthMethodLocked,
|
import { PROVIDER_MAP } from "./provider-registry"
|
||||||
} from "./provider-label"
|
|
||||||
|
|
||||||
interface AddForm {
|
interface AddForm {
|
||||||
modelName: string
|
modelName: string
|
||||||
|
|
@ -66,7 +56,7 @@ interface AddForm {
|
||||||
|
|
||||||
const EMPTY_ADD_FORM: AddForm = {
|
const EMPTY_ADD_FORM: AddForm = {
|
||||||
modelName: "",
|
modelName: "",
|
||||||
provider: "openai",
|
provider: "",
|
||||||
model: "",
|
model: "",
|
||||||
apiBase: "",
|
apiBase: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
|
|
@ -83,12 +73,41 @@ const EMPTY_ADD_FORM: AddForm = {
|
||||||
customHeaders: "",
|
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 {
|
interface AddModelSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
existingModelNames: string[]
|
existingModelNames: string[]
|
||||||
providerOptions: ModelProviderOption[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddModelSheet({
|
export function AddModelSheet({
|
||||||
|
|
@ -96,7 +115,6 @@ export function AddModelSheet({
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
existingModelNames,
|
existingModelNames,
|
||||||
providerOptions,
|
|
||||||
}: AddModelSheetProps) {
|
}: AddModelSheetProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
||||||
|
|
@ -106,41 +124,15 @@ export function AddModelSheet({
|
||||||
Partial<Record<keyof AddForm, string>>
|
Partial<Record<keyof AddForm, string>>
|
||||||
>({})
|
>({})
|
||||||
const [serverError, setServerError] = useState("")
|
const [serverError, setServerError] = useState("")
|
||||||
|
const [modelValidation, setModelValidation] = useState<FieldValidation | null>(null)
|
||||||
|
const [fetchOpen, setFetchOpen] = useState(false)
|
||||||
|
const [fetchedModels, setFetchedModels] = useState<string[]>([])
|
||||||
|
const [catalogModels, setCatalogModels] = useState<string[]>([])
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
const apiKeyPlaceholder = maskedSecretPlaceholder(
|
const apiKeyPlaceholder = maskedSecretPlaceholder(
|
||||||
form.apiKey,
|
form.apiKey,
|
||||||
t("models.field.apiKeyPlaceholder"),
|
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 =
|
const isDirty =
|
||||||
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
||||||
|
|
||||||
|
|
@ -150,9 +142,37 @@ export function AddModelSheet({
|
||||||
setSetAsDefault(false)
|
setSetAsDefault(false)
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
setServerError("")
|
setServerError("")
|
||||||
|
setModelValidation(null)
|
||||||
|
setFetchedModels([])
|
||||||
|
setCatalogModels([])
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [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 validate = (): boolean => {
|
||||||
const errors: Partial<Record<keyof AddForm, string>> = {}
|
const errors: Partial<Record<keyof AddForm, string>> = {}
|
||||||
const modelName = form.modelName.trim()
|
const modelName = form.modelName.trim()
|
||||||
|
|
@ -161,10 +181,10 @@ export function AddModelSheet({
|
||||||
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
||||||
errors.modelName = t("models.add.errorDuplicateModelName")
|
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 (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
||||||
|
if (modelValidation?.level === "error") {
|
||||||
|
errors.model = t(modelValidation.messageKey, modelValidation.messageParams)
|
||||||
|
}
|
||||||
setFieldErrors(errors)
|
setFieldErrors(errors)
|
||||||
return Object.keys(errors).length === 0
|
return Object.keys(errors).length === 0
|
||||||
}
|
}
|
||||||
|
|
@ -178,47 +198,109 @@ export function AddModelSheet({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setProvider = (value: string) => {
|
const debouncedValidateModel = useCallback(
|
||||||
setForm((f) => {
|
(value: string, provider: string) => {
|
||||||
const previousOption = findProviderOption(f.provider, providerOptions)
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
const nextOption = findProviderOption(value, providerOptions)
|
debounceRef.current = setTimeout(() => {
|
||||||
let authMethod = f.authMethod
|
const result = validateModelField(value, provider || undefined)
|
||||||
if (nextOption?.auth_method_locked) {
|
setModelValidation(result)
|
||||||
authMethod = nextOption.default_auth_method ?? ""
|
}, 300)
|
||||||
} else if (
|
},
|
||||||
previousOption?.auth_method_locked &&
|
[],
|
||||||
f.authMethod === (previousOption.default_auth_method ?? "")
|
)
|
||||||
) {
|
|
||||||
authMethod = ""
|
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
}
|
const value = e.target.value
|
||||||
return { ...f, provider: value, authMethod }
|
setForm((f) => ({ ...f, model: value }))
|
||||||
})
|
if (fieldErrors.model) {
|
||||||
const nextOption = findProviderOption(value, providerOptions)
|
setFieldErrors((prev) => ({ ...prev, model: undefined }))
|
||||||
if (nextOption?.default_model_allowed === false) {
|
|
||||||
setSetAsDefault(false)
|
|
||||||
}
|
}
|
||||||
if (fieldErrors.provider) {
|
debouncedValidateModel(value, form.provider)
|
||||||
setFieldErrors((prev) => ({ ...prev, provider: undefined }))
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
|
|
||||||
|
let extraBody: Record<string, unknown> | undefined
|
||||||
|
let customHeaders: Record<string, string> | 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)
|
setSaving(true)
|
||||||
setServerError("")
|
setServerError("")
|
||||||
try {
|
try {
|
||||||
const modelName = form.modelName.trim()
|
const modelName = form.modelName.trim()
|
||||||
|
const provider = form.provider.trim()
|
||||||
const modelId = form.model.trim()
|
const modelId = form.model.trim()
|
||||||
await addModel({
|
await addModel({
|
||||||
model_name: modelName,
|
model_name: modelName,
|
||||||
provider: form.provider.trim(),
|
provider: provider || undefined,
|
||||||
model: modelId,
|
model: modelId,
|
||||||
api_base: form.apiBase.trim() || undefined,
|
api_base: form.apiBase.trim() || undefined,
|
||||||
api_key: form.apiKey.trim() || undefined,
|
api_key: form.apiKey.trim() || undefined,
|
||||||
proxy: form.proxy.trim() || undefined,
|
proxy: form.proxy.trim() || undefined,
|
||||||
auth_method: authMethodLocked
|
auth_method: form.authMethod.trim() || undefined,
|
||||||
? defaultAuthMethod || undefined
|
|
||||||
: form.authMethod.trim() || undefined,
|
|
||||||
connect_mode: form.connectMode.trim() || undefined,
|
connect_mode: form.connectMode.trim() || undefined,
|
||||||
workspace: form.workspace.trim() || undefined,
|
workspace: form.workspace.trim() || undefined,
|
||||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||||
|
|
@ -228,12 +310,8 @@ export function AddModelSheet({
|
||||||
: undefined,
|
: undefined,
|
||||||
thinking_level: form.thinkingLevel.trim() || undefined,
|
thinking_level: form.thinkingLevel.trim() || undefined,
|
||||||
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
|
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
|
||||||
extra_body: form.extraBody.trim()
|
extra_body: extraBody,
|
||||||
? JSON.parse(form.extraBody.trim())
|
custom_headers: customHeaders,
|
||||||
: undefined,
|
|
||||||
custom_headers: form.customHeaders.trim()
|
|
||||||
? JSON.parse(form.customHeaders.trim())
|
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
if (setAsDefault) {
|
if (setAsDefault) {
|
||||||
await setDefaultModel(modelName)
|
await setDefaultModel(modelName)
|
||||||
|
|
@ -255,6 +333,7 @@ export function AddModelSheet({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
|
|
@ -289,29 +368,12 @@ export function AddModelSheet({
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.provider")}
|
label={t("models.field.provider")}
|
||||||
hint={t("models.field.providerHint")}
|
hint={t("models.field.providerHint")}
|
||||||
error={fieldErrors.provider}
|
|
||||||
required
|
|
||||||
>
|
>
|
||||||
<Select
|
<ProviderCombobox
|
||||||
value={selectedProviderOption?.id}
|
value={form.provider}
|
||||||
onValueChange={setProvider}
|
onChange={handleProviderChange}
|
||||||
>
|
placeholder={t("models.field.providerPlaceholder")}
|
||||||
<SelectTrigger
|
/>
|
||||||
className="w-full"
|
|
||||||
aria-invalid={!!fieldErrors.provider}
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t("models.field.providerPlaceholder")}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{creatableProviderOptions.map((option) => (
|
|
||||||
<SelectItem key={option.id} value={option.id}>
|
|
||||||
{getProviderLabel(option.id)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|
@ -320,48 +382,124 @@ export function AddModelSheet({
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.model}
|
value={form.model}
|
||||||
onChange={setField("model")}
|
onChange={handleModelChange}
|
||||||
placeholder={t("models.add.modelIdPlaceholder")}
|
placeholder={
|
||||||
|
providerDef
|
||||||
|
? `${commonModels[0] || "model-name"}`
|
||||||
|
: t("models.add.modelIdPlaceholder")
|
||||||
|
}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
aria-invalid={!!fieldErrors.model}
|
aria-invalid={
|
||||||
|
!!fieldErrors.model || modelValidation?.level === "error"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.model && (
|
{modelValidation && modelValidation.messageKey && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 text-xs ${
|
||||||
|
modelValidation.level === "error"
|
||||||
|
? "text-destructive"
|
||||||
|
: modelValidation.level === "warning"
|
||||||
|
? "text-yellow-600 dark:text-yellow-500"
|
||||||
|
: "text-green-600 dark:text-green-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{t(modelValidation.messageKey, modelValidation.messageParams)}</span>
|
||||||
|
{modelValidation.fix && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyFix}
|
||||||
|
className="text-primary underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{t("common.fix")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fieldErrors.model && !modelValidation && (
|
||||||
<p className="text-destructive text-xs">{fieldErrors.model}</p>
|
<p className="text-destructive text-xs">{fieldErrors.model}</p>
|
||||||
)}
|
)}
|
||||||
|
{commonModels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{commonModels.map((m) => (
|
||||||
|
<Badge
|
||||||
|
key={m}
|
||||||
|
variant="secondary"
|
||||||
|
className="cursor-pointer font-mono text-xs hover:bg-secondary/80"
|
||||||
|
onClick={() => handleCommonModel(m)}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{catalogModels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{catalogModels.map((m) => (
|
||||||
|
<Badge
|
||||||
|
key={m}
|
||||||
|
variant={form.model === m ? "default" : "outline"}
|
||||||
|
className="cursor-pointer font-mono text-xs"
|
||||||
|
onClick={() => handleCommonModel(m)}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fetchedModels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{fetchedModels.map((m) => (
|
||||||
|
<Badge
|
||||||
|
key={m}
|
||||||
|
variant={form.model === m ? "default" : "outline"}
|
||||||
|
className="cursor-pointer font-mono text-xs"
|
||||||
|
onClick={() => handleCommonModel(m)}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setFetchOpen(true)}
|
||||||
|
disabled={!form.provider}
|
||||||
|
>
|
||||||
|
<IconDownload className="size-3" />
|
||||||
|
{t("models.fetch.title")}
|
||||||
|
</Button>
|
||||||
|
{!form.provider && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t("models.field.selectProviderFirst")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{!isOAuth && (
|
<Field label={t("models.field.apiKey")}>
|
||||||
<Field label={t("models.field.apiKey")}>
|
<KeyInput
|
||||||
<KeyInput
|
value={form.apiKey}
|
||||||
value={form.apiKey}
|
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
placeholder={apiKeyPlaceholder}
|
||||||
placeholder={apiKeyPlaceholder}
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field
|
<Field label={t("models.field.apiBase")}>
|
||||||
label={t("models.field.apiBase")}
|
|
||||||
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
value={form.apiBase}
|
value={form.apiBase}
|
||||||
onChange={setField("apiBase")}
|
onChange={setField("apiBase")}
|
||||||
placeholder={apiBasePlaceholder}
|
placeholder="https://api.example.com/v1"
|
||||||
disabled={isOAuth}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("models.defaultOnSave.label")}
|
label={t("models.defaultOnSave.label")}
|
||||||
hint={
|
hint={t("models.defaultOnSave.description")}
|
||||||
defaultModelAllowed
|
|
||||||
? t("models.defaultOnSave.description")
|
|
||||||
: t("models.defaultOnSave.unsupportedProvider")
|
|
||||||
}
|
|
||||||
checked={setAsDefault}
|
checked={setAsDefault}
|
||||||
onCheckedChange={setSetAsDefault}
|
onCheckedChange={setSetAsDefault}
|
||||||
disabled={!defaultModelAllowed}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdvancedSection>
|
<AdvancedSection>
|
||||||
|
|
@ -378,17 +516,12 @@ export function AddModelSheet({
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.authMethod")}
|
label={t("models.field.authMethod")}
|
||||||
hint={
|
hint={t("models.field.authMethodHint")}
|
||||||
authMethodLocked
|
|
||||||
? t("models.field.authMethodManagedHint")
|
|
||||||
: t("models.field.authMethodHint")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
value={form.authMethod}
|
||||||
onChange={setField("authMethod")}
|
onChange={setField("authMethod")}
|
||||||
placeholder="oauth"
|
placeholder="oauth"
|
||||||
disabled={authMethodLocked}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
@ -517,12 +650,25 @@ export function AddModelSheet({
|
||||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || saving || modelValidation?.level === "error"}
|
||||||
|
>
|
||||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||||
{t("models.add.confirm")}
|
{t("models.add.confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
||||||
|
<FetchModelsDialog
|
||||||
|
open={fetchOpen}
|
||||||
|
onClose={() => setFetchOpen(false)}
|
||||||
|
onFill={handleFetchFill}
|
||||||
|
provider={form.provider}
|
||||||
|
apiKey={form.apiKey}
|
||||||
|
apiBase={form.apiBase}
|
||||||
|
/>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
322
web/frontend/src/components/models/catalog-dialog.tsx
Normal file
322
web/frontend/src/components/models/catalog-dialog.tsx
Normal file
|
|
@ -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<CatalogEntry[]>([])
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [selected, setSelected] = useState<Map<string, Set<string>>>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("models.catalog.title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("models.catalog.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||||
|
<IconLoader2 className="size-5 animate-spin" />
|
||||||
|
<span>{t("models.catalog.loading")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && entries.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t("models.catalog.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("models.catalog.filterPlaceholder")}
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-h-[400px] space-y-2 overflow-y-auto">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const isExpanded = expandedId === entry.id
|
||||||
|
const entrySelected = selected.get(entry.id) || new Set()
|
||||||
|
const filteredModels = getFilteredModels(entry.models)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="rounded-lg border bg-card text-card-foreground"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center gap-3 px-3 py-2.5 hover:bg-accent/50"
|
||||||
|
onClick={() => toggleExpand(entry.id)}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<IconChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<IconChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{getProviderLabel(entry.provider)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{entry.api_key_mask}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{entry.models.length} {t("models.catalog.models")}
|
||||||
|
</span>
|
||||||
|
{entry.api_base && (
|
||||||
|
<>
|
||||||
|
<span>|</span>
|
||||||
|
<span className="truncate">{entry.api_base}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{entry.fetched_at && (
|
||||||
|
<>
|
||||||
|
<span>|</span>
|
||||||
|
<span>
|
||||||
|
{t("models.catalog.fetchedAt")}{" "}
|
||||||
|
{new Date(entry.fetched_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(entry.id)
|
||||||
|
}}
|
||||||
|
title={t("models.catalog.delete")}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t px-3 py-2">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t("models.catalog.found", {
|
||||||
|
count: filteredModels.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAll(entry.id, entry.models)}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{filteredModels.every((m) =>
|
||||||
|
entrySelected.has(m.id),
|
||||||
|
)
|
||||||
|
? t("models.catalog.deselectAll")
|
||||||
|
: t("models.catalog.selectAll")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
|
||||||
|
{filteredModels.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={entrySelected.has(m.id)}
|
||||||
|
onChange={() => toggleModel(entry.id, m.id)}
|
||||||
|
className="size-3.5"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs">{m.id}</span>
|
||||||
|
{m.owned_by && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{m.owned_by}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{entrySelected.size > 0 && (
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddSelected(entry)}
|
||||||
|
disabled={adding}
|
||||||
|
>
|
||||||
|
{adding && (
|
||||||
|
<IconLoader2 className="mr-1 size-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("models.catalog.addSelected", {
|
||||||
|
count: entrySelected.size,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t("common.close")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import {
|
||||||
import { useEffect, useMemo, useState } from "react"
|
IconDownload,
|
||||||
|
IconLoader2,
|
||||||
|
IconPlugConnected,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import {
|
import { type ModelInfo, getCatalogs, setDefaultModel, updateModel } from "@/api/models"
|
||||||
type ModelInfo,
|
|
||||||
type ModelProviderOption,
|
|
||||||
setDefaultModel,
|
|
||||||
updateModel,
|
|
||||||
} from "@/api/models"
|
|
||||||
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,15 +15,9 @@ import {
|
||||||
KeyInput,
|
KeyInput,
|
||||||
SwitchCardField,
|
SwitchCardField,
|
||||||
} from "@/components/shared-form"
|
} from "@/components/shared-form"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -37,14 +30,16 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
import { FetchModelsDialog } from "./fetch-models-dialog"
|
||||||
import {
|
import {
|
||||||
findProviderOption,
|
type FieldValidation,
|
||||||
getProviderDefaultAPIBase,
|
validateModelField,
|
||||||
getProviderDefaultAuthMethod,
|
} from "./model-validation"
|
||||||
getProviderLabel,
|
import { ProviderCombobox } from "./provider-combobox"
|
||||||
getSortedProviderOptions,
|
import { getProviderKey } from "./provider-label"
|
||||||
isProviderAuthMethodLocked,
|
import { PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry"
|
||||||
} from "./provider-label"
|
|
||||||
|
import { TestModelDialog } from "./test-model-dialog"
|
||||||
|
|
||||||
interface EditForm {
|
interface EditForm {
|
||||||
provider: string
|
provider: string
|
||||||
|
|
@ -66,12 +61,41 @@ interface EditForm {
|
||||||
|
|
||||||
interface EditModelSheetProps {
|
interface EditModelSheetProps {
|
||||||
model: ModelInfo | null
|
model: ModelInfo | null
|
||||||
providerOptions: ModelProviderOption[]
|
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => 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 {
|
function buildInitialEditForm(model: ModelInfo): EditForm {
|
||||||
return {
|
return {
|
||||||
provider: model.provider ?? "",
|
provider: model.provider ?? "",
|
||||||
|
|
@ -84,7 +108,9 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
|
||||||
workspace: model.workspace ?? "",
|
workspace: model.workspace ?? "",
|
||||||
rpm: model.rpm ? String(model.rpm) : "",
|
rpm: model.rpm ? String(model.rpm) : "",
|
||||||
maxTokensField: model.max_tokens_field ?? "",
|
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 ?? "",
|
thinkingLevel: model.thinking_level ?? "",
|
||||||
toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA
|
toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA
|
||||||
extraBody: model.extra_body
|
extraBody: model.extra_body
|
||||||
|
|
@ -98,7 +124,6 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
|
||||||
|
|
||||||
export function EditModelSheet({
|
export function EditModelSheet({
|
||||||
model,
|
model,
|
||||||
providerOptions,
|
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
@ -124,43 +149,13 @@ export function EditModelSheet({
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
|
const [modelValidation, setModelValidation] = useState<FieldValidation | null>(null)
|
||||||
|
const [testOpen, setTestOpen] = useState(false)
|
||||||
|
const [fetchOpen, setFetchOpen] = useState(false)
|
||||||
|
const [fetchedModels, setFetchedModels] = useState<string[]>([])
|
||||||
|
const [catalogModels, setCatalogModels] = useState<string[]>([])
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
const initialForm = model ? buildInitialEditForm(model) : null
|
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 =
|
const isDirty =
|
||||||
model != null &&
|
model != null &&
|
||||||
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
||||||
|
|
@ -168,73 +163,126 @@ export function EditModelSheet({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model) {
|
if (model) {
|
||||||
const initialForm = buildInitialEditForm(model)
|
setForm(buildInitialEditForm(model))
|
||||||
const option = findProviderOption(initialForm.provider, providerOptions)
|
setSetAsDefault(model.is_default)
|
||||||
if (option?.auth_method_locked && !initialForm.authMethod) {
|
|
||||||
initialForm.authMethod = option.default_auth_method ?? ""
|
|
||||||
}
|
|
||||||
setForm(initialForm)
|
|
||||||
setSetAsDefault(model.is_default && model.default_model_allowed !== false)
|
|
||||||
setError("")
|
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 =
|
const setField =
|
||||||
(key: keyof EditForm) =>
|
(key: keyof EditForm) =>
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
if (error) {
|
|
||||||
setError("")
|
|
||||||
}
|
|
||||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||||
}
|
|
||||||
|
|
||||||
const setProvider = (value: string) => {
|
const debouncedValidateModel = useCallback(
|
||||||
if (error) {
|
(value: string, provider: string) => {
|
||||||
setError("")
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
}
|
debounceRef.current = setTimeout(() => {
|
||||||
setForm((f) => {
|
const result = validateModelField(value, provider || undefined)
|
||||||
const previousOption = findProviderOption(f.provider, providerOptions)
|
setModelValidation(result)
|
||||||
const nextOption = findProviderOption(value, providerOptions)
|
}, 300)
|
||||||
let authMethod = f.authMethod
|
},
|
||||||
if (nextOption?.auth_method_locked) {
|
[],
|
||||||
authMethod = nextOption.default_auth_method ?? ""
|
)
|
||||||
} else if (
|
|
||||||
previousOption?.auth_method_locked &&
|
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
f.authMethod === (previousOption.default_auth_method ?? "")
|
const value = e.target.value
|
||||||
) {
|
setForm((f) => ({ ...f, modelId: value }))
|
||||||
authMethod = ""
|
debouncedValidateModel(value, form.provider)
|
||||||
}
|
}
|
||||||
return { ...f, provider: value, authMethod }
|
|
||||||
})
|
const handleProviderChange = (provider: string) => {
|
||||||
const nextOption = findProviderOption(value, providerOptions)
|
setForm((f) => ({
|
||||||
if (nextOption?.default_model_allowed === false) {
|
...f,
|
||||||
setSetAsDefault(false)
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!model) return
|
if (!model) return
|
||||||
if (!selectedProviderOption) {
|
|
||||||
setError(providerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.modelId.trim()) {
|
if (!form.modelId.trim()) {
|
||||||
setError(t("models.add.errorRequired"))
|
setError(t("models.add.errorRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (modelValidation?.level === "error") return
|
||||||
|
|
||||||
|
let extraBody: Record<string, unknown> | undefined
|
||||||
|
let customHeaders: Record<string, string> | 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)
|
setSaving(true)
|
||||||
setError("")
|
setError("")
|
||||||
try {
|
try {
|
||||||
|
const modelId = form.modelId.trim()
|
||||||
|
const provider = form.provider.trim()
|
||||||
await updateModel(model.index, {
|
await updateModel(model.index, {
|
||||||
model_name: model.model_name,
|
model_name: model.model_name,
|
||||||
provider: form.provider.trim(),
|
provider: provider,
|
||||||
model: form.modelId.trim(),
|
model: modelId,
|
||||||
api_base: form.apiBase || undefined,
|
api_base: form.apiBase || undefined,
|
||||||
api_key: form.apiKey || undefined,
|
api_key: form.apiKey || undefined,
|
||||||
proxy: form.proxy || undefined,
|
proxy: form.proxy || undefined,
|
||||||
auth_method: authMethodLocked
|
auth_method: form.authMethod || undefined,
|
||||||
? defaultAuthMethod || undefined
|
|
||||||
: form.authMethod || undefined,
|
|
||||||
connect_mode: form.connectMode || undefined,
|
connect_mode: form.connectMode || undefined,
|
||||||
workspace: form.workspace || undefined,
|
workspace: form.workspace || undefined,
|
||||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||||
|
|
@ -244,12 +292,8 @@ export function EditModelSheet({
|
||||||
: undefined,
|
: undefined,
|
||||||
thinking_level: form.thinkingLevel || undefined,
|
thinking_level: form.thinkingLevel || undefined,
|
||||||
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
|
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
|
||||||
extra_body: form.extraBody.trim()
|
extra_body: extraBody,
|
||||||
? JSON.parse(form.extraBody.trim())
|
custom_headers: customHeaders,
|
||||||
: {},
|
|
||||||
custom_headers: form.customHeaders.trim()
|
|
||||||
? JSON.parse(form.customHeaders.trim())
|
|
||||||
: {},
|
|
||||||
})
|
})
|
||||||
if (setAsDefault && !model.is_default) {
|
if (setAsDefault && !model.is_default) {
|
||||||
await setDefaultModel(model.model_name)
|
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 hasSavedAPIKey = Boolean(model?.api_key)
|
||||||
const apiKeyPlaceholder = hasSavedAPIKey
|
const apiKeyPlaceholder = hasSavedAPIKey
|
||||||
? maskedSecretPlaceholder(
|
? maskedSecretPlaceholder(
|
||||||
|
|
@ -280,267 +324,350 @@ export function EditModelSheet({
|
||||||
: t("models.field.apiKeyPlaceholder")
|
: t("models.field.apiKeyPlaceholder")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
<>
|
||||||
<SheetContent
|
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
side="right"
|
<SheetContent
|
||||||
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
side="right"
|
||||||
>
|
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
||||||
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
>
|
||||||
<SheetTitle className="text-base">
|
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
||||||
{t("models.edit.title", { name: model?.model_name })}
|
<SheetTitle className="text-base">
|
||||||
</SheetTitle>
|
{t("models.edit.title", { name: model?.model_name })}
|
||||||
<SheetDescription className="font-mono text-xs">
|
</SheetTitle>
|
||||||
{model?.model}
|
<SheetDescription className="font-mono text-xs">
|
||||||
</SheetDescription>
|
{model?.model}
|
||||||
</SheetHeader>
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="space-y-5 px-6 py-5">
|
<div className="space-y-5 px-6 py-5">
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.provider")}
|
label={t("models.field.provider")}
|
||||||
hint={t("models.field.providerHint")}
|
hint={t("models.field.providerHint")}
|
||||||
error={providerError}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={selectedProviderOption?.id}
|
|
||||||
onValueChange={setProvider}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<ProviderCombobox
|
||||||
className="w-full"
|
value={form.provider}
|
||||||
aria-invalid={!!providerError}
|
onChange={handleProviderChange}
|
||||||
|
placeholder={t("models.field.providerPlaceholder")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("models.add.modelId")}
|
||||||
|
hint={t("models.add.modelIdHint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={form.modelId}
|
||||||
|
onChange={handleModelChange}
|
||||||
|
placeholder={
|
||||||
|
providerDef
|
||||||
|
? `${commonModels[0] || "model-name"}`
|
||||||
|
: t("models.add.modelIdPlaceholder")
|
||||||
|
}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
aria-invalid={
|
||||||
|
!!error || modelValidation?.level === "error"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{modelValidation && modelValidation.messageKey && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 text-xs ${
|
||||||
|
modelValidation.level === "error"
|
||||||
|
? "text-destructive"
|
||||||
|
: modelValidation.level === "warning"
|
||||||
|
? "text-yellow-600 dark:text-yellow-500"
|
||||||
|
: "text-green-600 dark:text-green-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{t(modelValidation.messageKey, modelValidation.messageParams)}</span>
|
||||||
|
{modelValidation.fix && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyFix}
|
||||||
|
className="text-primary underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{t("common.fix")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{commonModels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{commonModels.map((m) => (
|
||||||
|
<Badge
|
||||||
|
key={m}
|
||||||
|
variant="secondary"
|
||||||
|
className="cursor-pointer font-mono text-xs hover:bg-secondary/80"
|
||||||
|
onClick={() => handleCommonModel(m)}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{catalogModels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{catalogModels.map((m) => (
|
||||||
|
<Badge
|
||||||
|
key={m}
|
||||||
|
variant={form.modelId === m ? "default" : "outline"}
|
||||||
|
className="cursor-pointer font-mono text-xs"
|
||||||
|
onClick={() => handleCommonModel(m)}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fetchedModels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{fetchedModels.map((m) => (
|
||||||
|
<Badge
|
||||||
|
key={m}
|
||||||
|
variant={form.modelId === m ? "default" : "outline"}
|
||||||
|
className="cursor-pointer font-mono text-xs"
|
||||||
|
onClick={() => handleCommonModel(m)}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setFetchOpen(true)}
|
||||||
|
disabled={!form.provider}
|
||||||
|
>
|
||||||
|
<IconDownload className="size-3" />
|
||||||
|
{t("models.fetch.title")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!isOAuth && (
|
||||||
|
<Field
|
||||||
|
label={t("models.field.apiKey")}
|
||||||
|
hint={
|
||||||
|
hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectValue
|
<KeyInput
|
||||||
placeholder={t("models.field.providerPlaceholder")}
|
value={form.apiKey}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||||
|
placeholder={apiKeyPlaceholder}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</Field>
|
||||||
<SelectContent>
|
)}
|
||||||
{sortedProviderOptions.map((option) => (
|
|
||||||
<SelectItem
|
|
||||||
key={option.id}
|
|
||||||
value={option.id}
|
|
||||||
disabled={
|
|
||||||
!option.create_allowed &&
|
|
||||||
option.id !== currentProviderID
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getProviderLabel(option.id)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.add.modelId")}
|
label={t("models.field.apiBase")}
|
||||||
hint={t("models.add.modelIdHint")}
|
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.modelId}
|
value={form.apiBase}
|
||||||
onChange={setField("modelId")}
|
onChange={setField("apiBase")}
|
||||||
placeholder={t("models.add.modelIdPlaceholder")}
|
placeholder="https://api.example.com/v1"
|
||||||
className="font-mono text-sm"
|
disabled={isOAuth}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTestOpen(true)}
|
||||||
|
disabled={!model}
|
||||||
|
>
|
||||||
|
<IconPlugConnected className="size-4" />
|
||||||
|
{t("models.test.testConnection")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("models.defaultOnSave.label")}
|
||||||
|
hint={t("models.defaultOnSave.description")}
|
||||||
|
checked={setAsDefault}
|
||||||
|
onCheckedChange={setSetAsDefault}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
|
|
||||||
{!isOAuth && (
|
<AdvancedSection>
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.apiKey")}
|
label={t("models.field.proxy")}
|
||||||
hint={hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined}
|
hint={t("models.field.proxyHint")}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<Input
|
||||||
value={form.apiKey}
|
value={form.proxy}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
onChange={setField("proxy")}
|
||||||
placeholder={apiKeyPlaceholder}
|
placeholder="http://127.0.0.1:7890"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.apiBase")}
|
label={t("models.field.authMethod")}
|
||||||
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
hint={t("models.field.authMethodHint")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.apiBase}
|
value={form.authMethod}
|
||||||
onChange={setField("apiBase")}
|
onChange={setField("authMethod")}
|
||||||
placeholder={apiBasePlaceholder}
|
placeholder="oauth"
|
||||||
disabled={isOAuth}
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<SwitchCardField
|
<Field
|
||||||
label={t("models.defaultOnSave.label")}
|
label={t("models.field.connectMode")}
|
||||||
hint={
|
hint={t("models.field.connectModeHint")}
|
||||||
willClearDefaultOnSave
|
>
|
||||||
? t("models.defaultOnSave.clearOnSave")
|
<Input
|
||||||
: defaultModelAllowed
|
value={form.connectMode}
|
||||||
? t("models.defaultOnSave.description")
|
onChange={setField("connectMode")}
|
||||||
: t("models.defaultOnSave.unsupportedProvider")
|
placeholder="stdio"
|
||||||
}
|
/>
|
||||||
checked={setAsDefault}
|
</Field>
|
||||||
onCheckedChange={setSetAsDefault}
|
|
||||||
disabled={!defaultModelAllowed}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdvancedSection>
|
<Field
|
||||||
<Field
|
label={t("models.field.workspace")}
|
||||||
label={t("models.field.proxy")}
|
hint={t("models.field.workspaceHint")}
|
||||||
hint={t("models.field.proxyHint")}
|
>
|
||||||
>
|
<Input
|
||||||
<Input
|
value={form.workspace}
|
||||||
value={form.proxy}
|
onChange={setField("workspace")}
|
||||||
onChange={setField("proxy")}
|
placeholder="/path/to/workspace"
|
||||||
placeholder="http://127.0.0.1:7890"
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.authMethod")}
|
label={t("models.field.requestTimeout")}
|
||||||
hint={
|
hint={t("models.field.requestTimeoutHint")}
|
||||||
authMethodLocked
|
>
|
||||||
? t("models.field.authMethodManagedHint")
|
<Input
|
||||||
: t("models.field.authMethodHint")
|
value={form.requestTimeout}
|
||||||
}
|
onChange={setField("requestTimeout")}
|
||||||
>
|
placeholder="60"
|
||||||
<Input
|
type="number"
|
||||||
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
min={0}
|
||||||
onChange={setField("authMethod")}
|
/>
|
||||||
placeholder="oauth"
|
</Field>
|
||||||
disabled={authMethodLocked}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.connectMode")}
|
label={t("models.field.rpm")}
|
||||||
hint={t("models.field.connectModeHint")}
|
hint={t("models.field.rpmHint")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.connectMode}
|
value={form.rpm}
|
||||||
onChange={setField("connectMode")}
|
onChange={setField("rpm")}
|
||||||
placeholder="stdio"
|
placeholder="60"
|
||||||
/>
|
type="number"
|
||||||
</Field>
|
min={0}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.workspace")}
|
label={t("models.field.thinkingLevel")}
|
||||||
hint={t("models.field.workspaceHint")}
|
hint={t("models.field.thinkingLevelHint")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.workspace}
|
value={form.thinkingLevel}
|
||||||
onChange={setField("workspace")}
|
onChange={setField("thinkingLevel")}
|
||||||
placeholder="/path/to/workspace"
|
placeholder="off"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.requestTimeout")}
|
label={t("models.field.maxTokensField")}
|
||||||
hint={t("models.field.requestTimeoutHint")}
|
hint={t("models.field.maxTokensFieldHint")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.requestTimeout}
|
value={form.maxTokensField}
|
||||||
onChange={setField("requestTimeout")}
|
onChange={setField("maxTokensField")}
|
||||||
placeholder="60"
|
placeholder="max_completion_tokens"
|
||||||
type="number"
|
/>
|
||||||
min={0}
|
</Field>
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.rpm")}
|
label={t("models.field.extraBody")}
|
||||||
hint={t("models.field.rpmHint")}
|
hint={t("models.field.extraBodyHint")}
|
||||||
>
|
>
|
||||||
<Input
|
<Textarea
|
||||||
value={form.rpm}
|
value={form.extraBody}
|
||||||
onChange={setField("rpm")}
|
onChange={setField("extraBody")}
|
||||||
placeholder="60"
|
placeholder='{"key": "value"}'
|
||||||
type="number"
|
rows={3}
|
||||||
min={0}
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.thinkingLevel")}
|
label={t("models.field.customHeaders")}
|
||||||
hint={t("models.field.thinkingLevelHint")}
|
hint={t("models.field.customHeadersHint")}
|
||||||
>
|
>
|
||||||
<Input
|
<Textarea
|
||||||
value={form.thinkingLevel}
|
value={form.customHeaders}
|
||||||
onChange={setField("thinkingLevel")}
|
onChange={setField("customHeaders")}
|
||||||
placeholder="off"
|
placeholder='{"X-Source": "coding-plan"}'
|
||||||
/>
|
rows={3}
|
||||||
</Field>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.maxTokensField")}
|
label={t("models.field.toolSchemaTransform")}
|
||||||
hint={t("models.field.maxTokensFieldHint")}
|
hint={t("models.field.toolSchemaTransformHint")}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.maxTokensField}
|
value={form.toolSchemaTransform}
|
||||||
onChange={setField("maxTokensField")}
|
onChange={setField("toolSchemaTransform")}
|
||||||
placeholder="max_completion_tokens"
|
placeholder="google"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</AdvancedSection>
|
||||||
|
|
||||||
<Field
|
{error && (
|
||||||
label={t("models.field.toolSchemaTransform")}
|
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
|
||||||
hint={t("models.field.toolSchemaTransformHint")}
|
{error}
|
||||||
>
|
</p>
|
||||||
<Input
|
)}
|
||||||
value={form.toolSchemaTransform}
|
</div>
|
||||||
onChange={setField("toolSchemaTransform")}
|
|
||||||
placeholder="google"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t("models.field.extraBody")}
|
|
||||||
hint={t("models.field.extraBodyHint")}
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
value={form.extraBody}
|
|
||||||
onChange={setField("extraBody")}
|
|
||||||
placeholder='{"key": "value"}'
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t("models.field.customHeaders")}
|
|
||||||
hint={t("models.field.customHeadersHint")}
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
value={form.customHeaders}
|
|
||||||
onChange={setField("customHeaders")}
|
|
||||||
placeholder='{"X-Source": "coding-plan"}'
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</AdvancedSection>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<ConfigChangeNotice
|
<ConfigChangeNotice
|
||||||
kind="save"
|
kind="save"
|
||||||
title={t("common.saveChangesTitle")}
|
title={t("common.saveChangesTitle")}
|
||||||
description={t("models.unsavedPrompt")}
|
description={t("models.unsavedPrompt")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
<Button
|
||||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
onClick={handleSave}
|
||||||
{t("common.save")}
|
disabled={
|
||||||
</Button>
|
!isDirty || saving || modelValidation?.level === "error"
|
||||||
</SheetFooter>
|
}
|
||||||
</SheetContent>
|
>
|
||||||
</Sheet>
|
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<TestModelDialog
|
||||||
|
model={model}
|
||||||
|
open={testOpen}
|
||||||
|
onClose={() => setTestOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FetchModelsDialog
|
||||||
|
open={fetchOpen}
|
||||||
|
onClose={() => setFetchOpen(false)}
|
||||||
|
onFill={handleFetchFill}
|
||||||
|
provider={form.provider}
|
||||||
|
apiKey={form.apiKey}
|
||||||
|
apiBase={form.apiBase}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
223
web/frontend/src/components/models/fetch-models-dialog.tsx
Normal file
223
web/frontend/src/components/models/fetch-models-dialog.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { IconDownload, IconLoader2 } from "@tabler/icons-react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { fetchUpstreamModels, type UpstreamModel } 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<UpstreamModel[]>([])
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(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
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && provider) {
|
||||||
|
handleFetch()
|
||||||
|
}
|
||||||
|
}, [open, provider, 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 (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<IconDownload className="size-5" />
|
||||||
|
{t("models.fetch.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("models.fetch.description")}
|
||||||
|
{provider && (
|
||||||
|
<span className="mt-1 block font-mono text-xs">
|
||||||
|
{t("models.fetch.providerLabel")} {provider}
|
||||||
|
{apiBase && ` | ${apiBase}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{needsKey && !apiKey && (
|
||||||
|
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
{t("models.fetch.needApiKey")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fetching && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||||
|
<IconLoader2 className="size-5 animate-spin" />
|
||||||
|
<span>{t("models.fetch.fetching")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFetch}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t("models.fetch.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{models.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
placeholder={t("models.fetch.filterPlaceholder")}
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t("models.fetch.found", { count: models.length })}
|
||||||
|
{filter && ` ${t("models.fetch.shown", { count: filteredModels.length })}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAll}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{filteredModels.every((m) => selected.has(m.id))
|
||||||
|
? t("models.fetch.deselectAll")
|
||||||
|
: t("models.fetch.selectAll")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded-md border p-2">
|
||||||
|
{filteredModels.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(m.id)}
|
||||||
|
onChange={() => toggleModel(m.id)}
|
||||||
|
className="size-3.5"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs">{m.id}</span>
|
||||||
|
{m.owned_by && (
|
||||||
|
<span className="text-muted-foreground ml-auto text-xs">
|
||||||
|
{m.owned_by}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={handleClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
{models.length > 0 && (
|
||||||
|
<Button onClick={handleFill} disabled={selected.size === 0}>
|
||||||
|
{t("models.fetch.fill", { count: selected.size })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
web/frontend/src/components/models/model-validation.ts
Normal file
115
web/frontend/src/components/models/model-validation.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Real-time model field validation utilities.
|
||||||
|
* All checks are pure frontend, no network required.
|
||||||
|
*
|
||||||
|
* Messages use i18n keys with interpolation params — callers must
|
||||||
|
* translate them via t(key, params).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
findClosestProvider,
|
||||||
|
KNOWN_PROVIDER_KEYS,
|
||||||
|
PROVIDER_ALIASES,
|
||||||
|
} from "./provider-registry"
|
||||||
|
|
||||||
|
export type ValidationLevel = "error" | "warning" | "success"
|
||||||
|
|
||||||
|
export interface FieldValidation {
|
||||||
|
level: ValidationLevel
|
||||||
|
messageKey: string
|
||||||
|
messageParams?: Record<string, string>
|
||||||
|
fix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a model identifier string with optional provider context.
|
||||||
|
* Returns validation result with optional one-click fix suggestion.
|
||||||
|
*/
|
||||||
|
export function validateModelField(
|
||||||
|
input: string,
|
||||||
|
selectedProvider?: string,
|
||||||
|
): FieldValidation {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return { level: "success", messageKey: "" }
|
||||||
|
|
||||||
|
// Hard errors
|
||||||
|
if (/\s/.test(trimmed)) {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
messageKey: "models.validation.whitespace",
|
||||||
|
fix: trimmed.replace(/\s+/g, "/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("/")) {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
messageKey: "models.validation.leadingSlash",
|
||||||
|
fix: trimmed.replace(/^\/+/, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trimmed.includes("//")) {
|
||||||
|
return {
|
||||||
|
level: "error",
|
||||||
|
messageKey: "models.validation.consecutiveSlash",
|
||||||
|
fix: trimmed.replace(/\/+/g, "/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slashIdx = trimmed.indexOf("/")
|
||||||
|
if (slashIdx === -1) {
|
||||||
|
// No provider prefix — when a provider is already selected,
|
||||||
|
// the model ID is provider-local and needs no prefix.
|
||||||
|
if (selectedProvider) {
|
||||||
|
return {
|
||||||
|
level: "success",
|
||||||
|
messageKey: "models.validation.parsed",
|
||||||
|
messageParams: { provider: selectedProvider, model: trimmed },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: "warning",
|
||||||
|
messageKey: "models.validation.defaultToOpenAI",
|
||||||
|
fix: `openai/${trimmed}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = trimmed.slice(0, slashIdx)
|
||||||
|
const model = trimmed.slice(slashIdx + 1)
|
||||||
|
if (!model) {
|
||||||
|
return { level: "error", messageKey: "models.validation.emptyModel" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!KNOWN_PROVIDER_KEYS.has(provider)) {
|
||||||
|
// Check aliases
|
||||||
|
const alias = PROVIDER_ALIASES[provider]
|
||||||
|
if (alias) {
|
||||||
|
return {
|
||||||
|
level: "warning",
|
||||||
|
messageKey: "models.validation.shouldUse",
|
||||||
|
messageParams: { provider, alias },
|
||||||
|
fix: `${alias}/${model}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Typo check
|
||||||
|
const closest = findClosestProvider(provider)
|
||||||
|
if (closest) {
|
||||||
|
return {
|
||||||
|
level: "warning",
|
||||||
|
messageKey: "models.validation.didYouMean",
|
||||||
|
messageParams: { closest },
|
||||||
|
fix: `${closest}/${model}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: "warning",
|
||||||
|
messageKey: "models.validation.unknownProvider",
|
||||||
|
messageParams: { provider },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: "success",
|
||||||
|
messageKey: "models.validation.parsed",
|
||||||
|
messageParams: { provider, model },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,20 @@
|
||||||
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
|
import { IconDatabase, IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import {
|
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
||||||
type ModelInfo,
|
|
||||||
type ModelProviderOption,
|
|
||||||
getModels,
|
|
||||||
setDefaultModel,
|
|
||||||
} from "@/api/models"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
import { AddModelSheet } from "./add-model-sheet"
|
import { AddModelSheet } from "./add-model-sheet"
|
||||||
|
import { CatalogDialog } from "./catalog-dialog"
|
||||||
import { DeleteModelDialog } from "./delete-model-dialog"
|
import { DeleteModelDialog } from "./delete-model-dialog"
|
||||||
import { EditModelSheet } from "./edit-model-sheet"
|
import { EditModelSheet } from "./edit-model-sheet"
|
||||||
import {
|
import { getProviderKey, getProviderLabel } from "./provider-label"
|
||||||
PROVIDER_PRIORITY,
|
import { PROVIDER_PRIORITY } from "./provider-registry"
|
||||||
getProviderKey,
|
|
||||||
getProviderLabel,
|
|
||||||
} from "./provider-label"
|
|
||||||
import { ProviderSection } from "./provider-section"
|
import { ProviderSection } from "./provider-section"
|
||||||
|
|
||||||
interface ProviderGroup {
|
interface ProviderGroup {
|
||||||
|
|
@ -35,19 +28,16 @@ interface ProviderGroup {
|
||||||
export function ModelsPage() {
|
export function ModelsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [models, setModels] = useState<ModelInfo[]>([])
|
const [models, setModels] = useState<ModelInfo[]>([])
|
||||||
const [providerOptions, setProviderOptions] = useState<ModelProviderOption[]>(
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [fetchError, setFetchError] = useState("")
|
const [fetchError, setFetchError] = useState("")
|
||||||
|
|
||||||
const [editingModel, setEditingModel] = useState<ModelInfo | null>(null)
|
const [editingModel, setEditingModel] = useState<ModelInfo | null>(null)
|
||||||
const [deletingModel, setDeletingModel] = useState<ModelInfo | null>(null)
|
const [deletingModel, setDeletingModel] = useState<ModelInfo | null>(null)
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [catalogOpen, setCatalogOpen] = useState(false)
|
||||||
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
|
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const addDisabled = loading || providerOptions.length === 0
|
|
||||||
|
|
||||||
const fetchModels = useCallback(async () => {
|
const fetchModels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -60,7 +50,6 @@ export function ModelsPage() {
|
||||||
return a.model_name.localeCompare(b.model_name)
|
return a.model_name.localeCompare(b.model_name)
|
||||||
})
|
})
|
||||||
setModels(sorted)
|
setModels(sorted)
|
||||||
setProviderOptions(data.provider_options ?? [])
|
|
||||||
setFetchError("")
|
setFetchError("")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
||||||
|
|
@ -145,9 +134,12 @@ export function ModelsPage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={addDisabled}
|
onClick={() => setCatalogOpen(true)}
|
||||||
onClick={() => setAddOpen(true)}
|
|
||||||
>
|
>
|
||||||
|
<IconDatabase className="size-4" />
|
||||||
|
{t("models.catalog.button")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
||||||
<IconPlus className="size-4" />
|
<IconPlus className="size-4" />
|
||||||
{t("models.add.button")}
|
{t("models.add.button")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -200,7 +192,6 @@ export function ModelsPage() {
|
||||||
|
|
||||||
<EditModelSheet
|
<EditModelSheet
|
||||||
model={editingModel}
|
model={editingModel}
|
||||||
providerOptions={providerOptions}
|
|
||||||
open={editingModel !== null}
|
open={editingModel !== null}
|
||||||
onClose={() => setEditingModel(null)}
|
onClose={() => setEditingModel(null)}
|
||||||
onSaved={fetchModels}
|
onSaved={fetchModels}
|
||||||
|
|
@ -208,7 +199,6 @@ export function ModelsPage() {
|
||||||
|
|
||||||
<AddModelSheet
|
<AddModelSheet
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
providerOptions={providerOptions}
|
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
onSaved={fetchModels}
|
onSaved={fetchModels}
|
||||||
existingModelNames={models.map((model) => model.model_name)}
|
existingModelNames={models.map((model) => model.model_name)}
|
||||||
|
|
@ -219,6 +209,12 @@ export function ModelsPage() {
|
||||||
onClose={() => setDeletingModel(null)}
|
onClose={() => setDeletingModel(null)}
|
||||||
onDeleted={fetchModels}
|
onDeleted={fetchModels}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CatalogDialog
|
||||||
|
open={catalogOpen}
|
||||||
|
onClose={() => setCatalogOpen(false)}
|
||||||
|
onModelAdded={fetchModels}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
192
web/frontend/src/components/models/provider-combobox.tsx
Normal file
192
web/frontend/src/components/models/provider-combobox.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { IconCheck, IconChevronDown } from "@tabler/icons-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { ProviderIcon } from "./provider-icon"
|
||||||
|
import { KNOWN_PROVIDER_KEYS, PROVIDERS } from "./provider-registry"
|
||||||
|
|
||||||
|
interface ProviderComboboxProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderCombobox({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: ProviderComboboxProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [customMode, setCustomMode] = useState(false)
|
||||||
|
const [customValue, setCustomValue] = useState("")
|
||||||
|
|
||||||
|
const sorted = [...PROVIDERS].sort((a, b) => b.priority - a.priority)
|
||||||
|
const selected = sorted.find((p) => p.key === value)
|
||||||
|
const isCustom = value && !KNOWN_PROVIDER_KEYS.has(value)
|
||||||
|
|
||||||
|
const handleSelect = (currentValue: string) => {
|
||||||
|
if (currentValue === "__custom__") {
|
||||||
|
setCustomMode(true)
|
||||||
|
setCustomValue(isCustom ? value : "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(currentValue === value ? "" : currentValue)
|
||||||
|
setCustomMode(false)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomConfirm = () => {
|
||||||
|
const trimmed = customValue.trim()
|
||||||
|
if (trimmed) {
|
||||||
|
onChange(trimmed)
|
||||||
|
}
|
||||||
|
setCustomMode(false)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={(v) => {
|
||||||
|
setOpen(v)
|
||||||
|
if (!v) setCustomMode(false)
|
||||||
|
}}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
providerKey={selected.key}
|
||||||
|
providerLabel={selected.label}
|
||||||
|
/>
|
||||||
|
{selected.labelZh || selected.label}
|
||||||
|
</span>
|
||||||
|
) : isCustom ? (
|
||||||
|
<span className="flex items-center gap-2 font-mono text-sm">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{placeholder || t("models.combobox.selectProvider")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<IconChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||||
|
{customMode ? (
|
||||||
|
<div className="flex flex-col gap-2 p-2">
|
||||||
|
<Input
|
||||||
|
value={customValue}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
placeholder={t("models.combobox.customPlaceholder")}
|
||||||
|
className="h-8 font-mono text-sm"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleCustomConfirm()
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setCustomMode(false)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomMode(false)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
onClick={handleCustomConfirm}
|
||||||
|
disabled={!customValue.trim()}
|
||||||
|
>
|
||||||
|
{t("common.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={t("models.combobox.searchProvider")} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("models.combobox.noProvider")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{sorted.map((provider) => (
|
||||||
|
<CommandItem
|
||||||
|
key={provider.key}
|
||||||
|
value={provider.key}
|
||||||
|
keywords={[
|
||||||
|
provider.label,
|
||||||
|
provider.labelZh || "",
|
||||||
|
...(provider.aliases || []),
|
||||||
|
]}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
providerKey={provider.key}
|
||||||
|
providerLabel={provider.label}
|
||||||
|
/>
|
||||||
|
<span>{provider.labelZh || provider.label}</span>
|
||||||
|
{provider.isLocal && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t("models.combobox.local")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<IconCheck
|
||||||
|
className={cn(
|
||||||
|
"ml-auto size-4",
|
||||||
|
value === provider.key ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
<CommandItem
|
||||||
|
value="__custom__"
|
||||||
|
keywords={["custom", "自定义"]}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground italic">
|
||||||
|
{t("models.combobox.custom")}
|
||||||
|
</span>
|
||||||
|
{isCustom && (
|
||||||
|
<IconCheck className="ml-auto size-4 opacity-100" />
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,57 +1,9 @@
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
import {
|
||||||
openai: "openai",
|
PROVIDER_DOMAINS,
|
||||||
elevenlabs: "elevenlabs",
|
PROVIDER_ICON_SLUGS,
|
||||||
anthropic: "anthropic",
|
} from "./provider-registry"
|
||||||
azure: "microsoftazure",
|
|
||||||
gemini: "googlegemini",
|
|
||||||
deepseek: "deepseek",
|
|
||||||
"qwen-portal": "alibabacloud",
|
|
||||||
"qwen-intl": "alibabacloud",
|
|
||||||
groq: "groq",
|
|
||||||
openrouter: "openrouter",
|
|
||||||
nvidia: "nvidia",
|
|
||||||
cerebras: "cerebras",
|
|
||||||
volcengine: "bytedance",
|
|
||||||
"github-copilot": "githubcopilot",
|
|
||||||
ollama: "ollama",
|
|
||||||
mistral: "mistralai",
|
|
||||||
zhipu: "zhipu",
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROVIDER_DOMAINS: Record<string, string> = {
|
|
||||||
openai: "openai.com",
|
|
||||||
elevenlabs: "elevenlabs.io",
|
|
||||||
anthropic: "anthropic.com",
|
|
||||||
azure: "azure.com",
|
|
||||||
gemini: "gemini.google.com",
|
|
||||||
deepseek: "deepseek.com",
|
|
||||||
"qwen-portal": "qwenlm.ai",
|
|
||||||
"qwen-intl": "alibabacloud.com",
|
|
||||||
moonshot: "moonshot.ai",
|
|
||||||
groq: "groq.com",
|
|
||||||
openrouter: "openrouter.ai",
|
|
||||||
nvidia: "nvidia.com",
|
|
||||||
cerebras: "cerebras.ai",
|
|
||||||
volcengine: "volcengine.com",
|
|
||||||
shengsuanyun: "shengsuanyun.com",
|
|
||||||
antigravity: "antigravity.google",
|
|
||||||
"github-copilot": "github.com",
|
|
||||||
ollama: "ollama.com",
|
|
||||||
lmstudio: "lmstudio.ai",
|
|
||||||
mistral: "mistral.ai",
|
|
||||||
avian: "avian.io",
|
|
||||||
vllm: "vllm.ai",
|
|
||||||
zhipu: "zhipuai.cn",
|
|
||||||
zai: "z.ai",
|
|
||||||
mimo: "xiaomi.com",
|
|
||||||
venice: "venice.ai",
|
|
||||||
vivgrid: "vivgrid.com",
|
|
||||||
minimax: "minimaxi.com",
|
|
||||||
longcat: "longcat.chat",
|
|
||||||
modelscope: "modelscope.cn",
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderIconProps {
|
interface ProviderIconProps {
|
||||||
providerKey: string
|
providerKey: string
|
||||||
|
|
@ -82,7 +34,7 @@ export function ProviderIcon({
|
||||||
|
|
||||||
if (!iconUrl || loadFailed) {
|
if (!iconUrl || loadFailed) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-black/70">
|
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-white/70">
|
||||||
{initial}
|
{initial}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,7 @@
|
||||||
import type { ModelProviderOption } from "@/api/models"
|
import {
|
||||||
|
PROVIDER_ALIASES,
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
PROVIDER_LABELS,
|
||||||
openai: "OpenAI",
|
} from "./provider-registry"
|
||||||
bedrock: "AWS Bedrock",
|
|
||||||
elevenlabs: "ElevenLabs ASR",
|
|
||||||
anthropic: "Anthropic",
|
|
||||||
"anthropic-messages": "Anthropic Messages",
|
|
||||||
azure: "Azure OpenAI",
|
|
||||||
gemini: "Google Gemini",
|
|
||||||
deepseek: "DeepSeek",
|
|
||||||
"coding-plan": "Alibaba Coding Plan",
|
|
||||||
"coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)",
|
|
||||||
"qwen-portal": "Qwen (阿里云)",
|
|
||||||
"qwen-intl": "Qwen International",
|
|
||||||
"qwen-us": "Qwen US",
|
|
||||||
moonshot: "Moonshot (月之暗面)",
|
|
||||||
groq: "Groq",
|
|
||||||
openrouter: "OpenRouter",
|
|
||||||
nvidia: "NVIDIA",
|
|
||||||
cerebras: "Cerebras",
|
|
||||||
volcengine: "Volcengine (火山引擎)",
|
|
||||||
shengsuanyun: "ShengsuanYun (神算云)",
|
|
||||||
antigravity: "Google Code Assist",
|
|
||||||
"github-copilot": "GitHub Copilot",
|
|
||||||
"claude-cli": "Claude CLI (local)",
|
|
||||||
"codex-cli": "Codex CLI (local)",
|
|
||||||
ollama: "Ollama (local)",
|
|
||||||
lmstudio: "LM Studio (local)",
|
|
||||||
litellm: "LiteLLM",
|
|
||||||
mistral: "Mistral AI",
|
|
||||||
avian: "Avian",
|
|
||||||
vllm: "VLLM (local)",
|
|
||||||
zhipu: "Zhipu AI (智谱)",
|
|
||||||
zai: "Z.ai",
|
|
||||||
mimo: "Xiaomi MiMo",
|
|
||||||
venice: "Venice AI",
|
|
||||||
vivgrid: "Vivgrid",
|
|
||||||
minimax: "MiniMax",
|
|
||||||
longcat: "LongCat",
|
|
||||||
modelscope: "ModelScope (魔搭社区)",
|
|
||||||
novita: "Novita AI",
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROVIDER_ALIASES: Record<string, string> = {
|
|
||||||
qwen: "qwen-portal",
|
|
||||||
"qwen-international": "qwen-intl",
|
|
||||||
"dashscope-intl": "qwen-intl",
|
|
||||||
"z.ai": "zai",
|
|
||||||
"z-ai": "zai",
|
|
||||||
google: "gemini",
|
|
||||||
"google-antigravity": "antigravity",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PROVIDER_PRIORITY: Record<string, number> = {
|
|
||||||
volcengine: 0,
|
|
||||||
openai: 1,
|
|
||||||
gemini: 2,
|
|
||||||
anthropic: 3,
|
|
||||||
bedrock: 4,
|
|
||||||
elevenlabs: 5,
|
|
||||||
"anthropic-messages": 6,
|
|
||||||
zhipu: 7,
|
|
||||||
deepseek: 8,
|
|
||||||
openrouter: 9,
|
|
||||||
"qwen-portal": 10,
|
|
||||||
"qwen-intl": 11,
|
|
||||||
"qwen-us": 12,
|
|
||||||
moonshot: 13,
|
|
||||||
groq: 14,
|
|
||||||
"coding-plan": 15,
|
|
||||||
"coding-plan-anthropic": 16,
|
|
||||||
"github-copilot": 17,
|
|
||||||
antigravity: 18,
|
|
||||||
nvidia: 19,
|
|
||||||
cerebras: 20,
|
|
||||||
shengsuanyun: 21,
|
|
||||||
venice: 22,
|
|
||||||
vivgrid: 23,
|
|
||||||
minimax: 24,
|
|
||||||
longcat: 25,
|
|
||||||
modelscope: 26,
|
|
||||||
mistral: 27,
|
|
||||||
avian: 28,
|
|
||||||
novita: 29,
|
|
||||||
azure: 30,
|
|
||||||
litellm: 31,
|
|
||||||
ollama: 32,
|
|
||||||
vllm: 33,
|
|
||||||
lmstudio: 34,
|
|
||||||
"claude-cli": 35,
|
|
||||||
"codex-cli": 36,
|
|
||||||
zai: 37,
|
|
||||||
mimo: 38,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderKey(provider?: string): string {
|
export function getProviderKey(provider?: string): string {
|
||||||
const normalized = provider?.trim().toLowerCase()
|
const normalized = provider?.trim().toLowerCase()
|
||||||
|
|
@ -105,44 +14,4 @@ export function getProviderLabel(provider?: string): string {
|
||||||
return PROVIDER_LABELS[prefix] ?? prefix
|
return PROVIDER_LABELS[prefix] ?? prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findProviderOption(
|
export { PROVIDER_LABELS, PROVIDER_ALIASES }
|
||||||
provider: string | undefined,
|
|
||||||
options: ModelProviderOption[],
|
|
||||||
): ModelProviderOption | undefined {
|
|
||||||
const providerKey = getProviderKey(provider)
|
|
||||||
return options.find((option) => option.id === providerKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderDefaultAPIBase(
|
|
||||||
provider: string | undefined,
|
|
||||||
options: ModelProviderOption[],
|
|
||||||
): string {
|
|
||||||
return findProviderOption(provider, options)?.default_api_base ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSortedProviderOptions(
|
|
||||||
options: ModelProviderOption[],
|
|
||||||
): ModelProviderOption[] {
|
|
||||||
return [...options].sort((a, b) => {
|
|
||||||
const aPriority = PROVIDER_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER
|
|
||||||
const bPriority = PROVIDER_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER
|
|
||||||
if (aPriority !== bPriority) {
|
|
||||||
return aPriority - bPriority
|
|
||||||
}
|
|
||||||
return getProviderLabel(a.id).localeCompare(getProviderLabel(b.id))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderDefaultAuthMethod(
|
|
||||||
provider: string | undefined,
|
|
||||||
options: ModelProviderOption[],
|
|
||||||
): string {
|
|
||||||
return findProviderOption(provider, options)?.default_auth_method ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isProviderAuthMethodLocked(
|
|
||||||
provider: string | undefined,
|
|
||||||
options: ModelProviderOption[],
|
|
||||||
): boolean {
|
|
||||||
return findProviderOption(provider, options)?.auth_method_locked === true
|
|
||||||
}
|
|
||||||
|
|
|
||||||
446
web/frontend/src/components/models/provider-registry.ts
Normal file
446
web/frontend/src/components/models/provider-registry.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
/**
|
||||||
|
* Unified provider registry — single source of truth for all provider metadata.
|
||||||
|
* All consumer files (provider-label, provider-icon, models-page, add/edit sheets)
|
||||||
|
* should derive their data from this registry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProviderDefinition {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
labelZh?: string
|
||||||
|
iconSlug?: string
|
||||||
|
domain?: string
|
||||||
|
defaultApiBase?: string
|
||||||
|
requiresApiKey: boolean
|
||||||
|
isLocal: boolean
|
||||||
|
priority: number
|
||||||
|
commonModels?: string[]
|
||||||
|
aliases?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVIDERS: ProviderDefinition[] = [
|
||||||
|
{
|
||||||
|
key: 'openai',
|
||||||
|
label: 'OpenAI',
|
||||||
|
iconSlug: 'openai',
|
||||||
|
domain: 'openai.com',
|
||||||
|
defaultApiBase: 'https://api.openai.com/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 100,
|
||||||
|
commonModels: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1', 'o3-mini'],
|
||||||
|
aliases: ['gpt'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'anthropic',
|
||||||
|
label: 'Anthropic',
|
||||||
|
iconSlug: 'anthropic',
|
||||||
|
domain: 'anthropic.com',
|
||||||
|
defaultApiBase: 'https://api.anthropic.com/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 95,
|
||||||
|
commonModels: [
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
'claude-haiku-4-20250414',
|
||||||
|
'claude-3-5-sonnet-20241022',
|
||||||
|
],
|
||||||
|
aliases: ['claude'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gemini',
|
||||||
|
label: 'Google Gemini',
|
||||||
|
iconSlug: 'googlegemini',
|
||||||
|
domain: 'gemini.google.com',
|
||||||
|
defaultApiBase: 'https://generativelanguage.googleapis.com/v1beta',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 90,
|
||||||
|
commonModels: ['gemini-2.0-flash', 'gemini-2.5-pro', 'gemini-1.5-flash'],
|
||||||
|
aliases: ['google'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deepseek',
|
||||||
|
label: 'DeepSeek',
|
||||||
|
iconSlug: 'deepseek',
|
||||||
|
domain: 'deepseek.com',
|
||||||
|
defaultApiBase: 'https://api.deepseek.com/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 85,
|
||||||
|
commonModels: ['deepseek-chat', 'deepseek-reasoner'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'openrouter',
|
||||||
|
label: 'OpenRouter',
|
||||||
|
iconSlug: 'openrouter',
|
||||||
|
domain: 'openrouter.ai',
|
||||||
|
defaultApiBase: 'https://openrouter.ai/api/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 80,
|
||||||
|
commonModels: [
|
||||||
|
'openai/gpt-4o',
|
||||||
|
'anthropic/claude-sonnet-4',
|
||||||
|
'google/gemini-2.0-flash',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qwen-portal',
|
||||||
|
label: 'Qwen',
|
||||||
|
labelZh: 'Qwen (阿里云)',
|
||||||
|
iconSlug: 'alibabacloud',
|
||||||
|
domain: 'qwenlm.ai',
|
||||||
|
defaultApiBase:
|
||||||
|
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 75,
|
||||||
|
commonModels: ['qwen-max', 'qwen-plus', 'qwen-turbo'],
|
||||||
|
aliases: ['qwen'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qwen-intl',
|
||||||
|
label: 'Qwen International',
|
||||||
|
iconSlug: 'alibabacloud',
|
||||||
|
domain: 'alibabacloud.com',
|
||||||
|
defaultApiBase:
|
||||||
|
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 74,
|
||||||
|
commonModels: ['qwen-max', 'qwen-plus', 'qwen-turbo'],
|
||||||
|
aliases: ['qwen-international', 'dashscope-intl'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'moonshot',
|
||||||
|
label: 'Moonshot',
|
||||||
|
labelZh: 'Moonshot (月之暗面)',
|
||||||
|
domain: 'moonshot.ai',
|
||||||
|
defaultApiBase: 'https://api.moonshot.cn/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 70,
|
||||||
|
commonModels: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'volcengine',
|
||||||
|
label: 'Volcengine',
|
||||||
|
labelZh: 'Volcengine (火山引擎)',
|
||||||
|
iconSlug: 'bytedance',
|
||||||
|
domain: 'volcengine.com',
|
||||||
|
defaultApiBase:
|
||||||
|
'https://ark.cn-beijing.volces.com/api/v3',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 69,
|
||||||
|
commonModels: ['doubao-1.5-pro', 'doubao-1.5-lite'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'zhipu',
|
||||||
|
label: 'Zhipu AI',
|
||||||
|
labelZh: 'Zhipu AI (智谱)',
|
||||||
|
iconSlug: 'zhipu',
|
||||||
|
domain: 'zhipuai.cn',
|
||||||
|
defaultApiBase:
|
||||||
|
'https://open.bigmodel.cn/api/paas/v4',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 68,
|
||||||
|
commonModels: ['glm-4-plus', 'glm-4-flash'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'groq',
|
||||||
|
label: 'Groq',
|
||||||
|
iconSlug: 'groq',
|
||||||
|
domain: 'groq.com',
|
||||||
|
defaultApiBase: 'https://api.groq.com/openai/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 65,
|
||||||
|
commonModels: [
|
||||||
|
'llama-3.3-70b-versatile',
|
||||||
|
'mixtral-8x7b-32768',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mistral',
|
||||||
|
label: 'Mistral AI',
|
||||||
|
iconSlug: 'mistralai',
|
||||||
|
domain: 'mistral.ai',
|
||||||
|
defaultApiBase: 'https://api.mistral.ai/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 64,
|
||||||
|
commonModels: ['mistral-large-latest', 'mistral-small-latest'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nvidia',
|
||||||
|
label: 'NVIDIA',
|
||||||
|
iconSlug: 'nvidia',
|
||||||
|
domain: 'nvidia.com',
|
||||||
|
defaultApiBase:
|
||||||
|
'https://integrate.api.nvidia.com/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 63,
|
||||||
|
commonModels: ['meta/llama-3.1-405b-instruct'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cerebras',
|
||||||
|
label: 'Cerebras',
|
||||||
|
iconSlug: 'cerebras',
|
||||||
|
domain: 'cerebras.ai',
|
||||||
|
defaultApiBase: 'https://api.cerebras.ai/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 62,
|
||||||
|
commonModels: ['llama3.1-8b', 'llama3.1-70b'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'azure',
|
||||||
|
label: 'Azure OpenAI',
|
||||||
|
iconSlug: 'microsoftazure',
|
||||||
|
domain: 'azure.com',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 61,
|
||||||
|
commonModels: ['gpt-4o', 'gpt-4o-mini'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'github-copilot',
|
||||||
|
label: 'GitHub Copilot',
|
||||||
|
iconSlug: 'githubcopilot',
|
||||||
|
domain: 'github.com',
|
||||||
|
requiresApiKey: false,
|
||||||
|
isLocal: true,
|
||||||
|
priority: 55,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'antigravity',
|
||||||
|
label: 'Google Code Assist',
|
||||||
|
domain: 'antigravity.google',
|
||||||
|
requiresApiKey: false,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 54,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ollama',
|
||||||
|
label: 'Ollama',
|
||||||
|
labelZh: 'Ollama (本地)',
|
||||||
|
iconSlug: 'ollama',
|
||||||
|
domain: 'ollama.com',
|
||||||
|
defaultApiBase: 'http://localhost:11434/v1',
|
||||||
|
requiresApiKey: false,
|
||||||
|
isLocal: true,
|
||||||
|
priority: 50,
|
||||||
|
commonModels: ['llama3', 'mistral', 'codellama', 'qwen2.5'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vllm',
|
||||||
|
label: 'VLLM',
|
||||||
|
labelZh: 'VLLM (本地)',
|
||||||
|
domain: 'vllm.ai',
|
||||||
|
defaultApiBase: 'http://localhost:8000/v1',
|
||||||
|
requiresApiKey: false,
|
||||||
|
isLocal: true,
|
||||||
|
priority: 49,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lmstudio',
|
||||||
|
label: 'LM Studio',
|
||||||
|
labelZh: 'LM Studio (本地)',
|
||||||
|
domain: 'lmstudio.ai',
|
||||||
|
defaultApiBase: 'http://localhost:1234/v1',
|
||||||
|
requiresApiKey: false,
|
||||||
|
isLocal: true,
|
||||||
|
priority: 48,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'venice',
|
||||||
|
label: 'Venice AI',
|
||||||
|
iconSlug: 'venice',
|
||||||
|
domain: 'venice.ai',
|
||||||
|
defaultApiBase: 'https://api.venice.ai/api/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shengsuanyun',
|
||||||
|
label: 'ShengsuanYun',
|
||||||
|
labelZh: 'ShengsuanYun (神算云)',
|
||||||
|
domain: 'shengsuanyun.com',
|
||||||
|
defaultApiBase:
|
||||||
|
'https://router.shengsuanyun.com/api/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 44,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vivgrid',
|
||||||
|
label: 'Vivgrid',
|
||||||
|
domain: 'vivgrid.com',
|
||||||
|
defaultApiBase: 'https://api.vivgrid.com/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 43,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minimax',
|
||||||
|
label: 'MiniMax',
|
||||||
|
domain: 'minimaxi.com',
|
||||||
|
defaultApiBase: 'https://api.minimaxi.com/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'longcat',
|
||||||
|
label: 'LongCat',
|
||||||
|
domain: 'longcat.chat',
|
||||||
|
defaultApiBase: 'https://api.longcat.chat/openai',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 41,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'modelscope',
|
||||||
|
label: 'ModelScope',
|
||||||
|
labelZh: 'ModelScope (魔搭社区)',
|
||||||
|
domain: 'modelscope.cn',
|
||||||
|
defaultApiBase:
|
||||||
|
'https://api-inference.modelscope.cn/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mimo',
|
||||||
|
label: 'Xiaomi MiMo',
|
||||||
|
iconSlug: 'xiaomi',
|
||||||
|
domain: 'xiaomi.com',
|
||||||
|
defaultApiBase: 'https://api.xiaomimimo.com/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 39,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avian',
|
||||||
|
label: 'Avian',
|
||||||
|
domain: 'avian.io',
|
||||||
|
defaultApiBase: 'https://api.avian.io/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 38,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'zai',
|
||||||
|
label: 'Z.ai',
|
||||||
|
domain: 'z.ai',
|
||||||
|
defaultApiBase: 'https://api.z.ai/api/coding/paas/v4',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 37,
|
||||||
|
aliases: ['z.ai', 'z-ai'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'novita',
|
||||||
|
label: 'Novita AI',
|
||||||
|
domain: 'novita.ai',
|
||||||
|
defaultApiBase: 'https://api.novita.ai/openai',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 36,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'litellm',
|
||||||
|
label: 'LiteLLM',
|
||||||
|
domain: 'litellm.ai',
|
||||||
|
defaultApiBase: 'http://localhost:4000/v1',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isLocal: false,
|
||||||
|
priority: 35,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Derived data for consumers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PROVIDER_MAP = new Map(PROVIDERS.map((p) => [p.key, p]))
|
||||||
|
|
||||||
|
export const PROVIDER_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
PROVIDERS.map((p) => [p.key, p.labelZh || p.label]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PROVIDER_ALIASES: Record<string, string> = Object.fromEntries(
|
||||||
|
PROVIDERS.flatMap((p) =>
|
||||||
|
(p.aliases || []).map((a) => [a, p.key]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const KNOWN_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key))
|
||||||
|
|
||||||
|
export const PROVIDER_ICON_SLUGS: Record<string, string> = Object.fromEntries(
|
||||||
|
PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PROVIDER_DOMAINS: Record<string, string> = Object.fromEntries(
|
||||||
|
PROVIDERS.filter((p) => p.domain).map((p) => [p.key, p.domain!]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PROVIDER_PRIORITY: Record<string, number> = Object.fromEntries(
|
||||||
|
PROVIDERS.map((p) => [p.key, p.priority]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PROVIDER_API_BASES: Record<string, string> = Object.fromEntries(
|
||||||
|
PROVIDERS.filter((p) => p.defaultApiBase).map((p) => [
|
||||||
|
p.key,
|
||||||
|
p.defaultApiBase!,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the closest known provider key by edit distance.
|
||||||
|
* Returns the key if distance <= 2, otherwise undefined.
|
||||||
|
*/
|
||||||
|
export function findClosestProvider(input: string): string | undefined {
|
||||||
|
const lower = input.toLowerCase()
|
||||||
|
let best: string | undefined
|
||||||
|
let bestDist = 3 // only accept distance <= 2
|
||||||
|
|
||||||
|
for (const key of KNOWN_PROVIDER_KEYS) {
|
||||||
|
const dist = editDistance(lower, key)
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist
|
||||||
|
best = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check aliases
|
||||||
|
for (const alias of Object.keys(PROVIDER_ALIASES)) {
|
||||||
|
const dist = editDistance(lower, alias)
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist
|
||||||
|
best = PROVIDER_ALIASES[alias]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
function editDistance(a: string, b: string): number {
|
||||||
|
const m = a.length
|
||||||
|
const n = b.length
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||||
|
new Array(n + 1).fill(0),
|
||||||
|
)
|
||||||
|
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||||
|
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
dp[i][j] =
|
||||||
|
a[i - 1] === b[j - 1]
|
||||||
|
? dp[i - 1][j - 1]
|
||||||
|
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dp[m][n]
|
||||||
|
}
|
||||||
|
|
@ -57,7 +57,7 @@ export function ProviderSection({
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<ModelCard
|
<ModelCard
|
||||||
key={model.index}
|
key={model.model_name}
|
||||||
model={model}
|
model={model}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onSetDefault={onSetDefault}
|
onSetDefault={onSetDefault}
|
||||||
|
|
|
||||||
152
web/frontend/src/components/models/test-model-dialog.tsx
Normal file
152
web/frontend/src/components/models/test-model-dialog.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { IconLoader2, IconPlugConnected, IconX } from "@tabler/icons-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { type ModelInfo, testModel } from "@/api/models"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
interface TestModelDialogProps {
|
||||||
|
model: ModelInfo | null
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
success: boolean
|
||||||
|
latency_ms: number
|
||||||
|
status: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestModelDialog({
|
||||||
|
model,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: TestModelDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
const [result, setResult] = useState<TestResult | null>(null)
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!model) return
|
||||||
|
setTesting(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const res = await testModel(model.index)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<IconPlugConnected className="size-5" />
|
||||||
|
{t("models.test.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("models.test.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{model && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("models.test.modelLabel")} </span>
|
||||||
|
<span className="font-mono">{model.model_name}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("models.test.identifierLabel")} </span>
|
||||||
|
<span className="font-mono">{model.model}</span>
|
||||||
|
</div>
|
||||||
|
{model.api_base && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("models.test.endpointLabel")} </span>
|
||||||
|
<span className="font-mono text-xs">{model.api_base}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result && !testing && (
|
||||||
|
<Button onClick={handleTest} className="w-full">
|
||||||
|
<IconPlugConnected className="size-4" />
|
||||||
|
{t("models.test.testConnection")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testing && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground">
|
||||||
|
<IconLoader2 className="size-5 animate-spin" />
|
||||||
|
<span>{t("models.test.testing")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-4 text-sm ${
|
||||||
|
result.success
|
||||||
|
? "bg-green-500/10 text-green-700 dark:text-green-400"
|
||||||
|
: "bg-destructive/10 text-destructive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.success ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium">{t("models.test.success")}</div>
|
||||||
|
<div className="text-xs opacity-80">
|
||||||
|
{t("models.test.responseTime", { ms: result.latency_ms })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium flex items-center gap-1">
|
||||||
|
<IconX className="size-4" />
|
||||||
|
{t("models.test.failed")}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-80">
|
||||||
|
{result.error || t("models.test.status", { status: result.status })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={handleClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
{result && (
|
||||||
|
<Button variant="outline" onClick={handleTest}>
|
||||||
|
{t("models.test.testAgain")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
web/frontend/src/components/ui/command.tsx
Normal file
149
web/frontend/src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
const CommandDialog = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog>) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = "CommandShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
29
web/frontend/src/components/ui/popover.tsx
Normal file
29
web/frontend/src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
|
|
@ -129,10 +129,12 @@
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"close": "Close",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
"fix": "Fix",
|
||||||
"saveChangesTitle": "You have unsaved configuration changes",
|
"saveChangesTitle": "You have unsaved configuration changes",
|
||||||
"restartRequiredTitle": "Gateway restart required",
|
"restartRequiredTitle": "Gateway restart required",
|
||||||
"restartRequiredDesc": "The latest {{name}} configuration has been saved. Restart the gateway for it to take effect."
|
"restartRequiredDesc": "The latest {{name}} configuration has been saved. Restart the gateway for it to take effect."
|
||||||
|
|
@ -236,8 +238,7 @@
|
||||||
"setting": "Setting as default...",
|
"setting": "Setting as default...",
|
||||||
"unavailable": "Cannot set unavailable model as default",
|
"unavailable": "Cannot set unavailable model as default",
|
||||||
"isDefault": "Already the default model",
|
"isDefault": "Already the default model",
|
||||||
"isVirtual": "Cannot set virtual model as default",
|
"isVirtual": "Cannot set virtual model as default"
|
||||||
"unsupportedProvider": "This provider is ASR-only and cannot be the default chat model"
|
|
||||||
},
|
},
|
||||||
"deleteDisabled": {
|
"deleteDisabled": {
|
||||||
"isDefault": "Cannot delete the default model"
|
"isDefault": "Cannot delete the default model"
|
||||||
|
|
@ -245,9 +246,7 @@
|
||||||
},
|
},
|
||||||
"defaultOnSave": {
|
"defaultOnSave": {
|
||||||
"label": "Default Model",
|
"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 model_list, but it cannot be used as the default chat model.",
|
|
||||||
"clearOnSave": "Saving this ASR-only model will clear the current default chat model selection."
|
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"button": "Add Model",
|
"button": "Add Model",
|
||||||
|
|
@ -258,7 +257,7 @@
|
||||||
"modelNameHint": "A short name used to identify this model in conversations.",
|
"modelNameHint": "A short name used to identify this model in conversations.",
|
||||||
"modelId": "Model Identifier",
|
"modelId": "Model Identifier",
|
||||||
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
|
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
|
||||||
"modelIdHint": "This field is sent as the canonical model ID for the selected Provider. If the model ID itself contains slashes, such as openai/gpt-5.4, it is preserved as-is instead of being split again.",
|
"modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.",
|
||||||
"errorRequired": "This field is required.",
|
"errorRequired": "This field is required.",
|
||||||
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||||
"saveError": "Failed to add model",
|
"saveError": "Failed to add model",
|
||||||
|
|
@ -275,9 +274,9 @@
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"providerPlaceholder": "Select a provider",
|
"providerPlaceholder": "e.g. openai",
|
||||||
"providerHint": "Choose a Provider from the backend catalog. The Model Identifier field is interpreted as that Provider's canonical model ID.",
|
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
|
||||||
"providerInvalid": "The current Provider is invalid. Select a supported Provider.",
|
"selectProviderFirst": "Select a provider first",
|
||||||
"apiBase": "API Base URL",
|
"apiBase": "API Base URL",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
"apiKeyPlaceholder": "Enter your API key",
|
"apiKeyPlaceholder": "Enter your API key",
|
||||||
|
|
@ -286,7 +285,6 @@
|
||||||
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
||||||
"authMethod": "Auth Method",
|
"authMethod": "Auth Method",
|
||||||
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
|
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
|
||||||
"authMethodManagedHint": "This Provider manages its authentication mode automatically.",
|
|
||||||
"connectMode": "Connect Mode",
|
"connectMode": "Connect Mode",
|
||||||
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
|
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
|
||||||
"workspace": "Workspace Path",
|
"workspace": "Workspace Path",
|
||||||
|
|
@ -304,7 +302,8 @@
|
||||||
"extraBody": "Extra Body",
|
"extraBody": "Extra Body",
|
||||||
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}.",
|
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}.",
|
||||||
"customHeaders": "Custom Headers",
|
"customHeaders": "Custom Headers",
|
||||||
"customHeadersHint": "Additional HTTP headers to inject into every request, e.g. {\"X-Source\": \"coding-plan\"}."
|
"customHeadersHint": "Additional HTTP headers to inject into every request, e.g. {\"X-Source\": \"coding-plan\"}.",
|
||||||
|
"invalidJson": "Invalid JSON format"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Configure {{name}}",
|
"title": "Configure {{name}}",
|
||||||
|
|
@ -312,6 +311,76 @@
|
||||||
"oauthNote": "This provider uses OAuth — no API key required.",
|
"oauthNote": "This provider uses OAuth — no API key required.",
|
||||||
"saveError": "Failed to save",
|
"saveError": "Failed to save",
|
||||||
"saveSuccess": "Model configuration saved."
|
"saveSuccess": "Model configuration saved."
|
||||||
|
},
|
||||||
|
"fetch": {
|
||||||
|
"title": "Fetch Available Models",
|
||||||
|
"description": "Fetch model list from the upstream provider.",
|
||||||
|
"providerLabel": "Provider:",
|
||||||
|
"needApiKey": "Please enter an API Key first to fetch models.",
|
||||||
|
"fetching": "Fetching models...",
|
||||||
|
"retry": "Retry",
|
||||||
|
"filterPlaceholder": "Filter models...",
|
||||||
|
"found": "Found {{count}} model",
|
||||||
|
"found_plural": "Found {{count}} models",
|
||||||
|
"shown": "({{count}} shown)",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"fill": "Fill {{count}} Selected Model",
|
||||||
|
"fill_plural": "Fill {{count}} Selected Models",
|
||||||
|
"failed": "Failed to fetch models"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"button": "Saved Catalogs",
|
||||||
|
"title": "Saved Model Catalogs",
|
||||||
|
"description": "Previously fetched model lists, stored per API key. Select models to add to your configuration.",
|
||||||
|
"loading": "Loading catalogs...",
|
||||||
|
"empty": "No saved catalogs yet. Fetch models from a provider to save a catalog.",
|
||||||
|
"filterPlaceholder": "Filter models...",
|
||||||
|
"models": "models",
|
||||||
|
"fetchedAt": "Fetched",
|
||||||
|
"delete": "Delete catalog",
|
||||||
|
"refresh": "Refresh from upstream",
|
||||||
|
"found": "Found {{count}} model",
|
||||||
|
"found_plural": "Found {{count}} models",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"addSelected": "Add {{count}} Selected",
|
||||||
|
"addSuccess": "Added {{count}} model(s) to configuration."
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"title": "Test Model Connectivity",
|
||||||
|
"description": "Verify that the model endpoint is reachable and configured correctly.",
|
||||||
|
"modelLabel": "Model:",
|
||||||
|
"identifierLabel": "Identifier:",
|
||||||
|
"endpointLabel": "Endpoint:",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"testing": "Testing connection...",
|
||||||
|
"success": "Connection successful",
|
||||||
|
"responseTime": "Response time: {{ms}}ms",
|
||||||
|
"failed": "Connection failed",
|
||||||
|
"status": "Status: {{status}}",
|
||||||
|
"testFailed": "Test failed",
|
||||||
|
"testAgain": "Test Again"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"whitespace": "Model identifier cannot contain whitespace",
|
||||||
|
"leadingSlash": "Should not start with /",
|
||||||
|
"consecutiveSlash": "Should not contain consecutive /",
|
||||||
|
"useProvider": "Will use \"{{provider}}\" as provider",
|
||||||
|
"defaultToOpenAI": "No provider specified, defaults to OpenAI",
|
||||||
|
"emptyModel": "Model name cannot be empty",
|
||||||
|
"shouldUse": "\"{{provider}}\" should use \"{{alias}}\"",
|
||||||
|
"didYouMean": "Did you mean \"{{closest}}\"?",
|
||||||
|
"unknownProvider": "Unknown provider \"{{provider}}\"",
|
||||||
|
"parsed": "provider={{provider}}, model={{model}}"
|
||||||
|
},
|
||||||
|
"combobox": {
|
||||||
|
"selectProvider": "Select provider...",
|
||||||
|
"searchProvider": "Search provider...",
|
||||||
|
"noProvider": "No provider found.",
|
||||||
|
"local": "local",
|
||||||
|
"custom": "Custom provider...",
|
||||||
|
"customPlaceholder": "Enter provider name..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
|
||||||
|
|
@ -129,13 +129,15 @@
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
"close": "关闭",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"saveChangesTitle": "有未保存的配置更改",
|
"saveChangesTitle": "有未保存的配置更改",
|
||||||
"restartRequiredTitle": "需要重启服务",
|
"restartRequiredTitle": "需要重启服务",
|
||||||
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。"
|
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。",
|
||||||
|
"fix": "修复"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "加载中..."
|
"loading": "加载中..."
|
||||||
|
|
@ -304,7 +306,9 @@
|
||||||
"extraBody": "Extra Body",
|
"extraBody": "Extra Body",
|
||||||
"extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。",
|
"extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。",
|
||||||
"customHeaders": "Custom Headers",
|
"customHeaders": "Custom Headers",
|
||||||
"customHeadersHint": "要注入到每个请求中的额外 HTTP Headers,例如 {\"X-Source\": \"coding-plan\"}。"
|
"customHeadersHint": "要注入到每个请求中的额外 HTTP Headers,例如 {\"X-Source\": \"coding-plan\"}。",
|
||||||
|
"selectProviderFirst": "请先选择服务商",
|
||||||
|
"invalidJson": "JSON 格式不正确"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "配置 {{name}}",
|
"title": "配置 {{name}}",
|
||||||
|
|
@ -312,6 +316,73 @@
|
||||||
"oauthNote": "该服务商使用 OAuth 认证,无需 API Key。",
|
"oauthNote": "该服务商使用 OAuth 认证,无需 API Key。",
|
||||||
"saveError": "保存失败",
|
"saveError": "保存失败",
|
||||||
"saveSuccess": "模型配置已保存。"
|
"saveSuccess": "模型配置已保存。"
|
||||||
|
},
|
||||||
|
"fetch": {
|
||||||
|
"title": "获取可用模型",
|
||||||
|
"description": "从上游服务商获取模型列表。",
|
||||||
|
"providerLabel": "服务商:",
|
||||||
|
"needApiKey": "请先输入 API Key 再获取模型。",
|
||||||
|
"fetching": "正在获取模型...",
|
||||||
|
"retry": "重试",
|
||||||
|
"filterPlaceholder": "筛选模型...",
|
||||||
|
"found": "已找到 {{count}} 个模型",
|
||||||
|
"shown": "(显示 {{count}} 个)",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"deselectAll": "取消全选",
|
||||||
|
"fill": "填充 {{count}} 个选中的模型",
|
||||||
|
"failed": "获取模型失败"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"button": "已保存目录",
|
||||||
|
"title": "已保存的模型目录",
|
||||||
|
"description": "之前获取的模型列表,按 API Key 分别存储。选择模型以添加到配置中。",
|
||||||
|
"loading": "正在加载目录...",
|
||||||
|
"empty": "暂无已保存的模型目录。从服务商获取模型后将自动保存。",
|
||||||
|
"filterPlaceholder": "筛选模型...",
|
||||||
|
"models": "个模型",
|
||||||
|
"fetchedAt": "获取于",
|
||||||
|
"delete": "删除目录",
|
||||||
|
"refresh": "从上游刷新",
|
||||||
|
"found": "共 {{count}} 个模型",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"deselectAll": "取消全选",
|
||||||
|
"addSelected": "添加 {{count}} 个选中模型",
|
||||||
|
"addSuccess": "已添加 {{count}} 个模型到配置中。"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"title": "测试模型连通性",
|
||||||
|
"description": "验证模型端点是否可达且配置正确。",
|
||||||
|
"modelLabel": "模型:",
|
||||||
|
"identifierLabel": "标识符:",
|
||||||
|
"endpointLabel": "端点:",
|
||||||
|
"testConnection": "测试连接",
|
||||||
|
"testing": "正在测试连接...",
|
||||||
|
"success": "连接成功",
|
||||||
|
"responseTime": "响应时间:{{ms}}ms",
|
||||||
|
"failed": "连接失败",
|
||||||
|
"status": "状态:{{status}}",
|
||||||
|
"testFailed": "测试失败",
|
||||||
|
"testAgain": "重新测试"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"whitespace": "模型标识符不能包含空格",
|
||||||
|
"leadingSlash": "不应以 / 开头",
|
||||||
|
"consecutiveSlash": "不应包含连续的 /",
|
||||||
|
"useProvider": "将使用 \"{{provider}}\" 作为服务商",
|
||||||
|
"defaultToOpenAI": "未指定服务商,默认使用 OpenAI",
|
||||||
|
"emptyModel": "模型名称不能为空",
|
||||||
|
"shouldUse": "\"{{provider}}\" 应使用 \"{{alias}}\"",
|
||||||
|
"didYouMean": "您是否想输入 \"{{closest}}\"?",
|
||||||
|
"unknownProvider": "未知服务商 \"{{provider}}\"",
|
||||||
|
"parsed": "服务商={{provider}},模型={{model}}"
|
||||||
|
},
|
||||||
|
"combobox": {
|
||||||
|
"selectProvider": "选择服务商...",
|
||||||
|
"searchProvider": "搜索服务商...",
|
||||||
|
"noProvider": "未找到服务商。",
|
||||||
|
"local": "本地",
|
||||||
|
"custom": "自定义服务商...",
|
||||||
|
"customPlaceholder": "输入服务商名称..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -757,4 +828,4 @@
|
||||||
"description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
|
"description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue