diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.gitignore b/.gitignore
index 8f6ce17..f5b8d95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ backend/internal/managementasset/dist/
backend/.dev/
result
result-*
+.direnv
diff --git a/README.md b/README.md
index 9a76602..7bd6bd1 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,54 @@
# Vibe Proxy
+
+Vibe Proxy connects multiple OpenAI Codex OAuth accounts, displays their quotas, and exposes them through an OpenAI-compatible API.
+
+The management UI is available at `/management.html`. It supports adding and removing Codex accounts and refreshing their quota. Proxy API keys and server settings are deployment configuration, not UI settings.
+
+## Endpoints
+
+- `GET /v1/models`
+- `POST /v1/chat/completions`
+- `POST /v1/responses`
+- `GET /v1/responses` for WebSocket transport
+- `GET /healthz`
+
+## NixOS
+
+Add the module and package from the flake:
+
+```nix
+{
+ inputs.vibe-proxy.url = "github:methanium/vibe-proxy";
+
+ outputs = { nixpkgs, vibe-proxy, ... }: {
+ nixosConfigurations.host = nixpkgs.lib.nixosSystem {
+ modules = [
+ vibe-proxy.nixosModules.default
+ ({ ... }: {
+ services.vibe-proxy = {
+ enable = true;
+ host = "127.0.0.1";
+ port = 8317;
+ settings.api-keys = [ "replace-with-a-random-api-key" ];
+ environmentFiles = [ "/run/secrets/vibe-proxy" ];
+ };
+ })
+ ];
+ };
+ };
+}
+```
+
+The environment file should contain:
+
+```sh
+MANAGEMENT_PASSWORD=replace-with-a-random-management-key
+```
+
+The module stores OAuth credentials under `/var/lib/vibe-proxy/auths` by default. Set `openFirewall = true` only when the service should be reachable directly from other machines.
+
+Build or run the combined package with `nix build` or `nix run`.
+
+## Other deployments
+
+Copy `backend/config.example.yaml` to `config.yaml`, replace the example API key, and set `MANAGEMENT_PASSWORD`. Docker Compose and the standalone binary both use local JSON credential storage.
diff --git a/backend/Dockerfile b/backend/Dockerfile
index df0650b..de8df1a 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -19,8 +19,6 @@ FROM golang:1.26-bookworm AS builder
WORKDIR /app/backend
-RUN apt-get update && apt-get install -y --no-install-recommends build-essential git && rm -rf /var/lib/apt/lists/*
-
COPY backend/go.mod backend/go.sum ./
RUN go mod download
@@ -32,24 +30,20 @@ ARG VERSION=dev
ARG COMMIT=none
ARG BUILD_DATE=unknown
-RUN CGO_ENABLED=1 GOOS=linux go build -tags frontend -buildvcs=false -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
+RUN CGO_ENABLED=0 GOOS=linux go build -tags frontend -buildvcs=false -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./vibe-proxy ./cmd/server/
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends tzdata ca-certificates && rm -rf /var/lib/apt/lists/*
-RUN mkdir /CLIProxyAPI
+RUN mkdir /app
-COPY --from=builder /app/backend/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
+COPY --from=builder /app/backend/vibe-proxy /app/vibe-proxy
-COPY backend/config.example.yaml /CLIProxyAPI/config.example.yaml
+COPY backend/config.example.yaml /app/config.example.yaml
-WORKDIR /CLIProxyAPI
+WORKDIR /app
EXPOSE 8317
-ENV TZ=Asia/Shanghai
-
-RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
-
-CMD ["./CLIProxyAPI"]
+CMD ["./vibe-proxy", "--config", "/app/config.yaml"]
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 871e69c..ffce324 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -1,6 +1,3 @@
-// Package main provides the entry point for the CLI Proxy API server.
-// This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces
-// for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.
package main
import (
@@ -8,38 +5,54 @@ import (
"errors"
"flag"
"fmt"
- "io"
- "io/fs"
- "net/url"
"os"
"path/filepath"
"strings"
- "time"
- "github.com/joho/godotenv"
- configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cmd"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/safemode"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/store"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/tui"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
- sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
log "github.com/sirupsen/logrus"
)
+type codexStore struct {
+ fileStore *sdkAuth.FileTokenStore
+}
+
+func (s *codexStore) SetBaseDir(dir string) {
+ s.fileStore.SetBaseDir(dir)
+}
+
+func (s *codexStore) List(ctx context.Context) ([]*coreauth.Auth, error) {
+ auths, errList := s.fileStore.List(ctx)
+ if errList != nil {
+ return nil, errList
+ }
+ codexAuths := make([]*coreauth.Auth, 0, len(auths))
+ for _, auth := range auths {
+ if auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") && auth.AuthKind() == "oauth" {
+ codexAuths = append(codexAuths, auth)
+ }
+ }
+ return codexAuths, nil
+}
+
+func (s *codexStore) Save(ctx context.Context, auth *coreauth.Auth) (string, error) {
+ if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") || auth.AuthKind() != "oauth" {
+ return "", errors.New("only Codex OAuth credentials are supported")
+ }
+ return s.fileStore.Save(ctx, auth)
+}
+
+func (s *codexStore) Delete(ctx context.Context, id string) error {
+ return s.fileStore.Delete(ctx, id)
+}
+
var (
Version = "dev"
Commit = "none"
@@ -47,7 +60,6 @@ var (
DefaultConfigPath = ""
)
-// init initializes the shared logger setup.
func init() {
logging.SetupBaseLogger()
buildinfo.Version = Version
@@ -55,778 +67,54 @@ func init() {
buildinfo.BuildDate = BuildDate
}
-func shouldEnableExampleAPIKeySafeMode(cfg *config.Config, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode bool) bool {
- if cfg == nil || commandMode || homeMode || cloudConfigMissing {
- return false
- }
- if tuiMode && !standalone {
- return false
- }
- return safemode.HasExampleAPIKeys(cfg.APIKeys)
-}
-
-// main is the entry point of the application.
-// It parses command-line flags, loads configuration, and starts the appropriate
-// service based on the provided flags (login, codex-login, or server mode).
func main() {
- fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
-
- // Command-line flags to control the application's behavior.
- var codexLogin bool
- var codexDeviceLogin bool
- var claudeLogin bool
- var noBrowser bool
- var oauthCallbackPort int
- var antigravityLogin bool
- var kimiLogin bool
- var xaiLogin bool
- var vertexImport string
- var vertexImportPrefix string
var configPath string
- var password string
- var homeJWT string
- var homeDisableClusterDiscovery bool
- var tuiMode bool
- var standalone bool
- var localModel bool
-
- // Define command-line flags for different operation modes.
- flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
- flag.BoolVar(&codexDeviceLogin, "codex-device-login", false, "Login to Codex using device code flow")
- flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
- flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
- flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
- flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
- flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth")
- flag.BoolVar(&xaiLogin, "xai-login", false, "Login to xAI using OAuth")
- flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
- flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
- flag.StringVar(&vertexImportPrefix, "vertex-import-prefix", "", "Prefix for Vertex model namespacing (use with -vertex-import)")
- flag.StringVar(&password, "password", "", "")
- flag.StringVar(&homeJWT, "home-jwt", "", "Home control plane JWT for mTLS certificate bootstrap and connection")
- flag.BoolVar(&homeDisableClusterDiscovery, "home-disable-cluster-discovery", false, "Disable Home CLUSTER NODES discovery and keep using the configured -home-jwt address")
- flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI")
- flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server")
- flag.BoolVar(&localModel, "local-model", false, "Use embedded models.json and codex_client_models.json only, skip remote model catalog fetching")
-
- flag.CommandLine.Usage = func() {
- out := flag.CommandLine.Output()
- _, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
- flag.CommandLine.VisitAll(func(f *flag.Flag) {
- if f.Name == "password" {
- return
- }
- s := fmt.Sprintf(" -%s", f.Name)
- name, unquoteUsage := flag.UnquoteUsage(f)
- if name != "" {
- s += " " + name
- }
- if len(s) <= 4 {
- s += " "
- } else {
- s += "\n "
- }
- if unquoteUsage != "" {
- s += unquoteUsage
- }
- if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
- s += fmt.Sprintf(" (default %s)", f.DefValue)
- }
- _, _ = fmt.Fprint(out, s+"\n")
- })
- }
-
- pluginHost := pluginhost.New()
- if bootstrapCfg := loadPluginBootstrapConfig(pluginBootstrapConfigPath(os.Args[1:], DefaultConfigPath)); bootstrapCfg != nil {
- pluginHost.ApplyConfig(context.Background(), bootstrapCfg)
- pluginHost.RegisterCommandLineFlags(context.Background(), flag.CommandLine)
- }
-
- // Parse the command-line flags.
+ flag.StringVar(&configPath, "config", DefaultConfigPath, "configuration file path")
flag.Parse()
- // Core application variables.
- var err error
- var cfg *config.Config
- var isCloudDeploy bool
- var configLoadedFromHome bool
- var homeClient *home.Client
- var homePluginSyncReport homeplugins.SyncReport
- var homePluginStatusReady bool
- var (
- usePostgresStore bool
- pgStoreDSN string
- pgStoreSchema string
- pgStoreLocalPath string
- pgStoreInst *store.PostgresStore
- useGitStore bool
- gitStoreRemoteURL string
- gitStoreUser string
- gitStorePassword string
- gitStoreBranch string
- gitStoreLocalPath string
- gitStoreInst *store.GitTokenStore
- gitStoreRoot string
- useObjectStore bool
- objectStoreEndpoint string
- objectStoreAccess string
- objectStoreSecret string
- objectStoreBucket string
- objectStoreLocalPath string
- objectStoreInst *store.ObjectTokenStore
- )
+ if strings.TrimSpace(configPath) == "" {
+ workingDirectory, errWorkingDirectory := os.Getwd()
+ if errWorkingDirectory != nil {
+ log.WithError(errWorkingDirectory).Error("failed to get working directory")
+ return
+ }
+ configPath = filepath.Join(workingDirectory, "config.yaml")
+ }
- wd, err := os.Getwd()
- if err != nil {
- log.Errorf("failed to get working directory: %v", err)
+ cfg, errLoadConfig := config.LoadConfig(configPath)
+ if errLoadConfig != nil {
+ log.WithError(errLoadConfig).Error("failed to load configuration")
return
}
-
- // Load environment variables from .env if present.
- if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
- if !errors.Is(errLoad, os.ErrNotExist) {
- log.WithError(errLoad).Warn("failed to load .env file")
- }
- }
-
- lookupEnv := func(keys ...string) (string, bool) {
- for _, key := range keys {
- if value, ok := os.LookupEnv(key); ok {
- if trimmed := strings.TrimSpace(value); trimmed != "" {
- return trimmed, true
- }
- }
- }
- return "", false
- }
- writableBase := util.WritablePath()
-
- if strings.TrimSpace(homeJWT) == "" {
- if v, ok := lookupEnv("HOME_JWT", "home_jwt"); ok {
- homeJWT = v
- }
- }
-
- if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
- usePostgresStore = true
- pgStoreDSN = value
- }
- if usePostgresStore {
- if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
- pgStoreSchema = value
- }
- if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
- pgStoreLocalPath = value
- }
- if pgStoreLocalPath == "" {
- if writableBase != "" {
- pgStoreLocalPath = writableBase
- } else {
- pgStoreLocalPath = wd
- }
- }
- useGitStore = false
- }
- if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
- useGitStore = true
- gitStoreRemoteURL = value
- }
- if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
- gitStoreUser = value
- }
- if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
- gitStorePassword = value
- }
- if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
- gitStoreLocalPath = value
- }
- if value, ok := lookupEnv("GITSTORE_GIT_BRANCH", "gitstore_git_branch"); ok {
- gitStoreBranch = value
- }
- if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
- useObjectStore = true
- objectStoreEndpoint = value
- }
- if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
- objectStoreAccess = value
- }
- if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
- objectStoreSecret = value
- }
- if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
- objectStoreBucket = value
- }
- if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
- objectStoreLocalPath = value
- }
-
- // Check for cloud deploy mode only on first execution
- // Read env var name in uppercase: DEPLOY
- deployEnv := os.Getenv("DEPLOY")
- if deployEnv == "cloud" {
- isCloudDeploy = true
- }
-
- // Determine and load the configuration file.
- // Prefer the Postgres store when configured, otherwise fallback to git or local files.
- var configFilePath string
- if strings.TrimSpace(homeJWT) != "" {
- configLoadedFromHome = true
- ctxHome, cancelHome := context.WithTimeout(context.Background(), 30*time.Second)
- homeCfg, errHomeCfg := home.ConfigFromJWT(ctxHome, homeJWT)
- cancelHome()
- if errHomeCfg != nil {
- log.Errorf("invalid -home-jwt: %v", errHomeCfg)
- return
- }
- if homeDisableClusterDiscovery {
- homeCfg.DisableClusterDiscovery = true
- }
- homeClient = home.New(homeCfg)
- defer func() {
- if homeClient != nil {
- homeClient.Close()
- }
- }()
-
- ctxHomeConfig, cancelHomeConfig := context.WithTimeout(context.Background(), 30*time.Second)
- raw, errGetConfig := homeClient.GetConfig(ctxHomeConfig)
- cancelHomeConfig()
- if errGetConfig != nil {
- log.Errorf("failed to fetch config from home: %v", errGetConfig)
- return
- }
-
- parsed, errParseConfig := config.ParseConfigBytes(raw)
- if errParseConfig != nil {
- log.Errorf("failed to parse config payload from home: %v", errParseConfig)
- return
- }
- if parsed == nil {
- parsed = &config.Config{}
- }
- parsed.Home = homeCfg
- parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config
- parsed.UsageStatisticsEnabled = true
- pluginSyncCfg := *parsed
- parsed.Plugins.StoreAuth = nil
- var errHomePlugins error
- platform := homeplugins.CurrentPlatform()
- if pluginSyncCfg.Plugins.Enabled {
- ctxHomePlugins, cancelHomePlugins := context.WithTimeout(context.Background(), 30*time.Second)
- installedVersions, errInstalledPlugins := homeplugins.InstalledVersions(&pluginSyncCfg)
- if errInstalledPlugins != nil {
- homePluginStatusReady = true
- errHomePlugins = errInstalledPlugins
- homePluginSyncReport = homeplugins.CompletedSyncReport(platform, errInstalledPlugins)
- } else {
- pluginSyncRequest := sdkpluginstore.PluginSyncRequest{
- SchemaVersion: sdkpluginstore.PluginSyncSchemaVersion,
- GOOS: platform.GOOS,
- GOARCH: platform.GOARCH,
- InstalledVersions: installedVersions,
- }
- pluginSyncResponse, errFetchPlugins := homeClient.GetPluginSync(ctxHomePlugins, pluginSyncRequest)
- errHomePlugins = errFetchPlugins
- switch {
- case errHomePlugins == nil:
- homePluginStatusReady = true
- homePluginSyncReport, errHomePlugins = homeplugins.SyncResolvedWithReport(ctxHomePlugins, &pluginSyncCfg, pluginSyncResponse.Items, pluginSyncResponse.ExpiresAt, pluginSyncRequest.InstalledVersions, pluginHost)
- case errors.Is(errHomePlugins, home.ErrPluginSyncUnsupported):
- homePluginStatusReady = true
- homePluginSyncReport, errHomePlugins = homeplugins.SyncWithReport(ctxHomePlugins, &pluginSyncCfg, pluginHost)
- default:
- homePluginStatusReady = true
- homePluginSyncReport = homeplugins.CompletedSyncReport(platform, errHomePlugins)
- }
- pluginSyncRequest.Clear()
- pluginSyncResponse.Clear()
- }
- cancelHomePlugins()
- } else {
- homePluginStatusReady = true
- homePluginSyncReport = homeplugins.CompletedSyncReport(platform, nil)
- }
- if errHomePlugins != nil {
- log.Errorf("failed to sync plugins from home: %v", errHomePlugins)
- }
- if homePluginStatusReady {
- errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, homeCfg.NodeID, homePluginSyncReport)
- if errReportPlugins != nil {
- log.Warnf("failed to report home plugin sync status: %v", errReportPlugins)
- }
- }
- if errHomePlugins != nil {
- return
- }
- cfg = parsed
-
- // Keep a non-empty config path for downstream components (log paths, management assets, etc),
- // but do not require the file to exist when loading config from home.
- if strings.TrimSpace(configPath) != "" {
- configFilePath = configPath
- } else {
- configFilePath = filepath.Join(wd, "config.yaml")
- }
-
- // Local stores are intentionally disabled when config is loaded from home.
- usePostgresStore = false
- useObjectStore = false
- useGitStore = false
- } else if usePostgresStore {
- if pgStoreLocalPath == "" {
- pgStoreLocalPath = wd
- }
- pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
- DSN: pgStoreDSN,
- Schema: pgStoreSchema,
- SpoolDir: pgStoreLocalPath,
- })
- cancel()
- if err != nil {
- log.Errorf("failed to initialize postgres token store: %v", err)
- return
- }
- examplePath := filepath.Join(wd, "config.example.yaml")
- ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
- if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
- cancel()
- log.Errorf("failed to bootstrap postgres-backed config: %v", errBootstrap)
- return
- }
- cancel()
- configFilePath = pgStoreInst.ConfigPath()
- cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
- if err == nil {
- cfg.AuthDir = pgStoreInst.AuthDir()
- log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
- }
- } else if useObjectStore {
- if objectStoreLocalPath == "" {
- if writableBase != "" {
- objectStoreLocalPath = writableBase
- } else {
- objectStoreLocalPath = wd
- }
- }
- objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
- resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
- useSSL := true
- if strings.Contains(resolvedEndpoint, "://") {
- parsed, errParse := url.Parse(resolvedEndpoint)
- if errParse != nil {
- log.Errorf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
- return
- }
- switch strings.ToLower(parsed.Scheme) {
- case "http":
- useSSL = false
- case "https":
- useSSL = true
- default:
- log.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
- return
- }
- if parsed.Host == "" {
- log.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint)
- return
- }
- resolvedEndpoint = parsed.Host
- if parsed.Path != "" && parsed.Path != "/" {
- resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
- }
- }
- resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
- objCfg := store.ObjectStoreConfig{
- Endpoint: resolvedEndpoint,
- Bucket: objectStoreBucket,
- AccessKey: objectStoreAccess,
- SecretKey: objectStoreSecret,
- LocalRoot: objectStoreRoot,
- UseSSL: useSSL,
- PathStyle: true,
- }
- objectStoreInst, err = store.NewObjectTokenStore(objCfg)
- if err != nil {
- log.Errorf("failed to initialize object token store: %v", err)
- return
- }
- examplePath := filepath.Join(wd, "config.example.yaml")
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
- cancel()
- log.Errorf("failed to bootstrap object-backed config: %v", errBootstrap)
- return
- }
- cancel()
- configFilePath = objectStoreInst.ConfigPath()
- cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
- if err == nil {
- if cfg == nil {
- cfg = &config.Config{}
- }
- cfg.AuthDir = objectStoreInst.AuthDir()
- log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
- }
- } else if useGitStore {
- if gitStoreLocalPath == "" {
- if writableBase != "" {
- gitStoreLocalPath = writableBase
- } else {
- gitStoreLocalPath = wd
- }
- }
- gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
- authDir := filepath.Join(gitStoreRoot, "auths")
- gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword, gitStoreBranch)
- gitStoreInst.SetBaseDir(authDir)
- if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
- log.Errorf("failed to prepare git token store: %v", errRepo)
- return
- }
- configFilePath = gitStoreInst.ConfigPath()
- if configFilePath == "" {
- configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
- }
- if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
- examplePath := filepath.Join(wd, "config.example.yaml")
- if _, errExample := os.Stat(examplePath); errExample != nil {
- log.Errorf("failed to find template config file: %v", errExample)
- return
- }
- if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
- log.Errorf("failed to bootstrap git-backed config: %v", errCopy)
- return
- }
- if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
- log.Errorf("failed to commit initial git-backed config: %v", errCommit)
- return
- }
- log.Infof("git-backed config initialized from template: %s", configFilePath)
- } else if statErr != nil {
- log.Errorf("failed to inspect git-backed config: %v", statErr)
- return
- }
- cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
- if err == nil {
- cfg.AuthDir = gitStoreInst.AuthDir()
- log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
- }
- } else if configPath != "" {
- configFilePath = configPath
- cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
- } else {
- wd, err = os.Getwd()
- if err != nil {
- log.Errorf("failed to get working directory: %v", err)
- return
- }
- configFilePath = filepath.Join(wd, "config.yaml")
- cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
- }
- if err != nil {
- log.Errorf("failed to load config: %v", err)
+ resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir)
+ if errResolveAuthDir != nil {
+ log.WithError(errResolveAuthDir).Error("failed to resolve auth directory")
return
}
- if cfg == nil {
- cfg = &config.Config{}
- }
+ cfg.AuthDir = resolvedAuthDir
+ cfg.GeminiKey = nil
+ cfg.InteractionsKey = nil
+ cfg.CodexKey = nil
+ cfg.XAIKey = nil
+ cfg.ClaudeKey = nil
+ cfg.OpenAICompatibility = nil
+ cfg.VertexCompatAPIKey = nil
+ cfg.OAuthExcludedModels = nil
+ cfg.OAuthModelAlias = nil
+ cfg.Plugins.Enabled = false
+ cfg.Home.Enabled = false
+ cfg.Routing.Strategy = "round-robin"
+ cfg.Routing.SessionAffinity = false
- // In cloud deploy mode, check if we have a valid configuration
- var configFileExists bool
- if isCloudDeploy {
- if configLoadedFromHome && cfg != nil {
- configFileExists = cfg.Port != 0
- } else {
- if info, errStat := os.Stat(configFilePath); errStat != nil {
- // Don't mislead: API server will not start until configuration is provided.
- log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
- configFileExists = false
- } else if info.IsDir() {
- log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
- configFileExists = false
- } else if cfg.Port == 0 {
- // LoadConfigOptional returns empty config when file is empty or invalid.
- // Config file exists but is empty or invalid; treat as missing config
- log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
- configFileExists = false
- } else {
- log.Info("Cloud deploy mode: Configuration file detected; starting service")
- configFileExists = true
- }
- }
+ if errConfigureLogging := logging.ConfigureLogOutput(cfg); errConfigureLogging != nil {
+ log.WithError(errConfigureLogging).Error("failed to configure log output")
+ return
}
- redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
- redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
+ util.SetLogLevel(cfg)
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
coreauth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
+ sdkAuth.RegisterTokenStore(&codexStore{fileStore: sdkAuth.NewFileTokenStore()})
- if err = logging.ConfigureLogOutput(cfg); err != nil {
- log.Errorf("failed to configure log output: %v", err)
- return
- }
-
- log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
-
- // Set the log level based on the configuration.
- util.SetLogLevel(cfg)
-
- if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
- log.Errorf("failed to resolve auth directory: %v", errResolveAuthDir)
- return
- } else {
- cfg.AuthDir = resolvedAuthDir
- }
-
- // Create login options to be used in authentication flows.
- options := &cmd.LoginOptions{
- NoBrowser: noBrowser,
- CallbackPort: oauthCallbackPort,
- }
-
- commandMode := vertexImport != "" || antigravityLogin || codexLogin || codexDeviceLogin || claudeLogin || kimiLogin || xaiLogin
- cloudConfigMissing := isCloudDeploy && !configFileExists
- homeMode := configLoadedFromHome || (cfg != nil && cfg.Home.Enabled)
- exampleAPIKeySafeMode := shouldEnableExampleAPIKeySafeMode(cfg, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode)
- serverOptions := []api.ServerOption(nil)
- if exampleAPIKeySafeMode {
- matches := safemode.ExampleAPIKeys(cfg.APIKeys)
- log.WithField("api_keys", strings.Join(matches, ",")).Error("unsafe example API key configured; proxy API endpoints disabled until api-keys is updated")
- serverOptions = append(serverOptions, api.WithExampleAPIKeySafeMode())
- }
-
- // Register the shared token store once so all components use the same persistence backend.
- if usePostgresStore {
- sdkAuth.RegisterTokenStore(pgStoreInst)
- } else if useObjectStore {
- sdkAuth.RegisterTokenStore(objectStoreInst)
- } else if useGitStore {
- sdkAuth.RegisterTokenStore(gitStoreInst)
- } else {
- sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
- }
-
- // Register built-in access providers before constructing services.
- configaccess.Register(&cfg.SDKConfig)
- pluginHost.ApplyConfig(context.Background(), cfg)
- if configLoadedFromHome && homePluginStatusReady {
- errHomePluginLoad := homeplugins.MarkLoadResults(&homePluginSyncReport, pluginHost)
- errReportPlugins := home.ReportPluginStatus(context.Background(), homeClient, cfg.Home.NodeID, homePluginSyncReport)
- if errHomePluginLoad != nil {
- log.Errorf("failed to load home plugins: %v", errHomePluginLoad)
- }
- if errReportPlugins != nil {
- log.Warnf("failed to report home plugin load status: %v", errReportPlugins)
- }
- if errHomePluginLoad != nil {
- return
- }
- }
- if homeClient != nil {
- // The bootstrap client is not owned by the runtime service. Close it after
- // the final startup report so it cannot retain an idle RESP connection.
- homeClient.Close()
- homeClient = nil
- }
- if pluginHost.HasTriggeredCommandLineFlags() {
- if exitCode, handled := pluginHost.ExecuteCommandLine(context.Background(), os.Args[0], os.Args[1:], configFilePath, flag.CommandLine); handled {
- if exitCode != 0 {
- os.Exit(exitCode)
- }
- return
- }
- }
-
- // Handle different command modes based on the provided flags.
-
- if vertexImport != "" {
- // Handle Vertex service account import
- cmd.DoVertexImport(cfg, vertexImport, vertexImportPrefix)
- } else if antigravityLogin {
- // Handle Antigravity login
- cmd.DoAntigravityLogin(cfg, options)
- } else if codexLogin {
- // Handle Codex login
- cmd.DoCodexLogin(cfg, options)
- } else if codexDeviceLogin {
- // Handle Codex device-code login
- cmd.DoCodexDeviceLogin(cfg, options)
- } else if claudeLogin {
- // Handle Claude login
- cmd.DoClaudeLogin(cfg, options)
- } else if kimiLogin {
- cmd.DoKimiLogin(cfg, options)
- } else if xaiLogin {
- cmd.DoXAILogin(cfg, options)
- } else {
- // In cloud deploy mode without config file, just wait for shutdown signals
- if isCloudDeploy && !configFileExists {
- // No config file available, just wait for shutdown
- cmd.WaitForCloudDeploy()
- return
- }
- if localModel && (!tuiMode || standalone) {
- log.Info("Local model mode: using embedded model catalogs, remote model updates disabled")
- }
- if tuiMode {
- if standalone {
- // Standalone mode: start an embedded local server and connect TUI client to it.
- misc.StartAntigravityVersionUpdater(context.Background())
- startModelCatalogUpdaters(localModel, cfg.Home.Enabled)
- hook := tui.NewLogHook(2000)
- hook.SetFormatter(&logging.LogFormatter{})
- log.AddHook(hook)
-
- origStdout := os.Stdout
- origStderr := os.Stderr
- origLogOutput := log.StandardLogger().Out
- log.SetOutput(io.Discard)
-
- devNull, errOpenDevNull := os.Open(os.DevNull)
- if errOpenDevNull == nil {
- os.Stdout = devNull
- os.Stderr = devNull
- }
-
- restoreIO := func() {
- os.Stdout = origStdout
- os.Stderr = origStderr
- log.SetOutput(origLogOutput)
- if devNull != nil {
- _ = devNull.Close()
- }
- }
-
- localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano())
- if password == "" {
- password = localMgmtPassword
- }
-
- cancel, done := cmd.StartServiceBackgroundWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
-
- client := tui.NewClient(cfg.Port, password)
- ready := false
- backoff := 100 * time.Millisecond
- for i := 0; i < 30; i++ {
- if _, errGetConfig := client.GetConfig(); errGetConfig == nil {
- ready = true
- break
- }
- time.Sleep(backoff)
- if backoff < time.Second {
- backoff = time.Duration(float64(backoff) * 1.5)
- }
- }
-
- if !ready {
- restoreIO()
- cancel()
- <-done
- fmt.Fprintf(os.Stderr, "TUI error: embedded server is not ready\n")
- return
- }
-
- if errRun := tui.Run(cfg.Port, password, hook, origStdout); errRun != nil {
- restoreIO()
- fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
- } else {
- restoreIO()
- }
-
- cancel()
- <-done
- } else {
- // Default TUI mode: pure management client.
- // The proxy server must already be running.
- if errRun := tui.Run(cfg.Port, password, nil, os.Stdout); errRun != nil {
- fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
- }
- }
- } else {
- // Start the main proxy service
- misc.StartAntigravityVersionUpdater(context.Background())
- startModelCatalogUpdaters(localModel, cfg.Home.Enabled)
- cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
- }
- }
-}
-
-// modelCatalogUpdaterPlan decides which remote model catalogs should refresh.
-// Codex client templates still refresh under Home mode because the model list
-// comes from Home IDs while template metadata stays edge-local.
-func modelCatalogUpdaterPlan(localModel, homeEnabled bool) (startModels, startCodexClient bool) {
- if localModel {
- return false, false
- }
- return !homeEnabled, true
-}
-
-func startModelCatalogUpdaters(localModel, homeEnabled bool) {
- startModels, startCodexClient := modelCatalogUpdaterPlan(localModel, homeEnabled)
- if startCodexClient {
- registry.StartCodexClientModelsUpdater(context.Background())
- }
- if startModels {
- registry.StartModelsUpdater(context.Background())
- } else if homeEnabled {
- log.Info("Home mode: remote models.json updates disabled; Codex client model list follows Home model IDs")
- }
-}
-
-func pluginBootstrapConfigPath(args []string, defaultPath string) string {
- for i := 0; i < len(args); i++ {
- arg := args[i]
- switch {
- case arg == "--":
- return defaultPluginBootstrapConfigPath(defaultPath)
- case arg == "-config" || arg == "--config":
- if i+1 < len(args) {
- return args[i+1]
- }
- return defaultPluginBootstrapConfigPath(defaultPath)
- case strings.HasPrefix(arg, "-config="):
- return strings.TrimPrefix(arg, "-config=")
- case strings.HasPrefix(arg, "--config="):
- return strings.TrimPrefix(arg, "--config=")
- }
- }
- return defaultPluginBootstrapConfigPath(defaultPath)
-}
-
-func defaultPluginBootstrapConfigPath(defaultPath string) string {
- if strings.TrimSpace(defaultPath) != "" {
- return defaultPath
- }
- wd, errGetwd := os.Getwd()
- if errGetwd != nil {
- return "config.yaml"
- }
- return filepath.Join(wd, "config.yaml")
-}
-
-func loadPluginBootstrapConfig(path string) *config.Config {
- raw, errReadFile := os.ReadFile(path)
- if errReadFile != nil {
- if !errors.Is(errReadFile, os.ErrNotExist) {
- log.Warnf("failed to read plugin bootstrap config: %v", errReadFile)
- }
- cfg := &config.Config{}
- cfg.NormalizePluginsConfig()
- return cfg
- }
- if len(strings.TrimSpace(string(raw))) == 0 {
- cfg := &config.Config{}
- cfg.NormalizePluginsConfig()
- return cfg
- }
- cfg, errParseConfig := config.ParseConfigBytes(raw)
- if errParseConfig != nil {
- log.Warnf("failed to parse plugin bootstrap config: %v", errParseConfig)
- cfg = &config.Config{}
- cfg.NormalizePluginsConfig()
- return cfg
- }
- return cfg
+ fmt.Printf("Vibe Proxy %s (%s), built %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
+ cmd.StartService(cfg, configPath, "")
}
diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go
deleted file mode 100644
index fce4be9..0000000
--- a/backend/cmd/server/main_test.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package main
-
-import (
- "testing"
-
- "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
-)
-
-func TestShouldEnableExampleAPIKeySafeMode(t *testing.T) {
- cfgWithExampleKey := &config.Config{
- SDKConfig: config.SDKConfig{
- APIKeys: []string{"real-key", " your-api-key-1 "},
- },
- }
- cfgWithRealKey := &config.Config{
- SDKConfig: config.SDKConfig{
- APIKeys: []string{"real-key"},
- },
- }
-
- tests := []struct {
- name string
- cfg *config.Config
- commandMode bool
- tuiMode bool
- standalone bool
- cloudConfigMissing bool
- homeMode bool
- want bool
- }{
- {
- name: "normal server with example key",
- cfg: cfgWithExampleKey,
- want: true,
- },
- {
- name: "standalone tui with example key",
- cfg: cfgWithExampleKey,
- tuiMode: true,
- standalone: true,
- want: true,
- },
- {
- name: "pure tui client is not blocked",
- cfg: cfgWithExampleKey,
- tuiMode: true,
- standalone: false,
- commandMode: false,
- want: false,
- },
- {
- name: "one-shot command is not blocked",
- cfg: cfgWithExampleKey,
- commandMode: true,
- want: false,
- },
- {
- name: "home mode is not blocked",
- cfg: cfgWithExampleKey,
- homeMode: true,
- want: false,
- },
- {
- name: "cloud standby without config is not blocked",
- cfg: cfgWithExampleKey,
- cloudConfigMissing: true,
- want: false,
- },
- {
- name: "normal server with real key",
- cfg: cfgWithRealKey,
- want: false,
- },
- {
- name: "nil config",
- cfg: nil,
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := shouldEnableExampleAPIKeySafeMode(tt.cfg, tt.commandMode, tt.tuiMode, tt.standalone, tt.cloudConfigMissing, tt.homeMode)
- if got != tt.want {
- t.Fatalf("shouldEnableExampleAPIKeySafeMode() = %t, want %t", got, tt.want)
- }
- })
- }
-}
-
-func TestModelCatalogUpdaterPlan(t *testing.T) {
- tests := []struct {
- name string
- localModel bool
- homeEnabled bool
- wantModels bool
- wantCodexClient bool
- }{
- {
- name: "normal CPA refreshes both catalogs",
- localModel: false,
- homeEnabled: false,
- wantModels: true,
- wantCodexClient: true,
- },
- {
- name: "home mode keeps models.json local and refreshes codex templates",
- localModel: false,
- homeEnabled: true,
- wantModels: false,
- wantCodexClient: true,
- },
- {
- name: "local-model disables both remote catalogs",
- localModel: true,
- homeEnabled: false,
- wantModels: false,
- wantCodexClient: false,
- },
- {
- name: "local-model disables both remote catalogs even under home",
- localModel: true,
- homeEnabled: true,
- wantModels: false,
- wantCodexClient: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotModels, gotCodex := modelCatalogUpdaterPlan(tt.localModel, tt.homeEnabled)
- if gotModels != tt.wantModels || gotCodex != tt.wantCodexClient {
- t.Fatalf("modelCatalogUpdaterPlan(%v, %v) = (%v, %v), want (%v, %v)",
- tt.localModel, tt.homeEnabled, gotModels, gotCodex, tt.wantModels, tt.wantCodexClient)
- }
- })
- }
-}
diff --git a/backend/config.dev.yaml b/backend/config.dev.yaml
index dbc371c..ffa5164 100644
--- a/backend/config.dev.yaml
+++ b/backend/config.dev.yaml
@@ -10,7 +10,3 @@ auth-dir: ".dev/auths"
api-keys:
- "dev-api-key"
-
-plugins:
- enabled: true
- dir: ".dev/plugins"
diff --git a/backend/config.example.yaml b/backend/config.example.yaml
index 997cf11..e02e865 100644
--- a/backend/config.example.yaml
+++ b/backend/config.example.yaml
@@ -1,844 +1,32 @@
-# Server host/interface to bind to. Default is empty ("") to bind all interfaces (IPv4 + IPv6).
-# Use "127.0.0.1" or "localhost" to restrict access to local machine only.
-host: ""
-
-# Server port
+# Address and port for the OpenAI-compatible API and management UI.
+host: "127.0.0.1"
port: 8317
-# TLS settings for HTTPS. When enabled, the server listens with the provided certificate and key.
tls:
enable: false
cert: ""
key: ""
-# Management API settings
+# MANAGEMENT_PASSWORD can provide the management key without storing it here.
remote-management:
- # Whether to allow remote (non-localhost) management access.
- # When false, only localhost can access management endpoints (a key is still required).
allow-remote: false
-
- # Management key. If a plaintext value is provided here, it will be hashed on startup.
- # All management requests (even from localhost) require this key.
- # Leave empty to disable the Management API entirely (404 for all /v0/management routes).
secret-key: ""
-
- # Disable the bundled management control panel HTTP routes when true.
disable-control-panel: false
-# Authentication directory (supports ~ for home directory)
-auth-dir: "~/.cli-proxy-api"
+# Codex OAuth credentials are stored as JSON files in this directory.
+auth-dir: "~/.vibe-proxy/auths"
-# API keys for authentication
+# Clients use one of these keys with the OpenAI-compatible endpoints.
api-keys:
- - "your-api-key-1"
- - "your-api-key-2"
- - "your-api-key-3"
+ - "replace-with-a-random-api-key"
-# Enable debug logging
-debug: false
-
-# Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety.
-pprof:
- enable: false
- addr: "127.0.0.1:8316"
-
-# Credential concurrency is configured by Home in Home mode. The synthesized Home config is
-# authoritative and local values, including the values below, are ignored. Do not use local
-# configuration to override a Home concurrency policy.
-# credential-concurrency:
-# lifecycle-config-revision: 1
-# observation-barrier-revision: 0
-# cpa-heartbeat-timeout: "3s"
-# cpa-cancel-bound: "5s"
-# reclaim-grace: "5s"
-# cleanup-interval: "5s"
-# release-flush-interval: 250ms
-# release-max-backoff: 2s
-# busy-retry-min: 250ms
-# busy-retry-max: 1s
-# max-limit: 1000000
-
-# Credential in-flight observation snapshot contract.
-# credential-in-flight:
-# snapshot-interval: 2s
-# stale-after: 10s
-# max-part-bytes: 262144
-# max-part-count: 64
-# max-revision-bytes: 16777216
-# max-aggregate-groups: 100000
-# max-details: 10000
-# max-string-bytes: 256
-# staging-retention: 1m
-
-# Standard dynamic library plugins are trusted in-process code. They are disabled by default.
-# Build Go examples with go build -buildmode=c-shared for the target GOOS/GOARCH.
-# Other languages can implement the same C ABI and JSON method protocol.
-# Plugin executors require a matching auth record with the same provider key.
-# If the same provider is configured as OpenAI-compatible, the native executor wins.
-# Plugin command-line flags and Management API routes are optional capabilities.
-# Existing native flags/routes and higher-priority plugin flags/routes cannot be replaced.
-# Plugin list Management API reads Logo and ConfigFields from plugin metadata for management UI display.
-# Per-plugin enabled only controls plugins.configs..enabled and does not implicitly change global plugins.enabled.
-plugins:
- enabled: false
- dir: "plugins"
- # Additional plugin store registries. The built-in official registry is always included.
- # store-sources:
- # - "https://example.com/cliproxy-plugins/registry.json"
- # Optional plugin store auth rules. Values are read from environment variables;
- # tokens are not written into plugin manifests or node status.
- # store-auth:
- # - match: "https://example.com/cliproxy-plugins/"
- # apply-to: ["registry", "artifact"]
- # type: bearer
- # token-env: "CLIPROXY_PLUGIN_STORE_TOKEN"
- configs:
- example:
- enabled: true
- priority: 1
- config1: true
- config2: "string"
- config3: 3
- mode: "safe" # enum example: safe, fast
-
-# When true, disable high-overhead request logging and HTTP middleware features to reduce per-request memory usage under high concurrency.
-commercial-mode: false
-
-# When true, write application logs to rotating files instead of stdout
-logging-to-file: false
-
-# Maximum total size (MB) of log files under the logs directory. When exceeded, the oldest log
-# files are deleted until within the limit. Set to 0 to disable.
-logs-max-total-size-mb: 0
-
-# Maximum number of error log files retained when request logging is disabled.
-# When exceeded, the oldest error log files are deleted. Default is 10. Set to 0 to disable cleanup.
-error-logs-max-files: 10
-
-# When false, disable in-memory usage statistics aggregation
-usage-statistics-enabled: false
-
-# How long (in seconds) usage queue items are retained in memory for the Management API.
-# The local Redis RESP usage output is disabled.
-# Default: 60. Max: 3600.
-redis-usage-queue-retention-seconds: 60
-
-# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
-# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
+# Optional HTTP, HTTPS, SOCKS5, or SOCKS5H proxy for OAuth and upstream requests.
proxy-url: ""
-# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
-force-model-prefix: false
-
-# When true, forward filtered upstream response headers to downstream clients.
-# Default is false (disabled).
-passthrough-headers: false
-
-# Number of additional credential retry rounds after the first round exhausts
-# its eligible credentials. Round 0 is the initial round; round r only admits
-# credentials whose effective request-retry is at least r. Explicit non-negative
-# credential/provider overrides take precedence; omitted or negative overrides
-# inherit this global value, and explicit 0 only admits round 0. New CPA nodes
-# send retry_round=0 for the initial round and increment it for additional rounds;
-# legacy dispatch methods omit the field and keep old semantics.
-# Additional rounds apply to HTTP 403, 408, 429, 500, 502, 503, and 504 failures.
-# Individual credential/provider overrides take precedence; 0 disables additional
-# rounds, while an omitted or negative override inherits this global setting.
-request-retry: 3
-
-# Maximum number of different credentials to try in each credential retry round
-# after per-credential round filtering. Set to 0 to try all available
-# credentials. Credentials skipped by this cap still age with the global round,
-# so the cap does not guarantee a fixed number of actual retries per credential.
-max-retry-credentials: 0
-
-# Maximum cooldown wait in seconds between retry rounds.
-# Set to 0 or below to never wait for credential cooldown.
-# Retry rounds that need no wait remain controlled by request-retry.
-max-retry-interval: 30
-
-# When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states).
-# A credential/provider disable-cooling value, when present, overrides this global value.
-disable-cooling: false
-
-# When true, persist per-auth cooldown status as .cds files next to auth files.
-# Default is false; when false, cooldown status is kept in memory only.
-save-cooldown-status: false
-
-# Cooldown duration in seconds for transient upstream errors (408/500/502/503/504).
-# Set to 0 to keep the legacy 60-second cooldown; set to -1 to disable transient error cooldowns.
-transient-error-cooldown-seconds: 0
-
-# When true, globally disable Claude request cloaking (the Claude Code CLI disguise and
-# system prompt replacement), so the original system prompt is passed through to Claude as-is.
-# Individual credentials can still override this: a claude-api-key entry via its "cloak.mode",
-# or a Claude OAuth/token file via a "cloak_mode" value. Default false keeps the per-client
-# "auto" behavior (cloak only non-Claude-Code clients).
-disable-claude-cloak-mode: false
-
-# Claude Code compatibility settings.
-claude-code:
- # When true, return original model IDs in Anthropic model list responses instead of cloaked IDs.
- disable-cloaking-model-list: false
-
-# disable-image-generation supports: false (default), true, "chat", or "passthrough".
-# - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits).
-# - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled.
-# - "passthrough": never inject or strip image_generation on non-images endpoints (forward the client payload unchanged); behaves like "chat" on /v1/images/* endpoints.
-disable-image-generation: false
-
-# Base model used by the legacy hosted image_generation tool path when a Codex image request is not proxied directly through the Image API.
-# Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini".
-# gpt-image-2-base-model: "gpt-5.4-mini"
-
-# How long video IDs returned by /openai/v1/videos and xAI video creation stay bound
-# to the credential that created them. Default: 3h.
-video-result-auth-cache-ttl: "3h"
-
-# Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh).
-# When > 0, overrides the default worker count (16).
-# auth-auto-refresh-workers: 16
-
-# Quota exceeded behavior
-quota-exceeded:
- switch-project: true # Whether to automatically switch to another project when a quota is exceeded
- switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
- antigravity-credits: true # Whether to use credits as last-resort fallback when all free-tier auths are exhausted for Claude models
-
-# Routing strategy for selecting credentials when multiple match.
+# Multiple Codex accounts are selected in round-robin order with automatic failover.
routing:
- strategy: "round-robin" # round-robin (default), weighted-round-robin, fill-first
- # weighted-round-robin uses each credential's integer weight (default 1, maximum 1,000,000).
- # Non-positive weights exclude the credential while this strategy is active.
- # For OAuth/file credentials, add a top-level numeric "weight" field to the auth JSON.
- # Enable universal session-sticky routing for all clients.
- # Explicit Claude Code, Codex, OpenCode, and pi session headers are preferred,
- # followed by prompt_cache_key, Responses conversation IDs, legacy body IDs,
- # execution or derived session identity, and the existing first-message hash fallback.
- # Automatic failover is always enabled when bound auth becomes unavailable.
- # An established binding outranks credential priority: once a session is bound, that
- # credential is kept even if a higher-priority credential recovers. Credential priority
- # still decides cold bindings, requests without a session, and post-failover rebinding.
- session-affinity: false # default: false
- # How long session-to-auth bindings are retained. Default: 1h
- session-affinity-ttl: "1h"
+ strategy: "round-robin"
-# Codex provider behavior.
-codex:
- # When true, and routing.strategy is fill-first or routing.session-affinity is true,
- # remap Codex prompt_cache_key and installation identity per selected auth.
- # Some superstitious users believe request tracking identifiers can be used
- # as evidence for TOS enforcement bans; this option only satisfies those odd concerns.
- identity-confuse: false
- # Disable forcing the official Codex User-Agent and Originator headers on HTTP/SSE and WebSocket requests.
- disable-codex-cloaking: false
- # Hold back the initial handshake events (response.created, response.in_progress and the
- # websocket metadata frames) until the upstream emits its first generated event.
- # Why: the upstream smuggles `server_is_overloaded` rejections *inside* an HTTP 200 stream,
- # right after those handshake events, instead of returning 503 on the wire. Buffering them
- # keeps the downstream response headers uncommitted long enough to transparently retry on
- # another credential. Only overload/rate-limit rejections trigger failover; every other
- # terminal failure is still delivered in-stream exactly as before.
- # Trade-off: response headers are delayed until generation starts, which can trip client or
- # reverse-proxy read timeouts (e.g. nginx proxy_read_timeout) on long reasoning requests.
- # Default: false
- stream-bootstrap-buffering: false
- # When true, optimize Codex Desktop, codex-tui, and codex_cli_rs requests for multi-agent v2.
- # This refreshes Codex spawn_agent model details, removes message parameter encryption,
- # normalizes encrypted agent_message content for Codex, and converts agent_message input
- # into standard user messages for non-Codex upstream protocols.
- optimize-multi-agent-v2: false
- # Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process.
- # This requires inbound UDP reachability. Keep disabled to preserve direct media behavior.
- live-media-relay:
- enabled: false
- # Maximum concurrent media sessions. Zero uses the default of 32.
- max-sessions: 32
- # Reject downstream SDP candidates that target private, loopback, link-local, or unspecified IPs.
- # Keep false for local or trusted-network Codex Desktop connections.
- disable-private-remote-ips: false
- # Public IPv4 or IPv6 address advertised when CPA is behind 1:1 NAT.
- public-ip: ""
- # Optional UDP allocation range. Both values must be set together and provide at least two ports per session.
- udp-port-min: 0
- udp-port-max: 0
- # Optional STUN/TURN servers. TURN credentials are never returned by the JSON config API.
- # Without a concrete global/per-auth proxy-url, WebRTC uses normal direct ICE/STUN/TURN connectivity.
- # With http, https, socks5, or socks5h proxy-url, the OpenAI-facing leg is forced through
- # authenticated ICE-TCP over that proxy and never falls back to UDP or a direct connection.
- # The Codex Desktop-facing leg remains direct, and configured ICE servers still apply to it.
- # ice-servers:
- # - urls:
- # - "stun:stun.example.com:3478"
- # - urls:
- # - "turn:turn.example.com:3478?transport=udp"
- # username: "user"
- # credential: "secret"
-
-# Antigravity provider behavior.
-# antigravity:
-# sensitive-words: # optional: words to obfuscate with zero-width characters in system instructions
-# - "API"
-# - "proxy"
-
-# xAI provider behavior.
-xai:
- # When true, inject the native x_search tool when the request does not declare it.
- # The injected tool is also added to tool_choice.allowed_tools when applicable.
- inject-x-search: false
-
-# When true, enable authentication for the WebSocket API (/v1/ws).
-ws-auth: true
-
-# When > 0, emit blank lines every N seconds for non-streaming responses to prevent idle timeouts.
-nonstream-keepalive-interval: 0
-# Streaming behavior (SSE keep-alives + safe bootstrap retries).
-# streaming:
-# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
-# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
-
-# Signature cache validation for thinking blocks (Antigravity/Claude).
-# When true (default), cached signatures are preferred and validated.
-# When false, client signatures are used directly after normalization (bypass mode for testing).
-# antigravity-signature-cache-enabled: true
-
-# Bypass mode signature validation strictness (only applies when signature cache is disabled).
-# When true, validates full Claude protobuf tree (Field 2 -> Field 1 structure).
-# When false (default), only checks R/E prefix + base64 + first byte 0x12.
-# antigravity-signature-bypass-strict: false
-
-# Gemini API keys
-# gemini-api-key:
-# - api-key: "AIzaSy...01"
-# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
-# prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
-# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
-# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
-# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
-# - status: 400 # HTTP status code to match
-# match: # optional: string contains matching
-# - "maximum_context_length"
-# - "context_length_exceeded"
-# match-regexr: # optional: regular expression matching
-# - "maximum_context_length$"
-# - "^context_length_exceeded"
-# action: "stop" # "stop" (return error, no cooling), "stop-and-cooldown" (return error and cool down),
-# # "continue" (try next credential, no cooling), "continue-and-cooldown" (try next credential and cool down)
-# base-url: "https://generativelanguage.googleapis.com"
-# headers:
-# X-Custom-Header: "custom-value"
-# # Values starting with "$" dynamically copy the header value from downstream client requests.
-# # If the client did not send the specified header, the header is omitted.
-# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
-# proxy-url: "socks5://proxy.example.com:1080"
-# # proxy-url: "direct" # optional: explicit direct connect for this credential
-# models:
-# - name: "gemini-2.5-flash" # upstream model name
-# alias: "gemini-flash" # client alias mapped to the upstream model
-# display-name: "Gemini Flash" # optional catalog display name
-# max-context-length: 1048576 # optional: override Codex client context window metadata
-# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams
-# thinking: # optional: exact thinking capability for this configured model
-# levels: ["high", "medium", "low", "none", "auto"]
-# excluded-models:
-# - "gemini-2.5-pro" # exclude specific models from this provider (exact match)
-# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro)
-# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview)
-# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
-# - api-key: "AIzaSy...02"
-
-# Native Interactions API keys
-# These keys are used only for direct /v1beta/interactions execution. Regular gemini-api-key entries still
-# send Gemini generateContent/streamGenerateContent requests when the client enters through the interactions API.
-# interactions-api-key:
-# - api-key: "AIzaSy...03"
-# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
-# prefix: "native" # optional: require calls like "native/gemini-3-pro-preview" to target this credential
-# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
-# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
-# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
-# - status: 400
-# match:
-# - "invalid_argument"
-# action: "continue"
-# base-url: "https://generativelanguage.googleapis.com"
-# headers:
-# X-Custom-Header: "custom-value"
-# # Values starting with "$" dynamically copy the header value from downstream client requests.
-# # If the client did not send the specified header, the header is omitted.
-# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
-# proxy-url: "socks5://proxy.example.com:1080"
-# # proxy-url: "direct" # optional: explicit direct connect for this credential
-# models:
-# - name: "gemini-2.5-flash" # upstream model name
-# alias: "native-gemini-flash" # client alias mapped to the upstream model
-# max-context-length: 1048576 # optional: override Codex client context window metadata
-# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams
-# thinking: # optional: exact thinking capability for this configured model
-# levels: ["high", "medium", "low", "none", "auto"]
-# excluded-models:
-# - "gemini-2.5-pro"
-
-# Codex API keys
-# codex-api-key:
-# - api-key: "sk-atSM..."
-# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
-# prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential
-# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
-# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
-# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
-# - status: 400
-# match:
-# - "context_window_exceeded"
-# action: "stop-and-cooldown"
-# base-url: "https://www.example.com" # use the custom codex API endpoint
-# alpha-search: false # optional: allow this key to serve /v1/alpha/search via base-url + /alpha/search
-# headers:
-# X-Custom-Header: "custom-value"
-# # Values starting with "$" dynamically copy the header value from downstream client requests.
-# # If the client did not send the specified header, the header is omitted.
-# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
-# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
-# # proxy-url: "direct" # optional: explicit direct connect for this credential
-# models:
-# - name: "gpt-5-codex" # upstream model name
-# alias: "codex-latest" # client alias mapped to the upstream model
-# display-name: "Codex Latest" # optional catalog display name
-# max-context-length: 1048576 # optional: override Codex client context window metadata
-# force-mapping: true # optional: rewrite response model fields back to the alias
-# # When true and codex.optimize-multi-agent-v2 is also true, convert Codex
-# # MultiAgentV2 agent_message items into portable Responses message/user input
-# # for third-party Responses-compatible endpoints that reject agent_message.
-# # Default false keeps agent_message unchanged for native OpenAI/Codex endpoints.
-# # It also preserves thinking blocks with empty signatures for compatible upstreams.
-# is-compat: false
-# thinking: # optional: exact thinking capability for this configured model
-# levels: ["xhigh", "high", "medium", "low"]
-# excluded-models:
-# - "gpt-5.1" # exclude specific models (exact match)
-# - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex)
-# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini)
-# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low)
-
-# xAI API keys
-# Uses the native xAI executor, including its Responses namespace-tool handling.
-# xai-api-key:
-# - api-key: "xai-..."
-# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
-# prefix: "xai" # optional: require calls like "xai/grok-4.5" to target this credential
-# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
-# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
-# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
-# - status: 400
-# match:
-# - "rate_limit_exceeded"
-# action: "continue-and-cooldown"
-# base-url: "https://api.x.ai/v1" # xAI-compatible Responses API endpoint
-# websockets: true # optional: use the xAI upstream websocket transport for downstream websocket requests
-# headers:
-# X-Custom-Header: "custom-value"
-# # Values starting with "$" dynamically copy the header value from downstream client requests.
-# # If the client did not send the specified header, the header is omitted.
-# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
-# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
-# # proxy-url: "direct" # optional: explicit direct connect for this credential
-# models:
-# - name: "grok-4.5" # upstream model name
-# alias: "grok-latest" # client alias mapped to the upstream model
-# display-name: "Grok Latest" # optional catalog display name
-# max-context-length: 1048576 # optional: override Codex client context window metadata
-# force-mapping: true # optional: rewrite response model fields back to the alias
-# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams
-# thinking: # optional: exact thinking capability for this configured model
-# levels: ["xhigh", "high", "medium", "low"]
-# excluded-models:
-# - "grok-4.1" # exclude specific models (exact match)
-# - "grok-3-*" # wildcard matching prefix
-
-# Claude API keys
-# claude-api-key:
-# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
-# - api-key: "sk-atSM..."
-# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
-# prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential
-# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
-# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
-# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
-# - status: 400
-# match:
-# - "prompt is too long"
-# action: "stop"
-# base-url: "https://www.example.com" # use the custom claude API endpoint
-# headers:
-# X-Custom-Header: "custom-value"
-# # Values starting with "$" dynamically copy the header value from downstream client requests.
-# # If the client did not send the specified header, the header is omitted.
-# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
-# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
-# # proxy-url: "direct" # optional: explicit direct connect for this credential
-# models:
-# - name: "claude-3-5-sonnet-20241022" # upstream model name
-# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
-# display-name: "Claude Sonnet" # optional catalog display name
-# max-context-length: 1048576 # optional: override Codex client context window metadata
-# force-mapping: true # optional: rewrite response model fields back to the alias
-# is-compat: false # optional: preserve thinking blocks with empty signatures for compatible upstreams
-# thinking: # optional: exact thinking capability for this configured model
-# levels: ["max", "xhigh", "high", "medium", "low", "minimal", "none", "auto"]
-# excluded-models:
-# - "claude-opus-4-5-20251101" # exclude specific models (exact match)
-# - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219)
-# - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
-# - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
-# rebuild-mid-system-message: false # optional: default is false; when true, move messages with role "system" into the top-level Claude system field
-# cloak: # optional: explicitly enable request cloaking for non-Claude-Code clients
-# mode: "auto" # "auto" (default inside this block): cloak only when client is not Claude Code
-# # "always": cloak every unconfirmed client; confirmed native Claude Code still passes through
-# # "never": never apply cloaking
-# # This "cloak" block applies to this claude-api-key entry only. For Claude OAuth
-# # credentials, set the same options in the auth/token JSON file via "cloak_mode" /
-# # "cloak_strict_mode" / "cloak_sensitive_words" / "cloak_cache_user_id". The top-level
-# # "disable-claude-cloak-mode: true" disables cloaking for all Claude credentials at once.
-# strict-mode: false # false (default): legacy-model whitelist uses a user system-reminder;
-# # all other and future models use messages[].role=system
-# # true: strip caller prompts and keep only Claude Code billing and identity blocks
-# sensitive-words: # optional: words to obfuscate with zero-width characters
-# - "API"
-# - "proxy"
-# cache-user-id: true # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request
-# # Every custom tool on a cloaked OAuth request automatically uses a caller-stable opaque mcp____ alias.
-#
-# # fingerprint-profile (optional, top-level on this claude-api-key entry; not a cloak sub-field):
-# # OAuth and API-key fingerprints are different contracts.
-# # - Real Claude OAuth stays on the strict Claude Code CLI wire fingerprint.
-# # - API keys (official Anthropic, custom gateways, Kimi) stay loose and
-# # caller-owned unless this field is set.
-# #
-# # Default (omit / empty): keep the caller request fingerprint and headers.
-# # Official api.anthropic.com API keys do not add extra CLI betas/identity unless
-# # this field is set. Custom gateways and delegated providers are the same.
-# #
-# # Controls request fingerprint only on /v1/messages (and related Claude executor paths).
-# # Auth scheme stays API key (x-api-key on api.anthropic.com; Bearer on custom base-url).
-# # Does NOT enable OAuth refresh, profile fetch, or OAuth-cancellation semantics.
-# #
-# # Values:
-# # omit / empty = caller-owned API-key fingerprint (respects caller)
-# # "claude-code-cli" = same Messages fingerprint as Claude Code OAuth CLI,
-# # including official Anthropic API keys: OAuth Anthropic-Beta
-# # set, CCH signing on api.anthropic.com, stable CLI
-# # metadata.user_id / session_id / device identity.
-# # API keys seed identity from the key;
-# # delegated OAuth providers use stable auth ID instead of
-# # rotating access tokens. "oauth-cli" is a legacy alias.
-# #
-# # count_tokens keeps the native model/messages/tools shape for every origin, including
-# # Kimi opt-in. It does not send billing/CCH, currentDate, metadata, or diagnostics.
-# #
-# # CCH: the billing block may carry a per-request cch hash. CPA emits it exactly where
-# # Claude Code does, which is api.anthropic.com (first-party) and Vertex only. An opt-in
-# # on any other gateway (including Kimi) still sends the billing block, but without cch,
-# # so a per-request hash cannot bust that gateway's prompt cache. api.anthropic.com
-# # strips the block itself (0 tokens, no cache impact). Kimi drops the whole block by
-# # default and keeps it, unsigned, after an explicit fingerprint opt-in.
-# # A real Claude OAuth credential always signs, on every upstream: a downstream Claude
-# # Code pointed at CPA cannot produce that value itself.
-# #
-# # Example (official Anthropic or a custom Messages gateway):
-# # - api-key: "your-key"
-# # # base-url: "https://gateway.example" # omit for api.anthropic.com
-# # fingerprint-profile: "claude-code-cli"
-# # cloak:
-# # mode: "always" # recommended when upstream rejects non-CLI clients
-# #
-# # Delegated Anthropic Messages OAuth files (Kimi, etc.) use "fingerprint_profile"
-# # in the auth JSON. Refresh keeps it. Example:
-# # {
-# # "type": "kimi",
-# # "access_token": "...",
-# # "refresh_token": "...",
-# # "fingerprint_profile": "claude-code-cli"
-# # }
-# # Legacy "fingerprint-profile" credentials remain supported and are normalized at load time.
-# # fingerprint-profile: "claude-code-cli" # optional claude-api-key provider field; default is empty (caller-owned); uncomment to opt in
-# experimental-cch-signing: false # deprecated compatibility field; CCH is generated automatically
-# # for real Claude OAuth on any upstream, and for claude-code-cli profiles
-# # only on api.anthropic.com; Vertex keeps provider-native signing
-
-# Anthropic-Beta is assembled per request rather than sent as a fixed list, matching
-# Claude Code 2.1.220: context-1m sits right after claude-code, mid-conversation-system
-# is added only for models that accept a role=system turn, advanced-tool-use only when
-# the request declares tools, and server-side-fallback / fallback-credit /
-# structured-outputs trail effort. On direct api.anthropic.com a caller may only ask for
-# betas real Claude Code also sends, and they are placed at their observed positions;
-# anything else is dropped so the outgoing set stays one a real client could produce.
-# Other Anthropic-compatible upstreams still forward caller betas verbatim.
-#
-# Default headers for Claude API requests. Update only after measuring a new Claude Code release.
-# Unconfirmed clients use this CLI baseline. Verified native Claude Code CLI, sdk-cli,
-# and VSCode requests preserve their measured entrypoint and software shape only when the
-# Claude Code version, package version, and runtime version exactly match this configured
-# baseline; unmeasured versions fall back to it. In legacy mode, timeout is a fallback and
-# verified native OS/arch values remain client-supplied. When stabilize-device-profile is
-# enabled, OS/arch are pinned to the values below and cached profiles remain constrained to
-# the same exact software baseline rather than learning newer client versions.
-# claude-header-defaults:
-# user-agent: "claude-cli/2.1.220 (external, cli)"
-# package-version: "0.94.0"
-# runtime-version: "v26.3.0"
-# os: "MacOS"
-# arch: "arm64"
-# timeout: "600"
-# timezone: "Asia/Singapore" # fallback IANA timezone for cloaked currentDate; a credential JSON "timezone" takes priority
-# stabilize-device-profile: false # optional, default false; set true to enable per-auth/API-key fingerprint pinning
-
-# Default headers for Codex OAuth model requests.
-# These are used only for file-backed/OAuth Codex requests when the client
-# does not send the header. `user-agent` applies to HTTP and websocket requests;
-# `beta-features` only applies to websocket requests. They do not apply to codex-api-key entries.
-# codex-header-defaults:
-# user-agent: "codex_cli_rs/0.114.0 (Mac OS 14.2.0; x86_64) vscode/1.111.0"
-# beta-features: "multi_agent"
-
-# OpenAI compatibility providers
-# openai-compatibility:
-# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
-# disabled: false # optional: set to true to disable this provider without removing it
-# prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
-# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
-# support-prompt-cache-key: false # optional: derive prompt_cache_key for requests from all input protocols
-# disable-cooling: false # optional provider override: true disables cooling, false enables it; omit to inherit global
-# request-retry: 3 # optional per-provider override; 0 disables additional rounds; omit or set < 0 to inherit global
-# request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns
-# - status: 400
-# match:
-# - "maximum_context_length"
-# - "context_length_exceeded"
-# match-regexr:
-# - "maximum_context_length$"
-# - "^context_length_exceeded"
-# action: "stop" # "stop", "stop-and-cooldown", "continue", "continue-and-cooldown"
-# headers:
-# X-Custom-Header: "custom-value"
-# # Values starting with "$" dynamically copy the header value from downstream client requests.
-# # If the client did not send the specified header, the header is omitted.
-# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
-# api-key-entries:
-# - api-key: "sk-or-v1-...b780"
-# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
-# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
-# # proxy-url: "direct" # optional: explicit direct connect for this credential
-# - api-key: "sk-or-v1-...b781" # without proxy-url
-# models: # The models supported by the provider.
-# - name: "moonshotai/kimi-k2:free" # The actual model name.
-# alias: "kimi-k2" # The alias used in the API.
-# display-name: "Kimi K2" # optional catalog display name
-# max-context-length: 1048576 # optional: override Codex client context window metadata
-# image: false # optional: set true to allow this model on /v1/images/generations and /v1/images/edits (not chat/responses image input)
-# input-modalities: [text, image] # optional: declare /v1/chat/completions and /v1/responses multimodal input for Codex clients. Use [text] for upstreams that reject multimodal tool result content.
-# output-modalities: [text] # optional: declare output modalities when known
-# is-compat: false # optional: preserve Claude thinking blocks for compatible upstreams
-# thinking: # optional: omit to default to levels ["low","medium","high"]
-# levels: ["low", "medium", "high"]
-# # You may repeat the same alias to build an internal model pool.
-# # The client still sees only one alias in the model list.
-# # Requests to that alias will round-robin across the upstream names below,
-# # and if the chosen upstream fails before producing output, the request will
-# # continue with the next upstream model in the same alias pool.
-# - name: "deepseek-v3.1"
-# alias: "claude-opus-4.66"
-# - name: "glm-5"
-# alias: "claude-opus-4.66"
-# - name: "kimi-k2.5"
-# alias: "claude-opus-4.66"
-
-# Vertex API keys (Vertex-compatible endpoints, base-url is optional)
-# vertex-api-key:
-# - api-key: "vk-123..." # x-goog-api-key header
-# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
-# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
-# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global
-# request-retry: 3 # optional per-auth override; 0 disables additional rounds; omit or set < 0 to inherit global
-# base-url: "https://example.com/api" # optional, e.g. https://zenmux.ai/api; falls back to Google Vertex when omitted
-# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
-# # proxy-url: "direct" # optional: explicit direct connect for this credential
-# headers:
-# X-Custom-Header: "custom-value"
-# # Values starting with "$" dynamically copy the header value from downstream client requests.
-# # If the client did not send the specified header, the header is omitted.
-# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
-# models: # optional: map aliases to upstream model names
-# - name: "gemini-2.5-flash" # upstream model name
-# alias: "vertex-flash" # client-visible alias
-# display-name: "Vertex Flash" # optional catalog display name
-# thinking: # optional: exact thinking capability for this configured model
-# levels: ["high", "medium", "low", "none", "auto"]
-# - name: "gemini-2.5-pro"
-# alias: "vertex-pro"
-# excluded-models: # optional: models to exclude from listing
-# - "imagen-3.0-generate-002"
-# - "imagen-*"
-
-# Global OAuth model name aliases (per channel)
-# These aliases rename model IDs for both model listing and request routing.
-# Supported channels: vertex, aistudio, antigravity, claude, codex, kimi, xai.
-# NOTE: Aliases do not apply to gemini-api-key, interactions-api-key, codex-api-key, xai-api-key, claude-api-key, openai-compatibility, or vertex-api-key.
-# NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping
-# client-visible names can become ambiguous across providers. For strict backend pinning, use
-# unique aliases/prefixes or avoid overlapping names.
-# You can repeat the same name with different aliases to expose multiple client model names.
-# Optional per-entry fields:
-# fork: true # keep the upstream model and also expose the alias as a separate client-visible model
-# display-name: "Model Name" # override the human-readable name shown in model catalogs
-# force-mapping: true # rewrite upstream response model fields back to the client-visible alias (example below uses antigravity only)
-# Per-auth OAuth aliases can also be stored in an OAuth auth JSON file as "model_aliases".
-# Legacy "model-aliases" credentials remain supported and are normalized at load time.
-# They apply only to that selected auth and take precedence over global aliases for the same client-visible alias.
-# Example auth JSON:
-# {
-# "type": "codex",
-# "email": "user@example.com",
-# "model_aliases": [
-# {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.5"},
-# {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.4"}
-# ]
-# }
-# oauth-model-alias:
-# vertex:
-# - name: "gemini-2.5-pro"
-# alias: "g2.5p"
-# aistudio:
-# - name: "gemini-2.5-pro"
-# alias: "g2.5p"
-# antigravity:
-# - name: "gemini-pro-agent" # upstream Antigravity model id
-# alias: "gemini-3.1-pro-preview" # client-visible id (Gemini 3.1 Pro Preview)
-# display-name: "Antigravity Gemini 3.1 Pro" # optional catalog display name
-# fork: true
-# force-mapping: true
-# claude:
-# - name: "claude-sonnet-4-5-20250929"
-# alias: "cs4.5"
-# codex:
-# - name: "gpt-5"
-# alias: "g5"
-# kimi:
-# - name: "kimi-k2.5"
-# alias: "k2.5"
-# xai:
-# - name: "grok-4.3"
-# alias: "grok-latest"
-# sample-provider: # plugin provider keys are supported for OAuth plugins
-# - name: "sample-model-latest"
-# alias: "sample-latest"
-
-# OAuth provider excluded models
-# oauth-excluded-models:
-# vertex:
-# - "gemini-3-pro-preview"
-# aistudio:
-# - "gemini-3-pro-preview"
-# antigravity:
-# - "gemini-3-pro-preview"
-# claude:
-# - "claude-3-5-haiku-20241022"
-# codex:
-# - "gpt-5-codex-mini"
-# kimi:
-# - "kimi-k2-thinking"
-# xai:
-# - "grok-3-mini"
-
-# OAuth provider request-scoped error rules (custom error classification for OAuth credentials)
-# oauth-request-scoped-errors:
-# vertex:
-# - status: 400
-# match:
-# - "maximum_context_length"
-# - "context_length_exceeded"
-# match-regexr:
-# - "maximum_context_length$"
-# - "^context_length_exceeded"
-# action: "stop" # options: "stop", "stop-and-cooldown", "continue", "continue-and-cooldown"
-# aistudio:
-# - status: 400
-# match:
-# - "invalid_argument"
-# action: "stop"
-# antigravity:
-# - status: 500
-# match:
-# - "internal_server_error"
-# action: "stop-and-cooldown"
-# claude:
-# - status: 400
-# match:
-# - "prompt is too long"
-# action: "stop"
-# codex:
-# - status: 400
-# match:
-# - "context_window_exceeded"
-# action: "stop"
-# kimi:
-# - status: 400
-# match:
-# - "length_limit"
-# action: "stop"
-# xai:
-# - status: 400
-# match:
-# - "max_tokens_exceeded"
-# action: "stop"
-
-# Optional payload configuration
-# payload:
-# default: # Default rules only set parameters when they are missing in the payload.
-# - models:
-# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
-# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
-# from-protocol: "responses" # restricts the rule to the source protocol, options: openai, responses, gemini, claude
-# headers: # all configured request headers must match; values support "*" wildcards
-# X-Client-Tier: "tenant-*-region-*"
-# match: # all payload JSON paths must equal the configured values
-# - "metadata.client": "codex"
-# not-match: # payload JSON paths must not equal the configured values
-# - "metadata.mode": "dev"
-# exist: # all payload JSON paths must exist and not be null
-# - "tools.#(type==\"web_search\").type"
-# not-exist: # all payload JSON paths must be missing or null
-# - "metadata.disable_payload"
-# params: # JSON path (gjson/sjson syntax) -> value
-# "generationConfig.thinkingConfig.thinkingBudget": 32768
-# default-raw: # Default raw rules set parameters using raw JSON when missing (must be valid JSON).
-# - models:
-# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
-# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
-# params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)
-# "generationConfig.responseJsonSchema": "{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}"
-# override: # Override rules always set parameters, overwriting any existing values.
-# - models:
-# - name: "gpt-5.4-fast"
-# protocol: "codex"
-# - name: "gpt-5.5-fast"
-# protocol: "codex"
-# params:
-# service_tier: priority
-# - models:
-# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
-# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
-# params: # JSON path (gjson/sjson syntax) -> value
-# "reasoning.effort": "high"
-# override-raw: # Override raw rules always set parameters using raw JSON (must be valid JSON).
-# - models:
-# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
-# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
-# params: # JSON path (gjson/sjson syntax) -> raw JSON value (strings are used as-is, must be valid JSON)
-# "response_format": "{\"type\":\"json_schema\",\"json_schema\":{\"name\":\"answer\",\"schema\":{\"type\":\"object\"}}}"
-# filter: # Filter rules remove specified parameters from the payload.
-# - models:
-# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
-# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
-# params: # JSON paths (gjson/sjson syntax) to remove from the payload
-# - "generationConfig.thinkingConfig.thinkingBudget"
-# - "generationConfig.responseJsonSchema"
+debug: false
+logging-to-file: false
+usage-statistics-enabled: false
diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml
index b80d9ce..9ce6a55 100644
--- a/backend/docker-compose.yml
+++ b/backend/docker-compose.yml
@@ -1,7 +1,6 @@
services:
- cli-proxy-api:
- image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
- pull_policy: always
+ vibe-proxy:
+ image: ${VIBE_PROXY_IMAGE:-vibe-proxy:latest}
build:
context: ..
dockerfile: backend/Dockerfile
@@ -9,21 +8,12 @@ services:
VERSION: ${VERSION:-dev}
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
- container_name: cli-proxy-api
- # env_file:
- # - .env
+ container_name: vibe-proxy
environment:
- DEPLOY: ${DEPLOY:-}
+ MANAGEMENT_PASSWORD: ${MANAGEMENT_PASSWORD:-}
ports:
- "8317:8317"
- - "8085:8085"
- - "1455:1455"
- - "54545:54545"
- - "51121:51121"
- - "11451:11451"
volumes:
- - ${CLI_PROXY_CONFIG_PATH:-./config.yaml}:/CLIProxyAPI/config.yaml
- - ${CLI_PROXY_AUTH_PATH:-./auths}:/root/.cli-proxy-api
- - ${CLI_PROXY_LOG_PATH:-./logs}:/CLIProxyAPI/logs
- - ${CLI_PROXY_PLUGIN_PATH:-./plugins}:/CLIProxyAPI/plugins
+ - ${VIBE_PROXY_CONFIG_PATH:-./config.yaml}:/app/config.yaml:ro
+ - ${VIBE_PROXY_AUTH_PATH:-./auths}:/root/.vibe-proxy/auths
restart: unless-stopped
diff --git a/backend/internal/api/handlers/management/api_tools.go b/backend/internal/api/handlers/management/api_tools.go
index a619afd..d98aeac 100644
--- a/backend/internal/api/handlers/management/api_tools.go
+++ b/backend/internal/api/handlers/management/api_tools.go
@@ -26,6 +26,8 @@ const (
var antigravityOAuthTokenURL = "https://oauth2.googleapis.com/token"
+var codexUsageURL = "https://chatgpt.com/backend-api/wham/usage"
+
type apiCallRequest struct {
AuthIndexSnake *string `json:"auth_index"`
AuthIndexCamel *string `json:"authIndex"`
@@ -43,6 +45,83 @@ type apiCallResponse struct {
Body string `json:"body"`
}
+// CodexQuota fetches the usage payload for one exact Codex credential.
+func (h *Handler) CodexQuota(c *gin.Context) {
+ var body struct {
+ AuthIndexSnake *string `json:"auth_index"`
+ AuthIndexCamel *string `json:"authIndex"`
+ Method string `json:"method"`
+ URL string `json:"url"`
+ }
+ if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
+ return
+ }
+
+ authIndex := firstNonEmptyString(body.AuthIndexSnake, body.AuthIndexCamel)
+ if authIndex == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "auth_index is required"})
+ return
+ }
+ if method := strings.ToUpper(strings.TrimSpace(body.Method)); method != "" && method != http.MethodGet {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "only GET is allowed"})
+ return
+ }
+ if requestedURL := strings.TrimSpace(body.URL); requestedURL != "" && requestedURL != codexUsageURL {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "url is not allowed"})
+ return
+ }
+
+ auth := h.authByIndex(authIndex)
+ if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Codex auth not found"})
+ return
+ }
+ token := tokenValueForAuth(auth)
+ if token == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Codex auth token not found"})
+ return
+ }
+
+ req, errNewRequest := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, codexUsageURL, nil)
+ if errNewRequest != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build request"})
+ return
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal")
+ if accountID, ok := auth.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" {
+ req.Header.Set("Chatgpt-Account-Id", strings.TrimSpace(accountID))
+ }
+
+ resp, errDo := (&http.Client{
+ Transport: h.apiCallTransport(auth, ""),
+ }).Do(req)
+ if errDo != nil {
+ log.WithError(errDo).Debug("management Codex quota request failed")
+ c.JSON(http.StatusBadGateway, gin.H{"error": "request failed"})
+ return
+ }
+ defer func() {
+ if errClose := resp.Body.Close(); errClose != nil {
+ log.Errorf("Codex quota response body close error: %v", errClose)
+ }
+ }()
+
+ respBody, errReadAll := io.ReadAll(resp.Body)
+ if errReadAll != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read response"})
+ return
+ }
+ c.JSON(http.StatusOK, apiCallResponse{
+ StatusCode: resp.StatusCode,
+ Header: resp.Header,
+ Body: string(respBody),
+ })
+}
+
// APICall makes a generic HTTP request on behalf of the management API caller.
// It is protected by the management middleware.
//
diff --git a/backend/internal/api/handlers/management/auth_files.go b/backend/internal/api/handlers/management/auth_files.go
index f42b681..330fe0f 100644
--- a/backend/internal/api/handlers/management/auth_files.go
+++ b/backend/internal/api/handlers/management/auth_files.go
@@ -101,6 +101,9 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
auths := h.authManager.List()
files := make([]gin.H, 0, len(auths))
for _, auth := range auths {
+ if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") || auth.AuthKind() != "oauth" {
+ continue
+ }
if !matchesAuthFileLookup(auth, nameFilter, authIndexFilter) {
continue
}
diff --git a/backend/internal/api/handlers/management/auth_files_crud.go b/backend/internal/api/handlers/management/auth_files_crud.go
index 2c193b3..7049c71 100644
--- a/backend/internal/api/handlers/management/auth_files_crud.go
+++ b/backend/internal/api/handlers/management/auth_files_crud.go
@@ -135,40 +135,6 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
return
}
ctx := c.Request.Context()
- if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
- entries, err := os.ReadDir(h.cfg.AuthDir)
- if err != nil {
- c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
- return
- }
- deleted := 0
- for _, e := range entries {
- if e.IsDir() {
- continue
- }
- name := e.Name()
- if !strings.HasSuffix(strings.ToLower(name), ".json") {
- continue
- }
- full := filepath.Join(h.cfg.AuthDir, name)
- if !filepath.IsAbs(full) {
- if abs, errAbs := filepath.Abs(full); errAbs == nil {
- full = abs
- }
- }
- if err = os.Remove(full); err == nil {
- if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
- c.JSON(500, gin.H{"error": errDel.Error()})
- return
- }
- deleted++
- h.removeAuth(ctx, full)
- }
- }
- c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
- return
- }
-
names, errNames := requestedAuthFileNamesForDelete(c)
if errNames != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": errNames.Error()})
@@ -178,35 +144,15 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid name"})
return
}
- if len(names) == 1 {
- if _, status, errDelete := h.deleteAuthFileByName(ctx, names[0]); errDelete != nil {
- c.JSON(status, gin.H{"error": errDelete.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
+ if len(names) != 1 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "exactly one account is required"})
return
}
-
- deletedFiles := make([]string, 0, len(names))
- failed := make([]gin.H, 0)
- for _, name := range names {
- deletedName, _, errDelete := h.deleteAuthFileByName(ctx, name)
- if errDelete != nil {
- failed = append(failed, gin.H{"name": name, "error": errDelete.Error()})
- continue
- }
- deletedFiles = append(deletedFiles, deletedName)
- }
- if len(failed) > 0 {
- c.JSON(http.StatusMultiStatus, gin.H{
- "status": "partial",
- "deleted": len(deletedFiles),
- "files": deletedFiles,
- "failed": failed,
- })
+ if _, status, errDelete := h.deleteAuthFileByName(ctx, names[0]); errDelete != nil {
+ c.JSON(status, gin.H{"error": errDelete.Error()})
return
}
- c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted": len(deletedFiles), "files": deletedFiles})
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func (h *Handler) multipartAuthFileHeaders(c *gin.Context) ([]*multipart.FileHeader, error) {
@@ -347,7 +293,11 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
targetID := ""
- if targetAuth := h.findAuthForDelete(name); targetAuth != nil {
+ targetAuth := h.findAuthForDelete(name)
+ if targetAuth == nil || !strings.EqualFold(strings.TrimSpace(targetAuth.Provider), "codex") || targetAuth.AuthKind() != "oauth" {
+ return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
+ }
+ if targetAuth != nil {
if !isPluginVirtualSourceDelete(name, targetAuth) {
return filepath.Base(name), http.StatusConflict, errPluginVirtualAuth
}
diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go
index 4747aad..d63c080 100644
--- a/backend/internal/api/server.go
+++ b/backend/internal/api/server.go
@@ -236,9 +236,6 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
if hasManagementSecret {
s.registerManagementRoutes()
}
- s.refreshPluginManagementRoutes()
- engine.NoRoute(s.pluginManagementNoRoute)
-
if optionState.keepAliveEnabled {
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
}
diff --git a/backend/internal/api/server_management.go b/backend/internal/api/server_management.go
index 550ea6c..56fe2a2 100644
--- a/backend/internal/api/server_management.go
+++ b/backend/internal/api/server_management.go
@@ -1,7 +1,6 @@
package api
import (
- "context"
"errors"
"io/fs"
"net/http"
@@ -26,165 +25,16 @@ func (s *Server) registerManagementRoutes() {
log.Info("management routes registered after secret key configuration")
- s.engine.POST("/v0/management/oauth-callback", s.managementAvailabilityMiddleware(), s.mgmt.PostOAuthCallback)
- s.engine.GET("/v0/management/oauth-callback", s.managementAvailabilityMiddleware(), s.mgmt.GetOAuthCallback)
-
mgmt := s.engine.Group("/v0/management")
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
{
- mgmt.GET("/config", s.mgmt.GetConfig)
- mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
- mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
- mgmt.GET("/latest-version", s.mgmt.GetLatestVersion)
- mgmt.GET("/plugins", s.mgmt.ListPlugins)
- mgmt.GET("/plugin-store", s.mgmt.ListPluginStore)
- mgmt.POST("/plugin-store/:id/install", s.mgmt.InstallPluginFromStore)
- mgmt.DELETE("/plugins/:id", s.mgmt.DeletePlugin)
- mgmt.PATCH("/plugins/:id/enabled", s.mgmt.PatchPluginEnabled)
- mgmt.GET("/plugins/:id/config", s.mgmt.GetPluginConfig)
- mgmt.PUT("/plugins/:id/config", s.mgmt.PutPluginConfig)
- mgmt.PATCH("/plugins/:id/config", s.mgmt.PatchPluginConfig)
-
- mgmt.GET("/debug", s.mgmt.GetDebug)
- mgmt.PUT("/debug", s.mgmt.PutDebug)
- mgmt.PATCH("/debug", s.mgmt.PutDebug)
-
- mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)
- mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)
- mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile)
-
- mgmt.GET("/logs-max-total-size-mb", s.mgmt.GetLogsMaxTotalSizeMB)
- mgmt.PUT("/logs-max-total-size-mb", s.mgmt.PutLogsMaxTotalSizeMB)
- mgmt.PATCH("/logs-max-total-size-mb", s.mgmt.PutLogsMaxTotalSizeMB)
-
- mgmt.GET("/error-logs-max-files", s.mgmt.GetErrorLogsMaxFiles)
- mgmt.PUT("/error-logs-max-files", s.mgmt.PutErrorLogsMaxFiles)
- mgmt.PATCH("/error-logs-max-files", s.mgmt.PutErrorLogsMaxFiles)
-
- mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)
- mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
- mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
-
- mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
- mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
- mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
- mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL)
-
- mgmt.POST("/api-call", s.mgmt.APICall)
-
- mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)
- mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
- mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
-
- mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel)
- mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
- mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
- mgmt.POST("/reset-quota", s.mgmt.ResetQuota)
-
- mgmt.GET("/api-keys", s.mgmt.GetAPIKeys)
- mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
- mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
- mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
- mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage)
- mgmt.GET("/usage-queue", s.mgmt.GetUsageQueue)
-
- mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
- mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)
- mgmt.PATCH("/gemini-api-key", s.mgmt.PatchGeminiKey)
- mgmt.DELETE("/gemini-api-key", s.mgmt.DeleteGeminiKey)
-
- mgmt.GET("/interactions-api-key", s.mgmt.GetInteractionsKeys)
- mgmt.PUT("/interactions-api-key", s.mgmt.PutInteractionsKeys)
- mgmt.PATCH("/interactions-api-key", s.mgmt.PatchInteractionsKey)
- mgmt.DELETE("/interactions-api-key", s.mgmt.DeleteInteractionsKey)
-
- mgmt.GET("/logs", s.mgmt.GetLogs)
- mgmt.DELETE("/logs", s.mgmt.DeleteLogs)
- mgmt.GET("/request-error-logs", s.mgmt.GetRequestErrorLogs)
- mgmt.GET("/request-error-logs/:name", s.mgmt.DownloadRequestErrorLog)
- mgmt.GET("/request-log-by-id/:id", s.mgmt.GetRequestLogByID)
- mgmt.GET("/request-log", s.mgmt.GetRequestLog)
- mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
- mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
- mgmt.GET("/ws-auth", s.mgmt.GetWebsocketAuth)
- mgmt.PUT("/ws-auth", s.mgmt.PutWebsocketAuth)
- mgmt.PATCH("/ws-auth", s.mgmt.PutWebsocketAuth)
-
- mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
- mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
- mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
- mgmt.GET("/max-retry-credentials", s.mgmt.GetMaxRetryCredentials)
- mgmt.PUT("/max-retry-credentials", s.mgmt.PutMaxRetryCredentials)
- mgmt.PATCH("/max-retry-credentials", s.mgmt.PutMaxRetryCredentials)
- mgmt.GET("/max-retry-interval", s.mgmt.GetMaxRetryInterval)
- mgmt.PUT("/max-retry-interval", s.mgmt.PutMaxRetryInterval)
- mgmt.PATCH("/max-retry-interval", s.mgmt.PutMaxRetryInterval)
-
- mgmt.GET("/force-model-prefix", s.mgmt.GetForceModelPrefix)
- mgmt.PUT("/force-model-prefix", s.mgmt.PutForceModelPrefix)
- mgmt.PATCH("/force-model-prefix", s.mgmt.PutForceModelPrefix)
-
- mgmt.GET("/routing/strategy", s.mgmt.GetRoutingStrategy)
- mgmt.PUT("/routing/strategy", s.mgmt.PutRoutingStrategy)
- mgmt.PATCH("/routing/strategy", s.mgmt.PutRoutingStrategy)
-
- mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys)
- mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys)
- mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)
- mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey)
-
- mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys)
- mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys)
- mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
- mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)
-
- mgmt.GET("/xai-api-key", s.mgmt.GetXAIKeys)
- mgmt.PUT("/xai-api-key", s.mgmt.PutXAIKeys)
- mgmt.PATCH("/xai-api-key", s.mgmt.PatchXAIKey)
- mgmt.DELETE("/xai-api-key", s.mgmt.DeleteXAIKey)
-
- mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
- mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
- mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
- mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
-
- mgmt.GET("/vertex-api-key", s.mgmt.GetVertexCompatKeys)
- mgmt.PUT("/vertex-api-key", s.mgmt.PutVertexCompatKeys)
- mgmt.PATCH("/vertex-api-key", s.mgmt.PatchVertexCompatKey)
- mgmt.DELETE("/vertex-api-key", s.mgmt.DeleteVertexCompatKey)
-
- mgmt.GET("/oauth-excluded-models", s.mgmt.GetOAuthExcludedModels)
- mgmt.PUT("/oauth-excluded-models", s.mgmt.PutOAuthExcludedModels)
- mgmt.PATCH("/oauth-excluded-models", s.mgmt.PatchOAuthExcludedModels)
- mgmt.DELETE("/oauth-excluded-models", s.mgmt.DeleteOAuthExcludedModels)
-
- mgmt.GET("/oauth-model-alias", s.mgmt.GetOAuthModelAlias)
- mgmt.PUT("/oauth-model-alias", s.mgmt.PutOAuthModelAlias)
- mgmt.PATCH("/oauth-model-alias", s.mgmt.PatchOAuthModelAlias)
- mgmt.DELETE("/oauth-model-alias", s.mgmt.DeleteOAuthModelAlias)
-
- mgmt.GET("/oauth-request-scoped-errors", s.mgmt.GetOAuthRequestScopedErrors)
- mgmt.PUT("/oauth-request-scoped-errors", s.mgmt.PutOAuthRequestScopedErrors)
- mgmt.PATCH("/oauth-request-scoped-errors", s.mgmt.PatchOAuthRequestScopedErrors)
- mgmt.DELETE("/oauth-request-scoped-errors", s.mgmt.DeleteOAuthRequestScopedErrors)
-
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
- mgmt.GET("/auth-files/models", s.mgmt.GetAuthFileModels)
- mgmt.GET("/model-definitions/:channel", s.mgmt.GetStaticModelDefinitions)
- mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
- mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
- mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus)
- mgmt.PATCH("/auth-files/fields", s.mgmt.PatchAuthFileFields)
- mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential)
-
- mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
+ mgmt.POST("/codex-quota", s.mgmt.CodexQuota)
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
- mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
- mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
- mgmt.GET("/xai-auth-url", s.mgmt.RequestXAIToken)
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
mgmt.DELETE("/oauth-session", s.mgmt.CancelAuthSession)
+ mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
}
}
@@ -202,10 +52,6 @@ func (s *Server) managementAvailable(c *gin.Context) bool {
c.AbortWithStatus(http.StatusNotFound)
return false
}
- if s.cfg.Home.Enabled {
- c.AbortWithStatus(http.StatusNotFound)
- return false
- }
if !s.managementRoutesEnabled.Load() {
c.AbortWithStatus(http.StatusNotFound)
return false
@@ -213,90 +59,15 @@ func (s *Server) managementAvailable(c *gin.Context) bool {
return true
}
-func (s *Server) refreshPluginManagementRoutes() {
- if s == nil || s.pluginHost == nil || s.engine == nil {
- return
- }
- s.pluginHost.RegisterManagementRoutes(context.Background(), s.registeredManagementRouteKeys())
-}
-
-// RefreshPluginManagementRoutes rebuilds plugin-owned Management API routes.
-func (s *Server) RefreshPluginManagementRoutes() {
- s.refreshPluginManagementRoutes()
-}
-
-func (s *Server) registeredManagementRouteKeys() map[string]struct{} {
- out := make(map[string]struct{})
- if s == nil || s.engine == nil {
- return out
- }
- for _, route := range s.engine.Routes() {
- if strings.HasPrefix(route.Path, "/v0/management/") || route.Path == "/v0/management" {
- out[strings.ToUpper(strings.TrimSpace(route.Method))+" "+route.Path] = struct{}{}
- }
- }
- return out
-}
+func (s *Server) RefreshPluginManagementRoutes() {}
func (s *Server) pluginManagementNoRoute(c *gin.Context) {
- if s == nil || c == nil || c.Request == nil || c.Request.URL == nil {
- if c != nil {
- c.AbortWithStatus(http.StatusNotFound)
- }
- return
- }
- path := c.Request.URL.Path
- if strings.HasPrefix(path, "/v0/resource/plugins/") {
- s.pluginResourceNoRoute(c)
- return
- }
- if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") {
- c.AbortWithStatus(http.StatusNotFound)
- return
- }
- if s.pluginHost == nil || s.mgmt == nil {
- c.AbortWithStatus(http.StatusNotFound)
- return
- }
- if !s.managementAvailable(c) {
- return
- }
- s.mgmt.Middleware()(c)
- if c.IsAborted() {
- return
- }
- if s.mgmt.ServePluginAuthURL(c) {
- c.Abort()
- return
- }
- if s.pluginHost.ServeManagementHTTP(c.Writer, c.Request) {
- c.Abort()
- return
- }
- c.AbortWithStatus(http.StatusNotFound)
-}
-
-func (s *Server) pluginResourceNoRoute(c *gin.Context) {
- if s == nil || c == nil || c.Request == nil || c.Request.URL == nil {
- if c != nil {
- c.AbortWithStatus(http.StatusNotFound)
- }
- return
- }
- if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil {
- c.AbortWithStatus(http.StatusNotFound)
- return
- }
- if s.pluginHost.ServeResourceHTTP(c.Writer, c.Request) {
- c.Abort()
- return
- }
c.AbortWithStatus(http.StatusNotFound)
}
func (s *Server) serveManagementControlPanel(c *gin.Context) {
cfg := s.cfg
- if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
+ if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
c.AbortWithStatus(http.StatusNotFound)
return
}
@@ -314,7 +85,7 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
func (s *Server) serveManagementAsset(c *gin.Context) {
cfg := s.cfg
- if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
+ if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
c.AbortWithStatus(http.StatusNotFound)
return
}
diff --git a/backend/internal/api/server_reload.go b/backend/internal/api/server_reload.go
index 386dd2b..a1a9daf 100644
--- a/backend/internal/api/server_reload.go
+++ b/backend/internal/api/server_reload.go
@@ -188,7 +188,6 @@ func (s *Server) UpdateClientsContext(ctx context.Context, cfg *config.Config) b
s.mgmt.SetAuthManager(s.handlers.AuthManager)
s.mgmt.SetPluginHost(s.pluginHost)
}
- s.refreshPluginManagementRoutes()
// Count client sources from configuration and auth store.
authEntries := 0
diff --git a/backend/internal/api/server_routes.go b/backend/internal/api/server_routes.go
index 1152811..e38e5b2 100644
--- a/backend/internal/api/server_routes.go
+++ b/backend/internal/api/server_routes.go
@@ -1,51 +1,21 @@
package api
import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
"net/http"
- "sort"
- "strconv"
- "strings"
- "time"
"github.com/gin-gonic/gin"
managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management"
- claudemodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/claude/models"
- codexlive "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/live"
- codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/client/grokbuild"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
- "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude"
- "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai"
- "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
- coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
- "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
- log "github.com/sirupsen/logrus"
)
const oauthCallbackSuccessHTML = `Authentication successfulAuthentication successful!
You can close this window.
This window will close automatically in 5 seconds.
`
-const codexAlphaSearchSourceFormat = "codex-alpha-search"
-
-// setupRoutes configures the API routes for the server.
-// It defines the endpoints and associates them with their respective handlers.
func (s *Server) setupRoutes() {
healthzHandler := func(c *gin.Context) {
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
return
}
-
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
s.engine.GET("/healthz", healthzHandler)
@@ -55,110 +25,30 @@ func (s *Server) setupRoutes() {
s.engine.HEAD("/management.html", s.serveManagementControlPanel)
s.engine.GET("/management-assets/*filepath", s.serveManagementAsset)
s.engine.HEAD("/management-assets/*filepath", s.serveManagementAsset)
- openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
- geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)
- claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers)
- openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers)
- s.codexLiveHandler = codexlive.NewHandler(s.handlers.AuthManager, s.cfg)
- // OpenAI compatible API routes
+ openAIHandlers := openai.NewOpenAIAPIHandler(s.handlers)
+ responsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers)
v1 := s.engine.Group("/v1")
v1.Use(AuthMiddleware(s.accessManager))
{
- v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))
- v1.POST("/chat/completions", openaiHandlers.ChatCompletions)
- v1.POST("/completions", openaiHandlers.Completions)
- v1.POST("/images/generations", openaiHandlers.ImagesGenerations)
- v1.POST("/images/edits", openaiHandlers.ImagesEdits)
- v1.POST("/videos", openaiHandlers.XAIVideosGenerations)
- v1.POST("/videos/generations", openaiHandlers.XAIVideosGenerations)
- v1.POST("/videos/edits", openaiHandlers.XAIVideosEdits)
- v1.POST("/videos/extensions", openaiHandlers.XAIVideosExtensions)
- v1.GET("/videos/:request_id", openaiHandlers.XAIVideosRetrieve)
- v1.POST("/messages", claudeCodeHandlers.ClaudeMessages)
- v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens)
- v1.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket)
- v1.POST("/responses", openaiResponsesHandlers.Responses)
- v1.POST("/responses/compact", openaiResponsesHandlers.Compact)
- v1.POST("/alpha/search", s.codexAlphaSearch)
- v1.POST("/live", s.codexLiveHandler.Handle)
- v1.GET("/live/:call_id", s.codexLiveHandler.HandleSideband)
+ v1.GET("/models", openAIHandlers.OpenAIModels)
+ v1.POST("/chat/completions", openAIHandlers.ChatCompletions)
+ v1.GET("/responses", responsesHandlers.ResponsesWebsocket)
+ v1.POST("/responses", responsesHandlers.Responses)
}
- realtimeAuth := realtimeAuthMiddleware(s.accessManager, s.codexLiveHandler)
- standardAuth := realtimeStandardAuthMiddleware(s.accessManager)
- s.engine.GET("/v1/realtime", realtimeAuth, s.codexLiveHandler.HandleRealtimeWebsocket)
- s.engine.POST("/v1/realtime", realtimeAuth, s.codexLiveHandler.Handle)
- s.engine.POST("/v1/realtime/calls", realtimeAuth, s.codexLiveHandler.Handle)
- s.engine.GET("/v1/realtime/calls/:call_id", realtimeAuth, s.codexLiveHandler.HandleSideband)
- s.engine.POST("/v1/realtime/client_secrets", standardAuth, s.codexLiveHandler.CreateClientSecret)
- s.engine.POST("/v1/realtime/sessions", standardAuth, s.codexLiveHandler.CreateLegacySession)
- s.engine.POST("/v1/realtime/transcription_sessions", standardAuth, s.codexLiveHandler.HandleTranscriptionSession)
- s.engine.GET("/v1/realtime/translations", realtimeAuth, s.codexLiveHandler.HandleTranslation)
- s.engine.POST("/v1/realtime/translations", realtimeAuth, s.codexLiveHandler.HandleTranslation)
- s.engine.POST("/v1/realtime/translations/client_secrets", standardAuth, s.codexLiveHandler.HandleTranslation)
- s.engine.POST("/v1/realtime/calls/:call_id/hangup", standardAuth, s.codexLiveHandler.HandleHangup)
- s.engine.POST("/v1/realtime/calls/:call_id/accept", standardAuth, s.codexLiveHandler.HandleSIPControl)
- s.engine.POST("/v1/realtime/calls/:call_id/reject", standardAuth, s.codexLiveHandler.HandleSIPControl)
- s.engine.POST("/v1/realtime/calls/:call_id/refer", standardAuth, s.codexLiveHandler.HandleSIPControl)
-
- openaiV1 := s.engine.Group("/openai/v1")
- openaiV1.Use(AuthMiddleware(s.accessManager))
- {
- openaiV1.POST("/videos", openaiHandlers.VideosCreate)
- openaiV1.GET("/videos/:video_id/content", openaiHandlers.VideosContent)
- openaiV1.GET("/videos/:video_id", openaiHandlers.VideosRetrieve)
- }
-
- // Codex CLI direct route aliases (chatgpt_base_url compatible)
- codexDirect := s.engine.Group("/backend-api/codex")
- codexDirect.Use(AuthMiddleware(s.accessManager))
- {
- codexDirect.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket)
- codexDirect.POST("/responses", openaiResponsesHandlers.Responses)
- codexDirect.POST("/responses/compact", openaiResponsesHandlers.Compact)
- codexDirect.POST("/alpha/search", s.codexAlphaSearch)
- }
-
- // Gemini compatible API routes
- v1beta := s.engine.Group("/v1beta")
- v1beta.Use(AuthMiddleware(s.accessManager))
- {
- v1beta.GET("/models", s.geminiModelsHandler(geminiHandlers))
- v1beta.POST("/interactions", geminiHandlers.Interactions)
- v1beta.POST("/models/*action", geminiHandlers.GeminiHandler)
- v1beta.GET("/models/*action", s.geminiGetHandler(geminiHandlers))
- }
-
- // Root endpoint
s.engine.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
- "message": "CLI Proxy API Server",
+ "message": "Vibe Proxy",
"endpoints": []string{
- "POST /v1/chat/completions",
- "POST /v1/completions",
"GET /v1/models",
+ "POST /v1/chat/completions",
+ "GET /v1/responses",
+ "POST /v1/responses",
},
})
})
- // OAuth callback endpoints (reuse main server port)
- // These endpoints receive provider redirects and persist
- // the short-lived code/state for the waiting goroutine.
- s.engine.GET("/anthropic/callback", func(c *gin.Context) {
- code := c.Query("code")
- state := c.Query("state")
- errStr := c.Query("error")
- if errStr == "" {
- errStr = c.Query("error_description")
- }
- if state != "" {
- _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "anthropic", state, code, errStr)
- }
- c.Header("Content-Type", "text/html; charset=utf-8")
- c.String(http.StatusOK, oauthCallbackSuccessHTML)
- })
-
s.engine.GET("/codex/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
@@ -172,882 +62,4 @@ func (s *Server) setupRoutes() {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
})
-
- s.engine.GET("/antigravity/callback", func(c *gin.Context) {
- code := c.Query("code")
- state := c.Query("state")
- errStr := c.Query("error")
- if errStr == "" {
- errStr = c.Query("error_description")
- }
- if state != "" {
- _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "antigravity", state, code, errStr)
- }
- c.Header("Content-Type", "text/html; charset=utf-8")
- c.String(http.StatusOK, oauthCallbackSuccessHTML)
- })
-
- // Management routes are registered lazily by registerManagementRoutes when a secret is configured.
-}
-
-func (s *Server) codexAlphaSearchModelRouterHost() handlers.PluginModelRouterHost {
- if s == nil {
- return nil
- }
- if s.pluginHost != nil {
- return s.pluginHost
- }
- if s.handlers != nil && s.handlers.ModelRouterHost != nil {
- return s.handlers.ModelRouterHost
- }
- return nil
-}
-
-func (s *Server) codexAlphaSearchSelectionModel(ctx context.Context, c *gin.Context, body []byte, model string) (string, error) {
- host := s.codexAlphaSearchModelRouterHost()
- if host == nil {
- return model, nil
- }
-
- var headers http.Header
- queryValues := make(map[string][]string)
- requestPath := ""
- if c != nil && c.Request != nil {
- headers = c.Request.Header.Clone()
- if c.Request.URL != nil {
- queryValues = c.Request.URL.Query()
- requestPath = c.Request.URL.Path
- }
- }
- metadata := map[string]any{
- coreexecutor.RequestedModelMetadataKey: model,
- }
- if requestPath != "" {
- metadata[coreexecutor.RequestPathMetadataKey] = requestPath
- }
- resp, handled := host.RouteModel(ctx, pluginapi.ModelRouteRequest{
- SourceFormat: codexAlphaSearchSourceFormat,
- RequestedModel: model,
- Headers: headers,
- Query: queryValues,
- Body: body,
- Metadata: metadata,
- })
- if !handled || !resp.Handled {
- return model, nil
- }
- if resp.TargetKind != pluginapi.ModelRouteTargetProvider || !strings.EqualFold(strings.TrimSpace(resp.Target), "codex") {
- return "", fmt.Errorf("unsupported Codex Alpha Search model route target %q (%q)", resp.TargetKind, resp.Target)
- }
- if targetModel := strings.TrimSpace(resp.TargetModel); targetModel != "" {
- return targetModel, nil
- }
- return model, nil
-}
-
-func sanitizeCodexAlphaSearchBody(body []byte) []byte {
- var payload map[string]json.RawMessage
- if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil || payload == nil {
- return body
- }
-
- removed := false
- for _, field := range []string{"prompt_cache_key", "prompt_cache_retention"} {
- if _, exists := payload[field]; exists {
- delete(payload, field)
- removed = true
- }
- }
- if !removed {
- return body
- }
-
- sanitizedBody, errMarshal := json.Marshal(payload)
- if errMarshal != nil {
- return body
- }
- return sanitizedBody
-}
-
-// rewriteCodexAlphaSearchModel replaces the top-level model field with the
-// credential-resolved upstream model before the request is forwarded.
-func rewriteCodexAlphaSearchModel(body []byte, upstreamModel string) []byte {
- upstreamModel = strings.TrimSpace(upstreamModel)
- if upstreamModel == "" {
- return body
- }
-
- var payload map[string]json.RawMessage
- if errUnmarshal := json.Unmarshal(body, &payload); errUnmarshal != nil || payload == nil {
- return body
- }
- if _, exists := payload["model"]; !exists {
- return body
- }
-
- modelJSON, errMarshalModel := json.Marshal(upstreamModel)
- if errMarshalModel != nil {
- return body
- }
- if string(payload["model"]) == string(modelJSON) {
- return body
- }
-
- payload["model"] = modelJSON
- rewrittenBody, errMarshal := json.Marshal(payload)
- if errMarshal != nil {
- return body
- }
- return rewrittenBody
-}
-
-func homeSelectionAttemptContext(ctx context.Context, selection *auth.HomeDispatchSelection) (context.Context, func(), error) {
- if selection == nil {
- return nil, func() {}, errors.New("Home dispatch selection is nil")
- }
- return selection.AttemptContext(ctx)
-}
-
-// codexAlphaSearch forwards the standalone search endpoint used by current
-// Codex clients. Unlike /responses, this payload is already in Codex search
-// format and must not pass through a protocol translator.
-func (s *Server) codexAlphaSearch(c *gin.Context) {
- if s == nil || s.handlers == nil || s.handlers.AuthManager == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Codex auth manager unavailable"})
- return
- }
-
- body, err := io.ReadAll(io.LimitReader(c.Request.Body, 16<<20))
- if err != nil {
- c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "Failed to read search request"})
- return
- }
-
- var routing struct {
- ID string `json:"id"`
- Model string `json:"model"`
- }
- _ = json.Unmarshal(body, &routing)
- upstreamRequestBody := sanitizeCodexAlphaSearchBody(body)
-
- selectionHeaders := c.Request.Header.Clone()
- if sessionID := strings.TrimSpace(routing.ID); sessionID != "" {
- selectionHeaders.Set("X-Session-ID", sessionID)
- }
- ctx := context.WithValue(c.Request.Context(), "gin", c)
- selectionModel, errRoute := s.codexAlphaSearchSelectionModel(ctx, c, body, strings.TrimSpace(routing.Model))
- if errRoute != nil {
- log.WithError(errRoute).Warn("codex alpha search: model router returned an unsupported target")
- c.JSON(clienterror.HTTPStatusFromErrorOr(errRoute, http.StatusServiceUnavailable), gin.H{"error": errRoute.Error()})
- return
- }
- selectionOpts := coreexecutor.Options{Headers: selectionHeaders, OriginalRequest: body}
- var selection *auth.HomeDispatchSelection
- var selected *auth.Auth
- if s.handlers.AuthManager.HomeEnabled() {
- selection, err = s.handlers.AuthManager.SelectHomeAuthWithCredentialPolicy(ctx, "codex", selectionModel, auth.CredentialPolicyCodexAlphaSearchV1, selectionOpts)
- if selection != nil {
- selected = selection.CloneAuth()
- }
- } else {
- selected, err = s.handlers.AuthManager.SelectAuthWithCredentialPolicy(ctx, "codex", selectionModel, auth.CredentialPolicyCodexAlphaSearchV1, selectionOpts)
- }
- if err != nil {
- status := clienterror.HTTPStatusFromErrorOr(err, http.StatusServiceUnavailable)
- for _, value := range auth.SafeResponseHeaders(err).Values("Retry-After") {
- c.Writer.Header().Add("Retry-After", value)
- }
- c.JSON(status, gin.H{"error": err.Error()})
- return
- }
- if selected == nil {
- if selection != nil {
- selection.End("missing_auth")
- }
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Codex auth unavailable"})
- return
- }
- var releaseAttempt func()
- if selection != nil {
- attemptCtx, release, errBind := homeSelectionAttemptContext(ctx, selection)
- if errBind != nil {
- selection.End("attempt_bind_failed")
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": errBind.Error()})
- return
- }
- ctx = attemptCtx
- releaseAttempt = release
- defer releaseAttempt()
- }
- logging.SetGinCPATraceID(c, selected.EnsureIndex())
-
- baseHeaders := make(http.Header)
- baseHeaders.Set("Content-Type", "application/json")
- baseHeaders.Set("Accept", "application/json")
- baseHeaders.Set("Originator", "codex_cli_rs")
- for _, name := range []string{"Version", "User-Agent", "Session_id", "X-Client-Request-Id"} {
- if value := strings.TrimSpace(c.GetHeader(name)); value != "" {
- baseHeaders.Set(name, value)
- }
- }
-
- errMissingBaseURL := errors.New("Codex Alpha Search API key base URL unavailable")
- routeModel := strings.TrimSpace(selectionModel)
- if routeModel == "" {
- routeModel = strings.TrimSpace(routing.Model)
- }
- performRequest := func(current *auth.Auth) (*http.Response, error) {
- headers := baseHeaders.Clone()
- if accountID, ok := current.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" {
- headers.Set("Chatgpt-Account-Id", accountID)
- }
- upstreamURL := "https://chatgpt.com/backend-api/codex/alpha/search"
- requestBody := upstreamRequestBody
- // API-key Alpha Search reuses normal credential-aware model resolution so
- // CPA routing prefixes and model aliases are not forwarded upstream.
- if current.AuthKind() == auth.AuthKindAPIKey {
- baseURL := ""
- if current.Attributes != nil {
- baseURL = strings.TrimSpace(current.Attributes["base_url"])
- }
- if baseURL == "" {
- return nil, errMissingBaseURL
- }
- upstreamURL = strings.TrimRight(baseURL, "/") + "/alpha/search"
- if upstreamModel := s.handlers.AuthManager.ResolveExecutionModel(current, routeModel); upstreamModel != "" {
- requestBody = rewriteCodexAlphaSearchModel(upstreamRequestBody, upstreamModel)
- }
- }
- req, errRequest := s.handlers.AuthManager.NewHttpRequest(ctx, current, http.MethodPost, upstreamURL, requestBody, headers)
- if errRequest != nil {
- return nil, errRequest
- }
- authType, authValue := current.AccountInfo()
- helps.RecordAPIRequest(ctx, s.cfg, helps.UpstreamRequestLog{
- URL: upstreamURL,
- Method: http.MethodPost,
- Headers: req.Header.Clone(),
- Body: requestBody,
- Provider: "codex",
- AuthID: current.ID,
- AuthLabel: current.Label,
- AuthType: authType,
- AuthValue: authValue,
- })
- return s.handlers.AuthManager.HttpRequest(ctx, current, req)
- }
-
- if errCtx := ctx.Err(); errCtx != nil {
- if selection != nil {
- selection.End("attempt_canceled")
- }
- c.JSON(clienterror.HTTPStatusFromErrorOr(errCtx, http.StatusRequestTimeout), gin.H{"error": errCtx.Error()})
- return
- }
- resp, err := performRequest(selected)
- if err != nil {
- if errors.Is(err, errMissingBaseURL) {
- if selection != nil {
- selection.End("missing_base_url")
- }
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
- return
- }
- if selection != nil {
- selection.End("request_failed")
- }
- helps.RecordAPIResponseError(ctx, s.cfg, err)
- c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()})
- return
- }
- if selection != nil && resp.StatusCode == http.StatusUnauthorized {
- s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel)
- helps.RecordAPIResponseMetadata(ctx, s.cfg, resp.StatusCode, resp.Header.Clone())
- _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
- if errClose := resp.Body.Close(); errClose != nil {
- log.Errorf("codex alpha search: close unauthorized response body error: %v", errClose)
- }
- refreshed, didRefresh, errRefresh := s.handlers.AuthManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected)
- if errRefresh != nil {
- selection.End("refresh_failed")
- c.JSON(clienterror.HTTPStatusFromErrorOr(errRefresh, http.StatusServiceUnavailable), gin.H{"error": errRefresh.Error()})
- return
- }
- if !didRefresh || refreshed == nil {
- selection.End("refresh_unavailable")
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Codex credential unauthorized"})
- return
- }
- selected = refreshed
- logging.SetGinCPATraceID(c, selected.EnsureIndex())
- resp, err = performRequest(selected)
- if err != nil {
- if errors.Is(err, errMissingBaseURL) {
- selection.End("missing_base_url")
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
- return
- }
- selection.End("retry_failed")
- helps.RecordAPIResponseError(ctx, s.cfg, err)
- c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()})
- return
- }
- if resp.StatusCode == http.StatusUnauthorized {
- s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel)
- }
- }
- closeResponseBody := func() error {
- errClose := resp.Body.Close()
- if errClose != nil {
- log.Errorf("codex alpha search: close response body error: %v", errClose)
- }
- return errClose
- }
- if selection != nil {
- if errBind := selection.Bind(closeResponseBody); errBind != nil {
- selection.End("response_bind_failed")
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": errBind.Error()})
- return
- }
- defer selection.End("response_closed")
- } else {
- defer func() { _ = closeResponseBody() }()
- }
- helps.RecordAPIResponseMetadata(ctx, s.cfg, resp.StatusCode, resp.Header.Clone())
- upstreamBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
- if err != nil {
- helps.RecordAPIResponseError(ctx, s.cfg, err)
- c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": "Failed to read Codex search response"})
- return
- }
- helps.AppendAPIResponseChunk(ctx, s.cfg, upstreamBody)
- if contentType := resp.Header.Get("Content-Type"); contentType != "" {
- c.Header("Content-Type", contentType)
- }
- c.Status(resp.StatusCode)
- _, _ = c.Writer.Write(upstreamBody)
-}
-
-// AttachWebsocketRoute registers a websocket upgrade handler on the primary Gin engine.
-// The handler is served as-is without additional middleware beyond the standard stack already configured.
-func (s *Server) AttachWebsocketRoute(path string, handler http.Handler) {
- if s == nil || s.engine == nil || handler == nil {
- return
- }
- trimmed := strings.TrimSpace(path)
- if trimmed == "" {
- trimmed = "/v1/ws"
- }
- if !strings.HasPrefix(trimmed, "/") {
- trimmed = "/" + trimmed
- }
- s.wsRouteMu.Lock()
- if _, exists := s.wsRoutes[trimmed]; exists {
- s.wsRouteMu.Unlock()
- return
- }
- s.wsRoutes[trimmed] = struct{}{}
- s.wsRouteMu.Unlock()
-
- authMiddleware := AuthMiddleware(s.accessManager)
- conditionalAuth := func(c *gin.Context) {
- if !s.wsAuthEnabled.Load() {
- c.Next()
- return
- }
- authMiddleware(c)
- }
- finalHandler := func(c *gin.Context) {
- handler.ServeHTTP(c.Writer, c.Request)
- c.Abort()
- }
-
- s.engine.GET(trimmed, conditionalAuth, finalHandler)
-}
-
-// isAnthropicModelsRequest reports whether a /v1/models request should be served in
-// Anthropic format. Anthropic API clients send the Anthropic-Version header; Claude
-// Code additionally uses a claude-cli User-Agent.
-func isAnthropicModelsRequest(c *gin.Context) bool {
- if c.GetHeader("Anthropic-Version") != "" {
- return true
- }
- return strings.HasPrefix(c.GetHeader("User-Agent"), "claude-cli")
-}
-
-// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
-// that routes to different handlers based on the request.
-// Anthropic API requests (Anthropic-Version header, or a claude-cli User-Agent)
-// route to the Claude handler, otherwise they route to the OpenAI handler.
-func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
- return func(c *gin.Context) {
- if grokbuild.IsGrokShellUserAgent(c.GetHeader("User-Agent")) {
- s.handleGrokModels(c)
- return
- }
-
- if _, ok := c.Request.URL.Query()["client_version"]; ok {
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
- s.handleHomeCodexClientModels(c)
- return
- }
- openaiHandler.OpenAIModels(c)
- return
- }
-
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
- s.handleHomeModels(c)
- return
- }
-
- // Route to Claude handler for Anthropic API requests.
- if isAnthropicModelsRequest(c) {
- claudeHandler.ClaudeModels(c)
- } else {
- openaiHandler.OpenAIModels(c)
- }
- }
-}
-
-func grokModelsFromHomeEntries(entries []homeModelEntry) []grokbuild.ModelInfo {
- models := make([]grokbuild.ModelInfo, 0, len(entries))
- for _, entry := range entries {
- models = append(models, grokbuild.ModelInfo{
- ID: entry.id,
- DisplayName: entry.displayName,
- ContextLength: entry.contextLength,
- })
- }
- return models
-}
-
-func grokModelsFromRegistryInfos(infos []*registry.ModelInfo) []grokbuild.ModelInfo {
- models := make([]grokbuild.ModelInfo, 0, len(infos))
- for _, info := range infos {
- if info == nil {
- continue
- }
- model := grokbuild.ModelInfo{
- ID: info.ID,
- DisplayName: info.DisplayName,
- ContextLength: info.ContextLength,
- }
- if info.Thinking != nil {
- model.ReasoningLevels = append([]string(nil), info.Thinking.Levels...)
- }
- models = append(models, model)
- }
- return models
-}
-
-func (s *Server) handleGrokModels(c *gin.Context) {
- var models []grokbuild.ModelInfo
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
- entries, ok := s.loadHomeModelEntries(c)
- if !ok {
- return
- }
- models = grokModelsFromHomeEntries(entries)
- } else {
- models = grokModelsFromRegistryInfos(registry.GetGlobalRegistry().GetAvailableModelInfos())
- }
- c.JSON(http.StatusOK, grokbuild.BuildResponse(models))
-}
-
-// handleHomeCodexClientModels builds the Codex client catalog from Home model IDs.
-// Template metadata still comes from the local/remote codex_client_models catalog.
-func (s *Server) handleHomeCodexClientModels(c *gin.Context) {
- entries, ok := s.loadHomeModelEntries(c)
- if !ok {
- return
- }
-
- models := make([]map[string]any, 0, len(entries))
- for _, entry := range entries {
- model := map[string]any{
- "id": entry.id,
- "object": "model",
- }
- if entry.created > 0 {
- model["created"] = entry.created
- }
- if entry.ownedBy != "" {
- model["owned_by"] = entry.ownedBy
- }
- if entry.displayName != "" {
- model["display_name"] = entry.displayName
- model["description"] = entry.displayName
- }
- if entry.maxCompletionTokens > 0 {
- model["max_completion_tokens"] = entry.maxCompletionTokens
- }
- models = append(models, model)
- }
-
- c.JSON(http.StatusOK, codexmodels.BuildResponse(models, nil, s.cfg.Codex.OptimizeMultiAgentV2))
-}
-
-func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
- return func(c *gin.Context) {
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
- s.handleHomeGeminiModels(c)
- return
- }
-
- geminiHandler.GeminiModels(c)
- }
-}
-
-func (s *Server) geminiGetHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
- return func(c *gin.Context) {
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
- s.handleHomeGeminiModel(c)
- return
- }
-
- geminiHandler.GeminiGetHandler(c)
- }
-}
-
-type homeModelEntry struct {
- id string
- created int64
- ownedBy string
- displayName string
- contextLength int
- maxCompletionTokens int
-}
-
-func (s *Server) handleHomeModels(c *gin.Context) {
- entries, ok := s.loadHomeModelEntries(c)
- if !ok {
- return
- }
-
- isClaude := isAnthropicModelsRequest(c)
-
- if isClaude {
- disableCloaking := s.cfg != nil && s.cfg.ClaudeCode.DisableCloakingModelList
- c.JSON(http.StatusOK, claudemodels.BuildResponse(formatHomeClaudeModels(entries), disableCloaking))
- return
- }
-
- filtered := make([]map[string]any, 0, len(entries))
- for _, entry := range entries {
- model := map[string]any{
- "id": entry.id,
- "object": "model",
- }
- if entry.created > 0 {
- model["created"] = entry.created
- }
- if entry.ownedBy != "" {
- model["owned_by"] = entry.ownedBy
- }
- filtered = append(filtered, model)
- }
- c.JSON(http.StatusOK, gin.H{
- "object": "list",
- "data": filtered,
- })
-}
-
-func formatHomeClaudeModels(entries []homeModelEntry) []map[string]any {
- out := make([]map[string]any, 0, len(entries))
- for _, entry := range entries {
- out = append(out, formatHomeClaudeModel(entry))
- }
- return out
-}
-
-func formatHomeClaudeModel(entry homeModelEntry) map[string]any {
- displayName := entry.displayName
- if displayName == "" {
- displayName = entry.id
- }
- maxInput := entry.contextLength
- if maxInput <= 0 {
- maxInput = registry.DefaultClaudeMaxInputTokens
- }
- maxOutput := entry.maxCompletionTokens
- if maxOutput <= 0 {
- maxOutput = registry.DefaultClaudeMaxOutputTokens
- }
- model := map[string]any{
- "id": entry.id,
- "object": "model",
- "owned_by": entry.ownedBy,
- "type": "model",
- "display_name": displayName,
- "max_input_tokens": maxInput,
- "max_tokens": maxOutput,
- }
- if entry.created > 0 {
- model["created_at"] = time.Unix(entry.created, 0).UTC().Format(time.RFC3339)
- }
- return model
-}
-
-func (s *Server) handleHomeGeminiModels(c *gin.Context) {
- entries, ok := s.loadHomeModelEntries(c)
- if !ok {
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "models": formatHomeGeminiModels(entries),
- })
-}
-
-func (s *Server) handleHomeGeminiModel(c *gin.Context) {
- entries, ok := s.loadHomeModelEntries(c)
- if !ok {
- return
- }
-
- action := strings.TrimPrefix(c.Param("action"), "/")
- action = strings.TrimSpace(action)
- for _, entry := range entries {
- if homeGeminiModelMatches(entry, action) {
- c.JSON(http.StatusOK, formatHomeGeminiModel(entry))
- return
- }
- }
-
- c.JSON(http.StatusNotFound, handlers.ErrorResponse{
- Error: handlers.ErrorDetail{
- Message: "Not Found",
- Type: "not_found",
- },
- })
-}
-
-func (s *Server) loadHomeModelEntries(c *gin.Context) ([]homeModelEntry, bool) {
- if s == nil || c == nil || c.Request == nil {
- return nil, false
- }
- client := home.Current()
- if client == nil {
- c.JSON(http.StatusServiceUnavailable, handlers.ErrorResponse{
- Error: handlers.ErrorDetail{
- Message: "home control center unavailable",
- Type: "server_error",
- },
- })
- return nil, false
- }
-
- raw, errGet := client.GetModels(c.Request.Context(), c.Request.Header, c.Request.URL.Query())
- if errGet != nil {
- c.JSON(http.StatusBadGateway, handlers.ErrorResponse{
- Error: handlers.ErrorDetail{
- Message: errGet.Error(),
- Type: "server_error",
- },
- })
- return nil, false
- }
-
- if statusCode, ok := homeModelsAuthStatus(raw); ok {
- c.JSON(statusCode, handlers.ErrorResponse{
- Error: handlers.ErrorDetail{
- Message: homeModelsErrorMessage(raw),
- Type: "authentication_error",
- },
- })
- return nil, false
- }
-
- entries, errDecode := decodeHomeModels(raw)
- if errDecode != nil {
- c.JSON(http.StatusBadGateway, handlers.ErrorResponse{
- Error: handlers.ErrorDetail{
- Message: errDecode.Error(),
- Type: "server_error",
- },
- })
- return nil, false
- }
-
- return entries, true
-}
-
-func formatHomeGeminiModels(entries []homeModelEntry) []map[string]any {
- out := make([]map[string]any, 0, len(entries))
- for _, entry := range entries {
- out = append(out, formatHomeGeminiModel(entry))
- }
- return out
-}
-
-func formatHomeGeminiModel(entry homeModelEntry) map[string]any {
- name := entry.id
- if !strings.HasPrefix(name, "models/") {
- name = "models/" + name
- }
- displayName := entry.displayName
- if displayName == "" {
- displayName = entry.id
- }
- return map[string]any{
- "name": name,
- "displayName": displayName,
- "description": displayName,
- "supportedGenerationMethods": []string{"generateContent"},
- }
-}
-
-func homeGeminiModelMatches(entry homeModelEntry, action string) bool {
- id := strings.TrimSpace(entry.id)
- if id == "" || action == "" {
- return false
- }
- normalizedAction := strings.TrimPrefix(action, "models/")
- normalizedID := strings.TrimPrefix(id, "models/")
- return action == id || action == "models/"+id || normalizedAction == normalizedID
-}
-
-// homeModelsAuthStatus inspects a home models response for an authentication/error envelope.
-// It returns the HTTP status code to surface (401 for credential issues, 502 otherwise)
-// and true when the payload is an error response rather than model data.
-func homeModelsAuthStatus(raw []byte) (int, bool) {
- errType := homeModelsErrorType(raw)
- if errType == "" {
- return 0, false
- }
- if errType == "no_credentials" || errType == "invalid_credential" {
- return http.StatusUnauthorized, true
- }
- return http.StatusBadGateway, true
-}
-
-func homeModelsErrorType(raw []byte) string {
- top, ok := unmarshalHomeModelsTopLevel(raw)
- if !ok {
- return ""
- }
- rawErr, exists := top["error"]
- if !exists {
- return ""
- }
- var errObj struct {
- Type string `json:"type"`
- }
- if errUnmarshal := json.Unmarshal(rawErr, &errObj); errUnmarshal != nil {
- return ""
- }
- return strings.TrimSpace(errObj.Type)
-}
-
-func homeModelsErrorMessage(raw []byte) string {
- top, ok := unmarshalHomeModelsTopLevel(raw)
- if !ok {
- return "home models request failed"
- }
- rawErr, exists := top["error"]
- if !exists {
- return "home models request failed"
- }
- var errObj struct {
- Message string `json:"message"`
- }
- if errUnmarshal := json.Unmarshal(rawErr, &errObj); errUnmarshal != nil {
- return "home models request failed"
- }
- if msg := strings.TrimSpace(errObj.Message); msg != "" {
- return msg
- }
- return "home models request failed"
-}
-
-func unmarshalHomeModelsTopLevel(raw []byte) (map[string]json.RawMessage, bool) {
- if len(raw) == 0 {
- return nil, false
- }
- var top map[string]json.RawMessage
- if errUnmarshal := json.Unmarshal(raw, &top); errUnmarshal != nil {
- return nil, false
- }
- return top, true
-}
-
-func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
- if len(raw) == 0 {
- return nil, fmt.Errorf("home models payload is empty")
- }
-
- var bySection map[string][]map[string]any
- if err := json.Unmarshal(raw, &bySection); err != nil {
- return nil, fmt.Errorf("parse home models payload: %w", err)
- }
- if len(bySection) == 0 {
- return nil, fmt.Errorf("home models payload has no sections")
- }
-
- seen := make(map[string]struct{})
- out := make([]homeModelEntry, 0, 256)
- for _, models := range bySection {
- for _, model := range models {
- id, _ := model["id"].(string)
- id = strings.TrimSpace(id)
- if id == "" {
- name, _ := model["name"].(string)
- name = strings.TrimSpace(name)
- id = strings.TrimPrefix(name, "models/")
- }
- if id == "" {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
-
- ownedBy, _ := model["owned_by"].(string)
- ownedBy = strings.TrimSpace(ownedBy)
- displayName, _ := model["display_name"].(string)
- displayName = strings.TrimSpace(displayName)
- if displayName == "" {
- displayName, _ = model["displayName"].(string)
- displayName = strings.TrimSpace(displayName)
- }
-
- out = append(out, homeModelEntry{
- id: id,
- created: homeModelInt64Value(model, "created"),
- ownedBy: ownedBy,
- displayName: displayName,
- contextLength: int(homeModelInt64Value(model, "context_length", "contextLength", "inputTokenLimit", "max_input_tokens")),
- maxCompletionTokens: int(homeModelInt64Value(model, "max_completion_tokens", "maxCompletionTokens", "outputTokenLimit", "max_tokens")),
- })
- }
- }
-
- sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
- if len(out) == 0 {
- return nil, fmt.Errorf("home models payload contains no models")
- }
- return out, nil
-}
-
-func homeModelInt64Value(model map[string]any, keys ...string) int64 {
- for _, key := range keys {
- switch value := model[key].(type) {
- case float64:
- return int64(value)
- case int64:
- return value
- case int:
- return int64(value)
- case json.Number:
- if n, errInt := value.Int64(); errInt == nil {
- return n
- }
- case string:
- if n, errParse := strconv.ParseInt(strings.TrimSpace(value), 10, 64); errParse == nil {
- return n
- }
- }
- }
- return 0
}
diff --git a/backend/internal/translator/init.go b/backend/internal/translator/init.go
index 65428dd..10754e8 100644
--- a/backend/internal/translator/init.go
+++ b/backend/internal/translator/init.go
@@ -1,35 +1,6 @@
package translator
import (
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/interactions"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses"
-
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/interactions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/chat-completions"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/responses"
-
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/interactions"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
-
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/interactions/claude"
-
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/interactions/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/interactions/responses"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses"
-
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/interactions"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/responses"
)
diff --git a/backend/sdk/cliproxy/service_auth.go b/backend/sdk/cliproxy/service_auth.go
index 11b1e1d..8a8d682 100644
--- a/backend/sdk/cliproxy/service_auth.go
+++ b/backend/sdk/cliproxy/service_auth.go
@@ -20,8 +20,6 @@ func newDefaultAuthManager() *sdkAuth.Manager {
return sdkAuth.NewManager(
sdkAuth.GetTokenStore(),
sdkAuth.NewCodexAuthenticator(),
- sdkAuth.NewClaudeAuthenticator(),
- sdkAuth.NewXAIAuthenticator(),
)
}
@@ -112,6 +110,9 @@ func (s *Service) handleAuthUpdates(ctx context.Context, updates []watcher.AuthU
if update.Auth == nil || update.Auth.ID == "" {
continue
}
+ if !strings.EqualFold(strings.TrimSpace(update.Auth.Provider), "codex") || update.Auth.AuthKind() != "oauth" {
+ continue
+ }
auth := s.prepareCoreAuthForModelRegistration(registrationCtx, update.Auth)
if auth == nil {
continue
diff --git a/backend/sdk/cliproxy/service_executors.go b/backend/sdk/cliproxy/service_executors.go
index 0213ee4..5129806 100644
--- a/backend/sdk/cliproxy/service_executors.go
+++ b/backend/sdk/cliproxy/service_executors.go
@@ -199,18 +199,7 @@ func (s *Service) registerAvailableExecutors(ctx context.Context, opts executorR
}
func baselineExecutorAuths() []*coreauth.Auth {
- providers := []string{
- "codex",
- "claude",
- constant.Gemini,
- constant.GeminiInteractions,
- "vertex",
- "aistudio",
- "antigravity",
- "kimi",
- "xai",
- "openai-compatibility",
- }
+ providers := []string{"codex"}
auths := make([]*coreauth.Auth, 0, len(providers))
for _, provider := range providers {
auth := &coreauth.Auth{
diff --git a/backend/sdk/cliproxy/service_lifecycle.go b/backend/sdk/cliproxy/service_lifecycle.go
index e16b143..2717483 100644
--- a/backend/sdk/cliproxy/service_lifecycle.go
+++ b/backend/sdk/cliproxy/service_lifecycle.go
@@ -130,26 +130,6 @@ func (s *Service) Run(ctx context.Context) error {
s.startHomeSubscriber(ctx)
}
- if s.server != nil && s.wsGateway != nil {
- s.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler())
- s.server.SetWebsocketAuthChangeHandler(func(oldEnabled, newEnabled bool) {
- if oldEnabled == newEnabled {
- return
- }
- if !oldEnabled && newEnabled {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if errStop := s.wsGateway.Stop(ctx); errStop != nil {
- log.Warnf("failed to reset websocket connections after ws-auth change %t -> %t: %v", oldEnabled, newEnabled, errStop)
- return
- }
- log.Debugf("ws-auth enabled; existing websocket sessions terminated to enforce authentication")
- return
- }
- log.Debugf("ws-auth disabled; existing websocket sessions remain connected")
- })
- }
-
if s.hooks.OnBeforeStart != nil {
s.hooks.OnBeforeStart(s.cfg)
}
diff --git a/flake.nix b/flake.nix
index 92c0cc2..f01302b 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,5 +1,5 @@
{
- description = "CLI Proxy API with its Vite management frontend";
+ description = "Lightweight Codex OAuth proxy with an OpenAI-compatible API";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -49,7 +49,6 @@
{
default = pkgs.mkShell {
packages = with pkgs; [
- gcc
git
go_1_26
golangci-lint
@@ -57,11 +56,9 @@
gotools
nodejs_24
nixfmt
- pkg-config
pnpm
yaml-language-server
];
- CGO_ENABLED = "1";
};
}
);
diff --git a/frontend/.github/workflows/ci.yml b/frontend/.github/workflows/ci.yml
deleted file mode 100644
index 9d6133c..0000000
--- a/frontend/.github/workflows/ci.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: CI
-
-on:
- pull_request:
- push:
- branches:
- - main
- - dev
-
-jobs:
- verify:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '24'
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
- with:
- version: '11.21.0'
-
- - name: Install dependencies
- run: pnpm install --no-frozen-lockfile
-
- - name: Verify
- run: pnpm verify
diff --git a/frontend/.github/workflows/release.yml b/frontend/.github/workflows/release.yml
deleted file mode 100644
index 5aa9641..0000000
--- a/frontend/.github/workflows/release.yml
+++ /dev/null
@@ -1,67 +0,0 @@
-name: Build and Release
-
-on:
- push:
- tags:
- - 'v*'
-
-jobs:
- build-and-release:
- runs-on: ubuntu-latest
-
- permissions:
- contents: write
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- fetch-tags: true
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '24'
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
- with:
- version: '11.21.0'
-
- - name: Install dependencies
- run: pnpm install --no-frozen-lockfile
-
- - name: Build frontend
- run: pnpm build
- env:
- VERSION: ${{ github.ref_name }}
-
- - name: Prepare release assets
- run: |
- mv dist/index.html dist/management.html
- tar -czf management-webui.tar.gz -C dist .
-
- - name: Generate release notes
- run: |
- set -euo pipefail
- current_tag="${GITHUB_REF_NAME}"
- previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
- if [ -n "${previous_tag}" ]; then
- range="${previous_tag}..${current_tag}"
- else
- range="${current_tag}"
- fi
-
- : > release-notes.md
- git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
-
- - name: Create Release
- uses: softprops/action-gh-release@v1
- with:
- files: management-webui.tar.gz
- body_path: release-notes.md
- draft: false
- prerelease: false
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/frontend/index.html b/frontend/index.html
index d31b83f..1876161 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,11 +1,10 @@
-
+
-
-
- CLI Proxy API Management Center
+
+ Vibe Proxy Accounts
diff --git a/frontend/package.json b/frontend/package.json
index b9b060f..bbbd3d8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,50 +8,29 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
- "test": "vitest run",
+ "test": "vitest run src --passWithNoTests",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"verify": "pnpm test && pnpm lint && pnpm build",
- "format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
+ "format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"type-check": "tsc --noEmit"
},
"dependencies": {
- "@codemirror/lang-yaml": "^6.1.3",
- "@codemirror/merge": "^6.12.2",
- "@codemirror/search": "^6.7.1",
- "@codemirror/state": "^6.7.1",
- "@codemirror/view": "^6.43.9",
- "@uiw/react-codemirror": "^4.25.11",
- "axios": "1.18.1",
- "i18next": "^26.3.6",
- "motion": "^12.42.2",
- "motion-dom": "^12.43.0",
"react": "^19.2.7",
- "react-dom": "^19.2.7",
- "react-i18next": "^17.0.9",
- "react-router": "^7.18.2",
- "react-router-dom": "^7.18.1",
- "yaml": "^2.9.0",
- "zustand": "^5.0.14"
+ "react-dom": "^19.2.7"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
- "@typescript-eslint/eslint-plugin": "^8.63.0",
- "@typescript-eslint/parser": "^8.63.0",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"prettier": "^3.9.5",
- "sass": "^1.101.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
"vite": "^8.1.4",
"vitest": "^4.1.11"
- },
- "overrides": {
- "form-data": "4.0.6"
}
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c2938e3..e2acd72 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,59 +1,321 @@
-import { useEffect } from 'react';
-import { Outlet, RouterProvider, createHashRouter } from 'react-router-dom';
-import { LoginPage } from '@/pages/LoginPage';
-import { NotificationContainer } from '@/components/common/NotificationContainer';
-import { ConfirmationModal } from '@/components/common/ConfirmationModal';
-import { MainLayout } from '@/components/layout/MainLayout';
-import { ProtectedRoute } from '@/router/ProtectedRoute';
-import { useLanguageStore, useThemeStore } from '@/stores';
+import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
+import { api, type CodexAccount } from './api';
+import { fetchCodexQuota, type CodexQuota } from './codexQuota';
+
+const SESSION_KEY = 'vibe-proxy-management-key';
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : 'Something went wrong';
+}
+
+function statusFor(account: CodexAccount): { label: string; tone: string } {
+ if (account.disabled) return { label: 'Disabled', tone: 'muted' };
+ if (account.unavailable || account.status === 'error') return { label: 'Unavailable', tone: 'bad' };
+ return { label: account.status || 'Ready', tone: 'good' };
+}
+
+function Login({ onLogin }: { onLogin: (key: string) => void }) {
+ const [key, setKey] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ const managementKey = key.trim();
+ if (!managementKey) return;
+
+ setLoading(true);
+ setError('');
+ try {
+ await api.listAccounts(managementKey);
+ sessionStorage.setItem(SESSION_KEY, managementKey);
+ onLogin(managementKey);
+ } catch (loginError) {
+ setError(errorMessage(loginError));
+ } finally {
+ setLoading(false);
+ }
+ }
-function RootShell() {
return (
- <>
-
-
-
- >
+
+
+ V
+ Vibe Proxy
+ Account management
+ Sign in with the management key for this server.
+
+
+
);
}
-const router = createHashRouter([
- {
- element: ,
- children: [
- { path: '/login', element: },
- {
- path: '/*',
- element: (
-
-
-
- ),
- },
- ],
- },
-]);
-
-function App() {
- const initializeTheme = useThemeStore((state) => state.initializeTheme);
- const language = useLanguageStore((state) => state.language);
- const setLanguage = useLanguageStore((state) => state.setLanguage);
-
- useEffect(() => {
- const cleanupTheme = initializeTheme();
- return cleanupTheme;
- }, [initializeTheme]);
-
- useEffect(() => {
- setLanguage(language);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []); // 仅用于首屏同步 i18n 语言
-
- useEffect(() => {
- document.documentElement.lang = language;
- }, [language]);
-
- return ;
+function QuotaView({ quota }: { quota: CodexQuota }) {
+ return (
+
+ {quota.planType &&
{quota.planType}}
+ {quota.windows.length === 0 ? (
+
No quota windows returned.
+ ) : (
+ quota.windows.map((window) => (
+
+
+ {window.label}
+ {window.remaining === null ? '--' : `${Math.round(window.remaining)}%`}
+
+
+
+
+ {window.resetAt && (
+
Resets {new Date(window.resetAt).toLocaleString()}
+ )}
+
+ ))
+ )}
+
+ );
}
-export default App;
+function AccountCard({
+ account,
+ managementKey,
+ onDelete,
+}: {
+ account: CodexAccount;
+ managementKey: string;
+ onDelete: () => void;
+}) {
+ const [quota, setQuota] = useState();
+ const [quotaError, setQuotaError] = useState('');
+ const [loadingQuota, setLoadingQuota] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const status = statusFor(account);
+
+ async function refreshQuota() {
+ setLoadingQuota(true);
+ setQuotaError('');
+ try {
+ setQuota(await fetchCodexQuota(account, managementKey));
+ } catch (error) {
+ setQuotaError(errorMessage(error));
+ } finally {
+ setLoadingQuota(false);
+ }
+ }
+
+ async function remove() {
+ if (!window.confirm(`Delete ${account.email || 'this Codex account'}?`)) return;
+ setDeleting(true);
+ try {
+ await api.deleteAccount(account.name, managementKey);
+ onDelete();
+ } catch (error) {
+ setQuotaError(errorMessage(error));
+ setDeleting(false);
+ }
+ }
+
+ return (
+
+
+
+
{(account.email || 'C')[0].toUpperCase()}
+
+
{account.email || 'Email unavailable'}
+ {status.label}
+
+
+
+
+
+ {account.statusMessage && {account.statusMessage}
}
+ {quota && }
+ {quotaError && {quotaError}
}
+
+
+ );
+}
+
+function Management({ managementKey, onLogout }: { managementKey: string; onLogout: () => void }) {
+ const [accounts, setAccounts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [oauth, setOauth] = useState<{ url: string; state: string }>();
+ const [callbackUrl, setCallbackUrl] = useState('');
+ const [oauthStatus, setOauthStatus] = useState('');
+ const [adding, setAdding] = useState(false);
+ const pollRef = useRef(undefined);
+
+ const loadAccounts = useCallback(async () => {
+ setError('');
+ try {
+ setAccounts(await api.listAccounts(managementKey));
+ } catch (loadError) {
+ setError(errorMessage(loadError));
+ } finally {
+ setLoading(false);
+ }
+ }, [managementKey]);
+
+ const stopPolling = useCallback(() => {
+ if (pollRef.current !== undefined) window.clearInterval(pollRef.current);
+ pollRef.current = undefined;
+ }, []);
+
+ useEffect(() => {
+ void loadAccounts();
+ return stopPolling;
+ }, [loadAccounts, stopPolling]);
+
+ function pollAuth(state: string) {
+ stopPolling();
+ pollRef.current = window.setInterval(async () => {
+ try {
+ const result = await api.authStatus(state, managementKey);
+ if (result.status === 'ok') {
+ stopPolling();
+ setOauth(undefined);
+ setCallbackUrl('');
+ setOauthStatus('Account added.');
+ setAdding(false);
+ await loadAccounts();
+ } else if (result.status === 'error') {
+ stopPolling();
+ setOauthStatus(result.error || 'Authorization failed.');
+ setAdding(false);
+ }
+ } catch (pollError) {
+ stopPolling();
+ setOauthStatus(errorMessage(pollError));
+ setAdding(false);
+ }
+ }, 2500);
+ }
+
+ async function addAccount() {
+ setAdding(true);
+ setOauthStatus('');
+ try {
+ const result = await api.startCodexAuth(managementKey);
+ if (!result.state) throw new Error('The server did not return an OAuth state.');
+ setOauth({ url: result.url, state: result.state });
+ window.open(result.url, '_blank', 'noopener,noreferrer');
+ pollAuth(result.state);
+ } catch (oauthError) {
+ setOauthStatus(errorMessage(oauthError));
+ setAdding(false);
+ }
+ }
+
+ async function submitCallback(event: FormEvent) {
+ event.preventDefault();
+ if (!callbackUrl.trim()) return;
+ try {
+ await api.submitCallback(callbackUrl.trim(), managementKey);
+ setOauthStatus('Callback submitted. Waiting for the account...');
+ } catch (callbackError) {
+ setOauthStatus(errorMessage(callbackError));
+ }
+ }
+
+ return (
+
+
+
+
+
+
OpenAI Codex
+
Accounts
+
Connect accounts and check their current usage limits.
+
+
+
+
+ {oauth && (
+
+ )}
+
+ {oauthStatus && {oauthStatus}
}
+ {error && {error}
}
+
+
+ {loading ? (
+ Loading accounts...
+ ) : accounts.length === 0 ? (
+
+
No Codex accounts yet
+
Add an OpenAI account to start routing Codex requests.
+
+ ) : (
+ accounts.map((account) => (
+
+ ))
+ )}
+
+
+ );
+}
+
+export default function App() {
+ const [managementKey, setManagementKey] = useState(() => sessionStorage.getItem(SESSION_KEY) || '');
+
+ function logout() {
+ sessionStorage.removeItem(SESSION_KEY);
+ setManagementKey('');
+ }
+
+ return managementKey ? (
+
+ ) : (
+
+ );
+}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
new file mode 100644
index 0000000..1c8e7ac
--- /dev/null
+++ b/frontend/src/api.ts
@@ -0,0 +1,95 @@
+const API_ROOT = '/v0/management';
+
+export interface CodexAccount {
+ name: string;
+ type?: string;
+ provider?: string;
+ email?: string;
+ disabled?: boolean;
+ unavailable?: boolean;
+ status?: string;
+ statusMessage?: string;
+ status_message?: string;
+ authIndex?: string | number | null;
+ auth_index?: string | number | null;
+ [key: string]: unknown;
+}
+
+async function request(path: string, managementKey: string, init?: RequestInit): Promise {
+ const response = await fetch(`${API_ROOT}${path}`, {
+ ...init,
+ headers: {
+ Authorization: `Bearer ${managementKey}`,
+ 'Content-Type': 'application/json',
+ ...init?.headers,
+ },
+ });
+
+ const text = await response.text();
+ let body: unknown;
+ try {
+ body = text ? JSON.parse(text) : undefined;
+ } catch {
+ body = text;
+ }
+
+ if (!response.ok) {
+ const detail =
+ body && typeof body === 'object' && 'error' in body
+ ? String((body as { error: unknown }).error)
+ : typeof body === 'string'
+ ? body
+ : response.statusText;
+ throw new Error(detail || `Request failed with HTTP ${response.status}`);
+ }
+
+ return body as T;
+}
+
+function isCodexAccount(account: CodexAccount): boolean {
+ return [account.type, account.provider].some((value) => String(value || '').toLowerCase() === 'codex');
+}
+
+export const api = {
+ async listAccounts(managementKey: string): Promise {
+ const result = await request<{ files?: CodexAccount[] }>('/auth-files', managementKey);
+ return (result.files || [])
+ .filter(isCodexAccount)
+ .map((account) => ({
+ ...account,
+ email: typeof account.email === 'string' ? account.email.trim() : undefined,
+ statusMessage: account.statusMessage || account.status_message,
+ }));
+ },
+
+ deleteAccount(name: string, managementKey: string) {
+ return request(`/auth-files?name=${encodeURIComponent(name)}`, managementKey, {
+ method: 'DELETE',
+ });
+ },
+
+ startCodexAuth(managementKey: string) {
+ return request<{ url: string; state?: string }>('/codex-auth-url?is_webui=true', managementKey);
+ },
+
+ authStatus(state: string, managementKey: string) {
+ return request<{ status: 'ok' | 'wait' | 'error'; error?: string }>(
+ `/get-auth-status?state=${encodeURIComponent(state)}`,
+ managementKey,
+ );
+ },
+
+ submitCallback(redirectUrl: string, managementKey: string) {
+ return request('/oauth-callback', managementKey, {
+ method: 'POST',
+ body: JSON.stringify({ provider: 'codex', redirect_url: redirectUrl }),
+ });
+ },
+
+ getCodexQuota(authIndex: string, managementKey: string) {
+ return request>('/codex-quota', managementKey, {
+ method: 'POST',
+ body: JSON.stringify({ auth_index: authIndex }),
+ });
+ },
+};
diff --git a/frontend/src/assets/icons/antigravity.svg b/frontend/src/assets/icons/antigravity.svg
deleted file mode 100644
index 734c297..0000000
--- a/frontend/src/assets/icons/antigravity.svg
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
diff --git a/frontend/src/assets/icons/apikey-fun.png b/frontend/src/assets/icons/apikey-fun.png
deleted file mode 100644
index 0364ec6..0000000
Binary files a/frontend/src/assets/icons/apikey-fun.png and /dev/null differ
diff --git a/frontend/src/assets/icons/bestproxy.png b/frontend/src/assets/icons/bestproxy.png
deleted file mode 100644
index f77ac86..0000000
Binary files a/frontend/src/assets/icons/bestproxy.png and /dev/null differ
diff --git a/frontend/src/assets/icons/claude.svg b/frontend/src/assets/icons/claude.svg
deleted file mode 100644
index 62dc0db..0000000
--- a/frontend/src/assets/icons/claude.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/claudeapi.png b/frontend/src/assets/icons/claudeapi.png
deleted file mode 100644
index 776ced8..0000000
Binary files a/frontend/src/assets/icons/claudeapi.png and /dev/null differ
diff --git a/frontend/src/assets/icons/code0.png b/frontend/src/assets/icons/code0.png
deleted file mode 100644
index a440e8a..0000000
Binary files a/frontend/src/assets/icons/code0.png and /dev/null differ
diff --git a/frontend/src/assets/icons/codex.svg b/frontend/src/assets/icons/codex.svg
deleted file mode 100644
index d5cb0ac..0000000
--- a/frontend/src/assets/icons/codex.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/deepseek.svg b/frontend/src/assets/icons/deepseek.svg
deleted file mode 100644
index 3fc2302..0000000
--- a/frontend/src/assets/icons/deepseek.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/fenno-ai.png b/frontend/src/assets/icons/fenno-ai.png
deleted file mode 100644
index 173b654..0000000
Binary files a/frontend/src/assets/icons/fenno-ai.png and /dev/null differ
diff --git a/frontend/src/assets/icons/gemini.svg b/frontend/src/assets/icons/gemini.svg
deleted file mode 100644
index f1cf357..0000000
--- a/frontend/src/assets/icons/gemini.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/glm.svg b/frontend/src/assets/icons/glm.svg
deleted file mode 100644
index 0c6e61c..0000000
--- a/frontend/src/assets/icons/glm.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/grok-dark.svg b/frontend/src/assets/icons/grok-dark.svg
deleted file mode 100644
index 9d4ebdb..0000000
--- a/frontend/src/assets/icons/grok-dark.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/assets/icons/grok.svg b/frontend/src/assets/icons/grok.svg
deleted file mode 100644
index efb1a61..0000000
--- a/frontend/src/assets/icons/grok.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/iflow.svg b/frontend/src/assets/icons/iflow.svg
deleted file mode 100644
index ec7a6f4..0000000
--- a/frontend/src/assets/icons/iflow.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/infistar.png b/frontend/src/assets/icons/infistar.png
deleted file mode 100644
index 650b3ba..0000000
Binary files a/frontend/src/assets/icons/infistar.png and /dev/null differ
diff --git a/frontend/src/assets/icons/kimi-dark.svg b/frontend/src/assets/icons/kimi-dark.svg
deleted file mode 100644
index 3e84c92..0000000
--- a/frontend/src/assets/icons/kimi-dark.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/assets/icons/kimi-light.svg b/frontend/src/assets/icons/kimi-light.svg
deleted file mode 100644
index 29878cd..0000000
--- a/frontend/src/assets/icons/kimi-light.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/assets/icons/lmu-ai.png b/frontend/src/assets/icons/lmu-ai.png
deleted file mode 100644
index 9936686..0000000
Binary files a/frontend/src/assets/icons/lmu-ai.png and /dev/null differ
diff --git a/frontend/src/assets/icons/minimax.svg b/frontend/src/assets/icons/minimax.svg
deleted file mode 100644
index 2a60bd4..0000000
--- a/frontend/src/assets/icons/minimax.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/openai-dark.svg b/frontend/src/assets/icons/openai-dark.svg
deleted file mode 100644
index bdb605a..0000000
--- a/frontend/src/assets/icons/openai-dark.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/openai-light.svg b/frontend/src/assets/icons/openai-light.svg
deleted file mode 100644
index 238e9be..0000000
--- a/frontend/src/assets/icons/openai-light.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/qiniu-cloud.png b/frontend/src/assets/icons/qiniu-cloud.png
deleted file mode 100644
index 3485d1f..0000000
Binary files a/frontend/src/assets/icons/qiniu-cloud.png and /dev/null differ
diff --git a/frontend/src/assets/icons/qwen.svg b/frontend/src/assets/icons/qwen.svg
deleted file mode 100644
index 33b3f64..0000000
--- a/frontend/src/assets/icons/qwen.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/icons/vertex.svg b/frontend/src/assets/icons/vertex.svg
deleted file mode 100644
index efc3589..0000000
--- a/frontend/src/assets/icons/vertex.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/logoInline.ts b/frontend/src/assets/logoInline.ts
deleted file mode 100644
index 28d5731..0000000
--- a/frontend/src/assets/logoInline.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const INLINE_LOGO_JPEG =
- 'data:image/jpeg;base64,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';
diff --git a/frontend/src/codexQuota.ts b/frontend/src/codexQuota.ts
new file mode 100644
index 0000000..cabfd01
--- /dev/null
+++ b/frontend/src/codexQuota.ts
@@ -0,0 +1,122 @@
+import { api, type CodexAccount } from './api';
+
+interface UsageWindow {
+ used_percent?: unknown;
+ usedPercent?: unknown;
+ reset_at?: unknown;
+ resetAt?: unknown;
+ reset_after_seconds?: unknown;
+ resetAfterSeconds?: unknown;
+}
+
+interface RateLimit {
+ primary_window?: UsageWindow | null;
+ primaryWindow?: UsageWindow | null;
+ secondary_window?: UsageWindow | null;
+ secondaryWindow?: UsageWindow | null;
+}
+
+function normalizeAuthIndex(value: unknown): string | null {
+ if (typeof value === 'number' && Number.isFinite(value)) return value.toString();
+ if (typeof value === 'string') return value.trim() || null;
+ return null;
+}
+
+function normalizeNumberValue(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number(value.trim());
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+ return null;
+}
+
+function parseCodexUsagePayload(payload: unknown): Record | null {
+ if (typeof payload === 'string' && payload.trim()) {
+ try {
+ return JSON.parse(payload) as Record;
+ } catch {
+ return null;
+ }
+ }
+ return payload !== null && typeof payload === 'object'
+ ? (payload as Record)
+ : null;
+}
+
+export interface CodexQuota {
+ planType: string | null;
+ windows: Array<{ id: string; label: string; remaining: number | null; resetAt: number | null }>;
+}
+
+function resetAt(window: UsageWindow): number | null {
+ const absolute = normalizeNumberValue(window.reset_at ?? window.resetAt);
+ if (absolute !== null) return absolute < 1e12 ? absolute * 1000 : absolute;
+ const offset = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds);
+ return offset === null ? null : Date.now() + offset * 1000;
+}
+
+function addRateLimit(
+ result: CodexQuota['windows'],
+ limit: RateLimit | null | undefined,
+ prefix: string,
+ labels: [string, string],
+) {
+ const windows = [limit?.primary_window ?? limit?.primaryWindow, limit?.secondary_window ?? limit?.secondaryWindow];
+ windows.forEach((window, index) => {
+ if (!window) return;
+ const used = normalizeNumberValue(window.used_percent ?? window.usedPercent);
+ result.push({
+ id: `${prefix}-${index}`,
+ label: labels[index],
+ remaining: used === null ? null : Math.max(0, Math.min(100, 100 - used)),
+ resetAt: resetAt(window),
+ });
+ });
+}
+
+export async function fetchCodexQuota(
+ account: CodexAccount,
+ managementKey: string,
+): Promise {
+ const authIndex = normalizeAuthIndex(account.auth_index ?? account.authIndex);
+ if (!authIndex) throw new Error('This account has no auth index, so quota cannot be queried.');
+
+ const response = await api.getCodexQuota(authIndex, managementKey);
+ const statusCode = Number(response.status_code || 0);
+ if (statusCode < 200 || statusCode >= 300) {
+ throw new Error(`Quota request failed with HTTP ${statusCode || 'unknown'}`);
+ }
+
+ const payload = parseCodexUsagePayload(response.body);
+ if (!payload) throw new Error('The quota response was empty or invalid.');
+
+ const source = payload;
+ const windows: CodexQuota['windows'] = [];
+ addRateLimit(
+ windows,
+ (source.rate_limit ?? source.rateLimit) as RateLimit | undefined,
+ 'codex',
+ ['5-hour limit', 'Weekly limit'],
+ );
+ addRateLimit(
+ windows,
+ (source.code_review_rate_limit ?? source.codeReviewRateLimit) as RateLimit | undefined,
+ 'review',
+ ['Code review 5-hour limit', 'Code review weekly limit'],
+ );
+
+ return {
+ planType:
+ typeof source.plan_type === 'string'
+ ? source.plan_type
+ : typeof source.planType === 'string'
+ ? source.planType
+ : typeof account.plan_type === 'string'
+ ? account.plan_type
+ : typeof account.planType === 'string'
+ ? account.planType
+ : null,
+ windows,
+ };
+}
diff --git a/frontend/src/components/common/ConfirmationModal.tsx b/frontend/src/components/common/ConfirmationModal.tsx
deleted file mode 100644
index 416bfca..0000000
--- a/frontend/src/components/common/ConfirmationModal.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { Modal } from '@/components/ui/Modal';
-import { Button } from '@/components/ui/Button';
-import { useNotificationStore } from '@/stores';
-
-export function ConfirmationModal() {
- const { t } = useTranslation();
- const confirmation = useNotificationStore((state) => state.confirmation);
- const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
- const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
-
- const { isOpen, isLoading, options } = confirmation;
-
- if (!isOpen || !options) {
- return null;
- }
-
- const {
- title,
- message,
- onConfirm,
- onCancel,
- confirmText,
- cancelText,
- variant = 'primary',
- } = options;
-
- const handleConfirm = async () => {
- try {
- setConfirmationLoading(true);
- await onConfirm();
- hideConfirmation();
- } catch (error) {
- console.error('Confirmation action failed:', error);
- // Optional: show error notification here if needed,
- // but usually the calling component handles specific errors.
- } finally {
- setConfirmationLoading(false);
- }
- };
-
- const handleCancel = () => {
- if (isLoading) {
- return;
- }
- if (onCancel) {
- onCancel();
- }
- hideConfirmation();
- };
-
- return (
-
- {typeof message === 'string' ? (
- {message}
- ) : (
- {message}
- )}
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/common/NotificationContainer.tsx b/frontend/src/components/common/NotificationContainer.tsx
deleted file mode 100644
index 9f1903d..0000000
--- a/frontend/src/components/common/NotificationContainer.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useNotificationStore } from '@/stores';
-import { IconX } from '@/components/ui/icons';
-import type { Notification } from '@/types';
-
-interface AnimatedNotification extends Notification {
- isExiting?: boolean;
-}
-
-const ANIMATION_DURATION = 300; // ms
-
-export function NotificationContainer() {
- const { t } = useTranslation();
- const { notifications, removeNotification } = useNotificationStore();
- const [animatedNotifications, setAnimatedNotifications] = useState([]);
- const prevNotificationsRef = useRef([]);
-
- useEffect(() => {
- const prevNotifications = prevNotificationsRef.current;
- const prevIds = new Set(prevNotifications.map((n) => n.id));
- const currentIds = new Set(notifications.map((n) => n.id));
-
- const newNotifications = notifications.filter((n) => !prevIds.has(n.id));
-
- const removedIds = new Set(
- prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id)
- );
-
- setAnimatedNotifications((prev) => {
- let updated = prev.map((n) => (removedIds.has(n.id) ? { ...n, isExiting: true } : n));
-
- newNotifications.forEach((n) => {
- if (!updated.find((animatedNotification) => animatedNotification.id === n.id)) {
- updated.push({ ...n, isExiting: false });
- }
- });
-
- updated = updated.filter((n) => currentIds.has(n.id) || n.isExiting);
-
- return updated;
- });
-
- if (removedIds.size > 0) {
- setTimeout(() => {
- setAnimatedNotifications((prev) => prev.filter((n) => !removedIds.has(n.id)));
- }, ANIMATION_DURATION);
- }
-
- prevNotificationsRef.current = notifications;
- }, [notifications]);
-
- const handleClose = (id: string) => {
- setAnimatedNotifications((prev) =>
- prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))
- );
-
- setTimeout(() => {
- removeNotification(id);
- }, ANIMATION_DURATION);
- };
-
- if (!animatedNotifications.length) return null;
-
- return (
-
- {animatedNotifications.map((notification) => (
-
-
{notification.message}
-
-
- ))}
-
- );
-}
diff --git a/frontend/src/components/common/PageTransition.scss b/frontend/src/components/common/PageTransition.scss
deleted file mode 100644
index 6ff5600..0000000
--- a/frontend/src/components/common/PageTransition.scss
+++ /dev/null
@@ -1,54 +0,0 @@
-@use '@/styles/variables.scss' as *;
-
-.page-transition {
- position: relative;
- flex: 1 1 auto;
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
-
- &__layer {
- display: flex;
- flex-direction: column;
- gap: $spacing-lg;
- min-height: 0;
- flex: 1;
- background: var(--bg-secondary);
- backface-visibility: hidden;
- transform: translateZ(0);
-
- // During animation, exit layer uses absolute positioning
- &--exit {
- position: absolute;
- inset: 0;
- overflow: hidden;
- pointer-events: none;
- will-change: transform, opacity;
- }
-
- &--stacked {
- display: none;
-
- // Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
- // Older stacked layers remain `display: none` for performance.
- &.page-transition__layer--stacked-keep {
- display: flex;
- position: absolute;
- inset: 0;
- overflow: hidden;
- pointer-events: none;
- opacity: 0;
- will-change: transform, opacity;
- }
- }
- }
-
- &--animating &__layer {
- will-change: transform, opacity;
- }
-
- &--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
- position: relative;
- }
-}
diff --git a/frontend/src/components/common/PageTransition.tsx b/frontend/src/components/common/PageTransition.tsx
deleted file mode 100644
index 1573ace..0000000
--- a/frontend/src/components/common/PageTransition.tsx
+++ /dev/null
@@ -1,457 +0,0 @@
-import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
-import { useLocation, type Location } from 'react-router-dom';
-import { animate } from 'motion/mini';
-import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
-import {
- PAGE_TRANSITION_LAYER_CONTEXT_VALUES,
- PageTransitionLayerContext,
- type LayerStatus,
-} from './PageTransitionLayer';
-import './PageTransition.scss';
-
-interface PageTransitionProps {
- render: (location: Location) => ReactNode;
- getRouteOrder?: (pathname: string) => number | null;
- getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
- scrollContainerRef?: React.RefObject;
-}
-
-// Premium personality: enter > exit, decelerate-in / accelerate-out.
-const VERTICAL_ENTER_DURATION = 0.36;
-const VERTICAL_EXIT_DURATION = 0.22;
-const VERTICAL_ENTER_DISTANCE = 28;
-const VERTICAL_EXIT_DISTANCE = 12;
-const REDUCED_MOTION_DURATION = 0.15;
-
-const IOS_TRANSITION_DURATION = 0.44;
-const IOS_ENTER_FROM_X_PERCENT = 100;
-const IOS_EXIT_TO_X_PERCENT_FORWARD = -22;
-const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
-const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -22;
-const IOS_BACKGROUND_SCALE = 0.96;
-const IOS_BACKGROUND_OPACITY = 0.5;
-const IOS_SHADOW_VALUE = '-20px 0 36px rgba(0, 0, 0, 0.20)';
-
-// easeOutQuart: powerful but elegant deceleration for hero entrances.
-const easeOutQuart = (progress: number) => 1 - (1 - progress) ** 4;
-// easeInQuad: gentle start, accelerates away — exits should not linger.
-const easeInQuad = (progress: number) => progress * progress;
-// easeOutCubic: smooth Apple-style settle for iOS push/pop.
-const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3;
-
-const prefersReducedMotion = () =>
- typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
-
-const buildVerticalTransform = (y: number) => `translate3d(0px, ${y}px, 0px)`;
-const buildIosTransform = (xPercent: number, y: number, scale = 1) =>
- scale === 1
- ? `translate3d(${xPercent}%, ${y}px, 0px)`
- : `translate3d(${xPercent}%, ${y}px, 0px) scale(${scale})`;
-
-const clearLayerStyles = (element: HTMLElement | null) => {
- if (!element) return;
- element.style.removeProperty('transform');
- element.style.removeProperty('opacity');
- element.style.removeProperty('box-shadow');
-};
-
-type Layer = {
- key: string;
- location: Location;
- status: LayerStatus;
-};
-
-type TransitionDirection = 'forward' | 'backward';
-
-type TransitionVariant = 'vertical' | 'ios';
-
-export function PageTransition({
- render,
- getRouteOrder,
- getTransitionVariant,
- scrollContainerRef,
-}: PageTransitionProps) {
- const location = useLocation();
- const currentLayerRef = useRef(null);
- const exitingLayerRef = useRef(null);
- const transitionDirectionRef = useRef('forward');
- const transitionVariantRef = useRef('vertical');
- const exitScrollOffsetRef = useRef(0);
- const enterScrollOffsetRef = useRef(0);
- const scrollPositionsRef = useRef(new Map());
- const nextLayersRef = useRef(null);
-
- const [isAnimating, setIsAnimating] = useState(false);
- const [layers, setLayers] = useState(() => [
- {
- key: location.key,
- location,
- status: 'current',
- },
- ]);
- const currentLayer =
- layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
- const currentLayerKey = currentLayer?.key ?? location.key;
- const currentLayerPathname = currentLayer?.location.pathname;
-
- const resolveScrollContainer = useCallback(() => {
- if (scrollContainerRef?.current) return scrollContainerRef.current;
- if (typeof document === 'undefined') return null;
- return document.scrollingElement as HTMLElement | null;
- }, [scrollContainerRef]);
-
- useLayoutEffect(() => {
- if (isAnimating) return;
- if (location.key === currentLayerKey) return;
- if (currentLayerPathname === location.pathname) return;
- const scrollContainer = resolveScrollContainer();
- const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
- exitScrollOffsetRef.current = exitScrollOffset;
- scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
-
- enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0;
- const resolveOrderIndex = (pathname?: string) => {
- if (!getRouteOrder || !pathname) return null;
- const index = getRouteOrder(pathname);
- return typeof index === 'number' && index >= 0 ? index : null;
- };
- const fromIndex = resolveOrderIndex(currentLayerPathname);
- const toIndex = resolveOrderIndex(location.pathname);
- const nextVariant: TransitionVariant = getTransitionVariant
- ? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
- : 'vertical';
-
- let nextDirection: TransitionDirection =
- fromIndex === null || toIndex === null || fromIndex === toIndex
- ? 'forward'
- : toIndex > fromIndex
- ? 'forward'
- : 'backward';
-
- // When using iOS-style stacking, history POP within the same "section" can have equal route order.
- // In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
- if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
- nextDirection = 'backward';
- }
-
- transitionDirectionRef.current = nextDirection;
- transitionVariantRef.current = nextVariant;
-
- const shouldSkipExitLayer = (() => {
- if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
- const normalizeSegments = (pathname: string) =>
- pathname
- .split('/')
- .filter(Boolean)
- .filter((segment) => segment.length > 0);
- const fromSegments = normalizeSegments(currentLayerPathname ?? '');
- const toSegments = normalizeSegments(location.pathname);
- if (!fromSegments.length || !toSegments.length) return false;
- return fromSegments[0] === toSegments[0] && toSegments.length === 1;
- })();
-
- setLayers((prev) => {
- const variant = transitionVariantRef.current;
- const direction = transitionDirectionRef.current;
- const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
- const resolvedCurrentIndex =
- previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
- const previousCurrent = prev[resolvedCurrentIndex];
- const previousStack: Layer[] = prev
- .filter((_, idx) => idx !== resolvedCurrentIndex)
- .map((layer): Layer => ({ ...layer, status: 'stacked' }));
-
- const nextCurrent: Layer = { key: location.key, location, status: 'current' };
-
- if (!previousCurrent) {
- nextLayersRef.current = [nextCurrent];
- return [nextCurrent];
- }
-
- if (variant === 'ios') {
- if (direction === 'forward') {
- const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
- const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
-
- nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
- return [...previousStack, exitingLayer, nextCurrent];
- }
-
- const targetIndex = prev.findIndex((layer) => layer.key === location.key);
- if (targetIndex !== -1) {
- const targetStack: Layer[] = prev.slice(0, targetIndex + 1).map((layer, idx): Layer => {
- const isTarget = idx === targetIndex;
- return {
- ...layer,
- location: isTarget ? location : layer.location,
- status: isTarget ? 'current' : 'stacked',
- };
- });
-
- if (shouldSkipExitLayer) {
- nextLayersRef.current = targetStack;
- return targetStack;
- }
-
- const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
- nextLayersRef.current = targetStack;
- return [...targetStack, exitingLayer];
- }
- }
-
- if (shouldSkipExitLayer) {
- nextLayersRef.current = [nextCurrent];
- return [nextCurrent];
- }
-
- const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
-
- nextLayersRef.current = [nextCurrent];
- return [exitingLayer, nextCurrent];
- });
- setIsAnimating(true);
- }, [
- isAnimating,
- location,
- currentLayerKey,
- currentLayerPathname,
- getRouteOrder,
- getTransitionVariant,
- resolveScrollContainer,
- layers,
- ]);
-
- // Run Motion animation when animating starts
- useLayoutEffect(() => {
- if (!isAnimating) return;
-
- if (!currentLayerRef.current) return;
-
- const currentLayerEl = currentLayerRef.current;
- const exitingLayerEl = exitingLayerRef.current;
- const transitionVariant = transitionVariantRef.current;
-
- clearLayerStyles(currentLayerEl);
- clearLayerStyles(exitingLayerEl);
-
- const scrollContainer = resolveScrollContainer();
- const exitScrollOffset = exitScrollOffsetRef.current;
- const enterScrollOffset = enterScrollOffsetRef.current;
- if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
- scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
- }
-
- const transitionDirection = transitionDirectionRef.current;
- const isForward = transitionDirection === 'forward';
- const enterFromY = isForward ? VERTICAL_ENTER_DISTANCE : -VERTICAL_ENTER_DISTANCE;
- const exitToY = isForward ? -VERTICAL_EXIT_DISTANCE : VERTICAL_EXIT_DISTANCE;
- const exitBaseY = enterScrollOffset - exitScrollOffset;
- const reduceMotion = prefersReducedMotion();
- const activeAnimations: AnimationPlaybackControlsWithThen[] = [];
- let cancelled = false;
- let completed = false;
- const completeTransition = () => {
- if (completed) return;
- completed = true;
-
- const nextLayers = nextLayersRef.current;
- nextLayersRef.current = null;
- setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
- setIsAnimating(false);
-
- clearLayerStyles(currentLayerEl);
- clearLayerStyles(exitingLayerEl);
- };
-
- if (reduceMotion) {
- // Accessibility: skip spatial motion entirely, fall back to a quick crossfade.
- if (exitingLayerEl) {
- exitingLayerEl.style.transform =
- transitionVariant === 'ios'
- ? buildIosTransform(0, exitBaseY)
- : buildVerticalTransform(exitBaseY);
- activeAnimations.push(
- animate(
- exitingLayerEl,
- { opacity: [1, 0] },
- { duration: REDUCED_MOTION_DURATION, ease: easeOutCubic }
- )
- );
- }
- currentLayerEl.style.opacity = '0';
- activeAnimations.push(
- animate(
- currentLayerEl,
- { opacity: [0, 1] },
- { duration: REDUCED_MOTION_DURATION, ease: easeOutCubic }
- )
- );
- } else if (transitionVariant === 'ios') {
- const exitToXPercent = isForward
- ? IOS_EXIT_TO_X_PERCENT_FORWARD
- : IOS_EXIT_TO_X_PERCENT_BACKWARD;
- const enterFromXPercent = isForward
- ? IOS_ENTER_FROM_X_PERCENT
- : IOS_ENTER_FROM_X_PERCENT_BACKWARD;
-
- // Background layer (the one being pushed back / coming forward from behind) gets
- // scale + opacity dim to read as "behind". Top layer is the one sliding fully on/off.
- const exitScaleTo = isForward ? IOS_BACKGROUND_SCALE : 1;
- const exitOpacityTo = isForward ? IOS_BACKGROUND_OPACITY : 1;
- const enterScaleFrom = isForward ? 1 : IOS_BACKGROUND_SCALE;
- const enterOpacityFrom = isForward ? 1 : IOS_BACKGROUND_OPACITY;
-
- if (exitingLayerEl) {
- exitingLayerEl.style.transform = buildIosTransform(0, exitBaseY, 1);
- exitingLayerEl.style.opacity = '1';
- }
-
- currentLayerEl.style.transform = buildIosTransform(enterFromXPercent, 0, enterScaleFrom);
- currentLayerEl.style.opacity = String(enterOpacityFrom);
-
- // Shadow sits on whichever layer is visually in front of the other during the slide.
- const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
- if (topLayerEl) {
- topLayerEl.style.boxShadow = IOS_SHADOW_VALUE;
- }
-
- if (exitingLayerEl) {
- activeAnimations.push(
- animate(
- exitingLayerEl,
- {
- transform: [
- buildIosTransform(0, exitBaseY, 1),
- buildIosTransform(exitToXPercent, exitBaseY, exitScaleTo),
- ],
- opacity: [1, exitOpacityTo],
- },
- {
- duration: IOS_TRANSITION_DURATION,
- ease: easeOutCubic,
- }
- )
- );
- }
-
- activeAnimations.push(
- animate(
- currentLayerEl,
- {
- transform: [
- buildIosTransform(enterFromXPercent, 0, enterScaleFrom),
- buildIosTransform(0, 0, 1),
- ],
- opacity: [enterOpacityFrom, 1],
- },
- {
- duration: IOS_TRANSITION_DURATION,
- ease: easeOutCubic,
- }
- )
- );
- } else {
- // Vertical: split timing — exit leaves quickly (accelerate), enter settles slowly (decelerate).
- if (exitingLayerEl) {
- exitingLayerEl.style.transform = buildVerticalTransform(exitBaseY);
- activeAnimations.push(
- animate(
- exitingLayerEl,
- {
- transform: [
- buildVerticalTransform(exitBaseY),
- buildVerticalTransform(exitBaseY + exitToY),
- ],
- opacity: [1, 0],
- },
- {
- duration: VERTICAL_EXIT_DURATION,
- ease: easeInQuad,
- }
- )
- );
- }
-
- currentLayerEl.style.transform = buildVerticalTransform(enterFromY);
- currentLayerEl.style.opacity = '0';
- activeAnimations.push(
- animate(
- currentLayerEl,
- {
- transform: [buildVerticalTransform(enterFromY), buildVerticalTransform(0)],
- opacity: [0, 1],
- },
- {
- duration: VERTICAL_ENTER_DURATION,
- ease: easeOutQuart,
- }
- )
- );
- }
-
- if (!activeAnimations.length) {
- completeTransition();
- } else {
- void Promise.all(
- activeAnimations.map((animation) => animation.finished.catch(() => undefined))
- ).then(() => {
- if (cancelled) return;
- completeTransition();
- });
- }
-
- return () => {
- cancelled = true;
- activeAnimations.forEach((animation) => animation.stop());
- };
- }, [isAnimating, resolveScrollContainer]);
-
- return (
-
- {(() => {
- const currentIndex = layers.findIndex((layer) => layer.status === 'current');
- const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
- const keepStackedIndex = layers
- .slice(0, resolvedCurrentIndex)
- .map((layer, index) => ({ layer, index }))
- .reverse()
- .find(({ layer }) => layer.status === 'stacked')?.index;
-
- return layers.map((layer, index) => {
- const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
- return (
-
-
- {render(layer.location)}
-
-
- );
- });
- })()}
-
- );
-}
diff --git a/frontend/src/components/common/PageTransitionLayer.ts b/frontend/src/components/common/PageTransitionLayer.ts
deleted file mode 100644
index 036b11f..0000000
--- a/frontend/src/components/common/PageTransitionLayer.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { createContext, useContext } from 'react';
-
-export type LayerStatus = 'current' | 'exiting' | 'stacked';
-
-export type PageTransitionLayerContextValue = {
- status: LayerStatus;
- isCurrentLayer: boolean;
- isAnimating: boolean;
-};
-
-export const PageTransitionLayerContext = createContext(
- null
-);
-
-export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record<
- LayerStatus,
- PageTransitionLayerContextValue
-> = {
- current: { status: 'current', isCurrentLayer: true, isAnimating: false },
- stacked: { status: 'stacked', isCurrentLayer: false, isAnimating: false },
- exiting: { status: 'exiting', isCurrentLayer: false, isAnimating: false },
-};
-
-export function usePageTransitionLayer() {
- return useContext(PageTransitionLayerContext);
-}
diff --git a/frontend/src/components/common/SecondaryScreenShell.module.scss b/frontend/src/components/common/SecondaryScreenShell.module.scss
deleted file mode 100644
index 1561beb..0000000
--- a/frontend/src/components/common/SecondaryScreenShell.module.scss
+++ /dev/null
@@ -1,83 +0,0 @@
-@use '../../styles/variables' as *;
-
-.container {
- display: flex;
- flex-direction: column;
- gap: $spacing-lg;
- min-height: 0;
-}
-
-.topBar {
- position: sticky;
- top: 0;
- z-index: 5;
- display: grid;
- grid-template-columns: 1fr auto 1fr;
- align-items: center;
- gap: $spacing-md;
- padding: $spacing-sm $spacing-md;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border-color);
- min-height: 44px;
-}
-
-.topBarTitle {
- min-width: 0;
- text-align: center;
- font-size: 16px;
- font-weight: 650;
- color: var(--text-primary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- justify-self: center;
-}
-
-.backButton {
- padding-left: 6px;
- padding-right: 10px;
- justify-self: start;
- gap: 0;
-}
-
-.backButton > span:last-child {
- display: inline-flex;
- align-items: center;
- gap: 6px;
-}
-
-.backIcon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
-
- svg {
- display: block;
- }
-}
-
-.backText {
- font-weight: 600;
- line-height: 18px;
-}
-
-.rightSlot {
- justify-self: end;
- display: flex;
- justify-content: flex-end;
-}
-
-.loadingState {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: $spacing-sm;
- padding: $spacing-2xl 0;
- color: var(--text-secondary);
-}
-
-.content {
- display: flex;
- flex-direction: column;
- gap: $spacing-lg;
-}
diff --git a/frontend/src/components/common/SecondaryScreenShell.tsx b/frontend/src/components/common/SecondaryScreenShell.tsx
deleted file mode 100644
index 2dc3513..0000000
--- a/frontend/src/components/common/SecondaryScreenShell.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { forwardRef, type ReactNode } from 'react';
-import { Button } from '@/components/ui/Button';
-import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
-import { IconChevronLeft } from '@/components/ui/icons';
-import styles from './SecondaryScreenShell.module.scss';
-
-export type SecondaryScreenShellProps = {
- title: ReactNode;
- onBack?: () => void;
- backLabel?: string;
- backAriaLabel?: string;
- rightAction?: ReactNode;
- isLoading?: boolean;
- loadingLabel?: ReactNode;
- className?: string;
- contentClassName?: string;
- children?: ReactNode;
-};
-
-export const SecondaryScreenShell = forwardRef(
- function SecondaryScreenShell(
- {
- title,
- onBack,
- backLabel = 'Back',
- backAriaLabel,
- rightAction,
- isLoading = false,
- loadingLabel = 'Loading...',
- className = '',
- contentClassName = '',
- children,
- },
- ref
- ) {
- const containerClassName = [styles.container, className].filter(Boolean).join(' ');
- const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
- const titleTooltip = typeof title === 'string' ? title : undefined;
- const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
-
- return (
-
-
- {onBack ? (
-
- ) : (
-
- )}
-
- {title}
-
-
{rightAction}
-
-
- {isLoading ? (
-
-
- {loadingLabel}
-
- ) : (
-
{children}
- )}
-
- );
- }
-);
diff --git a/frontend/src/components/excludedModels/ExcludedModelRuleChip.module.scss b/frontend/src/components/excludedModels/ExcludedModelRuleChip.module.scss
deleted file mode 100644
index eee4d86..0000000
--- a/frontend/src/components/excludedModels/ExcludedModelRuleChip.module.scss
+++ /dev/null
@@ -1,102 +0,0 @@
-.chipRow {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- min-width: 0;
-}
-
-.chip {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- min-width: 0;
- max-width: 100%;
- padding: 4px 5px 4px 9px;
- border-radius: $radius-full;
- color: var(--text-primary);
- font-family: $font-mono;
- font-size: 11px;
- line-height: 1.5;
-}
-
-.label {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.detail {
- flex-shrink: 0;
- color: var(--text-tertiary);
- font-size: 10px;
-}
-
-/* 显式勾选:实线 + primary 染色,读起来是「我选的」。 */
-.exact {
- border: 1px solid color-mix(in srgb, var(--primary-color) 45%, var(--border-color));
- background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary));
-}
-
-/* 规则派生:虚线 = 「不是逐个挑的,是某条规则算出来的」。 */
-.wildcard {
- border: 1px dashed color-mix(in srgb, var(--primary-color) 38%, var(--border-color));
- background: transparent;
- color: var(--text-secondary);
-}
-
-/* 目录外的精确规则:同样虚线,但更弱——它指向一个我们无法确认存在的模型。 */
-.unknown {
- border: 1px dashed var(--border-color);
- background: transparent;
- color: var(--text-tertiary);
-}
-
-.remove {
- display: inline-flex;
- flex-shrink: 0;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- padding: 0;
- border: 0;
- border-radius: 50%;
- background: transparent;
- color: var(--text-tertiary);
- cursor: pointer;
- transition:
- background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
-
- &:active:not(:disabled) {
- transform: scale(0.9);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 1px;
- }
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.5;
- }
-
- @media (hover: hover) and (pointer: fine) {
- &:hover:not(:disabled) {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- }
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .remove {
- transition: none;
- }
-
- .remove:active:not(:disabled) {
- transform: none;
- }
-}
diff --git a/frontend/src/components/excludedModels/ExcludedModelRuleChip.tsx b/frontend/src/components/excludedModels/ExcludedModelRuleChip.tsx
deleted file mode 100644
index a7d5b70..0000000
--- a/frontend/src/components/excludedModels/ExcludedModelRuleChip.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { ReactNode } from 'react';
-import { IconX } from '@/components/ui/icons';
-import styles from './ExcludedModelRuleChip.module.scss';
-
-/**
- * 排除项 chip —— 按**来源**区分三种形态,取代两处近乎重复的手写标记
- * (`AuthFileDetailsSheet.module.scss` 的 `.excludedModelChip` 与
- * `AuthFilesOAuthExcludedEditPage.module.scss` 的 `.customRuleChip`)。
- *
- * - `exact` 实线 primary 染色:用户显式勾选的模型,可直接移除。
- * - `wildcard` 虚线:由通配符规则派生出的模型。没有 ✕——要移除得去改那条规则,
- * 直接给个 ✕ 会承诺一件它做不到的事。
- * - `unknown` 虚线弱化:精确规则但目录里没有(如已下线的模型 id),可移除。
- */
-export type ExcludedModelChipVariant = 'exact' | 'wildcard' | 'unknown';
-
-export interface ExcludedModelRuleChipProps {
- label: string;
- variant?: ExcludedModelChipVariant;
- /** 次要说明,例如派生该 chip 的规则。 */
- detail?: string;
- /** 省略即不渲染 ✕。 */
- onRemove?: () => void;
- removeAriaLabel?: string;
- disabled?: boolean;
- title?: string;
-}
-
-/** chip 的换行容器。单独导出,免得每个消费方各写一遍 flex-wrap。 */
-export function ExcludedModelChipRow({ children }: { children: ReactNode }) {
- return {children}
;
-}
-
-export function ExcludedModelRuleChip({
- label,
- variant = 'exact',
- detail,
- onRemove,
- removeAriaLabel,
- disabled = false,
- title,
-}: ExcludedModelRuleChipProps) {
- return (
-
- {label}
- {detail ? {detail} : null}
- {onRemove ? (
-
- ) : null}
-
- );
-}
diff --git a/frontend/src/components/excludedModels/ExcludedModelsPanel.tsx b/frontend/src/components/excludedModels/ExcludedModelsPanel.tsx
deleted file mode 100644
index b9bd0bb..0000000
--- a/frontend/src/components/excludedModels/ExcludedModelsPanel.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { IconCheck, IconSearch } from '@/components/ui/icons';
-import {
- getModelExclusionState,
- type ExclusionStats,
- type ModelExclusionState,
-} from './excludedModelRules';
-import styles from './ExcludedModelsPicker.module.scss';
-
-export interface ExcludedModelCandidate {
- id: string;
- displayName?: string;
-}
-
-interface ExcludedModelsPanelProps {
- rules: readonly string[];
- candidates: readonly ExcludedModelCandidate[];
- /** 由 Picker 算好传下来,避免在 footer 里把整个目录再扫一遍。 */
- stats: ExclusionStats;
- onToggle: (modelId: string, excluded: boolean) => void;
- onSelectAll: () => void;
- onClear: () => void;
- disabled: boolean;
- listboxId: string;
- /** 展开后是否把焦点送进搜索框(键盘展开时为 true,鼠标点开时也为 true)。 */
- autoFocus: boolean;
- /** 收起面板并把焦点还给 trigger。 */
- onDismiss: () => void;
-}
-
-const matchesQuery = (candidate: ExcludedModelCandidate, query: string): boolean =>
- candidate.id.toLowerCase().includes(query) ||
- (candidate.displayName ?? '').toLowerCase().includes(query);
-
-export function ExcludedModelsPanel({
- rules,
- candidates,
- stats,
- onToggle,
- onSelectAll,
- onClear,
- disabled,
- listboxId,
- autoFocus,
- onDismiss,
-}: ExcludedModelsPanelProps) {
- const { t } = useTranslation();
- const [query, setQuery] = useState('');
- const [highlight, setHighlight] = useState(0);
- const inputRef = useRef(null);
-
- const visible = useMemo(() => {
- const normalized = query.trim().toLowerCase();
- if (!normalized) return candidates;
- return candidates.filter((candidate) => matchesQuery(candidate, normalized));
- }, [candidates, query]);
-
- // 高亮永远钳在可见范围内:过滤后列表变短,旧索引会指向不存在的行。
- const activeIndex = visible.length === 0 ? -1 : Math.min(highlight, visible.length - 1);
- const activeId = activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined;
-
- useLayoutEffect(() => {
- if (!autoFocus) return;
- // preventScroll:裸 focus() 会把外层 Sheet 的滚动猛拽过来,动画中途还会把面板顶出视野。
- inputRef.current?.focus({ preventScroll: true });
- }, [autoFocus]);
-
- useEffect(() => {
- if (!autoFocus || activeIndex < 0) return;
- document
- .getElementById(`${listboxId}-opt-${activeIndex}`)
- ?.scrollIntoView({ block: 'nearest' });
- }, [activeIndex, autoFocus, listboxId]);
-
- const toggleAt = (index: number) => {
- const candidate = visible[index];
- if (!candidate || disabled) return;
- const current = getModelExclusionState(rules, candidate.id);
- // 纯通配符命中的行不可直接切换——它的排除权属于那条规则。行内副文本常驻解释原因。
- if (current.state === 'excluded' && current.by === 'wildcard') return;
- onToggle(candidate.id, current.state !== 'excluded');
- };
-
- const handleKeyDown = (event: React.KeyboardEvent) => {
- switch (event.key) {
- case 'ArrowDown':
- event.preventDefault();
- setHighlight((prev) => Math.min(prev + 1, visible.length - 1));
- return;
- case 'ArrowUp':
- event.preventDefault();
- setHighlight((prev) => Math.max(prev - 1, 0));
- return;
- case 'Home':
- if (visible.length === 0) return;
- event.preventDefault();
- setHighlight(0);
- return;
- case 'End':
- if (visible.length === 0) return;
- event.preventDefault();
- setHighlight(visible.length - 1);
- return;
- case 'Enter':
- event.preventDefault();
- if (activeIndex >= 0) toggleAt(activeIndex);
- return;
- case 'Escape':
- // 外层 Sheet 在 document 上、OAuth 页在 window 上都听 Escape。
- // 不拦住就会「关面板 = 关 Sheet / 离开页面 + 触发未保存弹窗」。
- event.preventDefault();
- event.stopPropagation();
- if (query) {
- setQuery('');
- setHighlight(0);
- return;
- }
- onDismiss();
- return;
- default:
- }
- };
-
- return (
-
-
-
- {
- setQuery(event.target.value);
- setHighlight(0);
- }}
- onKeyDown={handleKeyDown}
- placeholder={t('excluded_models.search_placeholder')}
- aria-label={t('excluded_models.search_aria')}
- aria-controls={listboxId}
- aria-activedescendant={activeId}
- disabled={disabled}
- autoComplete="off"
- spellCheck={false}
- />
-
-
-
- {visible.length === 0 ? (
-
- {query.trim()
- ? t('excluded_models.no_results', { query: query.trim() })
- : t('excluded_models.catalog_empty')}
-
- ) : (
- visible.map((candidate, index) => (
-
setHighlight(index)}
- onToggle={() => toggleAt(index)}
- />
- ))
- )}
-
-
-
-
- {t('excluded_models.footer_count', { excluded: stats.excluded, total: stats.total })}
-
-
-
-
-
-
-
- );
-}
-
-interface ExcludedModelRowProps {
- id: string;
- candidate: ExcludedModelCandidate;
- state: ModelExclusionState;
- highlighted: boolean;
- onHover: () => void;
- onToggle: () => void;
-}
-
-function ExcludedModelRow({
- id,
- candidate,
- state,
- highlighted,
- onHover,
- onToggle,
-}: ExcludedModelRowProps) {
- const { t } = useTranslation();
- const excluded = state.state === 'excluded';
- const lockedByRule = state.state === 'excluded' && state.by === 'wildcard';
- // 把「哪条规则、用哪句话解释」在一处收敛好,下面的 JSX 就不必再做类型收窄。
- const wildcardReason =
- state.state === 'excluded' && (state.by === 'wildcard' || state.by === 'both')
- ? {
- rule: state.rule,
- text:
- state.by === 'wildcard'
- ? t('excluded_models.wildcard_locked', { rule: state.rule })
- : t('excluded_models.also_wildcard', { rule: state.rule }),
- muted: state.by === 'both',
- }
- : null;
-
- const rowClass = [
- styles.row,
- excluded ? styles.rowExcluded : '',
- lockedByRule ? styles.rowLocked : '',
- highlighted ? styles.rowHighlighted : '',
- ]
- .filter(Boolean)
- .join(' ');
-
- return (
-
-
- {excluded ? : null}
-
-
- {candidate.id}
- {candidate.displayName && candidate.displayName !== candidate.id ? (
- {candidate.displayName}
- ) : null}
- {wildcardReason ? {wildcardReason.text} : null}
-
- {wildcardReason ? (
-
- {t('excluded_models.badge_wildcard')}
-
- ) : null}
-
- );
-}
diff --git a/frontend/src/components/excludedModels/ExcludedModelsPicker.module.scss b/frontend/src/components/excludedModels/ExcludedModelsPicker.module.scss
deleted file mode 100644
index b9d2728..0000000
--- a/frontend/src/components/excludedModels/ExcludedModelsPicker.module.scss
+++ /dev/null
@@ -1,477 +0,0 @@
-.root {
- display: flex;
- flex-direction: column;
- gap: $spacing-sm;
- min-width: 0;
-}
-
-/* -------------------------------------------------------------------------- */
-/* Trigger —— 摘要 + 计量条,取代「把计数塞进 placeholder」 */
-/* -------------------------------------------------------------------------- */
-
-.trigger {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $spacing-sm;
- width: 100%;
- min-height: 40px;
- padding: 0 12px;
- overflow: hidden;
- border: 1px solid var(--border-color);
- border-radius: $radius-md;
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 13px;
- text-align: left;
- cursor: pointer;
- transition:
- border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
-
- /* 整条 40px 宽元素上 0.97 太橡皮;0.99 足够被感知又不显廉价。 */
- &:active:not(:disabled) {
- transform: scale(0.99);
- }
-
- &:focus-visible {
- outline: none;
- border-color: var(--primary-color);
- box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
- }
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.6;
- }
-
- @media (hover: hover) and (pointer: fine) {
- &:hover:not(:disabled) {
- border-color: color-mix(in srgb, var(--primary-color) 40%, var(--border-color));
- }
- }
-}
-
-.triggerText {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.triggerSpinner {
- flex-shrink: 0;
- color: var(--text-tertiary);
- animation: excluded-spin 900ms linear infinite;
-}
-
-.chevron {
- flex-shrink: 0;
- color: var(--text-tertiary);
- transition: transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
-}
-
-.triggerOpen .chevron {
- transform: rotate(180deg);
-}
-
-/* 底边发丝计量条:零成本地长期回答「我到底排除了多少」。 */
-.meter {
- position: absolute;
- right: 0;
- bottom: 0;
- left: 0;
- height: 2px;
- background: var(--bg-tertiary);
-}
-
-.meterFill {
- display: block;
- height: 100%;
- background: color-mix(in srgb, var(--primary-color) 70%, var(--text-primary));
- transition: width 360ms var(--ease-out-strong, ease);
-}
-
-/* -------------------------------------------------------------------------- */
-/* 内联展开:grid 0fr→1fr。搜索框会在展开状态下过滤列表,每次击键都改高度, */
-/* grid 轨道自动重解,无需测量,也没有 ResizeObserver 要去跟击键搏斗。 */
-/* -------------------------------------------------------------------------- */
-
-.disclosure {
- display: grid;
- grid-template-rows: 0fr;
- /* 退场更快 + 加速。themes.scss 无 --ease-in* token,这里用关键字而非发明全局 token。 */
- transition: grid-template-rows 120ms ease-in;
-}
-
-.disclosureOpen {
- grid-template-rows: 1fr;
- transition: grid-template-rows var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
-}
-
-.disclosureInner {
- /* 必需:grid item 默认 min-height:auto,漏了它面板收不回去。 */
- min-height: 0;
- overflow: hidden;
-}
-
-.panel {
- display: flex;
- flex-direction: column;
- margin-top: 6px;
- overflow: hidden;
- border: 1px solid var(--border-color);
- border-radius: $radius-md;
- background: var(--bg-secondary);
- transform-origin: top;
- animation: excluded-panel-in var(--dur-hover, 200ms) var(--ease-out-strong, ease-out) both;
-}
-
-/* 只写 from,让元素的静止样式定义终点(与 toolbar-popover-in 同一写法)。 */
-@keyframes excluded-panel-in {
- from {
- opacity: 0;
- /* 0.98 而非 0.95——面板是宽内联块,600px 下 0.95 是 30px 的横向蠕动。 */
- transform: scale(0.98);
- }
-}
-
-@keyframes excluded-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-/* -------------------------------------------------------------------------- */
-/* 搜索 */
-/* -------------------------------------------------------------------------- */
-
-.searchRow {
- position: relative;
- display: flex;
- align-items: center;
- padding: 8px;
- border-bottom: 1px solid var(--border-color);
-}
-
-.searchIcon {
- position: absolute;
- left: 18px;
- color: var(--text-tertiary);
- pointer-events: none;
-}
-
-.search {
- width: 100%;
- padding: 6px 10px 6px 32px;
- border: 1px solid var(--border-color);
- border-radius: $radius-sm;
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 12px;
-
- &::placeholder {
- color: var(--text-tertiary);
- }
-
- &:focus {
- outline: none;
- border-color: var(--primary-color);
- box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
- }
-}
-
-/* -------------------------------------------------------------------------- */
-/* 列表 */
-/* -------------------------------------------------------------------------- */
-
-.list {
- display: flex;
- flex-direction: column;
- max-height: 260px;
- padding: 6px;
- overflow-y: auto;
- overscroll-behavior: contain;
- scrollbar-gutter: stable;
-}
-
-.row {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
- padding: 7px 8px;
- border-radius: $radius-sm;
- cursor: pointer;
- transition: background-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: -2px;
- }
-}
-
-.rowHighlighted {
- background: var(--bg-tertiary);
-}
-
-.rowExcluded .checkbox {
- border-color: var(--primary-color);
- background: var(--primary-color);
- color: var(--bg-primary);
-}
-
-/* 纯规则命中:压暗且不可切换,但仍可聚焦、仍会朗读原因。 */
-.rowLocked {
- cursor: default;
- opacity: 0.62;
-}
-
-.checkbox {
- display: inline-flex;
- flex-shrink: 0;
- align-items: center;
- justify-content: center;
- width: 16px;
- height: 16px;
- border: 1px solid var(--border-color);
- border-radius: $radius-sm;
- background: var(--bg-primary);
- transition:
- background-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
- border-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
-}
-
-.rowText {
- display: flex;
- flex-direction: column;
- gap: 1px;
- min-width: 0;
- flex: 1;
-}
-
-.rowId {
- overflow: hidden;
- color: var(--text-primary);
- font-family: $font-mono;
- font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.rowDisplayName,
-.rowReason {
- overflow: hidden;
- color: var(--text-tertiary);
- font-size: 11px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.badge {
- flex-shrink: 0;
- padding: 2px 6px;
- border: 1px dashed color-mix(in srgb, var(--primary-color) 38%, var(--border-color));
- border-radius: $radius-full;
- color: var(--text-secondary);
- font-size: 10px;
-}
-
-.badgeMuted {
- border-style: dotted;
- color: var(--text-tertiary);
-}
-
-.noResults {
- margin: 0;
- padding: $spacing-lg $spacing-sm;
- color: var(--text-tertiary);
- font-size: 12px;
- text-align: center;
-}
-
-/* -------------------------------------------------------------------------- */
-/* 吸底摘要 */
-/* -------------------------------------------------------------------------- */
-
-.footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $spacing-sm;
- padding: 8px 10px;
- border-top: 1px solid var(--border-color);
- background: var(--bg-primary);
-}
-
-.footerCount {
- color: var(--text-secondary);
- font-size: 11px;
- font-variant-numeric: tabular-nums;
-}
-
-.footerActions {
- display: inline-flex;
- align-items: center;
- gap: 4px;
-}
-
-.footerButton {
- padding: 4px 8px;
- border: 0;
- border-radius: $radius-sm;
- background: transparent;
- color: var(--text-secondary);
- font-size: 11px;
- cursor: pointer;
- transition:
- background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
-
- &:active:not(:disabled) {
- transform: scale(0.96);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 1px;
- }
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.5;
- }
-
- @media (hover: hover) and (pointer: fine) {
- &:hover:not(:disabled) {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- }
- }
-}
-
-/* -------------------------------------------------------------------------- */
-/* 无目录降级 / 规则编辑器 */
-/* -------------------------------------------------------------------------- */
-
-.catalogNotice {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $spacing-sm;
- margin-top: 6px;
- padding: 12px;
- border: 1px solid var(--border-color);
- border-radius: $radius-md;
- background: var(--bg-secondary);
- color: var(--text-secondary);
- font-size: 12px;
-}
-
-.retryButton {
- flex-shrink: 0;
- padding: 4px 10px;
- border: 1px solid var(--border-color);
- border-radius: $radius-sm;
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 11px;
- cursor: pointer;
- transition: transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
-
- &:active {
- transform: scale(0.96);
- }
-}
-
-.chipsMore {
- align-self: center;
- color: var(--text-tertiary);
- font-size: 11px;
-}
-
-.ruleEditor {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.ruleLabel {
- color: var(--text-secondary);
- font-size: 12px;
- font-weight: 500;
-}
-
-.ruleMatches {
- display: flex;
- flex-direction: column;
- gap: 2px;
- margin: 0;
- padding: 0;
- list-style: none;
-
- li {
- display: flex;
- align-items: center;
- gap: 6px;
- color: var(--text-tertiary);
- font-size: 11px;
- }
-
- code {
- color: var(--text-secondary);
- font-family: $font-mono;
- }
-}
-
-/* 零命中是 warning 不是 error:规则可以合法地指向目录不认识的模型。 */
-.ruleMatchNone {
- color: var(--warning-color, #{$warning-color});
-}
-
-.ruleWarning {
- display: flex;
- align-items: center;
- gap: 6px;
- margin: 0;
- color: var(--warning-color, #{$warning-color});
- font-size: 11px;
-}
-
-/* -------------------------------------------------------------------------- */
-
-@media (prefers-reduced-motion: reduce) {
- .disclosure,
- .disclosureOpen {
- transition: none;
- }
-
- .panel {
- animation: none;
- }
-
- .trigger,
- .chevron,
- .meterFill,
- .row,
- .checkbox,
- .footerButton,
- .retryButton {
- transition: none;
- }
-
- .triggerSpinner {
- animation: none;
- }
-
- .trigger:active:not(:disabled),
- .footerButton:active:not(:disabled),
- .retryButton:active {
- transform: none;
- }
-}
diff --git a/frontend/src/components/excludedModels/ExcludedModelsPicker.tsx b/frontend/src/components/excludedModels/ExcludedModelsPicker.tsx
deleted file mode 100644
index a866c2b..0000000
--- a/frontend/src/components/excludedModels/ExcludedModelsPicker.tsx
+++ /dev/null
@@ -1,319 +0,0 @@
-import { useCallback, useId, useMemo, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { IconAlertTriangle, IconChevronDown, IconLoader2 } from '@/components/ui/icons';
-import { ExcludedModelChipRow, ExcludedModelRuleChip } from './ExcludedModelRuleChip';
-import { ExcludedModelsPanel, type ExcludedModelCandidate } from './ExcludedModelsPanel';
-import {
- formatExcludedRulesText,
- getModelExclusionState,
- matchedModelsByRule,
- normalizeExcludedRules,
- replaceCustomExcludedRules,
- splitExcludedRules,
- summarizeExclusion,
- toggleExcludedRule,
-} from './excludedModelRules';
-import styles from './ExcludedModelsPicker.module.scss';
-
-export type { ExcludedModelCandidate };
-
-export type ExcludedModelsCatalogState = 'ready' | 'loading' | 'unavailable' | 'error';
-
-/** 派生 chip 的上限——超过这个数就只报总数,否则 chip 行会淹没整个字段。 */
-const DERIVED_CHIP_LIMIT = 8;
-
-export interface ExcludedModelsPickerProps {
- /** 规范的规则列表。调用方内部存文本/Set 都行,在边界上适配一次即可。 */
- value: readonly string[];
- onChange: (next: string[]) => void;
-
- candidates: readonly ExcludedModelCandidate[];
- catalogState?: ExcludedModelsCatalogState;
- onRetryCatalog?: () => void;
-
- /** 真实禁用(未连接 / 保存中)。**绝不要**因为目录为空就传 true。 */
- disabled?: boolean;
-
- /** picker 不得读写、也不许用户输入的规则。provider 表单传 `['*']`。 */
- reservedRules?: readonly string[];
- reservedRuleMessage?: string;
-
- /** 关掉通配符规则编辑器。 */
- showRuleEditor?: boolean;
-
- labelledBy?: string;
- className?: string;
-}
-
-export function ExcludedModelsPicker({
- value,
- onChange,
- candidates,
- catalogState = 'ready',
- onRetryCatalog,
- disabled = false,
- reservedRules,
- reservedRuleMessage,
- showRuleEditor = true,
- labelledBy,
- className,
-}: ExcludedModelsPickerProps) {
- const { t } = useTranslation();
- const baseId = useId();
- const panelId = `${baseId}-panel`;
- const listboxId = `${baseId}-listbox`;
- const [open, setOpen] = useState(false);
- const [reservedHit, setReservedHit] = useState(false);
- const triggerRef = useRef(null);
-
- const reservedKeys = useMemo(
- () => new Set((reservedRules ?? []).map((rule) => rule.trim().toLowerCase())),
- [reservedRules]
- );
-
- /**
- * 保留规则在**入口**就被剥掉,因此 picker 内部从不见到它,也就不可能把它写回去。
- * provider 表单的 `'*'`(= 已停用)由 disabled 开关独占,排除面无权触碰。
- */
- const rules = useMemo(
- () =>
- normalizeExcludedRules(value).filter((rule) => !reservedKeys.has(rule.trim().toLowerCase())),
- [reservedKeys, value]
- );
-
- const candidateIds = useMemo(() => candidates.map((c) => c.id), [candidates]);
- const stats = useMemo(() => summarizeExclusion(rules, candidateIds), [candidateIds, rules]);
- const { exactRules, unknownRules, customRules } = useMemo(
- () => splitExcludedRules(rules, candidateIds),
- [candidateIds, rules]
- );
-
- const commit = useCallback(
- (next: readonly string[]) => {
- // 出口再滤一次保留规则:纵深防御,规则编辑器里手打的 `*` 到不了调用方。
- onChange(next.filter((rule) => !reservedKeys.has(rule.trim().toLowerCase())));
- },
- [onChange, reservedKeys]
- );
-
- const hasCatalog = catalogState === 'ready' && candidates.length > 0;
-
- /** 通配符派生出的模型(排除掉已显式勾选的,那些走实线 chip)。 */
- const derivedModels = useMemo(() => {
- if (!hasCatalog) return [];
- const out: Array<{ id: string; rule: string }> = [];
- candidateIds.forEach((id) => {
- const state = getModelExclusionState(rules, id);
- if (state.state === 'excluded' && state.by === 'wildcard') out.push({ id, rule: state.rule });
- });
- return out;
- }, [candidateIds, hasCatalog, rules]);
-
- const ruleSummaries = useMemo(
- () => (hasCatalog ? matchedModelsByRule(customRules, candidateIds) : []),
- [candidateIds, customRules, hasCatalog]
- );
-
- const handleToggle = (modelId: string, excluded: boolean) =>
- commit(toggleExcludedRule(rules, modelId, excluded));
-
- const handleSelectAll = () => commit(normalizeExcludedRules([...rules, ...candidateIds]));
-
- /** 只清精确勾选,通配符规则留给它自己的编辑器——否则一次点击会抹掉用户手写的规则。 */
- const handleClear = () => commit(customRules);
-
- const handleRuleEditorChange = (text: string) => {
- const typedReserved = text
- .split(/\r?\n/)
- .some((line) => reservedKeys.has(line.trim().toLowerCase()));
- setReservedHit(typedReserved);
- commit(replaceCustomExcludedRules(rules, candidateIds, text));
- };
-
- const dismissPanel = useCallback(() => {
- setOpen(false);
- triggerRef.current?.focus({ preventScroll: true });
- }, []);
-
- const summaryText = () => {
- if (catalogState === 'loading') return t('excluded_models.catalog_loading');
- if (hasCatalog) {
- if (stats.excluded === 0 && rules.length === 0) return t('excluded_models.trigger_empty');
- return t('excluded_models.trigger_summary', {
- excluded: stats.excluded,
- available: stats.available,
- });
- }
- // 无目录:只能诚实地报规则条数,不能假装知道「还剩几个可用」。
- if (rules.length === 0) return t('excluded_models.trigger_empty');
- return t('excluded_models.trigger_summary_rules', { n: rules.length });
- };
-
- return (
-
-
-
-
-
- {catalogState === 'ready' || candidates.length > 0 ? (
-
- ) : (
-
-
- {catalogState === 'loading'
- ? t('excluded_models.catalog_loading')
- : catalogState === 'error'
- ? t('excluded_models.catalog_error')
- : t('excluded_models.catalog_unavailable')}
-
- {onRetryCatalog && catalogState !== 'loading' ? (
-
- ) : null}
-
- )}
-
-
-
- {exactRules.length > 0 || derivedModels.length > 0 || unknownRules.length > 0 ? (
-
- {exactRules.map((rule) => (
- commit(toggleExcludedRule(rules, rule, false))}
- removeAriaLabel={t('excluded_models.chip_remove', { rule })}
- disabled={disabled}
- />
- ))}
- {derivedModels.slice(0, DERIVED_CHIP_LIMIT).map((item) => (
-
- ))}
- {derivedModels.length > DERIVED_CHIP_LIMIT ? (
-
- {t('excluded_models.chips_more', { n: derivedModels.length - DERIVED_CHIP_LIMIT })}
-
- ) : null}
- {unknownRules.map((rule) => (
- commit(toggleExcludedRule(rules, rule, false))}
- removeAriaLabel={t('excluded_models.chip_remove', { rule })}
- disabled={disabled}
- />
- ))}
-
- ) : null}
-
- {showRuleEditor ? (
-
-
-
- ) : null}
-
- );
-}
diff --git a/frontend/src/components/excludedModels/excludedModelRules.ts b/frontend/src/components/excludedModels/excludedModelRules.ts
deleted file mode 100644
index c911468..0000000
--- a/frontend/src/components/excludedModels/excludedModelRules.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-/**
- * 排除模型规则 —— 唯一的纯逻辑源。
- *
- * 由两个已删除的模块合并而来:`excludedModelSelection.ts`(文本/数组式)与
- * `oauthExcludedRules.ts`(Set 式),二者曾是同一个领域模型写了两遍——
- * 其 normalize 实现逐字相同,只是一个吃换行文本、一个吃可迭代对象。
- *
- * 规则语义(与后端一致):
- * - 大小写不敏感;
- * - `*` 匹配任意字符,其余字符按字面量处理(`gpt-4.1` 里的 `.` 不是正则通配符);
- * - 去重按小写 key,但**保留首次出现的拼写**。
- */
-
-/** 后端「停用整个 provider」的编码。只属于 provider 表单的 disabled 开关,排除面永不产出它。 */
-export const DISABLE_ALL_RULE = '*';
-
-const ruleKey = (value: string): string => value.trim().toLowerCase();
-
-export const isWildcardRule = (rule: string): boolean => rule.includes('*');
-
-export function normalizeExcludedRules(values: Iterable): string[] {
- const seen = new Set();
- const rules: string[] = [];
-
- for (const value of values) {
- const rule = value.trim();
- const key = ruleKey(rule);
- if (!key || seen.has(key)) continue;
- seen.add(key);
- rules.push(rule);
- }
-
- return rules;
-}
-
-export const parseExcludedRulesText = (text: string): string[] =>
- normalizeExcludedRules(text.split(/\r?\n/));
-
-export const formatExcludedRulesText = (rules: readonly string[]): string => rules.join('\n');
-
-export function matchesExcludedRule(rule: string, modelId: string): boolean {
- const normalizedRule = ruleKey(rule);
- const normalizedModel = ruleKey(modelId);
- if (!normalizedRule || !normalizedModel) return false;
- if (!isWildcardRule(normalizedRule)) return normalizedRule === normalizedModel;
-
- // 按 `*` 切开,逐段转义正则元字符,再用 `.*` 接回——只有 `*` 是通配符。
- const escaped = normalizedRule
- .split('*')
- .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
- .join('.*');
- return new RegExp(`^${escaped}$`, 'i').test(normalizedModel);
-}
-
-/** 该模型是否被某条**通配符**规则命中(精确规则不算)。 */
-export const isMatchedByWildcardRule = (rules: Iterable, modelId: string): boolean =>
- Array.from(rules).some((rule) => isWildcardRule(rule) && matchesExcludedRule(rule, modelId));
-
-/** 规则列表里是否存在与 candidate 字面相等(忽略大小写)的一条。不做通配符展开。 */
-export function hasExcludedRule(rules: Iterable, candidate: string): boolean {
- const candidateKey = ruleKey(candidate);
- if (!candidateKey) return false;
- return Array.from(rules).some((rule) => ruleKey(rule) === candidateKey);
-}
-
-/**
- * 增删一条字面规则。
- *
- * 注意:这里按 key 过滤,**不**豁免含 `*` 的规则。旧的 `toggleExcludedModel` 曾拒绝删除
- * 通配符规则,那是因为调用点有两个互不知情的写入面(列表管精确、textarea 管通配符)需要
- * 互不践踏。统一组件里两个面同属一个组件,该守卫属于组件而非纯函数。
- */
-export function toggleExcludedRule(
- rules: Iterable,
- candidate: string,
- excluded: boolean
-): string[] {
- const candidateRule = candidate.trim();
- const candidateKey = ruleKey(candidateRule);
- const next = normalizeExcludedRules(rules).filter((rule) => ruleKey(rule) !== candidateKey);
-
- if (excluded && candidateKey) next.push(candidateRule);
- return next;
-}
-
-export interface SplitExcludedRules {
- /** 精确命中目录的规则,**改写为目录的拼写**(勾选框驱动,id 应当规范化)。 */
- exactRules: string[];
- /** 含 `*` 的规则,保留配置里的拼写。 */
- wildcardRules: string[];
- /** 精确但目录里没有的规则(如已下线的模型 id),保留配置里的拼写。 */
- unknownRules: string[];
- /** `wildcardRules ∪ unknownRules`,但按**原始出现顺序**——textarea 的内容与顺序敏感的 diff 都依赖它。 */
- customRules: string[];
-}
-
-export function splitExcludedRules(
- rules: Iterable,
- candidateIds: readonly string[]
-): SplitExcludedRules {
- const candidateByKey = new Map(candidateIds.map((id) => [ruleKey(id), id]));
- const exactRules: string[] = [];
- const wildcardRules: string[] = [];
- const unknownRules: string[] = [];
- const customRules: string[] = [];
-
- normalizeExcludedRules(rules).forEach((rule) => {
- if (isWildcardRule(rule)) {
- wildcardRules.push(rule);
- customRules.push(rule);
- return;
- }
- const candidate = candidateByKey.get(ruleKey(rule));
- if (candidate) {
- exactRules.push(candidate);
- return;
- }
- unknownRules.push(rule);
- customRules.push(rule);
- });
-
- return { exactRules, wildcardRules, unknownRules, customRules };
-}
-
-/** 用一段文本整体替换「自定义」半边(通配符 + 目录外精确规则),保留精确勾选的那一半。 */
-export function replaceCustomExcludedRules(
- rules: Iterable,
- candidateIds: readonly string[],
- text: string
-): string[] {
- const { exactRules } = splitExcludedRules(rules, candidateIds);
- return normalizeExcludedRules([...exactRules, ...parseExcludedRulesText(text)]);
-}
-
-/* -------------------------------------------------------------------------- */
-/* 展示用派生量 */
-/* -------------------------------------------------------------------------- */
-
-/**
- * 单个模型的排除态。
- *
- * `both` 是最微妙的一档:模型既被显式勾选、又被某条通配符规则命中。取消勾选后它**依然
- * 被排除**,所以那一行不能在视觉上「取消打勾」,否则用户会以为点击失败。旧 UI 把这一档
- * 完全藏了起来。
- */
-export type ModelExclusionState =
- | { state: 'included' }
- | { state: 'excluded'; by: 'exact' }
- | { state: 'excluded'; by: 'wildcard'; rule: string }
- | { state: 'excluded'; by: 'both'; rule: string };
-
-export function getModelExclusionState(
- rules: readonly string[],
- modelId: string
-): ModelExclusionState {
- const modelKey = ruleKey(modelId);
- if (!modelKey) return { state: 'included' };
-
- let hasExact = false;
- let wildcard: string | undefined;
-
- for (const rule of rules) {
- if (isWildcardRule(rule)) {
- if (wildcard === undefined && matchesExcludedRule(rule, modelId)) wildcard = rule;
- } else if (!hasExact && ruleKey(rule) === modelKey) {
- hasExact = true;
- }
- }
-
- if (hasExact && wildcard !== undefined) return { state: 'excluded', by: 'both', rule: wildcard };
- if (hasExact) return { state: 'excluded', by: 'exact' };
- if (wildcard !== undefined) return { state: 'excluded', by: 'wildcard', rule: wildcard };
- return { state: 'included' };
-}
-
-export const isModelExcluded = (rules: readonly string[], modelId: string): boolean =>
- getModelExclusionState(rules, modelId).state === 'excluded';
-
-export interface RuleMatchSummary {
- rule: string;
- /** 该规则命中的目录模型,按目录顺序。 */
- matched: string[];
- matchCount: number;
-}
-
-/** 每条规则各命中了目录里的哪些模型——通配符编辑器的实时反馈就靠它。 */
-export const matchedModelsByRule = (
- rules: readonly string[],
- candidateIds: readonly string[]
-): RuleMatchSummary[] =>
- rules.map((rule) => {
- const matched = candidateIds.filter((id) => matchesExcludedRule(rule, id));
- return { rule, matched, matchCount: matched.length };
- });
-
-export interface ExclusionStats {
- total: number;
- excluded: number;
- available: number;
-}
-
-/**
- * 摘要行与计量条的数据源。
- *
- * `excluded` 数的是**目录内被任意规则命中的模型数**,不是 `rules.length`——一条
- * `gpt-5-*` 可能命中 6 个模型,也可能一个都不命中。用规则数当分子会在新地方复刻
- * 旧 UI 那个谎言:分子分母必须同源,计量条才是诚实的。
- */
-export function summarizeExclusion(
- rules: readonly string[],
- candidateIds: readonly string[]
-): ExclusionStats {
- const total = candidateIds.length;
- const excluded = candidateIds.reduce(
- (count, id) => (isModelExcluded(rules, id) ? count + 1 : count),
- 0
- );
- return { total, excluded, available: total - excluded };
-}
diff --git a/frontend/src/components/excludedModels/index.ts b/frontend/src/components/excludedModels/index.ts
deleted file mode 100644
index 7ab1e41..0000000
--- a/frontend/src/components/excludedModels/index.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-export {
- ExcludedModelsPicker,
- type ExcludedModelCandidate,
- type ExcludedModelsCatalogState,
- type ExcludedModelsPickerProps,
-} from './ExcludedModelsPicker';
-export {
- ExcludedModelChipRow,
- ExcludedModelRuleChip,
- type ExcludedModelChipVariant,
- type ExcludedModelRuleChipProps,
-} from './ExcludedModelRuleChip';
-export {
- DISABLE_ALL_RULE,
- formatExcludedRulesText,
- getModelExclusionState,
- hasExcludedRule,
- isMatchedByWildcardRule,
- isModelExcluded,
- isWildcardRule,
- matchedModelsByRule,
- matchesExcludedRule,
- normalizeExcludedRules,
- parseExcludedRulesText,
- replaceCustomExcludedRules,
- splitExcludedRules,
- summarizeExclusion,
- toggleExcludedRule,
- type ExclusionStats,
- type ModelExclusionState,
- type RuleMatchSummary,
- type SplitExcludedRules,
-} from './excludedModelRules';
diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx
deleted file mode 100644
index 0fc6379..0000000
--- a/frontend/src/components/layout/MainLayout.tsx
+++ /dev/null
@@ -1,1177 +0,0 @@
-import {
- ReactNode,
- RefObject,
- SVGProps,
- useCallback,
- useEffect,
- useLayoutEffect,
- useRef,
- useState,
- type MouseEvent as ReactMouseEvent,
- type SyntheticEvent,
-} from 'react';
-import { NavLink, useLocation } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import { Button } from '@/components/ui/Button';
-import { PageTransition } from '@/components/common/PageTransition';
-import { MainRoutes } from '@/router/MainRoutes';
-import { authFilesApi, pluginsApi } from '@/services/api';
-import {
- IconSidebarAuthFiles,
- IconSidebarConfig,
- IconSidebarDashboard,
- IconSidebarLogs,
- IconSidebarOauth,
- IconSidebarPlugins,
- IconSidebarProviders,
- IconSidebarQuickStart,
- IconSidebarQuota,
- IconSidebarStore,
- IconSidebarSystem,
- IconChevronDown,
-} from '@/components/ui/icons';
-import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
-import {
- useAuthStore,
- useConfigStore,
- useLanguageStore,
- useNotificationStore,
- useThemeStore,
-} from '@/stores';
-import { AUTH_FILES_CHANGED_EVENT } from '@/features/authFiles/authFilesEvents';
-import {
- collectPluginResourceEntries,
- PLUGIN_RESOURCES_REFRESH_EVENT,
- resolvePluginAssetURL,
- type PluginResourceEntry,
-} from '@/features/plugins/pluginResources';
-import { APIKEY_FUN_DISPLAY_NAME, hasApiKeyFunConfig } from '@/features/providers/sponsor';
-import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
-import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
-import { isSupportedLanguage } from '@/utils/language';
-import type { Theme } from '@/types';
-
-const sidebarIcons: Record = {
- dashboard: ,
- quickStart: ,
- aiProviders: ,
- authFiles: ,
- oauth: ,
- quota: ,
- plugins: ,
- pluginStore: ,
- config: ,
- logs: ,
- system: ,
-};
-
-interface SidebarNavLinkItem {
- kind?: 'link';
- path: string;
- labelKey?: string;
- metaKey?: string;
- label?: string;
- meta?: string;
- badge?: number;
- badgeLabel?: string;
- icon: ReactNode;
-}
-
-interface SidebarNavDrawerItem {
- kind: 'drawer';
- id: string;
- label: string;
- meta?: string;
- icon: ReactNode;
- children: SidebarNavLinkItem[];
-}
-
-type SidebarNavItem = SidebarNavLinkItem | SidebarNavDrawerItem;
-
-const NAV_TOOLTIP_ID = 'sidebar-nav-tooltip';
-const NAV_TOOLTIP_VIEWPORT_MARGIN = 8;
-
-interface SidebarNavGroup {
- id: string;
- labelKey: string;
- items: SidebarNavItem[];
-}
-
-const flattenNavItems = (items: SidebarNavItem[]): SidebarNavLinkItem[] =>
- items.flatMap((item) => (item.kind === 'drawer' ? item.children : [item]));
-
-/** 点击菜单外或按下 Escape 时关闭弹出菜单 */
-function useMenuDismiss(
- open: boolean,
- menuRef: RefObject,
- onClose: () => void
-) {
- useEffect(() => {
- if (!open) {
- return;
- }
-
- const handlePointerDown = (event: MouseEvent) => {
- if (!menuRef.current?.contains(event.target as Node)) {
- onClose();
- }
- };
-
- const handleEscape = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- onClose();
- }
- };
-
- document.addEventListener('mousedown', handlePointerDown);
- document.addEventListener('keydown', handleEscape);
-
- return () => {
- document.removeEventListener('mousedown', handlePointerDown);
- document.removeEventListener('keydown', handleEscape);
- };
- }, [open, menuRef, onClose]);
-}
-
-function PluginSidebarIcon({ src }: { src: string }) {
- const [failed, setFailed] = useState(false);
- const showImage = Boolean(src) && !failed;
-
- return showImage ? (
-
setFailed(true)} />
- ) : (
-
- );
-}
-
-// Header action icons - smaller size for header buttons
-const headerIconProps: SVGProps = {
- width: 16,
- height: 16,
- viewBox: '0 0 24 24',
- fill: 'none',
- stroke: 'currentColor',
- strokeWidth: 2,
- strokeLinecap: 'round',
- strokeLinejoin: 'round',
- 'aria-hidden': 'true',
- focusable: 'false',
-};
-
-const headerIcons = {
- refresh: (
-
- ),
- menu: (
-
- ),
- close: (
-
- ),
- chevronLeft: (
-
- ),
- chevronRight: (
-
- ),
- language: (
-
- ),
- sun: (
-
- ),
- moon: (
-
- ),
- whiteTheme: (
-
- ),
- autoTheme: (
-
- ),
- logout: (
-
- ),
-};
-
-const THEME_CARDS: Array<{
- key: Theme;
- labelKey: string;
- colors: { bg: string; card: string; border: string; text: string; textMuted: string };
-}> = [
- {
- key: 'auto',
- labelKey: 'theme.auto',
- colors: {
- bg: 'linear-gradient(135deg, #ffffff 0 50%, #111111 50% 100%)',
- card: 'linear-gradient(135deg, #ffffff 0 50%, #1a1a1a 50% 100%)',
- border: '#bdbdbd',
- text: '#2d2a26',
- textMuted: 'linear-gradient(135deg, #c9c9c9 0 50%, #5a5a5a 50% 100%)',
- },
- },
- {
- key: 'white',
- labelKey: 'theme.white',
- colors: {
- bg: '#ffffff',
- card: '#ffffff',
- border: '#e5e5e5',
- text: '#2d2a26',
- textMuted: '#a29c95',
- },
- },
- {
- key: 'light',
- labelKey: 'theme.light',
- colors: {
- bg: '#faf9f5',
- card: '#f0eee8',
- border: '#e3e1db',
- text: '#2d2a26',
- textMuted: '#a29c95',
- },
- },
- {
- key: 'dark',
- labelKey: 'theme.dark',
- colors: {
- bg: '#151412',
- card: '#1d1b18',
- border: '#3a3530',
- text: '#f6f4f1',
- textMuted: '#9c958d',
- },
- },
-];
-
-export function MainLayout() {
- const { t } = useTranslation();
- const { showNotification } = useNotificationStore();
- const location = useLocation();
-
- const logout = useAuthStore((state) => state.logout);
- const connectionStatus = useAuthStore((state) => state.connectionStatus);
- const apiBase = useAuthStore((state) => state.apiBase);
- const supportsPlugin = useAuthStore((state) => state.supportsPlugin);
-
- const fetchConfig = useConfigStore((state) => state.fetchConfig);
- const clearCache = useConfigStore((state) => state.clearCache);
- const config = useConfigStore((state) => state.config);
-
- const theme = useThemeStore((state) => state.theme);
- const setTheme = useThemeStore((state) => state.setTheme);
- const language = useLanguageStore((state) => state.language);
- const setLanguage = useLanguageStore((state) => state.setLanguage);
-
- const [sidebarOpen, setSidebarOpen] = useState(false);
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
- const [authFilesCount, setAuthFilesCount] = useState(null);
- const [railTooltip, setRailTooltip] = useState<{
- targetID: string;
- label: string;
- meta?: string;
- anchorTop: number;
- top: number;
- } | null>(null);
- const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
- const [themeMenuOpen, setThemeMenuOpen] = useState(false);
- const [pluginResources, setPluginResources] = useState([]);
- const [expandedPluginResourceIDs, setExpandedPluginResourceIDs] = useState>(
- () => new Set()
- );
- const contentRef = useRef(null);
- const authFilesCountRequestRef = useRef(0);
- const railTooltipRef = useRef(null);
- const focusedRailItemRef = useRef(null);
- const languageMenuRef = useRef(null);
- const themeMenuRef = useRef(null);
- const headerRef = useRef(null);
-
- const fullBrandName = 'CLI Proxy API Management Center';
- const abbrBrandName = t('title.abbr');
- const isLogsPage = location.pathname.startsWith('/logs');
- const isPluginResourcePage = location.pathname.startsWith('/plugin-pages');
- const showSidebarLabels = !sidebarCollapsed || sidebarOpen;
-
- // Keep floating header height available to sticky mobile elements and overlays.
- useLayoutEffect(() => {
- const updateHeaderHeight = () => {
- const height = headerRef.current?.offsetHeight;
- if (height) {
- document.documentElement.style.setProperty('--header-height', `${height}px`);
- }
- };
-
- updateHeaderHeight();
-
- const resizeObserver =
- typeof ResizeObserver !== 'undefined' && headerRef.current
- ? new ResizeObserver(updateHeaderHeight)
- : null;
- if (resizeObserver && headerRef.current) {
- resizeObserver.observe(headerRef.current);
- }
-
- window.addEventListener('resize', updateHeaderHeight);
-
- return () => {
- if (resizeObserver) {
- resizeObserver.disconnect();
- }
- window.removeEventListener('resize', updateHeaderHeight);
- };
- }, []);
-
- useLayoutEffect(() => {
- if (!railTooltip) return;
-
- const updateRailTooltipPosition = () => {
- const tooltip = railTooltipRef.current;
- if (!tooltip) return;
-
- const halfHeight = tooltip.offsetHeight / 2;
- const minTop = NAV_TOOLTIP_VIEWPORT_MARGIN + halfHeight;
- const maxTop = Math.max(
- minTop,
- window.innerHeight - NAV_TOOLTIP_VIEWPORT_MARGIN - halfHeight
- );
- const top = Math.min(maxTop, Math.max(minTop, railTooltip.anchorTop));
-
- setRailTooltip((current) => {
- if (!current || current.targetID !== railTooltip.targetID || current.top === top) {
- return current;
- }
- return { ...current, top };
- });
- };
-
- updateRailTooltipPosition();
- window.addEventListener('resize', updateRailTooltipPosition);
- return () => window.removeEventListener('resize', updateRailTooltipPosition);
- }, [railTooltip]);
-
- // Keep the content center available to bottom overlays that align with the main area.
- useLayoutEffect(() => {
- const updateContentCenter = () => {
- const el = contentRef.current;
- if (!el) return;
- const rect = el.getBoundingClientRect();
- const centerX = rect.left + rect.width / 2;
- document.documentElement.style.setProperty('--content-center-x', `${centerX}px`);
- };
-
- updateContentCenter();
-
- const resizeObserver =
- typeof ResizeObserver !== 'undefined' && contentRef.current
- ? new ResizeObserver(updateContentCenter)
- : null;
-
- if (resizeObserver && contentRef.current) {
- resizeObserver.observe(contentRef.current);
- }
-
- window.addEventListener('resize', updateContentCenter);
-
- return () => {
- if (resizeObserver) {
- resizeObserver.disconnect();
- }
- window.removeEventListener('resize', updateContentCenter);
- document.documentElement.style.removeProperty('--content-center-x');
- };
- }, []);
-
- const closeLanguageMenu = useCallback(() => setLanguageMenuOpen(false), []);
- const closeThemeMenu = useCallback(() => setThemeMenuOpen(false), []);
- useMenuDismiss(languageMenuOpen, languageMenuRef, closeLanguageMenu);
- useMenuDismiss(themeMenuOpen, themeMenuRef, closeThemeMenu);
-
- const toggleLanguageMenu = useCallback(() => {
- setLanguageMenuOpen((prev) => !prev);
- setThemeMenuOpen(false);
- }, []);
-
- const toggleThemeMenu = useCallback(() => {
- setThemeMenuOpen((prev) => !prev);
- setLanguageMenuOpen(false);
- }, []);
-
- const handleThemeSelect = useCallback(
- (nextTheme: Theme) => {
- setTheme(nextTheme);
- setThemeMenuOpen(false);
- },
- [setTheme]
- );
-
- const handleLanguageSelect = useCallback(
- (nextLanguage: string) => {
- if (!isSupportedLanguage(nextLanguage)) {
- return;
- }
- setLanguage(nextLanguage);
- setLanguageMenuOpen(false);
- },
- [setLanguage]
- );
-
- useEffect(() => {
- fetchConfig().catch(() => {
- // Ignore the initial failure; the login flow shows the user-facing prompt.
- });
- }, [fetchConfig]);
-
- const loadPluginResources = useCallback(async () => {
- if (connectionStatus !== 'connected' || !supportsPlugin) {
- setPluginResources([]);
- return;
- }
-
- try {
- const plugins = await pluginsApi.list();
- setPluginResources(collectPluginResourceEntries(plugins.plugins));
- } catch {
- setPluginResources([]);
- }
- }, [connectionStatus, supportsPlugin]);
-
- const loadAuthFilesCount = useCallback(async () => {
- const requestID = ++authFilesCountRequestRef.current;
- if (connectionStatus !== 'connected') {
- setAuthFilesCount(null);
- return;
- }
-
- try {
- const response = await authFilesApi.list();
- if (requestID !== authFilesCountRequestRef.current) return;
- setAuthFilesCount(Array.isArray(response?.files) ? response.files.length : null);
- } catch {
- if (requestID !== authFilesCountRequestRef.current) return;
- setAuthFilesCount(null);
- }
- }, [connectionStatus]);
-
- useEffect(() => {
- const timer = window.setTimeout(() => {
- void loadPluginResources();
- void loadAuthFilesCount();
- }, 0);
-
- window.addEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadPluginResources);
- window.addEventListener(AUTH_FILES_CHANGED_EVENT, loadAuthFilesCount);
-
- return () => {
- authFilesCountRequestRef.current += 1;
- window.clearTimeout(timer);
- window.removeEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadPluginResources);
- window.removeEventListener(AUTH_FILES_CHANGED_EVENT, loadAuthFilesCount);
- };
- }, [apiBase, loadPluginResources, loadAuthFilesCount]);
-
- const pluginResourceGroups = pluginResources.reduce<
- Array<{ pluginID: string; pluginTitle: string; entries: PluginResourceEntry[] }>
- >((groups, resource) => {
- const group = groups.find((item) => item.pluginID === resource.pluginID);
- if (group) {
- group.entries.push(resource);
- return groups;
- }
-
- groups.push({
- pluginID: resource.pluginID,
- pluginTitle: resource.pluginTitle,
- entries: [resource],
- });
- return groups;
- }, []);
-
- const pluginPageNavItems: SidebarNavItem[] = supportsPlugin
- ? pluginResourceGroups.flatMap((group): SidebarNavItem[] => {
- if (group.entries.length === 1) {
- const resource = group.entries[0];
- const pluginLogo = resolvePluginAssetURL(resource.pluginLogo, apiBase);
- return [
- {
- path: resource.route,
- label: resource.label,
- meta: resource.description,
- icon: ,
- },
- ];
- }
-
- const pluginLogo = resolvePluginAssetURL(group.entries[0]?.pluginLogo ?? '', apiBase);
- return [
- {
- kind: 'drawer',
- id: `plugin-pages-${group.pluginID}`,
- label: group.pluginTitle,
- meta: t('plugin_resource.page_count', { count: group.entries.length }),
- icon: ,
- children: group.entries.map((resource) => ({
- path: resource.route,
- label: resource.label,
- meta: resource.description,
- icon: ,
- })),
- },
- ];
- })
- : [];
-
- const isApiKeyFunConfigured = hasApiKeyFunConfig(config);
- const quickStartNavItem: SidebarNavLinkItem = {
- path: '/quick-start',
- label: isApiKeyFunConfigured ? APIKEY_FUN_DISPLAY_NAME : undefined,
- labelKey: isApiKeyFunConfigured ? undefined : 'nav.quick_start',
- metaKey: 'nav_meta.quick_start',
- icon: sidebarIcons.quickStart,
- };
-
- const navGroups: SidebarNavGroup[] = [
- {
- id: 'operate',
- labelKey: 'nav_groups.operate',
- items: [
- {
- path: '/',
- labelKey: 'nav.dashboard',
- metaKey: 'nav_meta.dashboard',
- icon: sidebarIcons.dashboard,
- },
- ...(!isApiKeyFunConfigured ? [quickStartNavItem] : []),
- ],
- },
- {
- id: 'gateway',
- labelKey: 'nav_groups.gateway',
- items: [
- {
- path: '/ai-providers',
- labelKey: 'nav.ai_providers',
- metaKey: 'nav_meta.ai_providers',
- icon: sidebarIcons.aiProviders,
- },
- {
- path: '/auth-files',
- labelKey: 'nav.auth_files',
- metaKey: 'nav_meta.auth_files',
- badge: authFilesCount ?? undefined,
- badgeLabel:
- typeof authFilesCount === 'number'
- ? t('sidebar.auth_files_count', { count: authFilesCount })
- : undefined,
- icon: sidebarIcons.authFiles,
- },
- {
- path: '/oauth',
- labelKey: 'nav.oauth',
- metaKey: 'nav_meta.oauth',
- icon: sidebarIcons.oauth,
- },
- ...(isApiKeyFunConfigured ? [quickStartNavItem] : []),
- ],
- },
- {
- id: 'observe',
- labelKey: 'nav_groups.observe',
- items: [
- {
- path: '/quota',
- labelKey: 'nav.quota_management',
- metaKey: 'nav_meta.quota_management',
- icon: sidebarIcons.quota,
- },
- {
- path: '/logs',
- labelKey: 'nav.logs',
- metaKey: 'nav_meta.logs',
- icon: sidebarIcons.logs,
- },
- ],
- },
- {
- id: 'control',
- labelKey: 'nav_groups.control',
- items: [
- {
- path: '/config',
- labelKey: 'nav.config_management',
- metaKey: 'nav_meta.config_management',
- icon: sidebarIcons.config,
- },
- ...(supportsPlugin
- ? [
- {
- path: '/plugins',
- labelKey: 'nav.plugins',
- metaKey: 'nav_meta.plugins',
- icon: sidebarIcons.plugins,
- },
- {
- path: '/plugin-store',
- labelKey: 'nav.plugin_store',
- metaKey: 'nav_meta.plugin_store',
- icon: sidebarIcons.pluginStore,
- },
- ]
- : []),
- {
- path: '/system',
- labelKey: 'nav.system_info',
- metaKey: 'nav_meta.system_info',
- icon: sidebarIcons.system,
- },
- ],
- },
- ...(pluginPageNavItems.length > 0
- ? [
- {
- id: 'plugin-pages',
- labelKey: 'nav_groups.plugin_pages',
- items: pluginPageNavItems,
- },
- ]
- : []),
- ];
- const navItems = navGroups.flatMap((group) => flattenNavItems(group.items));
- const navOrder = navItems.map((item) => item.path);
- const getRouteOrder = (pathname: string) => {
- const trimmedPath =
- pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
- const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
-
- const authFilesIndex = navOrder.indexOf('/auth-files');
- if (authFilesIndex !== -1) {
- if (normalizedPath === '/auth-files') return authFilesIndex;
- if (normalizedPath.startsWith('/auth-files/')) {
- if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
- if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
- return authFilesIndex + 0.05;
- }
- }
-
- const exactIndex = navOrder.indexOf(normalizedPath);
- if (exactIndex !== -1) return exactIndex;
- const nestedIndex = navOrder.findIndex(
- (path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
- );
- return nestedIndex === -1 ? null : nestedIndex;
- };
-
- const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
- const normalize = (pathname: string) => {
- const trimmed =
- pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
- return trimmed === '/dashboard' ? '/' : trimmed;
- };
-
- const from = normalize(fromPathname);
- const to = normalize(toPathname);
- const isAuthFiles = (pathname: string) =>
- pathname === '/auth-files' || pathname.startsWith('/auth-files/');
- if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
- return 'vertical';
- }, []);
-
- const handleRefreshAll = async () => {
- clearCache();
- const results = await Promise.allSettled([
- fetchConfig(true),
- loadPluginResources(),
- loadAuthFilesCount(),
- triggerHeaderRefresh(),
- ]);
- const rejected = results.find((result) => result.status === 'rejected');
- if (rejected && rejected.status === 'rejected') {
- const reason = rejected.reason;
- const message =
- typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
- showNotification(
- `${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
- 'error'
- );
- return;
- }
- showNotification(t('notification.data_refreshed'), 'success');
- };
-
- const togglePluginResourceDrawer = useCallback((drawerID: string) => {
- setExpandedPluginResourceIDs((current) => {
- const next = new Set(current);
- if (next.has(drawerID)) {
- next.delete(drawerID);
- } else {
- next.add(drawerID);
- }
- return next;
- });
- }, []);
-
- const showRailTooltip = useCallback(
- (event: SyntheticEvent, targetID: string, label: string, meta?: string) => {
- const rect = event.currentTarget.getBoundingClientRect();
- const anchorTop = rect.top + rect.height / 2;
- setRailTooltip({ targetID, label, meta, anchorTop, top: anchorTop });
- },
- []
- );
- const hideRailTooltip = useCallback(() => setRailTooltip(null), []);
- const handleRailTooltipMouseEnter = useCallback(
- (event: ReactMouseEvent, targetID: string, label: string, meta?: string) => {
- const focusedItem = focusedRailItemRef.current;
- if (focusedItem && focusedItem !== event.currentTarget) return;
- showRailTooltip(event, targetID, label, meta);
- },
- [showRailTooltip]
- );
- const handleRailTooltipMouseLeave = useCallback(() => {
- if (!focusedRailItemRef.current) hideRailTooltip();
- }, [hideRailTooltip]);
- const handleRailTooltipFocus = useCallback(
- (event: SyntheticEvent, targetID: string, label: string, meta?: string) => {
- focusedRailItemRef.current = event.currentTarget;
- showRailTooltip(event, targetID, label, meta);
- },
- [showRailTooltip]
- );
- const handleRailTooltipBlur = useCallback(
- (event: SyntheticEvent, targetID: string, label: string, meta?: string) => {
- if (focusedRailItemRef.current === event.currentTarget) {
- focusedRailItemRef.current = null;
- }
- if (event.currentTarget.matches(':hover')) {
- showRailTooltip(event, targetID, label, meta);
- } else {
- hideRailTooltip();
- }
- },
- [hideRailTooltip, showRailTooltip]
- );
-
- const renderNavBadge = (badge?: number, badgeLabel?: string) =>
- typeof badge === 'number' ? (
- <>
- {badge > 0 ? (
-
- {badge}
-
- ) : null}
- {badgeLabel ? {badgeLabel} : null}
- >
- ) : null;
-
- const renderNavLink = (item: SidebarNavLinkItem, className = 'nav-item') => {
- const itemLabel = item.label ?? (item.labelKey ? t(item.labelKey) : '');
- const itemMeta = item.meta ?? (item.metaKey ? t(item.metaKey) : '');
- const accessibleLabel = item.badgeLabel ? `${itemLabel}, ${item.badgeLabel}` : itemLabel;
-
- return (
- `${className} ${isActive ? 'active' : ''}`}
- onClick={() => {
- focusedRailItemRef.current = null;
- setSidebarOpen(false);
- hideRailTooltip();
- }}
- aria-label={showSidebarLabels ? undefined : accessibleLabel}
- aria-describedby={
- !showSidebarLabels && itemMeta && railTooltip?.targetID === item.path
- ? NAV_TOOLTIP_ID
- : undefined
- }
- onMouseEnter={
- showSidebarLabels
- ? undefined
- : (event) => handleRailTooltipMouseEnter(event, item.path, itemLabel, itemMeta)
- }
- onMouseLeave={showSidebarLabels ? undefined : handleRailTooltipMouseLeave}
- onFocus={
- showSidebarLabels
- ? undefined
- : (event) => handleRailTooltipFocus(event, item.path, itemLabel, itemMeta)
- }
- onBlur={
- showSidebarLabels
- ? undefined
- : (event) => handleRailTooltipBlur(event, item.path, itemLabel, itemMeta)
- }
- >
- {item.icon}
- {showSidebarLabels ? (
- <>
-
- {itemLabel}
-
- {renderNavBadge(item.badge, item.badgeLabel)}
- >
- ) : (
- renderNavBadge(item.badge)
- )}
-
- );
- };
-
- const renderNavItem = (item: SidebarNavItem) => {
- if (item.kind !== 'drawer') {
- return renderNavLink(item);
- }
-
- const isActive = item.children.some((child) => child.path === location.pathname);
- const isOpen = isActive || expandedPluginResourceIDs.has(item.id);
-
- return (
-
-
- {isOpen ? (
-
- {item.children.map((child) => renderNavLink(child, 'nav-item nav-sub-item'))}
-
- ) : null}
-
- );
- };
-
- const mobileSidebarToggleLabel = sidebarOpen
- ? t('sidebar.toggle_collapse', { defaultValue: 'Close navigation' })
- : t('sidebar.toggle_expand', { defaultValue: 'Open navigation' });
-
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/modelAlias/ModelMappingDiagram.module.scss b/frontend/src/components/modelAlias/ModelMappingDiagram.module.scss
deleted file mode 100644
index 0950f78..0000000
--- a/frontend/src/components/modelAlias/ModelMappingDiagram.module.scss
+++ /dev/null
@@ -1,361 +0,0 @@
-@use '../../styles/variables' as *;
-
-.scrollContainer {
- width: 100%;
- overflow-x: auto;
- overscroll-behavior-x: contain;
- -webkit-overflow-scrolling: touch;
-}
-
-.tapHint {
- position: sticky;
- left: 0;
- z-index: 3;
- font-size: 12px;
- color: var(--text-secondary);
- padding: 0 4px;
- margin-bottom: 8px;
-}
-
-.container {
- display: inline-flex;
- position: relative;
- min-width: 100%;
- min-height: 300px;
- justify-content: space-between;
- padding: 20px 0;
- user-select: none;
-
- @media (max-width: 768px) {
- // Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll.
- min-width: max(100%, 960px);
- padding: 12px 0;
- }
-}
-
-// SVG layer for connection lines (behind columns so links are visible)
-.connections {
- position: absolute;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- pointer-events: none;
- z-index: 1;
- overflow: visible;
-
- path {
- fill: none;
- stroke-width: 2;
- }
-}
-
-.column {
- display: flex;
- flex-direction: column;
- gap: 12px;
- z-index: 2;
- flex: 0 0 auto;
-
- &.providers {
- align-items: flex-end;
- min-width: 140px;
- }
-
- &.sources {
- align-items: flex-start;
- min-width: 200px;
- }
-
- &.aliases {
- align-items: flex-start;
- min-width: 200px;
- }
-}
-
-.columnHeader {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-secondary);
- text-transform: uppercase;
- margin-bottom: 8px;
- padding: 0 4px;
-}
-
-.item {
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 10px 14px;
- font-size: 13px;
- color: var(--text-primary);
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- max-width: 280px;
- position: relative;
- transition: all 0.2s ease;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
-
- &:hover {
- border-color: var(--primary-color);
- transform: translateY(-1px);
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
- z-index: 10;
- }
-
- &.dropTarget {
- background-color: var(--bg-secondary);
- border-color: var(--primary-color);
- border-width: 2px;
- }
-
- &.selected {
- border-color: var(--primary-color);
- background-color: var(--bg-secondary);
- box-shadow: 0 0 0 2px rgba($primary-color, 0.18);
- }
-}
-
-// Mindmap-style provider branch (root node)
-.providerItem {
- border-left: 3px solid transparent;
- padding-left: 8px;
- display: flex;
- align-items: center;
- gap: 8px;
-
- .providerLabel {
- font-weight: 600;
- font-size: 13px;
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .collapseBtn {
- flex-shrink: 0;
- width: 24px;
- height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- background: var(--bg-secondary);
- border-radius: 4px;
- cursor: pointer;
- color: var(--text-secondary);
- transition:
- background-color 0.15s,
- color 0.15s;
-
- &:hover {
- background: var(--border-color);
- color: var(--text-primary);
- }
- }
-
- .chevronDown,
- .chevronRight {
- display: inline-block;
- width: 0;
- height: 0;
- border-style: solid;
- }
-
- .chevronDown {
- border-width: 5px 4px 0 4px;
- border-color: currentColor transparent transparent transparent;
- }
-
- .chevronRight {
- border-width: 4px 0 4px 5px;
- border-color: transparent transparent transparent currentColor;
- }
-}
-
-.providerGroup {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- width: 100%;
-}
-
-.sourceItem,
-.aliasItem {
- cursor: grab;
-
- &:active {
- cursor: grabbing;
- }
-
- &.dragging {
- opacity: 0.5;
- border-style: dashed;
- }
-}
-
-.dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- position: absolute;
- top: 50%;
- margin-top: -3px;
- flex-shrink: 0;
-
- &.dotLeft {
- left: -3px;
- background: var(--text-tertiary);
- }
-}
-
-.sourceItem .dot {
- right: -3px;
-}
-
-.providerBadge {
- font-size: 11px;
- padding: 2px 6px;
- border-radius: 4px;
- background: var(--bg-secondary);
- color: var(--text-secondary);
- margin-right: 8px;
- font-weight: 500;
-}
-
-.itemName {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.itemCount {
- font-size: 11px;
- color: var(--text-tertiary);
- margin-left: 8px;
- background: var(--bg-secondary);
- padding: 1px 6px;
- border-radius: 10px;
-}
-
-.contextMenu {
- position: fixed;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- z-index: 9999;
- min-width: 120px;
- overflow: hidden;
- padding: 4px 0;
-
- .menuItem {
- padding: 8px 12px;
- font-size: 13px;
- color: var(--text-primary);
- cursor: pointer;
- transition: background-color 0.1s;
- display: flex;
- align-items: center;
- gap: 8px;
-
- &:hover {
- background-color: var(--bg-secondary);
- }
-
- &.danger {
- color: var(--error-color);
-
- &:hover {
- background-color: var(--bg-error-light);
- }
- }
- }
-
- .menuDivider {
- height: 1px;
- margin: 4px 0;
- background: var(--border-color);
- padding: 0;
- cursor: default;
- pointer-events: none;
- }
-}
-
-.settingsEmpty {
- color: var(--text-tertiary);
- font-size: 13px;
- text-align: center;
- padding: $spacing-lg 0;
-}
-
-.settingsList {
- display: flex;
- flex-direction: column;
- gap: $spacing-sm;
-}
-
-.settingsRow {
- display: grid;
- grid-template-columns: minmax(200px, 1fr) auto;
- gap: $spacing-md;
- align-items: center;
- padding: $spacing-sm $spacing-md;
- border: 1px solid var(--border-color);
- border-radius: $radius-md;
- background: var(--bg-secondary);
-
- @media (max-width: 768px) {
- grid-template-columns: 1fr;
- align-items: flex-start;
- }
-}
-
-.settingsNames {
- display: flex;
- align-items: center;
- gap: $spacing-xs;
- font-size: 13px;
- color: var(--text-primary);
- min-width: 0;
-}
-
-.settingsSource,
-.settingsAlias {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 220px;
-}
-
-.settingsArrow {
- color: var(--text-tertiary);
-}
-
-.settingsActions {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
-}
-
-.settingsLabel {
- font-size: 12px;
- color: var(--text-secondary);
-}
-
-.settingsDelete {
- border: 0;
- background: transparent;
- color: var(--error-color);
- padding: 6px;
- border-radius: 6px;
- cursor: pointer;
-
- &:hover {
- background: var(--bg-error-light);
- }
-}
diff --git a/frontend/src/components/modelAlias/ModelMappingDiagram.tsx b/frontend/src/components/modelAlias/ModelMappingDiagram.tsx
deleted file mode 100644
index f19a8bb..0000000
--- a/frontend/src/components/modelAlias/ModelMappingDiagram.tsx
+++ /dev/null
@@ -1,700 +0,0 @@
-import {
- forwardRef,
- useCallback,
- useEffect,
- useImperativeHandle,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
- type DragEvent,
- type MouseEvent as ReactMouseEvent,
-} from 'react';
-import { useTranslation } from 'react-i18next';
-import type { OAuthModelAliasEntry } from '@/types';
-import { useThemeStore } from '@/stores';
-import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns';
-import { DiagramContextMenu } from './ModelMappingDiagramContextMenu';
-import {
- AddAliasModal,
- RenameAliasModal,
- SettingsAliasModal,
- SettingsSourceModal,
-} from './ModelMappingDiagramModals';
-import type {
- AliasNode,
- AuthFileModelItem,
- ContextMenuState,
- DiagramLine,
- SourceNode,
-} from './ModelMappingDiagramTypes';
-import { hasModelAliasConflict } from './aliasValidation';
-import styles from './ModelMappingDiagram.module.scss';
-
-export interface ModelMappingDiagramProps {
- modelAlias: Record;
- allProviderModels?: Record;
- onUpdate?: (provider: string, sourceModel: string, newAlias: string) => void;
- onDeleteLink?: (provider: string, sourceModel: string, alias: string) => void;
- onToggleFork?: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
- onRenameAlias?: (oldAlias: string, newAlias: string) => void;
- onDeleteAlias?: (alias: string) => void;
- onEditProvider?: (provider: string) => void;
- onDeleteProvider?: (provider: string) => void;
- className?: string;
-}
-
-const PROVIDER_COLORS = [
- '#8b8680',
- '#10b981',
- '#f59e0b',
- '#c65746',
- '#8b5cf6',
- '#ec4899',
- '#06b6d4',
- '#84cc16',
-];
-
-function getProviderColor(provider: string): string {
- const hash = provider.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
- return PROVIDER_COLORS[hash % PROVIDER_COLORS.length];
-}
-
-export interface ModelMappingDiagramRef {
- collapseAll: () => void;
- refreshLayout: () => void;
-}
-
-export const ModelMappingDiagram = forwardRef(
- function ModelMappingDiagram(
- {
- modelAlias,
- allProviderModels = {},
- onUpdate,
- onDeleteLink,
- onToggleFork,
- onRenameAlias,
- onDeleteAlias,
- onEditProvider,
- onDeleteProvider,
- className,
- },
- ref
- ) {
- const { t } = useTranslation();
- const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
- const isDark = resolvedTheme === 'dark';
- const enableTapLinking = useMemo(() => {
- if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false;
- return (
- window.matchMedia('(any-pointer: coarse)').matches &&
- !window.matchMedia('(any-pointer: fine)').matches
- );
- }, []);
-
- const containerRef = useRef(null);
- const [lines, setLines] = useState([]);
- const [draggedSource, setDraggedSource] = useState(null);
- const [draggedAlias, setDraggedAlias] = useState(null);
- const [dropTargetAlias, setDropTargetAlias] = useState(null);
- const [dropTargetSource, setDropTargetSource] = useState(null);
- const [tapSourceId, setTapSourceId] = useState(null);
- const [tapAlias, setTapAlias] = useState(null);
- const [extraAliases, setExtraAliases] = useState([]);
- const [contextMenu, setContextMenu] = useState(null);
- const [collapsedProviders, setCollapsedProviders] = useState>(new Set());
- const [providerGroupHeights, setProviderGroupHeights] = useState>({});
- const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null);
- const [renameValue, setRenameValue] = useState('');
- const [renameError, setRenameError] = useState('');
- const [addAliasOpen, setAddAliasOpen] = useState(false);
- const [addAliasValue, setAddAliasValue] = useState('');
- const [addAliasError, setAddAliasError] = useState('');
- const [settingsAlias, setSettingsAlias] = useState(null);
- const [settingsSourceId, setSettingsSourceId] = useState(null);
-
- // Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases.
- const { aliasNodes, providerNodes } = useMemo(() => {
- const sourceMap = new Map<
- string,
- { provider: string; name: string; aliases: Map }
- >();
- const aliasSet = new Set();
-
- // 1. Existing mappings: group by (provider, name), each source has a set of aliases
- Object.entries(modelAlias).forEach(([provider, mappings]) => {
- (mappings ?? []).forEach((m) => {
- const name = (m?.name || '').trim();
- const alias = (m?.alias || '').trim();
- if (!name || !alias) return;
-
- const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
- if (!sourceMap.has(pk)) {
- sourceMap.set(pk, { provider, name, aliases: new Map() });
- }
- sourceMap.get(pk)!.aliases.set(alias, m?.fork === true);
- aliasSet.add(alias);
- });
- });
-
- // 2. Unmapped models from allProviderModels (no mapping yet)
- Object.entries(allProviderModels).forEach(([provider, models]) => {
- (models ?? []).forEach((m) => {
- const name = (m.id || '').trim();
- if (!name) return;
- const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
- if (sourceMap.has(pk)) {
- // Already in sourceMap from mappings; keep provider from mapping for correct grouping.
- return;
- }
- sourceMap.set(pk, { provider, name, aliases: new Map() });
- });
- });
-
- // 3. Source nodes: distinct by id = provider::name
- const sources: SourceNode[] = Array.from(sourceMap.entries())
- .map(([id, v]) => ({
- id,
- provider: v.provider,
- name: v.name,
- aliases: Array.from(v.aliases.entries()).map(([alias, fork]) => ({ alias, fork })),
- }))
- .sort((a, b) => {
- if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
- return a.name.localeCompare(b.name);
- });
-
- // 4. Extra aliases (no mapping yet)
- extraAliases.forEach((alias) => aliasSet.add(alias));
-
- // 5. Alias nodes: distinct by id = alias; sources = SourceNodes that have this alias in their aliases
- const aliasNodesList: AliasNode[] = Array.from(aliasSet)
- .map((alias) => ({
- id: alias,
- alias,
- sources: sources.filter((s) => s.aliases.some((entry) => entry.alias === alias)),
- }))
- .sort((a, b) => {
- if (b.sources.length !== a.sources.length) return b.sources.length - a.sources.length;
- return a.alias.localeCompare(b.alias);
- });
-
- // 6. Group sources by provider
- const providerMap = new Map();
- sources.forEach((s) => {
- if (!providerMap.has(s.provider)) providerMap.set(s.provider, []);
- providerMap.get(s.provider)!.push(s);
- });
- const providerNodesList = Array.from(providerMap.entries())
- .map(([provider, providerSources]) => ({ provider, sources: providerSources }))
- .sort((a, b) => a.provider.localeCompare(b.provider));
-
- return { aliasNodes: aliasNodesList, providerNodes: providerNodesList };
- }, [modelAlias, allProviderModels, extraAliases]);
-
- // Track element positions
- const providerRefs = useRef
- ) : undefined
- }
- placeholder="socks5://user:pass@127.0.0.1:1080/"
- value={values.proxyUrl}
- onChange={(e) => onChange({ proxyUrl: e.target.value })}
- disabled={disabled}
- />
-
- );
-}
-
-/**
- * 与代理 URL 字段同排时的隐形占位行:渲染在标签上方(Input 的 topExtra),
- * 赞助商存在时把同排字段整体下移与赞助行同高,让输入框水平对齐,
- * 同时标签与输入框之间保持正常间距;无赞助商则不渲染。
- */
-export function SponsorHintSpacer() {
- if (SPONSORS.length === 0) return null;
- return (
-
-
-
- );
-}
-
-export function ApiKeysField({ values, disabled, onChange }: SharedFieldProps) {
- return (
-
-
- onChange({ apiKeysText })}
- />
-
-
- );
-}
-
-export function DebugToggle({ values, disabled, onChange }: SharedFieldProps) {
- const { t } = useTranslation();
- return (
-
- onChange({ debug })}
- />
-
- );
-}
-
-export function LoggingToFileToggle({ values, disabled, onChange }: SharedFieldProps) {
- const { t } = useTranslation();
- return (
-
- onChange({ loggingToFile })}
- />
-
- );
-}
-
-export function QuotaSwitchProjectToggle({ values, disabled, onChange }: SharedFieldProps) {
- const { t } = useTranslation();
- return (
-
- onChange({ quotaSwitchProject })}
- />
-
- );
-}
-
-export function QuotaSwitchPreviewModelToggle({ values, disabled, onChange }: SharedFieldProps) {
- const { t } = useTranslation();
- return (
-
- onChange({ quotaSwitchPreviewModel })}
- />
-
- );
-}
diff --git a/frontend/src/features/config/components/sections/SectionAdvanced.tsx b/frontend/src/features/config/components/sections/SectionAdvanced.tsx
deleted file mode 100644
index f8a1d70..0000000
--- a/frontend/src/features/config/components/sections/SectionAdvanced.tsx
+++ /dev/null
@@ -1,247 +0,0 @@
-import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Collapsible } from '@/components/ui/Collapsible';
-import { Input } from '@/components/ui/Input';
-import type { PluginStoreAuthRule } from '@/types/visualConfig';
-import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants';
-import type { ConfigSectionProps } from '../../types';
-import { SectionCard } from '../SectionCard';
-import {
- Divider,
- FieldAnchor,
- FieldGrid,
- FieldGroup,
- FieldGroupHeading,
- FieldHint,
- FieldShell,
- FieldStack,
- ToggleRow,
-} from '../fields/FieldPrimitives';
-import { PluginStoreAuthEditor } from '../blocks/PluginStoreAuthEditor';
-import { StringListEditor } from '../blocks/StringListEditor';
-
-const Icon = CONFIG_TAB_ICONS.advanced;
-
-/** 06 高级与实验:插件源(只存 env 变量名)、签名缓存、Claude/Codex 请求头默认值。 */
-export function SectionAdvanced({ values, disabled, animateIn, onChange }: ConfigSectionProps) {
- const { t } = useTranslation();
-
- const handlePluginStoreSourcesChange = useCallback(
- (pluginStoreSources: string[]) => onChange({ pluginStoreSources }),
- [onChange]
- );
- const handlePluginStoreAuthChange = useCallback(
- (pluginStoreAuth: PluginStoreAuthRule[]) => onChange({ pluginStoreAuth }),
- [onChange]
- );
-
- return (
- }
- title={t('config_management.visual.sections.advanced.title')}
- description={t('config_management.visual.sections.advanced.description')}
- animateIn={animateIn}
- >
-
-
-
-
-
- onChange({ pluginsEnabled })}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t('config_management.visual.sections.system.plugin_store_auth_hint')}
-
-
-
-
-
-
-
-
-
-
-
- onChange({ antigravitySignatureCacheEnabled })
- }
- />
-
-
-
- onChange({ antigravitySignatureBypassStrict })
- }
- />
-
-
-
-
-
-
-
-
-
- onChange({ claudeHeaderUserAgent: e.target.value })}
- disabled={disabled}
- />
-
-
- onChange({ claudeHeaderPackageVersion: e.target.value })}
- disabled={disabled}
- />
-
-
- onChange({ claudeHeaderRuntimeVersion: e.target.value })}
- disabled={disabled}
- />
-
-
- onChange({ claudeHeaderOs: e.target.value })}
- disabled={disabled}
- />
-
-
- onChange({ claudeHeaderArch: e.target.value })}
- disabled={disabled}
- />
-
-
- onChange({ claudeHeaderTimeout: e.target.value })}
- disabled={disabled}
- />
-
-
-
-
-
- onChange({ claudeHeaderStabilizeDeviceProfile })
- }
- />
-
-
-
-
-
-
- onChange({ codexHeaderUserAgent: e.target.value })}
- disabled={disabled}
- />
-
-
- onChange({ codexHeaderBetaFeatures: e.target.value })}
- disabled={disabled}
- />
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/config/components/sections/SectionCommon.tsx b/frontend/src/features/config/components/sections/SectionCommon.tsx
deleted file mode 100644
index 58a0481..0000000
--- a/frontend/src/features/config/components/sections/SectionCommon.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { CONFIG_TAB_ICONS } from '../../constants';
-import type { ConfigSectionProps } from '../../types';
-import { getValidationMessage } from '../blocks/shared';
-import { SectionCard } from '../SectionCard';
-import { FieldGrid, FieldStack } from '../fields/FieldPrimitives';
-import {
- ApiKeysField,
- DebugToggle,
- HostField,
- LoggingToFileToggle,
- PortField,
- ProxyUrlField,
- QuotaSwitchPreviewModelToggle,
- QuotaSwitchProjectToggle,
- SponsorHintSpacer,
-} from '../fields/sharedFields';
-
-const Icon = CONFIG_TAB_ICONS.common;
-
-/**
- * 「常用」tab:原简单模式的 8 个高频字段,别名视图(不占分区序号)。
- * 渲染源与正典分区共享(sharedFields),数据同为 useVisualConfig 一份状态。
- */
-export function SectionCommon({
- values,
- validationErrors,
- disabled,
- animateIn,
- onChange,
-}: ConfigSectionProps) {
- const { t } = useTranslation();
- const portError = getValidationMessage(t, validationErrors?.port);
-
- return (
- }
- title={t('config_management.visual.sections.common.title')}
- description={t('config_management.visual.sections.common.description')}
- animateIn={animateIn}
- >
-
-
- }
- />
- }
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/config/components/sections/SectionConnectivity.tsx b/frontend/src/features/config/components/sections/SectionConnectivity.tsx
deleted file mode 100644
index bcd4586..0000000
--- a/frontend/src/features/config/components/sections/SectionConnectivity.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { Collapsible } from '@/components/ui/Collapsible';
-import { Input } from '@/components/ui/Input';
-import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants';
-import type { ConfigSectionProps } from '../../types';
-import { SectionCard } from '../SectionCard';
-import { Divider, FieldAnchor, FieldGrid, FieldStack, ToggleRow } from '../fields/FieldPrimitives';
-import { ApiKeysField, HostField, PortField } from '../fields/sharedFields';
-import { getValidationMessage } from '../blocks/shared';
-
-const Icon = CONFIG_TAB_ICONS.connectivity;
-
-/** 01 接入与认证:服务地址、端口、认证目录、API 密钥 + TLS / 远程管理折叠组。 */
-export function SectionConnectivity({
- values,
- validationErrors,
- disabled,
- animateIn,
- onChange,
-}: ConfigSectionProps) {
- const { t } = useTranslation();
- const portError = getValidationMessage(t, validationErrors?.port);
-
- return (
- }
- title={t('config_management.visual.sections.connectivity.title')}
- description={t('config_management.visual.sections.connectivity.description')}
- animateIn={animateIn}
- >
-
-
-
-
-
-
-
- onChange({ authDir: e.target.value })}
- disabled={disabled}
- hint={t('config_management.visual.sections.auth.auth_dir_hint')}
- />
-
-
-
-
-
-
-
- onChange({ tlsEnable })}
- />
-
-
- {values.tlsEnable ? (
- <>
-
-
-
- onChange({ tlsCert: e.target.value })}
- disabled={disabled}
- />
-
-
- onChange({ tlsKey: e.target.value })}
- disabled={disabled}
- />
-
-
- >
- ) : null}
-
-
-
-
-
-
-
- onChange({ rmAllowRemote })}
- />
-
-
- onChange({ rmDisableControlPanel })}
- />
-
-
-
-
- onChange({ rmSecretKey: e.target.value })}
- disabled={disabled}
- />
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/config/components/sections/SectionLogging.tsx b/frontend/src/features/config/components/sections/SectionLogging.tsx
deleted file mode 100644
index c9711f0..0000000
--- a/frontend/src/features/config/components/sections/SectionLogging.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { Input } from '@/components/ui/Input';
-import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants';
-import type { ConfigSectionProps } from '../../types';
-import { SectionCard } from '../SectionCard';
-import { FieldAnchor, FieldGrid, FieldStack, ToggleRow } from '../fields/FieldPrimitives';
-import { DebugToggle, LoggingToFileToggle } from '../fields/sharedFields';
-import { getValidationMessage } from '../blocks/shared';
-
-const Icon = CONFIG_TAB_ICONS.logging;
-
-/** 03 日志与诊断:调试、商业模式(重启生效)、日志输出与使用统计。 */
-export function SectionLogging({
- values,
- validationErrors,
- disabled,
- animateIn,
- onChange,
-}: ConfigSectionProps) {
- const { t } = useTranslation();
- const logsMaxSizeError = getValidationMessage(t, validationErrors?.logsMaxTotalSizeMb);
- const errorLogsMaxFilesError = getValidationMessage(t, validationErrors?.errorLogsMaxFiles);
- const redisUsageQueueRetentionError = getValidationMessage(
- t,
- validationErrors?.redisUsageQueueRetentionSeconds
- );
-
- return (
- }
- title={t('config_management.visual.sections.logging.title')}
- description={t('config_management.visual.sections.logging.description')}
- animateIn={animateIn}
- >
-
-
-
-
- onChange({ commercialMode })}
- />
-
-
-
-
-
-
- onChange({ logsMaxTotalSizeMb: e.target.value })}
- disabled={disabled}
- error={logsMaxSizeError}
- />
-
-
- onChange({ errorLogsMaxFiles: e.target.value })}
- disabled={disabled}
- error={errorLogsMaxFilesError}
- />
-
-
- onChange({ redisUsageQueueRetentionSeconds: e.target.value })}
- disabled={disabled}
- hint={t('config_management.visual.sections.system.redis_usage_retention_hint')}
- error={redisUsageQueueRetentionError}
- />
-
-
-
-
-
- onChange({ usageStatisticsEnabled })}
- />
-
-
-
-
- );
-}
diff --git a/frontend/src/features/config/components/sections/SectionNetwork.tsx b/frontend/src/features/config/components/sections/SectionNetwork.tsx
deleted file mode 100644
index 9abcefa..0000000
--- a/frontend/src/features/config/components/sections/SectionNetwork.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-import { useId } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Input } from '@/components/ui/Input';
-import { Select } from '@/components/ui/Select';
-import type { VisualConfigValues } from '@/types/visualConfig';
-import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants';
-import type { ConfigSectionProps } from '../../types';
-import { SectionCard } from '../SectionCard';
-import {
- FieldAnchor,
- FieldGrid,
- FieldShell,
- FieldStack,
- ToggleRow,
-} from '../fields/FieldPrimitives';
-import { ProxyUrlField, SponsorHintSpacer } from '../fields/sharedFields';
-import { getValidationMessage } from '../blocks/shared';
-
-const Icon = CONFIG_TAB_ICONS.network;
-
-/** 02 网络配置:代理、重试、路由策略、图像生成开关与网络行为开关。 */
-export function SectionNetwork({
- values,
- validationErrors,
- disabled,
- animateIn,
- onChange,
-}: ConfigSectionProps) {
- const { t } = useTranslation();
- const routingStrategyLabelId = useId();
- const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
- const disableImageGenerationLabelId = useId();
- const disableImageGenerationHintId = `${disableImageGenerationLabelId}-hint`;
-
- const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry);
- const maxRetryCredentialsError = getValidationMessage(t, validationErrors?.maxRetryCredentials);
- const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval);
- const authAutoRefreshWorkersError = getValidationMessage(
- t,
- validationErrors?.authAutoRefreshWorkers
- );
-
- const disableImageGenerationOptions = [
- {
- value: 'false',
- label: t('config_management.visual.sections.network.disable_image_generation_false'),
- },
- {
- value: 'true',
- label: t('config_management.visual.sections.network.disable_image_generation_true'),
- },
- {
- value: 'chat',
- label: t('config_management.visual.sections.network.disable_image_generation_chat'),
- },
- {
- value: 'passthrough',
- label: t('config_management.visual.sections.network.disable_image_generation_passthrough'),
- },
- ];
-
- return (
- }
- title={t('config_management.visual.sections.network.title')}
- description={t('config_management.visual.sections.network.description')}
- animateIn={animateIn}
- >
-
-
-
-
- }
- type="number"
- placeholder="3"
- value={values.requestRetry}
- onChange={(e) => onChange({ requestRetry: e.target.value })}
- disabled={disabled}
- error={requestRetryError}
- />
-
-
- }
- type="number"
- placeholder="0"
- value={values.maxRetryCredentials}
- onChange={(e) => onChange({ maxRetryCredentials: e.target.value })}
- disabled={disabled}
- hint={t('config_management.visual.sections.network.max_retry_credentials_hint')}
- error={maxRetryCredentialsError}
- />
-
-
- onChange({ maxRetryInterval: e.target.value })}
- disabled={disabled}
- error={maxRetryIntervalError}
- />
-
-
- onChange({ authAutoRefreshWorkers: e.target.value })}
- disabled={disabled}
- hint={t('config_management.visual.sections.network.auth_auto_refresh_workers_hint')}
- error={authAutoRefreshWorkersError}
- />
-
-
-
-
-
-
-
-
-
-
- onChange({ gptImage2BaseModel: e.target.value })}
- disabled={disabled}
- hint={t('config_management.visual.sections.network.gpt_image_2_base_model_hint')}
- />
-
-
- onChange({ routingSessionAffinityTTL: e.target.value })}
- disabled={disabled}
- />
-
-
-
-
-
- onChange({ forceModelPrefix })}
- />
-
-
- onChange({ passthroughHeaders })}
- />
-
-
- onChange({ disableCooling })}
- />
-
-
- onChange({ routingSessionAffinity })}
- />
-
-
- onChange({ wsAuth })}
- />
-
-
-
-
- );
-}
diff --git a/frontend/src/features/config/components/sections/SectionPayload.tsx b/frontend/src/features/config/components/sections/SectionPayload.tsx
deleted file mode 100644
index 7a5a069..0000000
--- a/frontend/src/features/config/components/sections/SectionPayload.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Collapsible } from '@/components/ui/Collapsible';
-import type { PayloadFilterRule, PayloadRule } from '@/types/visualConfig';
-import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants';
-import type { ConfigSectionProps } from '../../types';
-import { SectionCard } from '../SectionCard';
-import { FieldAnchor, FieldStack } from '../fields/FieldPrimitives';
-import { PayloadFilterRulesEditor } from '../blocks/PayloadFilterRulesEditor';
-import { PayloadRulesEditor } from '../blocks/PayloadRulesEditor';
-
-const Icon = CONFIG_TAB_ICONS.payload;
-
-export type SectionPayloadProps = ConfigSectionProps & {
- /** 有载荷校验错误时折叠组带 key 重挂载并强制展开,把错误带到眼前。 */
- hasPayloadValidationErrors: boolean;
-};
-
-/** 07 Payload 配置:默认值 / 原始 JSON / 覆盖 / 过滤 五个规则组。 */
-export function SectionPayload({
- values,
- disabled,
- animateIn,
- hasPayloadValidationErrors,
- onChange,
-}: SectionPayloadProps) {
- const { t } = useTranslation();
- const payloadValidationKey = hasPayloadValidationErrors ? 'payload-errors' : 'payload-ok';
-
- const handlePayloadDefaultRulesChange = useCallback(
- (payloadDefaultRules: PayloadRule[]) => onChange({ payloadDefaultRules }),
- [onChange]
- );
- const handlePayloadDefaultRawRulesChange = useCallback(
- (payloadDefaultRawRules: PayloadRule[]) => onChange({ payloadDefaultRawRules }),
- [onChange]
- );
- const handlePayloadOverrideRulesChange = useCallback(
- (payloadOverrideRules: PayloadRule[]) => onChange({ payloadOverrideRules }),
- [onChange]
- );
- const handlePayloadOverrideRawRulesChange = useCallback(
- (payloadOverrideRawRules: PayloadRule[]) => onChange({ payloadOverrideRawRules }),
- [onChange]
- );
- const handlePayloadFilterRulesChange = useCallback(
- (payloadFilterRules: PayloadFilterRule[]) => onChange({ payloadFilterRules }),
- [onChange]
- );
-
- return (
- }
- title={t('config_management.visual.sections.payload.title')}
- description={t('config_management.visual.sections.payload.description')}
- animateIn={animateIn}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/config/components/sections/SectionQuota.tsx b/frontend/src/features/config/components/sections/SectionQuota.tsx
deleted file mode 100644
index 0492845..0000000
--- a/frontend/src/features/config/components/sections/SectionQuota.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants';
-import type { ConfigSectionProps } from '../../types';
-import { SectionCard } from '../SectionCard';
-import { FieldAnchor, FieldGrid, ToggleRow } from '../fields/FieldPrimitives';
-import { QuotaSwitchPreviewModelToggle, QuotaSwitchProjectToggle } from '../fields/sharedFields';
-
-const Icon = CONFIG_TAB_ICONS.quota;
-
-/** 04 配额回退:配额耗尽时的回退策略(两个开关默认 true)。 */
-export function SectionQuota({ values, disabled, animateIn, onChange }: ConfigSectionProps) {
- const { t } = useTranslation();
-
- return (
- }
- title={t('config_management.visual.sections.quota.title')}
- description={t('config_management.visual.sections.quota.description')}
- animateIn={animateIn}
- >
-
-
-
-
- onChange({ quotaAntigravityCredits })}
- />
-
-
-
- );
-}
diff --git a/frontend/src/features/config/components/sections/SectionStreaming.tsx b/frontend/src/features/config/components/sections/SectionStreaming.tsx
deleted file mode 100644
index a9403de..0000000
--- a/frontend/src/features/config/components/sections/SectionStreaming.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import { useId } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Input } from '@/components/ui/Input';
-import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants';
-import type { ConfigSectionProps } from '../../types';
-import { SectionCard } from '../SectionCard';
-import {
- FieldAnchor,
- FieldControl,
- FieldGrid,
- FieldShell,
- FieldStack,
- InlinePill,
-} from '../fields/FieldPrimitives';
-import { getValidationMessage } from '../blocks/shared';
-
-const Icon = CONFIG_TAB_ICONS.streaming;
-
-/** 05 流式传输:keepalive 与 bootstrap 重试;nonstream-keepalive-interval 是顶层 YAML 键。 */
-export function SectionStreaming({
- values,
- validationErrors,
- disabled,
- animateIn,
- onChange,
-}: ConfigSectionProps) {
- const { t } = useTranslation();
- const keepaliveInputId = useId();
- const keepaliveHintId = `${keepaliveInputId}-hint`;
- const keepaliveErrorId = `${keepaliveInputId}-error`;
- const nonstreamKeepaliveInputId = useId();
- const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`;
- const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`;
-
- const isKeepaliveDisabled =
- values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
- const isNonstreamKeepaliveDisabled =
- values.streaming.nonstreamKeepaliveInterval === '' ||
- values.streaming.nonstreamKeepaliveInterval === '0';
-
- const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']);
- const bootstrapRetriesError = getValidationMessage(
- t,
- validationErrors?.['streaming.bootstrapRetries']
- );
- const nonstreamKeepaliveError = getValidationMessage(
- t,
- validationErrors?.['streaming.nonstreamKeepaliveInterval']
- );
-
- return (
- }
- title={t('config_management.visual.sections.streaming.title')}
- description={t('config_management.visual.sections.streaming.description')}
- animateIn={animateIn}
- >
-
-
-
-
-
-
- onChange({
- streaming: {
- ...values.streaming,
- keepaliveSeconds: e.target.value,
- },
- })
- }
- disabled={disabled}
- />
- {isKeepaliveDisabled ? (
-
- {t('config_management.visual.sections.streaming.disabled')}
-
- ) : null}
-
-
-
-
-
-
- onChange({
- streaming: {
- ...values.streaming,
- bootstrapRetries: e.target.value,
- },
- })
- }
- disabled={disabled}
- hint={t('config_management.visual.sections.streaming.bootstrap_hint')}
- error={bootstrapRetriesError}
- />
-
-
-
-
-
-
-
-
- onChange({
- streaming: {
- ...values.streaming,
- nonstreamKeepaliveInterval: e.target.value,
- },
- })
- }
- disabled={disabled}
- />
- {isNonstreamKeepaliveDisabled ? (
-
- {t('config_management.visual.sections.streaming.disabled')}
-
- ) : null}
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/config/constants.ts b/frontend/src/features/config/constants.ts
deleted file mode 100644
index 13fb8cc..0000000
--- a/frontend/src/features/config/constants.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import type { ComponentType } from 'react';
-import {
- IconCode,
- IconKey,
- IconNetwork,
- IconSatellite,
- IconScrollText,
- IconShield,
- IconSlidersHorizontal,
- IconTimer,
- type IconProps,
-} from '@/components/ui/icons';
-import type { VisualConfigFieldPath } from '@/types/visualConfig';
-import type { VisualSectionId } from './searchIndex';
-
-/** 编辑模式:可视化表单 or YAML 源码。 */
-export type ConfigEditorMode = 'visual' | 'source';
-
-/** 顶部 tabs:'common'(常用,原简单模式的继任者)+ 7 个正典分区。 */
-export type ConfigTabId = 'common' | VisualSectionId;
-
-export const CONFIG_SECTION_IDS = [
- 'connectivity',
- 'network',
- 'logging',
- 'quota',
- 'streaming',
- 'advanced',
- 'payload',
-] as const satisfies readonly VisualSectionId[];
-
-export const CONFIG_TAB_IDS: readonly ConfigTabId[] = ['common', ...CONFIG_SECTION_IDS];
-
-/** 分区序号(01–07)。常用 tab 是别名视图,不占序号。 */
-export const SECTION_INDEX_LABELS: Record = {
- connectivity: '01',
- network: '02',
- logging: '03',
- quota: '04',
- streaming: '05',
- advanced: '06',
- payload: '07',
-};
-
-export const CONFIG_TAB_ICONS: Record> = {
- common: IconSlidersHorizontal,
- connectivity: IconKey,
- network: IconNetwork,
- logging: IconScrollText,
- quota: IconTimer,
- streaming: IconSatellite,
- advanced: IconShield,
- payload: IconCode,
-};
-
-/** 常用 tab 的 8 个字段(原简单模式),渲染源与正典分区共享(fields/sharedFields.tsx)。 */
-export const COMMON_FIELD_IDS = [
- 'host',
- 'port',
- 'apiKeys',
- 'proxyUrl',
- 'debug',
- 'loggingToFile',
- 'quotaSwitchProject',
- 'quotaSwitchPreviewModel',
-] as const;
-
-/**
- * 每个分区承载的校验字段路径(tab 错误徽章的分桶依据)。
- * payload 的校验不走字段路径,由 hasPayloadValidationErrors 旗标补记。
- */
-export const SECTION_VALIDATION_FIELDS: Record =
- {
- connectivity: ['port'],
- network: ['requestRetry', 'maxRetryCredentials', 'maxRetryInterval', 'authAutoRefreshWorkers'],
- logging: ['errorLogsMaxFiles', 'logsMaxTotalSizeMb', 'redisUsageQueueRetentionSeconds'],
- quota: [],
- streaming: [
- 'streaming.keepaliveSeconds',
- 'streaming.bootstrapRetries',
- 'streaming.nonstreamKeepaliveInterval',
- ],
- advanced: [],
- payload: [],
- };
-
-/**
- * fieldId → useVisualConfig dirtyFields 的键(= VisualConfigValues 叶值键,streaming 用点号叶)。
- * 与搜索索引 58 条一一对应;三方对账由 tests/configFieldParity.test.ts 守护 ——
- * 增删字段时漏改任何一边(索引 / 本表 / 分区 JSX)都会红。
- */
-export const FIELD_VALUE_KEYS: Record = {
- // ── connectivity ──────────────────────────────────────────────────────────
- host: ['host'],
- port: ['port'],
- authDir: ['authDir'],
- apiKeys: ['apiKeysText'],
- tlsEnable: ['tlsEnable'],
- tlsCert: ['tlsCert'],
- tlsKey: ['tlsKey'],
- rmAllowRemote: ['rmAllowRemote'],
- rmDisableControlPanel: ['rmDisableControlPanel'],
- rmSecretKey: ['rmSecretKey'],
- // ── network ───────────────────────────────────────────────────────────────
- proxyUrl: ['proxyUrl'],
- requestRetry: ['requestRetry'],
- maxRetryCredentials: ['maxRetryCredentials'],
- maxRetryInterval: ['maxRetryInterval'],
- authAutoRefreshWorkers: ['authAutoRefreshWorkers'],
- routingStrategy: ['routingStrategy'],
- disableImageGeneration: ['disableImageGeneration'],
- gptImage2BaseModel: ['gptImage2BaseModel'],
- routingSessionAffinityTTL: ['routingSessionAffinityTTL'],
- forceModelPrefix: ['forceModelPrefix'],
- passthroughHeaders: ['passthroughHeaders'],
- disableCooling: ['disableCooling'],
- routingSessionAffinity: ['routingSessionAffinity'],
- wsAuth: ['wsAuth'],
- // ── logging ───────────────────────────────────────────────────────────────
- debug: ['debug'],
- commercialMode: ['commercialMode'],
- loggingToFile: ['loggingToFile'],
- logsMaxTotalSizeMb: ['logsMaxTotalSizeMb'],
- errorLogsMaxFiles: ['errorLogsMaxFiles'],
- redisUsageQueueRetentionSeconds: ['redisUsageQueueRetentionSeconds'],
- usageStatisticsEnabled: ['usageStatisticsEnabled'],
- // ── quota ─────────────────────────────────────────────────────────────────
- quotaSwitchProject: ['quotaSwitchProject'],
- quotaSwitchPreviewModel: ['quotaSwitchPreviewModel'],
- quotaAntigravityCredits: ['quotaAntigravityCredits'],
- // ── streaming ─────────────────────────────────────────────────────────────
- streamingKeepaliveSeconds: ['streaming.keepaliveSeconds'],
- streamingBootstrapRetries: ['streaming.bootstrapRetries'],
- streamingNonstreamKeepalive: ['streaming.nonstreamKeepaliveInterval'],
- // ── advanced ──────────────────────────────────────────────────────────────
- pluginsEnabled: ['pluginsEnabled'],
- pluginStoreSources: ['pluginStoreSources'],
- pluginStoreAuth: ['pluginStoreAuth'],
- antigravitySignatureCacheEnabled: ['antigravitySignatureCacheEnabled'],
- antigravitySignatureBypassStrict: ['antigravitySignatureBypassStrict'],
- claudeHeaderUserAgent: ['claudeHeaderUserAgent'],
- claudeHeaderPackageVersion: ['claudeHeaderPackageVersion'],
- claudeHeaderRuntimeVersion: ['claudeHeaderRuntimeVersion'],
- claudeHeaderOs: ['claudeHeaderOs'],
- claudeHeaderArch: ['claudeHeaderArch'],
- claudeHeaderTimeout: ['claudeHeaderTimeout'],
- claudeHeaderStabilizeDeviceProfile: ['claudeHeaderStabilizeDeviceProfile'],
- codexHeaderUserAgent: ['codexHeaderUserAgent'],
- codexHeaderBetaFeatures: ['codexHeaderBetaFeatures'],
- // ── payload ───────────────────────────────────────────────────────────────
- payloadDefaultRules: ['payloadDefaultRules'],
- payloadDefaultRawRules: ['payloadDefaultRawRules'],
- payloadOverrideRules: ['payloadOverrideRules'],
- payloadOverrideRawRules: ['payloadOverrideRawRules'],
- payloadFilterRules: ['payloadFilterRules'],
-};
-
-/** tab / tabpanel 的 DOM id:单点定义,ConfigTabs 与页面侧面板用同一函数生成 aria 关联。 */
-export const configTabDomId = (id: ConfigTabId) => `config-tab-${id}`;
-export const configPanelDomId = (id: ConfigTabId) => `config-panel-${id}`;
-
-/** localStorage 键:mode 沿用旧键('visual' | 'source' 值域不变);section 为新键。 */
-export const CONFIG_MODE_STORAGE_KEY = 'config-management:tab';
-export const CONFIG_SECTION_STORAGE_KEY = 'config-management:section';
-/** 旧「简单/完整」双模式的持久化键,模式轴已删除;挂载时清理。 */
-export const LEGACY_EDITOR_MODE_STORAGE_KEY = 'config-management:editor-mode';
diff --git a/frontend/src/features/config/hooks/useConfigDocument.ts b/frontend/src/features/config/hooks/useConfigDocument.ts
deleted file mode 100644
index 5da2fec..0000000
--- a/frontend/src/features/config/hooks/useConfigDocument.ts
+++ /dev/null
@@ -1,324 +0,0 @@
-// 配置文档的加载 / 保存状态机 —— 从旧 pages/ConfigPage.tsx 逐字提取。
-// 正确性核心,勿随手「顺化」:两阶段保存(预览前 re-fetch → diff → 确认时再 re-fetch,
-// 服务端变更则重新预览不落盘)、可视化模式的规范化 diff、commercial-mode 重启警告、
-// 保存成功后刷新全局 config store。
-
-import { useCallback, useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { parse as parseYaml, parseDocument } from 'yaml';
-import { useConfigStore, useNotificationStore } from '@/stores';
-import { configFileApi } from '@/services/api/configFile';
-import type { ConfigEditorMode } from '../constants';
-
-function readCommercialModeFromYaml(yamlContent: string): boolean {
- try {
- const parsed = parseYaml(yamlContent);
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
- return Boolean((parsed as Record)['commercial-mode']);
- } catch {
- return false;
- }
-}
-
-function normalizeYamlForVisualDiff(yamlContent: string): string {
- try {
- const doc = parseDocument(yamlContent);
- return doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
- } catch {
- return yamlContent;
- }
-}
-
-export type UseConfigDocumentArgs = {
- /** 当前编辑模式(旧实现中的 activeTab)。 */
- mode: ConfigEditorMode;
- visualDirty: boolean;
- visualParseError: string | null;
- loadVisualValuesFromYaml: (yaml: string) => { ok: true } | { ok: false; error: string };
- applyVisualChangesToYaml: (yaml: string) => string;
-};
-
-export function useConfigDocument({
- mode,
- visualDirty,
- visualParseError,
- loadVisualValuesFromYaml,
- applyVisualChangesToYaml,
-}: UseConfigDocumentArgs) {
- const { t } = useTranslation();
- const showNotification = useNotificationStore((state) => state.showNotification);
- const showConfirmation = useNotificationStore((state) => state.showConfirmation);
-
- const [content, setContent] = useState('');
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [error, setError] = useState('');
- const [dirty, setDirty] = useState(false);
- const [diffModalOpen, setDiffModalOpen] = useState(false);
- const [serverYaml, setServerYaml] = useState('');
- const [mergedYaml, setMergedYaml] = useState('');
- const [previewServerYaml, setPreviewServerYaml] = useState('');
- const [previewMode, setPreviewMode] = useState('visual');
-
- const isDirty = dirty || visualDirty;
-
- const loadConfig = useCallback(async () => {
- setLoading(true);
- setError('');
- try {
- const data = await configFileApi.fetchConfigYaml();
- setContent(data);
- setDirty(false);
- setDiffModalOpen(false);
- setServerYaml(data);
- setMergedYaml(data);
- setPreviewServerYaml(data);
- loadVisualValuesFromYaml(data);
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : t('notification.refresh_failed');
- setError(message);
- } finally {
- setLoading(false);
- }
- }, [loadVisualValuesFromYaml, t]);
-
- useEffect(() => {
- loadConfig();
- }, [loadConfig]);
-
- const handleConfirmSave = useCallback(async () => {
- setSaving(true);
- try {
- const latestServerYaml = await configFileApi.fetchConfigYaml();
- if (latestServerYaml !== previewServerYaml) {
- const nextMergedYaml =
- previewMode === 'visual' && !dirty
- ? applyVisualChangesToYaml(latestServerYaml)
- : mergedYaml;
- const nextServerYaml =
- previewMode === 'visual'
- ? normalizeYamlForVisualDiff(latestServerYaml)
- : latestServerYaml;
-
- setPreviewServerYaml(latestServerYaml);
- setServerYaml(nextServerYaml);
- setMergedYaml(nextMergedYaml);
-
- if (nextServerYaml === nextMergedYaml) {
- setDirty(false);
- setDiffModalOpen(false);
- setContent(latestServerYaml);
- loadVisualValuesFromYaml(latestServerYaml);
- showNotification(t('config_management.diff.no_changes'), 'info');
- }
- return;
- }
-
- const previousCommercialMode = readCommercialModeFromYaml(latestServerYaml);
- const nextCommercialMode = readCommercialModeFromYaml(mergedYaml);
- const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
-
- await configFileApi.saveConfigYaml(mergedYaml);
- const latestContent = await configFileApi.fetchConfigYaml();
- setDirty(false);
- setDiffModalOpen(false);
- setContent(latestContent);
- setServerYaml(latestContent);
- setMergedYaml(latestContent);
- setPreviewServerYaml(latestContent);
- loadVisualValuesFromYaml(latestContent);
-
- // Keep the global config store in sync so sidebar / other pages reflect YAML changes immediately.
- try {
- useConfigStore.getState().clearCache();
- await useConfigStore.getState().fetchConfig(true);
- } catch (refreshError: unknown) {
- const message =
- refreshError instanceof Error
- ? refreshError.message
- : typeof refreshError === 'string'
- ? refreshError
- : '';
- showNotification(
- `${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
- 'error'
- );
- }
-
- showNotification(t('config_management.save_success'), 'success');
- if (commercialModeChanged) {
- showNotification(t('notification.commercial_mode_restart_required'), 'warning');
- }
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : '';
- showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
- } finally {
- setSaving(false);
- }
- }, [
- applyVisualChangesToYaml,
- dirty,
- loadVisualValuesFromYaml,
- mergedYaml,
- previewMode,
- previewServerYaml,
- showNotification,
- t,
- ]);
-
- const handleSave = useCallback(async () => {
- if (mode === 'visual' && visualParseError) {
- showNotification(t('config_management.visual_mode_save_blocked'), 'error');
- return;
- }
-
- setSaving(true);
- try {
- const latestServerYaml = await configFileApi.fetchConfigYaml();
-
- const visualBaseYaml = dirty ? content : latestServerYaml;
-
- if (mode !== 'source') {
- const latestDocument = parseDocument(latestServerYaml);
- if (latestDocument.errors.length > 0) {
- showNotification(
- t('config_management.visual_mode_latest_yaml_invalid', {
- message:
- latestDocument.errors[0]?.message ??
- t('config_management.visual_mode_save_blocked'),
- }),
- 'error'
- );
- return;
- }
-
- if (visualBaseYaml !== latestServerYaml) {
- const visualBaseDocument = parseDocument(visualBaseYaml);
- if (visualBaseDocument.errors.length > 0) {
- showNotification(
- t('config_management.visual_mode_latest_yaml_invalid', {
- message:
- visualBaseDocument.errors[0]?.message ??
- t('config_management.visual_mode_save_blocked'),
- }),
- 'error'
- );
- return;
- }
- }
- }
-
- // In source mode, save exactly what the user edited. In visual mode, preserve the
- // local source draft when it has unsaved edits so source-only backend fields are not dropped.
- const nextMergedYaml = mode === 'source' ? content : applyVisualChangesToYaml(visualBaseYaml);
-
- // In visual mode, applyVisualChangesToYaml re-serializes YAML via parseDocument → toString,
- // which may reformat comments/whitespace. Normalize the server YAML through the same pipeline
- // so the diff only shows actual value changes, not cosmetic reformatting.
- let diffOriginal = latestServerYaml;
- if (mode !== 'source') {
- diffOriginal = normalizeYamlForVisualDiff(latestServerYaml);
- }
-
- if (diffOriginal === nextMergedYaml) {
- setDirty(false);
- setContent(latestServerYaml);
- setServerYaml(latestServerYaml);
- setMergedYaml(nextMergedYaml);
- setPreviewServerYaml(latestServerYaml);
- loadVisualValuesFromYaml(latestServerYaml);
- showNotification(t('config_management.diff.no_changes'), 'info');
- return;
- }
-
- setServerYaml(diffOriginal);
- setMergedYaml(nextMergedYaml);
- setPreviewServerYaml(latestServerYaml);
- setPreviewMode(mode);
- setDiffModalOpen(true);
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : '';
- showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
- } finally {
- setSaving(false);
- }
- }, [
- applyVisualChangesToYaml,
- content,
- dirty,
- loadVisualValuesFromYaml,
- mode,
- showNotification,
- t,
- visualParseError,
- ]);
-
- /** 源码编辑器 onChange:写入内容并标脏。 */
- const handleChange = useCallback((value: string) => {
- setContent(value);
- setDirty(true);
- }, []);
-
- const handleReload = useCallback(() => {
- if (!isDirty) {
- void loadConfig();
- return;
- }
-
- showConfirmation({
- title: t('common.unsaved_changes_title'),
- message: t('config_management.reload_confirm_message'),
- confirmText: t('config_management.reload'),
- cancelText: t('common.cancel'),
- variant: 'danger',
- onConfirm: async () => {
- await loadConfig();
- },
- });
- }, [isDirty, loadConfig, showConfirmation, t]);
-
- /** 无需联网,直接恢复最近一次成功读取的原始服务端 YAML。 */
- const handleDiscard = useCallback(() => {
- if (!isDirty) return;
-
- showConfirmation({
- title: t('common.unsaved_changes_title'),
- message: t('config_management.discard_confirm_message'),
- confirmText: t('config_management.actions.discard'),
- cancelText: t('common.cancel'),
- variant: 'danger',
- onConfirm: () => {
- setContent(previewServerYaml);
- setDirty(false);
- setDiffModalOpen(false);
- setServerYaml(previewServerYaml);
- setMergedYaml(previewServerYaml);
- loadVisualValuesFromYaml(previewServerYaml);
- },
- });
- }, [isDirty, loadVisualValuesFromYaml, previewServerYaml, showConfirmation, t]);
-
- const closeDiff = useCallback(() => setDiffModalOpen(false), []);
-
- return {
- content,
- /** 模式切换握手(可视化→源码时把脏字段写进草稿)需要直接写 content/dirty。 */
- setContent,
- setDirty,
- loading,
- saving,
- error,
- dirty,
- isDirty,
- diffModalOpen,
- serverYaml,
- mergedYaml,
- loadConfig,
- handleSave,
- handleConfirmSave,
- handleChange,
- handleReload,
- handleDiscard,
- closeDiff,
- };
-}
diff --git a/frontend/src/features/config/hooks/useFieldJump.ts b/frontend/src/features/config/hooks/useFieldJump.ts
deleted file mode 100644
index f1f21ff..0000000
--- a/frontend/src/features/config/hooks/useFieldJump.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-// 搜索跳转:切换到目标分区 tab → 等目标挂载 → 展开折叠组 → 滚动居中 → 1800ms 脉冲高亮。
-// 旧实现要与横向滚动吸附轮播搏斗(两段式 scrollIntoView);轮播已退役,只剩纵向滚动。
-
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { prefersReducedMotion } from '@/hooks/motion';
-import type { VisualConfigValues } from '@/types/visualConfig';
-import { FIELD_HIGHLIGHT_CLASS } from '../components/fields/FieldPrimitives';
-import type { ConfigTabId } from '../constants';
-import {
- configFieldDomId,
- type ConfigFieldSearchEntry,
- type VisualSectionId,
-} from '../searchIndex';
-
-export type UseFieldJumpArgs = {
- values: VisualConfigValues;
- /** 切换激活 tab(页面的 handleSectionChange,含 localStorage 持久化)。 */
- setActiveSection: (id: ConfigTabId) => void;
-};
-
-export function useFieldJump({ values, setActiveSection }: UseFieldJumpArgs) {
- // A fresh object per jump; the effect handles it once (guarded by handledJumpRef) so it
- // never needs to clear state from inside the effect.
- const [jumpRequest, setJumpRequest] = useState<{
- fieldId: string;
- sectionId: VisualSectionId;
- } | null>(null);
- const handledJumpRef = useRef<{ fieldId: string; sectionId: VisualSectionId } | null>(null);
- const highlightTimerRef = useRef(null);
- const highlightedElRef = useRef(null);
-
- const jumpToField = useCallback(
- (entry: ConfigFieldSearchEntry) => {
- // 永远跳正典分区(常用 tab 是别名视图,字段的老家在各自分区)。
- setActiveSection(entry.sectionId);
- setJumpRequest({ fieldId: entry.fieldId, sectionId: entry.sectionId });
- },
- [setActiveSection]
- );
-
- // Imperatively scroll to and pulse-highlight the jumped-to field once the target
- // section tab has mounted.
- useEffect(() => {
- if (!jumpRequest || handledJumpRef.current === jumpRequest) return;
- handledJumpRef.current = jumpRequest; // handle each request once, even if deps re-fire
- const { fieldId } = jumpRequest;
- // TLS cert/key 在 TLS 关闭时不渲染 —— 重定向到 tlsEnable 开关。
- const targetFieldId =
- (fieldId === 'tlsCert' || fieldId === 'tlsKey') && !values.tlsEnable ? 'tlsEnable' : fieldId;
-
- const attempt = (retriesLeft: number) => {
- const el = document.getElementById(configFieldDomId(targetFieldId));
- if (!el) {
- // Tab 刚切换:目标分区可能还没提交到 DOM,隔帧重试一次。
- if (retriesLeft > 0) requestAnimationFrame(() => attempt(retriesLeft - 1));
- return;
- }
-
- // Expand the collapsed group this field belongs to: an ancestor when the
- // anchor sits inside the group (TLS / remote / advanced fields), or a descendant when
- // the anchor wraps the whole group (payload rule groups).
- const details = el.closest('details') ?? el.querySelector('details');
- if (details && !details.open) details.open = true;
-
- // Clear any in-flight highlight before starting a new one.
- if (highlightTimerRef.current !== null) {
- clearTimeout(highlightTimerRef.current);
- highlightedElRef.current?.classList.remove(FIELD_HIGHLIGHT_CLASS);
- }
-
- el.scrollIntoView({
- behavior: prefersReducedMotion() ? 'auto' : 'smooth',
- block: 'center',
- inline: 'nearest',
- });
- el.classList.add(FIELD_HIGHLIGHT_CLASS);
- highlightedElRef.current = el;
- highlightTimerRef.current = window.setTimeout(() => {
- el.classList.remove(FIELD_HIGHLIGHT_CLASS);
- highlightTimerRef.current = null;
- highlightedElRef.current = null;
- }, 1800);
- };
-
- requestAnimationFrame(() => attempt(1));
- }, [jumpRequest, values.tlsEnable]);
-
- // Clear the highlight timer on unmount.
- useEffect(
- () => () => {
- if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
- },
- []
- );
-
- return { jumpToField };
-}
diff --git a/frontend/src/features/config/hooks/useSourceSearch.ts b/frontend/src/features/config/hooks/useSourceSearch.ts
deleted file mode 100644
index 6fd334f..0000000
--- a/frontend/src/features/config/hooks/useSourceSearch.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-// 源码模式的文档内搜索 —— 从旧 pages/ConfigPage.tsx 逐字提取。
-// 手写 indexOf 扫描 + 光标定位(大小写不敏感、回绕),不依赖 @codemirror/search 面板。
-
-import { useCallback, useRef, useState } from 'react';
-import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
-
-export function useSourceSearch() {
- const editorRef = useRef(null);
- const [searchQuery, setSearchQuery] = useState('');
- const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({
- current: 0,
- total: 0,
- });
- const [lastSearchedQuery, setLastSearchedQuery] = useState('');
-
- const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
- if (!query || !editorRef.current?.view) return;
-
- const view = editorRef.current.view;
- const doc = view.state.doc.toString();
- const matches: number[] = [];
- const lowerQuery = query.toLowerCase();
- const lowerDoc = doc.toLowerCase();
-
- let pos = 0;
- while (pos < lowerDoc.length) {
- const index = lowerDoc.indexOf(lowerQuery, pos);
- if (index === -1) break;
- matches.push(index);
- pos = index + 1;
- }
-
- if (matches.length === 0) {
- setSearchResults({ current: 0, total: 0 });
- return;
- }
-
- // Find current match based on cursor position
- const selection = view.state.selection.main;
- const cursorPos = direction === 'prev' ? selection.from : selection.to;
- let currentIndex = 0;
-
- if (direction === 'next') {
- // Find next match after cursor
- for (let i = 0; i < matches.length; i++) {
- if (matches[i] > cursorPos) {
- currentIndex = i;
- break;
- }
- // If no match after cursor, wrap to first
- if (i === matches.length - 1) {
- currentIndex = 0;
- }
- }
- } else {
- // Find previous match before cursor
- for (let i = matches.length - 1; i >= 0; i--) {
- if (matches[i] < cursorPos) {
- currentIndex = i;
- break;
- }
- // If no match before cursor, wrap to last
- if (i === 0) {
- currentIndex = matches.length - 1;
- }
- }
- }
-
- const matchPos = matches[currentIndex];
- setSearchResults({ current: currentIndex + 1, total: matches.length });
-
- // Scroll to and select the match
- view.dispatch({
- selection: { anchor: matchPos, head: matchPos + query.length },
- scrollIntoView: true,
- });
- view.focus();
- }, []);
-
- const handleSearchChange = useCallback((value: string) => {
- setSearchQuery(value);
- // Do not auto-search on each keystroke. Clear previous results when query changes.
- if (!value) {
- setSearchResults({ current: 0, total: 0 });
- setLastSearchedQuery('');
- } else {
- setSearchResults({ current: 0, total: 0 });
- }
- }, []);
-
- const executeSearch = useCallback(
- (direction: 'next' | 'prev' = 'next') => {
- if (!searchQuery) return;
- setLastSearchedQuery(searchQuery);
- performSearch(searchQuery, direction);
- },
- [searchQuery, performSearch]
- );
-
- const handleSearchKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- executeSearch(e.shiftKey ? 'prev' : 'next');
- }
- },
- [executeSearch]
- );
-
- const handlePrevMatch = useCallback(() => {
- if (!lastSearchedQuery) return;
- performSearch(lastSearchedQuery, 'prev');
- }, [lastSearchedQuery, performSearch]);
-
- const handleNextMatch = useCallback(() => {
- if (!lastSearchedQuery) return;
- performSearch(lastSearchedQuery, 'next');
- }, [lastSearchedQuery, performSearch]);
-
- return {
- editorRef,
- searchQuery,
- searchResults,
- lastSearchedQuery,
- handleSearchChange,
- executeSearch,
- handleSearchKeyDown,
- handlePrevMatch,
- handleNextMatch,
- };
-}
-
-export type UseSourceSearchResult = ReturnType;
diff --git a/frontend/src/features/config/searchIndex.ts b/frontend/src/features/config/searchIndex.ts
deleted file mode 100644
index 2e77c72..0000000
--- a/frontend/src/features/config/searchIndex.ts
+++ /dev/null
@@ -1,476 +0,0 @@
-// Search index for the visual config editor's global "jump to field" search.
-//
-// IMPORTANT: this index is maintained by hand and is NOT what drives field
-// rendering — it only powers search. When you add, remove, or move a field in
-// components/sections/*.tsx (or fields/sharedFields.tsx), update the matching
-// entry here, wrap the field's JSX in with the same
-// `fieldId`, and map it in constants.ts FIELD_VALUE_KEYS.
-// tests/configFieldParity.test.ts enforces the three-way parity — a missing or
-// extra entry anywhere fails CI.
-
-export type VisualSectionId =
- 'connectivity' | 'network' | 'logging' | 'quota' | 'streaming' | 'advanced' | 'payload';
-
-export interface ConfigFieldSearchEntry {
- /** Stable anchor id; matches FieldAnchor's `fieldId` and the rendered DOM id. */
- fieldId: string;
- sectionId: VisualSectionId;
- /** i18n key resolved with t() at search time so matching follows the active language. */
- labelKey: string;
- /** Optional secondary i18n key shown next to the label to disambiguate duplicates
- * (e.g. Claude vs Codex "User-Agent"). Also searchable. */
- qualifierKey?: string;
- /** Optional hint i18n key — searchable but not shown in results. */
- hintKey?: string;
- /** Backend YAML key aliases, e.g. ['proxy-url']. Static strings (language-agnostic). */
- yamlKeys?: string[];
- /** Extra synonyms to match against (language-agnostic, lowercase). */
- keywords?: string[];
-}
-
-/** DOM id for a field anchor — kept in one place so the index and the anchors agree. */
-export const configFieldDomId = (fieldId: string) => `cfg-field-${fieldId}`;
-
-type Translate = (key: string) => string;
-
-// Compact helper: every label/hint key lives under config_management.visual.
-const L = (key: string) => `config_management.visual.${key}`;
-
-export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [
- // ── connectivity ──────────────────────────────────────────────────────────
- {
- fieldId: 'host',
- sectionId: 'connectivity',
- labelKey: L('sections.server.host'),
- yamlKeys: ['host'],
- },
- {
- fieldId: 'port',
- sectionId: 'connectivity',
- labelKey: L('sections.server.port'),
- yamlKeys: ['port'],
- },
- {
- fieldId: 'authDir',
- sectionId: 'connectivity',
- labelKey: L('sections.auth.auth_dir'),
- hintKey: L('sections.auth.auth_dir_hint'),
- yamlKeys: ['auth-dir'],
- },
- {
- fieldId: 'apiKeys',
- sectionId: 'connectivity',
- labelKey: L('api_keys.label'),
- yamlKeys: ['api-keys'],
- keywords: ['api key', 'apikey', 'token'],
- },
- {
- fieldId: 'tlsEnable',
- sectionId: 'connectivity',
- labelKey: L('sections.tls.enable'),
- hintKey: L('sections.tls.enable_desc'),
- yamlKeys: ['tls'],
- keywords: ['tls', 'ssl', 'https'],
- },
- {
- fieldId: 'tlsCert',
- sectionId: 'connectivity',
- labelKey: L('sections.tls.cert'),
- yamlKeys: ['tls', 'cert'],
- keywords: ['tls', 'ssl', 'certificate'],
- },
- {
- fieldId: 'tlsKey',
- sectionId: 'connectivity',
- labelKey: L('sections.tls.key'),
- yamlKeys: ['tls', 'key'],
- keywords: ['tls', 'ssl', 'private key'],
- },
- {
- fieldId: 'rmAllowRemote',
- sectionId: 'connectivity',
- labelKey: L('sections.remote.allow_remote'),
- hintKey: L('sections.remote.allow_remote_desc'),
- yamlKeys: ['remote-management', 'allow-remote'],
- },
- {
- fieldId: 'rmDisableControlPanel',
- sectionId: 'connectivity',
- labelKey: L('sections.remote.disable_panel'),
- yamlKeys: ['remote-management', 'disable-control-panel'],
- },
- {
- fieldId: 'rmSecretKey',
- sectionId: 'connectivity',
- labelKey: L('sections.remote.secret_key'),
- yamlKeys: ['remote-management', 'secret-key'],
- },
- // ── network ───────────────────────────────────────────────────────────────
- {
- fieldId: 'proxyUrl',
- sectionId: 'network',
- labelKey: L('sections.network.proxy_url'),
- yamlKeys: ['proxy-url'],
- },
- {
- fieldId: 'requestRetry',
- sectionId: 'network',
- labelKey: L('sections.network.request_retry'),
- yamlKeys: ['request-retry'],
- },
- {
- fieldId: 'maxRetryCredentials',
- sectionId: 'network',
- labelKey: L('sections.network.max_retry_credentials'),
- hintKey: L('sections.network.max_retry_credentials_hint'),
- yamlKeys: ['max-retry-credentials'],
- },
- {
- fieldId: 'maxRetryInterval',
- sectionId: 'network',
- labelKey: L('sections.network.max_retry_interval'),
- yamlKeys: ['max-retry-interval'],
- },
- {
- fieldId: 'authAutoRefreshWorkers',
- sectionId: 'network',
- labelKey: L('sections.network.auth_auto_refresh_workers'),
- hintKey: L('sections.network.auth_auto_refresh_workers_hint'),
- yamlKeys: ['auth-auto-refresh-workers'],
- },
- {
- fieldId: 'routingStrategy',
- sectionId: 'network',
- labelKey: L('sections.network.routing_strategy'),
- hintKey: L('sections.network.routing_strategy_hint'),
- yamlKeys: ['routing', 'strategy'],
- keywords: ['round-robin', 'weighted-round-robin', 'wrr', 'fill-first'],
- },
- {
- fieldId: 'disableImageGeneration',
- sectionId: 'network',
- labelKey: L('sections.network.disable_image_generation'),
- hintKey: L('sections.network.disable_image_generation_hint'),
- yamlKeys: ['disable-image-generation'],
- keywords: ['false', 'true', 'chat', 'passthrough'],
- },
- {
- fieldId: 'gptImage2BaseModel',
- sectionId: 'network',
- labelKey: L('sections.network.gpt_image_2_base_model'),
- hintKey: L('sections.network.gpt_image_2_base_model_hint'),
- yamlKeys: ['gpt-image-2-base-model'],
- },
- {
- fieldId: 'routingSessionAffinityTTL',
- sectionId: 'network',
- labelKey: L('sections.network.session_affinity_ttl'),
- yamlKeys: ['routing', 'session-affinity-ttl'],
- },
- {
- fieldId: 'forceModelPrefix',
- sectionId: 'network',
- labelKey: L('sections.network.force_model_prefix'),
- hintKey: L('sections.network.force_model_prefix_desc'),
- yamlKeys: ['force-model-prefix'],
- },
- {
- fieldId: 'passthroughHeaders',
- sectionId: 'network',
- labelKey: L('sections.network.passthrough_headers'),
- hintKey: L('sections.network.passthrough_headers_desc'),
- yamlKeys: ['passthrough-headers'],
- },
- {
- fieldId: 'disableCooling',
- sectionId: 'network',
- labelKey: L('sections.network.disable_cooling'),
- hintKey: L('sections.network.disable_cooling_desc'),
- yamlKeys: ['disable-cooling'],
- },
- {
- fieldId: 'routingSessionAffinity',
- sectionId: 'network',
- labelKey: L('sections.network.session_affinity'),
- yamlKeys: ['routing', 'session-affinity'],
- },
- {
- fieldId: 'wsAuth',
- sectionId: 'network',
- labelKey: L('sections.network.ws_auth'),
- hintKey: L('sections.network.ws_auth_desc'),
- yamlKeys: ['ws-auth'],
- keywords: ['websocket'],
- },
- // ── logging ───────────────────────────────────────────────────────────────
- {
- fieldId: 'debug',
- sectionId: 'logging',
- labelKey: L('sections.system.debug'),
- hintKey: L('sections.system.debug_desc'),
- yamlKeys: ['debug'],
- },
- {
- fieldId: 'commercialMode',
- sectionId: 'logging',
- labelKey: L('sections.system.commercial_mode'),
- hintKey: L('sections.system.commercial_mode_desc'),
- yamlKeys: ['commercial-mode'],
- },
- {
- fieldId: 'loggingToFile',
- sectionId: 'logging',
- labelKey: L('sections.system.logging_to_file'),
- hintKey: L('sections.system.logging_to_file_desc'),
- yamlKeys: ['logging-to-file'],
- },
- {
- fieldId: 'logsMaxTotalSizeMb',
- sectionId: 'logging',
- labelKey: L('sections.system.logs_max_size'),
- yamlKeys: ['logs-max-total-size-mb'],
- },
- {
- fieldId: 'errorLogsMaxFiles',
- sectionId: 'logging',
- labelKey: L('sections.system.error_logs_max_files'),
- yamlKeys: ['error-logs-max-files'],
- },
- {
- fieldId: 'redisUsageQueueRetentionSeconds',
- sectionId: 'logging',
- labelKey: L('sections.system.redis_usage_retention'),
- hintKey: L('sections.system.redis_usage_retention_hint'),
- yamlKeys: ['redis-usage-queue-retention-seconds'],
- },
- {
- fieldId: 'usageStatisticsEnabled',
- sectionId: 'logging',
- labelKey: L('sections.system.usage_statistics_enabled'),
- hintKey: L('sections.system.usage_statistics_enabled_desc'),
- yamlKeys: ['usage-statistics-enabled'],
- },
- // ── quota ─────────────────────────────────────────────────────────────────
- {
- fieldId: 'quotaSwitchProject',
- sectionId: 'quota',
- labelKey: L('sections.quota.switch_project'),
- hintKey: L('sections.quota.switch_project_desc'),
- yamlKeys: ['quota-exceeded', 'switch-project'],
- },
- {
- fieldId: 'quotaSwitchPreviewModel',
- sectionId: 'quota',
- labelKey: L('sections.quota.switch_preview_model'),
- hintKey: L('sections.quota.switch_preview_model_desc'),
- yamlKeys: ['quota-exceeded', 'switch-preview-model'],
- },
- {
- fieldId: 'quotaAntigravityCredits',
- sectionId: 'quota',
- labelKey: L('sections.quota.antigravity_credits'),
- yamlKeys: ['quota-exceeded', 'antigravity-credits'],
- },
- // ── streaming ─────────────────────────────────────────────────────────────
- {
- fieldId: 'streamingKeepaliveSeconds',
- sectionId: 'streaming',
- labelKey: L('sections.streaming.keepalive_seconds'),
- hintKey: L('sections.streaming.keepalive_hint'),
- yamlKeys: ['streaming', 'keepalive-seconds'],
- },
- {
- fieldId: 'streamingBootstrapRetries',
- sectionId: 'streaming',
- labelKey: L('sections.streaming.bootstrap_retries'),
- hintKey: L('sections.streaming.bootstrap_hint'),
- yamlKeys: ['streaming', 'bootstrap-retries'],
- },
- {
- fieldId: 'streamingNonstreamKeepalive',
- sectionId: 'streaming',
- labelKey: L('sections.streaming.nonstream_keepalive'),
- hintKey: L('sections.streaming.nonstream_keepalive_hint'),
- yamlKeys: ['streaming', 'nonstream-keepalive-interval'],
- },
- // ── advanced ──────────────────────────────────────────────────────────────
- {
- fieldId: 'pluginsEnabled',
- sectionId: 'advanced',
- labelKey: L('sections.system.plugins_enabled'),
- hintKey: L('sections.system.plugins_enabled_desc'),
- yamlKeys: ['plugins'],
- },
- {
- fieldId: 'pluginStoreSources',
- sectionId: 'advanced',
- labelKey: L('sections.system.plugin_store_sources'),
- hintKey: L('sections.system.plugin_store_sources_hint'),
- yamlKeys: ['plugins', 'store-sources'],
- },
- {
- fieldId: 'pluginStoreAuth',
- sectionId: 'advanced',
- labelKey: L('sections.system.plugin_store_auth'),
- hintKey: L('sections.system.plugin_store_auth_hint'),
- yamlKeys: ['plugins', 'store-auth'],
- },
- {
- fieldId: 'antigravitySignatureCacheEnabled',
- sectionId: 'advanced',
- labelKey: L('sections.system.antigravity_signature_cache'),
- hintKey: L('sections.system.antigravity_signature_cache_desc'),
- yamlKeys: ['antigravity-signature-cache-enabled'],
- },
- {
- fieldId: 'antigravitySignatureBypassStrict',
- sectionId: 'advanced',
- labelKey: L('sections.system.antigravity_signature_strict'),
- hintKey: L('sections.system.antigravity_signature_strict_desc'),
- yamlKeys: ['antigravity-signature-bypass-strict'],
- },
- // Claude header defaults — qualifierKey disambiguates the shared "User-Agent" label.
- {
- fieldId: 'claudeHeaderUserAgent',
- sectionId: 'advanced',
- labelKey: L('sections.headers.user_agent'),
- qualifierKey: L('sections.headers.claude_title'),
- yamlKeys: ['claude-header-defaults', 'user-agent'],
- keywords: ['claude'],
- },
- {
- fieldId: 'claudeHeaderPackageVersion',
- sectionId: 'advanced',
- labelKey: L('sections.headers.package_version'),
- qualifierKey: L('sections.headers.claude_title'),
- yamlKeys: ['claude-header-defaults', 'package-version'],
- keywords: ['claude'],
- },
- {
- fieldId: 'claudeHeaderRuntimeVersion',
- sectionId: 'advanced',
- labelKey: L('sections.headers.runtime_version'),
- qualifierKey: L('sections.headers.claude_title'),
- yamlKeys: ['claude-header-defaults', 'runtime-version'],
- keywords: ['claude'],
- },
- {
- fieldId: 'claudeHeaderOs',
- sectionId: 'advanced',
- labelKey: L('sections.headers.os'),
- qualifierKey: L('sections.headers.claude_title'),
- yamlKeys: ['claude-header-defaults', 'os'],
- keywords: ['claude'],
- },
- {
- fieldId: 'claudeHeaderArch',
- sectionId: 'advanced',
- labelKey: L('sections.headers.arch'),
- qualifierKey: L('sections.headers.claude_title'),
- yamlKeys: ['claude-header-defaults', 'arch'],
- keywords: ['claude'],
- },
- {
- fieldId: 'claudeHeaderTimeout',
- sectionId: 'advanced',
- labelKey: L('sections.headers.timeout'),
- qualifierKey: L('sections.headers.claude_title'),
- yamlKeys: ['claude-header-defaults', 'timeout'],
- keywords: ['claude'],
- },
- {
- fieldId: 'claudeHeaderStabilizeDeviceProfile',
- sectionId: 'advanced',
- labelKey: L('sections.headers.stabilize_device'),
- qualifierKey: L('sections.headers.claude_title'),
- hintKey: L('sections.headers.stabilize_device_desc'),
- yamlKeys: ['claude-header-defaults', 'stabilize-device-profile'],
- keywords: ['claude'],
- },
- // Codex header defaults.
- {
- fieldId: 'codexHeaderUserAgent',
- sectionId: 'advanced',
- labelKey: L('sections.headers.user_agent'),
- qualifierKey: L('sections.headers.codex_title'),
- yamlKeys: ['codex-header-defaults', 'user-agent'],
- keywords: ['codex'],
- },
- {
- fieldId: 'codexHeaderBetaFeatures',
- sectionId: 'advanced',
- labelKey: L('sections.headers.beta_features'),
- qualifierKey: L('sections.headers.codex_title'),
- yamlKeys: ['codex-header-defaults', 'beta-features'],
- keywords: ['codex'],
- },
- // ── payload (coarse: one entry per rule group) ──────────────────────────────
- {
- fieldId: 'payloadDefaultRules',
- sectionId: 'payload',
- labelKey: L('sections.payload.default_rules'),
- hintKey: L('sections.payload.default_rules_desc'),
- keywords: ['payload', 'rule'],
- },
- {
- fieldId: 'payloadDefaultRawRules',
- sectionId: 'payload',
- labelKey: L('sections.payload.default_raw_rules'),
- hintKey: L('sections.payload.default_raw_rules_desc'),
- keywords: ['payload', 'rule', 'json'],
- },
- {
- fieldId: 'payloadOverrideRules',
- sectionId: 'payload',
- labelKey: L('sections.payload.override_rules'),
- hintKey: L('sections.payload.override_rules_desc'),
- keywords: ['payload', 'rule'],
- },
- {
- fieldId: 'payloadOverrideRawRules',
- sectionId: 'payload',
- labelKey: L('sections.payload.override_raw_rules'),
- hintKey: L('sections.payload.override_raw_rules_desc'),
- keywords: ['payload', 'rule', 'json'],
- },
- {
- fieldId: 'payloadFilterRules',
- sectionId: 'payload',
- labelKey: L('sections.payload.filter_rules'),
- hintKey: L('sections.payload.filter_rules_desc'),
- keywords: ['payload', 'rule', 'filter'],
- },
-];
-
-const MAX_RESULTS = 8;
-
-/**
- * Lowercase substring search over label + qualifier + hint + YAML keys + keywords.
- * Returns the best ~8 matches, label/qualifier hits ranked above alias-only hits.
- */
-export function searchConfigFields(query: string, t: Translate): ConfigFieldSearchEntry[] {
- const q = query.trim().toLowerCase();
- if (!q) return [];
-
- const scored: { entry: ConfigFieldSearchEntry; score: number }[] = [];
-
- for (const entry of CONFIG_FIELD_SEARCH_INDEX) {
- const label = t(entry.labelKey).toLowerCase();
- const qualifier = entry.qualifierKey ? t(entry.qualifierKey).toLowerCase() : '';
- const hint = entry.hintKey ? t(entry.hintKey).toLowerCase() : '';
- const yaml = (entry.yamlKeys ?? []).join(' ').toLowerCase();
- const keywords = (entry.keywords ?? []).join(' ').toLowerCase();
-
- let score = Number.POSITIVE_INFINITY;
- if (label.startsWith(q)) score = 0;
- else if (label.includes(q)) score = 1;
- else if (qualifier.includes(q) || keywords.includes(q)) score = 2;
- else if (yaml.includes(q)) score = 3;
- else if (hint.includes(q)) score = 4;
-
- if (Number.isFinite(score)) scored.push({ entry, score });
- }
-
- scored.sort((a, b) => a.score - b.score);
- return scored.slice(0, MAX_RESULTS).map((item) => item.entry);
-}
diff --git a/frontend/src/features/config/sponsors.ts b/frontend/src/features/config/sponsors.ts
deleted file mode 100644
index 1d2b969..0000000
--- a/frontend/src/features/config/sponsors.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * 赞助商展示数据(网络配置分区「代理 URL」字段标签下方的赞助跳转行)。
- * 纯前端常量,与后端配置无关;新增/移除赞助商只需改动这里。
- * 数组为空时赞助行整体不渲染。
- */
-import bestproxyLogo from '@/assets/icons/bestproxy.png';
-
-export type Sponsor = {
- /** 赞助商名称(直接展示,不做翻译)。 */
- name: string;
- /** 点击跳转地址。 */
- url: string;
- /** 可选小图标(与名称并排展示)。 */
- logo?: string;
-};
-
-export const SPONSORS: readonly Sponsor[] = [
- {
- name: 'BestProxy.com',
- url: 'https://bestproxy.com/?keyword=ayh7otlb',
- logo: bestproxyLogo,
- },
-];
diff --git a/frontend/src/features/config/types.ts b/frontend/src/features/config/types.ts
deleted file mode 100644
index 693b562..0000000
--- a/frontend/src/features/config/types.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { VisualConfigValidationErrors, VisualConfigValues } from '@/types/visualConfig';
-
-/** 分区组件的统一签名:受控于 useVisualConfig 的表单值 + 补丁式 onChange。 */
-export type ConfigSectionProps = {
- values: VisualConfigValues;
- validationErrors?: VisualConfigValidationErrors;
- disabled: boolean;
- /** 仅首载入场为 true(页面挂载时捕获),tab 切换不重播。 */
- animateIn?: boolean;
- onChange: (patch: Partial) => void;
-};
diff --git a/frontend/src/features/config/uiState.ts b/frontend/src/features/config/uiState.ts
deleted file mode 100644
index f3a84cb..0000000
--- a/frontend/src/features/config/uiState.ts
+++ /dev/null
@@ -1,268 +0,0 @@
-// 配置页 UI 状态的纯函数层:状态机、徽章分桶、脏字段归属、localStorage 读取。
-// 全部无副作用,由 tests/configUiState.test.ts 覆盖。
-
-import type { VisualConfigValidationErrors } from '@/types/visualConfig';
-import {
- COMMON_FIELD_IDS,
- CONFIG_SECTION_IDS,
- CONFIG_TAB_IDS,
- FIELD_VALUE_KEYS,
- SECTION_VALIDATION_FIELDS,
- type ConfigEditorMode,
- type ConfigTabId,
-} from './constants';
-import { CONFIG_FIELD_SEARCH_INDEX, type VisualSectionId } from './searchIndex';
-
-/** 可视化编辑器暴露的配置项总数(头部 meta 行的「N 项配置」)。 */
-export const CONFIG_FIELD_COUNT = CONFIG_FIELD_SEARCH_INDEX.length;
-
-/** 叶值键(= useVisualConfig dirtyFields 的键)→ fieldId 反查表。 */
-const VALUE_KEY_TO_FIELD_ID: ReadonlyMap = (() => {
- const map = new Map();
- for (const [fieldId, valueKeys] of Object.entries(FIELD_VALUE_KEYS)) {
- for (const valueKey of valueKeys) map.set(valueKey, fieldId);
- }
- return map;
-})();
-
-const FIELD_ID_TO_SECTION: ReadonlyMap = new Map(
- CONFIG_FIELD_SEARCH_INDEX.map((entry) => [entry.fieldId, entry.sectionId])
-);
-
-const COMMON_FIELD_ID_SET: ReadonlySet = new Set(COMMON_FIELD_IDS);
-
-/** 常用 tab 渲染的字段对应的叶值键集合(校验错误归属常用 tab 时用)。 */
-const COMMON_VALUE_KEYS: ReadonlySet = new Set(
- COMMON_FIELD_IDS.flatMap((fieldId) => [...(FIELD_VALUE_KEYS[fieldId] ?? [])])
-);
-
-/** 脏字段集合 → 点亮脏点的 tabs。常用字段同时点亮 common 与其正典分区(两处都渲染它)。 */
-export function resolveDirtyTabs(dirtyFields: ReadonlySet): ReadonlySet {
- const tabs = new Set();
- for (const valueKey of dirtyFields) {
- const fieldId = VALUE_KEY_TO_FIELD_ID.get(valueKey);
- if (!fieldId) continue;
- const sectionId = FIELD_ID_TO_SECTION.get(fieldId);
- if (sectionId) tabs.add(sectionId);
- if (COMMON_FIELD_ID_SET.has(fieldId)) tabs.add('common');
- }
- return tabs;
-}
-
-/** 每个 tab 的校验错误数(错误徽章)。payload 的校验以旗标计 1。 */
-export function countSectionErrors(
- validationErrors: VisualConfigValidationErrors | undefined,
- hasPayloadValidationErrors: boolean
-): Record {
- const counts = Object.fromEntries(CONFIG_TAB_IDS.map((tabId) => [tabId, 0])) as Record<
- ConfigTabId,
- number
- >;
- for (const sectionId of CONFIG_SECTION_IDS) {
- counts[sectionId] = SECTION_VALIDATION_FIELDS[sectionId].reduce(
- (total, field) => total + (validationErrors?.[field] ? 1 : 0),
- 0
- );
- }
- if (hasPayloadValidationErrors) counts.payload += 1;
- counts.common = Object.entries(validationErrors ?? {}).reduce(
- (total, [field, error]) => total + (error && COMMON_VALUE_KEYS.has(field) ? 1 : 0),
- 0
- );
- return counts;
-}
-
-/** 全页校验错误总数(头部 meta 行)。 */
-export function countTotalErrors(
- validationErrors: VisualConfigValidationErrors | undefined,
- hasPayloadValidationErrors: boolean
-): number {
- const fieldErrors = Object.values(validationErrors ?? {}).filter(Boolean).length;
- return fieldErrors + (hasPayloadValidationErrors ? 1 : 0);
-}
-
-export type ConfigStatusKey =
- | 'disconnected'
- | 'loading'
- | 'load_failed'
- | 'yaml_error'
- | 'validation_blocked'
- | 'saving'
- | 'dirty'
- | 'synced';
-
-export type ConfigStatusTone = 'error' | 'warning' | 'busy' | 'muted' | 'ok';
-
-export type ConfigStatus = {
- key: ConfigStatusKey;
- /** 完整状态文案的 i18n 键。 */
- labelKey: string;
- /** 移动端短文案的 i18n 键。validation_blocked 的短键在 config_management 顶层(历史路径 bug 的修正)。 */
- shortLabelKey: string;
- tone: ConfigStatusTone;
-};
-
-export type ConfigStatusInput = {
- disconnected: boolean;
- loading: boolean;
- loadFailed: boolean;
- yamlError: boolean;
- validationBlocked: boolean;
- saving: boolean;
- dirty: boolean;
-};
-
-/** 悬浮保存栏 / 状态文案的状态机。优先级自上而下,与旧页 getStatusText 分支序一致。 */
-export function resolveStatus(input: ConfigStatusInput): ConfigStatus {
- if (input.disconnected) {
- return {
- key: 'disconnected',
- labelKey: 'config_management.status_disconnected',
- shortLabelKey: 'config_management.status_disconnected_short',
- tone: 'muted',
- };
- }
- if (input.loading) {
- return {
- key: 'loading',
- labelKey: 'config_management.status_loading',
- shortLabelKey: 'config_management.status_loading_short',
- tone: 'busy',
- };
- }
- if (input.loadFailed) {
- return {
- key: 'load_failed',
- labelKey: 'config_management.status_load_failed',
- shortLabelKey: 'config_management.status_load_failed_short',
- tone: 'error',
- };
- }
- if (input.yamlError) {
- return {
- key: 'yaml_error',
- labelKey: 'config_management.visual_mode_unavailable',
- shortLabelKey: 'config_management.visual_mode_unavailable_short',
- tone: 'error',
- };
- }
- if (input.validationBlocked) {
- return {
- key: 'validation_blocked',
- labelKey: 'config_management.visual.validation.validation_blocked',
- shortLabelKey: 'config_management.validation_blocked_short',
- tone: 'error',
- };
- }
- if (input.saving) {
- return {
- key: 'saving',
- labelKey: 'config_management.status_saving',
- shortLabelKey: 'config_management.status_saving_short',
- tone: 'busy',
- };
- }
- if (input.dirty) {
- return {
- key: 'dirty',
- labelKey: 'config_management.status_dirty',
- shortLabelKey: 'config_management.status_dirty_short',
- tone: 'warning',
- };
- }
- return {
- key: 'synced',
- labelKey: 'config_management.status_loaded',
- shortLabelKey: 'config_management.status_loaded_short',
- tone: 'ok',
- };
-}
-
-export type HeaderMetaSegment = {
- key: 'fields' | ConfigStatusKey | 'dirty_source' | 'errors';
- labelKey: string;
- count?: number;
- tone: 'muted' | 'warning' | 'error' | 'ok';
-};
-
-export type HeaderMetaInput = {
- fieldCount: number;
- status: ConfigStatus;
- dirtyCount: number;
- sourceDirty: boolean;
- errorCount: number;
-};
-
-/**
- * 头部 ▍mono meta 行直接消费页面状态机,避免 Header 与保存栏各自推导连接/加载状态。
- * 字段总数常驻;阻断状态优先,编辑状态再补充待保存和校验错误数量。
- */
-export function buildHeaderMeta(input: HeaderMetaInput): HeaderMetaSegment[] {
- const segments: HeaderMetaSegment[] = [
- {
- key: 'fields',
- labelKey: 'config_management.meta_fields',
- count: input.fieldCount,
- tone: 'muted',
- },
- ];
- const { status } = input;
-
- if (
- status.key === 'disconnected' ||
- status.key === 'loading' ||
- status.key === 'load_failed' ||
- status.key === 'saving'
- ) {
- segments.push({
- key: status.key,
- labelKey: status.labelKey,
- tone: status.tone === 'busy' ? 'muted' : status.tone,
- });
- return segments;
- }
- if (status.key === 'yaml_error') {
- segments.push({
- key: status.key,
- labelKey: status.shortLabelKey,
- tone: 'error',
- });
- }
- if (input.sourceDirty) {
- segments.push({
- key: 'dirty_source',
- labelKey: 'config_management.meta_dirty_source',
- tone: 'warning',
- });
- } else if (input.dirtyCount > 0) {
- segments.push({
- key: 'dirty',
- labelKey: 'config_management.meta_dirty',
- count: input.dirtyCount,
- tone: 'warning',
- });
- }
- if (input.errorCount > 0) {
- segments.push({
- key: 'errors',
- labelKey: 'config_management.meta_errors',
- count: input.errorCount,
- tone: 'error',
- });
- }
- if (status.key === 'synced') {
- segments.push({ key: status.key, labelKey: 'config_management.meta_synced', tone: 'ok' });
- }
- return segments;
-}
-
-/** localStorage 读取:非法/陈旧值回退默认。 */
-export function readSavedMode(raw: string | null): ConfigEditorMode {
- return raw === 'source' ? 'source' : 'visual';
-}
-
-export function readSavedSection(raw: string | null): ConfigTabId {
- return raw !== null && (CONFIG_TAB_IDS as readonly string[]).includes(raw)
- ? (raw as ConfigTabId)
- : 'common';
-}
diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx
deleted file mode 100644
index 85a029b..0000000
--- a/frontend/src/features/dashboard/DashboardPage.tsx
+++ /dev/null
@@ -1,541 +0,0 @@
-import { useMemo } from 'react';
-import { Link } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import {
- IconBot,
- IconFileText,
- IconSidebarConfig,
- IconSidebarLogs,
- IconSidebarQuota,
- IconSidebarSystem,
-} from '@/components/ui/icons';
-import { useAuthStore } from '@/stores';
-import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
-import { formatCompactNumber, formatDateValue, formatPercent } from '@/utils/format';
-import { useDashboardOverview } from './hooks/useDashboardOverview';
-import { LiveWire } from './components/LiveWire';
-import { Meter } from './components/Meter';
-import { Sparkline } from './components/Sparkline';
-import { ThroughputChart } from './components/ThroughputChart';
-import { useCountUp, useRevealGroup, useRevealOnScroll } from '@/hooks/motion';
-import { providerLabel, splitWindowMinutes, toneForSuccessRate, type MeterTone } from './utils';
-import styles from './dashboard.module.scss';
-
-const DASH = '—';
-
-/** KPI 卡左上角色签:有语义色调的卡用状态色,其余保持中性 */
-const TILE_ACCENTS: Record = {
- good: 'var(--viz-success)',
- warning: 'var(--amber-color)',
- critical: 'var(--viz-failure)',
- idle: 'var(--text-quaternary)',
-};
-
-/** 大数字:六位以内用千分位,再往上压缩,避免撑破排版 */
-const formatHeadline = (value: number): string =>
- value < 100_000 ? value.toLocaleString() : formatCompactNumber(value);
-
-export function DashboardPage() {
- const { t, i18n } = useTranslation();
- const serverVersion = useAuthStore((state) => state.serverVersion);
- const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
-
- const { connectionStatus, connected, config, counts, traffic, providers, credentials, refresh } =
- useDashboardOverview();
-
- useHeaderRefresh(refresh, connected);
-
- /* Hero 与静态网格走分组级联;异步内容区(图表/供应商)保持整块 reveal */
- const heroRef = useRevealGroup();
- const statsRef = useRevealGroup(0.12);
- const trafficRef = useRevealOnScroll();
- const fleetRef = useRevealOnScroll();
- const detailRef = useRevealGroup();
- const ctaRef = useRevealGroup();
-
- const animatedTotal = useCountUp(traffic.total, connected);
-
- const windowLabel = useMemo(() => {
- if (traffic.windowMinutes <= 0) return DASH;
- const { hours, minutes } = splitWindowMinutes(traffic.windowMinutes);
- if (hours === 0) return t('dashboard.window_m', { minutes });
- if (minutes === 0) return t('dashboard.window_h', { hours });
- return t('dashboard.window_hm', { hours, minutes });
- }, [traffic.windowMinutes, t]);
-
- const heroSparkPoints = useMemo(
- () => traffic.buckets.map((bucket) => bucket.success + bucket.failed),
- [traffic.buckets]
- );
-
- const routingStrategy = useMemo(() => {
- const raw = config?.routingStrategy?.trim() ?? '';
- if (!raw) return DASH;
- if (raw === 'round-robin') return t('basic_settings.routing_strategy_round_robin');
- if (raw === 'weighted-round-robin') {
- return t('basic_settings.routing_strategy_weighted_round_robin');
- }
- if (raw === 'fill-first') return t('basic_settings.routing_strategy_fill_first');
- return raw;
- }, [config?.routingStrategy, t]);
-
- const unknownProviderLabel = t('dashboard.provider_unknown');
- const successRateTone = toneForSuccessRate(traffic.successRate);
-
- /** 标题是算出来的判词,不是写死的口号;句尾句号充当状态灯 */
- const verdict = useMemo(() => {
- if (!connected) {
- return connectionStatus === 'connecting'
- ? { key: 'hero_verdict_connecting', accent: 'var(--amber-color)' }
- : { key: 'hero_verdict_offline', accent: 'var(--text-quaternary)' };
- }
- if (traffic.total === 0 || traffic.successRate === null) {
- return { key: 'hero_verdict_idle', accent: 'var(--text-quaternary)' };
- }
- const keyByTone: Record = {
- good: 'hero_verdict_good',
- warning: 'hero_verdict_warning',
- critical: 'hero_verdict_critical',
- idle: 'hero_verdict_idle',
- };
- return { key: keyByTone[successRateTone], accent: TILE_ACCENTS[successRateTone] };
- }, [connected, connectionStatus, traffic.total, traffic.successRate, successRateTone]);
-
- /* 句号状态灯只在「有活着的流量」时呼吸;离线/静默时保持安静 */
- const heroAlive = connected && traffic.total > 0;
-
- const connectionLabel = t(
- connectionStatus === 'connected'
- ? 'common.connected'
- : connectionStatus === 'connecting'
- ? 'common.connecting'
- : 'common.disconnected'
- );
- const versionLabel = serverVersion ? `v${serverVersion.trim().replace(/^[vV]+/, '')}` : null;
- const heroMetaLine = [versionLabel, connectionLabel].filter(Boolean).join(' · ');
-
- const statTiles = [
- {
- key: 'success',
- label: t('dashboard.success_rate'),
- value: traffic.successRate === null ? DASH : formatPercent(traffic.successRate),
- hint: t('dashboard.stat_success_hint', { total: traffic.total.toLocaleString() }),
- meter: traffic.successRate,
- tone: successRateTone,
- },
- {
- key: 'credentials',
- label: t('dashboard.stat_credentials'),
- value: credentials ? credentials.total.toLocaleString() : DASH,
- hint: credentials
- ? t('dashboard.stat_credentials_hint', {
- active: credentials.active,
- disabled: credentials.disabled + credentials.unavailable,
- })
- : t('dashboard.stat_credentials_empty'),
- meter:
- credentials && credentials.total > 0
- ? (credentials.active / credentials.total) * 100
- : null,
- tone: undefined,
- },
- {
- key: 'providerKeys',
- label: t('dashboard.stat_provider_keys'),
- value: counts.providerKeys === null ? DASH : counts.providerKeys.toLocaleString(),
- hint: t('dashboard.stat_provider_keys_hint'),
- meter: null,
- tone: undefined,
- },
- {
- key: 'models',
- label: t('dashboard.stat_models'),
- value: counts.models === null ? DASH : counts.models.toLocaleString(),
- hint: t('dashboard.stat_models_hint'),
- meter: null,
- tone: undefined,
- },
- ];
-
- const runtimeRows: Array<{ label: string; value: string; mono?: boolean }> = [
- { label: t('dashboard.runtime_routing'), value: routingStrategy },
- { label: t('dashboard.runtime_retry'), value: String(config?.requestRetry ?? 0) },
- {
- label: t('dashboard.runtime_management_keys'),
- value: counts.managementKeys === null ? DASH : String(counts.managementKeys),
- },
- { label: t('dashboard.runtime_version'), value: serverVersion?.trim() || DASH },
- {
- label: t('dashboard.runtime_build'),
- value: formatDateValue(serverBuildDate, i18n.language) || DASH,
- },
- { label: t('dashboard.runtime_proxy'), value: config?.proxyUrl?.trim() || DASH, mono: true },
- ];
-
- const runtimeToggles = config
- ? [
- { label: t('dashboard.runtime_debug'), on: Boolean(config.debug) },
- { label: t('dashboard.runtime_file_logging'), on: Boolean(config.loggingToFile) },
- { label: t('dashboard.runtime_request_log'), on: Boolean(config.requestLog) },
- { label: t('dashboard.runtime_ws_auth'), on: Boolean(config.wsAuth) },
- { label: t('dashboard.runtime_model_prefix'), on: Boolean(config.forceModelPrefix) },
- ]
- : [];
-
- const ctaCards = [
- {
- to: '/ai-providers',
- icon: ,
- title: t('nav.ai_providers'),
- description: t('dashboard.cta_providers_desc'),
- },
- {
- to: '/auth-files',
- icon: ,
- title: t('nav.auth_files'),
- description: t('dashboard.cta_auth_files_desc'),
- },
- {
- to: '/config',
- icon: ,
- title: t('nav.config_management'),
- description: t('dashboard.cta_config_desc'),
- },
- {
- to: '/quota',
- icon: ,
- title: t('nav.quota_management'),
- description: t('dashboard.cta_quota_desc'),
- },
- {
- to: '/logs',
- icon: ,
- title: t('nav.logs'),
- description: t('dashboard.cta_logs_desc'),
- },
- {
- to: '/system',
- icon: ,
- title: t('nav.system_info'),
- description: t('dashboard.cta_system_desc'),
- },
- ];
-
- return (
-
-
-
-
-
-
- {/* ---------- Hero ---------- */}
-
-
-
- {t(`dashboard.${verdict.key}`)}
-
- {t('dashboard.hero_period')}
-
-
-
- {heroMetaLine}
-
-
-
- {t('dashboard.cta_manage_providers')}
-
-
- {t('dashboard.cta_inspect_logs')}{' '}
-
- →
-
-
-
-
-
-
-
- {t('dashboard.hero_requests_label')}
- {connected && (
-
-
- {t('dashboard.hero_live')}
-
- )}
-
-
- {connected ? formatHeadline(animatedTotal) : DASH}
-
-
- {t('dashboard.hero_window_meta', { window: windowLabel })}
-
- {traffic.total > 0 && (
-
- {traffic.totalSuccess > 0 && (
-
- )}
- {traffic.totalFailure > 0 && (
-
- )}
-
- )}
-
-
-
- {t('stats.success')}
- {traffic.totalSuccess.toLocaleString()}
-
-
-
- {t('stats.failure')}
- {traffic.totalFailure.toLocaleString()}
-
-
-
-
-
-
-
-
-
- {/* ---------- KPI ---------- */}
-
- {statTiles.map((tile) => (
-
- {tile.label}
- {tile.value}
- {tile.meter !== null && tile.meter !== undefined && (
-
- )}
- {tile.hint}
-
- ))}
-
-
- {/* ---------- Traffic ---------- */}
-
-
- {/* ---------- Provider fleet ---------- */}
-
-
-
- {providers.length === 0 ? (
-
{t('dashboard.fleet_empty')}
- ) : (
-
- )}
-
-
-
- {/* ---------- Credential health + runtime ---------- */}
-
-
-
- {t('dashboard.health_eyebrow')}
- {t('dashboard.health_title')}
-
- {!credentials || credentials.total === 0 ? (
-
{t('dashboard.health_empty')}
- ) : (
- <>
-
- {credentials.active > 0 && (
-
- )}
- {credentials.unavailable > 0 && (
-
- )}
- {credentials.disabled > 0 && (
-
- )}
-
-
- -
-
- {t('dashboard.health_active')}
- {credentials.active.toLocaleString()}
-
- -
-
- {t('dashboard.health_unavailable')}
- {credentials.unavailable.toLocaleString()}
-
- -
-
- {t('dashboard.health_disabled')}
- {credentials.disabled.toLocaleString()}
-
-
-
-
{t('dashboard.health_by_type')}
-
- {credentials.byType.map((entry) => (
- -
- {providerLabel(entry.type, unknownProviderLabel)}
- {entry.count.toLocaleString()}
-
- ))}
-
-
-
- {t('dashboard.health_link')}{' '}
-
- →
-
-
- >
- )}
-
-
-
-
- {t('dashboard.runtime_eyebrow')}
- {t('dashboard.runtime_title')}
-
-
- {runtimeRows.map((row) => (
-
-
- {row.label}
- -
- {row.value}
-
-
- ))}
-
- {runtimeToggles.length > 0 && (
-
- {runtimeToggles.map((toggle) => (
- -
- {toggle.label}
- {toggle.on ? t('common.yes') : t('common.no')}
-
- ))}
-
- )}
-
- {t('dashboard.runtime_link')}{' '}
-
- →
-
-
-
-
-
- {/* ---------- CTA ---------- */}
-
-
- {t('dashboard.cta_eyebrow')}
- {t('dashboard.cta_title')}
-
-
- {ctaCards.map((card) => (
-
- {card.icon}
- {card.title}
- {card.description}
-
- →
-
-
- ))}
-
-
-
- );
-}
diff --git a/frontend/src/features/dashboard/components/LiveWire.module.scss b/frontend/src/features/dashboard/components/LiveWire.module.scss
deleted file mode 100644
index 7229708..0000000
--- a/frontend/src/features/dashboard/components/LiveWire.module.scss
+++ /dev/null
@@ -1,96 +0,0 @@
-.wire {
- /* 浅色纸面上纯 emerald 线对比不足,掺 12% 墨色压深一档;面积渐变仍用原色 */
- --wire-color: var(--viz-success, #10b981);
- --wire-stroke: color-mix(in srgb, var(--wire-color) 88%, var(--text-primary));
-
- position: relative;
- width: 100%;
- height: 100%;
- mask-image: linear-gradient(to right, transparent, #000 5%, #000 95%, transparent);
- -webkit-mask-image: linear-gradient(to right, transparent, #000 5%, #000 95%, transparent);
-}
-
-.canvas {
- display: block;
- width: 100%;
- height: 100%;
- overflow: visible;
-}
-
-/* 描画排在 hero 文案/面板级联之后:脉搏最后抵达,收束整场入场 */
-.line {
- stroke: var(--wire-stroke);
- stroke-width: 2px;
- stroke-linecap: round;
- stroke-linejoin: round;
- stroke-dasharray: 1;
- stroke-dashoffset: 0;
- animation: wireDraw 1.3s cubic-bezier(0.25, 0.6, 0.35, 1) 0.55s backwards;
-}
-
-.area {
- animation: wireFade 0.9s ease-out 1.25s backwards;
-}
-
-.idleLine {
- stroke: var(--text-quaternary);
- stroke-width: 1.5px;
- stroke-dasharray: 4 6;
- fill: none;
-}
-
-.pulse {
- position: absolute;
- right: 5%;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- transform: translate(50%, -50%);
- background: var(--wire-stroke);
- box-shadow: 0 0 0 0 color-mix(in srgb, var(--wire-color) 45%, transparent);
- animation: wirePulse 2.4s ease-out 1.9s infinite;
- /* 数据刷新时光点滑到新位置,而不是瞬移 */
- transition: top 400ms var(--ease-out-strong, ease-out);
-}
-
-@keyframes wireDraw {
- from {
- stroke-dashoffset: 1;
- }
- to {
- stroke-dashoffset: 0;
- }
-}
-
-@keyframes wireFade {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-@keyframes wirePulse {
- 0% {
- box-shadow: 0 0 0 0 color-mix(in srgb, var(--wire-color) 45%, transparent);
- }
- 70% {
- box-shadow: 0 0 0 10px transparent;
- }
- 100% {
- box-shadow: 0 0 0 0 transparent;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .line,
- .area,
- .pulse {
- animation: none;
- }
-
- .pulse {
- transition: none;
- }
-}
diff --git a/frontend/src/features/dashboard/components/LiveWire.tsx b/frontend/src/features/dashboard/components/LiveWire.tsx
deleted file mode 100644
index d77ba2d..0000000
--- a/frontend/src/features/dashboard/components/LiveWire.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useId, useMemo } from 'react';
-import { buildSmoothLinePath, type CurvePoint } from './curve';
-import styles from './LiveWire.module.scss';
-
-const VIEW_WIDTH = 100;
-const VIEW_HEIGHT = 40;
-/** 顶部留白,让峰值和呼吸光点都不被裁切 */
-const TOP_PADDING = 6;
-
-interface LiveWireProps {
- points: number[];
- ariaLabel: string;
- className?: string;
-}
-
-/**
- * Hero 签名元素:横贯 hero 底部的实时流量脉搏线。
- * 真实桶数据 → 平滑曲线 + 渐变面积,最新一桶的末端带呼吸光点;
- * 无流量时退化为一条安静的虚线基线。
- */
-export function LiveWire({ points, ariaLabel, className }: LiveWireProps) {
- const gradientId = useId();
-
- const geometry = useMemo(() => {
- const values = points.filter((value) => Number.isFinite(value));
- if (values.length < 2) return null;
-
- const max = Math.max(...values);
- const usableHeight = VIEW_HEIGHT - TOP_PADDING;
- const stepX = VIEW_WIDTH / (values.length - 1);
-
- const coordinates: CurvePoint[] = values.map((value, index) => ({
- x: index * stepX,
- y: VIEW_HEIGHT - (max > 0 ? value / max : 0) * usableHeight,
- }));
-
- const line = buildSmoothLinePath(coordinates, TOP_PADDING, VIEW_HEIGHT);
- const last = coordinates[coordinates.length - 1];
- const area = `${line} L${VIEW_WIDTH} ${VIEW_HEIGHT} L0 ${VIEW_HEIGHT} Z`;
-
- return {
- line,
- area,
- isFlat: max <= 0,
- lastYRatio: last.y / VIEW_HEIGHT,
- };
- }, [points]);
-
- const idle = !geometry || geometry.isFlat;
-
- return (
-
- {idle ? (
-
- ) : (
- <>
-
-
- >
- )}
-
- );
-}
diff --git a/frontend/src/features/dashboard/components/Meter.module.scss b/frontend/src/features/dashboard/components/Meter.module.scss
deleted file mode 100644
index 1748874..0000000
--- a/frontend/src/features/dashboard/components/Meter.module.scss
+++ /dev/null
@@ -1,21 +0,0 @@
-.track {
- position: relative;
- width: 100%;
- height: 6px;
- border-radius: $radius-full;
- overflow: hidden;
- // 轨道 = 填充色的淡化步阶,状态在整条上都可读
- background: color-mix(in srgb, var(--meter-fill) 16%, transparent);
-}
-
-.fill {
- height: 100%;
- border-radius: $radius-full;
- background: var(--meter-fill);
- // 数据刷新时填充平滑走位;曲线继承页面级 token,独立使用时退回 ease
- transition: width 360ms var(--ease-out-strong, ease);
-
- @media (prefers-reduced-motion: reduce) {
- transition: none;
- }
-}
diff --git a/frontend/src/features/dashboard/components/Meter.tsx b/frontend/src/features/dashboard/components/Meter.tsx
deleted file mode 100644
index f4de6d4..0000000
--- a/frontend/src/features/dashboard/components/Meter.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { toneForSuccessRate, type MeterTone } from '../utils';
-import styles from './Meter.module.scss';
-
-const TONE_COLORS: Record = {
- good: 'var(--viz-success, #10b981)',
- warning: 'var(--amber-color)',
- critical: 'var(--viz-failure, #c65746)',
- idle: 'var(--text-quaternary)',
-};
-
-interface MeterProps {
- /** 0–100;null 表示窗口内无请求 */
- value: number | null;
- tone?: MeterTone;
- ariaLabel: string;
- className?: string;
-}
-
-/**
- * 细条计量器:填充色承载严重度,轨道是同色淡化步阶,
- * 因此在整条上都能读出状态。
- */
-export function Meter({ value, tone, ariaLabel, className }: MeterProps) {
- const resolvedTone = tone ?? toneForSuccessRate(value);
- const clamped = value === null ? 0 : Math.max(0, Math.min(100, value));
-
- return (
-
- );
-}
diff --git a/frontend/src/features/dashboard/components/Sparkline.module.scss b/frontend/src/features/dashboard/components/Sparkline.module.scss
deleted file mode 100644
index f00e619..0000000
--- a/frontend/src/features/dashboard/components/Sparkline.module.scss
+++ /dev/null
@@ -1,12 +0,0 @@
-.sparkline {
- display: block;
- width: 100%;
- height: 32px;
- overflow: visible;
-}
-
-.empty {
- width: 100%;
- height: 32px;
- border-bottom: 1px solid var(--border-color);
-}
diff --git a/frontend/src/features/dashboard/components/Sparkline.tsx b/frontend/src/features/dashboard/components/Sparkline.tsx
deleted file mode 100644
index e648608..0000000
--- a/frontend/src/features/dashboard/components/Sparkline.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { useId, useMemo } from 'react';
-import { buildSmoothLinePath } from './curve';
-import styles from './Sparkline.module.scss';
-
-const VIEW_WIDTH = 100;
-const VIEW_HEIGHT = 32;
-/** 顶部留白,避免峰值贴边被裁切 */
-const TOP_PADDING = 3;
-
-interface SparklineProps {
- points: number[];
- /** 折线/填充色,默认取主色 */
- color?: string;
- ariaLabel: string;
- className?: string;
-}
-
-/**
- * 极简迷你折线:2px 线 + 同色 10% 面积。
- * 单序列,因此不需要图例;数值由所在卡片的文本承载。
- */
-export function Sparkline({ points, color, ariaLabel, className }: SparklineProps) {
- const gradientId = useId();
-
- const geometry = useMemo(() => {
- const values = points.filter((value) => Number.isFinite(value));
- if (values.length === 0) {
- return null;
- }
-
- const max = Math.max(...values);
- const usableHeight = VIEW_HEIGHT - TOP_PADDING;
- const stepX = values.length > 1 ? VIEW_WIDTH / (values.length - 1) : 0;
-
- const coordinates = values.map((value, index) => {
- const x = values.length > 1 ? index * stepX : VIEW_WIDTH / 2;
- const ratio = max > 0 ? value / max : 0;
- const y = VIEW_HEIGHT - ratio * usableHeight;
- return { x, y };
- });
-
- const line = buildSmoothLinePath(coordinates, TOP_PADDING, VIEW_HEIGHT);
-
- const first = coordinates[0];
- const last = coordinates[coordinates.length - 1];
- const area = `${line} L${last.x.toFixed(2)} ${VIEW_HEIGHT} L${first.x.toFixed(2)} ${VIEW_HEIGHT} Z`;
-
- return { line, area, isFlat: max <= 0 };
- }, [points]);
-
- if (!geometry) {
- return (
-
- );
- }
-
- const strokeColor = geometry.isFlat
- ? 'var(--text-quaternary)'
- : (color ?? 'var(--primary-color)');
-
- return (
-
- );
-}
diff --git a/frontend/src/features/dashboard/components/ThroughputChart.module.scss b/frontend/src/features/dashboard/components/ThroughputChart.module.scss
deleted file mode 100644
index b01c8f9..0000000
--- a/frontend/src/features/dashboard/components/ThroughputChart.module.scss
+++ /dev/null
@@ -1,378 +0,0 @@
-@use '../../../styles/mixins' as *;
-
-.chart {
- --chart-height: 208px;
- --bar-max-width: 24px;
-
- margin: 0;
- display: flex;
- flex-direction: column;
- gap: $spacing-md;
-}
-
-/* ---------- 图例 ---------- */
-
-.legend {
- display: flex;
- flex-wrap: wrap;
- gap: $spacing-lg;
- font-size: 13px;
- color: var(--text-secondary);
-}
-
-.legendItem {
- display: inline-flex;
- align-items: center;
- gap: $spacing-xs;
-}
-
-.legendValue {
- font-weight: 650;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
-}
-
-.legendSwatch {
- width: 10px;
- height: 10px;
- border-radius: 3px;
- flex: none;
-}
-
-.swatchSuccess {
- background: var(--viz-success);
-}
-
-.swatchFailure {
- background: var(--viz-failure);
-}
-
-/* ---------- 绘图区 ---------- */
-
-.plot {
- display: flex;
- gap: $spacing-sm;
- /* 给峰值直标留出空间:柱子顶到 100% 时标签仍落在这段留白里 */
- padding-top: 22px;
-}
-
-.yAxis {
- position: relative;
- width: 40px;
- height: var(--chart-height);
- flex: none;
-
- @include mobile {
- width: 30px;
- }
-}
-
-.yTick {
- position: absolute;
- right: 0;
- transform: translateY(-50%);
- font-size: 11px;
- color: var(--text-tertiary);
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
-}
-
-.canvas {
- position: relative;
- flex: 1;
- min-width: 0;
- height: var(--chart-height);
-}
-
-.gridlines {
- position: absolute;
- inset: 0;
-}
-
-.gridline {
- position: absolute;
- left: 0;
- right: 0;
- height: 1px;
- background: var(--border-color);
-
- &:last-child {
- background: var(--border-primary);
- }
-}
-
-/* ---------- 柱体 ---------- */
-
-.columns {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: flex-end;
- /*
- * 相邻柱体之间同样保持 2px 表面色间隙。窄屏时槽位宽度小于 24px,
- * 柱体会填满槽位,没有这个 gap 就会连成一片、读起来像面积图。
- */
- gap: 2px;
-}
-
-.column {
- position: relative;
- flex: 1 1 0;
- min-width: 0;
- height: 100%;
- display: flex;
- align-items: flex-end;
- /* 命中区域比柱体本身更大:整列高度都可悬停 */
- cursor: default;
-}
-
-.stack {
- position: relative;
- width: 100%;
- max-width: var(--bar-max-width);
- height: 100%;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- animation: barGrow 0.42s cubic-bezier(0.4, 0, 0.2, 1) both;
- /* 级差由 TSX 按桶数归一化写入,整波 ≤360ms */
- animation-delay: var(--bar-delay, 0ms);
- transform-origin: bottom center;
- transition: filter 120ms ease-out;
-
- @media (prefers-reduced-motion: reduce) {
- animation: none;
- transition: none;
- }
-}
-
-.columnActive .stack {
- filter: brightness(1.06);
-}
-
-.segment {
- display: block;
- width: 100%;
- /* 数据末端 4px 圆角,基线端保持直角 */
- border-radius: $radius-sm $radius-sm 0 0;
-}
-
-.segmentSuccess {
- background: var(--viz-success);
-}
-
-.segmentFailure {
- background: var(--viz-failure);
-}
-
-/* 堆叠段之间留 2px 表面色间隙 —— 用留白而不是描边来分隔 */
-.segmentGap {
- margin-bottom: 2px;
-}
-
-/* 上方还有失败段时,成功段顶部不再圆角 */
-.segmentSquareTop {
- border-radius: 0;
-}
-
-.idleTick {
- display: block;
- width: 100%;
- height: 2px;
- border-radius: $radius-full;
- background: var(--border-primary);
-}
-
-/* bottom 由 TSX 按柱高写入;柱子全部落定后再淡入 */
-.peakLabel {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- margin-bottom: 6px;
- font-size: 11px;
- font-weight: 650;
- color: var(--text-secondary);
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
- pointer-events: none;
- animation: peakFade 240ms ease-out 620ms backwards;
-
- @media (prefers-reduced-motion: reduce) {
- animation: none;
- }
-}
-
-.noRequests {
- position: absolute;
- inset: 0;
- margin: 0;
- display: grid;
- place-items: center;
- font-size: 13px;
- color: var(--text-tertiary);
- pointer-events: none;
-}
-
-/* ---------- 悬浮提示 ---------- */
-
-.tooltip {
- --glass-blur: 10px;
-
- position: absolute;
- top: 4px;
- z-index: 2;
- display: flex;
- flex-direction: column;
- gap: 3px;
- padding: $spacing-sm $spacing-md;
- min-width: 132px;
- border-radius: $radius-md;
- border: 1px solid var(--glass-border);
- background: var(--glass-bg);
- backdrop-filter: var(--glass-backdrop-filter);
- -webkit-backdrop-filter: var(--glass-backdrop-filter);
- box-shadow: var(--shadow-lg);
- font-size: 12px;
- color: var(--text-secondary);
- pointer-events: none;
- /* 悬停换柱时滑到新位置(可中断,CSS transition 自然 retarget),入场只淡入 */
- transition:
- left 200ms var(--ease-out-strong, ease-out),
- transform 200ms var(--ease-out-strong, ease-out),
- opacity 150ms ease-out;
-
- @starting-style {
- opacity: 0;
- }
-
- @media (prefers-reduced-motion: reduce) {
- transition: opacity 150ms ease-out;
- }
-}
-
-.tooltipTime {
- font-weight: 650;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
-}
-
-.tooltipRow {
- display: flex;
- align-items: center;
- gap: $spacing-xs;
-
- b {
- margin-left: auto;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
- }
-}
-
-.tooltipRate {
- margin-top: 2px;
- padding-top: 4px;
- border-top: 1px solid var(--border-color);
- color: var(--text-tertiary);
- font-variant-numeric: tabular-nums;
-}
-
-/* ---------- 横轴 ---------- */
-
-.xAxis {
- position: relative;
- height: 16px;
- margin-left: 48px;
-
- @include mobile {
- margin-left: 38px;
- }
-}
-
-.xTick {
- position: absolute;
- transform: translateX(-50%);
- font-size: 11px;
- color: var(--text-tertiary);
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
-}
-
-/* ---------- 数据表 ---------- */
-
-.tableToggle {
- font-size: 13px;
-}
-
-.table {
- width: 100%;
- border-collapse: collapse;
- font-size: 12px;
- font-variant-numeric: tabular-nums;
-
- th,
- td {
- padding: 5px $spacing-sm;
- text-align: right;
- border-bottom: 1px solid var(--border-color);
- color: var(--text-secondary);
- }
-
- thead th {
- color: var(--text-tertiary);
- font-weight: 600;
- white-space: nowrap;
- }
-
- tbody th[scope='row'] {
- text-align: left;
- font-weight: 500;
- color: var(--text-primary);
- }
-}
-
-/* ---------- 无数据占位 ---------- */
-
-.placeholder {
- display: flex;
- flex-direction: column;
- gap: $spacing-xs;
- padding: $spacing-xl;
- border-radius: $radius-lg;
- border: 1px dashed var(--border-primary);
- background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
- text-align: center;
-}
-
-.placeholderTitle {
- margin: 0;
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.placeholderHint {
- margin: 0;
- font-size: 13px;
- color: var(--text-secondary);
-}
-
-@keyframes barGrow {
- from {
- transform: scaleY(0.02);
- opacity: 0;
- }
- to {
- transform: scaleY(1);
- opacity: 1;
- }
-}
-
-@keyframes peakFade {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
diff --git a/frontend/src/features/dashboard/components/ThroughputChart.tsx b/frontend/src/features/dashboard/components/ThroughputChart.tsx
deleted file mode 100644
index b26e1ce..0000000
--- a/frontend/src/features/dashboard/components/ThroughputChart.tsx
+++ /dev/null
@@ -1,260 +0,0 @@
-import { useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Collapsible } from '@/components/ui/Collapsible';
-import { formatPercent } from '@/utils/format';
-import { TRAFFIC_BUCKET_MINUTES, type TrafficWindow } from '../types';
-import { axisMax } from '../utils';
-import styles from './ThroughputChart.module.scss';
-
-/** 纵轴刻度条数(含 0),即 TICK_COUNT - 1 个间隔 */
-const TICK_COUNT = 5;
-
-/**
- * 桶时间标签。后端返回的是服务器本地时间字符串("15:04-15:14"),
- * 优先使用它,而不是用浏览器时钟反推,避免时区不一致。
- */
-function bucketRangeLabel(time: string | undefined, index: number, count: number): string {
- if (time) return time;
- const minutesAgo = (count - index) * TRAFFIC_BUCKET_MINUTES;
- return `-${minutesAgo}m`;
-}
-
-function bucketStartLabel(time: string | undefined, index: number, count: number): string {
- if (!time) return bucketRangeLabel(time, index, count);
- const [start] = time.split('-');
- return start?.trim() || time;
-}
-
-interface ThroughputChartProps {
- traffic: TrafficWindow;
-}
-
-export function ThroughputChart({ traffic }: ThroughputChartProps) {
- const { t } = useTranslation();
- const [activeIndex, setActiveIndex] = useState(null);
-
- const { buckets, totalSuccess, totalFailure, total, peakIndex, peakTotal } = traffic;
- const scaleMax = useMemo(() => axisMax(peakTotal, TICK_COUNT - 1), [peakTotal]);
-
- const ticks = useMemo(
- () =>
- Array.from({ length: TICK_COUNT }, (_, index) => {
- const ratio = 1 - index / (TICK_COUNT - 1);
- return { ratio, value: Math.round(scaleMax * ratio) };
- }),
- [scaleMax]
- );
-
- const xAxisTicks = useMemo(() => {
- if (buckets.length === 0) return [];
- const positions = [0, Math.floor((buckets.length - 1) / 2), buckets.length - 1];
- return Array.from(new Set(positions)).map((index) => ({
- index,
- label: bucketStartLabel(buckets[index]?.time, index, buckets.length),
- }));
- }, [buckets]);
-
- const summary = t('dashboard.traffic_chart_summary', {
- total,
- success: totalSuccess,
- failed: totalFailure,
- minutes: traffic.windowMinutes,
- });
-
- const activeBucket = activeIndex === null ? null : buckets[activeIndex];
- const activeTotal = activeBucket ? activeBucket.success + activeBucket.failed : 0;
-
- if (buckets.length === 0) {
- return (
-
-
{t('dashboard.traffic_unavailable')}
-
{t('dashboard.traffic_unavailable_hint')}
-
- );
- }
-
- return (
-
- {/* 两条序列 → 图例常驻,并直接带上数值(浅色主题下绿色对比度偏低,数值即为补偿) */}
-
-
-
- {t('stats.success')}
- {totalSuccess.toLocaleString()}
-
-
-
- {t('stats.failure')}
- {totalFailure.toLocaleString()}
-
-
-
-
-
- {ticks.map((tick) => (
-
- {tick.value.toLocaleString()}
-
- ))}
-
-
-
-
- {ticks.map((tick) => (
-
- ))}
-
-
-
setActiveIndex(null)}
- >
- {buckets.map((bucket, index) => {
- const bucketTotal = bucket.success + bucket.failed;
- const successHeight = (bucket.success / scaleMax) * 100;
- const failureHeight = (bucket.failed / scaleMax) * 100;
- const hasBoth = bucket.success > 0 && bucket.failed > 0;
- /* 级差按桶数归一化:不管窗口多长,整波入场都收在 360ms 内 */
- const barDelayMs =
- buckets.length > 1 ? Math.round((index / (buckets.length - 1)) * 360) : 0;
-
- return (
-
setActiveIndex(index)}
- onClick={() => setActiveIndex((current) => (current === index ? null : index))}
- >
- {/* 峰值直标放在 scaleY 容器之外,避免入场时被一起挤压 */}
- {index === peakIndex && peakTotal > 0 && (
-
- {peakTotal.toLocaleString()}
-
- )}
-
- {bucket.failed > 0 && (
-
- )}
- {bucket.success > 0 && (
- 0 ? styles.segmentSquareTop : ''
- }`}
- style={{ height: `${successHeight}%` }}
- />
- )}
- {bucketTotal === 0 && }
-
-
- );
- })}
-
-
- {total === 0 &&
{t('status_bar.no_requests')}
}
-
- {activeBucket && (
-
buckets.length * 0.85
- ? 'translateX(-88%)'
- : 'translateX(-50%)',
- }}
- role="status"
- >
-
- {bucketRangeLabel(activeBucket.time, activeIndex!, buckets.length)}
-
-
-
- {t('stats.success')}
- {activeBucket.success.toLocaleString()}
-
-
-
- {t('stats.failure')}
- {activeBucket.failed.toLocaleString()}
-
-
- {activeTotal > 0
- ? formatPercent((activeBucket.success / activeTotal) * 100)
- : t('status_bar.no_requests')}
-
-
- )}
-
-
-
-
- {xAxisTicks.map((tick) => (
-
- {tick.label}
-
- ))}
-
-
-
-
-
-
- | {t('dashboard.traffic_table_window')} |
- {t('stats.success')} |
- {t('stats.failure')} |
- {t('dashboard.success_rate')} |
-
-
-
- {buckets.map((bucket, index) => {
- const bucketTotal = bucket.success + bucket.failed;
- return (
-
- | {bucketRangeLabel(bucket.time, index, buckets.length)} |
- {bucket.success.toLocaleString()} |
- {bucket.failed.toLocaleString()} |
-
- {bucketTotal > 0 ? formatPercent((bucket.success / bucketTotal) * 100) : '—'}
- |
-
- );
- })}
-
-
-
-
- );
-}
diff --git a/frontend/src/features/dashboard/components/curve.ts b/frontend/src/features/dashboard/components/curve.ts
deleted file mode 100644
index fc19ced..0000000
--- a/frontend/src/features/dashboard/components/curve.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-export interface CurvePoint {
- x: number;
- y: number;
-}
-
-/**
- * Catmull-Rom → 三次贝塞尔的平滑折线。
- * 控制点的 y 被夹在 [minY, maxY] 内,避免尖峰处的曲线越过基线或顶边。
- */
-export function buildSmoothLinePath(points: CurvePoint[], minY: number, maxY: number): string {
- if (points.length === 0) return '';
- if (points.length === 1) {
- return `M${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
- }
-
- const clampY = (value: number) => Math.max(minY, Math.min(maxY, value));
- let path = `M${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
-
- for (let index = 0; index < points.length - 1; index += 1) {
- const p0 = points[Math.max(0, index - 1)];
- const p1 = points[index];
- const p2 = points[index + 1];
- const p3 = points[Math.min(points.length - 1, index + 2)];
-
- const cp1x = p1.x + (p2.x - p0.x) / 6;
- const cp1y = clampY(p1.y + (p2.y - p0.y) / 6);
- const cp2x = p2.x - (p3.x - p1.x) / 6;
- const cp2y = clampY(p2.y - (p3.y - p1.y) / 6);
-
- path += ` C${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
- }
-
- return path;
-}
diff --git a/frontend/src/features/dashboard/dashboard.module.scss b/frontend/src/features/dashboard/dashboard.module.scss
deleted file mode 100644
index ee6e13b..0000000
--- a/frontend/src/features/dashboard/dashboard.module.scss
+++ /dev/null
@@ -1,985 +0,0 @@
-@use '../../styles/mixins' as *;
-
-/* 可视化配色与动效 tokens(--viz-*、--ease-out-strong、--dur-*)已提升到 themes.scss :root。 */
-.page {
- position: relative;
- max-width: 1120px;
- width: 100%;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- gap: clamp(44px, 6.5vh, 76px);
-}
-
-/* ---------- 环境层(ambient)---------- */
-
-/*
- * 页面被 .page-transition(overflow: hidden)裁切,环境层绝不能伸出页面顶端,
- * 否则会在容器上缘被硬切出一条分界线。因此这里不做越界定位:
- * 强度用 mask 从顶端 0 平滑升起,任何祖先在哪裁切都只会切到零强度区。
- */
-.ambient {
- position: absolute;
- inset: 0 -40px auto;
- height: 560px;
- z-index: 0;
- overflow: hidden;
- pointer-events: none;
-}
-
-/* 环境层收敛为一道极淡的顶部绿色 wash:绿色只属于"活着的流量"。
- * radial-gradient 本身就是连续的,不需要 blur(blur 的溢出反而会被裁出硬边)。 */
-.washTop {
- position: absolute;
- top: 0;
- left: 50%;
- width: min(960px, 96vw);
- height: 480px;
- transform: translateX(-50%);
- background: radial-gradient(
- ellipse 75% 70% at 50% 18%,
- color-mix(in srgb, var(--viz-success) 10%, transparent),
- transparent 70%
- );
- mask-image: linear-gradient(to bottom, transparent, #000 26%, #000 62%, transparent);
- -webkit-mask-image: linear-gradient(to bottom, transparent, #000 26%, #000 62%, transparent);
-}
-
-.gridWash {
- position: absolute;
- inset: 0;
- background-image:
- linear-gradient(to right, var(--border-color) 1px, transparent 1px),
- linear-gradient(to bottom, var(--border-color) 1px, transparent 1px);
- background-size: 72px 72px;
- opacity: 0.24;
- /* 同样从顶端 0 淡入(intersect 取两层交集),避免网格在裁切线处满强度起步 */
- mask-image:
- linear-gradient(to bottom, transparent, #000 48px),
- radial-gradient(ellipse 70% 60% at 50% 0%, #000 0%, transparent 78%);
- -webkit-mask-image:
- linear-gradient(to bottom, transparent, #000 48px),
- radial-gradient(ellipse 70% 60% at 50% 0%, #000 0%, transparent 78%);
- mask-composite: intersect;
- -webkit-mask-composite: source-in;
-}
-
-/* ---------- Hero ---------- */
-
-.hero {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr);
- gap: clamp($spacing-lg, 4vw, 56px);
- align-items: center;
- padding-top: clamp($spacing-md, 3vh, 40px);
- /* 底部留出脉搏线的呼吸空间 */
- padding-bottom: clamp(64px, 11vh, 120px);
-
- @media (max-width: 900px) {
- grid-template-columns: minmax(0, 1fr);
- padding-bottom: clamp(48px, 8vh, 72px);
- }
-}
-
-/* 签名元素:横贯 hero 底部的实时流量脉搏线 */
-.heroWire {
- position: absolute;
- left: 0;
- right: 0;
- bottom: -10px;
- height: clamp(88px, 15vh, 160px);
- z-index: 0;
- pointer-events: none;
-}
-
-.heroCopy {
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: $spacing-md;
- min-width: 0;
-}
-
-/* 判词标题:文案来自实时状态,短,所以敢放大 */
-.heroTitle {
- margin: 0;
- font-size: clamp(44px, 6.2vw, 80px);
- line-height: 1.05;
- font-weight: 700;
- letter-spacing: -0.035em;
- color: var(--text-primary);
- text-wrap: balance;
-}
-
-/* 句号即状态灯,颜色由 TSX 按语义色注入 */
-.heroPeriod {
- display: inline;
- margin-inline-start: clamp(4px, 0.08em, 7px);
- letter-spacing: 0;
-}
-
-/* 有活流量时状态灯极缓呼吸(仅 opacity,inline 元素安全) */
-.heroPeriodLive {
- animation: periodBreath 3.2s ease-in-out 1.8s infinite;
-
- @media (prefers-reduced-motion: reduce) {
- animation: none;
- }
-}
-
-.heroMeta {
- margin: 0;
- font-family: $font-mono;
- font-size: 13px;
- letter-spacing: 0.02em;
- color: var(--text-secondary);
- font-variant-numeric: tabular-nums;
-}
-
-.heroActions {
- display: flex;
- flex-wrap: wrap;
- gap: $spacing-sm;
- margin-top: $spacing-xs;
-}
-
-.primaryAction,
-.ghostAction {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 11px 20px;
- border-radius: $radius-full;
- font-size: 14px;
- font-weight: 600;
- text-decoration: none;
- transition:
- transform var(--dur-press) var(--ease-out-strong),
- background-color $transition-fast,
- border-color $transition-fast,
- box-shadow var(--dur-hover) var(--ease-out-strong),
- color $transition-fast;
-
- /* 按下即回应:不等 click,指针落下就缩 */
- &:active {
- transform: scale(0.97);
- }
-
- @media (prefers-reduced-motion: reduce) {
- transition: none;
-
- &:active {
- transform: none;
- }
- }
-}
-
-/* 墨色药丸:浅色主题下是深墨,深色主题自动反相为浅纸 */
-.primaryAction {
- background: var(--text-primary);
- color: var(--bg-secondary);
- border: 1px solid transparent;
-
- @media (hover: hover) and (pointer: fine) {
- &:hover {
- transform: translateY(-1px);
- background: color-mix(in srgb, var(--text-primary) 86%, var(--bg-secondary));
- box-shadow: 0 12px 26px color-mix(in srgb, var(--text-primary) 22%, transparent);
- }
- }
-
- /* 悬停态下按压:抬升让位于回缩 */
- &:active {
- transform: translateY(0) scale(0.97);
- }
-
- @media (prefers-reduced-motion: reduce) {
- @media (hover: hover) and (pointer: fine) {
- &:hover {
- transform: none;
- }
- }
- }
-}
-
-/* 次要动作降为安静的文字链接:不抬升,箭头代它点头 */
-.ghostAction {
- padding-left: $spacing-xs;
- padding-right: $spacing-xs;
- border: none;
- color: var(--text-secondary);
- background: transparent;
-
- @media (hover: hover) and (pointer: fine) {
- &:hover {
- color: var(--text-primary);
- background: transparent;
-
- .linkArrow {
- transform: translateX(3px);
- }
- }
- }
-}
-
-/* 文字链接末尾的箭头:hover 时前移 3px,暗示去向 */
-.linkArrow {
- display: inline-block;
- transition: transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
-
- @media (prefers-reduced-motion: reduce) {
- transition: none;
- transform: none !important;
- }
-}
-
-/* Hero 数字仪表卡 —— 悬浮在脉搏线之上的玻璃面板 */
-.heroPanel {
- --glass-blur: 14px;
-
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- gap: $spacing-xs;
- padding: clamp($spacing-lg, 2.5vw, 28px);
- border-radius: 20px;
- border: 1px solid var(--glass-border);
- background: linear-gradient(
- 145deg,
- color-mix(in srgb, var(--bg-primary) 90%, transparent),
- color-mix(in srgb, var(--bg-secondary) 74%, transparent)
- );
- backdrop-filter: var(--glass-backdrop-filter);
- -webkit-backdrop-filter: var(--glass-backdrop-filter);
- box-shadow: var(--shadow-lg);
-}
-
-.heroPanelTop {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $spacing-sm;
-}
-
-.heroPanelLabel {
- font-family: $font-mono;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--text-tertiary);
-}
-
-.liveBadge {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 3px 9px;
- border-radius: $radius-full;
- border: 1px solid color-mix(in srgb, var(--viz-success) 35%, transparent);
- background: color-mix(in srgb, var(--viz-success) 8%, transparent);
- font-family: $font-mono;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--success-badge-text);
-}
-
-.liveDot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--viz-success);
- animation: livePing 2s ease-out infinite;
-
- @media (prefers-reduced-motion: reduce) {
- animation: none;
- }
-}
-
-/* 遥测数字统一等宽:这是 CLI 代理,mono 是它的母语 */
-.heroFigure {
- font-family: $font-mono;
- font-size: clamp(42px, 5.6vw, 62px);
- line-height: 1.05;
- font-weight: 600;
- letter-spacing: -0.02em;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
-}
-
-.heroPanelMeta {
- font-family: $font-mono;
- font-size: 12px;
- color: var(--text-secondary);
-}
-
-/* 成功/失败构成比例条 */
-.ratioBar {
- display: flex;
- gap: 2px;
- height: 6px;
- margin-top: $spacing-sm;
-}
-
-.ratioSegment {
- display: block;
- min-width: 4px;
- border-radius: 3px;
-}
-
-.heroSplit {
- display: flex;
- flex-wrap: wrap;
- gap: $spacing-md;
- padding-top: $spacing-sm;
- border-top: 1px solid var(--border-color);
- font-size: 12px;
- color: var(--text-secondary);
-}
-
-.heroSplitItem {
- display: inline-flex;
- align-items: center;
- gap: 6px;
-
- b {
- font-family: $font-mono;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
- }
-}
-
-.splitSwatch {
- width: 8px;
- height: 8px;
- border-radius: 2px;
- flex: none;
-}
-
-.splitSuccess {
- background: var(--viz-success);
-}
-
-.splitFailure {
- background: var(--viz-failure);
-}
-
-/* ---------- KPI 行 ---------- */
-
-.statsRow {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: $spacing-md;
-
- @media (max-width: 900px) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- @media (max-width: 460px) {
- grid-template-columns: minmax(0, 1fr);
- }
-}
-
-.statTile {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: $spacing-lg;
- border-radius: 14px;
- border: 1px solid var(--border-color);
- background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
- transition: border-color $transition-fast;
-
- /* 左上角色签:语义色调由 --tile-accent 注入 */
- &::before {
- content: '';
- width: 28px;
- height: 3px;
- margin-bottom: 2px;
- border-radius: $radius-full;
- background: var(--tile-accent, var(--border-hover));
- }
-
- /* 卡片不可点击,因此不抬升不投影——悬停只轻描边框辅助聚焦 */
- @media (hover: hover) and (pointer: fine) {
- &:hover {
- border-color: var(--border-hover);
- }
- }
-
- @media (prefers-reduced-motion: reduce) {
- transition: none;
- }
-}
-
-.statLabel {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary);
-}
-
-.statValue {
- font-family: $font-mono;
- font-size: 28px;
- line-height: 1.1;
- font-weight: 600;
- letter-spacing: -0.01em;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
-}
-
-.statMeter {
- margin: 4px 0 2px;
-}
-
-.statHint {
- font-size: 12px;
- line-height: 1.45;
- color: var(--text-tertiary);
-}
-
-/* ---------- 通用区块 ---------- */
-
-.section {
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- gap: $spacing-lg;
-}
-
-.sectionHead {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-/* 终端光标前缀是全页的结构记号 */
-.eyebrow {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- font-family: $font-mono;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--text-tertiary);
-
- &::before {
- content: '▍';
- color: var(--viz-success);
- font-size: 12px;
- line-height: 1;
- }
-}
-
-.sectionTitle {
- margin: 0;
- font-size: clamp(22px, 2.6vw, 30px);
- line-height: 1.18;
- font-weight: 650;
- letter-spacing: -0.02em;
- color: var(--text-primary);
- text-wrap: balance;
-}
-
-.sectionDescription {
- margin: 0;
- max-width: 64ch;
- font-size: 14px;
- line-height: 1.6;
- color: var(--text-secondary);
-}
-
-.panel {
- display: flex;
- flex-direction: column;
- gap: $spacing-md;
- padding: clamp($spacing-lg, 2.4vw, 28px);
- border-radius: 16px;
- border: 1px solid var(--border-color);
- background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
-}
-
-.panelHead {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.panelTitle {
- margin: 0;
- font-size: 18px;
- font-weight: 650;
- letter-spacing: -0.01em;
- color: var(--text-primary);
-}
-
-.panelLink {
- align-self: flex-start;
- margin-top: auto;
- font-size: 13px;
- font-weight: 600;
- color: var(--primary-active);
- text-decoration: none;
-
- @media (hover: hover) and (pointer: fine) {
- &:hover {
- text-decoration: underline;
-
- .linkArrow {
- transform: translateX(3px);
- }
- }
- }
-}
-
-.emptyNote {
- margin: 0;
- padding: $spacing-lg 0;
- text-align: center;
- font-size: 13px;
- color: var(--text-tertiary);
-}
-
-/* ---------- 供应商列表 ---------- */
-
-.fleetList {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
-}
-
-.fleetRow {
- display: grid;
- grid-template-columns: auto minmax(0, 1.3fr) minmax(80px, 1fr) auto minmax(96px, 0.7fr);
- align-items: center;
- gap: $spacing-md;
- padding: $spacing-md 0;
- border-bottom: 1px solid var(--border-color);
-
- &:last-child {
- border-bottom: none;
- }
-
- @media (max-width: 760px) {
- grid-template-columns: minmax(0, 1fr) auto;
- row-gap: $spacing-sm;
- }
-}
-
-/* 列表按流量排序,位次是真实信息 */
-.fleetRank {
- font-family: $font-mono;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-quaternary);
- font-variant-numeric: tabular-nums;
-
- @media (max-width: 760px) {
- display: none;
- }
-}
-
-.fleetIdentity {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
-}
-
-.fleetName {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
- @include text-ellipsis;
-}
-
-.fleetMeta {
- font-size: 12px;
- color: var(--text-tertiary);
-}
-
-.fleetSpark {
- height: 28px;
-
- @media (max-width: 760px) {
- grid-column: 1 / -1;
- order: 3;
- }
-}
-
-.fleetNumbers {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 1px;
-}
-
-.fleetTotal {
- font-family: $font-mono;
- font-size: 15px;
- font-weight: 600;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
-}
-
-.fleetTotalLabel {
- font-size: 11px;
- color: var(--text-tertiary);
-}
-
-.fleetRate {
- display: flex;
- flex-direction: column;
- gap: 5px;
-}
-
-.fleetRateValue {
- font-family: $font-mono;
- font-size: 12.5px;
- font-weight: 600;
- color: var(--text-secondary);
- font-variant-numeric: tabular-nums;
-}
-
-.fleetMeter {
- height: 5px;
-}
-
-/* ---------- 双栏细节区 ---------- */
-
-.detailGrid {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: $spacing-md;
-
- @media (max-width: 900px) {
- grid-template-columns: minmax(0, 1fr);
- }
-}
-
-/* 分段条:相邻色块之间用 2px 表面色间隙分隔 */
-.healthBar {
- display: flex;
- gap: 2px;
- height: 10px;
- margin-top: $spacing-xs;
-}
-
-.healthSegment {
- display: block;
- border-radius: 3px;
- min-width: 4px;
-}
-
-.healthActive {
- background: var(--viz-success);
-}
-
-.healthUnavailable {
- background: var(--amber-color);
-}
-
-.healthDisabled {
- background: var(--text-quaternary);
-}
-
-.healthLegend {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-wrap: wrap;
- gap: $spacing-md;
- font-size: 12px;
- color: var(--text-secondary);
-
- li {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- }
-
- b {
- font-family: $font-mono;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
- }
-}
-
-.healthKey {
- width: 8px;
- height: 8px;
- border-radius: 2px;
- flex: none;
-}
-
-.typeBreakdown {
- display: flex;
- flex-direction: column;
- gap: $spacing-sm;
- padding-top: $spacing-md;
- border-top: 1px solid var(--border-color);
-}
-
-.typeBreakdownLabel {
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--text-tertiary);
-}
-
-.typeList {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-.typeChip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 10px;
- border-radius: $radius-full;
- border: 1px solid var(--border-color);
- background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
- font-size: 12px;
- color: var(--text-secondary);
-
- b {
- font-family: $font-mono;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
- }
-}
-
-/* ---------- 运行参数表 ---------- */
-
-.specList {
- margin: 0;
- display: flex;
- flex-direction: column;
-}
-
-.specRow {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: $spacing-md;
- padding: 9px 0;
- border-bottom: 1px solid var(--border-color);
-
- &:last-child {
- border-bottom: none;
- }
-}
-
-.specLabel {
- font-size: 13px;
- color: var(--text-secondary);
-}
-
-.specValue {
- margin: 0;
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary);
- text-align: right;
- min-width: 0;
- font-variant-numeric: tabular-nums;
- @include text-ellipsis;
-}
-
-.specMono {
- font-family: $font-mono;
- font-weight: 500;
- font-size: 12px;
-}
-
-.toggleList {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-
-.togglePill {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 10px;
- border-radius: $radius-full;
- border: 1px solid var(--border-color);
- font-size: 12px;
- color: var(--text-secondary);
-
- b {
- font-weight: 650;
- }
-}
-
-.toggleOn {
- border-color: color-mix(in srgb, var(--viz-success) 40%, transparent);
- background: color-mix(in srgb, var(--viz-success) 10%, transparent);
-
- b {
- color: var(--success-badge-text);
- }
-}
-
-.toggleOff {
- background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
-
- b {
- color: var(--text-tertiary);
- }
-}
-
-/* ---------- CTA ---------- */
-
-.ctaGrid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: $spacing-md;
-
- @media (max-width: 900px) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- @include mobile {
- grid-template-columns: minmax(0, 1fr);
- }
-}
-
-.ctaCard {
- position: relative;
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: $spacing-lg;
- border-radius: 14px;
- border: 1px solid var(--border-color);
- background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
- text-decoration: none;
- overflow: hidden;
- transition:
- transform var(--dur-press) var(--ease-out-strong),
- border-color $transition-fast,
- box-shadow var(--dur-hover) var(--ease-out-strong);
-
- @media (hover: hover) and (pointer: fine) {
- &:hover {
- transform: translateY(-2px);
- border-color: var(--border-hover);
- box-shadow: 0 14px 30px rgb(0 0 0 / 0.12);
-
- .ctaArrow {
- transform: translateX(3px);
- opacity: 1;
- }
- }
- }
-
- /* 整卡可点:按下回缩,面积大所以比按钮更含蓄 */
- &:active {
- transform: translateY(0) scale(0.98);
- }
-
- @media (prefers-reduced-motion: reduce) {
- transition: none;
-
- &:active {
- transform: none;
- }
-
- @media (hover: hover) and (pointer: fine) {
- &:hover {
- transform: none;
-
- .ctaArrow {
- transform: none;
- }
- }
- }
- }
-}
-
-.ctaIcon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 36px;
- height: 36px;
- margin-bottom: $spacing-xs;
- border-radius: $radius-md;
- border: 1px solid var(--border-color);
- background: color-mix(in srgb, var(--text-primary) 6%, transparent);
- color: var(--text-primary);
-}
-
-.ctaTitle {
- font-size: 15px;
- font-weight: 650;
- color: var(--text-primary);
-}
-
-.ctaDescription {
- font-size: 13px;
- line-height: 1.5;
- color: var(--text-secondary);
-}
-
-.ctaArrow {
- position: absolute;
- top: $spacing-lg;
- right: $spacing-lg;
- font-size: 15px;
- color: var(--text-tertiary);
- opacity: 0.55;
- transition:
- transform var(--dur-hover) var(--ease-out-strong),
- opacity var(--dur-hover) var(--ease-out-strong);
-}
-
-@keyframes periodBreath {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.45;
- }
-}
-
-@keyframes livePing {
- 0% {
- box-shadow: 0 0 0 0 color-mix(in srgb, var(--viz-success) 40%, transparent);
- }
- 70% {
- box-shadow: 0 0 0 6px transparent;
- }
- 100% {
- box-shadow: 0 0 0 0 transparent;
- }
-}
diff --git a/frontend/src/features/dashboard/hooks/useDashboardOverview.ts b/frontend/src/features/dashboard/hooks/useDashboardOverview.ts
deleted file mode 100644
index cce98d1..0000000
--- a/frontend/src/features/dashboard/hooks/useDashboardOverview.ts
+++ /dev/null
@@ -1,308 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { authFilesApi } from '@/services/api';
-import { useAuthStore, useConfigStore, useModelsStore } from '@/stores';
-import { useApiKeysForModels } from '@/hooks/useApiKeysForModels';
-import { useProviderRecentRequests } from '@/components/providers/hooks/useProviderRecentRequests';
-import {
- mergeRecentRequestBucketGroups,
- normalizeRecentRequestUsageEntry,
- type RecentRequestBucket,
-} from '@/utils/recentRequests';
-import type { Config } from '@/types';
-import type { AuthFileItem } from '@/types/authFile';
-import {
- TRAFFIC_BUCKET_MINUTES,
- type CredentialHealth,
- type DashboardCounts,
- type ProviderTraffic,
- type TrafficWindow,
-} from '../types';
-
-const EMPTY_TRAFFIC: TrafficWindow = {
- buckets: [],
- totalSuccess: 0,
- totalFailure: 0,
- total: 0,
- successRate: null,
- peakTotal: 0,
- peakIndex: -1,
- activeBuckets: 0,
- windowMinutes: 0,
-};
-
-/** `api-key-usage` 的键形如 `|`,取第一个分隔符之后的部分 */
-const apiKeyFromCompositeKey = (compositeKey: string): string => {
- const separatorIndex = compositeKey.indexOf('|');
- return separatorIndex < 0 ? '' : compositeKey.slice(separatorIndex + 1).trim();
-};
-
-const providerIdOfAuthFile = (file: AuthFileItem): string => {
- const candidate = String(file.type ?? file.provider ?? '')
- .trim()
- .toLowerCase();
- return candidate && candidate !== 'empty' ? candidate : 'unknown';
-};
-
-const buildTrafficWindow = (bucketGroups: RecentRequestBucket[][]): TrafficWindow => {
- const buckets = mergeRecentRequestBucketGroups(bucketGroups);
- if (buckets.length === 0) {
- return EMPTY_TRAFFIC;
- }
-
- let totalSuccess = 0;
- let totalFailure = 0;
- let peakTotal = 0;
- let peakIndex = -1;
- let activeBuckets = 0;
-
- buckets.forEach((bucket, index) => {
- const bucketTotal = bucket.success + bucket.failed;
- totalSuccess += bucket.success;
- totalFailure += bucket.failed;
- if (bucketTotal > 0) {
- activeBuckets += 1;
- }
- if (bucketTotal > peakTotal) {
- peakTotal = bucketTotal;
- peakIndex = index;
- }
- });
-
- const total = totalSuccess + totalFailure;
-
- return {
- buckets,
- totalSuccess,
- totalFailure,
- total,
- successRate: total > 0 ? (totalSuccess / total) * 100 : null,
- peakTotal,
- peakIndex,
- activeBuckets,
- windowMinutes: buckets.length * TRAFFIC_BUCKET_MINUTES,
- };
-};
-
-interface ProviderAccumulator {
- credentials: number;
- success: number;
- failure: number;
- bucketGroups: RecentRequestBucket[][];
-}
-
-const createAccumulator = (): ProviderAccumulator => ({
- credentials: 0,
- success: 0,
- failure: 0,
- bucketGroups: [],
-});
-
-export const getProviderKeyCounts = (config: Config) => ({
- gemini: config.geminiApiKeys?.length ?? 0,
- interactions: config.interactionsApiKeys?.length ?? 0,
- codex: config.codexApiKeys?.length ?? 0,
- xai: config.xaiApiKeys?.length ?? 0,
- claude: config.claudeApiKeys?.length ?? 0,
- vertex: config.vertexApiKeys?.length ?? 0,
- openai: config.openaiCompatibility?.length ?? 0,
-});
-
-/**
- * 汇总仪表盘所需的全部数据。
- *
- * 流量数据有两个互不重叠的来源:`api-key-usage`(配置内联的 API Key 凭证)
- * 与 `auth-files`(文件/运行时凭证)。后端对二者的判定条件互斥,但插件提供的
- * 凭证理论上可同时命中,因此这里按 `account_type` + `account` 做一次防御性去重。
- */
-export function useDashboardOverview() {
- const connectionStatus = useAuthStore((state) => state.connectionStatus);
- const apiBase = useAuthStore((state) => state.apiBase);
- const config = useConfigStore((state) => state.config);
- const fetchConfig = useConfigStore((state) => state.fetchConfig);
-
- const models = useModelsStore((state) => state.models);
- const modelsLoading = useModelsStore((state) => state.loading);
- const modelsError = useModelsStore((state) => state.error);
- const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
-
- const connected = connectionStatus === 'connected';
- const resolveApiKeysForModels = useApiKeysForModels();
-
- const { usageByProvider, refreshRecentRequests } = useProviderRecentRequests({
- enabled: connected,
- });
-
- const [authFiles, setAuthFiles] = useState(null);
- const [authFilesLoading, setAuthFilesLoading] = useState(false);
-
- const loadAuthFiles = useCallback(async () => {
- if (!connected) return;
- setAuthFilesLoading(true);
- try {
- const response = await authFilesApi.list();
- setAuthFiles(response.files);
- } catch {
- setAuthFiles(null);
- } finally {
- setAuthFilesLoading(false);
- }
- }, [connected]);
-
- const loadModels = useCallback(async () => {
- if (!connected || !apiBase) return;
- try {
- const apiKeys = await resolveApiKeysForModels();
- await fetchModelsFromStore(apiBase, apiKeys[0]);
- } catch {
- // 模型列表失败不应影响仪表盘其余部分
- }
- }, [connected, apiBase, resolveApiKeysForModels, fetchModelsFromStore]);
-
- useEffect(() => {
- if (!connected) return;
- void fetchConfig().catch(() => undefined);
- void loadAuthFiles();
- void loadModels();
- }, [connected, fetchConfig, loadAuthFiles, loadModels]);
-
- const refresh = useCallback(async () => {
- if (!connected) return;
- await Promise.allSettled([
- fetchConfig(true),
- loadAuthFiles(),
- loadModels(),
- refreshRecentRequests(),
- ]);
- }, [connected, fetchConfig, loadAuthFiles, loadModels, refreshRecentRequests]);
-
- const providerKeyCounts = useMemo(() => (config ? getProviderKeyCounts(config) : null), [config]);
-
- const { traffic, providers } = useMemo(() => {
- const accumulators = new Map();
- const allBucketGroups: RecentRequestBucket[][] = [];
- const apiKeysFromUsage = new Set();
-
- const accumulatorFor = (providerId: string): ProviderAccumulator => {
- const existing = accumulators.get(providerId);
- if (existing) return existing;
- const created = createAccumulator();
- accumulators.set(providerId, created);
- return created;
- };
-
- usageByProvider.forEach((entriesByKey, providerId) => {
- const accumulator = accumulatorFor(providerId);
- entriesByKey.forEach((entry, compositeKey) => {
- const apiKey = apiKeyFromCompositeKey(compositeKey);
- if (apiKey) {
- apiKeysFromUsage.add(apiKey);
- }
- accumulator.credentials += 1;
- accumulator.success += entry.success;
- accumulator.failure += entry.failed;
- if (entry.recentRequests.length > 0) {
- accumulator.bucketGroups.push(entry.recentRequests);
- allBucketGroups.push(entry.recentRequests);
- }
- });
- });
-
- (authFiles ?? []).forEach((file) => {
- const accountType = String(file.account_type ?? '')
- .trim()
- .toLowerCase();
- const account = String(file.account ?? '').trim();
- // 已经由 api-key-usage 统计过的凭证不再重复计入
- if (accountType === 'api_key' && account && apiKeysFromUsage.has(account)) {
- return;
- }
-
- const accumulator = accumulatorFor(providerIdOfAuthFile(file));
- const entry = normalizeRecentRequestUsageEntry(file);
- accumulator.credentials += 1;
- accumulator.success += entry.success;
- accumulator.failure += entry.failed;
- if (entry.recentRequests.length > 0) {
- accumulator.bucketGroups.push(entry.recentRequests);
- allBucketGroups.push(entry.recentRequests);
- }
- });
-
- const providerRows: ProviderTraffic[] = Array.from(accumulators.entries())
- .map(([id, accumulator]) => {
- const total = accumulator.success + accumulator.failure;
- return {
- id,
- credentials: accumulator.credentials,
- success: accumulator.success,
- failure: accumulator.failure,
- total,
- successRate: total > 0 ? (accumulator.success / total) * 100 : null,
- buckets: mergeRecentRequestBucketGroups(accumulator.bucketGroups),
- };
- })
- .sort(
- (a, b) => b.total - a.total || b.credentials - a.credentials || a.id.localeCompare(b.id)
- );
-
- return {
- traffic: buildTrafficWindow(allBucketGroups),
- providers: providerRows,
- };
- }, [usageByProvider, authFiles]);
-
- const credentials = useMemo(() => {
- if (!authFiles) return null;
-
- let disabled = 0;
- let unavailable = 0;
- const countsByType = new Map();
-
- authFiles.forEach((file) => {
- if (file.disabled) {
- disabled += 1;
- } else if (file.unavailable) {
- unavailable += 1;
- }
- const type = providerIdOfAuthFile(file);
- countsByType.set(type, (countsByType.get(type) ?? 0) + 1);
- });
-
- return {
- total: authFiles.length,
- active: authFiles.length - disabled - unavailable,
- disabled,
- unavailable,
- byType: Array.from(countsByType.entries())
- .map(([type, count]) => ({ type, count }))
- .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)),
- };
- }, [authFiles]);
-
- const counts = useMemo(
- () => ({
- managementKeys: config ? (config.apiKeys?.length ?? 0) : null,
- providerKeys: providerKeyCounts
- ? Object.values(providerKeyCounts).reduce((sum, count) => sum + count, 0)
- : null,
- credentials: authFiles ? authFiles.length : null,
- models: modelsLoading || modelsError ? null : models.length,
- }),
- [config, providerKeyCounts, authFiles, models.length, modelsLoading, modelsError]
- );
-
- return {
- connectionStatus,
- connected,
- config,
- counts,
- providerKeyCounts,
- traffic,
- providers,
- credentials,
- /** 首屏骨架的判定:配置与凭证都还没回来 */
- initialLoading: connected && !config && authFiles === null,
- authFilesLoading,
- refresh,
- };
-}
diff --git a/frontend/src/features/dashboard/types.ts b/frontend/src/features/dashboard/types.ts
deleted file mode 100644
index 0227c68..0000000
--- a/frontend/src/features/dashboard/types.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { RecentRequestBucket } from '@/utils/recentRequests';
-
-/** 每个统计桶覆盖的分钟数(后端固定为 10 分钟 × 20 桶) */
-export const TRAFFIC_BUCKET_MINUTES = 10;
-
-/** 聚合后的整体流量窗口 */
-export interface TrafficWindow {
- buckets: RecentRequestBucket[];
- totalSuccess: number;
- totalFailure: number;
- total: number;
- /** 0–100;窗口内无请求时为 null */
- successRate: number | null;
- /** 单桶最大请求数,用于图表纵轴 */
- peakTotal: number;
- /** 峰值所在桶下标,-1 表示无数据 */
- peakIndex: number;
- /** 有请求的桶数量 */
- activeBuckets: number;
- /** 窗口跨度(分钟) */
- windowMinutes: number;
-}
-
-/** 单个供应商的流量切片 */
-export interface ProviderTraffic {
- id: string;
- credentials: number;
- success: number;
- failure: number;
- total: number;
- successRate: number | null;
- buckets: RecentRequestBucket[];
-}
-
-/** 凭证健康度 */
-export interface CredentialHealth {
- total: number;
- active: number;
- disabled: number;
- unavailable: number;
- /** 按供应商类型分组的凭证数,按数量降序 */
- byType: Array<{ type: string; count: number }>;
-}
-
-/** 顶部计数卡片的原始数值 */
-export interface DashboardCounts {
- managementKeys: number | null;
- providerKeys: number | null;
- credentials: number | null;
- models: number | null;
-}
diff --git a/frontend/src/features/dashboard/utils.ts b/frontend/src/features/dashboard/utils.ts
deleted file mode 100644
index 7f28afd..0000000
--- a/frontend/src/features/dashboard/utils.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { TRAFFIC_BUCKET_MINUTES } from './types';
-
-/** 供应商展示名。均为专有名词,不进入 i18n。 */
-const PROVIDER_LABELS: Record = {
- gemini: 'Gemini',
- 'gemini-interactions': 'Interactions API',
- aistudio: 'AI Studio',
- codex: 'Codex',
- claude: 'Claude',
- xai: 'xAI',
- vertex: 'Vertex AI',
- openai: 'OpenAI Compatible',
- 'openai-compatibility': 'OpenAI Compatible',
- qwen: 'Qwen',
- kimi: 'Kimi',
- iflow: 'iFlow',
- antigravity: 'Antigravity',
-};
-
-/**
- * 解析供应商展示名;未知 id 首字母大写兜底,`unknown` 交给调用方本地化。
- */
-export function providerLabel(id: string, unknownLabel: string): string {
- if (id === 'unknown' || !id) return unknownLabel;
- return PROVIDER_LABELS[id] ?? id.charAt(0).toUpperCase() + id.slice(1);
-}
-
-export interface WindowParts {
- hours: number;
- minutes: number;
-}
-
-/** 把窗口分钟数拆成 时/分,供 i18n 插值 */
-export function splitWindowMinutes(totalMinutes: number): WindowParts {
- const safe = Math.max(0, Math.round(totalMinutes));
- return { hours: Math.floor(safe / 60), minutes: safe % 60 };
-}
-
-/** 桶数 → 覆盖分钟数 */
-export function bucketsToMinutes(bucketCount: number): number {
- return bucketCount * TRAFFIC_BUCKET_MINUTES;
-}
-
-export type MeterTone = 'good' | 'warning' | 'critical' | 'idle';
-
-/** 成功率 → 严重度。数值本身始终可见,颜色只是辅助通道。 */
-export function toneForSuccessRate(rate: number | null): MeterTone {
- if (rate === null) return 'idle';
- if (rate >= 95) return 'good';
- if (rate >= 80) return 'warning';
- return 'critical';
-}
-
-/** 刻度阶梯。比 1/2/5 更细,避免峰值 112 被抬到 200 这种浪费半张图的情况。 */
-const STEP_LADDER = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] as const;
-
-/** 把数值向上取整到易读刻度(阶梯 × 10^n) */
-export function niceCeil(value: number): number {
- if (value <= 0) return 1;
- const magnitude = 10 ** Math.floor(Math.log10(value));
- const normalized = value / magnitude;
- const step = STEP_LADDER.find((candidate) => normalized <= candidate) ?? 10;
- return step * magnitude;
-}
-
-/**
- * 纵轴上限:先把「刻度间距」取整,再乘以间隔数。
- * 这样每条网格线都落在整数上,且上限不会远高于峰值。
- */
-export function axisMax(peak: number, intervals: number): number {
- if (peak <= 0 || intervals <= 0) return Math.max(1, intervals);
- const step = Math.max(1, Math.ceil(niceCeil(peak / intervals)));
- return step * intervals;
-}
diff --git a/frontend/src/features/plugins/PluginResourcePage.module.scss b/frontend/src/features/plugins/PluginResourcePage.module.scss
deleted file mode 100644
index 58141e7..0000000
--- a/frontend/src/features/plugins/PluginResourcePage.module.scss
+++ /dev/null
@@ -1,35 +0,0 @@
-.page {
- display: flex;
- width: 100%;
- height: 100%;
- min-width: 0;
- min-height: 0;
- flex: 1 1 auto;
- background: #ffffff;
-}
-
-.frame {
- display: block;
- flex: 1 1 auto;
- width: 100%;
- height: 100%;
- min-width: 0;
- min-height: 0;
- border: 0;
- background: #ffffff;
-}
-
-.stateShell {
- display: flex;
- width: 100%;
- min-height: 100%;
- align-items: center;
- justify-content: center;
- padding: 70px clamp(20px, 3vw, 48px) 40px;
- background: var(--bg-secondary);
-}
-
-.statusPanel {
- color: var(--text-secondary);
- font-size: 14px;
-}
diff --git a/frontend/src/features/plugins/PluginResourcePage.tsx b/frontend/src/features/plugins/PluginResourcePage.tsx
deleted file mode 100644
index 051f035..0000000
--- a/frontend/src/features/plugins/PluginResourcePage.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { useParams } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import { EmptyState } from '@/components/ui/EmptyState';
-import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
-import { pluginsApi } from '@/services/api';
-import { useAuthStore } from '@/stores';
-import { getErrorMessage, isRecord } from '@/utils/helpers';
-import type { PluginListResponse } from '@/types';
-import {
- collectPluginResourceEntries,
- PLUGIN_RESOURCES_REFRESH_EVENT,
- resolvePluginAssetURL,
-} from './pluginResources';
-import styles from './PluginResourcePage.module.scss';
-
-const hasStatus = (error: unknown, status: number) => isRecord(error) && error.status === status;
-
-const safeDecodeURIComponent = (value = '') => {
- try {
- return decodeURIComponent(value);
- } catch {
- return value;
- }
-};
-
-const parseMenuIndex = (value = '') => {
- const index = Number.parseInt(value, 10);
- return Number.isInteger(index) && index >= 0 ? index : -1;
-};
-
-export function PluginResourcePage() {
- const { t } = useTranslation();
- const params = useParams<{ pluginId: string; menuIndex: string }>();
- const connectionStatus = useAuthStore((state) => state.connectionStatus);
- const apiBase = useAuthStore((state) => state.apiBase);
-
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState('');
-
- const connected = connectionStatus === 'connected';
- const pluginID = useMemo(() => safeDecodeURIComponent(params.pluginId), [params.pluginId]);
- const menuIndex = useMemo(() => parseMenuIndex(params.menuIndex), [params.menuIndex]);
-
- const loadResource = useCallback(async () => {
- if (!connected) {
- setLoading(false);
- setError(t('notification.connection_required'));
- return;
- }
-
- setLoading(true);
- setError('');
- try {
- const plugins = await pluginsApi.list();
- setData(plugins);
- } catch (err: unknown) {
- setError(
- hasStatus(err, 404)
- ? t('plugin_management.unsupported_backend')
- : getErrorMessage(err, t('plugin_resource.load_failed'))
- );
- } finally {
- setLoading(false);
- }
- }, [connected, t]);
-
- useHeaderRefresh(loadResource, connected);
-
- useEffect(() => {
- void loadResource();
- }, [loadResource]);
-
- useEffect(() => {
- window.addEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadResource);
-
- return () => {
- window.removeEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadResource);
- };
- }, [loadResource]);
-
- const resource = useMemo(() => {
- const entries = collectPluginResourceEntries(data?.plugins ?? []);
- return entries.find((entry) => entry.pluginID === pluginID && entry.menuIndex === menuIndex);
- }, [data?.plugins, menuIndex, pluginID]);
-
- const iframeSrc = resource ? resolvePluginAssetURL(resource.menu.path, apiBase) : '';
-
- return (
-
- {loading ? (
-
-
{t('common.loading')}
-
- ) : error ? (
-
-
-
- ) : !resource ? (
-
-
-
- ) : !iframeSrc ? (
-
-
-
- ) : (
-
- )}
-
- );
-}
diff --git a/frontend/src/features/plugins/PluginStorePage.module.scss b/frontend/src/features/plugins/PluginStorePage.module.scss
deleted file mode 100644
index af8b116..0000000
--- a/frontend/src/features/plugins/PluginStorePage.module.scss
+++ /dev/null
@@ -1,857 +0,0 @@
-@use '../../styles/variables' as *;
-@use '../../styles/mixins' as *;
-
-// ─── Page Container ─────────────────────────────────────
-
-.page {
- display: flex;
- flex-direction: column;
- gap: $spacing-lg;
- width: 100%;
-}
-
-// ─── Header ─────────────────────────────────────────────
-
-.pageHeader {
- display: flex;
- flex-direction: column;
- gap: $spacing-sm;
-}
-
-.title {
- margin: 0;
- color: var(--text-primary);
- font-size: 28px;
- font-weight: 700;
- line-height: 1.2;
-}
-
-.description {
- margin: 0;
- color: var(--text-secondary);
- font-size: 14px;
- line-height: 1.5;
-}
-
-// ─── Alert Boxes ────────────────────────────────────────
-
-.errorBox,
-.warningBox {
- padding: $spacing-md;
- border-radius: $radius-md;
- font-size: 14px;
- line-height: 1.5;
-}
-
-.errorBox {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $spacing-md;
- border: 1px solid var(--danger-color);
- background: rgba($error-color, 0.1);
- color: var(--danger-color);
-
- span {
- min-width: 0;
- overflow-wrap: anywhere;
- }
-
- :global(.btn) {
- flex-shrink: 0;
- }
-
- @include mobile {
- flex-direction: column;
- align-items: stretch;
- }
-}
-
-.warningBox {
- border: 1px solid color-mix(in srgb, var(--warning-color, #c65746) 42%, var(--border-color));
- background: color-mix(in srgb, var(--warning-color, #c65746) 9%, var(--bg-secondary));
- color: var(--text-primary);
-}
-
-.sourceErrorList {
- display: flex;
- flex-direction: column;
- gap: 6px;
- margin: 8px 0 0;
- padding: 0;
- list-style: none;
-
- li {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
- }
-
- span {
- font-weight: 700;
- overflow-wrap: anywhere;
- }
-
- small {
- color: var(--text-secondary);
- font-size: 12px;
- overflow-wrap: anywhere;
- }
-}
-
-// ─── Security Banner (third-party plugin risk) ──────────
-
-.securityBanner {
- display: flex;
- align-items: flex-start;
- gap: $spacing-sm;
- padding: 12px 14px;
- border-radius: $radius-md;
- border: 1px solid color-mix(in srgb, var(--quota-medium-color, #e0aa14) 45%, var(--border-color));
- background: color-mix(in srgb, var(--quota-medium-color, #e0aa14) 14%, var(--bg-secondary));
- color: var(--text-primary);
-
- > svg {
- flex-shrink: 0;
- margin-top: 1px;
- color: var(--quota-medium-color, #e0aa14);
- }
-}
-
-.securityBannerText {
- min-width: 0;
-
- strong {
- display: block;
- font-size: 14px;
- font-weight: 700;
- line-height: 1.4;
- }
-
- p {
- margin: 2px 0 0;
- font-size: 13px;
- line-height: 1.5;
- color: var(--text-secondary);
- }
-}
-
-// ─── Status Bar ─────────────────────────────────────────
-
-.statusBar {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 14px;
- border-radius: 10px;
- border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
- background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
- flex-wrap: wrap;
-}
-
-.statusPill {
- display: inline-flex;
- min-width: 0;
- max-width: 100%;
- align-items: center;
- gap: 6px;
- padding: 5px 12px;
- border-radius: $radius-full;
- border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
- background: color-mix(in srgb, var(--bg-primary) 56%, transparent);
- font-size: 12px;
- font-weight: 600;
- white-space: nowrap;
-}
-
-.statusDot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.statusDotOn {
- background: $success-color;
- box-shadow: 0 0 6px rgba($success-color, 0.5);
-}
-
-.statusDotOff {
- background: var(--text-tertiary);
-}
-
-.statusLabel {
- color: var(--text-secondary);
-}
-
-.statusValue {
- color: var(--text-primary);
- font-weight: 700;
-}
-
-.statusPathValue {
- display: block;
- min-width: 0;
- max-width: min(360px, 58vw);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.statusDivider {
- width: 1px;
- height: 20px;
- background: color-mix(in srgb, var(--border-color) 60%, transparent);
- flex-shrink: 0;
-
- @include mobile {
- display: none;
- }
-}
-
-// ─── Toolbar ────────────────────────────────────────────
-
-.toolbar {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
-
- :global(.form-group) {
- flex: 1;
- max-width: 480px;
- margin: 0;
- }
-
- :global(.input) {
- padding-right: 36px;
- }
-
- :global(.btn > span) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- }
-
- @include mobile {
- flex-direction: column;
- align-items: stretch;
-
- :global(.form-group) {
- max-width: none;
- }
- }
-}
-
-// ─── Status Filter Chips ────────────────────────────────
-
-.filterChips {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
- flex-wrap: wrap;
-}
-
-.filterChip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 12px;
- border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
- border-radius: $radius-full;
- background: var(--bg-primary);
- color: var(--text-secondary);
- font-size: 12px;
- font-weight: 600;
- line-height: 1.4;
- cursor: pointer;
- transition:
- border-color $transition-fast,
- background-color $transition-fast,
- color $transition-fast;
-
- &:hover {
- border-color: var(--primary-color);
- color: var(--text-primary);
- }
-}
-
-.filterChipActive {
- border-color: var(--primary-color);
- background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-primary));
- color: var(--text-primary);
-}
-
-.filterChipCount {
- display: inline-flex;
- min-width: 18px;
- align-items: center;
- justify-content: center;
- padding: 0 5px;
- border-radius: $radius-full;
- background: color-mix(in srgb, var(--border-color) 45%, transparent);
- color: var(--text-secondary);
- font-size: 11px;
- font-weight: 700;
-}
-
-// ─── Card Grid ──────────────────────────────────────────
-
-.cardGrid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: $spacing-md;
-
- @include mobile {
- grid-template-columns: 1fr;
- }
-}
-
-// ─── Plugin Card ────────────────────────────────────────
-
-.card {
- display: flex;
- flex-direction: column;
- gap: $spacing-sm;
- padding: $spacing-md;
- border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
- border-radius: $radius-lg;
- background: var(--bg-primary);
- transition:
- border-color $transition-fast,
- background-color $transition-fast;
-
- &:hover {
- border-color: color-mix(in srgb, var(--primary-color) 45%, var(--border-color));
- background: var(--bg-hover);
- }
-}
-
-.cardHeader {
- display: grid;
- grid-template-columns: 40px minmax(0, 1fr);
- align-items: flex-start;
- column-gap: $spacing-sm;
- row-gap: 6px;
-}
-
-.logoBox {
- display: inline-flex;
- grid-column: 1;
- grid-row: 1;
- width: 40px;
- height: 40px;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- border-radius: 10px;
- border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
- background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
- color: var(--text-secondary);
- flex-shrink: 0;
-
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
-}
-
-.cardTitleBlock {
- display: flex;
- grid-column: 2;
- grid-row: 1;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
-}
-
-.cardTitle {
- margin: 0;
- color: var(--text-primary);
- font-size: 15px;
- font-weight: 650;
- line-height: 1.3;
- overflow-wrap: anywhere;
- @include text-ellipsis-multiline(2);
-}
-
-.cardId {
- color: var(--text-tertiary);
- font-family: $font-mono;
- font-size: 12px;
- @include text-ellipsis;
-}
-
-.cardBadges {
- display: flex;
- grid-column: 2;
- grid-row: 2;
- flex-wrap: wrap;
- justify-content: flex-start;
- gap: 5px;
- min-width: 0;
-
- &:empty {
- display: none;
- }
-}
-
-.badge,
-.badgeSuccess,
-.badgeWarning,
-.badgeUntrusted {
- display: inline-flex;
- min-height: 22px;
- align-items: center;
- border-radius: 6px;
- padding: 2px 8px;
- font-size: 11px;
- font-weight: 600;
- line-height: 1.25;
-}
-
-.badge {
- background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
- border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
- color: var(--text-secondary);
-}
-
-.badgeSuccess {
- background: rgba($success-color, 0.1);
- border: 1px solid rgba($success-color, 0.2);
- color: var(--success-color);
-}
-
-.badgeWarning {
- background: rgba($warning-color, 0.1);
- border: 1px solid rgba($warning-color, 0.2);
- color: var(--warning-color);
-}
-
-.badgeUntrusted {
- gap: 4px;
- background: rgba($warning-color, 0.12);
- border: 1px solid rgba($warning-color, 0.4);
- color: var(--danger-color);
-
- svg {
- flex-shrink: 0;
- }
-}
-
-.cardDesc {
- display: -webkit-box;
- margin: 0;
- color: var(--text-secondary);
- font-size: 13px;
- line-height: 1.5;
- overflow: hidden;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
-}
-
-.cardDescBlock {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 4px;
-}
-
-.cardDescExpanded {
- display: block;
- overflow: visible;
- -webkit-line-clamp: initial;
-}
-
-.cardDescToggle {
- align-self: flex-start;
- padding: 0;
- border: 0;
- background: transparent;
- color: var(--primary-color);
- cursor: pointer;
- font: inherit;
- font-size: 12px;
- font-weight: 650;
- line-height: 1.4;
-
- &:hover {
- color: var(--primary-hover);
- text-decoration: underline;
- }
-
- &:focus-visible {
- outline: 2px solid color-mix(in srgb, var(--primary-color) 45%, transparent);
- outline-offset: 3px;
- border-radius: 4px;
- }
-}
-
-.cardMeta {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
- flex-wrap: wrap;
-}
-
-.metaItem {
- display: inline-flex;
- min-width: 0;
- max-width: 100%;
- align-items: center;
- gap: $spacing-sm;
- color: var(--text-tertiary);
- font-size: 12px;
- white-space: nowrap;
-
- strong {
- color: var(--text-secondary);
- font-weight: 600;
- }
-}
-
-.metaDot {
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: color-mix(in srgb, var(--text-tertiary) 50%, transparent);
- flex-shrink: 0;
-}
-
-.tagRow {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
-}
-
-.tag {
- display: inline-flex;
- align-items: center;
- padding: 2px 8px;
- border-radius: $radius-full;
- background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
- border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
- color: var(--text-tertiary);
- font-size: 11px;
- font-weight: 600;
- line-height: 1.4;
-}
-
-.cardFooter {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $spacing-sm;
- margin-top: auto;
- padding-top: $spacing-sm;
-}
-
-.cardActions {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: $spacing-sm;
-
- :global(.btn > span) {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- }
-}
-
-.cardLinks {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
- flex-shrink: 0;
-}
-
-.iconLink {
- display: inline-flex;
- width: 30px;
- min-height: 30px;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- border: 1px solid var(--border-color);
- border-radius: $radius-md;
- background: var(--bg-primary);
- color: var(--text-primary);
- text-decoration: none;
- transition:
- border-color $transition-fast,
- background-color $transition-fast,
- color $transition-fast;
-
- &:hover {
- border-color: var(--primary-color);
- background: var(--bg-hover);
- color: var(--primary-color);
- }
-}
-
-// ─── Install Options Dialog ───────────────────────────────
-
-.installOptions {
- display: flex;
- flex-direction: column;
- gap: $spacing-md;
-}
-
-.installMessage {
- margin: 0;
- color: var(--text-primary);
- font-size: 14px;
- line-height: 1.55;
-}
-
-.installVersionField {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.installVersionLabel {
- color: var(--text-secondary);
- font-size: 12px;
- font-weight: 700;
-}
-
-.installVersionModes {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
-
- @include mobile {
- grid-template-columns: 1fr;
- }
-}
-
-.installVersionMode {
- display: flex;
- min-width: 0;
- gap: 8px;
- padding: 10px;
- border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
- border-radius: $radius-md;
- background: var(--bg-primary);
- cursor: pointer;
- transition:
- border-color $transition-fast,
- background-color $transition-fast;
-
- &:hover {
- border-color: color-mix(in srgb, var(--primary-color) 45%, var(--border-color));
- background: var(--bg-hover);
- }
-
- input {
- margin-top: 2px;
- flex: 0 0 auto;
- }
-}
-
-.installVersionModeActive {
- border-color: var(--primary-color);
- background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-primary));
-}
-
-.installVersionModeDisabled {
- cursor: not-allowed;
- opacity: 0.7;
-}
-
-.installVersionModeText {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 2px;
-
- strong {
- color: var(--text-primary);
- font-size: 13px;
- font-weight: 700;
- line-height: 1.3;
- }
-
- small {
- color: var(--text-secondary);
- font-size: 11px;
- line-height: 1.4;
- overflow-wrap: anywhere;
- }
-}
-
-.installVersionPanel {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 10px;
- border-radius: $radius-md;
- border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
- background: color-mix(in srgb, var(--bg-secondary) 65%, transparent);
-
- :global(.form-group) {
- margin: 0;
- }
-}
-
-.installVersionHint {
- margin: 0;
- color: var(--text-secondary);
- font-size: 12px;
- line-height: 1.45;
-}
-
-.installVersionWarning {
- margin: 0;
- color: var(--warning-color);
- font-size: 12px;
- line-height: 1.45;
- overflow-wrap: anywhere;
-}
-
-.installVersionCheckbox {
- display: inline-flex;
- width: fit-content;
- max-width: 100%;
- align-items: center;
- gap: 7px;
- color: var(--text-secondary);
- font-size: 12px;
- font-weight: 600;
- line-height: 1.4;
- cursor: pointer;
-
- input {
- flex: 0 0 auto;
- }
-}
-
-.installReleaseLink {
- display: inline-flex;
- width: fit-content;
- max-width: 100%;
- align-items: center;
- gap: 5px;
- color: var(--primary-color);
- font-size: 12px;
- font-weight: 650;
- line-height: 1.4;
- text-decoration: none;
-
- &:hover {
- color: var(--primary-hover);
- text-decoration: underline;
- }
-
- svg {
- flex: 0 0 auto;
- }
-}
-
-.installDialogActions {
- display: flex;
- justify-content: flex-end;
- gap: $spacing-sm;
- margin-top: $spacing-sm;
-
- @include mobile {
- flex-direction: column-reverse;
- }
-}
-
-// ─── Skeleton ───────────────────────────────────────────
-
-.skeletonCard {
- display: flex;
- flex-direction: column;
- gap: $spacing-md;
- padding: $spacing-md;
- border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
- border-radius: $radius-lg;
- background: var(--bg-primary);
-}
-
-.skeletonHeader {
- display: flex;
- align-items: center;
- gap: $spacing-md;
-}
-
-.skeletonAvatar {
- width: 40px;
- height: 40px;
- border-radius: 10px;
- background: linear-gradient(
- 90deg,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
- color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
- );
- background-size: 400% 100%;
- animation: skeletonPulse 1.35s ease-in-out infinite;
- flex-shrink: 0;
-}
-
-.skeletonText {
- display: flex;
- flex-direction: column;
- gap: 8px;
- flex: 1;
-}
-
-.skeletonLine {
- height: 14px;
- border-radius: 4px;
- background: linear-gradient(
- 90deg,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
- color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
- );
- background-size: 400% 100%;
- animation: skeletonPulse 1.35s ease-in-out infinite;
-
- &:first-child {
- width: 45%;
- }
-
- &:last-child {
- width: 70%;
- height: 10px;
- }
-}
-
-.skeletonBody {
- height: 56px;
- border-radius: $radius-md;
- background: linear-gradient(
- 90deg,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
- color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
- );
- background-size: 400% 100%;
- animation: skeletonPulse 1.35s ease-in-out infinite;
-}
-
-// ─── Animation ──────────────────────────────────────────
-
-@keyframes skeletonPulse {
- 0% {
- background-position: 100% 0;
- }
- 100% {
- background-position: 0 0;
- }
-}
-
-// ─── Mobile Overrides ───────────────────────────────────
-
-@include mobile {
- .page {
- gap: $spacing-md;
- }
-}
diff --git a/frontend/src/features/plugins/PluginStorePage.tsx b/frontend/src/features/plugins/PluginStorePage.tsx
deleted file mode 100644
index da84081..0000000
--- a/frontend/src/features/plugins/PluginStorePage.tsx
+++ /dev/null
@@ -1,1196 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
-import { Button } from '@/components/ui/Button';
-import { EmptyState } from '@/components/ui/EmptyState';
-import { Input } from '@/components/ui/Input';
-import { Modal } from '@/components/ui/Modal';
-import { Select } from '@/components/ui/Select';
-import {
- IconAlertTriangle,
- IconDownload,
- IconExternalLink,
- IconGithub,
- IconPlug,
- IconRefreshCw,
- IconSearch,
- IconSettings,
- IconShield,
-} from '@/components/ui/icons';
-import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
-import { pluginStoreApi } from '@/services/api';
-import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
-import { getErrorMessage, isRecord } from '@/utils/helpers';
-import type { PluginStoreEntry, PluginStoreResponse } from '@/types';
-import {
- buildRepositoryURL,
- isDefaultPluginStoreSource,
- isOfficialPlugin,
- notifyPluginResourcesChanged,
- resolvePluginAssetURL,
-} from './pluginResources';
-import { PluginInstallGateModal } from './components/PluginInstallGateModal';
-import {
- buildGitHubReleasesPageURL,
- fetchPluginReleaseVersions,
- isValidManualReleaseTag,
- supportsPluginVersionSelection,
- type PluginReleaseVersion,
-} from './pluginReleaseVersions';
-import { waitForPluginStoreState } from './pluginPolling';
-import styles from './PluginStorePage.module.scss';
-
-type StoreStatusFilter = 'all' | 'installed' | 'notInstalled' | 'updates';
-type InstallVersionMode = 'latest' | 'release' | 'manual';
-
-interface StoreLoadError {
- kind: 'unsupported' | 'registry' | 'generic';
- message: string;
-}
-
-const getErrorStatus = (error: unknown): number | undefined =>
- isRecord(error) && typeof error.status === 'number' ? error.status : undefined;
-
-const getErrorDetailMessage = (error: unknown): string => {
- if (!isRecord(error) || !isRecord(error.details)) return '';
- const message = error.details.message;
- return typeof message === 'string' ? message.trim() : '';
-};
-
-const DESCRIPTION_COLLAPSED_LINES = 2;
-
-const getStoreEntryTitle = (entry: PluginStoreEntry) => entry.name || entry.id;
-const getStoreEntryKey = (entry: PluginStoreEntry) => entry.storeId || entry.id;
-const getDescriptionDOMID = (entryKey: string) =>
- `plugin-store-desc-${encodeURIComponent(entryKey)}`;
-const normalizePluginVersion = (version: string) => version.trim().replace(/^v/i, '');
-const formatPluginVersion = (version: string) => {
- const trimmed = version.trim();
- if (!trimmed) return '';
- return /^v/i.test(trimmed) ? trimmed : `v${trimmed}`;
-};
-const pluginVersionMatches = (left: string, right: string) =>
- normalizePluginVersion(left) === normalizePluginVersion(right);
-const formatInstallType = (installType: string) =>
- installType
- .trim()
- .split('-')
- .map((part) => (part ? `${part[0].toUpperCase()}${part.slice(1)}` : part))
- .join(' ');
-const releaseVersionsCache = new Map();
-
-const formatReleaseDate = (value: string, locale: string) => {
- if (!value) return '';
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return '';
- return new Intl.DateTimeFormat(locale, {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- }).format(date);
-};
-
-function StoreCardLogo({ src }: { src: string }) {
- const [failed, setFailed] = useState(false);
- const showImage = Boolean(src) && !failed;
-
- return showImage ? (
-
setFailed(true)} />
- ) : (
-
- );
-}
-
-interface PluginInstallOptionsModalProps {
- entry: PluginStoreEntry | null;
- isUpdate: boolean;
- version: string;
- installing: boolean;
- onVersionChange: (version: string) => void;
- onClose: () => void;
- onConfirm: () => void | Promise;
-}
-
-function PluginInstallOptionsModal({
- entry,
- isUpdate,
- version,
- installing,
- onVersionChange,
- onClose,
- onConfirm,
-}: PluginInstallOptionsModalProps) {
- const { i18n, t } = useTranslation();
- const [versionMode, setVersionMode] = useState('latest');
- const [showPrerelease, setShowPrerelease] = useState(false);
- const [releaseVersions, setReleaseVersions] = useState([]);
- const [releaseLoading, setReleaseLoading] = useState(false);
- const [releaseError, setReleaseError] = useState('');
- const [loadedReleaseKey, setLoadedReleaseKey] = useState('');
-
- const entryKey = entry ? getStoreEntryKey(entry) : '';
- const entryRepository = entry?.repository ?? '';
- const supportsVersionSelection = entry
- ? supportsPluginVersionSelection(entry.installType)
- : false;
- const releasePageURL =
- entry && supportsVersionSelection ? buildGitHubReleasesPageURL(entry.repository) : '';
- const releaseCacheKey = entryKey
- ? `${entryKey}|${entryRepository.trim()}|${entry?.installType.trim().toLowerCase() ?? ''}`
- : '';
-
- if (releaseCacheKey !== loadedReleaseKey) {
- const cached = releaseCacheKey ? releaseVersionsCache.get(releaseCacheKey) : undefined;
- setLoadedReleaseKey(releaseCacheKey);
- setVersionMode('latest');
- setShowPrerelease(false);
- setReleaseVersions(cached ?? []);
- setReleaseError(
- entryKey && supportsVersionSelection && !releasePageURL
- ? t('plugin_store.install_version_non_github')
- : ''
- );
- setReleaseLoading(Boolean(entryKey && supportsVersionSelection && releasePageURL && !cached));
- }
-
- useEffect(() => {
- if (!entryKey || !supportsVersionSelection || !releasePageURL) return;
-
- if (releaseVersionsCache.has(releaseCacheKey)) return;
-
- let active = true;
-
- fetchPluginReleaseVersions(entryRepository)
- .then((releases) => {
- if (!active) return;
- releaseVersionsCache.set(releaseCacheKey, releases);
- setReleaseVersions(releases);
- })
- .catch((err: unknown) => {
- if (!active) return;
- setReleaseError(getErrorMessage(err, t('plugin_store.install_versions_load_failed')));
- })
- .finally(() => {
- if (!active) return;
- setReleaseLoading(false);
- });
-
- return () => {
- active = false;
- };
- }, [entryKey, entryRepository, releaseCacheKey, releasePageURL, supportsVersionSelection, t]);
-
- const stableReleaseVersions = useMemo(
- () => releaseVersions.filter((release) => !release.prerelease),
- [releaseVersions]
- );
- const visibleReleaseVersions = useMemo(
- () =>
- showPrerelease ? releaseVersions : releaseVersions.filter((release) => !release.prerelease),
- [releaseVersions, showPrerelease]
- );
- const latestRelease = stableReleaseVersions[0] ?? releaseVersions[0] ?? null;
- const releaseOptions = useMemo(
- () =>
- visibleReleaseVersions.map((release) => {
- const releaseTitle =
- release.name && release.name !== release.tagName
- ? `${release.tagName} - ${release.name}`
- : release.tagName;
- const releaseDate = formatReleaseDate(release.publishedAt, i18n.language);
- const labelParts = [
- releaseTitle,
- releaseDate,
- release.prerelease ? t('plugin_store.install_version_prerelease_badge') : '',
- release.assetNames.length > 0
- ? t('plugin_store.install_version_assets_count', {
- count: release.assetNames.length,
- })
- : '',
- ].filter(Boolean);
- return {
- value: release.tagName,
- label: labelParts.join(' · '),
- };
- }),
- [i18n.language, t, visibleReleaseVersions]
- );
-
- useEffect(() => {
- if (!supportsVersionSelection || versionMode !== 'release') return;
- if (visibleReleaseVersions.length === 0) {
- if (version) onVersionChange('');
- return;
- }
- if (visibleReleaseVersions.some((release) => release.tagName === version)) return;
- onVersionChange(visibleReleaseVersions[0].tagName);
- }, [onVersionChange, supportsVersionSelection, version, versionMode, visibleReleaseVersions]);
-
- if (!entry) return null;
-
- const title = isUpdate
- ? t('plugin_store.update_confirm_title')
- : t('plugin_store.install_confirm_title');
- const requestedVersion =
- !supportsVersionSelection || versionMode === 'latest' ? '' : version.trim();
- const displayVersion = requestedVersion || entry.version;
- const target = displayVersion
- ? `${getStoreEntryTitle(entry)} ${formatPluginVersion(displayVersion)}`
- : getStoreEntryTitle(entry);
- const message = isUpdate
- ? t('plugin_store.update_confirm_message', { target })
- : t('plugin_store.install_confirm_message', { target });
- const latestVersionLabel = entry.version
- ? formatPluginVersion(entry.version)
- : latestRelease
- ? formatPluginVersion(latestRelease.tagName)
- : t('plugin_store.install_version_latest');
- const hasPrereleaseVersions = releaseVersions.some((release) => release.prerelease);
- const releaseModeDisabled = installing || (releaseLoading && releaseVersions.length === 0);
- const manualVersionInvalid =
- supportsVersionSelection &&
- versionMode === 'manual' &&
- Boolean(version.trim()) &&
- !isValidManualReleaseTag(version);
- const currentVersionSelected =
- Boolean(requestedVersion) &&
- Boolean(entry.installedVersion) &&
- pluginVersionMatches(entry.installedVersion, requestedVersion);
- const confirmDisabled =
- (supportsVersionSelection && versionMode === 'release' && !requestedVersion) ||
- (supportsVersionSelection && versionMode === 'manual' && !isValidManualReleaseTag(version)) ||
- currentVersionSelected;
-
- const handleVersionModeChange = (nextMode: InstallVersionMode) => {
- if (installing) return;
- if (!supportsVersionSelection && nextMode !== 'latest') return;
- setVersionMode(nextMode);
- if (nextMode === 'latest') {
- onVersionChange('');
- return;
- }
- if (nextMode === 'release') {
- onVersionChange(visibleReleaseVersions[0]?.tagName ?? '');
- return;
- }
- onVersionChange('');
- };
-
- const handleClose = () => {
- if (installing) return;
- onClose();
- };
-
- return (
-
-
-
{message}
-
-
- {t('plugin_store.install_version_label')}
-
-
-
- {supportsVersionSelection ? (
- <>
-
-
- >
- ) : null}
-
-
- {supportsVersionSelection && versionMode === 'release' ? (
-
- {releaseError ? (
-
- {t('plugin_store.install_versions_load_failed')}: {releaseError}
-
- ) : null}
- {!releaseLoading && !releaseError && releaseVersions.length === 0 ? (
-
- {t('plugin_store.install_versions_empty')}
-
- ) : null}
- {!releaseLoading &&
- !releaseError &&
- releaseVersions.length > 0 &&
- visibleReleaseVersions.length === 0 ? (
-
- {t('plugin_store.install_versions_only_prerelease')}
-
- ) : null}
-
- {hasPrereleaseVersions ? (
-
- ) : null}
-
- ) : null}
-
- {supportsVersionSelection && versionMode === 'manual' ? (
-
-
onVersionChange(event.target.value)}
- placeholder={t('plugin_store.install_version_manual_placeholder')}
- disabled={installing}
- autoComplete="off"
- spellCheck={false}
- aria-invalid={manualVersionInvalid}
- />
- {manualVersionInvalid ? (
-
- {t('plugin_store.install_version_manual_error')}
-
- ) : null}
-
- ) : null}
-
- {currentVersionSelected ? (
-
- {t('plugin_store.install_version_current_selected', {
- version: formatPluginVersion(entry.installedVersion),
- })}
-
- ) : null}
-
- {supportsVersionSelection && releasePageURL ? (
-
- {t('plugin_store.install_version_releases_link')}
-
-
- ) : null}
-
-
-
-
-
-
-
- );
-}
-
-export function PluginStorePage() {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const connectionStatus = useAuthStore((state) => state.connectionStatus);
- const apiBase = useAuthStore((state) => state.apiBase);
- const clearConfigCache = useConfigStore((state) => state.clearCache);
- const showNotification = useNotificationStore((state) => state.showNotification);
-
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [filter, setFilter] = useState('');
- const [statusFilter, setStatusFilter] = useState('all');
- const [installingKey, setInstallingKey] = useState('');
- const [restartRequiredKeys, setRestartRequiredKeys] = useState([]);
- const [expandedDescriptionKeys, setExpandedDescriptionKeys] = useState([]);
- const [overflowingDescriptionKeys, setOverflowingDescriptionKeys] = useState([]);
- const descriptionRefs = useRef>({});
-
- // Multi-step install gauntlet, shown only for non-official (third-party) plugins.
- const [gateOpen, setGateOpen] = useState(false);
- const [gateEntry, setGateEntry] = useState(null);
- const [gateIsUpdate, setGateIsUpdate] = useState(false);
- const [gateRequestedVersion, setGateRequestedVersion] = useState('');
-
- const [installOptionsEntry, setInstallOptionsEntry] = useState(null);
- const [installOptionsIsUpdate, setInstallOptionsIsUpdate] = useState(false);
- const [installVersion, setInstallVersion] = useState('');
-
- const connected = connectionStatus === 'connected';
-
- const loadStore = useCallback(async () => {
- if (!connected) {
- setLoading(false);
- setError({ kind: 'generic', message: t('notification.connection_required') });
- return;
- }
-
- setLoading(true);
- setError(null);
- try {
- const store = await pluginStoreApi.list();
- setData(store);
- } catch (err: unknown) {
- const status = getErrorStatus(err);
- if (status === 404) {
- setError({ kind: 'unsupported', message: t('plugin_store.unsupported_backend') });
- } else if (status === 502) {
- const detail = getErrorDetailMessage(err);
- setError({
- kind: 'registry',
- message: detail
- ? `${t('plugin_store.registry_failed')}: ${detail}`
- : t('plugin_store.registry_failed'),
- });
- } else {
- setError({
- kind: 'generic',
- message: getErrorMessage(err, t('plugin_store.load_failed')),
- });
- }
- } finally {
- setLoading(false);
- }
- }, [connected, t]);
-
- useHeaderRefresh(loadStore, connected);
-
- useEffect(() => {
- void loadStore();
- }, [loadStore]);
-
- const stats = useMemo(() => {
- const plugins = data?.plugins ?? [];
- const installed = plugins.filter((plugin) => plugin.installed).length;
- return {
- total: plugins.length,
- installed,
- notInstalled: plugins.length - installed,
- updates: plugins.filter((plugin) => plugin.installed && plugin.updateAvailable).length,
- };
- }, [data?.plugins]);
-
- const visiblePlugins = useMemo(() => {
- const plugins = data?.plugins ?? [];
- const byStatus = plugins.filter((plugin) => {
- if (statusFilter === 'installed') return plugin.installed;
- if (statusFilter === 'notInstalled') return !plugin.installed;
- if (statusFilter === 'updates') return plugin.installed && plugin.updateAvailable;
- return true;
- });
-
- const query = filter.trim().toLowerCase();
- if (!query) return byStatus;
-
- return byStatus.filter((plugin) => {
- const haystack = [
- plugin.id,
- plugin.name,
- plugin.description,
- plugin.author,
- plugin.repository,
- plugin.sourceName,
- plugin.sourceUrl,
- plugin.license,
- ...plugin.tags,
- ]
- .filter(Boolean)
- .join(' ')
- .toLowerCase();
- return haystack.includes(query);
- });
- }, [data?.plugins, filter, statusFilter]);
-
- const statusFilters: Array<{ key: StoreStatusFilter; label: string; count: number }> = [
- { key: 'all', label: t('plugin_store.filter_all'), count: stats.total },
- { key: 'installed', label: t('plugin_store.filter_installed'), count: stats.installed },
- {
- key: 'notInstalled',
- label: t('plugin_store.filter_not_installed'),
- count: stats.notInstalled,
- },
- { key: 'updates', label: t('plugin_store.filter_updates'), count: stats.updates },
- ];
-
- const restartNames = restartRequiredKeys.map((key) => {
- const entry = data?.plugins.find((plugin) => getStoreEntryKey(plugin) === key);
- return entry ? getStoreEntryTitle(entry) : key;
- });
-
- const hasActiveFilters = Boolean(filter.trim()) || statusFilter !== 'all';
-
- const expandedDescriptionKeySet = useMemo(
- () => new Set(expandedDescriptionKeys),
- [expandedDescriptionKeys]
- );
- const overflowingDescriptionKeySet = useMemo(
- () => new Set(overflowingDescriptionKeys),
- [overflowingDescriptionKeys]
- );
-
- const registerDescriptionRef = useCallback((id: string, node: HTMLParagraphElement | null) => {
- if (node) {
- descriptionRefs.current[id] = node;
- } else {
- delete descriptionRefs.current[id];
- }
- }, []);
-
- const measureDescriptionOverflow = useCallback(() => {
- const nextIDs = Object.entries(descriptionRefs.current)
- .filter(([, node]) => {
- if (!node) return false;
- const computed = window.getComputedStyle(node);
- const lineHeight = Number.parseFloat(computed.lineHeight);
- if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
- return node.scrollHeight > node.clientHeight + 1;
- }
- return node.scrollHeight > lineHeight * DESCRIPTION_COLLAPSED_LINES + 1;
- })
- .map(([id]) => id);
-
- setOverflowingDescriptionKeys((current) => {
- if (current.length === nextIDs.length && current.every((id) => nextIDs.includes(id))) {
- return current;
- }
- return nextIDs;
- });
- }, []);
-
- useEffect(() => {
- const frame = window.requestAnimationFrame(measureDescriptionOverflow);
- return () => {
- window.cancelAnimationFrame(frame);
- };
- }, [measureDescriptionOverflow, visiblePlugins]);
-
- useEffect(() => {
- const handleResize = () => {
- window.requestAnimationFrame(measureDescriptionOverflow);
- };
-
- window.addEventListener('resize', handleResize);
- return () => {
- window.removeEventListener('resize', handleResize);
- };
- }, [measureDescriptionOverflow]);
-
- const toggleDescription = useCallback((id: string) => {
- setExpandedDescriptionKeys((current) =>
- current.includes(id) ? current.filter((currentID) => currentID !== id) : [...current, id]
- );
- }, []);
-
- const runInstall = useCallback(
- async (entry: PluginStoreEntry, isUpdate: boolean, requestedVersion = '') => {
- const entryKey = getStoreEntryKey(entry);
- const failedKey = isUpdate ? 'plugin_store.update_failed' : 'plugin_store.install_failed';
- const version = requestedVersion.trim();
- setInstallingKey(entryKey);
- try {
- const result = await pluginStoreApi.install(entry.id, {
- sourceId: entry.sourceId || undefined,
- version: version || undefined,
- });
- clearConfigCache();
- const sourceId = result.sourceId || entry.sourceId;
- const installedState = await waitForPluginStoreState(
- entry.id,
- sourceId,
- (plugin) =>
- plugin.installed &&
- plugin.configured &&
- (!version || pluginVersionMatches(plugin.installedVersion, version))
- );
- setData(installedState.response);
- if (
- installedState.timedOut ||
- !installedState.plugin?.installed ||
- !installedState.plugin.configured
- ) {
- showNotification(t('plugin_store.status_pending'), 'warning');
- return;
- }
-
- if (result.restartRequired) {
- setRestartRequiredKeys((current) =>
- current.includes(entryKey) ? current : [...current, entryKey]
- );
- showNotification(
- isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
- 'success'
- );
- showNotification(t('plugin_store.restart_required_notice'), 'warning');
- return;
- }
-
- if (!installedState.response.pluginsEnabled) {
- showNotification(
- isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
- 'success'
- );
- showNotification(t('plugin_store.global_disabled_hint'), 'warning');
- return;
- }
-
- if (installedState.plugin.enabled) {
- const registeredState = await waitForPluginStoreState(
- entry.id,
- sourceId,
- (plugin) => plugin.registered && plugin.effectiveEnabled
- );
- setData(registeredState.response);
- if (
- registeredState.timedOut ||
- !registeredState.plugin?.registered ||
- !registeredState.plugin.effectiveEnabled
- ) {
- showNotification(t('plugin_store.registration_pending'), 'warning');
- return;
- }
- notifyPluginResourcesChanged();
- }
-
- showNotification(
- isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
- 'success'
- );
- } catch (err: unknown) {
- showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error');
- throw err;
- } finally {
- setInstallingKey('');
- }
- },
- [clearConfigCache, showNotification, t]
- );
-
- const handleInstall = (entry: PluginStoreEntry) => {
- const isUpdate = entry.installed && entry.updateAvailable;
- setInstallOptionsEntry(entry);
- setInstallOptionsIsUpdate(isUpdate);
- setInstallVersion('');
- };
-
- const handleInstallOptionsClose = useCallback(() => {
- if (installingKey) return;
- setInstallOptionsEntry(null);
- setInstallVersion('');
- }, [installingKey]);
-
- const handleInstallOptionsConfirm = useCallback(async () => {
- if (!installOptionsEntry) return;
- const requestedVersion = installVersion.trim();
-
- // Third-party plugins must clear the multi-step confirmation gauntlet first.
- if (!isOfficialPlugin(installOptionsEntry)) {
- setGateEntry(installOptionsEntry);
- setGateIsUpdate(installOptionsIsUpdate);
- setGateRequestedVersion(requestedVersion);
- setGateOpen(true);
- setInstallOptionsEntry(null);
- setInstallVersion('');
- return;
- }
-
- try {
- await runInstall(installOptionsEntry, installOptionsIsUpdate, requestedVersion);
- setInstallOptionsEntry(null);
- setInstallVersion('');
- } catch {
- // runInstall already surfaced a notification; keep the modal available for correction.
- }
- }, [installOptionsEntry, installOptionsIsUpdate, installVersion, runInstall]);
-
- const handleGateConfirm = useCallback(async () => {
- if (!gateEntry) return;
- await runInstall(gateEntry, gateIsUpdate, gateRequestedVersion);
- setGateOpen(false);
- setGateRequestedVersion('');
- }, [gateEntry, gateIsUpdate, gateRequestedVersion, runInstall]);
-
- const handleGateClose = useCallback(() => {
- setGateOpen(false);
- setGateRequestedVersion('');
- }, []);
-
- const renderCard = (entry: PluginStoreEntry) => {
- const entryKey = getStoreEntryKey(entry);
- const logo = resolvePluginAssetURL(entry.logo, apiBase);
- const repositoryURL = buildRepositoryURL(entry.repository);
- const homepageURL = /^https?:\/\//i.test(entry.homepage) ? entry.homepage : '';
- const isUpdate = entry.installed && entry.updateAvailable;
- const isOfficial = isOfficialPlugin(entry);
- const versionText =
- isUpdate && entry.installedVersion && entry.version
- ? t('plugin_store.version_arrow', { from: entry.installedVersion, to: entry.version })
- : entry.installed && entry.installedVersion
- ? `v${entry.installedVersion}`
- : entry.version
- ? `v${entry.version}`
- : '';
- const sourceName = isDefaultPluginStoreSource(entry)
- ? t('plugin_store.cli_proxy_api_source')
- : entry.sourceName;
- const sourceText = sourceName ? t('plugin_store.source_name', { source: sourceName }) : '';
- const metaItems = [versionText, sourceText, entry.author, entry.license].filter(Boolean);
- const isInstalling = installingKey === entryKey;
- const hasPendingInstall = Boolean(installingKey);
- const missingAuth = entry.authRequired && !entry.authConfigured;
- const isDescriptionExpanded = expandedDescriptionKeySet.has(entryKey);
- const isDescriptionOverflowing = overflowingDescriptionKeySet.has(entryKey);
- const descriptionID = getDescriptionDOMID(entryKey);
- const installTypeText = entry.installType ? formatInstallType(entry.installType) : '';
- const platformText =
- entry.platforms.length > 0
- ? t('plugin_store.platforms', {
- platforms: entry.platforms
- .map((platform) => `${platform.goos}/${platform.goarch}`)
- .join(', '),
- })
- : '';
- const authText = entry.authRequired
- ? entry.authConfigured
- ? t('plugin_store.auth_configured')
- : t('plugin_store.auth_required')
- : '';
- const actionDisabled = !connected || missingAuth || (hasPendingInstall && !isInstalling);
- const actionTitle = missingAuth ? t('plugin_store.auth_required_hint') : undefined;
-
- return (
-
-
-
-
-
-
-
{getStoreEntryTitle(entry)}
- {entry.id}
-
-
- {!isOfficial ? (
-
-
- {t('plugin_store.badge_untrusted')}
-
- ) : null}
- {isUpdate ? (
- {t('plugin_store.badge_update')}
- ) : entry.installed ? (
- {t('plugin_store.badge_installed')}
- ) : null}
- {entry.installed && entry.effectiveEnabled ? (
- {t('plugin_store.badge_effective')}
- ) : null}
- {entry.authRequired ? (
-
- {authText}
-
- ) : null}
-
-
-
- {entry.description ? (
-
-
registerDescriptionRef(entryKey, node)}
- className={`${styles.cardDesc} ${
- isDescriptionExpanded ? styles.cardDescExpanded : ''
- }`}
- >
- {entry.description}
-
- {isDescriptionOverflowing ? (
-
- ) : null}
-
- ) : null}
-
- {metaItems.length > 0 || installTypeText || platformText ? (
-
- {installTypeText ? (
-
- {t('plugin_store.install_type', { type: installTypeText })}
-
- ) : null}
- {platformText ? {platformText} : null}
- {metaItems.map((item, index) => (
-
- {index > 0 ? : null}
- {index === 0 && versionText ? {item} : item}
-
- ))}
-
- ) : null}
-
- {entry.tags.length > 0 ? (
-
- {entry.tags.map((tag) => (
-
- {tag}
-
- ))}
-
- ) : null}
-
-
-
- {!entry.installed ? (
-
- ) : (
- <>
- {entry.updateAvailable ? (
-
- ) : null}
-
- >
- )}
-
-
- {repositoryURL ? (
-
-
-
- ) : null}
- {homepageURL ? (
-
-
-
- ) : null}
-
-
-
- );
- };
-
- return (
-
- {/* ── Page Header ── */}
-
-
{t('plugin_store.title')}
-
{t('plugin_store.description')}
-
-
- {/* ── Security Banner ── */}
-
-
-
-
{t('plugin_store.security_banner_title')}
-
{t('plugin_store.security_banner_text')}
-
-
-
- {/* ── Alerts ── */}
- {error ? (
-
- {error.message}
- {error.kind !== 'unsupported' ? (
-
- ) : null}
-
- ) : null}
-
- {data?.sourceErrors.length ? (
-
-
{t('plugin_store.source_errors_title')}
-
- {data.sourceErrors.map((sourceError, index) => {
- const sourceLabel =
- sourceError.sourceName || sourceError.sourceUrl || sourceError.sourceId;
- return (
- -
- {sourceLabel}
- {sourceError.message ? {sourceError.message} : null}
-
- );
- })}
-
-
- ) : null}
-
- {data && !data.pluginsEnabled ? (
-
{t('plugin_store.global_disabled_hint')}
- ) : null}
-
- {restartNames.length > 0 ? (
-
- {t('plugin_store.restart_required_banner', { plugins: restartNames.join(', ') })}
-
- ) : null}
-
- {/* ── Status Bar ── */}
- {data ? (
-
-
-
- {t('plugin_store.global_status')}
-
- {data.pluginsEnabled
- ? t('plugin_store.global_enabled')
- : t('plugin_store.global_disabled')}
-
-
-
-
-
-
- {t('plugin_store.plugins_dir')}
-
- {data.pluginsDir || 'plugins'}
-
-
-
-
-
-
- {t('plugin_store.stat_available')}
- {stats.total}
-
-
- ) : null}
-
- {/* ── Toolbar ── */}
-
- setFilter(event.target.value)}
- placeholder={t('plugin_store.search_placeholder')}
- aria-label={t('plugin_store.search_label')}
- rightElement={}
- />
-
-
-
- {/* ── Status Filter Chips ── */}
-
- {statusFilters.map((item) => (
-
- ))}
-
-
- {/* ── Plugin Cards ── */}
- {loading ? (
-
- {Array.from({ length: 6 }, (_, index) => (
-
- ))}
-
- ) : visiblePlugins.length === 0 ? (
- !error ? (
- stats.total === 0 ? (
-
-
- {t('plugin_store.refresh')}
-
- }
- />
- ) : (
- {
- setFilter('');
- setStatusFilter('all');
- }}
- >
- {t('plugin_store.clear_filters')}
-
- ) : undefined
- }
- />
- )
- ) : null
- ) : (
- {visiblePlugins.map((entry) => renderCard(entry))}
- )}
-
-
-
-
- );
-}
diff --git a/frontend/src/features/plugins/PluginsPage.module.scss b/frontend/src/features/plugins/PluginsPage.module.scss
deleted file mode 100644
index 7fb9b02..0000000
--- a/frontend/src/features/plugins/PluginsPage.module.scss
+++ /dev/null
@@ -1,587 +0,0 @@
-@use '../../styles/variables' as *;
-@use '../../styles/mixins' as *;
-
-// ─── Page Container ─────────────────────────────────────
-
-.page {
- display: flex;
- flex-direction: column;
- gap: $spacing-lg;
- width: 100%;
-}
-
-// ─── Header ─────────────────────────────────────────────
-
-.pageHeader {
- display: flex;
- flex-direction: column;
- gap: $spacing-sm;
-}
-
-.title {
- margin: 0;
- color: var(--text-primary);
- font-size: 28px;
- font-weight: 700;
- line-height: 1.2;
-}
-
-.description {
- margin: 0;
- color: var(--text-secondary);
- font-size: 14px;
- line-height: 1.5;
-}
-
-// ─── Alert Boxes ────────────────────────────────────────
-
-.errorBox,
-.warningBox {
- padding: $spacing-md;
- border-radius: $radius-md;
- font-size: 14px;
- line-height: 1.5;
-}
-
-.errorBox {
- border: 1px solid var(--danger-color);
- background: rgba($error-color, 0.1);
- color: var(--danger-color);
-}
-
-.warningBox {
- border: 1px solid color-mix(in srgb, var(--warning-color, #c65746) 42%, var(--border-color));
- background: color-mix(in srgb, var(--warning-color, #c65746) 9%, var(--bg-secondary));
- color: var(--text-primary);
-}
-
-// ─── Status Bar ─────────────────────────────────────────
-
-.statusBar {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 14px;
- border-radius: 10px;
- border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
- background: color-mix(in srgb, var(--bg-secondary) 60%, transparent);
- flex-wrap: wrap;
-}
-
-.statusPill {
- display: inline-flex;
- min-width: 0;
- max-width: 100%;
- align-items: center;
- gap: 6px;
- padding: 5px 12px;
- border-radius: $radius-full;
- border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
- background: color-mix(in srgb, var(--bg-primary) 56%, transparent);
- font-size: 12px;
- font-weight: 600;
- white-space: nowrap;
-}
-
-.statusDot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.statusDotOn {
- background: $success-color;
- box-shadow: 0 0 6px rgba($success-color, 0.5);
-}
-
-.statusDotOff {
- background: var(--text-tertiary);
-}
-
-.statusLabel {
- color: var(--text-secondary);
-}
-
-.statusValue {
- color: var(--text-primary);
- font-weight: 700;
-}
-
-.statusPathValue {
- display: block;
- min-width: 0;
- max-width: min(360px, 58vw);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.statusDivider {
- width: 1px;
- height: 20px;
- background: color-mix(in srgb, var(--border-color) 60%, transparent);
- flex-shrink: 0;
-
- @include mobile {
- display: none;
- }
-}
-
-// ─── Toolbar ────────────────────────────────────────────
-
-.toolbar {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
-
- :global(.form-group) {
- flex: 1;
- max-width: 480px;
- margin: 0;
- }
-
- :global(.input) {
- padding-right: 36px;
- }
-
- :global(.btn > span) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- }
-
- @include mobile {
- flex-direction: column;
- align-items: stretch;
-
- :global(.form-group) {
- max-width: none;
- }
- }
-}
-
-// ─── Plugin List ────────────────────────────────────────
-
-.pluginList {
- display: flex;
- flex-direction: column;
- gap: 1px;
- border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
- border-radius: $radius-lg;
- overflow: hidden;
- background: color-mix(in srgb, var(--border-color) 30%, transparent);
-}
-
-// ─── Plugin Row ─────────────────────────────────────────
-
-.pluginRow {
- display: grid;
- grid-template-columns: 40px 1fr auto;
- align-items: center;
- gap: $spacing-md;
- padding: $spacing-md $spacing-lg;
- background: var(--bg-primary);
- transition: background-color $transition-fast;
-
- &:hover {
- background: var(--bg-hover);
- }
-
- @include mobile {
- grid-template-columns: 36px 1fr;
- grid-template-rows: auto auto;
- gap: $spacing-sm;
- padding: $spacing-md;
- }
-}
-
-.logoBox {
- display: inline-flex;
- width: 40px;
- height: 40px;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- border-radius: 10px;
- border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
- background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
- color: var(--text-secondary);
- flex-shrink: 0;
-
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
-
- @include mobile {
- width: 36px;
- height: 36px;
- border-radius: 8px;
- }
-}
-
-.pluginInfo {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
-}
-
-.pluginName {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
- min-width: 0;
-
- h2 {
- min-width: 0;
- margin: 0;
- color: var(--text-primary);
- font-size: 15px;
- font-weight: 650;
- line-height: 1.3;
- @include text-ellipsis;
- }
-}
-
-.pluginId {
- color: var(--text-tertiary);
- font-family: $font-mono;
- font-size: 12px;
- @include text-ellipsis;
-}
-
-.pluginMeta {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
- flex-wrap: wrap;
- margin-top: 2px;
-}
-
-.metaItem {
- display: inline-flex;
- min-width: 0;
- max-width: 100%;
- align-items: center;
- gap: 4px;
- color: var(--text-tertiary);
- font-size: 12px;
- white-space: nowrap;
-
- strong {
- color: var(--text-secondary);
- font-weight: 600;
- }
-}
-
-.metaPath {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.metaDot {
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: color-mix(in srgb, var(--text-tertiary) 50%, transparent);
- flex-shrink: 0;
-}
-
-// ─── Badges ─────────────────────────────────────────────
-
-.badgeRow {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
-}
-
-.badge,
-.badgeSuccess,
-.badgeWarning,
-.badgeMuted {
- display: inline-flex;
- min-height: 22px;
- align-items: center;
- border-radius: 6px;
- padding: 2px 8px;
- font-size: 11px;
- font-weight: 600;
- line-height: 1.25;
-}
-
-.badge {
- background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
- border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
- color: var(--text-secondary);
-}
-
-.badgeSuccess {
- background: rgba($success-color, 0.1);
- border: 1px solid rgba($success-color, 0.2);
- color: var(--success-color);
-}
-
-.badgeWarning {
- background: rgba($warning-color, 0.1);
- border: 1px solid rgba($warning-color, 0.2);
- color: var(--warning-color);
-}
-
-.badgeMuted {
- background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
- border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
- color: var(--text-tertiary);
-}
-
-// ─── Row Actions ────────────────────────────────────────
-
-.rowActions {
- display: flex;
- min-width: 0;
- max-width: min(560px, 48vw);
- flex-wrap: wrap;
- align-items: center;
- justify-content: flex-end;
- gap: $spacing-sm;
- flex-shrink: 0;
-
- :global(.btn > span) {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- }
-
- @include mobile {
- grid-column: 1 / -1;
- justify-content: flex-start;
- max-width: none;
- }
-}
-
-.iconLink {
- display: inline-flex;
- min-height: 30px;
- align-items: center;
- justify-content: center;
- border: 1px solid var(--border-color);
- border-radius: $radius-md;
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 12px;
- font-weight: 600;
- text-decoration: none;
- transition:
- border-color $transition-fast,
- background-color $transition-fast,
- color $transition-fast;
-
- &:hover {
- border-color: var(--primary-color);
- background: var(--bg-hover);
- color: var(--primary-color);
- }
-}
-
-.iconLink {
- width: 30px;
- flex: 0 0 auto;
-}
-
-// ─── Skeleton ───────────────────────────────────────────
-
-.skeletonRow {
- display: flex;
- align-items: center;
- gap: $spacing-md;
- padding: $spacing-md $spacing-lg;
- background: var(--bg-primary);
-}
-
-.skeletonAvatar {
- width: 40px;
- height: 40px;
- border-radius: 10px;
- background: linear-gradient(
- 90deg,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
- color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
- );
- background-size: 400% 100%;
- animation: skeletonPulse 1.35s ease-in-out infinite;
- flex-shrink: 0;
-}
-
-.skeletonText {
- display: flex;
- flex-direction: column;
- gap: 8px;
- flex: 1;
-}
-
-.skeletonLine {
- height: 14px;
- border-radius: 4px;
- background: linear-gradient(
- 90deg,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
- color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
- color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
- );
- background-size: 400% 100%;
- animation: skeletonPulse 1.35s ease-in-out infinite;
-
- &:first-child {
- width: 45%;
- }
-
- &:last-child {
- width: 70%;
- height: 10px;
- }
-}
-
-// ─── Sheet Config Form ──────────────────────────────────
-
-.sheetFooter {
- display: flex;
- justify-content: flex-end;
- gap: $spacing-sm;
-}
-
-.configForm {
- display: flex;
- flex-direction: column;
- gap: $spacing-lg;
-}
-
-.formSection {
- display: flex;
- flex-direction: column;
- gap: $spacing-md;
-
- h3 {
- margin: 0;
- color: var(--text-primary);
- font-size: 16px;
- font-weight: 700;
- }
-
- :global(.form-group) {
- margin: 0;
- }
-}
-
-.fieldRow {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $spacing-md;
- min-height: 52px;
- padding: $spacing-md;
- border: 1px solid var(--border-color);
- border-radius: $radius-md;
- background: color-mix(in srgb, var(--bg-secondary) 72%, transparent);
-}
-
-.fieldText {
- min-width: 0;
-}
-
-.fieldLabel {
- color: var(--text-primary);
- font-size: 14px;
- font-weight: 650;
- overflow-wrap: anywhere;
-}
-
-.fieldDescription,
-.fieldHint {
- margin-top: 4px;
- color: var(--text-tertiary);
- font-size: 12px;
- line-height: 1.45;
- overflow-wrap: anywhere;
-}
-
-.formField {
- display: flex;
- flex-direction: column;
- gap: 6px;
-
- label {
- color: var(--text-primary);
- font-size: 14px;
- font-weight: 600;
- overflow-wrap: anywhere;
- }
-}
-
-.textarea {
- min-height: 148px;
- resize: vertical;
- border: 1px solid var(--border-color);
- border-radius: $radius-md;
- padding: 10px 12px;
- background: var(--bg-primary);
- color: var(--text-primary);
- font-family: $font-mono;
- font-size: 13px;
- line-height: 1.5;
-
- &:focus {
- border-color: var(--primary-color);
- outline: none;
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent);
- }
-}
-
-.fieldError {
- padding: 8px 10px;
- border-radius: $radius-sm;
- background: rgba($error-color, 0.1);
- color: var(--danger-color);
- font-size: 12px;
- line-height: 1.4;
-}
-
-.emptyConfig {
- padding: $spacing-md;
- border: 1px dashed var(--border-color);
- border-radius: $radius-md;
- background: color-mix(in srgb, var(--bg-secondary) 70%, transparent);
- color: var(--text-secondary);
- font-size: 14px;
-}
-
-// ─── Animation ──────────────────────────────────────────
-
-@keyframes skeletonPulse {
- 0% {
- background-position: 100% 0;
- }
- 100% {
- background-position: 0 0;
- }
-}
-
-// ─── Mobile Overrides ───────────────────────────────────
-
-@include mobile {
- .page {
- gap: $spacing-md;
- }
-
- .sheetFooter {
- flex-direction: column-reverse;
-
- :global(.btn) {
- width: 100%;
- }
- }
-
-}
diff --git a/frontend/src/features/plugins/PluginsPage.tsx b/frontend/src/features/plugins/PluginsPage.tsx
deleted file mode 100644
index 767a30b..0000000
--- a/frontend/src/features/plugins/PluginsPage.tsx
+++ /dev/null
@@ -1,760 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
-import { Button } from '@/components/ui/Button';
-import { EmptyState } from '@/components/ui/EmptyState';
-import { Input } from '@/components/ui/Input';
-import { Select } from '@/components/ui/Select';
-import { Sheet } from '@/components/ui/Sheet';
-import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
-import {
- IconGithub,
- IconPlug,
- IconRefreshCw,
- IconSearch,
- IconSettings,
- IconSidebarStore,
- IconTrash2,
-} from '@/components/ui/icons';
-import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
-import { pluginsApi } from '@/services/api';
-import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
-import { getErrorMessage, isRecord } from '@/utils/helpers';
-import type {
- PluginConfigField,
- PluginListEntry,
- PluginListResponse,
-} from '@/types';
-import {
- buildPluginConfigDraft,
- buildPluginConfigPatch,
- normalizePluginConfigFieldType,
- type PluginConfigDraft,
-} from './pluginConfigDraft';
-import {
- getPluginTitle,
- notifyPluginResourcesChanged,
- resolvePluginAssetURL,
-} from './pluginResources';
-import { waitForPluginState } from './pluginPolling';
-import styles from './PluginsPage.module.scss';
-
-type PluginRuntimeWaitStatus = 'ready' | 'globalDisabled' | 'timeout';
-
-function PluginCardLogo({ src }: { src: string }) {
- const [failed, setFailed] = useState(false);
- const showImage = Boolean(src) && !failed;
-
- return showImage ? (
-
setFailed(true)} />
- ) : (
-
- );
-}
-
-const hasStatus = (error: unknown, status: number) => isRecord(error) && error.status === status;
-
-const hasRestartRequired = (value: unknown) => isRecord(value) && value.restart_required === true;
-
-const hasRestartRequiredError = (error: unknown) =>
- isRecord(error) && (hasRestartRequired(error.details) || hasRestartRequired(error.data));
-
-export function PluginsPage() {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const connectionStatus = useAuthStore((state) => state.connectionStatus);
- const apiBase = useAuthStore((state) => state.apiBase);
- const clearConfigCache = useConfigStore((state) => state.clearCache);
- const showNotification = useNotificationStore((state) => state.showNotification);
- const showConfirmation = useNotificationStore((state) => state.showConfirmation);
-
- const [data, setData] = useState(null);
- const [filter, setFilter] = useState('');
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState('');
- const [editingPlugin, setEditingPlugin] = useState(null);
- const [draft, setDraft] = useState(null);
- const [mutatingID, setMutatingID] = useState('');
- const [deletingID, setDeletingID] = useState('');
- const [openingConfigID, setOpeningConfigID] = useState('');
- const configRequestSeq = useRef(0);
-
- const connected = connectionStatus === 'connected';
-
- const loadPlugins = useCallback(async () => {
- if (!connected) {
- setLoading(false);
- setError(t('notification.connection_required'));
- return;
- }
-
- setLoading(true);
- setError('');
- try {
- const plugins = await pluginsApi.list();
- setData(plugins);
- } catch (err: unknown) {
- setError(
- hasStatus(err, 404)
- ? t('plugin_management.unsupported_backend')
- : getErrorMessage(err, t('plugin_management.load_failed'))
- );
- } finally {
- setLoading(false);
- }
- }, [connected, t]);
-
- const waitForPluginRuntimeState = useCallback(
- async (id: string, enabled: boolean): Promise => {
- const result = await waitForPluginState(id, (item, response) =>
- enabled
- ? !response.pluginsEnabled || (item.registered && item.effectiveEnabled)
- : !item.effectiveEnabled
- );
- setData(result.response);
- if (enabled && !result.response.pluginsEnabled) {
- return 'globalDisabled';
- }
- return result.timedOut ? 'timeout' : 'ready';
- },
- []
- );
-
- useHeaderRefresh(loadPlugins, connected);
-
- useEffect(() => {
- void loadPlugins();
- }, [loadPlugins]);
-
- const pluginStats = useMemo(() => {
- const plugins = data?.plugins ?? [];
- return {
- discovered: plugins.length,
- registered: plugins.filter((plugin) => plugin.registered).length,
- configured: plugins.filter((plugin) => plugin.configured).length,
- effective: plugins.filter((plugin) => plugin.effectiveEnabled).length,
- };
- }, [data?.plugins]);
-
- const visiblePlugins = useMemo(() => {
- const query = filter.trim().toLowerCase();
- const plugins = data?.plugins ?? [];
- if (!query) return plugins;
-
- return plugins.filter((plugin) => {
- const haystack = [
- plugin.id,
- plugin.path,
- plugin.metadata?.name,
- plugin.metadata?.author,
- plugin.metadata?.version,
- plugin.metadata?.githubRepository,
- ...plugin.menus.map((menu) => `${menu.menu} ${menu.path} ${menu.description}`),
- ]
- .filter(Boolean)
- .join(' ')
- .toLowerCase();
- return haystack.includes(query);
- });
- }, [data?.plugins, filter]);
-
- const resolvePluginAsset = useCallback(
- (value: string) => resolvePluginAssetURL(value, apiBase),
- [apiBase]
- );
-
- const openConfigSheet = async (plugin: PluginListEntry) => {
- if (openingConfigID || mutatingID || deletingID) return;
-
- const requestSeq = configRequestSeq.current + 1;
- configRequestSeq.current = requestSeq;
- setOpeningConfigID(plugin.id);
- setEditingPlugin(plugin);
- setDraft(null);
-
- try {
- const currentConfig = await pluginsApi.getConfig(plugin.id);
- if (configRequestSeq.current !== requestSeq) return;
-
- setDraft(buildPluginConfigDraft(plugin, currentConfig));
- } catch (err: unknown) {
- if (configRequestSeq.current !== requestSeq) return;
-
- setEditingPlugin(null);
- setDraft(null);
- showNotification(
- hasStatus(err, 404)
- ? t('plugin_management.config_not_found')
- : `${t('plugin_management.config_load_failed')}: ${getErrorMessage(
- err,
- t('plugin_management.config_load_failed')
- )}`,
- 'error'
- );
- } finally {
- if (configRequestSeq.current === requestSeq) {
- setOpeningConfigID('');
- }
- }
- };
-
- const closeConfigSheet = () => {
- if (mutatingID || openingConfigID || deletingID) return;
- setEditingPlugin(null);
- setDraft(null);
- };
-
- const updateDraft = (updater: (current: PluginConfigDraft) => PluginConfigDraft) => {
- setDraft((current) => (current ? updater(current) : current));
- };
-
- const handleTogglePlugin = async (plugin: PluginListEntry, enabled: boolean) => {
- if (deletingID) return;
- setMutatingID(plugin.id);
- try {
- await pluginsApi.updateEnabled(plugin.id, enabled);
- clearConfigCache();
- const status = await waitForPluginRuntimeState(plugin.id, enabled);
- if (status === 'ready') {
- notifyPluginResourcesChanged();
- showNotification(t('plugin_management.toggle_success'), 'success');
- } else {
- showNotification(
- t(
- status === 'globalDisabled'
- ? 'plugin_management.global_disabled_hint'
- : 'plugin_management.runtime_pending'
- ),
- 'warning'
- );
- }
- } catch (err: unknown) {
- showNotification(
- `${t('plugin_management.toggle_failed')}: ${getErrorMessage(
- err,
- t('plugin_management.toggle_failed')
- )}`,
- 'error'
- );
- } finally {
- setMutatingID('');
- }
- };
-
- const handleDeletePlugin = (plugin: PluginListEntry) => {
- if (!connected || mutatingID || openingConfigID || deletingID) return;
-
- const name = getPluginTitle(plugin);
- showConfirmation({
- title: t('plugin_management.delete_confirm_title'),
- message: t('plugin_management.delete_confirm_message', { name, id: plugin.id }),
- variant: 'danger',
- confirmText: t('plugin_management.delete_plugin'),
- onConfirm: async () => {
- setDeletingID(plugin.id);
- setMutatingID(plugin.id);
- try {
- const result = await pluginsApi.deletePlugin(plugin.id);
- clearConfigCache();
- if (editingPlugin?.id === plugin.id) {
- setEditingPlugin(null);
- setDraft(null);
- }
- await loadPlugins();
- notifyPluginResourcesChanged();
- showNotification(t('plugin_management.delete_success'), 'success');
- if (result.restartRequired) {
- showNotification(t('plugin_management.delete_restart_required'), 'warning');
- }
- } catch (err: unknown) {
- const restartRequired = hasRestartRequiredError(err);
- const fallback = restartRequired
- ? t('plugin_management.delete_restart_required')
- : t('plugin_management.delete_failed');
- showNotification(
- `${t('plugin_management.delete_failed')}: ${getErrorMessage(err, fallback)}`,
- restartRequired ? 'warning' : 'error'
- );
- } finally {
- setDeletingID('');
- setMutatingID('');
- }
- },
- });
- };
-
- const handleSaveConfig = async () => {
- if (!editingPlugin || !draft || openingConfigID || mutatingID || deletingID) return;
- const { patch, errors } = buildPluginConfigPatch(draft, editingPlugin.configFields, t);
-
- if (Object.keys(errors).length > 0) {
- setDraft({ ...draft, errors });
- showNotification(t('plugin_management.validation_failed'), 'warning');
- return;
- }
-
- if (Object.keys(patch).length === 0) {
- setEditingPlugin(null);
- setDraft(null);
- showNotification(t('plugin_management.save_success'), 'success');
- return;
- }
-
- setMutatingID(editingPlugin.id);
- try {
- await pluginsApi.patchConfig(editingPlugin.id, patch);
- clearConfigCache();
- const enabledChanged =
- typeof patch.enabled === 'boolean' && patch.enabled !== editingPlugin.enabled;
- const status = enabledChanged
- ? await waitForPluginRuntimeState(editingPlugin.id, patch.enabled === true)
- : await loadPlugins().then((): PluginRuntimeWaitStatus => 'ready');
- if (status === 'ready') {
- notifyPluginResourcesChanged();
- }
- setEditingPlugin(null);
- setDraft(null);
- if (status === 'ready') {
- showNotification(t('plugin_management.save_success'), 'success');
- } else {
- showNotification(
- t(
- status === 'globalDisabled'
- ? 'plugin_management.global_disabled_hint'
- : 'plugin_management.runtime_pending'
- ),
- 'warning'
- );
- }
- } catch (err: unknown) {
- showNotification(
- `${t('plugin_management.save_failed')}: ${getErrorMessage(
- err,
- t('plugin_management.save_failed')
- )}`,
- 'error'
- );
- } finally {
- setMutatingID('');
- }
- };
-
- const handleFieldTextChange =
- (fieldName: string) => (event: ChangeEvent) => {
- const value = event.target.value;
- updateDraft((current) => ({
- ...current,
- values: { ...current.values, [fieldName]: value },
- errors: { ...current.errors, [fieldName]: '' },
- touchedFields: { ...current.touchedFields, [fieldName]: true },
- }));
- };
-
- const handleFieldBooleanChange = (fieldName: string, value: boolean) => {
- updateDraft((current) => ({
- ...current,
- values: { ...current.values, [fieldName]: value },
- errors: { ...current.errors, [fieldName]: '' },
- touchedFields: { ...current.touchedFields, [fieldName]: true },
- }));
- };
-
- const handlePriorityChange = (event: ChangeEvent) => {
- const value = event.target.value;
- updateDraft((current) => ({
- ...current,
- priority: value,
- errors: { ...current.errors, priority: '' },
- priorityTouched: true,
- }));
- };
-
- const renderFieldEditor = (field: PluginConfigField) => {
- if (!draft) return null;
- const fieldType = normalizePluginConfigFieldType(field);
- const value = draft.values[field.name];
- const textValue = typeof value === 'string' ? value : '';
- const errorText = draft.errors[field.name];
-
- if (fieldType === 'boolean') {
- return (
-
-
-
{field.name}
- {field.description ? (
-
{field.description}
- ) : null}
-
-
handleFieldBooleanChange(field.name, nextValue)}
- ariaLabel={field.name}
- />
-
- );
- }
-
- if (fieldType === 'enum' && field.enumValues.length > 0) {
- return (
-
-
-
- );
- }
-
- if (fieldType === 'array' || fieldType === 'object') {
- return (
-
-
-
- {field.description ?
{field.description}
: null}
- {errorText ?
{errorText}
: null}
-
- );
- }
-
- return (
-
- );
- };
-
- const savingConfig = Boolean(editingPlugin && mutatingID === editingPlugin.id);
-
- return (
-
- {/* ── Page Header ── */}
-
-
{t('plugin_management.title')}
-
{t('plugin_management.description')}
-
-
- {/* ── Alerts ── */}
- {error ?
{error}
: null}
-
- {data && !data.pluginsEnabled ? (
-
{t('plugin_management.global_disabled_hint')}
- ) : null}
-
- {/* ── Status Bar ── */}
- {data ? (
-
-
-
- {t('plugin_management.global_status')}
-
- {data.pluginsEnabled
- ? t('plugin_management.global_enabled')
- : t('plugin_management.global_disabled')}
-
-
-
-
-
-
- {t('plugin_management.plugins_dir')}
-
- {data.pluginsDir || 'plugins'}
-
-
-
-
-
-
- {t('plugin_management.discovered')}
- {pluginStats.discovered}
-
-
-
-
-
- {t('plugin_management.effective')}
-
- {pluginStats.effective}/{pluginStats.registered}
-
-
-
- ) : null}
-
- {/* ── Toolbar ── */}
-
- setFilter(event.target.value)}
- placeholder={t('plugin_management.search_placeholder')}
- aria-label={t('plugin_management.search_label')}
- rightElement={}
- />
-
-
-
-
- {/* ── Plugin List ── */}
- {loading ? (
-
- {Array.from({ length: 4 }, (_, index) => (
-
- ))}
-
- ) : visiblePlugins.length === 0 ? (
-
-
- {t('plugin_management.refresh')}
-
- }
- />
- ) : (
-
- {visiblePlugins.map((plugin) => {
- const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || '');
- const github = plugin.metadata?.githubRepository.trim();
- const openingConfig = openingConfigID === plugin.id;
- const deletingPlugin = deletingID === plugin.id;
- const actionBusy = Boolean(mutatingID || openingConfigID || deletingID);
- const version = plugin.metadata?.version;
- const author = plugin.metadata?.author;
-
- return (
-
- {/* Logo */}
-
-
- {/* Info */}
-
-
-
{getPluginTitle(plugin)}
-
-
- {plugin.effectiveEnabled
- ? t('plugin_management.status_effective')
- : t('plugin_management.status_inactive')}
-
-
- {plugin.registered
- ? t('plugin_management.registered')
- : t('plugin_management.not_registered')}
-
-
- {plugin.configured
- ? t('plugin_management.configured')
- : t('plugin_management.not_configured')}
-
- {plugin.supportsOAuth ? (
- {t('plugin_management.oauth')}
- ) : null}
-
-
-
-
{plugin.id}
-
- {version || author || plugin.path ? (
-
- {version ? (
-
- {version}
-
- ) : null}
- {version && author ? (
-
- ) : null}
- {author ? {author} : null}
- {(version || author) && plugin.path ? (
-
- ) : null}
- {plugin.path ? (
-
- {plugin.path}
-
- ) : null}
-
- ) : null}
-
-
- {/* Actions */}
-
-
handleTogglePlugin(plugin, enabled)}
- disabled={!connected || actionBusy}
- ariaLabel={t('plugin_management.enabled')}
- />
-
-
- {github ? (
-
-
-
- ) : null}
-
-
- );
- })}
-
- )}
-
- {/* ── Config Sheet ── */}
-
-
-
-
- }
- >
- {draft && editingPlugin ? (
-
-
- {t('plugin_management.base_settings')}
-
-
-
{t('plugin_management.enabled')}
-
- {t('plugin_management.enabled_hint')}
-
-
-
- updateDraft((current) => ({
- ...current,
- enabled,
- enabledTouched: true,
- }))
- }
- ariaLabel={t('plugin_management.enabled')}
- />
-
-
-
-
-
- {t('plugin_management.config_fields')}
- {editingPlugin.configFields.length > 0 ? (
- editingPlugin.configFields.map((field) => renderFieldEditor(field))
- ) : (
- {t('plugin_management.no_config_fields')}
- )}
-
-
- ) : null}
-
-
- );
-}
diff --git a/frontend/src/features/plugins/components/PluginInstallGateModal.module.scss b/frontend/src/features/plugins/components/PluginInstallGateModal.module.scss
deleted file mode 100644
index cc4ea77..0000000
--- a/frontend/src/features/plugins/components/PluginInstallGateModal.module.scss
+++ /dev/null
@@ -1,203 +0,0 @@
-// Multi-step install confirmation ("gauntlet") for third-party plugins.
-
-.gateModal {
- text-align: left;
-}
-
-// ── Plugin identity card (shown on every step) ──
-.identity {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- padding: $spacing-sm 0 $spacing-md;
- text-align: center;
- border-bottom: 1px solid var(--border-color);
-}
-
-.logoBox {
- display: inline-flex;
- width: 52px;
- height: 52px;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- border-radius: 12px;
- border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
- background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
- color: var(--text-secondary);
-
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
-}
-
-.name {
- margin: 0;
- font-size: 20px;
- font-weight: 700;
- color: var(--text-primary);
- overflow-wrap: anywhere;
-}
-
-.slug {
- margin: 0;
- font-family: $font-mono;
- font-size: 13px;
- color: var(--text-secondary);
- overflow-wrap: anywhere;
-}
-
-.repoLink {
- display: inline-flex;
- max-width: 100%;
- align-items: center;
- gap: 4px;
- margin: 0;
- font-family: $font-mono;
- font-size: 13px;
- color: var(--text-secondary);
- text-decoration: none;
-
- span {
- min-width: 0;
- overflow-wrap: anywhere;
- }
-
- svg {
- flex-shrink: 0;
- color: var(--text-tertiary);
- }
-
- &:hover {
- color: var(--accent-color);
- text-decoration: underline;
-
- svg {
- color: currentColor;
- }
- }
-
- &:focus-visible {
- outline: 2px solid var(--focus-ring-color, var(--accent-color));
- outline-offset: 3px;
- border-radius: $radius-sm;
- }
-}
-
-.source {
- margin: 0;
- font-size: 12px;
- color: var(--text-tertiary);
- overflow-wrap: anywhere;
-}
-
-// ── Step 2: caution banner + effects + untrusted alert ──
-.warningBanner {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
- margin-top: $spacing-lg;
- padding: 12px 14px;
- border-radius: $radius-md;
- border: 1px solid color-mix(in srgb, var(--quota-medium-color, #e0aa14) 45%, var(--border-color));
- background: color-mix(in srgb, var(--quota-medium-color, #e0aa14) 16%, var(--bg-secondary));
- color: var(--text-primary);
- font-weight: 600;
- font-size: 14px;
- line-height: 1.4;
-
- svg {
- flex-shrink: 0;
- color: var(--quota-medium-color, #e0aa14);
- }
-}
-
-.effects {
- margin: $spacing-md 0 0;
- padding-left: $spacing-md;
- border-left: 2px solid var(--border-color);
- list-style: none;
- display: flex;
- flex-direction: column;
- gap: 12px;
-
- li {
- position: relative;
- padding-left: 18px;
- font-size: 14px;
- line-height: 1.5;
- color: var(--text-secondary);
-
- &::before {
- content: '';
- position: absolute;
- left: 0;
- top: 7px;
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--text-tertiary);
- }
- }
-}
-
-.untrustedAlert {
- margin-top: $spacing-lg;
- padding: 12px 14px;
- border-radius: $radius-md;
- border: 1px solid rgba($warning-color, 0.35);
- background: rgba($warning-color, 0.1);
-}
-
-.untrustedText {
- margin: 0;
- font-size: 13.5px;
- line-height: 1.5;
- font-weight: 600;
- color: var(--danger-color);
-}
-
-.originGrid {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: 4px 12px;
- margin: 10px 0 0;
-
- dt {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary);
- }
-
- dd {
- margin: 0;
- font-family: $font-mono;
- font-size: 12px;
- color: var(--text-primary);
- overflow-wrap: anywhere;
- }
-}
-
-// ── Step 3: type-to-confirm ──
-.confirmBlock {
- margin-top: $spacing-lg;
-}
-
-.confirmPrompt {
- display: block;
- margin-bottom: $spacing-sm;
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
- line-height: 1.5;
- overflow-wrap: anywhere;
-}
-
-.confirmHint {
- margin: $spacing-sm 0 0;
- font-size: 12px;
- color: var(--text-tertiary);
-}
diff --git a/frontend/src/features/plugins/components/PluginInstallGateModal.tsx b/frontend/src/features/plugins/components/PluginInstallGateModal.tsx
deleted file mode 100644
index e0b1371..0000000
--- a/frontend/src/features/plugins/components/PluginInstallGateModal.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { useState, type ReactNode } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Button } from '@/components/ui/Button';
-import { Input } from '@/components/ui/Input';
-import { Modal } from '@/components/ui/Modal';
-import { IconAlertTriangle, IconExternalLink, IconPlug } from '@/components/ui/icons';
-import { useAuthStore } from '@/stores';
-import type { PluginStoreEntry } from '@/types';
-import {
- buildRepositoryURL,
- getPluginConfirmToken,
- getPluginRepositorySlug,
- isDefaultPluginStoreSource,
- resolvePluginAssetURL,
-} from '../pluginResources';
-import styles from './PluginInstallGateModal.module.scss';
-
-interface PluginInstallGateModalProps {
- open: boolean;
- entry: PluginStoreEntry | null;
- isUpdate: boolean;
- installing: boolean;
- onClose: () => void;
- onConfirm: () => void | Promise;
-}
-
-function GateLogo({ src }: { src: string }) {
- const [failed, setFailed] = useState(false);
- return src && !failed ? (
-
setFailed(true)} />
- ) : (
-
- );
-}
-
-export function PluginInstallGateModal({
- open,
- entry,
- isUpdate,
- installing,
- onClose,
- onConfirm,
-}: PluginInstallGateModalProps) {
- const { t } = useTranslation();
- const apiBase = useAuthStore((state) => state.apiBase);
- const [step, setStep] = useState(1);
- const [typed, setTyped] = useState('');
- const [wasOpen, setWasOpen] = useState(false);
-
- // Reset the gauntlet to step 1 on each fresh open. Adjusting state during render
- // (React's "you might not need an effect" guidance) avoids a setState-in-effect.
- if (open !== wasOpen) {
- setWasOpen(open);
- if (open) {
- setStep(1);
- setTyped('');
- }
- }
-
- if (!entry) return null;
-
- const title = entry.name || entry.id;
- const repoSlug = getPluginRepositorySlug(entry.repository);
- const repositoryURL = buildRepositoryURL(entry.repository);
- const repoLabel = repoSlug || entry.id;
- const token = getPluginConfirmToken(entry);
- const logo = resolvePluginAssetURL(entry.logo, apiBase);
- const rawSourceText = entry.sourceName || entry.sourceUrl;
- const sourceText = isDefaultPluginStoreSource(entry)
- ? t('plugin_store.cli_proxy_api_source')
- : rawSourceText;
- const tokenMatches = typed.trim() === token;
-
- const handleClose = () => {
- if (installing) return;
- onClose();
- };
-
- const handleFinalConfirm = async () => {
- try {
- await onConfirm();
- } catch {
- // The caller surfaces the error via a notification; stay on this step.
- }
- };
-
- const identity = (
-
-
-
-
-
{title}
- {repositoryURL ? (
-
- {repoLabel}
-
-
- ) : (
-
{repoLabel}
- )}
- {sourceText ? (
-
{t('plugin_store.source_name', { source: sourceText })}
- ) : null}
-
- );
-
- let body: ReactNode;
- let footer: ReactNode;
-
- if (step === 1) {
- body = identity;
- footer = (
-
- );
- } else if (step === 2) {
- body = (
- <>
- {identity}
-
-
- {t('plugin_store.gate_warning')}
-
-
- - {t('plugin_store.gate_effect_runs_code')}
- - {t('plugin_store.gate_effect_no_review')}
- - {t('plugin_store.gate_effect_restart')}
-
-
-
{t('plugin_store.gate_untrusted_alert')}
-
- - {t('plugin_store.gate_repository_label')}
- - {repoSlug || entry.repository || '—'}
- - {t('plugin_store.gate_source_label')}
- - {sourceText || '—'}
-
-
- >
- );
- footer = (
-
- );
- } else {
- body = (
- <>
- {identity}
-
-
-
setTyped(event.target.value)}
- autoComplete="off"
- spellCheck={false}
- disabled={installing}
- aria-label={t('plugin_store.gate_step3_prompt', { token })}
- />
-
{t('plugin_store.gate_step3_hint')}
-
- >
- );
- footer = (
-
- );
- }
-
- return (
-
- {body}
-
- );
-}
diff --git a/frontend/src/features/plugins/pluginConfigDraft.ts b/frontend/src/features/plugins/pluginConfigDraft.ts
deleted file mode 100644
index c34383e..0000000
--- a/frontend/src/features/plugins/pluginConfigDraft.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import type { PluginConfigField, PluginConfigObject, PluginListEntry } from '@/types';
-import { isRecord } from '@/utils/helpers';
-
-export type PluginDraftValue = string | boolean;
-
-export interface PluginConfigDraft {
- enabled: boolean;
- priority: string;
- values: Record;
- errors: Record;
- enabledTouched: boolean;
- priorityTouched: boolean;
- touchedFields: Record;
-}
-
-type Translate = (key: string, options?: Record) => string;
-
-export const normalizePluginConfigFieldType = (field: PluginConfigField): string =>
- field.type.trim().toLowerCase();
-
-const stringifyJSONValue = (value: unknown): string => {
- if (value === undefined || value === null) return '';
- try {
- return JSON.stringify(value, null, 2);
- } catch {
- return String(value);
- }
-};
-
-const getFieldDraftValue = (field: PluginConfigField, value: unknown): PluginDraftValue => {
- const type = normalizePluginConfigFieldType(field);
- if (type === 'boolean') return value === true;
- if (type === 'array' || type === 'object') return stringifyJSONValue(value);
- if (value === undefined || value === null) return '';
- return String(value);
-};
-
-export function buildPluginConfigDraft(
- plugin: Pick,
- currentConfig: PluginConfigObject
-): PluginConfigDraft {
- const enabled =
- typeof currentConfig.enabled === 'boolean' ? currentConfig.enabled : plugin.enabled;
- const priority =
- typeof currentConfig.priority === 'number' || typeof currentConfig.priority === 'string'
- ? String(currentConfig.priority)
- : '0';
- const values: PluginConfigDraft['values'] = {};
-
- plugin.configFields.forEach((field) => {
- values[field.name] = getFieldDraftValue(field, currentConfig[field.name]);
- });
-
- return {
- enabled,
- priority,
- values,
- errors: {},
- enabledTouched: false,
- priorityTouched: false,
- touchedFields: {},
- };
-}
-
-const parseJSONField = (
- text: string,
- fieldType: string,
- fieldName: string,
- t: Translate,
- errors: Record
-) => {
- try {
- const parsed = JSON.parse(text);
- if (fieldType === 'array' && !Array.isArray(parsed)) {
- errors[fieldName] = t('plugin_management.expected_array');
- return undefined;
- }
- if (fieldType === 'object' && !isRecord(parsed)) {
- errors[fieldName] = t('plugin_management.expected_object');
- return undefined;
- }
- return parsed;
- } catch {
- errors[fieldName] = t('plugin_management.invalid_json');
- return undefined;
- }
-};
-
-export function buildPluginConfigPatch(
- draft: PluginConfigDraft,
- fields: PluginConfigField[],
- t: Translate
-): { patch: PluginConfigObject; errors: Record } {
- const errors: Record = {};
- const patch: PluginConfigObject = {};
-
- if (draft.enabledTouched) patch.enabled = draft.enabled;
-
- if (draft.priorityTouched) {
- const priorityText = draft.priority.trim();
- if (!priorityText) {
- patch.priority = 0;
- } else if (!/^-?\d+$/.test(priorityText)) {
- errors.priority = t('plugin_management.invalid_priority');
- } else {
- patch.priority = Number.parseInt(priorityText, 10);
- }
- }
-
- fields.forEach((field) => {
- if (!draft.touchedFields[field.name]) return;
-
- const fieldType = normalizePluginConfigFieldType(field);
- const value = draft.values[field.name];
-
- if (fieldType === 'boolean') {
- patch[field.name] = value === true;
- return;
- }
-
- const text = typeof value === 'string' ? value.trim() : '';
- if (!text) {
- patch[field.name] = null;
- return;
- }
-
- if (fieldType === 'enum') {
- if (field.enumValues.length > 0 && !field.enumValues.includes(text)) {
- errors[field.name] = t('plugin_management.invalid_enum');
- return;
- }
- patch[field.name] = text;
- return;
- }
-
- if (fieldType === 'number') {
- const parsed = Number(text);
- if (!Number.isFinite(parsed)) {
- errors[field.name] = t('plugin_management.invalid_number');
- return;
- }
- patch[field.name] = parsed;
- return;
- }
-
- if (fieldType === 'integer') {
- if (!/^-?\d+$/.test(text)) {
- errors[field.name] = t('plugin_management.invalid_integer');
- return;
- }
- patch[field.name] = Number.parseInt(text, 10);
- return;
- }
-
- if (fieldType === 'array' || fieldType === 'object') {
- const parsed = parseJSONField(text, fieldType, field.name, t, errors);
- if (!errors[field.name]) patch[field.name] = parsed;
- return;
- }
-
- patch[field.name] = text;
- });
-
- return { patch, errors };
-}
diff --git a/frontend/src/features/plugins/pluginPolling.ts b/frontend/src/features/plugins/pluginPolling.ts
deleted file mode 100644
index b12fe12..0000000
--- a/frontend/src/features/plugins/pluginPolling.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { pluginsApi, pluginStoreApi } from '@/services/api';
-import type {
- PluginListEntry,
- PluginListResponse,
- PluginStoreEntry,
- PluginStoreResponse,
-} from '@/types';
-
-const PLUGIN_STATE_TIMEOUT_MS = 15_000;
-const PLUGIN_STATE_INTERVAL_MS = 500;
-
-const wait = (ms: number) =>
- new Promise((resolve) => {
- window.setTimeout(resolve, ms);
- });
-
-export interface PluginStateWaitResult {
- response: PluginListResponse;
- plugin: PluginListEntry | null;
- timedOut: boolean;
-}
-
-export interface PluginStoreStateWaitResult {
- response: PluginStoreResponse;
- plugin: PluginStoreEntry | null;
- timedOut: boolean;
-}
-
-export async function waitForPluginState(
- id: string,
- predicate: (plugin: PluginListEntry, response: PluginListResponse) => boolean,
- timeoutMs = PLUGIN_STATE_TIMEOUT_MS,
- intervalMs = PLUGIN_STATE_INTERVAL_MS
-): Promise {
- const deadline = Date.now() + timeoutMs;
- let latest = await pluginsApi.list();
-
- for (;;) {
- const plugin = latest.plugins.find((item) => item.id === id) ?? null;
- if (plugin && predicate(plugin, latest)) {
- return { response: latest, plugin, timedOut: false };
- }
- if (Date.now() >= deadline) {
- return { response: latest, plugin, timedOut: true };
- }
- await wait(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
- latest = await pluginsApi.list();
- }
-}
-
-export async function waitForPluginStoreState(
- id: string,
- sourceId: string,
- predicate: (plugin: PluginStoreEntry, response: PluginStoreResponse) => boolean,
- timeoutMs = PLUGIN_STATE_TIMEOUT_MS,
- intervalMs = PLUGIN_STATE_INTERVAL_MS
-): Promise {
- const deadline = Date.now() + timeoutMs;
- let latest = await pluginStoreApi.list();
-
- for (;;) {
- const plugin =
- latest.plugins.find((item) => item.id === id && (!sourceId || item.sourceId === sourceId)) ??
- null;
- if (plugin && predicate(plugin, latest)) {
- return { response: latest, plugin, timedOut: false };
- }
- if (Date.now() >= deadline) {
- return { response: latest, plugin, timedOut: true };
- }
- await wait(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
- latest = await pluginStoreApi.list();
- }
-}
diff --git a/frontend/src/features/plugins/pluginReleaseVersions.ts b/frontend/src/features/plugins/pluginReleaseVersions.ts
deleted file mode 100644
index 111c680..0000000
--- a/frontend/src/features/plugins/pluginReleaseVersions.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
-import { isRecord } from '@/utils/helpers';
-
-export interface PluginReleaseVersion {
- tagName: string;
- name: string;
- publishedAt: string;
- prerelease: boolean;
- htmlUrl: string;
- assetNames: string[];
-}
-
-const GITHUB_API_BASE = 'https://api.github.com';
-const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']);
-const GITHUB_RELEASES_PAGE_SIZE = 50;
-
-export const supportsPluginVersionSelection = (installType: string): boolean =>
- installType.trim().toLowerCase() === 'github-release';
-
-const stripGitSuffix = (value: string) => value.replace(/\.git$/i, '');
-
-export const getGitHubRepositorySlug = (repository: string): string => {
- const trimmed = repository.trim();
- if (!trimmed) return '';
-
- if (/^https?:\/\//i.test(trimmed)) {
- try {
- const url = new URL(trimmed);
- if (!GITHUB_HOSTS.has(url.hostname.toLowerCase())) return '';
- const [owner = '', repo = ''] = url.pathname.replace(/^\/+/, '').split('/');
- if (!owner || !repo) return '';
- return `${owner}/${stripGitSuffix(repo)}`;
- } catch {
- return '';
- }
- }
-
- const withoutHost = trimmed.replace(/^github\.com\//i, '').replace(/^\/+/, '');
- const [owner = '', repo = ''] = withoutHost.split('/');
- if (!owner || !repo) return '';
- return `${owner}/${stripGitSuffix(repo)}`;
-};
-
-export const buildGitHubReleasesPageURL = (repository: string): string => {
- const slug = getGitHubRepositorySlug(repository);
- return slug ? `https://github.com/${slug}/releases` : '';
-};
-
-export const isValidManualReleaseTag = (value: string): boolean => {
- const trimmed = value.trim();
- return Boolean(trimmed) && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/.test(trimmed);
-};
-
-const normalizeAssetNames = (value: unknown): string[] => {
- if (!Array.isArray(value)) return [];
- return value
- .map((asset) => (isRecord(asset) && typeof asset.name === 'string' ? asset.name.trim() : ''))
- .filter(Boolean);
-};
-
-const normalizeRelease = (value: unknown): PluginReleaseVersion | null => {
- if (!isRecord(value) || typeof value.tag_name !== 'string') return null;
- const tagName = value.tag_name.trim();
- if (!tagName) return null;
-
- return {
- tagName,
- name: typeof value.name === 'string' ? value.name.trim() : '',
- publishedAt: typeof value.published_at === 'string' ? value.published_at : '',
- prerelease: value.prerelease === true,
- htmlUrl: typeof value.html_url === 'string' ? value.html_url.trim() : '',
- assetNames: normalizeAssetNames(value.assets),
- };
-};
-
-export const fetchPluginReleaseVersions = async (
- repository: string
-): Promise => {
- const slug = getGitHubRepositorySlug(repository);
- if (!slug) {
- throw new Error('Repository is not a GitHub repository');
- }
-
- const result = await apiCallApi.request({
- method: 'GET',
- url: `${GITHUB_API_BASE}/repos/${slug}/releases?per_page=${GITHUB_RELEASES_PAGE_SIZE}`,
- header: {
- Accept: 'application/vnd.github+json',
- 'X-GitHub-Api-Version': '2022-11-28',
- },
- });
-
- if (result.statusCode < 200 || result.statusCode >= 300) {
- throw new Error(getApiCallErrorMessage(result));
- }
-
- if (!Array.isArray(result.body)) {
- throw new Error('GitHub releases response is not a list');
- }
-
- return result.body
- .map(normalizeRelease)
- .filter((release): release is PluginReleaseVersion => Boolean(release));
-};
diff --git a/frontend/src/features/plugins/pluginResources.ts b/frontend/src/features/plugins/pluginResources.ts
deleted file mode 100644
index 98ed462..0000000
--- a/frontend/src/features/plugins/pluginResources.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import type { PluginListEntry, PluginMenu, PluginStoreEntry } from '@/types';
-import { normalizeApiBase } from '@/utils/connection';
-
-export const PLUGIN_RESOURCES_REFRESH_EVENT = 'plugin-resources-refresh';
-
-export const notifyPluginResourcesChanged = () => {
- window.dispatchEvent(new Event(PLUGIN_RESOURCES_REFRESH_EVENT));
-};
-
-export interface PluginResourceEntry {
- pluginID: string;
- pluginTitle: string;
- pluginLogo: string;
- menuIndex: number;
- menu: PluginMenu;
- label: string;
- description: string;
- route: string;
-}
-
-export const getPluginTitle = (plugin: PluginListEntry) =>
- plugin.metadata?.name.trim() || plugin.id;
-
-export const buildPluginResourceRoute = (pluginID: string, menuIndex: number) =>
- `/plugin-pages/${encodeURIComponent(pluginID)}/${menuIndex}`;
-
-export const resolvePluginAssetURL = (value: string, apiBase: string) => {
- const trimmed = value.trim();
- if (!trimmed) return '';
- if (/^(https?:|data:|blob:)/i.test(trimmed)) return trimmed;
- if (!trimmed.startsWith('/')) return trimmed;
- const base = normalizeApiBase(apiBase);
- return base ? `${base}${trimmed}` : trimmed;
-};
-
-// Registry entries usually carry an "owner/repo" slug rather than a full URL.
-export const buildRepositoryURL = (repository: string) => {
- const trimmed = repository.trim();
- if (!trimmed) return '';
- if (/^https?:\/\//i.test(trimmed)) return trimmed;
- return `https://github.com/${trimmed.replace(/^\/+/, '')}`;
-};
-
-// The exact, fully-qualified prefix every first-party repository lives under.
-// Matching the whole URL (not just the extracted owner) prevents look-alike
-// hosts like "https://github.com.evil.com/router-for-me/..." from being
-// mistaken for the official org.
-export const OFFICIAL_PLUGIN_REPO_PREFIX = 'https://github.com/router-for-me/';
-export const DEFAULT_PLUGIN_STORE_SOURCE_ID = 'official';
-const DEFAULT_PLUGIN_STORE_SOURCE_NAME = 'official';
-
-// Normalize an "owner/repo" slug or repository URL to a bare "owner/repo".
-export const getPluginRepositorySlug = (repository: string): string => {
- const trimmed = repository.trim();
- if (!trimmed) return '';
- const withoutHost = /^https?:\/\/[^/]+\/(.+)$/i.exec(trimmed)?.[1] ?? trimmed;
- const [owner = '', repo = ''] = withoutHost.replace(/^\/+/, '').split('/');
- if (!owner) return '';
- return repo ? `${owner}/${repo.replace(/\.git$/i, '')}` : owner;
-};
-
-// A repository is official only when its canonical github.com URL sits exactly
-// under the router-for-me org prefix. Slugs ("router-for-me/repo") and full URLs
-// are both normalized first; anything else (other hosts, look-alike domains,
-// other owners) is untrusted.
-export const isOfficialRepository = (repository: string): boolean =>
- buildRepositoryURL(repository).toLowerCase().startsWith(OFFICIAL_PLUGIN_REPO_PREFIX);
-
-// Both the backend-assigned source identity and repository must be official.
-// A third-party registry can copy repository metadata, so repository alone is
-// insufficient to bypass the third-party installation gate.
-export const isOfficialPlugin = (entry: PluginStoreEntry): boolean =>
- entry.sourceId.trim().toLowerCase() === DEFAULT_PLUGIN_STORE_SOURCE_ID &&
- isOfficialRepository(entry.repository);
-
-export const isDefaultPluginStoreSource = (
- entry: Pick
-): boolean =>
- entry.sourceId.trim().toLowerCase() === DEFAULT_PLUGIN_STORE_SOURCE_ID ||
- entry.sourceName.trim().toLowerCase() === DEFAULT_PLUGIN_STORE_SOURCE_NAME;
-
-// The string a user must retype to confirm a risky install: the repo slug when
-// available (most faithful to the source), otherwise the plugin id.
-export const getPluginConfirmToken = (entry: PluginStoreEntry): string =>
- getPluginRepositorySlug(entry.repository) || entry.id;
-
-export const collectPluginResourceEntries = (plugins: PluginListEntry[]): PluginResourceEntry[] =>
- plugins.flatMap((plugin) => {
- if (!plugin.effectiveEnabled) return [];
-
- const pluginTitle = getPluginTitle(plugin);
- const pluginLogo = plugin.logo || plugin.metadata?.logo || '';
-
- return plugin.menus
- .map((menu, menuIndex): PluginResourceEntry | null => {
- const path = menu.path.trim();
- if (!path) return null;
-
- const menuLabel = menu.menu.trim();
- return {
- pluginID: plugin.id,
- pluginTitle,
- pluginLogo,
- menuIndex,
- menu: { ...menu, path },
- label: menuLabel || pluginTitle,
- description: menu.description.trim() || pluginTitle,
- route: buildPluginResourceRoute(plugin.id, menuIndex),
- };
- })
- .filter((entry): entry is PluginResourceEntry => Boolean(entry));
- });
diff --git a/frontend/src/features/providers/ProvidersWorkbenchPage.module.scss b/frontend/src/features/providers/ProvidersWorkbenchPage.module.scss
deleted file mode 100644
index ef0a5ef..0000000
--- a/frontend/src/features/providers/ProvidersWorkbenchPage.module.scss
+++ /dev/null
@@ -1,39 +0,0 @@
-@use '../../styles/mixins' as *;
-@use '../../styles/variables' as *;
-
-.page {
- display: flex;
- flex-direction: column;
- gap: 20px;
- width: 100%;
- padding: 24px;
- box-sizing: border-box;
-}
-
-.layout {
- display: grid;
- align-items: start;
- gap: 16px;
- grid-template-columns: minmax(0, 1fr);
-
- @media (min-width: 1280px) {
- grid-template-columns: 240px minmax(0, 1fr);
- }
-}
-
-.layoutSingle {
- @media (min-width: 1280px) {
- grid-template-columns: minmax(0, 1fr);
- }
-}
-
-.modelCatalogSlot {
- margin-top: 4px;
-}
-
-@media (max-width: 768px) {
- .page {
- padding: 16px;
- gap: 16px;
- }
-}
diff --git a/frontend/src/features/providers/ProvidersWorkbenchPage.tsx b/frontend/src/features/providers/ProvidersWorkbenchPage.tsx
deleted file mode 100644
index 5c0b7c1..0000000
--- a/frontend/src/features/providers/ProvidersWorkbenchPage.tsx
+++ /dev/null
@@ -1,492 +0,0 @@
-import { useCallback, useMemo, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
-import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
-import { Skeleton } from '@/components/ui/Skeleton';
-import { useAuthStore, useNotificationStore } from '@/stores';
-import { useProviderRecentRequests } from '@/components/providers/hooks/useProviderRecentRequests';
-import {
- getOpenAIProviderRecentWindowStats,
- getProviderRecentWindowStats,
- getProviderUsageKey,
- type ProviderRecentUsageMap,
-} from '@/components/providers/utils';
-import type { OpenAIProviderConfig } from '@/types';
-import { ProviderHeaderCard } from './components/ProviderHeaderCard';
-import { ProviderCategoryList } from './components/ProviderCategoryList';
-import { ProviderResourcePanel } from './components/ProviderResourcePanel';
-import type { ProviderPanelControls } from './components/ProviderResourcePanel';
-import { SponsorQuickStartPanel } from './components/SponsorQuickStartPanel';
-import { ProviderSheet, type ProviderSheetHandle } from './sheets/ProviderSheet';
-import { APIKEY_FUN_DISPLAY_NAME } from './sponsor';
-import { isMultiProtocolSponsorBrand } from './sponsorDefinitions';
-import { isSponsorPartialMutationError } from './sponsorMutationRecovery';
-import { useProviderWorkbench } from './useProviderWorkbench';
-import {
- getProviderFilterState,
- readProvidersWorkbenchUiState,
- writeProvidersWorkbenchUiState,
- type ProviderFilterState,
- type ProvidersWorkbenchUiState,
-} from './uiState';
-import type { ProviderBrand, ProviderResource, ProviderSortBy, SortDir } from './types';
-import styles from './ProvidersWorkbenchPage.module.scss';
-
-type SheetMode = 'detail' | 'create' | 'edit';
-
-interface SheetState {
- open: boolean;
- brand: ProviderBrand;
- mode: SheetMode;
- resource: ProviderResource | null;
-}
-
-interface ProvidersWorkbenchPageProps {
- fixedBrand?: ProviderBrand;
-}
-
-const formatDateTime = (iso: string, locale?: string) => {
- try {
- const date = new Date(iso);
- if (Number.isNaN(date.getTime())) return iso;
- return new Intl.DateTimeFormat(locale, {
- dateStyle: 'medium',
- timeStyle: 'short',
- }).format(date);
- } catch {
- return iso;
- }
-};
-
-const matchesFilter = (r: ProviderResource, normalized: string): boolean => {
- if (!normalized) return true;
- const haystack = [
- r.identifier,
- r.name,
- r.authIndex,
- r.apiKeyPreview,
- r.apiKey,
- r.baseUrl,
- r.proxyUrl,
- r.prefix,
- ]
- .filter(Boolean)
- .map((v) => String(v).toLowerCase());
- return haystack.some((v) => v.includes(normalized));
-};
-
-const getResourceSortName = (resource: ProviderResource): string =>
- (resource.name ?? resource.identifier ?? resource.apiKeyPreview ?? '').toLowerCase();
-
-const getResourceRecentSuccess = (
- resource: ProviderResource,
- usageByProvider: ProviderRecentUsageMap
-): number => {
- if (isMultiProtocolSponsorBrand(resource.brand)) {
- return 0;
- }
- if (resource.brand === 'openaiCompatibility') {
- return getOpenAIProviderRecentWindowStats(resource.raw as OpenAIProviderConfig, usageByProvider)
- .success;
- }
- return getProviderRecentWindowStats(
- usageByProvider,
- getProviderUsageKey(resource.brand),
- resource.apiKey ?? undefined,
- resource.baseUrl ?? undefined
- ).success;
-};
-
-export function ProvidersWorkbenchPage({ fixedBrand }: ProvidersWorkbenchPageProps = {}) {
- const { t, i18n } = useTranslation();
- const connectionStatus = useAuthStore((s) => s.connectionStatus);
- const { showNotification, showConfirmation } = useNotificationStore();
-
- const pageTransitionLayer = usePageTransitionLayer();
- const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
-
- const workbench = useProviderWorkbench();
- const [uiState, setUiState] = useState(readProvidersWorkbenchUiState);
- const [sheetState, setSheetState] = useState({
- open: false,
- brand: 'gemini',
- mode: 'detail',
- resource: null,
- });
- const sheetRef = useRef(null);
-
- const connected = connectionStatus === 'connected';
- const { usageByProvider, refreshRecentRequests } = useProviderRecentRequests({
- enabled: connected,
- });
-
- const handleRefresh = useCallback(async () => {
- await Promise.allSettled([workbench.refetch(), refreshRecentRequests().catch(() => undefined)]);
- }, [refreshRecentRequests, workbench]);
-
- useHeaderRefresh(handleRefresh, isCurrentLayer);
-
- const disableMutations =
- connectionStatus !== 'connected' ||
- workbench.mutating ||
- workbench.isFetching ||
- workbench.isError;
-
- const persistUiState = useCallback(
- (updater: (prev: ProvidersWorkbenchUiState) => ProvidersWorkbenchUiState) => {
- setUiState((prev) => {
- const next = updater(prev);
- writeProvidersWorkbenchUiState(next);
- return next;
- });
- },
- []
- );
-
- const setActiveBrand = useCallback(
- (brand: ProviderBrand) => {
- persistUiState((prev) =>
- prev.activeBrand === brand ? prev : { ...prev, activeBrand: brand }
- );
- },
- [persistUiState]
- );
-
- const allGroups = useMemo(() => workbench.snapshot?.groups ?? [], [workbench.snapshot]);
- const groups = useMemo(
- () =>
- fixedBrand
- ? allGroups.filter((group) => group.id === fixedBrand)
- : allGroups.filter((group) => group.id !== 'apikeyFun'),
- [allGroups, fixedBrand]
- );
- const firstVisibleBrand = groups[0]?.id ?? fixedBrand ?? 'gemini';
- const activeBrand =
- fixedBrand ??
- (groups.some((group) => group.id === uiState.activeBrand)
- ? uiState.activeBrand
- : firstVisibleBrand);
- const activeFilterState = getProviderFilterState(uiState, activeBrand);
- const filter = activeFilterState.filter;
- const providerSortBy = activeFilterState.sortBy;
- const providerSortDir = activeFilterState.sortDir;
- const activeGroup = groups.find((g) => g.id === activeBrand) ?? groups[0] ?? null;
-
- const updateActiveFilterState = useCallback(
- (patch: Partial) => {
- persistUiState((prev) => {
- const current = getProviderFilterState(prev, activeBrand);
- return {
- ...prev,
- filtersByBrand: {
- ...prev.filtersByBrand,
- [activeBrand]: {
- ...current,
- ...patch,
- },
- },
- };
- });
- },
- [activeBrand, persistUiState]
- );
-
- const filteredResources = useMemo(() => {
- if (!activeGroup) return [];
- const normalized = filter.trim().toLowerCase();
- return activeGroup.resources.filter((r) => matchesFilter(r, normalized));
- }, [activeGroup, filter]);
-
- const availableModels = useMemo(() => {
- if (!activeGroup) return [];
- const seen = new Set();
- activeGroup.resources.forEach((r) => {
- r.models.forEach((name) => seen.add(name));
- });
- return Array.from(seen).sort();
- }, [activeGroup]);
-
- const selectedModels = useMemo(() => {
- if (availableModels.length === 0) return new Set();
- const availableModelSet = new Set(availableModels);
- return new Set(activeFilterState.selectedModels.filter((name) => availableModelSet.has(name)));
- }, [activeFilterState.selectedModels, availableModels]);
-
- const visibleResources = useMemo(() => {
- let arr = filteredResources;
- if (selectedModels.size > 0) {
- arr = arr.filter((r) => r.models.some((name) => selectedModels.has(name)));
- }
-
- const sorted = [...arr].sort((a, b) => {
- const sortDiff =
- providerSortBy === 'name'
- ? getResourceSortName(a).localeCompare(getResourceSortName(b))
- : providerSortBy === 'priority'
- ? a.priority - b.priority
- : getResourceRecentSuccess(a, usageByProvider) -
- getResourceRecentSuccess(b, usageByProvider);
- const diff = sortDiff || a.originalIndex - b.originalIndex;
- return providerSortDir === 'asc' ? diff : -diff;
- });
-
- return sorted;
- }, [filteredResources, providerSortBy, providerSortDir, selectedModels, usageByProvider]);
-
- const toolbarControls = useMemo(() => {
- if (!activeGroup) return undefined;
- return {
- sortBy: providerSortBy,
- sortDir: providerSortDir,
- onSortBy: (value: ProviderSortBy) => updateActiveFilterState({ sortBy: value }),
- onSortDir: (value: SortDir) => updateActiveFilterState({ sortDir: value }),
- availableModels,
- selectedModels,
- onSelectedModelsChange: (next) =>
- updateActiveFilterState({
- selectedModels: Array.from(next).sort((a, b) => a.localeCompare(b)),
- }),
- };
- }, [
- activeGroup,
- availableModels,
- providerSortBy,
- providerSortDir,
- selectedModels,
- updateActiveFilterState,
- ]);
-
- const totalResources = useMemo(
- () => groups.reduce((sum, g) => sum + g.resources.length, 0),
- [groups]
- );
-
- const totalActive = useMemo(
- () => groups.reduce((sum, g) => sum + g.resources.filter((r) => !r.disabled).length, 0),
- [groups]
- );
-
- const providerFamilies = useMemo(
- () => groups.filter((g) => g.resources.length > 0).length,
- [groups]
- );
- const quickStartResource = useMemo(
- () =>
- fixedBrand === 'apikeyFun' && activeGroup ? (activeGroup.resources[0] ?? null) : null,
- [activeGroup, fixedBrand]
- );
-
- const updatedAtLabel = workbench.snapshot
- ? formatDateTime(workbench.snapshot.fetchedAt, i18n.language)
- : t('providersPage.modelCatalog.notLoaded');
- const headerTitle =
- fixedBrand === 'apikeyFun'
- ? quickStartResource
- ? APIKEY_FUN_DISPLAY_NAME
- : t('nav.quick_start')
- : undefined;
- const errorBanner = workbench.errorMessage ? (
- {workbench.errorMessage}
- ) : null;
-
- const openCreate = useCallback(() => {
- const brand = activeBrand;
- setSheetState({ open: true, brand, mode: 'create', resource: null });
- }, [activeBrand]);
-
- const openView = useCallback((resource: ProviderResource) => {
- setSheetState({
- open: true,
- brand: resource.brand,
- mode: 'detail',
- resource,
- });
- }, []);
-
- const openEdit = useCallback((resource: ProviderResource) => {
- setSheetState({
- open: true,
- brand: resource.brand,
- mode: 'edit',
- resource,
- });
- }, []);
-
- const closeSheet = useCallback(() => {
- setSheetState((s) => ({ ...s, open: false }));
- }, []);
-
- const handleDelete = useCallback(
- (resource: ProviderResource) => {
- const name = resource.name ?? resource.apiKeyPreview ?? resource.identifier ?? '';
- showConfirmation({
- title: t('providersPage.delete.title'),
- message: t('providersPage.delete.confirm', { name }),
- variant: 'danger',
- confirmText: t('providersPage.actions.delete'),
- onConfirm: async () => {
- try {
- await workbench.deleteProvider(resource);
- showNotification(t('providersPage.toast.deleted'), 'success');
- } catch (err) {
- if (isSponsorPartialMutationError(err)) {
- showNotification(t('providersPage.sponsor.partialMutationWarning'), 'warning');
- return;
- }
- const msg = err instanceof Error ? err.message : String(err);
- showNotification(`${t('notification.delete_failed')}: ${msg}`, 'error');
- }
- },
- });
- },
- [showConfirmation, showNotification, t, workbench]
- );
-
- const handleToggleDisabled = useCallback(
- async (resource: ProviderResource, disabled: boolean) => {
- try {
- await workbench.toggleDisabled(resource, disabled);
- showNotification(
- disabled ? t('providersPage.toast.disabled') : t('providersPage.toast.enabled'),
- 'success'
- );
- } catch (err) {
- if (isSponsorPartialMutationError(err)) {
- showNotification(t('providersPage.sponsor.partialMutationWarning'), 'warning');
- return;
- }
- const msg = err instanceof Error ? err.message : String(err);
- showNotification(`${t('providersPage.toast.toggleFailed')}: ${msg}`, 'error');
- }
- },
- [showNotification, t, workbench]
- );
-
- const handleCreated = useCallback(() => {
- showNotification(t('providersPage.toast.created'), 'success');
- closeSheet();
- }, [closeSheet, showNotification, t]);
-
- const handleUpdated = useCallback(() => {
- showNotification(t('providersPage.toast.updated'), 'success');
- closeSheet();
- }, [closeSheet, showNotification, t]);
-
- // 加载状态
- if (!workbench.snapshot && workbench.isPending) {
- return (
-
- );
- }
-
- if (!activeGroup) {
- return (
-
-
void handleRefresh()}
- onNew={() => {}}
- isNewDisabled
- showNewAction={!fixedBrand}
- showSummary={fixedBrand !== 'apikeyFun'}
- />
- {errorBanner}
-
- );
- }
-
- return (
-
-
void handleRefresh()}
- onNew={openCreate}
- />
-
- {errorBanner}
-
-
- {!fixedBrand ? (
-
{
- const isSwitching = sheetState.open && sheetState.brand !== brand;
- const proceed =
- isSwitching && sheetRef.current
- ? sheetRef.current.confirmDiscardIfDirty()
- : Promise.resolve(true);
- void proceed.then((ok) => {
- if (!ok) return;
- setActiveBrand(brand);
- if (isSwitching) {
- closeSheet();
- }
- });
- }}
- />
- ) : null}
- {fixedBrand === 'apikeyFun' ? (
-
- ) : (
- updateActiveFilterState({ filter: value })}
- filteredResources={visibleResources}
- selectedId={sheetState.open ? (sheetState.resource?.id ?? null) : null}
- disableMutations={disableMutations}
- usageByProvider={usageByProvider}
- toolbarControls={toolbarControls}
- onView={openView}
- onEdit={openEdit}
- onDelete={handleDelete}
- onToggleDisabled={handleToggleDisabled}
- onCreate={openCreate}
- />
- )}
-
-
- {!fixedBrand ? (
- {
- setSheetState((s) => (s.resource ? { ...s, mode: 'edit' } : s));
- }}
- workbench={workbench}
- onCreated={handleCreated}
- onUpdated={handleUpdated}
- mutationDisabled={disableMutations}
- usageByProvider={usageByProvider}
- />
- ) : null}
-
- );
-}
diff --git a/frontend/src/features/providers/adapters.ts b/frontend/src/features/providers/adapters.ts
deleted file mode 100644
index 67101bb..0000000
--- a/frontend/src/features/providers/adapters.ts
+++ /dev/null
@@ -1,397 +0,0 @@
-import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import { hasDisableAllModelsRule, stripDisableAllModelsRule } from '@/components/providers/utils';
-import { maskApiKey } from '@/utils/format';
-import {
- APIKEY_FUN_DISPLAY_NAME,
- APIKEY_FUN_PROTOCOLS,
- getApiKeyFunProtocolUrls,
- resolveApiKeyFunBaseUrl,
-} from './sponsor';
-import { CLAUDE_API_DISPLAY_NAME } from './claudeApi';
-import {
- CODE0_DISPLAY_NAME,
- CODE0_PROTOCOL_LABELS,
- getCode0ProtocolUrls,
- resolveCode0BaseUrl,
-} from './code0';
-import {
- FENNO_AI_DISPLAY_NAME,
- FENNO_AI_PROTOCOL_LABELS,
- getFennoAIProtocolUrls,
- resolveFennoAIBaseUrl,
-} from './fennoAI';
-import {
- QINIU_CLOUD_DISPLAY_NAME,
- QINIU_CLOUD_PROTOCOL_LABELS,
- getQiniuCloudProtocolUrls,
- resolveQiniuCloudBaseUrl,
-} from './qiniuCloud';
-import {
- LMU_AI_DISPLAY_NAME,
- LMU_AI_PROTOCOL_LABELS,
- getLmuAIProtocolUrls,
- resolveLmuAIBaseUrl,
-} from './lmuAI';
-import {
- INFISTAR_DISPLAY_NAME,
- INFISTAR_PROTOCOL_LABELS,
- getInfistarProtocolUrls,
- resolveInfistarBaseUrl,
-} from './infistar';
-import {
- KIMI_DISPLAY_NAME,
- KIMI_PROTOCOL_LABELS,
- getKimiProtocolUrls,
- resolveKimiBaseUrl,
-} from './kimi';
-import type {
- ProviderBrand,
- ProviderResource,
- ProviderResourceSelector,
- SponsorProviderBrand,
- SponsorProviderRaw,
-} from './types';
-
-const countHeaders = (headers?: Record): number =>
- headers ? Object.keys(headers).length : 0;
-
-const collectModelNames = (models?: Array<{ name?: string }>): string[] => {
- const seen = new Set();
- (models ?? []).forEach((model) => {
- const name = (model?.name ?? '').trim();
- if (name) seen.add(name);
- });
- return Array.from(seen);
-};
-
-const normalizePriority = (priority?: number): number =>
- typeof priority === 'number' && Number.isFinite(priority) ? priority : 0;
-
-const buildId = (brand: ProviderBrand, index: number, fragment: string) =>
- `${brand}:${index}:${fragment || 'item'}`;
-
-const truncateForId = (value: string | undefined | null): string => {
- const trimmed = String(value ?? '').trim();
- if (!trimmed) return '';
- if (trimmed.length <= 12) return trimmed;
- return trimmed.slice(0, 8);
-};
-
-function providerKeyToResource(
- brand: 'gemini' | 'interactions' | 'codex' | 'xai' | 'claude' | 'claudeApi' | 'vertex',
- config: GeminiKeyConfig | ProviderKeyConfig,
- index: number
-): ProviderResource {
- const apiKey = config.apiKey ?? '';
- const disabled = hasDisableAllModelsRule(config.excludedModels);
- const flags: ProviderResource['flags'] = {};
- if (brand === 'codex' || brand === 'xai') {
- flags.websockets = (config as ProviderKeyConfig).websockets === true;
- }
- if (brand === 'claude' || brand === 'claudeApi') {
- const cloak = (config as ProviderKeyConfig).cloak;
- flags.cloakEnabled = Boolean(cloak?.mode?.trim());
- }
-
- const selector: ProviderResourceSelector = {
- brand,
- apiKey,
- baseUrl: config.baseUrl,
- index,
- } as ProviderResourceSelector;
-
- return {
- id: buildId(brand, index, truncateForId(apiKey)),
- brand,
- originalIndex: index,
- name: null,
- identifier: maskApiKey(apiKey) || `#${index + 1}`,
- apiKeyPreview: apiKey ? maskApiKey(apiKey) : null,
- apiKey: apiKey || null,
- authIndex: config.authIndex ?? null,
- baseUrl: config.baseUrl ?? null,
- proxyUrl: config.proxyUrl ?? null,
- prefix: config.prefix ?? null,
- modelCount: config.models?.length ?? 0,
- models: collectModelNames(config.models),
- priority: normalizePriority(config.priority),
- headerCount: countHeaders(config.headers),
- excludedModelCount: stripDisableAllModelsRule(config.excludedModels).length,
- apiKeyEntryCount: 0,
- disabled,
- flags,
- selector,
- raw: config,
- };
-}
-
-export function geminiToResource(config: GeminiKeyConfig, index: number): ProviderResource {
- return providerKeyToResource('gemini', config, index);
-}
-
-export function interactionsToResource(config: GeminiKeyConfig, index: number): ProviderResource {
- return providerKeyToResource('interactions', config, index);
-}
-
-export function codexToResource(config: ProviderKeyConfig, index: number): ProviderResource {
- return providerKeyToResource('codex', config, index);
-}
-
-export function xaiToResource(config: ProviderKeyConfig, index: number): ProviderResource {
- return providerKeyToResource('xai', config, index);
-}
-
-export function claudeToResource(config: ProviderKeyConfig, index: number): ProviderResource {
- return providerKeyToResource('claude', config, index);
-}
-
-export function claudeApiToResource(config: ProviderKeyConfig, index: number): ProviderResource {
- const resource = providerKeyToResource('claudeApi', config, index);
- return {
- ...resource,
- name: CLAUDE_API_DISPLAY_NAME,
- };
-}
-
-export function vertexToResource(config: ProviderKeyConfig, index: number): ProviderResource {
- return providerKeyToResource('vertex', config, index);
-}
-
-export function openaiToResource(config: OpenAIProviderConfig, index: number): ProviderResource {
- const sourceIndex = config.sourceIndex ?? index;
- const name = (config.name ?? '').trim();
- const firstEntry = config.apiKeyEntries?.[0];
- const previewApiKey = firstEntry?.apiKey ? maskApiKey(firstEntry.apiKey) : null;
- return {
- id: buildId('openaiCompatibility', sourceIndex, truncateForId(name) || `#${sourceIndex}`),
- brand: 'openaiCompatibility',
- originalIndex: sourceIndex,
- name: name || null,
- identifier: name || `#${sourceIndex + 1}`,
- apiKeyPreview: previewApiKey,
- apiKey: null,
- authIndex: config.authIndex ?? null,
- baseUrl: config.baseUrl ?? null,
- proxyUrl: null,
- prefix: config.prefix ?? null,
- modelCount: config.models?.length ?? 0,
- models: collectModelNames(config.models),
- priority: normalizePriority(config.priority),
- headerCount: countHeaders(config.headers),
- excludedModelCount: 0,
- apiKeyEntryCount: config.apiKeyEntries?.length ?? 0,
- disabled: config.disabled === true,
- flags: {},
- selector: { brand: 'openaiCompatibility', name, index: sourceIndex },
- raw: config,
- };
-}
-
-interface SponsorResourceOptions {
- displayName: string;
- protocolLabels: readonly string[];
- resolveBaseUrl: (value: string | undefined | null) => string;
- getProtocolUrls: (value: string | undefined | null) => {
- anthropic: string;
- openai: string;
- codex: string;
- gemini: string;
- };
-}
-
-function sponsorRawToResource(
- brand: SponsorProviderBrand,
- raw: SponsorProviderRaw,
- options: SponsorResourceOptions
-): ProviderResource | null {
- if (
- raw.openai.length === 0 &&
- raw.claude.length === 0 &&
- raw.codex.length === 0 &&
- raw.gemini.length === 0
- ) {
- return null;
- }
- const openaiKeyCount = raw.openai.reduce(
- (count, item) => count + (item.config.apiKeyEntries?.length ?? 0),
- 0
- );
- const codexKeyCount = raw.codex.length;
- const geminiKeyCount = raw.gemini.length;
- const firstOpenAIEntry = raw.openai
- .flatMap((item) => item.config.apiKeyEntries ?? [])
- .find((entry) => entry.apiKey?.trim());
- const firstCodex = raw.codex.find((item) => item.config.apiKey?.trim());
- const firstClaude = raw.claude.find((item) => item.config.apiKey?.trim());
- const firstGemini = raw.gemini.find((item) => item.config.apiKey?.trim());
- const apiKey =
- firstOpenAIEntry?.apiKey ??
- firstCodex?.config.apiKey ??
- firstClaude?.config.apiKey ??
- firstGemini?.config.apiKey ??
- '';
- const openaiDisabled =
- raw.openai.length > 0 && raw.openai.every((item) => item.config.disabled === true);
- const codexDisabled =
- raw.codex.length > 0 &&
- raw.codex.every((item) => hasDisableAllModelsRule(item.config.excludedModels));
- const claudeDisabled =
- raw.claude.length > 0 &&
- raw.claude.every((item) => hasDisableAllModelsRule(item.config.excludedModels));
- const geminiDisabled =
- raw.gemini.length > 0 &&
- raw.gemini.every((item) => hasDisableAllModelsRule(item.config.excludedModels));
- const enabledCount =
- (raw.openai.length > 0 && !openaiDisabled ? 1 : 0) +
- (raw.codex.length > 0 && !codexDisabled ? 1 : 0) +
- (raw.claude.length > 0 && !claudeDisabled ? 1 : 0) +
- (raw.gemini.length > 0 && !geminiDisabled ? 1 : 0);
- const allResourcesConfigured =
- raw.openai.length > 0 || raw.codex.length > 0 || raw.claude.length > 0 || raw.gemini.length > 0;
- const disabled = allResourcesConfigured && enabledCount === 0;
- const models = [
- ...raw.openai.flatMap((item) => collectModelNames(item.config.models)),
- ...raw.codex.flatMap((item) => collectModelNames(item.config.models)),
- ...raw.claude.flatMap((item) => collectModelNames(item.config.models)),
- ...raw.gemini.flatMap((item) => collectModelNames(item.config.models)),
- ];
- const uniqueModels = Array.from(new Set(models));
- const headerCount =
- raw.openai.reduce((count, item) => count + countHeaders(item.config.headers), 0) +
- raw.codex.reduce((count, item) => count + countHeaders(item.config.headers), 0) +
- raw.claude.reduce((count, item) => count + countHeaders(item.config.headers), 0) +
- raw.gemini.reduce((count, item) => count + countHeaders(item.config.headers), 0);
- const priority = Math.max(
- 0,
- ...raw.openai.map((item) => normalizePriority(item.config.priority)),
- ...raw.codex.map((item) => normalizePriority(item.config.priority)),
- ...raw.claude.map((item) => normalizePriority(item.config.priority)),
- ...raw.gemini.map((item) => normalizePriority(item.config.priority))
- );
- const baseUrl = options.resolveBaseUrl(
- raw.openai[0]?.config.baseUrl ??
- raw.codex[0]?.config.baseUrl ??
- raw.claude[0]?.config.baseUrl ??
- raw.gemini[0]?.config.baseUrl
- );
- const protocolUrls = options.getProtocolUrls(baseUrl);
-
- return {
- id: buildId(brand, 0, 'sponsor'),
- brand,
- originalIndex: 0,
- name: options.displayName,
- identifier: options.displayName,
- apiKeyPreview: apiKey ? maskApiKey(apiKey) : null,
- apiKey: apiKey || null,
- authIndex: null,
- baseUrl: [protocolUrls.openai, protocolUrls.anthropic, protocolUrls.gemini]
- .filter(Boolean)
- .join(' / '),
- proxyUrl:
- firstOpenAIEntry?.proxyUrl ??
- raw.codex.find((item) => item.config.proxyUrl)?.config.proxyUrl ??
- raw.claude.find((item) => item.config.proxyUrl)?.config.proxyUrl ??
- raw.gemini.find((item) => item.config.proxyUrl)?.config.proxyUrl ??
- null,
- prefix:
- raw.openai[0]?.config.prefix ??
- raw.codex[0]?.config.prefix ??
- raw.claude[0]?.config.prefix ??
- raw.gemini[0]?.config.prefix ??
- null,
- modelCount: uniqueModels.length,
- models: uniqueModels,
- priority,
- headerCount,
- excludedModelCount:
- raw.codex.reduce(
- (count, item) => count + stripDisableAllModelsRule(item.config.excludedModels).length,
- 0
- ) +
- raw.claude.reduce(
- (count, item) => count + stripDisableAllModelsRule(item.config.excludedModels).length,
- 0
- ) +
- raw.gemini.reduce(
- (count, item) => count + stripDisableAllModelsRule(item.config.excludedModels).length,
- 0
- ),
- apiKeyEntryCount: openaiKeyCount + codexKeyCount + raw.claude.length + geminiKeyCount,
- disabled,
- flags: {
- protocols: [...options.protocolLabels],
- },
- selector: {
- brand,
- openaiIndices: raw.openai.map((item) => item.index),
- claudeIndices: raw.claude.map((item) => item.index),
- codexIndices: raw.codex.map((item) => item.index),
- geminiIndices: raw.gemini.map((item) => item.index),
- } as ProviderResourceSelector,
- raw,
- };
-}
-
-export function apiKeyFunToResource(raw: SponsorProviderRaw): ProviderResource | null {
- return sponsorRawToResource('apikeyFun', raw, {
- displayName: APIKEY_FUN_DISPLAY_NAME,
- protocolLabels: APIKEY_FUN_PROTOCOLS,
- resolveBaseUrl: resolveApiKeyFunBaseUrl,
- getProtocolUrls: getApiKeyFunProtocolUrls,
- });
-}
-
-export function code0ToResource(raw: SponsorProviderRaw): ProviderResource | null {
- return sponsorRawToResource('code0', raw, {
- displayName: CODE0_DISPLAY_NAME,
- protocolLabels: CODE0_PROTOCOL_LABELS,
- resolveBaseUrl: resolveCode0BaseUrl,
- getProtocolUrls: getCode0ProtocolUrls,
- });
-}
-
-export function fennoAIToResource(raw: SponsorProviderRaw): ProviderResource | null {
- return sponsorRawToResource('fennoAI', raw, {
- displayName: FENNO_AI_DISPLAY_NAME,
- protocolLabels: FENNO_AI_PROTOCOL_LABELS,
- resolveBaseUrl: resolveFennoAIBaseUrl,
- getProtocolUrls: getFennoAIProtocolUrls,
- });
-}
-
-export function qiniuCloudToResource(raw: SponsorProviderRaw): ProviderResource | null {
- return sponsorRawToResource('qiniuCloud', raw, {
- displayName: QINIU_CLOUD_DISPLAY_NAME,
- protocolLabels: QINIU_CLOUD_PROTOCOL_LABELS,
- resolveBaseUrl: resolveQiniuCloudBaseUrl,
- getProtocolUrls: getQiniuCloudProtocolUrls,
- });
-}
-
-export function lmuAIToResource(raw: SponsorProviderRaw): ProviderResource | null {
- return sponsorRawToResource('lmuAI', raw, {
- displayName: LMU_AI_DISPLAY_NAME,
- protocolLabels: LMU_AI_PROTOCOL_LABELS,
- resolveBaseUrl: resolveLmuAIBaseUrl,
- getProtocolUrls: getLmuAIProtocolUrls,
- });
-}
-
-export function infistarToResource(raw: SponsorProviderRaw): ProviderResource | null {
- return sponsorRawToResource('infistar', raw, {
- displayName: INFISTAR_DISPLAY_NAME,
- protocolLabels: INFISTAR_PROTOCOL_LABELS,
- resolveBaseUrl: resolveInfistarBaseUrl,
- getProtocolUrls: getInfistarProtocolUrls,
- });
-}
-
-export function kimiToResource(raw: SponsorProviderRaw): ProviderResource | null {
- return sponsorRawToResource('kimi', raw, {
- displayName: KIMI_DISPLAY_NAME,
- protocolLabels: KIMI_PROTOCOL_LABELS,
- resolveBaseUrl: resolveKimiBaseUrl,
- getProtocolUrls: getKimiProtocolUrls,
- });
-}
diff --git a/frontend/src/features/providers/brandLogos.ts b/frontend/src/features/providers/brandLogos.ts
deleted file mode 100644
index 8384d1d..0000000
--- a/frontend/src/features/providers/brandLogos.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import claudeLogo from '@/assets/icons/claude.svg';
-import codexLogo from '@/assets/icons/codex.svg';
-import geminiLogo from '@/assets/icons/gemini.svg';
-import openaiLightLogo from '@/assets/icons/openai-light.svg';
-import openaiDarkLogo from '@/assets/icons/openai-dark.svg';
-import vertexLogo from '@/assets/icons/vertex.svg';
-import claudeApiLogo from '@/assets/icons/claudeapi.png';
-import apikeyFunLogo from '@/assets/icons/apikey-fun.png';
-import code0Logo from '@/assets/icons/code0.png';
-import fennoAILogo from '@/assets/icons/fenno-ai.png';
-import qiniuCloudLogo from '@/assets/icons/qiniu-cloud.png';
-import lmuAILogo from '@/assets/icons/lmu-ai.png';
-import infistarLogo from '@/assets/icons/infistar.png';
-import xaiLightLogo from '@/assets/icons/grok.svg';
-import xaiDarkLogo from '@/assets/icons/grok-dark.svg';
-import kimiLightLogo from '@/assets/icons/kimi-light.svg';
-import kimiDarkLogo from '@/assets/icons/kimi-dark.svg';
-import type { ProviderBrand } from './types';
-
-export interface ProviderBrandLogo {
- src: string;
- darkSrc?: string;
- transparent?: boolean;
- themeSurface?: boolean;
- invertOnDark?: boolean;
-}
-
-export const PROVIDER_LOGOS: Record = {
- gemini: { src: geminiLogo },
- interactions: { src: geminiLogo },
- claude: { src: claudeLogo },
- claudeApi: { src: claudeApiLogo },
- codex: { src: codexLogo },
- xai: { src: xaiLightLogo, darkSrc: xaiDarkLogo, transparent: true },
- vertex: { src: vertexLogo },
- openaiCompatibility: { src: openaiLightLogo, darkSrc: openaiDarkLogo, transparent: true },
- apikeyFun: { src: apikeyFunLogo },
- code0: { src: code0Logo },
- fennoAI: { src: fennoAILogo, transparent: true },
- qiniuCloud: { src: qiniuCloudLogo, transparent: true },
- lmuAI: { src: lmuAILogo, transparent: true },
- infistar: { src: infistarLogo, transparent: true },
- kimi: {
- src: kimiDarkLogo,
- darkSrc: kimiLightLogo,
- transparent: true,
- themeSurface: true,
- },
-};
diff --git a/frontend/src/features/providers/claudeApi.ts b/frontend/src/features/providers/claudeApi.ts
deleted file mode 100644
index 8a56172..0000000
--- a/frontend/src/features/providers/claudeApi.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { ProviderKeyConfig } from '@/types';
-
-export const CLAUDE_API_DISPLAY_NAME = 'Claudeapi.com';
-export const CLAUDE_API_BASE_URL = 'https://gw.apito.ai';
-export const CLAUDE_API_LEGACY_BASE_URL = 'https://gw.claudeapi.com';
-export const CLAUDE_API_AFFILIATE_URL =
- 'https://console.claudeapi.com/agent/register/pJq9T52Fpugrhpgo';
-
-const normalizeBaseUrl = (value: string | undefined | null): string =>
- String(value ?? '')
- .trim()
- .toLowerCase()
- .replace(/\/+$/, '');
-
-export const isClaudeApiProvider = (
- config: ProviderKeyConfig | undefined | null
-): boolean => {
- if (!config) return false;
- const baseUrl = normalizeBaseUrl(config.baseUrl);
- return [CLAUDE_API_BASE_URL, CLAUDE_API_LEGACY_BASE_URL].some(
- (candidate) => baseUrl === normalizeBaseUrl(candidate)
- );
-};
diff --git a/frontend/src/features/providers/code0.ts b/frontend/src/features/providers/code0.ts
deleted file mode 100644
index 6800a48..0000000
--- a/frontend/src/features/providers/code0.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import type { Config, GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import type { SponsorProviderRaw } from './types';
-
-export const CODE0_PROVIDER_NAME = 'code0';
-export const CODE0_DISPLAY_NAME = 'Code0';
-export const CODE0_AFFILIATE_URL = 'https://code0.ai/agent/register/slxVMR3uVBoRgNBf';
-export const CODE0_BASE_URL = 'https://code0.ai';
-export const CODE0_OPENAI_BASE_URL = `${CODE0_BASE_URL}/v1`;
-export const CODE0_CODEX_BASE_URL = CODE0_OPENAI_BASE_URL;
-export const CODE0_ANTHROPIC_BASE_URL = CODE0_BASE_URL;
-export const CODE0_GEMINI_BASE_URL = CODE0_BASE_URL;
-
-export const CODE0_BASE_URL_OPTIONS = [
- {
- id: 'standard',
- baseUrl: CODE0_BASE_URL,
- openaiBaseUrl: CODE0_OPENAI_BASE_URL,
- codexBaseUrl: CODE0_CODEX_BASE_URL,
- anthropicBaseUrl: CODE0_ANTHROPIC_BASE_URL,
- geminiBaseUrl: CODE0_GEMINI_BASE_URL,
- },
-] as const;
-
-export const CODE0_PROTOCOL_LABELS = [
- 'openai',
- 'anthropic',
- 'gemini',
- 'codexResponses',
-] as const;
-
-const normalizeText = (value: string | undefined | null): string =>
- String(value ?? '')
- .trim()
- .toLowerCase();
-
-const normalizeBaseUrl = (value: string | undefined | null): string =>
- normalizeText(value).replace(/\/+$/, '');
-
-export const resolveCode0BaseUrl = (value: string | undefined | null): string => {
- const normalized = normalizeBaseUrl(value);
- const matched = CODE0_BASE_URL_OPTIONS.find(
- (option) =>
- normalized === normalizeBaseUrl(option.baseUrl) ||
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl) ||
- normalized === normalizeBaseUrl(option.anthropicBaseUrl) ||
- normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
- return matched?.baseUrl ?? CODE0_BASE_URL;
-};
-
-export const getCode0ProtocolUrls = (value: string | undefined | null) => {
- const baseUrl = resolveCode0BaseUrl(value);
- const matched =
- CODE0_BASE_URL_OPTIONS.find(
- (option) => normalizeBaseUrl(option.baseUrl) === normalizeBaseUrl(baseUrl)
- ) ?? CODE0_BASE_URL_OPTIONS[0];
- return {
- anthropic: matched.anthropicBaseUrl,
- openai: matched.openaiBaseUrl,
- codex: matched.codexBaseUrl,
- gemini: matched.geminiBaseUrl,
- };
-};
-
-const matchesCode0OpenAIBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return CODE0_BASE_URL_OPTIONS.some(
- (option) =>
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl)
- );
-};
-
-const matchesCode0AnthropicBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return CODE0_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.anthropicBaseUrl)
- );
-};
-
-const matchesCode0GeminiBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return CODE0_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
-};
-
-export const isCode0OpenAIProvider = (
- config: OpenAIProviderConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesCode0OpenAIBaseUrl(config.baseUrl);
-};
-
-export const isCode0ClaudeProvider = (config: ProviderKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesCode0AnthropicBaseUrl(config.baseUrl);
-};
-
-export const isCode0CodexProvider = (config: ProviderKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesCode0OpenAIBaseUrl(config.baseUrl);
-};
-
-export const isCode0GeminiProvider = (config: GeminiKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesCode0GeminiBaseUrl(config.baseUrl);
-};
-
-export const buildCode0Raw = (config: Config | null | undefined): SponsorProviderRaw => ({
- openai: (config?.openaiCompatibility ?? [])
- .map((item, index) => ({ config: item, index: item.sourceIndex ?? index }))
- .filter((item) => isCode0OpenAIProvider(item.config)),
- claude: (config?.claudeApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isCode0ClaudeProvider(item.config)),
- codex: (config?.codexApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isCode0CodexProvider(item.config)),
- gemini: (config?.geminiApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isCode0GeminiProvider(item.config)),
-});
-
diff --git a/frontend/src/features/providers/components/ProviderCategoryList.module.scss b/frontend/src/features/providers/components/ProviderCategoryList.module.scss
deleted file mode 100644
index b464c54..0000000
--- a/frontend/src/features/providers/components/ProviderCategoryList.module.scss
+++ /dev/null
@@ -1,169 +0,0 @@
-@use '../../../styles/mixins' as *;
-@use '../../../styles/variables' as *;
-
-.stack {
- display: flex;
- flex-direction: column;
- gap: 12px;
- min-width: 0;
- align-self: start;
-}
-
-.aside {
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 12px;
- box-shadow: var(--shadow);
- padding: 12px;
- min-width: 0;
-}
-
-.eyebrow {
- margin: 4px 8px 8px;
- font-size: 12px;
- font-weight: 500;
- letter-spacing: 0.05em;
- text-transform: uppercase;
- color: var(--muted-foreground);
-}
-
-.list {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 10px 12px;
- border-radius: var(--radius-md);
- border: 1px solid transparent;
- background: transparent;
- cursor: pointer;
- text-align: left;
- width: 100%;
- color: var(--text-primary);
- transition:
- background-color $transition-fast,
- border-color $transition-fast,
- color $transition-fast;
- min-width: 0;
-
- &:hover:not(.active) {
- background: color-mix(in srgb, var(--accent-bg) 50%, transparent);
- border-color: var(--border-color);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: -2px;
- }
-
- &.active {
- border-color: var(--primary-30);
- background: var(--primary-10);
- color: var(--primary-color);
- }
-}
-
-.itemLeft {
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
- flex: 1;
-}
-
-.logo {
- width: 26px;
- height: 26px;
- border-radius: var(--radius-md);
- flex-shrink: 0;
- object-fit: contain;
- background: var(--bg-tertiary);
- padding: 2px;
-}
-
-.logoTransparent {
- background: transparent;
-}
-
-.logoThemeSurface {
- box-sizing: border-box;
- background: #000;
- padding: 5px;
-
- [data-theme='dark'] & {
- background: #fff;
- }
-}
-
-.logoThemeDark {
- display: none;
-
- [data-theme='dark'] & {
- display: block;
- }
-}
-
-.logoThemeLight {
- [data-theme='dark'] & {
- display: none;
- }
-}
-
-.logoInvertOnDark {
- [data-theme='dark'] & {
- filter: invert(1) hue-rotate(180deg);
- }
-}
-
-.itemText {
- display: flex;
- flex-direction: column;
- min-width: 0;
-}
-
-.itemTitle {
- font-size: 14px;
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.itemSubtitle {
- font-size: 12px;
- color: var(--muted-foreground);
- margin-top: 2px;
- font-weight: 400;
-
- .active & {
- color: var(--primary-color);
- opacity: 0.85;
- }
-}
-
-.badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 26px;
- padding: 2px 9px;
- border-radius: var(--radius-md);
- font-size: 12px;
- font-weight: 500;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- color: var(--muted-foreground);
- flex-shrink: 0;
-}
-
-.badgeAmber {
- background: var(--amber-10);
- border-color: var(--amber-30);
- color: var(--amber-text);
-}
diff --git a/frontend/src/features/providers/components/ProviderCategoryList.tsx b/frontend/src/features/providers/components/ProviderCategoryList.tsx
deleted file mode 100644
index a8d7008..0000000
--- a/frontend/src/features/providers/components/ProviderCategoryList.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { PROVIDER_LOGOS } from '../brandLogos';
-import type { ProviderBrand, ProviderGroup } from '../types';
-import styles from './ProviderCategoryList.module.scss';
-
-interface ProviderCategoryListProps {
- groups: ProviderGroup[];
- activeBrand: ProviderBrand;
- onSelect: (brand: ProviderBrand) => void;
-}
-
-const QUICK_FILL_BRAND_ORDER: readonly ProviderBrand[] = [
- 'code0',
- 'fennoAI',
- 'qiniuCloud',
- 'claudeApi',
- 'lmuAI',
- 'infistar',
-];
-
-const QUICK_FILL_BRANDS: ReadonlySet = new Set(QUICK_FILL_BRAND_ORDER);
-
-export function ProviderCategoryList({ groups, activeBrand, onSelect }: ProviderCategoryListProps) {
- const { t } = useTranslation();
-
- const quickFillGroups = groups
- .filter((g) => QUICK_FILL_BRANDS.has(g.id))
- .sort(
- (left, right) =>
- QUICK_FILL_BRAND_ORDER.indexOf(left.id) - QUICK_FILL_BRAND_ORDER.indexOf(right.id)
- );
- const providerGroups = groups.filter((g) => !QUICK_FILL_BRANDS.has(g.id));
-
- const renderGroups = (items: ProviderGroup[]) => (
-
- {items.map((group) => {
- const active = group.id === activeBrand;
- const total = group.resources.length;
- const activeCount = group.resources.filter((r) => !r.disabled).length;
- const logo = PROVIDER_LOGOS[group.id];
- const itemClass = `${styles.item} ${active ? styles.active : ''}`;
- const logoClassName = [
- styles.logo,
- logo?.transparent ? styles.logoTransparent : '',
- logo?.themeSurface ? styles.logoThemeSurface : '',
- logo?.darkSrc ? styles.logoThemeLight : '',
- logo?.invertOnDark ? styles.logoInvertOnDark : '',
- ]
- .filter(Boolean)
- .join(' ');
- const darkLogoClassName = [
- styles.logo,
- logo?.transparent ? styles.logoTransparent : '',
- logo?.themeSurface ? styles.logoThemeSurface : '',
- styles.logoThemeDark,
- ]
- .filter(Boolean)
- .join(' ');
-
- return (
-
- );
- })}
-
- );
-
- return (
-
-
- {quickFillGroups.length > 0 && (
-
- )}
-
- );
-}
diff --git a/frontend/src/features/providers/components/ProviderHeaderCard.module.scss b/frontend/src/features/providers/components/ProviderHeaderCard.module.scss
deleted file mode 100644
index 5d7a698..0000000
--- a/frontend/src/features/providers/components/ProviderHeaderCard.module.scss
+++ /dev/null
@@ -1,181 +0,0 @@
-@use '../../../styles/mixins' as *;
-@use '../../../styles/variables' as *;
-
-.card {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.row {
- display: flex;
- flex-direction: column;
- gap: 16px;
-
- @media (min-width: 1024px) {
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- }
-}
-
-.titleArea {
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.title {
- margin: 0;
- font-size: 30px;
- font-weight: 700;
- line-height: 1.2;
- color: var(--text-primary);
-}
-
-.quickStartCard {
- gap: 14px;
-
- .title {
- font-size: 32px;
- font-weight: 720;
- line-height: 1.18;
- letter-spacing: 0;
- }
-
- .btn {
- font-size: 14px;
- font-weight: 600;
- line-height: 1;
- }
-}
-
-.subtitle {
- margin: 0;
- font-size: 14px;
- color: var(--muted-foreground);
- line-height: 1.5;
-}
-
-.actions {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-items: center;
-
- @media (min-width: 1024px) {
- justify-content: flex-end;
- }
-}
-
-.btn {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 34px;
- padding: 0 14px;
- border-radius: var(--radius-md);
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition:
- background-color $transition-fast,
- border-color $transition-fast,
- color $transition-fast;
- border: 1px solid transparent;
- background: transparent;
- color: var(--text-primary);
-
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-}
-
-.btnOutline {
- border-color: var(--border-color);
- background: var(--bg-primary);
-
- &:hover:not(:disabled) {
- background: var(--bg-tertiary);
- border-color: var(--border-hover);
- }
-}
-
-.btnPrimary {
- background: var(--primary-color);
- color: var(--primary-contrast);
-
- &:hover:not(:disabled) {
- background: var(--primary-hover);
- }
-
- &:active:not(:disabled) {
- background: var(--primary-active);
- }
-}
-
-.chips {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.chip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 12px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--muted-bg);
- color: var(--muted-foreground);
- font-size: 13px;
- font-weight: 500;
-}
-
-.chipPrimary {
- border-color: var(--primary-30);
- background: var(--primary-10);
- color: var(--primary-color);
-}
-
-.chipAmber {
- border-color: var(--amber-30);
- background: var(--amber-10);
- color: var(--amber-text);
-}
-
-.spin {
- display: inline-flex;
- animation: pwc-spin 0.9s linear infinite;
-}
-
-.btnIcon {
- display: inline-flex;
- align-items: center;
-}
-
-@keyframes pwc-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (max-width: 768px) {
- .quickStartCard {
- .title {
- font-size: 28px;
- line-height: 1.2;
- }
- }
-}
diff --git a/frontend/src/features/providers/components/ProviderHeaderCard.tsx b/frontend/src/features/providers/components/ProviderHeaderCard.tsx
deleted file mode 100644
index 6aa0db2..0000000
--- a/frontend/src/features/providers/components/ProviderHeaderCard.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { IconLoader2, IconPlus, IconRefreshCw } from '@/components/ui/icons';
-import styles from './ProviderHeaderCard.module.scss';
-
-interface ProviderHeaderCardProps {
- title?: string;
- totalActive: number;
- totalResources: number;
- providerFamilies: number;
- updatedAtLabel: string;
- isFetching?: boolean;
- isNewDisabled?: boolean;
- showNewAction?: boolean;
- showSummary?: boolean;
- newLabel?: string;
- variant?: 'quickStart';
- onRefresh: () => void;
- onNew: () => void;
-}
-
-export function ProviderHeaderCard({
- title,
- totalActive,
- totalResources,
- providerFamilies,
- updatedAtLabel,
- isFetching = false,
- isNewDisabled = false,
- showNewAction = true,
- showSummary = true,
- newLabel,
- variant,
- onRefresh,
- onNew,
-}: ProviderHeaderCardProps) {
- const { t } = useTranslation();
- const cardClassName = [styles.card, variant === 'quickStart' ? styles.quickStartCard : '']
- .filter(Boolean)
- .join(' ');
-
- return (
-
-
-
-
{title ?? t('providersPage.header.title')}
-
-
-
- {showNewAction ? (
-
- ) : null}
-
-
-
- {showSummary ? (
-
-
- {t('providersPage.header.activeResources', {
- active: totalActive,
- total: totalResources,
- })}
-
-
- {t('providersPage.header.providerFamilies', { count: providerFamilies })}
-
-
- {t('providersPage.header.updatedAt', { time: updatedAtLabel })}
-
-
- ) : null}
-
- );
-}
diff --git a/frontend/src/features/providers/components/ProviderResourcePanel.module.scss b/frontend/src/features/providers/components/ProviderResourcePanel.module.scss
deleted file mode 100644
index 0ba1507..0000000
--- a/frontend/src/features/providers/components/ProviderResourcePanel.module.scss
+++ /dev/null
@@ -1,310 +0,0 @@
-@use '../../../styles/mixins' as *;
-@use '../../../styles/variables' as *;
-
-.panel {
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 12px;
- box-shadow: var(--shadow);
- padding: 20px;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.header {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.headerMain {
- display: flex;
- flex-direction: column;
- gap: 12px;
-
- @media (min-width: 768px) {
- flex-direction: row;
- align-items: flex-start;
- justify-content: space-between;
- }
-}
-
-.headerToolbarRow {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.titleArea {
- min-width: 0;
- display: flex;
- flex-direction: column;
-}
-
-.titleRow {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.titleLink {
- width: fit-content;
- max-width: 100%;
- margin: -4px -6px;
- padding: 4px 6px;
- border-radius: var(--radius-md);
- color: inherit;
- text-decoration: none;
- cursor: pointer;
-
- &:hover {
- .title,
- .titleExternalIcon {
- color: var(--text-primary);
- }
-
- .titleExternalIcon {
- opacity: 1;
- transform: translate(1px, -1px);
- }
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 3px;
- }
-}
-
-.logo {
- width: 26px;
- height: 26px;
- border-radius: var(--radius-md);
- object-fit: contain;
- flex-shrink: 0;
-}
-
-.logoThemeSurface {
- background: #000;
- padding: 5px;
-
- [data-theme='dark'] & {
- background: #fff;
- }
-}
-
-.logoThemeDark {
- display: none;
-
- [data-theme='dark'] & {
- display: block;
- }
-}
-
-.logoThemeLight {
- [data-theme='dark'] & {
- display: none;
- }
-}
-
-.logoInvertOnDark {
- [data-theme='dark'] & {
- filter: invert(1) hue-rotate(180deg);
- }
-}
-
-.title {
- font-size: 22px;
- font-weight: 600;
- color: var(--text-primary);
- margin: 0;
- letter-spacing: -0.005em;
-}
-
-.titleExternalIcon {
- flex-shrink: 0;
- color: var(--muted-foreground);
- opacity: 0.72;
- transition:
- color 0.16s ease,
- opacity 0.16s ease,
- transform 0.16s ease;
-}
-
-.sponsorLink {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- width: fit-content;
- color: var(--text-primary);
- margin-top: 10px;
- padding: 8px 11px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-primary);
- font-size: 13px;
- font-weight: 600;
- line-height: 1.4;
- max-width: min(520px, 100%);
- overflow-wrap: anywhere;
- text-decoration: none;
- transition:
- background 0.16s ease,
- border-color 0.16s ease,
- color 0.16s ease,
- box-shadow 0.16s ease,
- transform 0.16s ease;
-
- &:hover {
- border-color: var(--border-hover);
- background: var(--bg-hover);
- transform: translateY(-1px);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-}
-
-.sponsorLinkEmphasis {
- color: var(--amber-text);
- border-color: color-mix(in srgb, var(--amber-color) 48%, var(--border-color));
- background: color-mix(in srgb, var(--amber-color) 14%, var(--bg-primary));
- box-shadow: 0 1px 0 color-mix(in srgb, var(--amber-color) 18%, transparent);
-
- &:hover {
- border-color: color-mix(in srgb, var(--amber-color) 72%, var(--border-color));
- background: color-mix(in srgb, var(--amber-color) 20%, var(--bg-primary));
- box-shadow: 0 4px 12px color-mix(in srgb, var(--amber-color) 18%, transparent);
- }
-
- &:focus-visible {
- outline-color: var(--amber-color);
- }
-}
-
-.sponsorLinkText {
- min-width: 0;
- overflow-wrap: anywhere;
-}
-
-.sponsorLinkIcon {
- flex-shrink: 0;
- color: currentColor;
-
- .sponsorLink:hover & {
- transform: translate(1px, -1px);
- }
-}
-
-.searchWrap {
- position: relative;
- min-width: 0;
-
- @media (min-width: 768px) {
- width: 280px;
- }
-}
-
-.searchInput {
- width: 100%;
- height: 38px;
- padding: 0 12px 0 38px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 14px;
- box-sizing: border-box;
-
- &::placeholder {
- color: var(--text-tertiary);
- }
-
- &:focus {
- outline: none;
- border-color: var(--primary-color);
- box-shadow: 0 0 0 3px var(--primary-10);
- }
-}
-
-.searchIcon {
- position: absolute;
- top: 50%;
- left: 13px;
- transform: translateY(-50%);
- color: var(--muted-foreground);
- pointer-events: none;
- display: inline-flex;
-}
-
-.empty {
- border: 1px dashed var(--border-color);
- border-radius: var(--radius-md);
- padding: 32px;
- text-align: center;
- color: var(--muted-foreground);
- font-size: 14px;
-}
-
-.emptyAction {
- margin-top: 12px;
-}
-
-.emptyActionButton {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 7px 13px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-primary);
- color: var(--text-primary);
- cursor: pointer;
- font-size: 14px;
- font-weight: 500;
- line-height: 1.4;
- text-decoration: none;
- transition:
- background 0.16s ease,
- border-color 0.16s ease,
- color 0.16s ease,
- box-shadow 0.16s ease;
-
- &:hover {
- border-color: var(--border-hover);
- background: var(--bg-hover);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.6;
- }
-}
-
-.emptyActionButtonEmphasis {
- color: var(--amber-text);
- border-color: color-mix(in srgb, var(--amber-color) 48%, var(--border-color));
- background: color-mix(in srgb, var(--amber-color) 14%, var(--bg-primary));
- font-weight: 600;
- box-shadow: 0 1px 0 color-mix(in srgb, var(--amber-color) 18%, transparent);
-
- &:hover {
- border-color: color-mix(in srgb, var(--amber-color) 72%, var(--border-color));
- background: color-mix(in srgb, var(--amber-color) 20%, var(--bg-primary));
- box-shadow: 0 4px 12px color-mix(in srgb, var(--amber-color) 18%, transparent);
- }
-
- &:focus-visible {
- outline-color: var(--amber-color);
- }
-}
diff --git a/frontend/src/features/providers/components/ProviderResourcePanel.tsx b/frontend/src/features/providers/components/ProviderResourcePanel.tsx
deleted file mode 100644
index d034fd4..0000000
--- a/frontend/src/features/providers/components/ProviderResourcePanel.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { IconExternalLink, IconPlus, IconSearch } from '@/components/ui/icons';
-import type { ProviderRecentUsageMap } from '@/components/providers/utils';
-import { PROVIDER_LOGOS } from '../brandLogos';
-import { CLAUDE_API_AFFILIATE_URL } from '../claudeApi';
-import { getKimiAffiliateUrl } from '../kimi';
-import { APIKEY_FUN_AFFILIATE_URL, APIKEY_FUN_DASHBOARD_URL } from '../sponsor';
-import { getSponsorProviderDefinition } from '../sponsorDefinitions';
-import type { ProviderGroup, ProviderResource } from '../types';
-import { ProviderResourceTable } from './ProviderResourceTable';
-import { ProviderResourceToolbar } from './ProviderResourceToolbar';
-import type { ProviderSortBy, SortDir } from '../types';
-import styles from './ProviderResourcePanel.module.scss';
-
-export interface ProviderPanelControls {
- sortBy: ProviderSortBy;
- sortDir: SortDir;
- onSortBy: (value: ProviderSortBy) => void;
- onSortDir: (value: SortDir) => void;
- availableModels: ReadonlyArray;
- selectedModels: ReadonlySet;
- onSelectedModelsChange: (next: Set) => void;
-}
-
-interface ProviderResourcePanelProps {
- group: ProviderGroup;
- filter: string;
- onFilterChange: (value: string) => void;
- filteredResources: ProviderResource[];
- selectedId: string | null;
- disableMutations?: boolean;
- usageByProvider?: ProviderRecentUsageMap;
- toolbarControls?: ProviderPanelControls;
- onView: (resource: ProviderResource) => void;
- onEdit: (resource: ProviderResource) => void;
- onDelete: (resource: ProviderResource) => void;
- onToggleDisabled?: (resource: ProviderResource, disabled: boolean) => void;
- onCreate: () => void;
-}
-
-export function ProviderResourcePanel({
- group,
- filter,
- onFilterChange,
- filteredResources,
- selectedId,
- disableMutations,
- usageByProvider,
- toolbarControls,
- onView,
- onEdit,
- onDelete,
- onToggleDisabled,
- onCreate,
-}: ProviderResourcePanelProps) {
- const { t, i18n } = useTranslation();
- const logo = PROVIDER_LOGOS[group.id];
- const providerTitle = t(`providersPage.providerNames.${group.id}`);
- const hasProviderInfo = group.resources.length > 0;
- const showSponsorRegistrationLink = group.id === 'apikeyFun' && !hasProviderInfo;
- const showSponsorDashboardLink = group.id === 'apikeyFun' && hasProviderInfo;
- const showClaudeApiSponsorLink = group.id === 'claudeApi';
- const registrationUrl =
- group.id === 'claudeApi'
- ? CLAUDE_API_AFFILIATE_URL
- : group.id === 'kimi'
- ? getKimiAffiliateUrl(i18n.resolvedLanguage ?? i18n.language)
- : group.id === 'code0' ||
- group.id === 'lmuAI' ||
- group.id === 'infistar' ||
- group.id === 'fennoAI' ||
- group.id === 'qiniuCloud'
- ? getSponsorProviderDefinition(group.id).affiliateUrl
- : null;
- const registrationLabel = t(
- group.id === 'kimi' ? 'providersPage.sponsor.registerNow' : 'providersPage.sponsor.registerLink'
- );
- const emptyText = showSponsorRegistrationLink
- ? t('providersPage.sponsor.emptyRegisterHint')
- : t('providersPage.table.empty');
- const logoClassName = [
- styles.logo,
- logo?.themeSurface ? styles.logoThemeSurface : '',
- logo?.darkSrc ? styles.logoThemeLight : '',
- logo?.invertOnDark ? styles.logoInvertOnDark : '',
- ]
- .filter(Boolean)
- .join(' ');
- const darkLogoClassName = [
- styles.logo,
- logo?.themeSurface ? styles.logoThemeSurface : '',
- styles.logoThemeDark,
- ]
- .filter(Boolean)
- .join(' ');
-
- const titleContent = (
- <>
- {logo ? (
- <>
-
- {logo.darkSrc ? (
-
- ) : null}
- >
- ) : null}
- {providerTitle}
- {showSponsorDashboardLink ? (
-
- ) : null}
- >
- );
-
- return (
-
-
-
-
-
-
-
-
- onFilterChange(event.target.value)}
- placeholder={t('providersPage.table.filterPlaceholder')}
- />
-
-
- {toolbarControls ? (
-
- ) : null}
-
-
- {filteredResources.length === 0 ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/frontend/src/features/providers/components/ProviderResourceTable.module.scss b/frontend/src/features/providers/components/ProviderResourceTable.module.scss
deleted file mode 100644
index 610ccc9..0000000
--- a/frontend/src/features/providers/components/ProviderResourceTable.module.scss
+++ /dev/null
@@ -1,254 +0,0 @@
-@use '../../../styles/mixins' as *;
-@use '../../../styles/variables' as *;
-
-.providerTable {
- min-width: 960px;
- table-layout: fixed;
-
- tbody tr:hover .actionsCell {
- background: color-mix(in srgb, var(--accent-bg) 50%, transparent);
- }
-}
-
-.primaryCell {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
-}
-
-.primaryName {
- font-weight: 500;
- color: var(--text-primary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 220px;
-}
-
-.primarySub {
- font-size: 12px;
- color: var(--muted-foreground);
- font-family: $font-mono;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 220px;
-}
-
-.baseUrl {
- display: block;
- width: 100%;
- min-width: 0;
- max-width: 100%;
- font-family: $font-mono;
- font-size: 12px;
- color: var(--muted-foreground);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.chip {
- display: inline-flex;
- align-items: center;
- padding: 3px 9px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--muted-bg);
- color: var(--muted-foreground);
- font-size: 12px;
-}
-
-.metricsCell {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- align-items: center;
-}
-
-.metric {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 3px 9px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--muted-bg);
- font-size: 12px;
- line-height: 1.4;
- white-space: nowrap;
-}
-
-.metricLabel {
- color: var(--muted-foreground);
-}
-
-.metricValue {
- color: var(--text-primary);
- font-weight: 600;
- font-variant-numeric: tabular-nums;
-}
-
-.flagTag {
- display: inline-flex;
- align-items: center;
- padding: 3px 9px;
- border-radius: var(--radius-md);
- border: 1px solid var(--primary-30);
- background: var(--primary-10);
- color: var(--primary-color);
- font-size: 12px;
- font-weight: 500;
- line-height: 1.4;
- white-space: nowrap;
-}
-
-.statusBadge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 3px 9px;
- border-radius: var(--radius-md);
- border: 1px solid;
- font-size: 12px;
- font-weight: 500;
- white-space: nowrap;
-}
-
-.statusCell {
- display: flex;
- flex-direction: column;
- gap: 6px;
- align-items: flex-start;
- min-width: 0;
- max-width: 190px;
-}
-
-.stats {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- max-width: 100%;
-}
-
-.statPill {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 7px;
- border-radius: 999px;
- font-size: 12px;
- font-weight: 600;
- line-height: 1.4;
- border: 1px solid transparent;
- white-space: nowrap;
- font-variant-numeric: tabular-nums;
-}
-
-.statSuccess {
- background-color: var(--success-badge-bg);
- color: var(--success-badge-text);
- border-color: var(--success-badge-border);
-}
-
-.statFailure {
- background-color: var(--failure-badge-bg);
- color: var(--failure-badge-text);
- border-color: var(--failure-badge-border);
-}
-
-.statusActive {
- border-color: var(--success-badge-border);
- background: var(--success-badge-bg);
- color: var(--success-badge-text);
-}
-
-.statusDisabled {
- border-color: var(--amber-30);
- background: var(--amber-10);
- color: var(--amber-text);
-}
-
-.statusBarWrap {
- width: min(100%, 178px);
- min-width: 0;
-}
-
-.actionsHead,
-.actionsCell {
- position: sticky;
- right: 0;
- width: 176px;
- min-width: 176px;
- box-shadow: -12px 0 16px -18px rgba(15, 23, 42, 0.45);
-}
-
-.actionsHead {
- z-index: 3;
- background: var(--muted-bg);
-}
-
-.actionsCell {
- z-index: 2;
- background: var(--bg-primary);
-}
-
-.actionsCellSelected {
- background: var(--primary-8);
-}
-
-.actions {
- display: flex;
- gap: 4px;
- align-items: center;
- justify-content: flex-end;
- min-width: 0;
-}
-
-.toggleWrap {
- display: inline-flex;
- align-items: center;
- margin-right: 4px;
-}
-
-.iconBtn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- padding: 0;
- border: 1px solid transparent;
- border-radius: var(--radius-md);
- background: transparent;
- color: var(--text-secondary);
- cursor: pointer;
- transition:
- background-color $transition-fast,
- color $transition-fast;
-
- &:hover:not(:disabled) {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: -1px;
- }
-}
-
-.iconBtnDanger {
- color: var(--destructive-color);
-
- &:hover:not(:disabled) {
- background: var(--destructive-10);
- color: var(--destructive-color);
- }
-}
diff --git a/frontend/src/features/providers/components/ProviderResourceTable.tsx b/frontend/src/features/providers/components/ProviderResourceTable.tsx
deleted file mode 100644
index 9b3c44a..0000000
--- a/frontend/src/features/providers/components/ProviderResourceTable.tsx
+++ /dev/null
@@ -1,330 +0,0 @@
-import type { ReactNode } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- IconAlertTriangle,
- IconCheckCircle2,
- IconEye,
- IconPencil,
- IconTrash2,
-} from '@/components/ui/icons';
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/Table';
-import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
-import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
-import {
- getOpenAIProviderRecentStatusData,
- getOpenAIProviderTotalStats,
- getProviderRecentStatusData,
- getProviderTotalStats,
- getProviderUsageKey,
- type ProviderRecentUsageMap,
-} from '@/components/providers/utils';
-import type { OpenAIProviderConfig } from '@/types';
-import type { StatusBarData } from '@/utils/recentRequests';
-import type { ProviderResource } from '../types';
-import { isMultiProtocolSponsorBrand } from '../sponsorDefinitions';
-import styles from './ProviderResourceTable.module.scss';
-import statusBarStyles from './providerStatusBar.module.scss';
-
-interface ProviderResourceTableProps {
- resources: ProviderResource[];
- selectedId?: string | null;
- disableMutations?: boolean;
- usageByProvider?: ProviderRecentUsageMap;
- onView: (resource: ProviderResource) => void;
- onEdit: (resource: ProviderResource) => void;
- onDelete: (resource: ProviderResource) => void;
- onToggleDisabled?: (resource: ProviderResource, disabled: boolean) => void;
-}
-
-const columnWidths = ['180px', '220px', '72px', '138px', '174px', '176px'];
-
-const isSponsorResource = (resource: ProviderResource): boolean =>
- isMultiProtocolSponsorBrand(resource.brand);
-
-const resolveStatusBarData = (
- resource: ProviderResource,
- usageByProvider: ProviderRecentUsageMap
-): StatusBarData => {
- if (resource.brand === 'openaiCompatibility') {
- return getOpenAIProviderRecentStatusData(resource.raw as OpenAIProviderConfig, usageByProvider);
- }
- return getProviderRecentStatusData(
- usageByProvider,
- getProviderUsageKey(resource.brand),
- resource.apiKey ?? undefined,
- resource.baseUrl ?? undefined
- );
-};
-
-const resolveTotalStats = (
- resource: ProviderResource,
- usageByProvider: ProviderRecentUsageMap
-): { success: number; failure: number } => {
- if (resource.brand === 'openaiCompatibility') {
- return getOpenAIProviderTotalStats(resource.raw as OpenAIProviderConfig, usageByProvider);
- }
- return getProviderTotalStats(
- usageByProvider,
- getProviderUsageKey(resource.brand),
- resource.apiKey ?? undefined,
- resource.baseUrl ?? undefined
- );
-};
-
-export function ProviderResourceTable({
- resources,
- selectedId,
- disableMutations,
- usageByProvider,
- onView,
- onEdit,
- onDelete,
- onToggleDisabled,
-}: ProviderResourceTableProps) {
- const { t } = useTranslation();
-
- const renderMetric = (key: string, label: string, value: number) => (
-
- {label}
- {value}
-
- );
-
- const renderFlagTag = (key: string, label: string) => (
-
- {label}
-
- );
-
- const renderProtocolSummary = (r: ProviderResource) =>
- (r.flags.protocols ?? [])
- .map((protocol) => t(`providersPage.sponsor.protocols.${protocol}`))
- .join(' / ');
-
- const renderModelsSummary = (r: ProviderResource) => {
- const items: ReactNode[] = [];
- if (isSponsorResource(r)) {
- (r.flags.protocols ?? []).forEach((protocol) => {
- items.push(renderFlagTag(protocol, t(`providersPage.sponsor.protocols.${protocol}`)));
- });
- return {items}
;
- }
- if (r.brand === 'openaiCompatibility') {
- items.push(
- renderMetric('models', t('providersPage.table.metrics.models'), r.modelCount),
- renderMetric('keys', t('providersPage.table.metrics.keys'), r.apiKeyEntryCount),
- renderMetric('headers', t('providersPage.table.metrics.headers'), r.headerCount)
- );
- } else {
- items.push(
- renderMetric('models', t('providersPage.table.metrics.models'), r.modelCount),
- renderMetric('headers', t('providersPage.table.metrics.headers'), r.headerCount)
- );
- if ((r.brand === 'codex' || r.brand === 'xai') && r.flags.websockets) {
- items.push(renderFlagTag('ws', t('providersPage.table.websocketsTag')));
- }
- if (r.brand === 'claude' && r.flags.cloakEnabled) {
- items.push(renderFlagTag('cloak', t('providersPage.table.cloakTag')));
- }
- }
- return {items}
;
- };
-
- const renderStatus = (r: ProviderResource) => {
- if (r.disabled) {
- return (
-
-
- {t('providersPage.status.disabled')}
-
- );
- }
- return (
-
-
- {t('providersPage.status.active')}
-
- );
- };
-
- const renderPrimary = (r: ProviderResource) => {
- if (isSponsorResource(r)) {
- return (
-
- {r.name ?? r.identifier}
-
- {r.apiKeyPreview ?? t('providersPage.status.notConfigured')}
-
-
- );
- }
- if (r.brand === 'openaiCompatibility') {
- const extra = r.apiKeyEntryCount > 1 ? ` · +${r.apiKeyEntryCount - 1}` : '';
- return (
-
- {r.name ?? r.identifier}
- {(r.apiKeyPreview ?? '—') + extra}
-
- );
- }
- return (
-
- {r.apiKeyPreview ?? '—'}
- {r.authIndex ? auth: {r.authIndex} : null}
-
- );
- };
-
- const renderBaseUrl = (r: ProviderResource) => {
- if (isSponsorResource(r)) {
- return {renderProtocolSummary(r)};
- }
- if (r.brand === 'claude' && !r.baseUrl) {
- return (
-
- https://api.anthropic.com {t('providersPage.status.defaultSuffix')}
-
- );
- }
- return {r.baseUrl ?? t('providersPage.status.notSet')};
- };
-
- return (
- (
-
- ))}
- >
-
-
- {t('providersPage.table.key')}
- {t('providersPage.table.baseUrl')}
- {t('providersPage.table.prefix')}
- {t('providersPage.table.models')}
- {t('providersPage.table.status')}
-
- {t('providersPage.table.actions')}
-
-
-
-
- {resources.map((resource) => {
- return (
-
- {renderPrimary(resource)}
- {renderBaseUrl(resource)}
-
- {resource.prefix ? (
- {resource.prefix}
- ) : (
- {t('providersPage.status.none')}
- )}
-
- {renderModelsSummary(resource)}
-
-
- {renderStatus(resource)}
- {usageByProvider && !isSponsorResource(resource) ? (
- <>
- {(() => {
- const stats = resolveTotalStats(resource, usageByProvider);
- return (
-
-
- {t('stats.success')}: {stats.success}
-
-
- {t('stats.failure')}: {stats.failure}
-
-
- );
- })()}
-
- >
- ) : null}
-
-
-
-
- {onToggleDisabled ? (
- e.stopPropagation()}>
- onToggleDisabled(resource, !value)}
- ariaLabel={
- resource.disabled
- ? t('providersPage.actions.enable')
- : t('providersPage.actions.disable')
- }
- />
-
- ) : null}
-
-
-
-
-
-
- );
- })}
-
-
- );
-}
diff --git a/frontend/src/features/providers/components/ProviderResourceToolbar.module.scss b/frontend/src/features/providers/components/ProviderResourceToolbar.module.scss
deleted file mode 100644
index 7268e43..0000000
--- a/frontend/src/features/providers/components/ProviderResourceToolbar.module.scss
+++ /dev/null
@@ -1,150 +0,0 @@
-@use '../../../styles/mixins' as *;
-@use '../../../styles/variables' as *;
-
-.root {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
-}
-
-.sortGroup {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.label {
- font-size: 12px;
- color: var(--muted-foreground);
- white-space: nowrap;
-}
-
-.dirBtn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
- color: var(--text-secondary);
- cursor: pointer;
- transition:
- background-color $transition-fast,
- color $transition-fast;
-
- &:hover {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- }
-}
-
-.filterGroup {
- position: relative;
-}
-
-.filterTrigger {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 30px;
- padding: 0 11px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 13px;
- cursor: pointer;
- transition:
- background-color $transition-fast,
- border-color $transition-fast;
-
- &:hover:not(:disabled) {
- border-color: var(--primary-color);
- color: var(--primary-color);
- }
-
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-}
-
-.filterPanel {
- position: absolute;
- top: calc(100% + 6px);
- right: 0;
- min-width: 220px;
- max-width: 320px;
- z-index: $z-dropdown;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
- padding: 8px;
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.filterToolbar {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 6px;
-}
-
-.filterToolbarBtn {
- padding: 3px 9px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
- color: var(--text-secondary);
- font-size: 12px;
- cursor: pointer;
-
- &:hover:not(:disabled) {
- border-color: var(--primary-color);
- color: var(--primary-color);
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-}
-
-.filterEmpty {
- padding: 12px;
- text-align: center;
- font-size: 13px;
- color: var(--muted-foreground);
-}
-
-.filterList {
- list-style: none;
- margin: 0;
- padding: 0;
- max-height: 220px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.filterItem {
- padding: 4px 6px;
- border-radius: var(--radius-sm);
-
- &:hover {
- background: var(--bg-tertiary);
- }
-}
-
-.filterItemLabel {
- font-family: $font-mono;
- font-size: 13px;
- word-break: break-all;
-}
diff --git a/frontend/src/features/providers/components/ProviderResourceToolbar.tsx b/frontend/src/features/providers/components/ProviderResourceToolbar.tsx
deleted file mode 100644
index 6c07e7f..0000000
--- a/frontend/src/features/providers/components/ProviderResourceToolbar.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { useMemo, useRef, useState, useEffect } from 'react';
-import { useTranslation } from 'react-i18next';
-import { IconChevronDown, IconChevronUp, IconSlidersHorizontal } from '@/components/ui/icons';
-import { Select } from '@/components/ui/Select';
-import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
-import type { ProviderSortBy, SortDir } from '../types';
-import styles from './ProviderResourceToolbar.module.scss';
-
-interface ProviderResourceToolbarProps {
- sortBy: ProviderSortBy;
- sortDir: SortDir;
- onSortBy: (value: ProviderSortBy) => void;
- onSortDir: (value: SortDir) => void;
- availableModels: ReadonlyArray;
- selectedModels: ReadonlySet;
- onSelectedModelsChange: (next: Set) => void;
-}
-
-export function ProviderResourceToolbar({
- sortBy,
- sortDir,
- onSortBy,
- onSortDir,
- availableModels,
- selectedModels,
- onSelectedModelsChange,
-}: ProviderResourceToolbarProps) {
- const { t } = useTranslation();
- const [filterOpen, setFilterOpen] = useState(false);
- const containerRef = useRef(null);
-
- const sortOptions = useMemo(
- () => [
- { value: 'name', label: t('providersPage.toolbar.sort.name') },
- { value: 'priority', label: t('providersPage.toolbar.sort.priority') },
- {
- value: 'recent-success',
- label: t('providersPage.toolbar.sort.recentSuccess'),
- },
- ],
- [t]
- );
-
- useEffect(() => {
- if (!filterOpen) return;
- const onClickOutside = (e: PointerEvent) => {
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
- setFilterOpen(false);
- }
- };
- document.addEventListener('pointerdown', onClickOutside);
- return () => document.removeEventListener('pointerdown', onClickOutside);
- }, [filterOpen]);
-
- const toggleModel = (name: string) => {
- const next = new Set(selectedModels);
- if (next.has(name)) next.delete(name);
- else next.add(name);
- onSelectedModelsChange(next);
- };
-
- const selectAll = () => onSelectedModelsChange(new Set(availableModels));
- const clearAll = () => onSelectedModelsChange(new Set());
-
- const filterLabel =
- selectedModels.size === 0
- ? t('providersPage.toolbar.filter.allModels')
- : t('providersPage.toolbar.filter.selectedModels', {
- selected: selectedModels.size,
- total: availableModels.length,
- });
-
- return (
-
-
- {t('providersPage.toolbar.sortBy')}
-
-
-
-
- {filterOpen ? (
-
-
-
-
-
- {availableModels.length === 0 ? (
-
{t('providersPage.toolbar.filter.empty')}
- ) : (
-
- {availableModels.map((name) => (
- -
- toggleModel(name)}
- label={{name}}
- />
-
- ))}
-
- )}
-
- ) : null}
-
-
- );
-}
diff --git a/frontend/src/features/providers/components/SponsorQuickStartPanel.module.scss b/frontend/src/features/providers/components/SponsorQuickStartPanel.module.scss
deleted file mode 100644
index 1d18355..0000000
--- a/frontend/src/features/providers/components/SponsorQuickStartPanel.module.scss
+++ /dev/null
@@ -1,230 +0,0 @@
-@use '../../../styles/mixins' as *;
-@use '../../../styles/variables' as *;
-
-.panel {
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 12px;
- box-shadow: var(--shadow);
- padding: 20px;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 22px;
-}
-
-.header {
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- min-width: 0;
-}
-
-.topLink {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- max-width: 100%;
- height: 30px;
- padding: 0 8px;
- border: 1px solid transparent;
- border-radius: var(--radius-md);
- background: transparent;
- color: var(--muted-foreground);
- font-size: 13px;
- font-weight: 600;
- line-height: 1;
- text-decoration: none;
- overflow-wrap: anywhere;
- transition:
- background-color $transition-fast,
- color $transition-fast;
-
- &:hover {
- background: var(--bg-hover);
- color: var(--text-primary);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-
- svg {
- flex-shrink: 0;
- }
-}
-
-.titleRow {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 12px;
- min-width: 0;
-}
-
-.logo {
- width: 30px;
- height: 30px;
- border-radius: var(--radius-md);
- object-fit: contain;
- flex-shrink: 0;
-}
-
-.titleText {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 4px;
-}
-
-.title {
- margin: 0;
- color: var(--text-primary);
- font-size: 24px;
- font-weight: 700;
- line-height: 1.22;
- letter-spacing: 0;
-}
-
-.footer {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- gap: 8px;
- padding-top: 4px;
-}
-
-.empty {
- border: 1px dashed var(--border-color);
- border-radius: var(--radius-md);
- padding: 32px;
- text-align: center;
- color: var(--muted-foreground);
- font-size: 14px;
-}
-
-.emptyActions {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: center;
- gap: 8px;
- margin-top: 12px;
-}
-
-.emptyActionButton {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 7px 13px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-primary);
- color: var(--text-primary);
- cursor: pointer;
- font-size: 14px;
- font-weight: 500;
- line-height: 1.4;
- text-decoration: none;
- transition:
- background 0.16s ease,
- border-color 0.16s ease,
- color 0.16s ease,
- box-shadow 0.16s ease;
-
- &:hover {
- border-color: var(--border-hover);
- background: var(--bg-hover);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.6;
- }
-}
-
-.emptyActionButtonPrimary {
- border-color: var(--primary-color);
- background: var(--primary-color);
- color: var(--primary-contrast);
- font-weight: 600;
-
- &:hover:not(:disabled) {
- border-color: var(--primary-hover);
- background: var(--primary-hover);
- }
-
- &:focus-visible {
- outline-color: var(--primary-color);
- }
-}
-
-.emptyActionButtonEmphasis {
- color: var(--amber-text);
- border-color: color-mix(in srgb, var(--amber-color) 48%, var(--border-color));
- background: color-mix(in srgb, var(--amber-color) 14%, var(--bg-primary));
- font-weight: 600;
- box-shadow: 0 1px 0 color-mix(in srgb, var(--amber-color) 18%, transparent);
-
- &:hover {
- border-color: color-mix(in srgb, var(--amber-color) 72%, var(--border-color));
- background: color-mix(in srgb, var(--amber-color) 20%, var(--bg-primary));
- box-shadow: 0 4px 12px color-mix(in srgb, var(--amber-color) 18%, transparent);
- }
-
- &:focus-visible {
- outline-color: var(--amber-color);
- }
-}
-
-.primaryAction {
- height: 38px;
- font-size: 14px;
- font-weight: 600;
- line-height: 1;
-}
-
-.spin {
- animation: sponsor-quick-start-spin 0.8s linear infinite;
-}
-
-@keyframes sponsor-quick-start-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (max-width: 768px) {
- .panel {
- padding: 16px;
- gap: 18px;
- }
-
- .title {
- font-size: 22px;
- line-height: 1.25;
- }
-
- .titleRow {
- gap: 10px;
- }
-
- .footer {
- justify-content: stretch;
-
- button {
- width: 100%;
- justify-content: center;
- }
- }
-}
diff --git a/frontend/src/features/providers/components/SponsorQuickStartPanel.tsx b/frontend/src/features/providers/components/SponsorQuickStartPanel.tsx
deleted file mode 100644
index e2ba4a6..0000000
--- a/frontend/src/features/providers/components/SponsorQuickStartPanel.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-import { useId, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
-import { useNotificationStore } from '@/stores';
-import { IconCheckCircle2, IconExternalLink, IconLoader2, IconPlus } from '@/components/ui/icons';
-import { PROVIDER_LOGOS } from '../brandLogos';
-import { APIKEY_FUN_AFFILIATE_URL, APIKEY_FUN_DASHBOARD_URL } from '../sponsor';
-import { isSponsorPartialMutationError } from '../sponsorMutationRecovery';
-import type { ProviderEntryFormInput, ProviderResource } from '../types';
-import type { UseProviderWorkbenchResult } from '../useProviderWorkbench';
-import { SponsorProviderForm } from '../sheets/forms/SponsorProviderForm';
-import formStyles from '../sheets/forms/sharedForm.module.scss';
-import styles from './SponsorQuickStartPanel.module.scss';
-
-interface SponsorQuickStartPanelProps {
- resource: ProviderResource | null;
- workbench: UseProviderWorkbenchResult;
- mutationDisabled?: boolean;
-}
-
-export function SponsorQuickStartPanel({
- resource,
- workbench,
- mutationDisabled = false,
-}: SponsorQuickStartPanelProps) {
- const { t } = useTranslation();
- const { showNotification } = useNotificationStore();
- const formId = useId();
- const [submitting, setSubmitting] = useState(false);
- const [isDirty, setIsDirty] = useState(false);
- const [formVersion, setFormVersion] = useState(0);
- const [showCreateForm, setShowCreateForm] = useState(false);
-
- const formMutating = submitting || mutationDisabled || workbench.mutating;
- const mode = resource ? 'edit' : 'create';
- const submitDisabled = formMutating || (mode === 'edit' && !isDirty);
- const logo = PROVIDER_LOGOS.apikeyFun;
-
- useUnsavedChangesGuard({
- shouldBlock: isDirty && !submitting,
- dialog: {
- title: t('providersPage.unsavedChanges.title'),
- message: t('providersPage.unsavedChanges.message'),
- confirmText: t('providersPage.unsavedChanges.discard'),
- cancelText: t('providersPage.unsavedChanges.keepEditing'),
- variant: 'danger',
- },
- });
-
- const handleSubmit = async (input: ProviderEntryFormInput) => {
- if (mutationDisabled) return;
- setSubmitting(true);
- try {
- if (resource) {
- await workbench.updateProvider(resource, input);
- showNotification(t('providersPage.toast.updated'), 'success');
- } else {
- await workbench.createProvider('apikeyFun', input);
- showNotification(t('providersPage.toast.created'), 'success');
- setShowCreateForm(false);
- }
- setIsDirty(false);
- setFormVersion((current) => current + 1);
- } catch (err) {
- if (isSponsorPartialMutationError(err)) {
- showNotification(t('providersPage.sponsor.partialMutationWarning'), 'warning');
- throw err;
- }
- const msg = err instanceof Error ? err.message : String(err);
- showNotification(
- `${t(resource ? 'notification.update_failed' : 'notification.add_failed')}: ${msg}`,
- 'error'
- );
- throw err;
- } finally {
- setSubmitting(false);
- }
- };
-
- if (!resource && !showCreateForm) {
- return (
-
-
-
-

-
-
{t('providersPage.providerNames.apikeyFun')}
-
-
-
-
-
-
{t('providersPage.sponsor.emptyRegisterHint')}
-
-
-
- );
- }
-
- const actionHref = resource ? APIKEY_FUN_DASHBOARD_URL : APIKEY_FUN_AFFILIATE_URL;
- const actionLabel = resource
- ? t('providersPage.sponsor.dashboardLink')
- : t('providersPage.sponsor.registerLink');
-
- return (
-
-
-
-
-
-
- {!resource ? (
-
- ) : null}
-
-
-
- );
-}
diff --git a/frontend/src/features/providers/components/providerStatusBar.module.scss b/frontend/src/features/providers/components/providerStatusBar.module.scss
deleted file mode 100644
index d53aa37..0000000
--- a/frontend/src/features/providers/components/providerStatusBar.module.scss
+++ /dev/null
@@ -1,163 +0,0 @@
-@use '../../../styles/mixins' as *;
-@use '../../../styles/variables' as *;
-
-.statusBar {
- display: grid;
- grid-template-columns: minmax(0, 1fr) max-content;
- align-items: center;
- gap: 6px;
- width: 100%;
- max-width: 100%;
-}
-
-.statusBlocks {
- display: flex;
- gap: 2px;
- width: 100%;
- min-width: 0;
- position: relative;
-}
-
-.statusBlockWrapper {
- flex: 1 1 0;
- min-width: 0;
- position: relative;
- cursor: pointer;
- -webkit-tap-highlight-color: transparent;
-}
-
-.statusBlock {
- width: 100%;
- height: 6px;
- border-radius: 2px;
- transition:
- transform 0.15s ease,
- opacity 0.15s ease;
-
- .statusBlockWrapper:hover &,
- .statusBlockWrapper.statusBlockActive & {
- transform: scaleY(1.8);
- opacity: 0.9;
- }
-}
-
-.statusBlockIdle {
- background-color: var(--border-color);
-}
-
-.statusTooltip {
- position: absolute;
- bottom: calc(100% + 8px);
- left: 50%;
- transform: translateX(-50%);
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 6px 10px;
- font-size: 12px;
- line-height: 1.5;
- white-space: nowrap;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
- z-index: $z-dropdown;
- pointer-events: none;
- color: var(--text-primary);
-
- &::after {
- content: '';
- position: absolute;
- top: 100%;
- left: 50%;
- transform: translateX(-50%);
- border: 5px solid transparent;
- border-top-color: var(--bg-primary);
- }
-
- &::before {
- content: '';
- position: absolute;
- top: 100%;
- left: 50%;
- transform: translateX(-50%);
- border: 6px solid transparent;
- border-top-color: var(--border-color);
- }
-}
-
-.statusTooltipLeft {
- left: 0;
- transform: translateX(0);
-
- &::after,
- &::before {
- left: 8px;
- transform: none;
- }
-}
-
-.statusTooltipRight {
- left: auto;
- right: 0;
- transform: translateX(0);
-
- &::after,
- &::before {
- left: auto;
- right: 8px;
- transform: none;
- }
-}
-
-.tooltipTime {
- color: var(--text-secondary);
- display: block;
- margin-bottom: 2px;
-}
-
-.tooltipStats {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.tooltipSuccess {
- color: var(--success-color, #22c55e);
-}
-
-.tooltipFailure {
- color: var(--danger-color, #ef4444);
-}
-
-.tooltipRate {
- color: var(--text-secondary);
- margin-left: 2px;
-}
-
-.statusRate {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 48px;
- font-size: 12px;
- font-weight: 600;
- white-space: nowrap;
- padding: 2px 6px;
- border-radius: 4px;
- background: var(--bg-tertiary);
- color: var(--text-secondary);
- font-variant-numeric: tabular-nums;
-}
-
-.statusRateHigh {
- color: var(--success-badge-text, #065f46);
- background: var(--success-badge-bg, #d1fae5);
-}
-
-.statusRateMedium {
- color: var(--warning-text, #92400e);
- background: var(--warning-bg, #fef3c7);
-}
-
-.statusRateLow {
- color: var(--failure-badge-text);
- background: var(--failure-badge-bg);
-}
diff --git a/frontend/src/features/providers/descriptors.ts b/frontend/src/features/providers/descriptors.ts
deleted file mode 100644
index 75308a3..0000000
--- a/frontend/src/features/providers/descriptors.ts
+++ /dev/null
@@ -1,328 +0,0 @@
-import type { ProviderBrand } from './types';
-
-export interface ProviderDescriptor {
- id: ProviderBrand;
- supportsName: boolean;
- supportsApiKey: boolean;
- supportsDisabled: boolean;
- supportsBaseUrl: boolean;
- baseUrlRequired: boolean;
- supportsProxyUrl: boolean;
- supportsPrefix: boolean;
- supportsModels: boolean;
- supportsHeaders: boolean;
- supportsExcludedModels: boolean;
- supportsPriority: boolean;
- supportsTestModel: boolean;
- supportsWebsockets: boolean;
- supportsCloak: boolean;
- supportsApiKeyEntries: boolean;
- /** Sheet 默认宽度 */
- sheetSize: 'md' | 'lg' | 'xl';
-}
-
-export const PROVIDER_DESCRIPTORS: Record = {
- gemini: {
- id: 'gemini',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: true,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: true,
- supportsHeaders: true,
- supportsExcludedModels: true,
- supportsPriority: true,
- supportsTestModel: true,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- interactions: {
- id: 'interactions',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: true,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: true,
- supportsHeaders: true,
- supportsExcludedModels: true,
- supportsPriority: true,
- supportsTestModel: true,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- codex: {
- id: 'codex',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: true,
- baseUrlRequired: true,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: true,
- supportsHeaders: true,
- supportsExcludedModels: true,
- supportsPriority: true,
- supportsTestModel: true,
- supportsWebsockets: true,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- xai: {
- id: 'xai',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: true,
- baseUrlRequired: true,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: true,
- supportsHeaders: true,
- supportsExcludedModels: true,
- supportsPriority: true,
- supportsTestModel: true,
- supportsWebsockets: true,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- claude: {
- id: 'claude',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: true,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: true,
- supportsHeaders: true,
- supportsExcludedModels: true,
- supportsPriority: true,
- supportsTestModel: true,
- supportsWebsockets: false,
- supportsCloak: true,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- claudeApi: {
- id: 'claudeApi',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: false,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: true,
- supportsHeaders: true,
- supportsExcludedModels: true,
- supportsPriority: true,
- supportsTestModel: true,
- supportsWebsockets: false,
- supportsCloak: true,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- vertex: {
- id: 'vertex',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: true,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: true,
- supportsHeaders: true,
- supportsExcludedModels: true,
- supportsPriority: true,
- supportsTestModel: false,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- openaiCompatibility: {
- id: 'openaiCompatibility',
- supportsName: true,
- supportsApiKey: false,
- supportsDisabled: true,
- supportsBaseUrl: true,
- baseUrlRequired: true,
- supportsProxyUrl: false,
- supportsPrefix: true,
- supportsModels: true,
- supportsHeaders: true,
- supportsExcludedModels: false,
- supportsPriority: true,
- supportsTestModel: true,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: true,
- sheetSize: 'lg',
- },
- apikeyFun: {
- id: 'apikeyFun',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: false,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: false,
- supportsHeaders: false,
- supportsExcludedModels: false,
- supportsPriority: true,
- supportsTestModel: false,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- code0: {
- id: 'code0',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: false,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: false,
- supportsHeaders: false,
- supportsExcludedModels: false,
- supportsPriority: true,
- supportsTestModel: false,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- fennoAI: {
- id: 'fennoAI',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: false,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: false,
- supportsHeaders: false,
- supportsExcludedModels: false,
- supportsPriority: true,
- supportsTestModel: false,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- qiniuCloud: {
- id: 'qiniuCloud',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: false,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: false,
- supportsHeaders: false,
- supportsExcludedModels: false,
- supportsPriority: true,
- supportsTestModel: false,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- lmuAI: {
- id: 'lmuAI',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: false,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: false,
- supportsHeaders: false,
- supportsExcludedModels: false,
- supportsPriority: true,
- supportsTestModel: false,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- infistar: {
- id: 'infistar',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: false,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: false,
- supportsHeaders: false,
- supportsExcludedModels: false,
- supportsPriority: true,
- supportsTestModel: false,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
- kimi: {
- id: 'kimi',
- supportsName: false,
- supportsApiKey: true,
- supportsDisabled: true,
- supportsBaseUrl: false,
- baseUrlRequired: false,
- supportsProxyUrl: true,
- supportsPrefix: true,
- supportsModels: false,
- supportsHeaders: false,
- supportsExcludedModels: false,
- supportsPriority: true,
- supportsTestModel: false,
- supportsWebsockets: false,
- supportsCloak: false,
- supportsApiKeyEntries: false,
- sheetSize: 'md',
- },
-};
-
-export const PROVIDER_BRAND_ORDER: ProviderBrand[] = [
- 'kimi',
- 'gemini',
- 'interactions',
- 'codex',
- 'xai',
- 'claude',
- 'vertex',
- 'openaiCompatibility',
- 'apikeyFun',
- 'claudeApi',
- 'code0',
- 'fennoAI',
- 'qiniuCloud',
- 'lmuAI',
- 'infistar',
-];
diff --git a/frontend/src/features/providers/fennoAI.ts b/frontend/src/features/providers/fennoAI.ts
deleted file mode 100644
index de345af..0000000
--- a/frontend/src/features/providers/fennoAI.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { Config, ProviderKeyConfig } from '@/types';
-import type { SponsorProviderRaw } from './types';
-
-export const FENNO_AI_PROVIDER_NAME = 'fennoAI';
-export const FENNO_AI_DISPLAY_NAME = 'FennoAI';
-export const FENNO_AI_AFFILIATE_URL = 'https://api.fenno.ai/register?aff=DQFAMNB6CBLY';
-export const FENNO_AI_BASE_URL = 'https://api.fenno.ai';
-export const FENNO_AI_CODEX_BASE_URL = `${FENNO_AI_BASE_URL}/v1`;
-export const FENNO_AI_ANTHROPIC_BASE_URL = FENNO_AI_BASE_URL;
-export const FENNO_AI_OPENAI_BASE_URL = FENNO_AI_CODEX_BASE_URL;
-export const FENNO_AI_GEMINI_BASE_URL = FENNO_AI_BASE_URL;
-
-export const FENNO_AI_BASE_URL_OPTIONS = [
- {
- id: 'standard',
- baseUrl: FENNO_AI_BASE_URL,
- openaiBaseUrl: FENNO_AI_OPENAI_BASE_URL,
- codexBaseUrl: FENNO_AI_CODEX_BASE_URL,
- anthropicBaseUrl: FENNO_AI_ANTHROPIC_BASE_URL,
- geminiBaseUrl: FENNO_AI_GEMINI_BASE_URL,
- },
-] as const;
-
-export const FENNO_AI_PROTOCOL_LABELS = ['codexResponses', 'anthropic'] as const;
-
-const normalizeText = (value: string | undefined | null): string =>
- String(value ?? '')
- .trim()
- .toLowerCase();
-
-const normalizeBaseUrl = (value: string | undefined | null): string =>
- normalizeText(value).replace(/\/+$/, '');
-
-export const resolveFennoAIBaseUrl = (value: string | undefined | null): string => {
- const normalized = normalizeBaseUrl(value);
- const matched = FENNO_AI_BASE_URL_OPTIONS.find(
- (option) =>
- normalized === normalizeBaseUrl(option.baseUrl) ||
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl) ||
- normalized === normalizeBaseUrl(option.anthropicBaseUrl) ||
- normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
- return matched?.baseUrl ?? FENNO_AI_BASE_URL;
-};
-
-export const getFennoAIProtocolUrls = (value: string | undefined | null) => {
- const baseUrl = resolveFennoAIBaseUrl(value);
- const matched =
- FENNO_AI_BASE_URL_OPTIONS.find(
- (option) => normalizeBaseUrl(option.baseUrl) === normalizeBaseUrl(baseUrl)
- ) ?? FENNO_AI_BASE_URL_OPTIONS[0];
- return {
- anthropic: matched.anthropicBaseUrl,
- openai: matched.openaiBaseUrl,
- codex: matched.codexBaseUrl,
- gemini: matched.geminiBaseUrl,
- };
-};
-
-const matchesFennoAICodexBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return FENNO_AI_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.codexBaseUrl)
- );
-};
-
-const matchesFennoAIAnthropicBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return FENNO_AI_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.anthropicBaseUrl)
- );
-};
-
-export const isFennoAIClaudeProvider = (
- config: ProviderKeyConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesFennoAIAnthropicBaseUrl(config.baseUrl);
-};
-
-export const isFennoAICodexProvider = (
- config: ProviderKeyConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesFennoAICodexBaseUrl(config.baseUrl);
-};
-
-export const buildFennoAIRaw = (config: Config | null | undefined): SponsorProviderRaw => ({
- // FennoAI does not expose OpenAI in its protocol definition. Keep any
- // name-matching OpenAI compatibility entry in the generic provider group.
- openai: [],
- claude: (config?.claudeApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isFennoAIClaudeProvider(item.config)),
- codex: (config?.codexApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isFennoAICodexProvider(item.config)),
- gemini: [],
-});
diff --git a/frontend/src/features/providers/infistar.ts b/frontend/src/features/providers/infistar.ts
deleted file mode 100644
index 468e18f..0000000
--- a/frontend/src/features/providers/infistar.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import type { Config, GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import type { SponsorProviderRaw } from './types';
-
-export const INFISTAR_PROVIDER_NAME = 'infistar';
-export const INFISTAR_DISPLAY_NAME = '无限星河';
-export const INFISTAR_AFFILIATE_URL = 'https://infistar.ai/register?aff=FQKC6J6R&ref_source=link';
-export const INFISTAR_DOMESTIC_ROOT_URL = 'https://coneverse.com';
-export const INFISTAR_GLOBAL_ROOT_URL = 'https://infistar.ai';
-export const INFISTAR_DOMESTIC_BASE_URL = `${INFISTAR_DOMESTIC_ROOT_URL}/v1`;
-export const INFISTAR_GLOBAL_BASE_URL = `${INFISTAR_GLOBAL_ROOT_URL}/v1`;
-
-export const INFISTAR_BASE_URL_OPTIONS = [
- {
- id: 'mainlandChina',
- descriptionKey: 'mainlandChinaRecommended',
- baseUrl: INFISTAR_DOMESTIC_BASE_URL,
- openaiBaseUrl: INFISTAR_DOMESTIC_BASE_URL,
- codexBaseUrl: INFISTAR_DOMESTIC_BASE_URL,
- anthropicBaseUrl: INFISTAR_DOMESTIC_ROOT_URL,
- geminiBaseUrl: INFISTAR_DOMESTIC_ROOT_URL,
- },
- {
- id: 'global',
- descriptionKey: 'global',
- baseUrl: INFISTAR_GLOBAL_BASE_URL,
- openaiBaseUrl: INFISTAR_GLOBAL_BASE_URL,
- codexBaseUrl: INFISTAR_GLOBAL_BASE_URL,
- anthropicBaseUrl: INFISTAR_GLOBAL_ROOT_URL,
- geminiBaseUrl: INFISTAR_GLOBAL_ROOT_URL,
- },
-] as const;
-
-export const INFISTAR_PROTOCOL_LABELS = [
- 'openai',
- 'anthropic',
- 'gemini',
- 'codexResponses',
-] as const;
-
-const normalizeText = (value: string | undefined | null): string =>
- String(value ?? '')
- .trim()
- .toLowerCase();
-
-const normalizeBaseUrl = (value: string | undefined | null): string =>
- normalizeText(value).replace(/\/+$/, '');
-
-export const resolveInfistarBaseUrl = (value: string | undefined | null): string => {
- const normalized = normalizeBaseUrl(value);
- const matched = INFISTAR_BASE_URL_OPTIONS.find(
- (option) =>
- normalized === normalizeBaseUrl(option.baseUrl) ||
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl) ||
- normalized === normalizeBaseUrl(option.anthropicBaseUrl) ||
- normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
- return matched?.baseUrl ?? INFISTAR_DOMESTIC_BASE_URL;
-};
-
-export const getInfistarProtocolUrls = (value: string | undefined | null) => {
- const baseUrl = resolveInfistarBaseUrl(value);
- const matched =
- INFISTAR_BASE_URL_OPTIONS.find(
- (option) => normalizeBaseUrl(option.baseUrl) === normalizeBaseUrl(baseUrl)
- ) ?? INFISTAR_BASE_URL_OPTIONS[0];
- return {
- anthropic: matched.anthropicBaseUrl,
- openai: matched.openaiBaseUrl,
- codex: matched.codexBaseUrl,
- gemini: matched.geminiBaseUrl,
- };
-};
-
-const matchesInfistarOpenAIBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return INFISTAR_BASE_URL_OPTIONS.some(
- (option) =>
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl)
- );
-};
-
-const matchesInfistarAnthropicBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return INFISTAR_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.anthropicBaseUrl)
- );
-};
-
-const matchesInfistarGeminiBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return INFISTAR_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
-};
-
-export const isInfistarOpenAIProvider = (
- config: OpenAIProviderConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesInfistarOpenAIBaseUrl(config.baseUrl);
-};
-
-export const isInfistarClaudeProvider = (config: ProviderKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesInfistarAnthropicBaseUrl(config.baseUrl);
-};
-
-export const isInfistarCodexProvider = (config: ProviderKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesInfistarOpenAIBaseUrl(config.baseUrl);
-};
-
-export const isInfistarGeminiProvider = (config: GeminiKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesInfistarGeminiBaseUrl(config.baseUrl);
-};
-
-export const buildInfistarRaw = (config: Config | null | undefined): SponsorProviderRaw => ({
- openai: (config?.openaiCompatibility ?? [])
- .map((item, index) => ({ config: item, index: item.sourceIndex ?? index }))
- .filter((item) => isInfistarOpenAIProvider(item.config)),
- claude: (config?.claudeApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isInfistarClaudeProvider(item.config)),
- codex: (config?.codexApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isInfistarCodexProvider(item.config)),
- gemini: (config?.geminiApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isInfistarGeminiProvider(item.config)),
-});
diff --git a/frontend/src/features/providers/kimi.ts b/frontend/src/features/providers/kimi.ts
deleted file mode 100644
index 11202e3..0000000
--- a/frontend/src/features/providers/kimi.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import type { Config, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import type { SponsorProviderRaw } from './types';
-
-export const KIMI_PROVIDER_NAME = 'kimi';
-export const KIMI_DISPLAY_NAME = 'Kimi';
-export const KIMI_LEGACY_OPENAI_BASE_URL = 'https://api.moonshot.ai';
-export const KIMI_DOMESTIC_BASE_URL = 'https://api.moonshot.cn';
-export const KIMI_OPENAI_BASE_URL = `${KIMI_LEGACY_OPENAI_BASE_URL}/v1`;
-export const KIMI_DOMESTIC_OPENAI_BASE_URL = `${KIMI_DOMESTIC_BASE_URL}/v1`;
-export const KIMI_ANTHROPIC_BASE_URL = `${KIMI_LEGACY_OPENAI_BASE_URL}/anthropic`;
-export const KIMI_DOMESTIC_ANTHROPIC_BASE_URL = `${KIMI_DOMESTIC_BASE_URL}/anthropic`;
-export const KIMI_CHINESE_AFFILIATE_URL = 'https://platform.kimi.com/?aff=cliproxyapi';
-export const KIMI_INTERNATIONAL_AFFILIATE_URL = 'https://platform.kimi.ai/?aff=cliproxyapi';
-
-export const KIMI_BASE_URL_OPTIONS = [
- {
- id: 'domestic',
- descriptionKey: 'domestic',
- baseUrl: KIMI_DOMESTIC_OPENAI_BASE_URL,
- openaiBaseUrl: KIMI_DOMESTIC_OPENAI_BASE_URL,
- codexBaseUrl: '',
- anthropicBaseUrl: KIMI_DOMESTIC_ANTHROPIC_BASE_URL,
- geminiBaseUrl: '',
- },
- {
- id: 'overseas',
- descriptionKey: 'overseas',
- baseUrl: KIMI_OPENAI_BASE_URL,
- openaiBaseUrl: KIMI_OPENAI_BASE_URL,
- codexBaseUrl: '',
- anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL,
- geminiBaseUrl: '',
- },
-] as const;
-
-export const KIMI_PROTOCOL_LABELS = ['openai', 'anthropic'] as const;
-
-export const getKimiAffiliateUrl = (language: string | undefined | null): string =>
- language?.toLowerCase().startsWith('zh')
- ? KIMI_CHINESE_AFFILIATE_URL
- : KIMI_INTERNATIONAL_AFFILIATE_URL;
-
-const normalizeText = (value: string | undefined | null): string =>
- String(value ?? '')
- .trim()
- .toLowerCase();
-
-const normalizeBaseUrl = (value: string | undefined | null): string =>
- normalizeText(value).replace(/\/+$/, '');
-
-export const resolveKimiBaseUrl = (value: string | undefined | null): string => {
- const normalized = normalizeBaseUrl(value);
- const matched = KIMI_BASE_URL_OPTIONS.find(
- (option) =>
- normalized === normalizeBaseUrl(option.baseUrl) ||
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.anthropicBaseUrl)
- );
- if (matched) return matched.baseUrl;
- if (normalized === normalizeBaseUrl(KIMI_LEGACY_OPENAI_BASE_URL)) {
- return KIMI_OPENAI_BASE_URL;
- }
- return KIMI_DOMESTIC_OPENAI_BASE_URL;
-};
-
-export const getKimiProtocolUrls = (value: string | undefined | null) => {
- const baseUrl = resolveKimiBaseUrl(value);
- const matched =
- KIMI_BASE_URL_OPTIONS.find(
- (option) => normalizeBaseUrl(option.baseUrl) === normalizeBaseUrl(baseUrl)
- ) ?? KIMI_BASE_URL_OPTIONS[0];
- return {
- anthropic: matched.anthropicBaseUrl,
- openai: matched.openaiBaseUrl,
- codex: '',
- gemini: '',
- };
-};
-
-export const isKimiOpenAIProvider = (config: OpenAIProviderConfig | undefined | null): boolean => {
- if (!config) return false;
- const baseUrl = normalizeBaseUrl(config.baseUrl);
- return (
- KIMI_BASE_URL_OPTIONS.some((option) => baseUrl === normalizeBaseUrl(option.openaiBaseUrl)) ||
- baseUrl === normalizeBaseUrl(KIMI_LEGACY_OPENAI_BASE_URL) ||
- baseUrl === normalizeBaseUrl(KIMI_DOMESTIC_BASE_URL)
- );
-};
-
-export const isKimiClaudeProvider = (config: ProviderKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- const baseUrl = normalizeBaseUrl(config.baseUrl);
- return KIMI_BASE_URL_OPTIONS.some(
- (option) => baseUrl === normalizeBaseUrl(option.anthropicBaseUrl)
- );
-};
-
-export const buildKimiRaw = (config: Config | null | undefined): SponsorProviderRaw => ({
- openai: (config?.openaiCompatibility ?? [])
- .map((item, index) => ({ config: item, index: item.sourceIndex ?? index }))
- .filter((item) => isKimiOpenAIProvider(item.config)),
- claude: (config?.claudeApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isKimiClaudeProvider(item.config)),
- codex: [],
- gemini: [],
-});
diff --git a/frontend/src/features/providers/lmuAI.ts b/frontend/src/features/providers/lmuAI.ts
deleted file mode 100644
index 54f749f..0000000
--- a/frontend/src/features/providers/lmuAI.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import type { Config, GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import type { SponsorProviderRaw } from './types';
-
-export const LMU_AI_PROVIDER_NAME = 'lmuAI';
-export const LMU_AI_DISPLAY_NAME = 'LMU AI(灵眸AI)';
-export const LMU_AI_AFFILIATE_URL = 'https://api.lmuai.com/register?ref=yJ6Kwg9g';
-export const LMU_AI_BASE_URL = 'https://api.lmuai.com';
-export const LMU_AI_OPENAI_BASE_URL = `${LMU_AI_BASE_URL}/v1`;
-export const LMU_AI_CODEX_BASE_URL = LMU_AI_OPENAI_BASE_URL;
-export const LMU_AI_ANTHROPIC_BASE_URL = LMU_AI_BASE_URL;
-export const LMU_AI_GEMINI_BASE_URL = LMU_AI_BASE_URL;
-
-export const LMU_AI_BASE_URL_OPTIONS = [
- {
- id: 'standard',
- baseUrl: LMU_AI_BASE_URL,
- openaiBaseUrl: LMU_AI_OPENAI_BASE_URL,
- codexBaseUrl: LMU_AI_CODEX_BASE_URL,
- anthropicBaseUrl: LMU_AI_ANTHROPIC_BASE_URL,
- geminiBaseUrl: LMU_AI_GEMINI_BASE_URL,
- },
-] as const;
-
-export const LMU_AI_PROTOCOL_LABELS = ['openai', 'anthropic', 'gemini', 'codexResponses'] as const;
-
-const normalizeText = (value: string | undefined | null): string =>
- String(value ?? '')
- .trim()
- .toLowerCase();
-
-const normalizeBaseUrl = (value: string | undefined | null): string =>
- normalizeText(value).replace(/\/+$/, '');
-
-export const resolveLmuAIBaseUrl = (value: string | undefined | null): string => {
- const normalized = normalizeBaseUrl(value);
- const matched = LMU_AI_BASE_URL_OPTIONS.find(
- (option) =>
- normalized === normalizeBaseUrl(option.baseUrl) ||
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl) ||
- normalized === normalizeBaseUrl(option.anthropicBaseUrl) ||
- normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
- return matched?.baseUrl ?? LMU_AI_BASE_URL;
-};
-
-export const getLmuAIProtocolUrls = (value: string | undefined | null) => {
- const baseUrl = resolveLmuAIBaseUrl(value);
- const matched =
- LMU_AI_BASE_URL_OPTIONS.find(
- (option) => normalizeBaseUrl(option.baseUrl) === normalizeBaseUrl(baseUrl)
- ) ?? LMU_AI_BASE_URL_OPTIONS[0];
- return {
- anthropic: matched.anthropicBaseUrl,
- openai: matched.openaiBaseUrl,
- codex: matched.codexBaseUrl,
- gemini: matched.geminiBaseUrl,
- };
-};
-
-const matchesLmuAIOpenAIBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return LMU_AI_BASE_URL_OPTIONS.some(
- (option) =>
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl)
- );
-};
-
-const matchesLmuAIAnthropicBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return LMU_AI_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.anthropicBaseUrl)
- );
-};
-
-const matchesLmuAIGeminiBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return LMU_AI_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
-};
-
-export const isLmuAIOpenAIProvider = (config: OpenAIProviderConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesLmuAIOpenAIBaseUrl(config.baseUrl);
-};
-
-export const isLmuAIClaudeProvider = (config: ProviderKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesLmuAIAnthropicBaseUrl(config.baseUrl);
-};
-
-export const isLmuAICodexProvider = (config: ProviderKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesLmuAIOpenAIBaseUrl(config.baseUrl);
-};
-
-export const isLmuAIGeminiProvider = (config: GeminiKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesLmuAIGeminiBaseUrl(config.baseUrl);
-};
-
-export const buildLmuAIRaw = (config: Config | null | undefined): SponsorProviderRaw => ({
- openai: (config?.openaiCompatibility ?? [])
- .map((item, index) => ({ config: item, index: item.sourceIndex ?? index }))
- .filter((item) => isLmuAIOpenAIProvider(item.config)),
- claude: (config?.claudeApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isLmuAIClaudeProvider(item.config)),
- codex: (config?.codexApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isLmuAICodexProvider(item.config)),
- gemini: (config?.geminiApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isLmuAIGeminiProvider(item.config)),
-});
diff --git a/frontend/src/features/providers/qiniuCloud.ts b/frontend/src/features/providers/qiniuCloud.ts
deleted file mode 100644
index 59cebfd..0000000
--- a/frontend/src/features/providers/qiniuCloud.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import type { Config, GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import type { SponsorProviderRaw } from './types';
-
-export const QINIU_CLOUD_PROVIDER_NAME = 'qiniuCloud';
-export const QINIU_CLOUD_DISPLAY_NAME = '七牛云';
-export const QINIU_CLOUD_AFFILIATE_URL = 'https://s.qiniu.com/miI73q';
-export const QINIU_CLOUD_DOMESTIC_BASE_URL = 'https://api.qnaigc.com';
-export const QINIU_CLOUD_OVERSEAS_BASE_URL = 'https://api.modelink.ai';
-
-const openAIBaseUrl = (baseUrl: string): string => `${baseUrl}/v1`;
-
-export const QINIU_CLOUD_BASE_URL_OPTIONS = [
- {
- id: 'domestic',
- descriptionKey: 'domestic',
- baseUrl: QINIU_CLOUD_DOMESTIC_BASE_URL,
- openaiBaseUrl: openAIBaseUrl(QINIU_CLOUD_DOMESTIC_BASE_URL),
- codexBaseUrl: openAIBaseUrl(QINIU_CLOUD_DOMESTIC_BASE_URL),
- anthropicBaseUrl: QINIU_CLOUD_DOMESTIC_BASE_URL,
- geminiBaseUrl: QINIU_CLOUD_DOMESTIC_BASE_URL,
- },
- {
- id: 'overseas',
- descriptionKey: 'overseas',
- baseUrl: QINIU_CLOUD_OVERSEAS_BASE_URL,
- openaiBaseUrl: openAIBaseUrl(QINIU_CLOUD_OVERSEAS_BASE_URL),
- codexBaseUrl: openAIBaseUrl(QINIU_CLOUD_OVERSEAS_BASE_URL),
- anthropicBaseUrl: QINIU_CLOUD_OVERSEAS_BASE_URL,
- geminiBaseUrl: QINIU_CLOUD_OVERSEAS_BASE_URL,
- },
-] as const;
-
-export const QINIU_CLOUD_PROTOCOL_LABELS = [
- 'openai',
- 'anthropic',
- 'gemini',
- 'codexResponses',
-] as const;
-
-const normalizeText = (value: string | undefined | null): string =>
- String(value ?? '')
- .trim()
- .toLowerCase();
-
-const normalizeBaseUrl = (value: string | undefined | null): string =>
- normalizeText(value).replace(/\/+$/, '');
-
-export const resolveQiniuCloudBaseUrl = (value: string | undefined | null): string => {
- const normalized = normalizeBaseUrl(value);
- const matched = QINIU_CLOUD_BASE_URL_OPTIONS.find(
- (option) =>
- normalized === normalizeBaseUrl(option.baseUrl) ||
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl) ||
- normalized === normalizeBaseUrl(option.anthropicBaseUrl) ||
- normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
- return matched?.baseUrl ?? QINIU_CLOUD_DOMESTIC_BASE_URL;
-};
-
-export const getQiniuCloudProtocolUrls = (value: string | undefined | null) => {
- const baseUrl = resolveQiniuCloudBaseUrl(value);
- const matched =
- QINIU_CLOUD_BASE_URL_OPTIONS.find(
- (option) => normalizeBaseUrl(option.baseUrl) === normalizeBaseUrl(baseUrl)
- ) ?? QINIU_CLOUD_BASE_URL_OPTIONS[0];
- return {
- anthropic: matched.anthropicBaseUrl,
- openai: matched.openaiBaseUrl,
- codex: matched.codexBaseUrl,
- gemini: matched.geminiBaseUrl,
- };
-};
-
-const matchesQiniuCloudOpenAIBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return QINIU_CLOUD_BASE_URL_OPTIONS.some(
- (option) =>
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl)
- );
-};
-
-const matchesQiniuCloudAnthropicBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return QINIU_CLOUD_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.anthropicBaseUrl)
- );
-};
-
-const matchesQiniuCloudGeminiBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return QINIU_CLOUD_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.geminiBaseUrl)
- );
-};
-
-export const isQiniuCloudOpenAIProvider = (
- config: OpenAIProviderConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesQiniuCloudOpenAIBaseUrl(config.baseUrl);
-};
-
-export const isQiniuCloudClaudeProvider = (
- config: ProviderKeyConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesQiniuCloudAnthropicBaseUrl(config.baseUrl);
-};
-
-export const isQiniuCloudCodexProvider = (
- config: ProviderKeyConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesQiniuCloudOpenAIBaseUrl(config.baseUrl);
-};
-
-export const isQiniuCloudGeminiProvider = (
- config: GeminiKeyConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesQiniuCloudGeminiBaseUrl(config.baseUrl);
-};
-
-export const buildQiniuCloudRaw = (config: Config | null | undefined): SponsorProviderRaw => ({
- openai: (config?.openaiCompatibility ?? [])
- .map((item, index) => ({ config: item, index: item.sourceIndex ?? index }))
- .filter((item) => isQiniuCloudOpenAIProvider(item.config)),
- claude: (config?.claudeApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isQiniuCloudClaudeProvider(item.config)),
- codex: (config?.codexApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isQiniuCloudCodexProvider(item.config)),
- gemini: (config?.geminiApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isQiniuCloudGeminiProvider(item.config)),
-});
diff --git a/frontend/src/features/providers/sheets/ProviderSheet.tsx b/frontend/src/features/providers/sheets/ProviderSheet.tsx
deleted file mode 100644
index 92229d5..0000000
--- a/frontend/src/features/providers/sheets/ProviderSheet.tsx
+++ /dev/null
@@ -1,270 +0,0 @@
-import { useCallback, useEffect, useId, useImperativeHandle, useState, type Ref } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Sheet } from '@/components/ui/Sheet';
-import { IconLoader2, IconPencil } from '@/components/ui/icons';
-import type { ProviderRecentUsageMap } from '@/components/providers/utils';
-import { useNotificationStore } from '@/stores';
-import { PROVIDER_DESCRIPTORS } from '../descriptors';
-import { isMultiProtocolSponsorBrand } from '../sponsorDefinitions';
-import type { ProviderBrand, ProviderEntryFormInput, ProviderResource } from '../types';
-import type { UseProviderWorkbenchResult } from '../useProviderWorkbench';
-import { BaseProviderForm } from './forms/BaseProviderForm';
-import { ResourceDetailView } from './ResourceDetailView';
-import { SponsorProviderForm } from './forms/SponsorProviderForm';
-import styles from './forms/sharedForm.module.scss';
-
-type SheetMode = 'detail' | 'create' | 'edit';
-
-export interface ProviderSheetState {
- open: boolean;
- brand: ProviderBrand;
- mode: SheetMode;
- resource: ProviderResource | null;
-}
-
-export interface ProviderSheetHandle {
- confirmDiscardIfDirty: () => Promise;
-}
-
-interface ProviderSheetProps {
- state: ProviderSheetState;
- onClose: () => void;
- onSwitchToEdit: () => void;
- workbench: UseProviderWorkbenchResult;
- onCreated: () => void;
- onUpdated: () => void;
- mutationDisabled?: boolean;
- usageByProvider?: ProviderRecentUsageMap;
- ref?: Ref;
-}
-
-export function ProviderSheet({
- state,
- onClose,
- onSwitchToEdit,
- workbench,
- onCreated,
- onUpdated,
- mutationDisabled = false,
- usageByProvider,
- ref,
-}: ProviderSheetProps) {
- const { t } = useTranslation();
- const { showConfirmation } = useNotificationStore();
- const formId = useId();
- const [submitting, setSubmitting] = useState(false);
- const [isDirty, setIsDirty] = useState(false);
-
- // Reset dirty flag whenever the sheet is closed or the editing target
- // (brand / resource / mode) changes — the child form will re-mount and
- // re-report its own dirty state.
- useEffect(() => {
- setIsDirty(false);
- }, [state.brand, state.mode, state.resource?.id, state.open]);
-
- const handleDirtyChange = useCallback((dirty: boolean) => {
- setIsDirty(dirty);
- }, []);
-
- const descriptor = PROVIDER_DESCRIPTORS[state.brand];
- const isEditingForm = state.mode === 'create' || state.mode === 'edit';
- const formMutating = submitting || mutationDisabled;
- const submitDisabled = formMutating || (state.mode === 'edit' && !isDirty);
-
- const confirmDiscardIfDirty = useCallback((): Promise => {
- if (!isEditingForm || !isDirty || submitting) {
- return Promise.resolve(true);
- }
- return new Promise((resolve) => {
- showConfirmation({
- title: t('providersPage.unsavedChanges.title'),
- message: t('providersPage.unsavedChanges.message'),
- variant: 'danger',
- confirmText: t('providersPage.unsavedChanges.discard'),
- cancelText: t('providersPage.unsavedChanges.keepEditing'),
- onConfirm: () => resolve(true),
- onCancel: () => resolve(false),
- });
- });
- }, [isDirty, isEditingForm, showConfirmation, submitting, t]);
-
- useImperativeHandle(ref, () => ({ confirmDiscardIfDirty }), [confirmDiscardIfDirty]);
-
- const handleCancelClick = useCallback(() => {
- void confirmDiscardIfDirty().then((ok) => {
- if (ok) onClose();
- });
- }, [confirmDiscardIfDirty, onClose]);
-
- const titleText =
- state.mode === 'create'
- ? `${t('providersPage.form.createEyebrow')} · ${t(
- `providersPage.providerNames.${state.brand}`
- )}`
- : state.mode === 'edit'
- ? `${t('providersPage.form.editEyebrow')} · ${t(
- `providersPage.providerNames.${state.brand}`
- )}`
- : `${t('providersPage.detail.title')} · ${t(`providersPage.providerNames.${state.brand}`)}`;
-
- const handleCreate = useCallback(
- async (input: ProviderEntryFormInput) => {
- if (mutationDisabled) return;
- setSubmitting(true);
- try {
- await workbench.createProvider(state.brand, input);
- onCreated();
- } finally {
- setSubmitting(false);
- }
- },
- [mutationDisabled, onCreated, state.brand, workbench]
- );
-
- const handleUpdate = useCallback(
- async (input: ProviderEntryFormInput) => {
- if (!state.resource || mutationDisabled || !isDirty) return;
- setSubmitting(true);
- try {
- await workbench.updateProvider(state.resource, input);
- onUpdated();
- } finally {
- setSubmitting(false);
- }
- },
- [isDirty, mutationDisabled, onUpdated, state.resource, workbench]
- );
-
- const renderBody = () => {
- if (state.mode === 'detail') {
- if (!state.resource) {
- return null;
- }
- return ;
- }
- const formKey = `${state.brand}:${state.resource?.id ?? 'new'}:${state.mode}`;
- if (isMultiProtocolSponsorBrand(state.brand)) {
- return (
-
- );
- }
- return (
-
- );
- };
-
- const footer =
- state.mode === 'detail' ? (
- state.resource ? (
- <>
-
-
- >
- ) : (
-
- )
- ) : (
- <>
-
-
- >
- );
-
- return (
-
- {renderBody()}
-
- );
-}
diff --git a/frontend/src/features/providers/sheets/ResourceDetailView.tsx b/frontend/src/features/providers/sheets/ResourceDetailView.tsx
deleted file mode 100644
index bc94e57..0000000
--- a/frontend/src/features/providers/sheets/ResourceDetailView.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { Collapsible } from '@/components/ui/Collapsible';
-import { IconCheck, IconX } from '@/components/ui/icons';
-import { getProviderTotalStats, type ProviderRecentUsageMap } from '@/components/providers/utils';
-import type { OpenAIProviderConfig } from '@/types';
-import { maskApiKey } from '@/utils/format';
-import {
- getSponsorProviderDefinition,
- isMultiProtocolSponsorBrand,
- sponsorProtocolI18nKey,
- sponsorProtocolUrl,
-} from '../sponsorDefinitions';
-import type { ProviderResource, SponsorProviderRaw } from '../types';
-import styles from './forms/sharedForm.module.scss';
-
-interface ResourceDetailViewProps {
- resource: ProviderResource;
- usageByProvider?: ProviderRecentUsageMap;
-}
-
-const sponsorProtocolEntryKey = (protocol: string): string => {
- if (protocol === 'claude') return 'anthropicEntries';
- if (protocol === 'codex') return 'codexEntries';
- return `${protocol}Entries`;
-};
-
-export function ResourceDetailView({ resource, usageByProvider }: ResourceDetailViewProps) {
- const { t } = useTranslation();
-
- if (isMultiProtocolSponsorBrand(resource.brand)) {
- const definition = getSponsorProviderDefinition(resource.brand);
- const raw = resource.raw as SponsorProviderRaw;
- const openaiKeyCount = raw.openai.reduce(
- (count, item) => count + (item.config.apiKeyEntries?.length ?? 0),
- 0
- );
- const codexKeyCount = raw.codex.length;
- const geminiKeyCount = raw.gemini.length;
- const firstKey =
- raw.openai
- .flatMap((item) => item.config.apiKeyEntries ?? [])
- .find((entry) => entry.apiKey?.trim())?.apiKey ??
- raw.codex.find((item) => item.config.apiKey?.trim())?.config.apiKey ??
- raw.claude.find((item) => item.config.apiKey?.trim())?.config.apiKey ??
- raw.gemini.find((item) => item.config.apiKey?.trim())?.config.apiKey;
- const baseUrl = definition.resolveBaseUrl(
- raw.openai[0]?.config.baseUrl ??
- raw.codex[0]?.config.baseUrl ??
- raw.claude[0]?.config.baseUrl ??
- raw.gemini[0]?.config.baseUrl
- );
- const protocolUrls = definition.getProtocolUrls(baseUrl);
- const protocolCounts: Record = {
- openai: openaiKeyCount,
- codex: codexKeyCount,
- claude: raw.claude.length,
- gemini: geminiKeyCount,
- };
-
- return (
-
-
-
{resource.name ?? resource.identifier}
-
- {t('providersPage.sponsor.detailHint', { provider: definition.displayName })}
-
-
-
-
- {definition.protocols.map((protocol) => (
-
-
- {t(`providersPage.sponsor.protocols.${sponsorProtocolI18nKey(protocol)}`)}
-
-
- {sponsorProtocolUrl(protocolUrls, protocol)}
-
-
- ))}
-
-
-
-
-
- {t('providersPage.detail.fields.identifier')}
- - {firstKey ? maskApiKey(firstKey) : resource.identifier}
-
-
-
- {t('providersPage.detail.fields.prefix')}
- - {resource.prefix ?? t('providersPage.status.none')}
-
- {definition.protocols.map((protocol) => (
-
-
-
- {t(`providersPage.sponsor.${sponsorProtocolEntryKey(protocol)}`)}
-
- - {protocolCounts[protocol]}
-
- ))}
-
-
- );
- }
-
- const primary: Array<[string, string]> = [
- ['identifier', resource.identifier],
- ['baseUrl', resource.baseUrl ?? t('providersPage.status.notSet')],
- ['proxyUrl', resource.proxyUrl ?? t('providersPage.status.notSet')],
- ['prefix', resource.prefix ?? t('providersPage.status.none')],
- ['models', String(resource.modelCount)],
- ['headers', String(resource.headerCount)],
- ];
-
- const metadata: Array<[string, string]> = [
- ['authIndex', resource.authIndex ?? t('providersPage.status.notSet')],
- ['excludedModels', String(resource.excludedModelCount)],
- ['apiKeyEntries', String(resource.apiKeyEntryCount)],
- ];
-
- const openaiConfig =
- resource.brand === 'openaiCompatibility' ? (resource.raw as OpenAIProviderConfig) : null;
- const apiKeyEntries = openaiConfig?.apiKeyEntries ?? [];
-
- return (
-
-
-
{resource.name ?? resource.identifier}
-
-
-
- {primary.map(([key, value]) => (
-
-
- {t(`providersPage.detail.fields.${key}`)}
- - {value}
-
- ))}
-
-
- {openaiConfig && apiKeyEntries.length > 0 ? (
-
-
- {t('providersPage.form.apiKeyEntriesSection')}: {apiKeyEntries.length}
-
-
- {apiKeyEntries.map((entry, entryIndex) => {
- const entryStats = usageByProvider
- ? getProviderTotalStats(
- usageByProvider,
- openaiConfig.name,
- entry.apiKey,
- openaiConfig.baseUrl
- )
- : { success: 0, failure: 0 };
- return (
-
-
{entryIndex + 1}
-
{maskApiKey(entry.apiKey)}
- {entry.proxyUrl ? (
-
{entry.proxyUrl}
- ) : null}
-
-
- {entryStats.success}
-
-
- {entryStats.failure}
-
-
-
- );
- })}
-
-
- ) : null}
-
-
-
-
- {metadata.map(([key, value]) => (
-
-
- {t(`providersPage.detail.fields.${key}`)}
- - {value}
-
- ))}
-
-
-
-
- );
-}
diff --git a/frontend/src/features/providers/sheets/forms/ApiKeyEntriesEditor.tsx b/frontend/src/features/providers/sheets/forms/ApiKeyEntriesEditor.tsx
deleted file mode 100644
index d79d6de..0000000
--- a/frontend/src/features/providers/sheets/forms/ApiKeyEntriesEditor.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- IconChevronDown,
- IconEye,
- IconEyeOff,
- IconLoader2,
- IconPlus,
- IconX,
-} from '@/components/ui/icons';
-import { maskApiKey } from '@/utils/format';
-import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
-import type { ApiKeyEntryInput } from '../../types';
-import type { ConnectivityState, ConnectivityStatus } from './useConnectivityTest';
-import { ConnectivityStatusIcon } from './ConnectivityStatusIcon';
-import styles from './sharedForm.module.scss';
-
-const COLLAPSED_LIMIT = 10;
-
-const idleStatus: ConnectivityStatus = { state: 'idle' as ConnectivityState, message: '' };
-
-const isBlankEntry = (entry: ApiKeyEntryInput): boolean =>
- !entry.apiKey.trim() && !entry.existingApiKey?.trim();
-
-interface ApiKeyEntriesEditorProps {
- entries: ApiKeyEntryInput[];
- removeDisabled: boolean;
- mutating: boolean;
- statuses: ConnectivityStatus[];
- isTestingAny: boolean;
- onUpdate: (idx: number, patch: Partial) => void;
- /** Appends a new blank entry and returns its index. */
- onAdd: () => number;
- onRemove: (idx: number) => void;
- onTest: (idx: number) => void;
- onTestAll: () => void;
-}
-
-export function ApiKeyEntriesEditor({
- entries,
- removeDisabled,
- mutating,
- statuses,
- isTestingAny,
- onUpdate,
- onAdd,
- onRemove,
- onTest,
- onTestAll,
-}: ApiKeyEntriesEditorProps) {
- const { t } = useTranslation();
- const [expandedIdx, setExpandedIdx] = useState(() =>
- entries.length === 1 && isBlankEntry(entries[0]) ? 0 : null
- );
- const [showPasswords, setShowPasswords] = useState>(new Set());
- const [showAll, setShowAll] = useState(false);
-
- const togglePasswordVisibility = (idx: number) => {
- setShowPasswords((prev) => {
- const next = new Set(prev);
- if (next.has(idx)) {
- next.delete(idx);
- } else {
- next.add(idx);
- }
- return next;
- });
- };
-
- const handleAdd = () => {
- const idx = onAdd();
- setExpandedIdx(idx);
- };
-
- const handleRemove = (removeIdx: number) => {
- setShowPasswords((prev) => {
- if (!prev.size) return prev;
- const next = new Set();
- prev.forEach((idx) => {
- if (idx < removeIdx) {
- next.add(idx);
- } else if (idx > removeIdx) {
- next.add(idx - 1);
- }
- });
- return next;
- });
- setExpandedIdx((prev) => {
- if (prev === null || prev === removeIdx) return null;
- return prev > removeIdx ? prev - 1 : prev;
- });
- onRemove(removeIdx);
- };
-
- // Newest entries first, matching the append-on-add order.
- const reversed = entries.map((entry, idx) => ({ entry, idx })).reverse();
- const visible = showAll ? reversed : reversed.slice(0, COLLAPSED_LIMIT);
-
- return (
-
-
-
-
-
- {visible.map(({ entry, idx }) => {
- const status = statuses[idx] ?? idleStatus;
- const expanded = expandedIdx === idx;
- const summaryKey = entry.apiKey.trim() || entry.existingApiKey?.trim() || '';
- return (
-
-
-
-
-
-
-
-
-
-
- {status.state === 'error' ? (
-
{status.message}
- ) : null}
- {expanded ? (
-
-
-
-
- onUpdate(idx, { apiKey: e.target.value })}
- autoComplete="new-password"
- data-1p-ignore="true"
- data-lpignore="true"
- data-bwignore="true"
- disabled={mutating}
- placeholder={
- entry.existingApiKey
- ? t('providersPage.form.apiKeyEditPlaceholder')
- : t('providersPage.form.apiKeyCreatePlaceholder')
- }
- />
-
-
-
-
-
- onUpdate(idx, { proxyUrl: e.target.value })}
- disabled={mutating}
- placeholder="http://127.0.0.1:7890"
- />
-
-
-
-
- onUpdate(idx, {
- weight: e.target.value === '' ? undefined : Number(e.target.value),
- })
- }
- disabled={mutating}
- placeholder="1"
- />
- {t('providersPage.form.weightHint')}
-
-
- ) : null}
-
- );
- })}
- {entries.length > COLLAPSED_LIMIT ? (
-
- ) : null}
-
- );
-}
diff --git a/frontend/src/features/providers/sheets/forms/BaseProviderForm.tsx b/frontend/src/features/providers/sheets/forms/BaseProviderForm.tsx
deleted file mode 100644
index 1b23465..0000000
--- a/frontend/src/features/providers/sheets/forms/BaseProviderForm.tsx
+++ /dev/null
@@ -1,1008 +0,0 @@
-import { useEffect, useId, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- IconDownload,
- IconEye,
- IconEyeOff,
- IconLoader2,
- IconPlus,
- IconX,
-} from '@/components/ui/icons';
-import { Collapsible } from '@/components/ui/Collapsible';
-import { Select } from '@/components/ui/Select';
-import {
- DISABLE_ALL_RULE,
- ExcludedModelsPicker,
- formatExcludedRulesText,
- parseExcludedRulesText,
- type ExcludedModelsCatalogState,
-} from '@/components/excludedModels';
-import { hasDisableAllModelsRule } from '@/components/providers/utils';
-import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import type { ModelInfo } from '@/utils/models';
-import { PROVIDER_DESCRIPTORS } from '../../descriptors';
-import { readThinkingLevels } from '../../thinkingLevels';
-import type {
- ApiKeyEntryInput,
- ModelEntryInput,
- ProviderBrand,
- ProviderEntryFormInput,
- ProviderResource,
-} from '../../types';
-import { useConnectivityTest, type ConnectivityErrorMessages } from './useConnectivityTest';
-import { useModelDiscovery } from './useModelDiscovery';
-import { ModelDiscoveryPanel } from './ModelDiscoveryPanel';
-import { ConnectivityStatusIcon } from './ConnectivityStatusIcon';
-import { ApiKeyEntriesEditor } from './ApiKeyEntriesEditor';
-import { ModelEntriesEditor } from './ModelEntriesEditor';
-import styles from './sharedForm.module.scss';
-import { CLAUDE_API_BASE_URL } from '../../claudeApi';
-import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
-
-/** 模块级常量,免得每次渲染都给 picker 一个新数组引用。 */
-const DISABLE_ALL_RULES = [DISABLE_ALL_RULE];
-
-interface BaseProviderFormProps {
- brand: ProviderBrand;
- resource: ProviderResource | null;
- mode: 'create' | 'edit';
- mutating: boolean;
- formId: string;
- onSubmit: (input: ProviderEntryFormInput) => Promise;
- onDirtyChange?: (dirty: boolean) => void;
-}
-
-const emptyHeader = () => ({ key: '', value: '' });
-const emptyModel = (): ModelEntryInput => ({ name: '', alias: '' });
-const emptyApiKeyEntry = (): ApiKeyEntryInput => ({
- apiKey: '',
- proxyUrl: '',
- weight: undefined,
-});
-const XAI_API_BASE_URL = 'https://api.x.ai/v1';
-
-const stripDisableAllRule = (list?: string[]): string[] =>
- (list ?? []).filter((s) => s.trim() !== '*');
-
-const formatJsonObject = (value?: Record): string => {
- if (!value || Object.keys(value).length === 0) return '';
- return JSON.stringify(value, null, 2);
-};
-
-const isClaudeLikeBrand = (brand: ProviderBrand): boolean =>
- brand === 'claude' || brand === 'claudeApi';
-
-function buildInitialForm(
- brand: ProviderBrand,
- resource: ProviderResource | null,
- mode: 'create' | 'edit'
-): ProviderEntryFormInput {
- if (mode === 'create' || !resource) {
- return {
- apiKey: '',
- name: '',
- baseUrl:
- brand === 'claudeApi' ? CLAUDE_API_BASE_URL : brand === 'xai' ? XAI_API_BASE_URL : '',
- proxyUrl: '',
- prefix: '',
- disabled: false,
- disableCooling: false,
- priority: undefined,
- weight: undefined,
- models: [emptyModel()],
- headers: [emptyHeader()],
- excludedModelsText: '',
- websockets: brand === 'codex' || brand === 'xai' ? false : undefined,
- cloak: isClaudeLikeBrand(brand)
- ? { mode: '', strictMode: false, sensitiveWordsText: '', cacheUserId: false }
- : undefined,
- experimentalCchSigning: isClaudeLikeBrand(brand) ? false : undefined,
- testModel:
- brand === 'openaiCompatibility' ||
- brand === 'codex' ||
- brand === 'xai' ||
- isClaudeLikeBrand(brand) ||
- brand === 'gemini' ||
- brand === 'interactions'
- ? ''
- : undefined,
- apiKeyEntries: brand === 'openaiCompatibility' ? [emptyApiKeyEntry()] : undefined,
- };
- }
-
- const raw = resource.raw;
- if (brand === 'openaiCompatibility') {
- const cfg = raw as OpenAIProviderConfig;
- return {
- apiKey: '',
- name: cfg.name ?? '',
- baseUrl: cfg.baseUrl ?? '',
- proxyUrl: '',
- prefix: cfg.prefix ?? '',
- disabled: cfg.disabled === true,
- disableCooling: cfg.disableCooling === true,
- priority: cfg.priority,
- models: cfg.models?.length
- ? cfg.models.map((m) => ({
- name: m.name,
- alias: m.alias ?? '',
- priority: m.priority,
- testModel: m.testModel,
- image: m.image === true,
- thinkingJson: formatJsonObject(m.thinking),
- thinkingLevels: readThinkingLevels(m.thinking),
- }))
- : [emptyModel()],
- headers: cfg.headers
- ? Object.entries(cfg.headers).map(([k, v]) => ({ key: k, value: String(v) }))
- : [emptyHeader()],
- excludedModelsText: '',
- testModel: cfg.testModel ?? '',
- apiKeyEntries: cfg.apiKeyEntries?.length
- ? cfg.apiKeyEntries.map((entry) => ({
- apiKey: '',
- existingApiKey: entry.apiKey,
- proxyUrl: entry.proxyUrl ?? '',
- weight: entry.weight,
- authIndex: entry.authIndex,
- }))
- : [emptyApiKeyEntry()],
- };
- }
-
- const cfg = raw as GeminiKeyConfig & ProviderKeyConfig;
- const disabled = hasDisableAllModelsRule(cfg.excludedModels);
- const excludedList = stripDisableAllRule(cfg.excludedModels);
- return {
- // Keep the API key blank in edit mode. Pre-filling the real key makes this
- // password field a browser-autofill target (the saved management key can
- // overwrite it) and defeats the "leave empty = keep unchanged" contract; an
- // empty field is preserved on save via buildProviderKeyConfig's existing fallback.
- apiKey: '',
- name: '',
- baseUrl: cfg.baseUrl ?? '',
- proxyUrl: cfg.proxyUrl ?? '',
- prefix: cfg.prefix ?? '',
- disabled,
- disableCooling: cfg.disableCooling === true,
- priority: cfg.priority,
- weight: cfg.weight,
- models: cfg.models?.length
- ? cfg.models.map((m) => ({
- name: m.name,
- alias: m.alias ?? '',
- priority: m.priority,
- testModel: m.testModel,
- thinkingJson: formatJsonObject(m.thinking),
- thinkingLevels: readThinkingLevels(m.thinking),
- }))
- : [emptyModel()],
- headers: cfg.headers
- ? Object.entries(cfg.headers).map(([k, v]) => ({ key: k, value: String(v) }))
- : [emptyHeader()],
- excludedModelsText: excludedList.join('\n'),
- websockets:
- brand === 'codex' || brand === 'xai'
- ? (cfg as ProviderKeyConfig).websockets === true
- : undefined,
- cloak: isClaudeLikeBrand(brand)
- ? {
- mode: (cfg as ProviderKeyConfig).cloak?.mode ?? '',
- strictMode: (cfg as ProviderKeyConfig).cloak?.strictMode === true,
- sensitiveWordsText: (cfg as ProviderKeyConfig).cloak?.sensitiveWords?.join('\n') ?? '',
- cacheUserId: (cfg as ProviderKeyConfig).cloak?.cacheUserId === true,
- }
- : undefined,
- experimentalCchSigning: isClaudeLikeBrand(brand)
- ? (cfg as ProviderKeyConfig).experimentalCchSigning === true
- : undefined,
- testModel:
- brand === 'codex' ||
- brand === 'xai' ||
- isClaudeLikeBrand(brand) ||
- brand === 'gemini' ||
- brand === 'interactions'
- ? ''
- : undefined,
- };
-}
-
-export function BaseProviderForm({
- brand,
- resource,
- mode,
- mutating,
- formId,
- onSubmit,
- onDirtyChange,
-}: BaseProviderFormProps) {
- const { t } = useTranslation();
- const descriptor = PROVIDER_DESCRIPTORS[brand];
- const fid = useId();
- const [form, setForm] = useState(() =>
- buildInitialForm(brand, resource, mode)
- );
- const [initialFormSignature] = useState(() =>
- JSON.stringify(buildInitialForm(brand, resource, mode))
- );
- const [error, setError] = useState(null);
- const [showSingleApiKey, setShowSingleApiKey] = useState(false);
-
- const isDirty = useMemo(
- () => JSON.stringify(form) !== initialFormSignature,
- [form, initialFormSignature]
- );
-
- useEffect(() => {
- onDirtyChange?.(isDirty);
- }, [isDirty, onDirtyChange]);
-
- const fallbackApiKey = useMemo(() => {
- if (mode !== 'edit' || !resource) return '';
- if (brand === 'openaiCompatibility') return '';
- return (resource.raw as { apiKey?: string } | undefined)?.apiKey ?? '';
- }, [brand, mode, resource]);
-
- const fallbackAuthIndex = useMemo(() => {
- if (mode !== 'edit' || !resource) return '';
- return (resource.raw as { authIndex?: string } | undefined)?.authIndex ?? '';
- }, [mode, resource]);
-
- const connectivityMessages = useMemo(
- () => ({
- baseUrlRequired: t('providersPage.connectivity.baseUrlRequired'),
- endpointInvalid: t('providersPage.connectivity.endpointInvalid'),
- apiKeyRequired: t('providersPage.connectivity.apiKeyRequired'),
- modelRequired: t('providersPage.connectivity.modelRequired'),
- timeout: (seconds: number) => t('providersPage.connectivity.timeout', { seconds }),
- requestFailed: t('providersPage.connectivity.requestFailed'),
- }),
- [t]
- );
-
- const connectivity = useConnectivityTest(
- {
- brand,
- baseUrl: form.baseUrl,
- testModel: form.testModel,
- models: form.models,
- formHeaders: form.headers,
- apiKeyEntries: form.apiKeyEntries,
- apiKey: form.apiKey,
- fallbackApiKey,
- authIndex: fallbackAuthIndex,
- },
- connectivityMessages
- );
-
- const discovery = useModelDiscovery({
- brand,
- baseUrl: form.baseUrl,
- formHeaders: form.headers,
- apiKeyEntries: form.apiKeyEntries,
- apiKey: form.apiKey,
- fallbackApiKey,
- authIndex: fallbackAuthIndex,
- });
- const [discoveryOpen, setDiscoveryOpen] = useState(false);
-
- const existingModelNames = useMemo(() => {
- const set = new Set();
- form.models.forEach((m) => {
- const name = (m.name ?? '').trim();
- if (name) set.add(name);
- });
- return set;
- }, [form.models]);
-
- const testModelOptions = useMemo(() => {
- const seen = new Set();
- const names: string[] = [];
- form.models.forEach((m) => {
- const name = (m.name ?? '').trim();
- if (!name || seen.has(name)) return;
- seen.add(name);
- names.push(name);
- });
- const firstName = names[0];
- const autoLabel = firstName
- ? t('providersPage.form.testModelAutoWith', { name: firstName })
- : t('providersPage.form.testModelAutoEmpty');
- const opts: Array<{ value: string; label: string }> = [{ value: '', label: autoLabel }];
- names.forEach((n) => opts.push({ value: n, label: n }));
- const tm = (form.testModel ?? '').trim();
- if (tm && !seen.has(tm)) {
- opts.push({
- value: tm,
- label: t('providersPage.form.testModelCustom', { name: tm }),
- });
- }
- return opts;
- }, [form.models, form.testModel, t]);
-
- const openDiscovery = () => {
- setDiscoveryOpen(true);
- if (!discovery.loading && !discovery.hasFetched) {
- void discovery.fetch();
- }
- };
-
- const closeDiscovery = () => {
- setDiscoveryOpen(false);
- };
-
- const applyDiscoveredModels = (incoming: ModelInfo[]) => {
- if (!incoming.length) return;
- setForm((prev) => {
- const seen = new Set();
- const next: ModelEntryInput[] = [];
- prev.models.forEach((entry) => {
- const trimmed = (entry.name ?? '').trim();
- if (trimmed) {
- if (seen.has(trimmed)) return;
- seen.add(trimmed);
- }
- next.push(entry);
- });
- // If the existing list is just an empty placeholder row, drop it.
- const placeholderIdx = next.findIndex(
- (it) => !(it.name ?? '').trim() && !(it.alias ?? '').trim()
- );
- if (placeholderIdx !== -1) {
- next.splice(placeholderIdx, 1);
- }
- incoming.forEach((info) => {
- const trimmed = info.name.trim();
- if (!trimmed || seen.has(trimmed)) return;
- seen.add(trimmed);
- next.push({
- name: trimmed,
- alias: (info.alias ?? '').trim(),
- });
- });
- return { ...prev, models: next };
- });
- };
-
- const updateField = (
- key: K,
- value: ProviderEntryFormInput[K]
- ) => {
- setForm((prev) => ({ ...prev, [key]: value }));
- };
-
- const updateCloak = >(
- key: K,
- value: NonNullable[K]
- ) => {
- setForm((prev) => ({
- ...prev,
- cloak: {
- ...(prev.cloak ?? {
- mode: '',
- strictMode: false,
- sensitiveWordsText: '',
- cacheUserId: false,
- }),
- [key]: value,
- },
- }));
- };
-
- const validate = (): string | null => {
- if (descriptor.supportsName && !form.name.trim()) {
- return t('providersPage.form.validation.nameRequired');
- }
- if (descriptor.supportsApiKey && mode === 'create' && !form.apiKey.trim()) {
- return t('providersPage.form.validation.apiKeyRequired');
- }
- if (descriptor.baseUrlRequired && !form.baseUrl.trim()) {
- return t('providersPage.form.validation.baseUrlRequired');
- }
- const weights = [
- ...(brand === 'openaiCompatibility'
- ? (form.apiKeyEntries ?? []).map((entry) => entry.weight)
- : []),
- ...(brand !== 'openaiCompatibility' ? [form.weight] : []),
- ];
- if (weights.some((weight) => weight !== undefined && !Number.isSafeInteger(weight))) {
- return t('providersPage.form.validation.weightInteger');
- }
- if (weights.some((weight) => weight !== undefined && weight > MAX_CREDENTIAL_WEIGHT)) {
- return t('providersPage.form.validation.weightMax', { max: MAX_CREDENTIAL_WEIGHT });
- }
- return null;
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- const v = validate();
- if (v) {
- setError(v);
- return;
- }
- try {
- setError(null);
- await onSubmit(form);
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err));
- }
- };
-
- /* ------------------ entries helpers ------------------ */
-
- const headersList = useMemo(
- () => (form.headers.length ? form.headers : [emptyHeader()]),
- [form.headers]
- );
- const modelsList = useMemo(
- () => (form.models.length ? form.models : [emptyModel()]),
- [form.models]
- );
- const apiKeyEntries = useMemo(
- () =>
- form.apiKeyEntries && form.apiKeyEntries.length ? form.apiKeyEntries : [emptyApiKeyEntry()],
- [form.apiKeyEntries]
- );
-
- const excludedRules = useMemo(
- () => parseExcludedRulesText(form.excludedModelsText),
- [form.excludedModelsText]
- );
- /**
- * 候选目录 = discovery 发现的模型 ∪ 表单里已配置的模型名。
- *
- * 两者都可能为空——`vertex` 支持排除模型却不在 MODEL_DISCOVERY_BRANDS 里,永远没有
- * discovery;其余 brand 在用户手动跑一次发现之前也没有。因此**无目录是常态**,
- * picker 必须能在没有目录时退化成纯规则编辑器。
- */
- const excludedCandidates = useMemo(() => {
- const byKey = new Map();
- discovery.models.forEach((model) => {
- const id = model.name?.trim();
- if (id) byKey.set(id.toLowerCase(), { id, displayName: model.alias || undefined });
- });
- form.models.forEach((model) => {
- const id = model.name?.trim();
- if (id && !byKey.has(id.toLowerCase())) byKey.set(id.toLowerCase(), { id });
- });
- return [...byKey.values()].sort((left, right) =>
- left.id.localeCompare(right.id, undefined, { sensitivity: 'base' })
- );
- }, [discovery.models, form.models]);
- const excludedCatalogState: ExcludedModelsCatalogState = discovery.loading
- ? 'loading'
- : discovery.error
- ? 'error'
- : excludedCandidates.length === 0
- ? 'unavailable'
- : 'ready';
- const actualApiKeyEntries = form.apiKeyEntries ?? [];
- const supportsDisableCooling =
- brand === 'gemini' ||
- brand === 'interactions' ||
- brand === 'codex' ||
- brand === 'xai' ||
- isClaudeLikeBrand(brand) ||
- brand === 'openaiCompatibility';
- const supportsModelImage = brand === 'openaiCompatibility';
- const singleConnectivity =
- brand === 'codex' || brand === 'xai'
- ? { status: connectivity.codexStatus, run: connectivity.runCodex }
- : brand === 'gemini' || brand === 'interactions'
- ? { status: connectivity.geminiStatus, run: connectivity.runGemini }
- : isClaudeLikeBrand(brand)
- ? { status: connectivity.claudeStatus, run: connectivity.runClaude }
- : null;
-
- const updateModelEntry = (idx: number, patch: Partial) => {
- updateField(
- 'models',
- modelsList.map((it, i) => (i === idx ? { ...it, ...patch } : it))
- );
- };
-
- const removeModelEntry = (idx: number) => {
- updateField(
- 'models',
- modelsList.filter((_, i) => i !== idx)
- );
- };
-
- return (
-
- );
-}
diff --git a/frontend/src/features/providers/sheets/forms/ConnectivityStatusIcon.tsx b/frontend/src/features/providers/sheets/forms/ConnectivityStatusIcon.tsx
deleted file mode 100644
index 08b5bc1..0000000
--- a/frontend/src/features/providers/sheets/forms/ConnectivityStatusIcon.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { IconAlertTriangle, IconCheckCircle2, IconLoader2 } from '@/components/ui/icons';
-import type { ConnectivityState } from './useConnectivityTest';
-import styles from './sharedForm.module.scss';
-
-export function ConnectivityStatusIcon({ state }: { state: ConnectivityState }) {
- if (state === 'loading') {
- return (
-
-
-
- );
- }
- if (state === 'success') {
- return (
-
-
-
- );
- }
- if (state === 'error') {
- return (
-
-
-
- );
- }
- return null;
-}
diff --git a/frontend/src/features/providers/sheets/forms/ModelDiscoveryPanel.tsx b/frontend/src/features/providers/sheets/forms/ModelDiscoveryPanel.tsx
deleted file mode 100644
index 8c70a1b..0000000
--- a/frontend/src/features/providers/sheets/forms/ModelDiscoveryPanel.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { IconLoader2, IconRefreshCw, IconSearch } from '@/components/ui/icons';
-import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
-import type { ModelInfo } from '@/utils/models';
-import styles from './sharedForm.module.scss';
-
-interface ModelDiscoveryPanelProps {
- loading: boolean;
- error: string | null;
- models: ModelInfo[];
- hasFetched: boolean;
- existingNames: Set;
- mutating?: boolean;
- onApply: (picked: ModelInfo[]) => void;
- onReload: () => void;
- onClose: () => void;
-}
-
-export function ModelDiscoveryPanel({
- loading,
- error,
- models,
- hasFetched,
- existingNames,
- mutating,
- onApply,
- onReload,
- onClose,
-}: ModelDiscoveryPanelProps) {
- const { t } = useTranslation();
- const [search, setSearch] = useState('');
- const [selected, setSelected] = useState>(new Set());
-
- const filtered = useMemo(() => {
- const q = search.trim().toLowerCase();
- if (!q) return models;
- return models.filter((m) => `${m.name} ${m.alias ?? ''}`.toLowerCase().includes(q));
- }, [models, search]);
-
- const selectable = useMemo(
- () => filtered.filter((m) => !existingNames.has(m.name)),
- [filtered, existingNames]
- );
-
- const allSelectableChecked =
- selectable.length > 0 && selectable.every((m) => selected.has(m.name));
-
- const toggle = (name: string) => {
- setSelected((prev) => {
- const next = new Set(prev);
- if (next.has(name)) next.delete(name);
- else next.add(name);
- return next;
- });
- };
-
- const toggleAll = () => {
- if (allSelectableChecked) {
- setSelected(new Set());
- } else {
- setSelected(new Set(selectable.map((m) => m.name)));
- }
- };
-
- const handleApply = () => {
- const picked = models.filter((m) => selected.has(m.name) && !existingNames.has(m.name));
- if (!picked.length) return;
- onApply(picked);
- setSelected(new Set());
- };
-
- const renderModelLabel = (model: ModelInfo) => (
-
- {model.name}
- {model.alias ? {model.alias} : null}
-
- );
-
- return (
-
-
-
-
-
-
- setSearch(e.target.value)}
- placeholder={t('providersPage.discovery.searchPlaceholder')}
- />
-
-
-
-
- {loading && !models.length ? (
-
{t('providersPage.discovery.loading')}
- ) : error ? (
-
{error}
- ) : hasFetched && !models.length ? (
-
{t('providersPage.discovery.empty')}
- ) : models.length ? (
- <>
-
-
- {allSelectableChecked
- ? t('providersPage.discovery.clearAll')
- : t('providersPage.discovery.selectAll')}
-
- }
- />
-
- {t('providersPage.discovery.selectedCount', {
- selected: selected.size,
- total: selectable.length,
- })}
-
-
-
- {filtered.map((m) => {
- const existing = existingNames.has(m.name);
- return (
- -
- {existing ? (
- <>
- {renderModelLabel(m)}
-
- {t('providersPage.discovery.alreadyAdded')}
-
- >
- ) : (
- toggle(m.name)}
- label={renderModelLabel(m)}
- />
- )}
-
- );
- })}
-
- >
- ) : (
-
{t('providersPage.discovery.notLoaded')}
- )}
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/providers/sheets/forms/ModelEntriesEditor.tsx b/frontend/src/features/providers/sheets/forms/ModelEntriesEditor.tsx
deleted file mode 100644
index 2527da3..0000000
--- a/frontend/src/features/providers/sheets/forms/ModelEntriesEditor.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { IconChevronDown, IconPlus, IconX } from '@/components/ui/icons';
-import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
-import { THINKING_LEVELS, type ThinkingLevel } from '../../thinkingLevels';
-import type { ModelEntryInput } from '../../types';
-import styles from './sharedForm.module.scss';
-
-const COLLAPSED_LIMIT = 10;
-
-interface ModelEntriesEditorProps {
- models: ModelEntryInput[];
- /** Only OpenAI-compatible entries can expose the image-generation capability. */
- supportsImage: boolean;
- /** Every backend provider model can override its thinking capability. */
- supportsThinking: boolean;
- mutating: boolean;
- removeDisabled: boolean;
- onUpdate: (idx: number, patch: Partial) => void;
- onAdd: () => void;
- onRemove: (idx: number) => void;
-}
-
-export function ModelEntriesEditor({
- models,
- supportsImage,
- supportsThinking,
- mutating,
- removeDisabled,
- onUpdate,
- onAdd,
- onRemove,
-}: ModelEntriesEditorProps) {
- const { t } = useTranslation();
- const [expandedIdx, setExpandedIdx] = useState(null);
- const [showAll, setShowAll] = useState(false);
-
- const handleAdd = () => {
- // New rows are appended; make sure the truncated list doesn't hide them.
- if (!showAll && models.length >= COLLAPSED_LIMIT) {
- setShowAll(true);
- }
- onAdd();
- };
-
- const handleRemove = (removeIdx: number) => {
- setExpandedIdx((prev) => {
- if (prev === null || prev === removeIdx) return null;
- return prev > removeIdx ? prev - 1 : prev;
- });
- onRemove(removeIdx);
- };
-
- const visible = showAll ? models : models.slice(0, COLLAPSED_LIMIT);
-
- return (
- <>
- {visible.map((entry, idx) => {
- const hasExtendedOptions = supportsImage || supportsThinking;
- const expanded = hasExtendedOptions && expandedIdx === idx;
- const thinkingLevels = entry.thinkingLevels ?? [];
- const hasThinking = entry.thinkingLevelsTouched
- ? thinkingLevels.length > 0
- : (entry.thinkingJson ?? '').trim().length > 0;
- const toggleThinkingLevel = (level: ThinkingLevel) => {
- const nextLevels = thinkingLevels.includes(level)
- ? thinkingLevels.filter((item) => item !== level)
- : THINKING_LEVELS.filter((item) => item === level || thinkingLevels.includes(item));
- onUpdate(idx, { thinkingLevels: nextLevels, thinkingLevelsTouched: true });
- };
- return (
-
-
- {expanded ? (
-
- {supportsImage ? (
-
- ) : null}
- {supportsThinking ? (
-
- ) : null}
-
- ) : null}
-
- );
- })}
- {models.length > COLLAPSED_LIMIT ? (
-
- ) : null}
-
- >
- );
-}
diff --git a/frontend/src/features/providers/sheets/forms/SponsorProviderForm.tsx b/frontend/src/features/providers/sheets/forms/SponsorProviderForm.tsx
deleted file mode 100644
index 0893274..0000000
--- a/frontend/src/features/providers/sheets/forms/SponsorProviderForm.tsx
+++ /dev/null
@@ -1,925 +0,0 @@
-import { useEffect, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Collapsible } from '@/components/ui/Collapsible';
-import { Select } from '@/components/ui/Select';
-import {
- IconAlertTriangle,
- IconChevronDown,
- IconCheckCircle2,
- IconDollarSign,
- IconDownload,
- IconEye,
- IconEyeOff,
- IconLoader2,
- IconPlus,
- IconX,
-} from '@/components/ui/icons';
-import { hasDisableAllModelsRule } from '@/components/providers/utils';
-import { maskApiKey } from '@/utils/format';
-import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
-import type { ModelInfo } from '@/utils/models';
-import type { ApiKeyFunUsageSummary } from '../../sponsor';
-import { readThinkingLevels } from '../../thinkingLevels';
-import { isSponsorPartialMutationError } from '../../sponsorMutationRecovery';
-import {
- discoveryBrandForSponsorProtocol,
- getSponsorAggregationConflict,
- getSponsorProviderDefinition,
- sponsorProtocolI18nKey,
- sponsorProtocolModelI18nKey,
- sponsorProtocolUrl,
- type SponsorProviderDefinition,
-} from '../../sponsorDefinitions';
-import type {
- ModelEntryInput,
- ProviderEntryFormInput,
- ProviderResource,
- SponsorKeyEntryInput,
- SponsorProtocol,
- SponsorProviderBrand,
- SponsorProviderRaw,
-} from '../../types';
-import { ModelDiscoveryPanel } from './ModelDiscoveryPanel';
-import { ModelEntriesEditor } from './ModelEntriesEditor';
-import { useModelDiscovery, type UseModelDiscoveryResult } from './useModelDiscovery';
-import { useSponsorUsageCheck, type SponsorUsageMessages } from './useSponsorUsageCheck';
-import styles from './sharedForm.module.scss';
-
-interface SponsorProviderFormProps {
- brand?: SponsorProviderBrand;
- resource: ProviderResource | null;
- mode: 'create' | 'edit';
- mutating: boolean;
- formId: string;
- variant?: 'quickStart';
- onSubmit: (input: ProviderEntryFormInput) => Promise;
- onDirtyChange?: (dirty: boolean) => void;
-}
-
-interface SponsorModelSectionProps {
- label: string;
- description: string;
- protocol: SponsorProtocol;
- models: ModelEntryInput[];
- discovery: UseModelDiscoveryResult;
- mutating: boolean;
- onChange: (next: ModelEntryInput[]) => void;
-}
-
-interface SponsorKeyEntryCardProps {
- entry: SponsorKeyEntryInput;
- index: number;
- formId: string;
- mode: 'create' | 'edit';
- definition: SponsorProviderDefinition;
- usedProtocols: Set;
- canRemove: boolean;
- mutating: boolean;
- onChange: (entry: SponsorKeyEntryInput) => void;
- onRemove: () => void;
-}
-
-const emptyModel = (): ModelEntryInput => ({ name: '', alias: '' });
-
-const emptySponsorKeyEntry = (
- definition: SponsorProviderDefinition,
- protocol: SponsorProtocol = definition.defaultProtocol
-): SponsorKeyEntryInput => ({
- protocol,
- apiKey: '',
- existingApiKey: '',
- baseUrl: definition.baseUrlOptions[0]?.baseUrl ?? '',
- proxyUrl: '',
- prefix: '',
- disabled: false,
- disableCooling: false,
- priority: undefined,
- weight: undefined,
- models: [emptyModel()],
-});
-
-const emptySponsorForm = (definition: SponsorProviderDefinition): ProviderEntryFormInput => ({
- apiKey: '',
- name: '',
- baseUrl: '',
- proxyUrl: '',
- prefix: '',
- disabled: false,
- disableCooling: false,
- priority: undefined,
- weight: undefined,
- models: [],
- headers: [],
- excludedModelsText: '',
- sponsorKeyEntries: [emptySponsorKeyEntry(definition)],
-});
-
-const getSponsorRaw = (
- resource: ProviderResource | null,
- brand: SponsorProviderBrand
-): SponsorProviderRaw | null => {
- if (!resource || resource.brand !== brand) return null;
- return resource.raw as SponsorProviderRaw;
-};
-
-const protocolUrlForEntry = (
- entry: SponsorKeyEntryInput,
- definition: SponsorProviderDefinition
-): string => sponsorProtocolUrl(definition.getProtocolUrls(entry.baseUrl), entry.protocol);
-
-const formatUsageAmount = (value: ApiKeyFunUsageSummary['remaining'], locale: string): string => {
- if (value === null) return '--';
- if (typeof value === 'number') {
- return new Intl.NumberFormat(locale, {
- maximumFractionDigits: 6,
- }).format(value);
- }
- return value;
-};
-
-const isHealthyUsageSummary = (summary: ApiKeyFunUsageSummary): boolean => {
- const normalizedStatus = (summary.status ?? '').trim().toLowerCase();
- return summary.isValid && (!normalizedStatus || normalizedStatus === 'active');
-};
-
-const modelsFromConfig = (
- models:
- | Array<{
- name?: string;
- alias?: string;
- priority?: number;
- testModel?: string;
- image?: boolean;
- thinking?: Record;
- }>
- | undefined
-): ModelEntryInput[] =>
- models?.length
- ? models.map((model) => ({
- name: model.name ?? '',
- alias: model.alias ?? '',
- priority: model.priority,
- testModel: model.testModel,
- image: model.image === true,
- thinkingJson: model.thinking ? JSON.stringify(model.thinking, null, 2) : '',
- thinkingLevels: readThinkingLevels(model.thinking),
- }))
- : [emptyModel()];
-
-const sponsorEntryFromProviderKey = (
- definition: SponsorProviderDefinition,
- protocol: Exclude,
- config:
- | SponsorProviderRaw['codex'][number]['config']
- | SponsorProviderRaw['claude'][number]['config']
- | SponsorProviderRaw['gemini'][number]['config']
-): SponsorKeyEntryInput => ({
- ...emptySponsorKeyEntry(definition, protocol),
- existingApiKey: config.apiKey ?? '',
- baseUrl: definition.resolveBaseUrl(config.baseUrl),
- proxyUrl: config.proxyUrl ?? '',
- prefix: config.prefix ?? '',
- disabled: hasDisableAllModelsRule(config.excludedModels),
- disableCooling: config.disableCooling === true,
- priority: config.priority,
- weight: config.weight,
- models: modelsFromConfig(config.models),
-});
-
-const sponsorEntryFromOpenAI = (
- definition: SponsorProviderDefinition,
- config: SponsorProviderRaw['openai'][number]['config']
-): SponsorKeyEntryInput => {
- const firstEntry = config.apiKeyEntries?.find((entry) => entry.apiKey?.trim());
- return {
- ...emptySponsorKeyEntry(definition, 'openai'),
- existingApiKey: firstEntry?.apiKey ?? '',
- baseUrl: definition.resolveBaseUrl(config.baseUrl),
- proxyUrl: firstEntry?.proxyUrl ?? '',
- prefix: config.prefix ?? '',
- disabled: config.disabled === true,
- disableCooling: config.disableCooling === true,
- priority: config.priority,
- weight: firstEntry?.weight,
- models: modelsFromConfig(config.models),
- };
-};
-
-const sponsorKeyEntriesFromRaw = (
- raw: SponsorProviderRaw | null,
- definition: SponsorProviderDefinition
-): SponsorKeyEntryInput[] => {
- if (!raw) return [emptySponsorKeyEntry(definition)];
- const entries = definition.protocols.flatMap((protocol): SponsorKeyEntryInput[] => {
- if (protocol === 'openai') {
- const openai = raw.openai[0]?.config;
- return openai ? [sponsorEntryFromOpenAI(definition, openai)] : [];
- }
- const config = raw[protocol][0]?.config;
- return config ? [sponsorEntryFromProviderKey(definition, protocol, config)] : [];
- });
- return entries.length ? entries : [emptySponsorKeyEntry(definition)];
-};
-
-const applyDiscoveredModels = (
- currentModels: ModelEntryInput[],
- incoming: ModelInfo[]
-): ModelEntryInput[] => {
- if (!incoming.length) return currentModels;
- const seen = new Set();
- const next: ModelEntryInput[] = [];
- currentModels.forEach((entry) => {
- const trimmed = (entry.name ?? '').trim();
- if (trimmed) {
- if (seen.has(trimmed)) return;
- seen.add(trimmed);
- }
- next.push(entry);
- });
- const placeholderIdx = next.findIndex(
- (entry) => !(entry.name ?? '').trim() && !(entry.alias ?? '').trim()
- );
- if (placeholderIdx !== -1) {
- next.splice(placeholderIdx, 1);
- }
- incoming.forEach((info) => {
- const trimmed = info.name.trim();
- if (!trimmed || seen.has(trimmed)) return;
- seen.add(trimmed);
- next.push({
- name: trimmed,
- alias: (info.alias ?? '').trim(),
- });
- });
- return next.length ? next : [emptyModel()];
-};
-
-function SponsorModelSection({
- label,
- description,
- protocol,
- models,
- discovery,
- mutating,
- onChange,
-}: SponsorModelSectionProps) {
- const { t } = useTranslation();
- const [discoveryOpen, setDiscoveryOpen] = useState(false);
- const modelsList = useMemo(() => (models.length ? models : [emptyModel()]), [models]);
- const existingModelNames = useMemo(() => {
- const set = new Set();
- modelsList.forEach((model) => {
- const name = (model.name ?? '').trim();
- if (name) set.add(name);
- });
- return set;
- }, [modelsList]);
-
- const openDiscovery = () => {
- setDiscoveryOpen(true);
- if (!discovery.loading && !discovery.hasFetched) {
- void discovery.fetch();
- }
- };
-
- return (
-
-
-
{description}
-
-
-
- {discoveryOpen ? (
-
onChange(applyDiscoveredModels(modelsList, picked))}
- onReload={() => void discovery.fetch()}
- onClose={() => setDiscoveryOpen(false)}
- />
- ) : null}
-
- onChange(
- modelsList.map((item, itemIndex) =>
- itemIndex === modelIndex ? { ...item, ...patch } : item
- )
- )
- }
- onAdd={() => onChange([...modelsList, emptyModel()])}
- onRemove={(modelIndex) => {
- const next = modelsList.filter((_, itemIndex) => itemIndex !== modelIndex);
- onChange(next.length ? next : [emptyModel()]);
- }}
- />
-
-
- );
-}
-
-function SponsorKeyEntryCard({
- entry,
- index,
- formId,
- mode,
- definition,
- usedProtocols,
- canRemove,
- mutating,
- onChange,
- onRemove,
-}: SponsorKeyEntryCardProps) {
- const { t, i18n } = useTranslation();
- const [showApiKey, setShowApiKey] = useState(false);
- const [expanded, setExpanded] = useState(
- () => mode === 'create' || !entry.existingApiKey?.trim()
- );
- const endpointUrl = protocolUrlForEntry(entry, definition);
- const protocolLabel = t(
- `providersPage.sponsor.protocols.${sponsorProtocolI18nKey(entry.protocol)}`
- );
- const titleLabel = t('providersPage.sponsor.groupedKey', { index: index + 1 });
- const summaryKey = entry.apiKey.trim() || entry.existingApiKey?.trim() || '';
- const summaryKeyLabel = summaryKey
- ? maskApiKey(summaryKey)
- : t('providersPage.status.notConfigured');
- const modelKey = sponsorProtocolModelI18nKey(entry.protocol);
- const usageMessages = useMemo(
- () => ({
- apiKeyRequired: t('providersPage.sponsor.usageApiKeyRequired'),
- emptyResponse: t('providersPage.sponsor.usageEmpty'),
- requestFailed: t('providersPage.connectivity.requestFailed'),
- }),
- [t]
- );
- const usageCheck = useSponsorUsageCheck(
- {
- baseUrl: entry.baseUrl,
- apiKey: entry.apiKey,
- fallbackApiKey: entry.existingApiKey,
- },
- usageMessages
- );
- const usageSummary = usageCheck.status.summary;
- const usageHealthy = usageSummary ? isHealthyUsageSummary(usageSummary) : true;
- const usageRemaining =
- usageSummary !== null ? formatUsageAmount(usageSummary.remaining, i18n.language) : '';
- const usageUsed =
- usageSummary !== null ? formatUsageAmount(usageSummary.used, i18n.language) : '';
- const usageLimit =
- usageSummary !== null ? formatUsageAmount(usageSummary.limit, i18n.language) : '';
- const discoveryHeaders = useMemo>(() => [], []);
- const openaiDiscoveryEntries = useMemo(
- () => [
- {
- apiKey: entry.apiKey,
- existingApiKey: entry.existingApiKey,
- proxyUrl: entry.proxyUrl,
- },
- ],
- [entry.apiKey, entry.existingApiKey, entry.proxyUrl]
- );
- const discovery = useModelDiscovery({
- brand: discoveryBrandForSponsorProtocol(entry.protocol),
- baseUrl: endpointUrl,
- formHeaders: discoveryHeaders,
- apiKey: entry.apiKey,
- fallbackApiKey: entry.existingApiKey,
- apiKeyEntries: entry.protocol === 'openai' ? openaiDiscoveryEntries : undefined,
- });
- const protocolOptions = definition.protocols
- .filter((protocol) => protocol === entry.protocol || !usedProtocols.has(protocol))
- .map((protocol) => ({
- value: protocol,
- label: t(`providersPage.sponsor.protocols.${sponsorProtocolI18nKey(protocol)}`),
- }));
-
- const updateEntry = (patch: Partial) => {
- onChange({ ...entry, ...patch });
- };
-
- return (
-
-
-
-
-
-
-
-
-
- {expanded ? (
-
-
-
-
-
- {definition.baseUrlOptions.length > 1 ? (
-
-
- {t('providersPage.sponsor.urlMode', { provider: definition.displayName })}
-
-
- {definition.baseUrlOptions.map((option) => {
- const checked = definition.resolveBaseUrl(entry.baseUrl) === option.baseUrl;
- const className = [
- styles.sponsorUrlOption,
- checked ? styles.sponsorUrlOptionActive : '',
- ]
- .filter(Boolean)
- .join(' ');
- return (
-
- );
- })}
-
-
{t('providersPage.sponsor.urlHint')}
-
- ) : null}
-
-
-
- {t('providersPage.sponsor.protocolEndpoint')}
-
- {endpointUrl}
-
-
-
-
-
- updateEntry({ apiKey: event.target.value })}
- autoComplete="new-password"
- data-1p-ignore="true"
- data-lpignore="true"
- data-bwignore="true"
- placeholder={
- mode === 'edit'
- ? t('providersPage.form.apiKeyEditPlaceholder')
- : t('providersPage.form.apiKeyCreatePlaceholder')
- }
- disabled={mutating}
- />
-
-
-
{t('providersPage.sponsor.apiKeyHint')}
-
-
- {definition.supportsUsageCheck ? (
-
-
- {usageCheck.status.state === 'success' && usageSummary ? (
-
-
- {usageHealthy ? (
-
- ) : (
-
- )}
-
- {t('providersPage.sponsor.usageRemaining', {
- amount: usageRemaining,
- unit: usageSummary.unit,
- })}
-
-
- {usageSummary.used !== null || usageSummary.limit !== null ? (
-
- {t('providersPage.sponsor.usageBreakdown', {
- used: usageUsed,
- limit: usageLimit,
- })}
-
- ) : null}
- {!usageHealthy ? (
-
- {t('providersPage.sponsor.usageStatus', {
- status: usageSummary.status || t('providersPage.sponsor.usageInvalid'),
- })}
-
- ) : null}
-
- ) : null}
- {usageCheck.status.state === 'error' ? (
-
{usageCheck.status.message}
- ) : null}
-
- ) : null}
-
-
-
- updateEntry({ proxyUrl: event.target.value })}
- placeholder="http://127.0.0.1:7890"
- disabled={mutating}
- />
-
-
-
-
-
-
-
- updateEntry({
- weight: event.target.value === '' ? undefined : Number(event.target.value),
- })
- }
- disabled={mutating}
- />
- {t('providersPage.form.weightHint')}
-
-
-
-
-
-
-
updateEntry({ models })}
- />
-
- ) : null}
-
- );
-}
-
-const buildInitialForm = (
- definition: SponsorProviderDefinition,
- resource: ProviderResource | null,
- mode: 'create' | 'edit'
-): ProviderEntryFormInput => {
- if (mode === 'create') return emptySponsorForm(definition);
- const raw = getSponsorRaw(resource, definition.brand);
- return {
- ...emptySponsorForm(definition),
- sponsorKeyEntries: sponsorKeyEntriesFromRaw(raw, definition),
- };
-};
-
-export function SponsorProviderForm({
- brand = 'apikeyFun',
- resource,
- mode,
- mutating,
- formId,
- variant,
- onSubmit,
- onDirtyChange,
-}: SponsorProviderFormProps) {
- const { t } = useTranslation();
- const definition = useMemo(() => getSponsorProviderDefinition(brand), [brand]);
- const [form, setForm] = useState(() =>
- buildInitialForm(definition, resource, mode)
- );
- const [initialFormSignature] = useState(() =>
- JSON.stringify(buildInitialForm(definition, resource, mode))
- );
- const [error, setError] = useState(null);
- const entries = useMemo(
- () => form.sponsorKeyEntries ?? [emptySponsorKeyEntry(definition)],
- [definition, form.sponsorKeyEntries]
- );
- const usedProtocols = useMemo(() => new Set(entries.map((entry) => entry.protocol)), [entries]);
- const missingProtocols = useMemo(
- () => definition.protocols.filter((protocol) => !usedProtocols.has(protocol)),
- [definition.protocols, usedProtocols]
- );
-
- const isDirty = useMemo(
- () => JSON.stringify({ ...form, sponsorKeyEntries: entries }) !== initialFormSignature,
- [entries, form, initialFormSignature]
- );
-
- useEffect(() => {
- onDirtyChange?.(isDirty);
- }, [isDirty, onDirtyChange]);
-
- const updateEntries = (nextEntries: SponsorKeyEntryInput[]) => {
- setForm((prev) => ({ ...prev, sponsorKeyEntries: nextEntries }));
- };
-
- const updateEntry = (entryIndex: number, nextEntry: SponsorKeyEntryInput) => {
- updateEntries(entries.map((entry, index) => (index === entryIndex ? nextEntry : entry)));
- };
-
- const removeEntry = (entryIndex: number) => {
- const nextEntries = entries.filter((_, index) => index !== entryIndex);
- updateEntries(
- nextEntries.length || mode === 'edit' ? nextEntries : [emptySponsorKeyEntry(definition)]
- );
- };
-
- const addEntry = () => {
- const protocol = missingProtocols[0];
- if (!protocol) return;
- updateEntries([...entries, emptySponsorKeyEntry(definition, protocol)]);
- };
-
- const validateEntries = (): string | null => {
- if (!entries.length) {
- return mode === 'edit' ? null : t('providersPage.sponsor.validation.keyRequired');
- }
- const missingKey = entries.some(
- (entry) => !entry.apiKey.trim() && !entry.existingApiKey?.trim()
- );
- if (missingKey) return t('providersPage.sponsor.validation.keyRequired');
- const protocolSet = new Set(entries.map((entry) => entry.protocol));
- if (protocolSet.size !== entries.length) {
- return t('providersPage.sponsor.validation.protocolDuplicate');
- }
- if (
- entries.some((entry) => entry.weight !== undefined && !Number.isSafeInteger(entry.weight))
- ) {
- return t('providersPage.form.validation.weightInteger');
- }
- if (
- entries.some((entry) => entry.weight !== undefined && entry.weight > MAX_CREDENTIAL_WEIGHT)
- ) {
- return t('providersPage.form.validation.weightMax', { max: MAX_CREDENTIAL_WEIGHT });
- }
- return null;
- };
-
- const handleSubmit = async (event: React.FormEvent) => {
- event.preventDefault();
- const validationError = validateEntries();
- if (validationError) {
- setError(validationError);
- return;
- }
- try {
- setError(null);
- await onSubmit({ ...form, sponsorKeyEntries: entries });
- } catch (err) {
- setError(
- isSponsorPartialMutationError(err)
- ? t('providersPage.sponsor.partialMutationWarning')
- : err instanceof Error
- ? err.message
- : String(err)
- );
- }
- };
-
- const formClassName = [styles.form, variant === 'quickStart' ? styles.quickStartForm : '']
- .filter(Boolean)
- .join(' ');
- const aggregationConflict =
- mode === 'edit'
- ? getSponsorAggregationConflict(getSponsorRaw(resource, definition.brand))
- : null;
-
- if (aggregationConflict) {
- return (
-
- );
- }
-
- return (
-
- );
-}
diff --git a/frontend/src/features/providers/sheets/forms/sharedForm.module.scss b/frontend/src/features/providers/sheets/forms/sharedForm.module.scss
deleted file mode 100644
index 2ec2be9..0000000
--- a/frontend/src/features/providers/sheets/forms/sharedForm.module.scss
+++ /dev/null
@@ -1,1283 +0,0 @@
-@use '../../../../styles/mixins' as *;
-@use '../../../../styles/variables' as *;
-
-.form {
- display: flex;
- flex-direction: column;
- gap: 16px;
- min-width: 0;
- max-width: 100%;
-}
-
-.quickStartForm {
- gap: 20px;
-
- .section {
- gap: 14px;
- }
-
- .sectionTitle {
- font-size: 17px;
- font-weight: 700;
- line-height: 1.35;
- letter-spacing: 0;
- }
-
- .sectionDesc {
- max-width: 72ch;
- font-size: 14px;
- line-height: 1.6;
- color: var(--text-secondary);
- }
-
- .field {
- gap: 7px;
- }
-
- .label {
- font-size: 13px;
- font-weight: 600;
- line-height: 1.45;
- }
-
- .labelHint {
- font-size: 12px;
- line-height: 1.6;
- color: var(--muted-foreground);
- }
-
- .input,
- .textarea,
- .discoverySearch {
- font-size: 14px;
- line-height: 1.45;
- }
-
- .entryCard {
- gap: 12px;
- }
-
- .entryCardHeader {
- font-size: 13px;
- line-height: 1.45;
- }
-
- .sponsorGroupTitle {
- gap: 3px;
-
- strong {
- font-size: 14px;
- font-weight: 650;
- line-height: 1.4;
- }
- }
-
- .sponsorUrlOptionText,
- .checkboxText {
- font-size: 14px;
- line-height: 1.45;
- }
-
- .sponsorUrlOptionText small,
- .checkboxText small,
- .sponsorProtocolUrl,
- .sponsorUsageResult,
- .connectivityError,
- .discoveryEmpty,
- .discoveryItem,
- .discoveryBatchLabel,
- .discoveryName {
- font-size: 12px;
- line-height: 1.6;
- }
-
- .sponsorProtocolName {
- font-size: 13px;
- font-weight: 650;
- line-height: 1.4;
- }
-
- .sponsorUsageMain {
- line-height: 1.45;
- }
-
- .sponsorUsageMeta,
- .discoveryAlias,
- .discoveryCount,
- .discoveryAddedTag {
- font-size: 12px;
- line-height: 1.55;
- }
-
- .connectivityBtn,
- .discoveryApplyBtn,
- .addBtn,
- .removeBtn {
- font-size: 13px;
- font-weight: 600;
- line-height: 1;
- }
-
- .footerBtn {
- font-size: 14px;
- font-weight: 600;
- line-height: 1;
- }
-
- details summary {
- font-size: 14px;
- font-weight: 650;
- line-height: 1.45;
- }
-}
-
-.section {
- display: flex;
- flex-direction: column;
- gap: 12px;
- min-width: 0;
-}
-
-.sectionTitle {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary);
- margin: 0;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.sectionDesc {
- font-size: 12px;
- color: var(--muted-foreground);
- line-height: 1.5;
- margin: 0;
-}
-
-.field {
- display: grid;
- gap: 6px;
- min-width: 0;
-}
-
-.label {
- font-size: 12px;
- font-weight: 500;
- color: var(--text-primary);
-}
-
-.labelHint {
- font-size: 11px;
- color: var(--muted-foreground);
- font-weight: 400;
-}
-
-.input,
-.textarea {
- width: 100%;
- height: 36px;
- padding: 8px 12px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 13px;
- font-family: inherit;
- box-sizing: border-box;
-
- &::placeholder {
- color: var(--text-tertiary);
- }
-
- &:focus {
- outline: none;
- border-color: var(--primary-color);
- box-shadow: 0 0 0 3px var(--primary-10);
- }
-
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-}
-
-.textarea {
- height: auto;
- min-height: 80px;
- resize: vertical;
- font-family: $font-mono;
- line-height: 1.5;
-}
-
-.checkboxRow {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- cursor: pointer;
- user-select: none;
-}
-
-.checkboxBox {
- margin-top: 2px;
- width: 16px;
- height: 16px;
- border-radius: 4px;
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- cursor: pointer;
- appearance: none;
- position: relative;
-
- &:checked {
- background: var(--primary-color);
- border-color: var(--primary-color);
- }
-
- &:checked::after {
- content: '';
- width: 8px;
- height: 4px;
- border-left: 2px solid var(--primary-contrast);
- border-bottom: 2px solid var(--primary-contrast);
- transform: rotate(-45deg) translateY(-2px);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-}
-
-.checkboxText {
- display: flex;
- flex-direction: column;
- gap: 2px;
- font-size: 13px;
- color: var(--text-primary);
-
- small {
- color: var(--muted-foreground);
- font-size: 11px;
- }
-}
-
-.fieldRow {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 12px;
- min-width: 0;
-}
-
-.divider {
- border: 0;
- border-top: 1px solid var(--border-color);
- margin: 4px 0;
-}
-
-.errorBox {
- border: 1px solid var(--destructive-30);
- background: var(--destructive-10);
- color: var(--destructive-color);
- padding: 10px 12px;
- border-radius: var(--radius-md);
- font-size: 12px;
- line-height: 1.5;
-}
-
-.dl {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 12px 16px;
- margin: 0;
-}
-
-.dt {
- font-size: 11px;
- color: var(--muted-foreground);
- font-weight: 500;
-}
-
-.dd {
- margin: 0;
- margin-top: 2px;
- font-size: 12px;
- font-family: $font-mono;
- color: var(--text-primary);
- word-break: break-word;
-}
-
-.detailHeader {
- margin-bottom: 4px;
-}
-
-.entryCard {
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- padding: 12px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- min-width: 0;
- max-width: 100%;
- box-sizing: border-box;
- background: var(--bg-secondary);
-}
-
-.entryCardHeader {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- min-width: 0;
- font-size: 12px;
- font-weight: 500;
- color: var(--muted-foreground);
-}
-
-.entryCardToggle {
- display: flex;
- min-width: 0;
- flex: 1;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 0;
- border: 0;
- background: transparent;
- color: inherit;
- text-align: left;
- cursor: pointer;
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 3px;
- border-radius: var(--radius-sm);
- }
-}
-
-.sponsorGroupTitle {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 2px;
-
- strong {
- color: var(--text-primary);
- font-size: 13px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-}
-
-.sponsorGroupSummary {
- display: flex;
- min-width: 0;
- flex: 1;
- justify-content: flex-end;
- align-items: center;
- gap: 8px;
-}
-
-.sponsorSummaryKey,
-.sponsorSummaryUrl {
- display: inline-block;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.sponsorSummaryKey {
- max-width: 140px;
- font-family: $font-mono;
- color: var(--text-primary);
-}
-
-.sponsorSummaryUrl {
- max-width: min(360px, 45vw);
- font-family: $font-mono;
- color: var(--muted-foreground);
-}
-
-.entryCardHeaderRight {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-shrink: 0;
-}
-
-.entryCardBody {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 10px;
-}
-
-.entryCardIconBtn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- padding: 0;
- border: 1px solid transparent;
- border-radius: var(--radius-md);
- background: transparent;
- color: var(--text-tertiary);
- cursor: pointer;
- transition:
- background-color $transition-fast,
- color $transition-fast;
-
- &:hover {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: -1px;
- }
-}
-
-.entryCardChevron {
- transition: transform $transition-fast;
-}
-
-.entryCardChevronOpen {
- transform: rotate(180deg);
-}
-
-.entriesToolbar {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
- margin-bottom: 2px;
-}
-
-.entriesToolbarSplit {
- justify-content: space-between;
-}
-
-.sponsorUrlOptions {
- display: grid;
- grid-template-columns: 1fr;
- gap: 8px;
-
- @media (min-width: 560px) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-.sponsorUrlOption {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- min-width: 0;
- padding: 10px 12px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-secondary);
- cursor: pointer;
- transition:
- border-color $transition-fast,
- background-color $transition-fast;
-
- input {
- margin: 2px 0 0;
- accent-color: var(--primary-color);
- flex-shrink: 0;
-
- &:disabled {
- cursor: not-allowed;
- }
- }
-
- &:hover {
- border-color: var(--primary-color);
- }
-}
-
-.sponsorUrlOptionActive {
- border-color: var(--primary-color);
- background: var(--primary-10);
-}
-
-.sponsorUrlOptionText {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 2px;
- font-size: 13px;
- color: var(--text-primary);
-
- small {
- font-family: $font-mono;
- font-size: 11px;
- line-height: 1.4;
- color: var(--muted-foreground);
- overflow-wrap: anywhere;
- }
-}
-
-.sponsorUrlOptionText .sponsorUrlOptionDescription {
- font-family: inherit;
- font-size: 12px;
- line-height: 1.45;
-}
-
-.sponsorProtocolGrid {
- display: grid;
- grid-template-columns: 1fr;
- gap: 8px;
-
- @media (min-width: 560px) {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
-}
-
-.sponsorProtocolCard {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 4px;
- padding: 10px 12px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-secondary);
-}
-
-.sponsorProtocolName {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.sponsorProtocolUrl {
- font-family: $font-mono;
- font-size: 11px;
- line-height: 1.4;
- color: var(--muted-foreground);
- overflow-wrap: anywhere;
-}
-
-.sponsorUsageSection {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
-}
-
-.sponsorUsageResult {
- width: 100%;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- gap: 4px;
- padding: 8px 10px;
- border: 1px solid color-mix(in srgb, var(--success-color) 34%, var(--border-color));
- border-radius: var(--radius-md);
- background: color-mix(in srgb, var(--success-color) 8%, var(--bg-primary));
- color: var(--text-primary);
- font-size: 11px;
- line-height: 1.4;
-}
-
-.sponsorUsageResultWarning {
- color: var(--warning-text);
- border-color: var(--warning-border);
- background: var(--warning-bg);
-}
-
-.sponsorUsageMain {
- display: flex;
- align-items: center;
- gap: 6px;
- font-weight: 500;
-}
-
-.sponsorUsageMeta {
- color: var(--muted-foreground);
-}
-
-.connectivityRow {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 4px;
-}
-
-.connectivityBtn {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 28px;
- padding: 0 12px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 12px;
- font-weight: 500;
- cursor: pointer;
- transition:
- background-color $transition-fast,
- border-color $transition-fast;
-
- &:hover:not(:disabled) {
- border-color: var(--primary-color);
- color: var(--primary-color);
- }
-
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-}
-
-.connectivityBtnGhost {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 24px;
- padding: 0 8px;
- border-radius: var(--radius-md);
- border: 1px solid transparent;
- background: transparent;
- color: var(--text-secondary);
- font-size: 11px;
- font-weight: 500;
- cursor: pointer;
- transition:
- background-color $transition-fast,
- color $transition-fast;
-
- &:hover:not(:disabled) {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- }
-
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-}
-
-.connectivityError {
- border: 1px solid var(--destructive-30);
- background: var(--destructive-10);
- color: var(--destructive-color);
- padding: 8px 10px;
- border-radius: var(--radius-md);
- font-size: 11px;
- line-height: 1.4;
- word-break: break-word;
-}
-
-.connectivityHintSuccess {
- font-size: 11px;
- color: var(--primary-color);
-}
-
-.statusIcon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 14px;
- height: 14px;
-}
-
-.statusIconLoading {
- color: var(--text-secondary);
- animation: spin 0.8s linear infinite;
-}
-
-.statusIconSuccess {
- color: var(--success-color);
-}
-
-.statusIconError {
- color: var(--destructive-color);
-}
-
-.discoveryPanel {
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 12px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-secondary);
-}
-
-.discoveryToolbar {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.discoverySearchWrap {
- position: relative;
- flex: 1;
- display: flex;
- align-items: center;
-}
-
-.discoverySearchIcon {
- position: absolute;
- left: 10px;
- display: inline-flex;
- align-items: center;
- color: var(--muted-foreground);
- pointer-events: none;
-}
-
-.discoverySearch {
- width: 100%;
- height: 32px;
- padding: 6px 10px 6px 30px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 12px;
- box-sizing: border-box;
-
- &::placeholder {
- color: var(--text-tertiary);
- }
-
- &:focus {
- outline: none;
- border-color: var(--primary-color);
- box-shadow: 0 0 0 3px var(--primary-10);
- }
-}
-
-.discoveryEmpty {
- padding: 16px;
- text-align: center;
- font-size: 12px;
- color: var(--muted-foreground);
- border: 1px dashed var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-primary);
-}
-
-.discoveryBatchRow {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 2px 4px;
-}
-
-.discoveryBatchLabel {
- font-size: 12px;
- font-weight: 500;
- color: var(--text-primary);
-}
-
-.discoveryCount {
- font-size: 11px;
- color: var(--muted-foreground);
- font-variant-numeric: tabular-nums;
-}
-
-.discoveryList {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 2px;
- max-height: 240px;
- overflow-y: auto;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-primary);
-}
-
-.discoveryItem {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding: 8px 10px;
- border-bottom: 1px solid var(--border-color);
- font-size: 12px;
- color: var(--text-primary);
-
- &:last-child {
- border-bottom: 0;
- }
-}
-
-.discoveryItemExisting {
- background: var(--muted-bg);
- color: var(--muted-foreground);
-}
-
-.discoveryName {
- font-family: $font-mono;
- font-size: 12px;
- word-break: break-all;
-}
-
-.discoveryNameGroup {
- display: inline-flex;
- min-width: 0;
- flex-direction: column;
- gap: 2px;
-}
-
-.discoveryAlias {
- font-size: 11px;
- color: var(--muted-foreground);
- word-break: break-all;
-}
-
-.discoveryAddedTag {
- font-size: 11px;
- color: var(--muted-foreground);
- padding: 2px 8px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-color);
- background: var(--bg-primary);
-}
-
-.discoveryFooter {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
-}
-
-.discoveryApplyBtn {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 30px;
- padding: 0 14px;
- border-radius: var(--radius-md);
- border: 1px solid transparent;
- background: var(--primary-color);
- color: var(--primary-contrast);
- font-size: 12px;
- font-weight: 500;
- cursor: pointer;
- transition: background-color $transition-fast;
-
- &:hover:not(:disabled) {
- background: var(--primary-hover);
- }
-
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-}
-
-@keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-.passwordField {
- position: relative;
- display: flex;
- align-items: center;
- min-width: 0;
-}
-
-.passwordInput {
- @extend .input;
- padding-right: 36px;
-}
-
-.passwordToggle {
- position: absolute;
- right: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 4px;
- border: none;
- background: transparent;
- color: var(--text-tertiary);
- cursor: pointer;
- border-radius: var(--radius-sm);
- transition: color $transition-fast;
-
- &:hover:not(:disabled) {
- color: var(--text-primary);
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-}
-
-.entriesList {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.modelAliasRow {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
- gap: 8px;
-
- @media (max-width: 520px) {
- grid-template-columns: 1fr auto;
-
- input:first-child {
- grid-column: 1 / -1;
- }
- }
-}
-
-.modelEntry {
- display: flex;
- flex-direction: column;
- gap: 8px;
- min-width: 0;
-}
-
-.modelEntryActions {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-shrink: 0;
-}
-
-.modelEntryDetails {
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 12px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-secondary);
-}
-
-.thinkingFieldset {
- display: grid;
- gap: 8px;
- min-width: 0;
- margin: 0;
- padding: 0;
- border: 0;
-}
-
-.thinkingExistingHint {
- margin: 0;
- color: var(--warning-color);
- font-size: 11px;
- line-height: 1.5;
-}
-
-.thinkingLevelGrid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
-
- @media (max-width: 520px) {
- grid-template-columns: 1fr;
- }
-}
-
-.thinkingLevelOption {
- width: 100%;
- min-width: 0;
- padding: 8px 10px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- background: var(--bg-primary);
- box-sizing: border-box;
-}
-
-.thinkingLevelLabel {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- min-width: 0;
- flex: 1;
- color: var(--text-secondary);
- font-size: 12px;
- font-weight: 500;
-
- code {
- color: var(--muted-foreground);
- font-size: 10px;
- font-weight: 400;
- }
-}
-
-.thinkingLevelOptionSelected {
- border-color: color-mix(in srgb, var(--primary-color) 50%, var(--border-color));
- background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary));
-
- .thinkingLevelLabel {
- color: var(--text-primary);
- }
-}
-
-.entrySummary {
- display: flex;
- min-width: 0;
- flex: 1;
- justify-content: flex-end;
- align-items: center;
- gap: 8px;
-}
-
-.entrySummaryKey {
- display: inline-block;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-family: $font-mono;
- color: var(--text-primary);
-}
-
-.entryBadge {
- display: inline-flex;
- align-items: center;
- padding: 1px 7px;
- border-radius: 999px;
- border: 1px solid var(--border-color);
- background: var(--bg-tertiary);
- color: var(--muted-foreground);
- font-size: 10px;
- font-weight: 500;
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-.showMoreBtn {
- align-self: center;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 6px 12px;
- border-radius: var(--radius-md);
- border: 1px dashed var(--border-color);
- background: var(--bg-primary);
- color: var(--text-secondary);
- cursor: pointer;
- font-size: 12px;
- font-weight: 500;
-
- &:hover {
- border-color: var(--primary-color);
- color: var(--primary-color);
- }
-}
-
-.removeBtn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 4px 8px;
- border-radius: var(--radius-md);
- border: 1px solid transparent;
- background: transparent;
- color: var(--destructive-color);
- cursor: pointer;
- font-size: 12px;
-
- &:hover {
- background: var(--destructive-10);
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-}
-
-.addBtn {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 6px 12px;
- border-radius: var(--radius-md);
- border: 1px dashed var(--border-color);
- background: var(--bg-primary);
- color: var(--text-secondary);
- cursor: pointer;
- font-size: 12px;
- font-weight: 500;
- align-self: flex-start;
-
- &:hover {
- border-color: var(--primary-color);
- color: var(--primary-color);
- }
-}
-
-.footerBtn {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- height: 32px;
- padding: 0 14px;
- border-radius: var(--radius-md);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- transition: background-color $transition-fast;
- border: 1px solid transparent;
- background: transparent;
- color: var(--text-primary);
-
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-}
-
-.footerBtnGhost {
- background: transparent;
- border-color: transparent;
-
- &:hover:not(:disabled) {
- background: var(--bg-tertiary);
- }
-}
-
-.footerBtnOutline {
- border-color: var(--border-color);
- background: var(--bg-primary);
-
- &:hover:not(:disabled) {
- background: var(--bg-tertiary);
- }
-}
-
-.footerBtnPrimary {
- background: var(--primary-color);
- color: var(--primary-contrast);
-
- &:hover:not(:disabled) {
- background: var(--primary-hover);
- }
-}
-
-.apiKeyEntriesSection {
- margin-top: 16px;
- min-width: 0;
- max-width: 100%;
-}
-
-.apiKeyEntriesLabel {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary);
- margin-bottom: 6px;
-}
-
-.apiKeyEntryList {
- display: flex;
- flex-direction: column;
- gap: 6px;
- min-width: 0;
- max-width: 100%;
-}
-
-.apiKeyEntryCard {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
- min-width: 0;
- max-width: 100%;
- padding: 8px 12px;
- background: var(--bg-secondary);
- border: 1px solid var(--border-secondary);
- border-radius: 8px;
- font-size: 12px;
-}
-
-.apiKeyEntryIndex {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- border-radius: 50%;
- background: var(--primary-color);
- color: var(--primary-contrast);
- font-size: 11px;
- font-weight: 600;
- flex-shrink: 0;
-}
-
-.apiKeyEntryKey {
- font-family: 'Monaco', 'Menlo', 'Consolas', 'Ubuntu Mono', monospace;
- font-weight: 600;
- color: var(--text-primary);
- min-width: 0;
- max-width: 100%;
- overflow-wrap: anywhere;
- word-break: break-all;
-}
-
-.apiKeyEntryProxy {
- color: var(--text-tertiary);
- font-size: 11px;
- min-width: 0;
- max-width: 100%;
- overflow-wrap: anywhere;
- word-break: break-word;
-
- &::before {
- content: '| Proxy: ';
- color: var(--text-quaternary);
- }
-}
-
-.apiKeyEntryStats {
- display: flex;
- gap: 6px;
- margin-left: auto;
-}
-
-.apiKeyEntryStat {
- display: inline-flex;
- align-items: center;
- gap: 3px;
- padding: 2px 6px;
- border-radius: 10px;
- font-size: 10px;
- font-weight: 600;
-
- svg {
- display: block;
- }
-}
-
-.apiKeyEntryStatSuccess {
- background: var(--success-badge-bg);
- color: var(--success-badge-text);
-}
-
-.apiKeyEntryStatFailure {
- background: var(--failure-badge-bg);
- color: var(--failure-badge-text);
-}
diff --git a/frontend/src/features/providers/sheets/forms/useConnectivityTest.ts b/frontend/src/features/providers/sheets/forms/useConnectivityTest.ts
deleted file mode 100644
index f0f3826..0000000
--- a/frontend/src/features/providers/sheets/forms/useConnectivityTest.ts
+++ /dev/null
@@ -1,526 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
-import {
- buildCodexResponsesEndpoint,
- buildClaudeMessagesEndpoint,
- buildGeminiGenerateContentEndpoint,
- buildInteractionsEndpoint,
- buildInteractionsProbePayload,
- INTERACTIONS_API_REVISION,
- buildOpenAIChatCompletionsEndpoint,
-} from '@/components/providers/utils';
-import { buildHeaderObject, hasHeader } from '@/utils/headers';
-import { getErrorMessage } from '@/utils/helpers';
-import type { ApiKeyEntryInput, ModelEntryInput, ProviderBrand } from '../../types';
-
-const DEFAULT_TIMEOUT_MS = 30_000;
-const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
-
-export type ConnectivityState = 'idle' | 'loading' | 'success' | 'error';
-
-export interface ConnectivityStatus {
- state: ConnectivityState;
- message: string;
-}
-
-const IDLE: ConnectivityStatus = { state: 'idle', message: '' };
-
-const requestFailureMessage = (err: unknown, messages: ConnectivityErrorMessages): string => {
- const raw = getErrorMessage(err);
- const isTimeout =
- (typeof err === 'object' &&
- err !== null &&
- 'code' in err &&
- String((err as { code?: string }).code) === 'ECONNABORTED') ||
- raw.toLowerCase().includes('timeout');
-
- return isTimeout ? messages.timeout(DEFAULT_TIMEOUT_MS / 1000) : raw || messages.requestFailed;
-};
-
-const pickModel = (testModel: string | undefined, models: ModelEntryInput[]): string => {
- const trimmed = (testModel ?? '').trim();
- if (trimmed) return trimmed;
- for (const m of models) {
- const name = (m.name ?? '').trim();
- if (name) return name;
- }
- return '';
-};
-
-const resolveBearerToken = (headers: Record): string => {
- const auth = Object.entries(headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1];
- if (!auth) return '';
- const match = String(auth).match(/^Bearer\s+(.+)$/i);
- return match ? match[1].trim() : '';
-};
-
-export interface UseConnectivityTestArgs {
- brand: ProviderBrand;
- baseUrl: string;
- testModel?: string;
- models: ModelEntryInput[];
- formHeaders: Array<{ key: string; value: string }>;
- apiKeyEntries?: ApiKeyEntryInput[];
- apiKey?: string;
- fallbackApiKey?: string;
- authIndex?: string;
-}
-
-export interface ConnectivityErrorMessages {
- baseUrlRequired: string;
- endpointInvalid: string;
- apiKeyRequired: string;
- modelRequired: string;
- timeout: (seconds: number) => string;
- requestFailed: string;
-}
-
-export interface UseConnectivityTestResult {
- openaiStatuses: ConnectivityStatus[];
- codexStatus: ConnectivityStatus;
- geminiStatus: ConnectivityStatus;
- claudeStatus: ConnectivityStatus;
- isTestingAny: boolean;
- runOpenAIKey: (idx: number) => Promise;
- runOpenAIAllKeys: () => Promise;
- runCodex: () => Promise;
- runGemini: () => Promise;
- runClaude: () => Promise;
-}
-
-export function useConnectivityTest(
- args: UseConnectivityTestArgs,
- messages: ConnectivityErrorMessages
-): UseConnectivityTestResult {
- const {
- brand,
- baseUrl,
- testModel,
- models,
- formHeaders,
- apiKeyEntries,
- apiKey,
- fallbackApiKey,
- authIndex,
- } = args;
-
- const entriesCount = apiKeyEntries?.length ?? 0;
-
- const [openaiStatuses, setOpenaiStatuses] = useState(() =>
- Array.from({ length: entriesCount }, () => IDLE)
- );
- const [codexStatus, setCodexStatus] = useState(IDLE);
- const [geminiStatus, setGeminiStatus] = useState(IDLE);
- const [claudeStatus, setClaudeStatus] = useState(IDLE);
- const [inFlight, setInFlight] = useState(0);
-
- const entrySignatures = useMemo(
- () =>
- (apiKeyEntries ?? []).map((entry) =>
- [
- entry.apiKey ?? '',
- entry.existingApiKey ?? '',
- entry.authIndex ?? '',
- entry.proxyUrl ?? '',
- ].join('||')
- ),
- [apiKeyEntries]
- );
-
- const lastEntrySignaturesRef = useRef(entrySignatures);
- useEffect(() => {
- const prev = lastEntrySignaturesRef.current;
- const curr = entrySignatures;
- lastEntrySignaturesRef.current = curr;
-
- setOpenaiStatuses((statuses) => {
- const nextLen = curr.length;
- let mutated = statuses.length !== nextLen;
- const next = statuses.slice(0, nextLen);
- while (next.length < nextLen) next.push(IDLE);
- for (let i = 0; i < nextLen; i++) {
- if (prev[i] !== undefined && prev[i] !== curr[i] && next[i].state !== 'idle') {
- next[i] = IDLE;
- mutated = true;
- }
- }
- return mutated ? next : statuses;
- });
- }, [entrySignatures]);
-
- const signature = useMemo(() => {
- const h = formHeaders.map((it) => `${it.key}:${it.value}`).join('|');
- const m = models.map((it) => `${it.name}:${it.alias ?? ''}`).join('|');
- return [
- baseUrl,
- (testModel ?? '').trim(),
- apiKey ?? '',
- fallbackApiKey ?? '',
- authIndex ?? '',
- h,
- m,
- ].join('||');
- }, [apiKey, authIndex, baseUrl, fallbackApiKey, testModel, formHeaders, models]);
-
- const lastSignatureRef = useRef(signature);
- useEffect(() => {
- if (lastSignatureRef.current === signature) return;
- lastSignatureRef.current = signature;
- setOpenaiStatuses((prev) => prev.map(() => IDLE));
- setCodexStatus(IDLE);
- setGeminiStatus(IDLE);
- setClaudeStatus(IDLE);
- }, [signature]);
-
- const updateOpenaiStatus = useCallback((idx: number, value: ConnectivityStatus) => {
- setOpenaiStatuses((prev) => {
- const next = [...prev];
- next[idx] = value;
- return next;
- });
- }, []);
-
- const runOpenAIKey = useCallback(
- async (idx: number): Promise => {
- if (brand !== 'openaiCompatibility') return false;
-
- const trimmedBase = baseUrl.trim();
- if (!trimmedBase) {
- updateOpenaiStatus(idx, {
- state: 'error',
- message: messages.baseUrlRequired,
- });
- return false;
- }
- const endpoint = buildOpenAIChatCompletionsEndpoint(trimmedBase);
- if (!endpoint) {
- updateOpenaiStatus(idx, {
- state: 'error',
- message: messages.endpointInvalid,
- });
- return false;
- }
- const entry = apiKeyEntries?.[idx];
- const entryKey = (entry?.apiKey ?? '').trim() || (entry?.existingApiKey ?? '').trim();
- const resolvedAuthIndex =
- (entry?.authIndex ?? '').trim() || (authIndex ?? '').trim() || undefined;
- if (!entryKey && !resolvedAuthIndex) {
- updateOpenaiStatus(idx, {
- state: 'error',
- message: messages.apiKeyRequired,
- });
- return false;
- }
- const model = pickModel(testModel, models);
- if (!model) {
- updateOpenaiStatus(idx, {
- state: 'error',
- message: messages.modelRequired,
- });
- return false;
- }
-
- const headerObj: Record = {
- 'Content-Type': 'application/json',
- ...buildHeaderObject(formHeaders),
- };
- if (!hasHeader(headerObj, 'authorization')) {
- if (entryKey) {
- headerObj.Authorization = `Bearer ${entryKey}`;
- } else if (resolvedAuthIndex) {
- headerObj.Authorization = 'Bearer $TOKEN$';
- }
- }
-
- updateOpenaiStatus(idx, { state: 'loading', message: '' });
- setInFlight((n) => n + 1);
- try {
- const result = await apiCallApi.request(
- {
- authIndex: resolvedAuthIndex,
- method: 'POST',
- url: endpoint,
- header: headerObj,
- data: JSON.stringify({
- model,
- messages: [{ role: 'user', content: 'Hi' }],
- stream: false,
- max_tokens: 5,
- }),
- },
- { timeout: DEFAULT_TIMEOUT_MS }
- );
- if (result.statusCode < 200 || result.statusCode >= 300) {
- throw new Error(getApiCallErrorMessage(result));
- }
- updateOpenaiStatus(idx, { state: 'success', message: '' });
- return true;
- } catch (err) {
- updateOpenaiStatus(idx, {
- state: 'error',
- message: requestFailureMessage(err, messages),
- });
- return false;
- } finally {
- setInFlight((n) => n - 1);
- }
- },
- [
- apiKeyEntries,
- authIndex,
- baseUrl,
- brand,
- formHeaders,
- messages,
- models,
- testModel,
- updateOpenaiStatus,
- ]
- );
-
- const runOpenAIAllKeys = useCallback(async (): Promise => {
- if (brand !== 'openaiCompatibility') return;
- const entries = apiKeyEntries ?? [];
- if (!entries.length) return;
- await Promise.all(entries.map((_, idx) => runOpenAIKey(idx)));
- }, [apiKeyEntries, brand, runOpenAIKey]);
-
- const runCodex = useCallback(async (): Promise => {
- if (brand !== 'codex' && brand !== 'xai') return;
-
- const trimmedBase = baseUrl.trim();
- if (!trimmedBase) {
- setCodexStatus({ state: 'error', message: messages.baseUrlRequired });
- return;
- }
-
- const endpoint = buildCodexResponsesEndpoint(trimmedBase);
- if (!endpoint) {
- setCodexStatus({ state: 'error', message: messages.endpointInvalid });
- return;
- }
-
- const model = pickModel(testModel, models);
- if (!model) {
- setCodexStatus({ state: 'error', message: messages.modelRequired });
- return;
- }
-
- const customHeaders = buildHeaderObject(formHeaders);
- const explicitKey = (apiKey ?? '').trim();
- const persistedKey = (fallbackApiKey ?? '').trim();
- const hasAuthorization = hasHeader(customHeaders, 'authorization');
- const resolvedKey = explicitKey || persistedKey;
- const resolvedAuthIndex = (authIndex ?? '').trim() || undefined;
-
- if (!resolvedKey && !hasAuthorization && !resolvedAuthIndex) {
- setCodexStatus({ state: 'error', message: messages.apiKeyRequired });
- return;
- }
-
- const headerObj: Record = {
- 'Content-Type': 'application/json',
- ...customHeaders,
- };
- if (!hasHeader(headerObj, 'authorization')) {
- if (resolvedKey) {
- headerObj.Authorization = `Bearer ${resolvedKey}`;
- } else if (resolvedAuthIndex) {
- headerObj.Authorization = 'Bearer $TOKEN$';
- }
- }
-
- setCodexStatus({ state: 'loading', message: '' });
- setInFlight((n) => n + 1);
- try {
- const result = await apiCallApi.request(
- {
- authIndex: resolvedAuthIndex,
- method: 'POST',
- url: endpoint,
- header: headerObj,
- data: JSON.stringify({
- model,
- input: 'Hi',
- stream: false,
- }),
- },
- { timeout: DEFAULT_TIMEOUT_MS }
- );
- if (result.statusCode < 200 || result.statusCode >= 300) {
- throw new Error(getApiCallErrorMessage(result));
- }
- setCodexStatus({ state: 'success', message: '' });
- } catch (err) {
- setCodexStatus({
- state: 'error',
- message: requestFailureMessage(err, messages),
- });
- } finally {
- setInFlight((n) => n - 1);
- }
- }, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
-
- const runGemini = useCallback(async (): Promise => {
- if (brand !== 'gemini' && brand !== 'interactions') return;
-
- const model = pickModel(testModel, models);
- if (!model) {
- setGeminiStatus({ state: 'error', message: messages.modelRequired });
- return;
- }
-
- const endpoint =
- brand === 'interactions'
- ? buildInteractionsEndpoint(baseUrl ?? '')
- : buildGeminiGenerateContentEndpoint(baseUrl ?? '', model);
- if (!endpoint) {
- setGeminiStatus({ state: 'error', message: messages.endpointInvalid });
- return;
- }
-
- const customHeaders = buildHeaderObject(formHeaders);
- const explicitKey = (apiKey ?? '').trim();
- const persistedKey = (fallbackApiKey ?? '').trim();
- const hasApiKeyHeader = hasHeader(customHeaders, 'x-goog-api-key');
- const resolvedKey = explicitKey || persistedKey;
- const resolvedAuthIndex = (authIndex ?? '').trim() || undefined;
-
- if (!resolvedKey && !hasApiKeyHeader && !resolvedAuthIndex) {
- setGeminiStatus({ state: 'error', message: messages.apiKeyRequired });
- return;
- }
-
- const headerObj: Record = {
- 'Content-Type': 'application/json',
- ...customHeaders,
- };
- if (!hasHeader(headerObj, 'x-goog-api-key')) {
- if (resolvedKey) {
- headerObj['x-goog-api-key'] = resolvedKey;
- } else if (resolvedAuthIndex) {
- headerObj['x-goog-api-key'] = '$TOKEN$';
- }
- }
- if (brand === 'interactions' && !hasHeader(headerObj, 'api-revision')) {
- headerObj['Api-Revision'] = INTERACTIONS_API_REVISION;
- }
-
- setGeminiStatus({ state: 'loading', message: '' });
- setInFlight((n) => n + 1);
- try {
- const result = await apiCallApi.request(
- {
- authIndex: resolvedAuthIndex,
- method: 'POST',
- url: endpoint,
- header: headerObj,
- data: JSON.stringify(
- brand === 'interactions'
- ? buildInteractionsProbePayload(model)
- : {
- contents: [{ parts: [{ text: 'Hi' }] }],
- generationConfig: { maxOutputTokens: 8 },
- }
- ),
- },
- { timeout: DEFAULT_TIMEOUT_MS }
- );
- if (result.statusCode < 200 || result.statusCode >= 300) {
- throw new Error(getApiCallErrorMessage(result));
- }
- setGeminiStatus({ state: 'success', message: '' });
- } catch (err) {
- setGeminiStatus({
- state: 'error',
- message: requestFailureMessage(err, messages),
- });
- } finally {
- setInFlight((n) => n - 1);
- }
- }, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
-
- const runClaude = useCallback(async (): Promise => {
- if (brand !== 'claude' && brand !== 'claudeApi') return;
-
- const endpoint = buildClaudeMessagesEndpoint(baseUrl ?? '');
- if (!endpoint) {
- setClaudeStatus({ state: 'error', message: messages.endpointInvalid });
- return;
- }
- const model = pickModel(testModel, models);
- if (!model) {
- setClaudeStatus({ state: 'error', message: messages.modelRequired });
- return;
- }
-
- const customHeaders = buildHeaderObject(formHeaders);
- const explicitKey = (apiKey ?? '').trim();
- const persistedKey = (fallbackApiKey ?? '').trim();
- const headerKey = resolveBearerToken(customHeaders);
- const hasApiKeyHeader = hasHeader(customHeaders, 'x-api-key');
- const resolvedKey = explicitKey || persistedKey || headerKey;
- const resolvedAuthIndex = (authIndex ?? '').trim() || undefined;
-
- if (!resolvedKey && !hasApiKeyHeader && !resolvedAuthIndex) {
- setClaudeStatus({ state: 'error', message: messages.apiKeyRequired });
- return;
- }
-
- const headerObj: Record = {
- 'Content-Type': 'application/json',
- ...customHeaders,
- };
- if (!hasHeader(headerObj, 'anthropic-version')) {
- headerObj['anthropic-version'] = DEFAULT_ANTHROPIC_VERSION;
- }
- if (!hasApiKeyHeader && resolvedKey) {
- headerObj['x-api-key'] = resolvedKey;
- } else if (!hasApiKeyHeader && resolvedAuthIndex) {
- headerObj['x-api-key'] = '$TOKEN$';
- }
-
- setClaudeStatus({ state: 'loading', message: '' });
- setInFlight((n) => n + 1);
- try {
- const result = await apiCallApi.request(
- {
- authIndex: resolvedAuthIndex,
- method: 'POST',
- url: endpoint,
- header: headerObj,
- data: JSON.stringify({
- model,
- max_tokens: 8,
- messages: [{ role: 'user', content: 'Hi' }],
- }),
- },
- { timeout: DEFAULT_TIMEOUT_MS }
- );
- if (result.statusCode < 200 || result.statusCode >= 300) {
- throw new Error(getApiCallErrorMessage(result));
- }
- setClaudeStatus({ state: 'success', message: '' });
- } catch (err) {
- setClaudeStatus({
- state: 'error',
- message: requestFailureMessage(err, messages),
- });
- } finally {
- setInFlight((n) => n - 1);
- }
- }, [apiKey, authIndex, baseUrl, brand, fallbackApiKey, formHeaders, messages, models, testModel]);
-
- return {
- openaiStatuses,
- codexStatus,
- geminiStatus,
- claudeStatus,
- isTestingAny: inFlight > 0,
- runOpenAIKey,
- runOpenAIAllKeys,
- runCodex,
- runGemini,
- runClaude,
- };
-}
diff --git a/frontend/src/features/providers/sheets/forms/useModelDiscovery.ts b/frontend/src/features/providers/sheets/forms/useModelDiscovery.ts
deleted file mode 100644
index 935a006..0000000
--- a/frontend/src/features/providers/sheets/forms/useModelDiscovery.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { modelsApi } from '@/services/api';
-import { buildHeaderObject } from '@/utils/headers';
-import { getErrorMessage } from '@/utils/helpers';
-import type { ModelInfo } from '@/utils/models';
-import type { ApiKeyEntryInput, ProviderBrand } from '../../types';
-
-export const MODEL_DISCOVERY_BRANDS: ReadonlyArray = [
- 'gemini',
- 'interactions',
- 'codex',
- 'xai',
- 'claude',
- 'claudeApi',
- 'openaiCompatibility',
-];
-
-export const isModelDiscoveryBrand = (brand: ProviderBrand): boolean =>
- MODEL_DISCOVERY_BRANDS.includes(brand);
-
-export interface UseModelDiscoveryArgs {
- brand: ProviderBrand;
- baseUrl: string;
- formHeaders: Array<{ key: string; value: string }>;
- apiKeyEntries?: ApiKeyEntryInput[];
- apiKey?: string;
- fallbackApiKey?: string;
- authIndex?: string;
-}
-
-export interface UseModelDiscoveryResult {
- available: boolean;
- loading: boolean;
- error: string | null;
- models: ModelInfo[];
- hasFetched: boolean;
- fetch: () => Promise;
- reset: () => void;
-}
-
-export function useModelDiscovery(args: UseModelDiscoveryArgs): UseModelDiscoveryResult {
- const { brand, baseUrl, formHeaders, apiKeyEntries, apiKey, fallbackApiKey, authIndex } = args;
-
- const available = isModelDiscoveryBrand(brand);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [models, setModels] = useState([]);
- const [hasFetched, setHasFetched] = useState(false);
-
- const fetch = useCallback(async () => {
- if (!available) return;
- setLoading(true);
- setError(null);
- try {
- const baseHeaders = buildHeaderObject(formHeaders);
- const resolvedAuthIndex = (authIndex ?? '').trim() || undefined;
- let next: ModelInfo[] = [];
- if (brand === 'gemini' || brand === 'interactions') {
- const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
- next = await modelsApi.fetchGeminiModelsViaApiCall(
- baseUrl,
- key,
- baseHeaders,
- resolvedAuthIndex
- );
- } else if (brand === 'codex' || brand === 'xai') {
- const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
- next = await modelsApi.fetchV1ModelsViaApiCall(
- baseUrl,
- key,
- baseHeaders,
- resolvedAuthIndex
- );
- } else if (brand === 'claude' || brand === 'claudeApi') {
- const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
- next = await modelsApi.fetchClaudeModelsViaApiCall(
- baseUrl,
- key,
- baseHeaders,
- resolvedAuthIndex
- );
- } else if (brand === 'openaiCompatibility') {
- const firstEntry = (apiKeyEntries ?? []).find(
- (e) =>
- (e.apiKey ?? '').trim() || (e.existingApiKey ?? '').trim() || (e.authIndex ?? '').trim()
- );
- const entryKey =
- (firstEntry?.apiKey ?? '').trim() || (firstEntry?.existingApiKey ?? '').trim();
- const entryAuthIndex = (firstEntry?.authIndex ?? '').trim() || resolvedAuthIndex;
- try {
- next = await modelsApi.fetchModelsViaApiCall(
- baseUrl,
- entryKey,
- baseHeaders,
- entryAuthIndex
- );
- } catch (firstErr) {
- // Some OpenAI-compatible endpoints expose /models without auth, or
- // reject the configured key for the discovery route. Retry once
- // without any auth/headers before surfacing the original error.
- try {
- next = await modelsApi.fetchModelsViaApiCall(baseUrl);
- } catch {
- throw firstErr;
- }
- }
- }
- setModels(next ?? []);
- setHasFetched(true);
- } catch (err) {
- setModels([]);
- setError(getErrorMessage(err) || 'Failed to fetch models');
- setHasFetched(true);
- } finally {
- setLoading(false);
- }
- }, [available, apiKey, apiKeyEntries, authIndex, baseUrl, brand, fallbackApiKey, formHeaders]);
-
- const reset = useCallback(() => {
- setModels([]);
- setError(null);
- setLoading(false);
- setHasFetched(false);
- }, []);
-
- const inputSignature = useMemo(() => {
- const headerSig = formHeaders.map((h) => `${h.key}:${h.value}`).join('|');
- const entriesSig = (apiKeyEntries ?? [])
- .map((e) => `${e.apiKey ?? ''}::${e.existingApiKey ?? ''}::${e.authIndex ?? ''}`)
- .join('|');
- return [
- baseUrl,
- apiKey ?? '',
- fallbackApiKey ?? '',
- authIndex ?? '',
- headerSig,
- entriesSig,
- ].join('||');
- }, [apiKey, apiKeyEntries, authIndex, baseUrl, fallbackApiKey, formHeaders]);
-
- const lastSignatureRef = useRef(inputSignature);
- useEffect(() => {
- if (lastSignatureRef.current === inputSignature) return;
- lastSignatureRef.current = inputSignature;
- reset();
- }, [inputSignature, reset]);
-
- return { available, loading, error, models, hasFetched, fetch, reset };
-}
diff --git a/frontend/src/features/providers/sheets/forms/useSponsorUsageCheck.ts b/frontend/src/features/providers/sheets/forms/useSponsorUsageCheck.ts
deleted file mode 100644
index 15d1c39..0000000
--- a/frontend/src/features/providers/sheets/forms/useSponsorUsageCheck.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { useCallback, useMemo, useState } from 'react';
-import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
-import { getErrorMessage } from '@/utils/helpers';
-import {
- getApiKeyFunUsageEndpoints,
- normalizeApiKeyFunUsagePayload,
- type ApiKeyFunUsageSummary,
-} from '../../sponsor';
-
-const DEFAULT_TIMEOUT_MS = 30_000;
-
-export type SponsorUsageState = 'idle' | 'loading' | 'success' | 'error';
-
-export interface SponsorUsageStatus {
- state: SponsorUsageState;
- message: string;
- summary: ApiKeyFunUsageSummary | null;
-}
-
-export interface SponsorUsageMessages {
- apiKeyRequired: string;
- emptyResponse: string;
- requestFailed: string;
-}
-
-export interface UseSponsorUsageCheckArgs {
- baseUrl: string;
- apiKey: string;
- fallbackApiKey?: string;
-}
-
-const IDLE: SponsorUsageStatus = {
- state: 'idle',
- message: '',
- summary: null,
-};
-
-interface SponsorUsageStateBucket {
- signature: string;
- status: SponsorUsageStatus;
-}
-
-export function useSponsorUsageCheck(
- args: UseSponsorUsageCheckArgs,
- messages: SponsorUsageMessages
-) {
- const { baseUrl, apiKey, fallbackApiKey } = args;
-
- const endpoints = useMemo(() => getApiKeyFunUsageEndpoints(baseUrl), [baseUrl]);
- const signature = useMemo(
- () => [baseUrl, apiKey, fallbackApiKey ?? ''].join('||'),
- [apiKey, baseUrl, fallbackApiKey]
- );
- const [bucket, setBucket] = useState(() => ({
- signature,
- status: IDLE,
- }));
- const status = bucket.signature === signature ? bucket.status : IDLE;
- const setStatus = useCallback(
- (next: SponsorUsageStatus) => {
- setBucket({ signature, status: next });
- },
- [signature]
- );
-
- const run = useCallback(async () => {
- const key = apiKey.trim() || (fallbackApiKey ?? '').trim();
- if (!key) {
- setStatus({
- state: 'error',
- message: messages.apiKeyRequired,
- summary: null,
- });
- return;
- }
-
- setStatus({ state: 'loading', message: '', summary: null });
- let lastNetworkError = '';
-
- for (let idx = 0; idx < endpoints.length; idx += 1) {
- const endpoint = endpoints[idx];
- try {
- const result = await apiCallApi.request(
- {
- method: 'GET',
- url: endpoint,
- header: {
- Authorization: `Bearer ${key}`,
- },
- },
- { timeout: DEFAULT_TIMEOUT_MS }
- );
-
- if (result.statusCode < 200 || result.statusCode >= 300) {
- setStatus({
- state: 'error',
- message: getApiCallErrorMessage(result),
- summary: null,
- });
- return;
- }
-
- const summary = normalizeApiKeyFunUsagePayload(result.body ?? result.bodyText);
- if (!summary) {
- setStatus({
- state: 'error',
- message: messages.emptyResponse,
- summary: null,
- });
- return;
- }
-
- setStatus({
- state: 'success',
- message: '',
- summary,
- });
- return;
- } catch (err) {
- lastNetworkError = getErrorMessage(err, messages.requestFailed);
- if (idx < endpoints.length - 1) {
- continue;
- }
- }
- }
-
- setStatus({
- state: 'error',
- message: lastNetworkError || messages.requestFailed,
- summary: null,
- });
- }, [apiKey, endpoints, fallbackApiKey, messages, setStatus]);
-
- return {
- status,
- isLoading: status.state === 'loading',
- run,
- reset: () => setStatus(IDLE),
- };
-}
diff --git a/frontend/src/features/providers/sponsor.ts b/frontend/src/features/providers/sponsor.ts
deleted file mode 100644
index c3c9290..0000000
--- a/frontend/src/features/providers/sponsor.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-import type { Config, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import type { SponsorProviderRaw } from './types';
-
-export const APIKEY_FUN_PROVIDER_NAME = 'apikeyFun';
-export const APIKEY_FUN_DISPLAY_NAME = 'APIKEY.FUN';
-export const APIKEY_FUN_AFFILIATE_URL = 'https://apikey.fun/register?aff=AKCPA';
-export const APIKEY_FUN_DASHBOARD_URL = 'https://apikey.fun/dashboard';
-export const APIKEY_FUN_STANDARD_BASE_URL = 'https://api.apikey.fun';
-export const APIKEY_FUN_DIRECT_BASE_URL = 'https://slb.apikey.fun';
-export const APIKEY_FUN_OPENAI_BASE_URL = `${APIKEY_FUN_STANDARD_BASE_URL}/v1`;
-export const APIKEY_FUN_CODEX_BASE_URL = APIKEY_FUN_OPENAI_BASE_URL;
-export const APIKEY_FUN_ANTHROPIC_BASE_URL = APIKEY_FUN_STANDARD_BASE_URL;
-export const APIKEY_FUN_GEMINI_BASE_URL = APIKEY_FUN_STANDARD_BASE_URL;
-export const APIKEY_FUN_USAGE_PATH = '/v1/usage';
-
-export const APIKEY_FUN_BASE_URL_OPTIONS = [
- {
- id: 'standard',
- baseUrl: APIKEY_FUN_STANDARD_BASE_URL,
- openaiBaseUrl: APIKEY_FUN_OPENAI_BASE_URL,
- codexBaseUrl: APIKEY_FUN_CODEX_BASE_URL,
- anthropicBaseUrl: APIKEY_FUN_ANTHROPIC_BASE_URL,
- geminiBaseUrl: APIKEY_FUN_GEMINI_BASE_URL,
- },
- {
- id: 'direct',
- baseUrl: APIKEY_FUN_DIRECT_BASE_URL,
- openaiBaseUrl: `${APIKEY_FUN_DIRECT_BASE_URL}/v1`,
- codexBaseUrl: `${APIKEY_FUN_DIRECT_BASE_URL}/v1`,
- anthropicBaseUrl: APIKEY_FUN_DIRECT_BASE_URL,
- geminiBaseUrl: APIKEY_FUN_DIRECT_BASE_URL,
- },
-] as const;
-
-export const APIKEY_FUN_PROTOCOLS = ['anthropic', 'openai', 'codexResponses'] as const;
-
-const normalizeText = (value: string | undefined | null): string =>
- String(value ?? '')
- .trim()
- .toLowerCase();
-
-const normalizeBaseUrl = (value: string | undefined | null): string =>
- normalizeText(value).replace(/\/+$/, '');
-
-export const resolveApiKeyFunBaseUrl = (value: string | undefined | null): string => {
- const normalized = normalizeBaseUrl(value);
- const matched = APIKEY_FUN_BASE_URL_OPTIONS.find(
- (option) =>
- normalized === normalizeBaseUrl(option.baseUrl) ||
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl) ||
- normalized === normalizeBaseUrl(option.anthropicBaseUrl)
- );
- return matched?.baseUrl ?? APIKEY_FUN_STANDARD_BASE_URL;
-};
-
-export const getApiKeyFunProtocolUrls = (value: string | undefined | null) => {
- const baseUrl = resolveApiKeyFunBaseUrl(value);
- const matched =
- APIKEY_FUN_BASE_URL_OPTIONS.find(
- (option) => normalizeBaseUrl(option.baseUrl) === normalizeBaseUrl(baseUrl)
- ) ?? APIKEY_FUN_BASE_URL_OPTIONS[0];
- return {
- anthropic: matched.anthropicBaseUrl,
- openai: matched.openaiBaseUrl,
- codex: matched.codexBaseUrl,
- gemini: matched.geminiBaseUrl,
- };
-};
-
-const buildApiKeyFunUsageEndpoint = (baseUrl: string): string =>
- `${baseUrl.replace(/\/+$/, '')}${APIKEY_FUN_USAGE_PATH}`;
-
-export const getApiKeyFunUsageEndpoints = (value: string | undefined | null): string[] => {
- const baseUrl = resolveApiKeyFunBaseUrl(value);
- const primary = buildApiKeyFunUsageEndpoint(baseUrl);
- const standard = buildApiKeyFunUsageEndpoint(APIKEY_FUN_STANDARD_BASE_URL);
- return primary === standard ? [primary] : [primary, standard];
-};
-
-export interface ApiKeyFunUsageSummary {
- isValid: boolean;
- status?: string;
- mode?: string;
- remaining: number | string | null;
- unit: string;
- limit: number | string | null;
- used: number | string | null;
-}
-
-const normalizeUsageAmount = (value: unknown): number | string | null => {
- if (typeof value === 'number') {
- return Number.isFinite(value) ? value : null;
- }
- if (typeof value === 'string') {
- const trimmed = value.trim();
- return trimmed ? trimmed : null;
- }
- return null;
-};
-
-const normalizeString = (value: unknown): string | undefined =>
- typeof value === 'string' && value.trim() ? value.trim() : undefined;
-
-const normalizeBoolean = (value: unknown, fallback: boolean): boolean => {
- if (typeof value === 'boolean') return value;
- if (typeof value === 'string') {
- const normalized = value.trim().toLowerCase();
- if (['true', '1', 'yes', 'active'].includes(normalized)) return true;
- if (['false', '0', 'no', 'inactive', 'disabled'].includes(normalized)) return false;
- }
- return fallback;
-};
-
-const isRecord = (value: unknown): value is Record =>
- value !== null && typeof value === 'object' && !Array.isArray(value);
-
-export const normalizeApiKeyFunUsagePayload = (payload: unknown): ApiKeyFunUsageSummary | null => {
- if (!isRecord(payload)) return null;
-
- const quota = isRecord(payload.quota) ? payload.quota : {};
- const remaining = normalizeUsageAmount(payload.remaining ?? quota.remaining ?? payload.balance);
- const unit = normalizeString(payload.unit ?? quota.unit) ?? 'USD';
- const limit = normalizeUsageAmount(quota.limit);
- const used = normalizeUsageAmount(quota.used);
- const status = normalizeString(payload.status);
- const mode = normalizeString(payload.mode);
- const isValid = normalizeBoolean(payload.is_active ?? payload.isValid, true);
-
- if (remaining === null && limit === null && used === null && !status && !mode) {
- return null;
- }
-
- return {
- isValid,
- status,
- mode,
- remaining,
- unit,
- limit,
- used,
- };
-};
-
-const matchesApiKeyFunOpenAIBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return APIKEY_FUN_BASE_URL_OPTIONS.some(
- (option) =>
- normalized === normalizeBaseUrl(option.openaiBaseUrl) ||
- normalized === normalizeBaseUrl(option.codexBaseUrl)
- );
-};
-
-const matchesApiKeyFunAnthropicBaseUrl = (value: string | undefined | null): boolean => {
- const normalized = normalizeBaseUrl(value);
- return APIKEY_FUN_BASE_URL_OPTIONS.some(
- (option) => normalized === normalizeBaseUrl(option.anthropicBaseUrl)
- );
-};
-
-export const isApiKeyFunOpenAIProvider = (
- config: OpenAIProviderConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesApiKeyFunOpenAIBaseUrl(config.baseUrl);
-};
-
-export const isApiKeyFunClaudeProvider = (
- config: ProviderKeyConfig | undefined | null
-): boolean => {
- if (!config) return false;
- return matchesApiKeyFunAnthropicBaseUrl(config.baseUrl);
-};
-
-export const isApiKeyFunCodexProvider = (config: ProviderKeyConfig | undefined | null): boolean => {
- if (!config) return false;
- return matchesApiKeyFunOpenAIBaseUrl(config.baseUrl);
-};
-
-export const buildApiKeyFunRaw = (config: Config | null | undefined): SponsorProviderRaw => ({
- openai: (config?.openaiCompatibility ?? [])
- .map((item, index) => ({ config: item, index: item.sourceIndex ?? index }))
- .filter((item) => isApiKeyFunOpenAIProvider(item.config)),
- claude: (config?.claudeApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isApiKeyFunClaudeProvider(item.config)),
- codex: (config?.codexApiKeys ?? [])
- .map((item, index) => ({ config: item, index }))
- .filter((item) => isApiKeyFunCodexProvider(item.config)),
- gemini: [],
-});
-
-export const hasApiKeyFunConfig = (config: Config | null | undefined): boolean => {
- const raw = buildApiKeyFunRaw(config);
- return raw.openai.length > 0 || raw.claude.length > 0 || raw.codex.length > 0;
-};
diff --git a/frontend/src/features/providers/sponsorDefinitions.ts b/frontend/src/features/providers/sponsorDefinitions.ts
deleted file mode 100644
index bf03b31..0000000
--- a/frontend/src/features/providers/sponsorDefinitions.ts
+++ /dev/null
@@ -1,269 +0,0 @@
-import {
- APIKEY_FUN_AFFILIATE_URL,
- APIKEY_FUN_BASE_URL_OPTIONS,
- APIKEY_FUN_DASHBOARD_URL,
- APIKEY_FUN_DISPLAY_NAME,
- APIKEY_FUN_PROTOCOLS,
- APIKEY_FUN_PROVIDER_NAME,
- getApiKeyFunProtocolUrls,
- resolveApiKeyFunBaseUrl,
-} from './sponsor';
-import {
- CODE0_AFFILIATE_URL,
- CODE0_BASE_URL_OPTIONS,
- CODE0_DISPLAY_NAME,
- CODE0_PROTOCOL_LABELS,
- CODE0_PROVIDER_NAME,
- getCode0ProtocolUrls,
- resolveCode0BaseUrl,
-} from './code0';
-import {
- FENNO_AI_AFFILIATE_URL,
- FENNO_AI_BASE_URL_OPTIONS,
- FENNO_AI_DISPLAY_NAME,
- FENNO_AI_PROTOCOL_LABELS,
- FENNO_AI_PROVIDER_NAME,
- getFennoAIProtocolUrls,
- resolveFennoAIBaseUrl,
-} from './fennoAI';
-import {
- QINIU_CLOUD_AFFILIATE_URL,
- QINIU_CLOUD_BASE_URL_OPTIONS,
- QINIU_CLOUD_DISPLAY_NAME,
- QINIU_CLOUD_PROTOCOL_LABELS,
- QINIU_CLOUD_PROVIDER_NAME,
- getQiniuCloudProtocolUrls,
- resolveQiniuCloudBaseUrl,
-} from './qiniuCloud';
-import {
- LMU_AI_AFFILIATE_URL,
- LMU_AI_BASE_URL_OPTIONS,
- LMU_AI_DISPLAY_NAME,
- LMU_AI_PROTOCOL_LABELS,
- LMU_AI_PROVIDER_NAME,
- getLmuAIProtocolUrls,
- resolveLmuAIBaseUrl,
-} from './lmuAI';
-import {
- INFISTAR_AFFILIATE_URL,
- INFISTAR_BASE_URL_OPTIONS,
- INFISTAR_DISPLAY_NAME,
- INFISTAR_PROTOCOL_LABELS,
- INFISTAR_PROVIDER_NAME,
- getInfistarProtocolUrls,
- resolveInfistarBaseUrl,
-} from './infistar';
-import {
- KIMI_BASE_URL_OPTIONS,
- KIMI_DISPLAY_NAME,
- KIMI_PROTOCOL_LABELS,
- KIMI_PROVIDER_NAME,
- getKimiProtocolUrls,
- resolveKimiBaseUrl,
-} from './kimi';
-import type {
- ProviderBrand,
- SponsorProtocol,
- SponsorProviderBrand,
- SponsorProviderRaw,
-} from './types';
-
-export interface SponsorProtocolUrls {
- anthropic: string;
- openai: string;
- codex: string;
- gemini: string;
-}
-
-export interface SponsorBaseUrlOption {
- id: string;
- descriptionKey?: string;
- baseUrl: string;
- openaiBaseUrl: string;
- codexBaseUrl: string;
- anthropicBaseUrl: string;
- geminiBaseUrl: string;
-}
-
-export interface SponsorProviderDefinition {
- brand: SponsorProviderBrand;
- displayName: string;
- providerName: string;
- affiliateUrl?: string;
- dashboardUrl?: string;
- protocols: readonly SponsorProtocol[];
- protocolLabels: readonly string[];
- defaultProtocol: SponsorProtocol;
- baseUrlOptions: readonly SponsorBaseUrlOption[];
- supportsUsageCheck: boolean;
- resolveBaseUrl: (value: string | undefined | null) => string;
- getProtocolUrls: (value: string | undefined | null) => SponsorProtocolUrls;
-}
-
-const SPONSOR_DEFINITIONS: Record = {
- apikeyFun: {
- brand: 'apikeyFun',
- displayName: APIKEY_FUN_DISPLAY_NAME,
- providerName: APIKEY_FUN_PROVIDER_NAME,
- affiliateUrl: APIKEY_FUN_AFFILIATE_URL,
- dashboardUrl: APIKEY_FUN_DASHBOARD_URL,
- protocols: ['codex', 'claude', 'openai'],
- protocolLabels: APIKEY_FUN_PROTOCOLS,
- defaultProtocol: 'codex',
- baseUrlOptions: APIKEY_FUN_BASE_URL_OPTIONS,
- supportsUsageCheck: true,
- resolveBaseUrl: resolveApiKeyFunBaseUrl,
- getProtocolUrls: getApiKeyFunProtocolUrls,
- },
- code0: {
- brand: 'code0',
- displayName: CODE0_DISPLAY_NAME,
- providerName: CODE0_PROVIDER_NAME,
- affiliateUrl: CODE0_AFFILIATE_URL,
- protocols: ['openai', 'claude', 'gemini', 'codex'],
- protocolLabels: CODE0_PROTOCOL_LABELS,
- defaultProtocol: 'openai',
- baseUrlOptions: CODE0_BASE_URL_OPTIONS,
- supportsUsageCheck: false,
- resolveBaseUrl: resolveCode0BaseUrl,
- getProtocolUrls: getCode0ProtocolUrls,
- },
- fennoAI: {
- brand: 'fennoAI',
- displayName: FENNO_AI_DISPLAY_NAME,
- providerName: FENNO_AI_PROVIDER_NAME,
- affiliateUrl: FENNO_AI_AFFILIATE_URL,
- protocols: ['codex', 'claude'],
- protocolLabels: FENNO_AI_PROTOCOL_LABELS,
- defaultProtocol: 'codex',
- baseUrlOptions: FENNO_AI_BASE_URL_OPTIONS,
- supportsUsageCheck: false,
- resolveBaseUrl: resolveFennoAIBaseUrl,
- getProtocolUrls: getFennoAIProtocolUrls,
- },
- qiniuCloud: {
- brand: 'qiniuCloud',
- displayName: QINIU_CLOUD_DISPLAY_NAME,
- providerName: QINIU_CLOUD_PROVIDER_NAME,
- affiliateUrl: QINIU_CLOUD_AFFILIATE_URL,
- protocols: ['openai', 'claude', 'gemini', 'codex'],
- protocolLabels: QINIU_CLOUD_PROTOCOL_LABELS,
- defaultProtocol: 'openai',
- baseUrlOptions: QINIU_CLOUD_BASE_URL_OPTIONS,
- supportsUsageCheck: false,
- resolveBaseUrl: resolveQiniuCloudBaseUrl,
- getProtocolUrls: getQiniuCloudProtocolUrls,
- },
- lmuAI: {
- brand: 'lmuAI',
- displayName: LMU_AI_DISPLAY_NAME,
- providerName: LMU_AI_PROVIDER_NAME,
- affiliateUrl: LMU_AI_AFFILIATE_URL,
- protocols: ['openai', 'claude', 'gemini', 'codex'],
- protocolLabels: LMU_AI_PROTOCOL_LABELS,
- defaultProtocol: 'openai',
- baseUrlOptions: LMU_AI_BASE_URL_OPTIONS,
- supportsUsageCheck: false,
- resolveBaseUrl: resolveLmuAIBaseUrl,
- getProtocolUrls: getLmuAIProtocolUrls,
- },
- infistar: {
- brand: 'infistar',
- displayName: INFISTAR_DISPLAY_NAME,
- providerName: INFISTAR_PROVIDER_NAME,
- affiliateUrl: INFISTAR_AFFILIATE_URL,
- protocols: ['openai', 'claude', 'gemini', 'codex'],
- protocolLabels: INFISTAR_PROTOCOL_LABELS,
- defaultProtocol: 'openai',
- baseUrlOptions: INFISTAR_BASE_URL_OPTIONS,
- supportsUsageCheck: false,
- resolveBaseUrl: resolveInfistarBaseUrl,
- getProtocolUrls: getInfistarProtocolUrls,
- },
- kimi: {
- brand: 'kimi',
- displayName: KIMI_DISPLAY_NAME,
- providerName: KIMI_PROVIDER_NAME,
- protocols: ['openai', 'claude'],
- protocolLabels: KIMI_PROTOCOL_LABELS,
- defaultProtocol: 'openai',
- baseUrlOptions: KIMI_BASE_URL_OPTIONS,
- supportsUsageCheck: false,
- resolveBaseUrl: resolveKimiBaseUrl,
- getProtocolUrls: getKimiProtocolUrls,
- },
-};
-
-export const isMultiProtocolSponsorBrand = (brand: ProviderBrand): brand is SponsorProviderBrand =>
- brand === 'apikeyFun' ||
- brand === 'code0' ||
- brand === 'fennoAI' ||
- brand === 'qiniuCloud' ||
- brand === 'lmuAI' ||
- brand === 'infistar' ||
- brand === 'kimi';
-
-/**
- * 临时隐藏的赞助商品牌:入口从提供商列表隐藏,其配置改由对应协议分组
- * (codex/claude/gemini/openaiCompatibility)直接显示与管理。
- * 以后恢复时,把对应 brand 从集合中删除即可。
- */
-export const TEMPORARILY_HIDDEN_SPONSOR_BRANDS: ReadonlySet = new Set([]);
-
-export const isTemporarilyHiddenSponsorBrand = (brand: ProviderBrand): boolean =>
- TEMPORARILY_HIDDEN_SPONSOR_BRANDS.has(brand as SponsorProviderBrand);
-
-export type SponsorAggregationConflict = 'multiple-configs' | 'multiple-openai-keys';
-
-export const getSponsorAggregationConflict = (
- raw: SponsorProviderRaw | null | undefined
-): SponsorAggregationConflict | null => {
- if (!raw) return null;
- if (
- raw.openai.length > 1 ||
- raw.claude.length > 1 ||
- raw.codex.length > 1 ||
- raw.gemini.length > 1
- ) {
- return 'multiple-configs';
- }
-
- const openAIKeyCount = raw.openai.reduce(
- (count, item) =>
- count + (item.config.apiKeyEntries ?? []).filter((entry) => entry.apiKey?.trim()).length,
- 0
- );
- return openAIKeyCount > 1 ? 'multiple-openai-keys' : null;
-};
-
-export const getSponsorProviderDefinition = (
- brand: SponsorProviderBrand
-): SponsorProviderDefinition => SPONSOR_DEFINITIONS[brand];
-
-export const sponsorProtocolI18nKey = (
- protocol: SponsorProtocol
-): 'openai' | 'codexResponses' | 'anthropic' | 'gemini' => {
- if (protocol === 'claude') return 'anthropic';
- if (protocol === 'codex') return 'codexResponses';
- return protocol;
-};
-
-export const sponsorProtocolModelI18nKey = (
- protocol: SponsorProtocol
-): 'openai' | 'codex' | 'anthropic' | 'gemini' => {
- if (protocol === 'claude') return 'anthropic';
- return protocol;
-};
-
-export const discoveryBrandForSponsorProtocol = (protocol: SponsorProtocol): ProviderBrand =>
- protocol === 'openai' ? 'openaiCompatibility' : protocol;
-
-export const sponsorProtocolUrl = (
- urls: SponsorProtocolUrls,
- protocol: SponsorProtocol
-): string => {
- if (protocol === 'claude') return urls.anthropic;
- if (protocol === 'codex') return urls.codex;
- if (protocol === 'gemini') return urls.gemini;
- return urls.openai;
-};
diff --git a/frontend/src/features/providers/sponsorMutationRecovery.ts b/frontend/src/features/providers/sponsorMutationRecovery.ts
deleted file mode 100644
index 7e60cca..0000000
--- a/frontend/src/features/providers/sponsorMutationRecovery.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export class SponsorPartialMutationError extends Error {
- readonly cause: unknown;
-
- constructor(cause: unknown) {
- super(cause instanceof Error ? cause.message : String(cause ?? 'Sponsor mutation failed'));
- this.name = 'SponsorPartialMutationError';
- this.cause = cause;
- }
-}
-
-export const isSponsorPartialMutationError = (
- error: unknown
-): error is SponsorPartialMutationError => error instanceof SponsorPartialMutationError;
-
-export async function runSponsorMutationWithRecovery(
- action: () => Promise,
- refresh: () => Promise
-): Promise {
- try {
- return await action();
- } catch (error: unknown) {
- try {
- await refresh();
- } catch {
- // Preserve the original mutation error; refresh is best-effort recovery.
- }
- throw new SponsorPartialMutationError(error);
- }
-}
diff --git a/frontend/src/features/providers/thinkingLevels.ts b/frontend/src/features/providers/thinkingLevels.ts
deleted file mode 100644
index c0cf4f6..0000000
--- a/frontend/src/features/providers/thinkingLevels.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-export const THINKING_LEVELS = [
- 'none',
- 'minimal',
- 'low',
- 'medium',
- 'high',
- 'xhigh',
- 'max',
- 'auto',
-] as const;
-
-export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
-
-const THINKING_LEVEL_SET = new Set(THINKING_LEVELS);
-const SERIALIZED_LEVEL_ORDER: readonly ThinkingLevel[] = [
- 'minimal',
- 'low',
- 'medium',
- 'high',
- 'xhigh',
- 'max',
- 'none',
- 'auto',
-];
-
-const isRecord = (value: unknown): value is Record =>
- value !== null && typeof value === 'object' && !Array.isArray(value);
-
-export const readThinkingLevels = (value: unknown): ThinkingLevel[] => {
- if (!isRecord(value)) return [];
-
- const selected = new Set();
- if (Array.isArray(value.levels)) {
- value.levels.forEach((rawLevel) => {
- if (typeof rawLevel !== 'string') return;
- const level = rawLevel.trim().toLowerCase();
- if (THINKING_LEVEL_SET.has(level)) selected.add(level as ThinkingLevel);
- });
- }
- if (value.zero_allowed === true) selected.add('none');
- if (value.dynamic_allowed === true) selected.add('auto');
-
- return THINKING_LEVELS.filter((level) => selected.has(level));
-};
-
-export const buildThinkingFromLevels = (
- levels: readonly ThinkingLevel[] | undefined
-): Record | undefined => {
- if (!levels?.length) return undefined;
- const selected = new Set(levels);
- return {
- levels: SERIALIZED_LEVEL_ORDER.filter((level) => selected.has(level)),
- };
-};
diff --git a/frontend/src/features/providers/types.ts b/frontend/src/features/providers/types.ts
deleted file mode 100644
index d16d6f1..0000000
--- a/frontend/src/features/providers/types.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-/**
- * AI 提供商 Workbench 视图模型(归一化各 brand 的异构 config)
- */
-
-import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import type { ThinkingLevel } from './thinkingLevels';
-
-export type ProviderBrand =
- | 'gemini'
- | 'interactions'
- | 'codex'
- | 'xai'
- | 'claude'
- | 'claudeApi'
- | 'vertex'
- | 'openaiCompatibility'
- | 'apikeyFun'
- | 'code0'
- | 'fennoAI'
- | 'qiniuCloud'
- | 'lmuAI'
- | 'infistar'
- | 'kimi';
-
-export type SponsorProviderBrand =
- 'apikeyFun' | 'code0' | 'fennoAI' | 'qiniuCloud' | 'lmuAI' | 'infistar' | 'kimi';
-
-export const PROVIDER_SORT_BY_VALUES = ['name', 'priority', 'recent-success'] as const;
-export type ProviderSortBy = (typeof PROVIDER_SORT_BY_VALUES)[number];
-
-export const SORT_DIR_VALUES = ['asc', 'desc'] as const;
-export type SortDir = (typeof SORT_DIR_VALUES)[number];
-
-export type ProviderResourceSelector =
- | { brand: 'gemini'; apiKey: string; baseUrl?: string; index: number }
- | { brand: 'interactions'; apiKey: string; baseUrl?: string; index: number }
- | { brand: 'codex'; apiKey: string; baseUrl?: string; index: number }
- | { brand: 'xai'; apiKey: string; baseUrl?: string; index: number }
- | { brand: 'claude'; apiKey: string; baseUrl?: string; index: number }
- | { brand: 'claudeApi'; apiKey: string; baseUrl?: string; index: number }
- | { brand: 'vertex'; apiKey: string; baseUrl?: string; index: number }
- | { brand: 'openaiCompatibility'; name: string; index: number }
- | {
- brand: 'apikeyFun';
- openaiIndices: number[];
- claudeIndices: number[];
- codexIndices: number[];
- geminiIndices: number[];
- }
- | {
- brand: 'code0';
- openaiIndices: number[];
- claudeIndices: number[];
- codexIndices: number[];
- geminiIndices: number[];
- }
- | {
- brand: 'fennoAI';
- openaiIndices: number[];
- claudeIndices: number[];
- codexIndices: number[];
- geminiIndices: number[];
- }
- | {
- brand: 'qiniuCloud';
- openaiIndices: number[];
- claudeIndices: number[];
- codexIndices: number[];
- geminiIndices: number[];
- }
- | {
- brand: 'lmuAI';
- openaiIndices: number[];
- claudeIndices: number[];
- codexIndices: number[];
- geminiIndices: number[];
- }
- | {
- brand: 'infistar';
- openaiIndices: number[];
- claudeIndices: number[];
- codexIndices: number[];
- geminiIndices: number[];
- }
- | {
- brand: 'kimi';
- openaiIndices: number[];
- claudeIndices: number[];
- codexIndices: number[];
- geminiIndices: number[];
- };
-
-export interface ProviderResourceFlags {
- cloakEnabled?: boolean;
- websockets?: boolean;
- protocols?: string[];
-}
-
-export interface ProviderResource {
- /** 稳定 id,用作 React key 与选中态判断 */
- id: string;
- brand: ProviderBrand;
- /** 在原数组中的下标 */
- originalIndex: number;
- /** 表格 key 列显示名(OpenAI=name,其余=null) */
- name: string | null;
- /** 备用展示文字(API 密钥脱敏或 fallback) */
- identifier: string;
- /** apiKey 脱敏预览,展示用 */
- apiKeyPreview: string | null;
- /** 用于 selector 的真实 apiKey;OpenAI 因为多密钥这里返回 null */
- apiKey: string | null;
- authIndex: string | null;
- baseUrl: string | null;
- proxyUrl: string | null;
- prefix: string | null;
- modelCount: number;
- /** 去重后的模型名, 供筛选/搜索用 */
- models: string[];
- /** 排序用优先级,未配置时为 0 */
- priority: number;
- headerCount: number;
- excludedModelCount: number;
- /** 仅 OpenAI 有意义,其它 brand 该字段不展示但保留 */
- apiKeyEntryCount: number;
- /** 是否被禁用(各 brand 判定规则不同) */
- disabled: boolean;
- /** 额外能力旗标 */
- flags: ProviderResourceFlags;
- /** 删除/更新使用的 selector */
- selector: ProviderResourceSelector;
- /** 原始 raw config,Sheet 表单初始化用 */
- raw: unknown;
-}
-
-export interface ProviderGroup {
- id: ProviderBrand;
- resources: ProviderResource[];
-}
-
-export interface ProviderSnapshot {
- fetchedAt: string;
- groups: ProviderGroup[];
-}
-
-export interface SponsorProviderRaw {
- openai: Array<{ config: OpenAIProviderConfig; index: number }>;
- claude: Array<{ config: ProviderKeyConfig; index: number }>;
- codex: Array<{ config: ProviderKeyConfig; index: number }>;
- gemini: Array<{ config: GeminiKeyConfig; index: number }>;
-}
-
-/**
- * 通用 Sheet 表单值。
- * Gemini/Codex/Claude/Vertex/OpenAI 共用基础字段,各自启用 advanced 区。
- */
-export interface ModelEntryInput {
- name: string;
- alias?: string;
- priority?: number;
- testModel?: string;
- image?: boolean;
- /** Original backend value, preserved until the standard-level selector is changed. */
- thinkingJson?: string;
- thinkingLevels?: ThinkingLevel[];
- thinkingLevelsTouched?: boolean;
-}
-
-export type SponsorProtocol = 'openai' | 'codex' | 'claude' | 'gemini';
-
-export interface SponsorKeyEntryInput {
- protocol: SponsorProtocol;
- apiKey: string;
- existingApiKey?: string;
- baseUrl: string;
- proxyUrl: string;
- prefix: string;
- disabled: boolean;
- disableCooling?: boolean;
- priority?: number;
- weight?: number;
- models: ModelEntryInput[];
-}
-
-export interface ApiKeyEntryInput {
- apiKey: string;
- existingApiKey?: string;
- proxyUrl: string;
- weight?: number;
- authIndex?: string;
-}
-
-export interface CloakInput {
- mode: string;
- strictMode: boolean;
- sensitiveWordsText: string;
- cacheUserId: boolean;
-}
-
-export interface ProviderEntryFormInput {
- /** OpenAI 创建时只在 apiKeyEntries 中传 */
- apiKey: string;
- /** OpenAI 必填,其余 brand 不展示 */
- name: string;
- baseUrl: string;
- proxyUrl: string;
- prefix: string;
- disabled: boolean;
- disableCooling?: boolean;
- priority?: number;
- weight?: number;
-
- /** 高级折叠区 */
- models: ModelEntryInput[];
- headers: Array<{ key: string; value: string }>;
- excludedModelsText: string;
-
- /** Codex 专属 */
- websockets?: boolean;
- /** Claude 专属 */
- cloak?: CloakInput;
- experimentalCchSigning?: boolean;
- /** OpenAI persists this; Gemini/Claude use it for one-off connectivity tests. */
- testModel?: string;
- apiKeyEntries?: ApiKeyEntryInput[];
- /** APIKEY.FUN stores one grouped key per platform protocol. */
- sponsorKeyEntries?: SponsorKeyEntryInput[];
-}
diff --git a/frontend/src/features/providers/uiState.ts b/frontend/src/features/providers/uiState.ts
deleted file mode 100644
index 15ee855..0000000
--- a/frontend/src/features/providers/uiState.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { isRecord } from '@/utils/helpers';
-import { PROVIDER_BRAND_ORDER } from './descriptors';
-import {
- PROVIDER_SORT_BY_VALUES,
- SORT_DIR_VALUES,
- type ProviderBrand,
- type ProviderSortBy,
- type SortDir,
-} from './types';
-
-const PROVIDERS_UI_STATE_KEY = 'providersPage.uiState';
-const DEFAULT_ACTIVE_BRAND: ProviderBrand = 'gemini';
-const DEFAULT_PROVIDER_FILTER_STATE: ProviderFilterState = {
- filter: '',
- sortBy: 'name',
- sortDir: 'asc',
- selectedModels: [],
-};
-
-const PROVIDER_BRAND_SET = new Set(PROVIDER_BRAND_ORDER);
-const PROVIDER_SORT_BY_SET = new Set(PROVIDER_SORT_BY_VALUES);
-const SORT_DIR_SET = new Set(SORT_DIR_VALUES);
-
-export interface ProviderFilterState {
- filter: string;
- sortBy: ProviderSortBy;
- sortDir: SortDir;
- selectedModels: string[];
-}
-
-export interface ProvidersWorkbenchUiState {
- activeBrand: ProviderBrand;
- filtersByBrand: Partial>;
-}
-
-const isProviderBrand = (value: unknown): value is ProviderBrand =>
- typeof value === 'string' && PROVIDER_BRAND_SET.has(value as ProviderBrand);
-
-const isProviderSortBy = (value: unknown): value is ProviderSortBy =>
- typeof value === 'string' && PROVIDER_SORT_BY_SET.has(value as ProviderSortBy);
-
-const isSortDir = (value: unknown): value is SortDir =>
- typeof value === 'string' && SORT_DIR_SET.has(value as SortDir);
-
-const normalizeSelectedModels = (value: unknown): string[] => {
- if (!Array.isArray(value)) return [];
- const seen = new Set();
- value.forEach((item) => {
- if (typeof item !== 'string') return;
- const name = item.trim();
- if (!name) return;
- seen.add(name);
- });
- return Array.from(seen);
-};
-
-const normalizeProviderFilterState = (value: unknown): ProviderFilterState => {
- if (!isRecord(value)) return { ...DEFAULT_PROVIDER_FILTER_STATE };
- return {
- filter: typeof value.filter === 'string' ? value.filter : '',
- sortBy: isProviderSortBy(value.sortBy) ? value.sortBy : 'name',
- sortDir: isSortDir(value.sortDir) ? value.sortDir : 'asc',
- selectedModels: normalizeSelectedModels(value.selectedModels),
- };
-};
-
-const createDefaultProvidersWorkbenchUiState = (): ProvidersWorkbenchUiState => ({
- activeBrand: DEFAULT_ACTIVE_BRAND,
- filtersByBrand: {},
-});
-
-export const getProviderFilterState = (
- state: ProvidersWorkbenchUiState,
- brand: ProviderBrand
-): ProviderFilterState => state.filtersByBrand[brand] ?? DEFAULT_PROVIDER_FILTER_STATE;
-
-export const readProvidersWorkbenchUiState = (): ProvidersWorkbenchUiState => {
- if (typeof window === 'undefined') return createDefaultProvidersWorkbenchUiState();
-
- try {
- const raw = window.localStorage.getItem(PROVIDERS_UI_STATE_KEY);
- if (!raw) return createDefaultProvidersWorkbenchUiState();
-
- const parsed = JSON.parse(raw);
- if (!isRecord(parsed)) return createDefaultProvidersWorkbenchUiState();
-
- const source = isRecord(parsed.filtersByBrand) ? parsed.filtersByBrand : {};
- const filtersByBrand: ProvidersWorkbenchUiState['filtersByBrand'] = {};
- PROVIDER_BRAND_ORDER.forEach((brand) => {
- const filterState = source[brand];
- if (filterState !== undefined) {
- filtersByBrand[brand] = normalizeProviderFilterState(filterState);
- }
- });
-
- return {
- activeBrand: isProviderBrand(parsed.activeBrand) ? parsed.activeBrand : DEFAULT_ACTIVE_BRAND,
- filtersByBrand,
- };
- } catch {
- return createDefaultProvidersWorkbenchUiState();
- }
-};
-
-export const writeProvidersWorkbenchUiState = (state: ProvidersWorkbenchUiState) => {
- if (typeof window === 'undefined') return;
- try {
- window.localStorage.setItem(PROVIDERS_UI_STATE_KEY, JSON.stringify(state));
- } catch {
- // ignore storage failures
- }
-};
diff --git a/frontend/src/features/providers/useProviderWorkbench.ts b/frontend/src/features/providers/useProviderWorkbench.ts
deleted file mode 100644
index ea7800c..0000000
--- a/frontend/src/features/providers/useProviderWorkbench.ts
+++ /dev/null
@@ -1,1003 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { providersApi } from '@/services/api';
-import { getErrorMessage } from '@/utils/helpers';
-import { useAuthStore, useConfigStore } from '@/stores';
-import {
- stripDisableAllModelsRule,
- withDisableAllModelsRule,
- withoutDisableAllModelsRule,
-} from '@/components/providers/utils';
-import type { GeminiKeyConfig, ModelAlias, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
-import {
- apiKeyFunToResource,
- claudeApiToResource,
- claudeToResource,
- code0ToResource,
- codexToResource,
- fennoAIToResource,
- geminiToResource,
- interactionsToResource,
- openaiToResource,
- qiniuCloudToResource,
- lmuAIToResource,
- infistarToResource,
- kimiToResource,
- vertexToResource,
- xaiToResource,
-} from './adapters';
-import { PROVIDER_BRAND_ORDER } from './descriptors';
-import { buildThinkingFromLevels } from './thinkingLevels';
-import type {
- ProviderBrand,
- ProviderEntryFormInput,
- ProviderGroup,
- ProviderResource,
- ProviderSnapshot,
- SponsorKeyEntryInput,
- SponsorProviderBrand,
- SponsorProviderRaw,
-} from './types';
-import {
- buildApiKeyFunRaw,
- isApiKeyFunClaudeProvider,
- isApiKeyFunCodexProvider,
- isApiKeyFunOpenAIProvider,
-} from './sponsor';
-import { CLAUDE_API_BASE_URL, isClaudeApiProvider } from './claudeApi';
-import {
- buildCode0Raw,
- isCode0ClaudeProvider,
- isCode0CodexProvider,
- isCode0GeminiProvider,
- isCode0OpenAIProvider,
-} from './code0';
-import { buildFennoAIRaw, isFennoAIClaudeProvider, isFennoAICodexProvider } from './fennoAI';
-import {
- buildQiniuCloudRaw,
- isQiniuCloudClaudeProvider,
- isQiniuCloudCodexProvider,
- isQiniuCloudGeminiProvider,
- isQiniuCloudOpenAIProvider,
-} from './qiniuCloud';
-import {
- buildLmuAIRaw,
- isLmuAIClaudeProvider,
- isLmuAICodexProvider,
- isLmuAIGeminiProvider,
- isLmuAIOpenAIProvider,
-} from './lmuAI';
-import {
- buildInfistarRaw,
- isInfistarClaudeProvider,
- isInfistarCodexProvider,
- isInfistarGeminiProvider,
- isInfistarOpenAIProvider,
-} from './infistar';
-import { buildKimiRaw, isKimiClaudeProvider, isKimiOpenAIProvider } from './kimi';
-import {
- getSponsorProviderDefinition,
- isTemporarilyHiddenSponsorBrand,
- TEMPORARILY_HIDDEN_SPONSOR_BRANDS,
- type SponsorProtocolUrls,
-} from './sponsorDefinitions';
-import { runSponsorMutationWithRecovery } from './sponsorMutationRecovery';
-
-export interface UseProviderWorkbenchResult {
- connected: boolean;
- isPending: boolean;
- isFetching: boolean;
- isError: boolean;
- errorMessage: string | null;
- snapshot: ProviderSnapshot | null;
- refetch: () => Promise;
-
- createProvider: (brand: ProviderBrand, input: ProviderEntryFormInput) => Promise;
- updateProvider: (resource: ProviderResource, input: ProviderEntryFormInput) => Promise;
- deleteProvider: (resource: ProviderResource) => Promise;
- toggleDisabled: (resource: ProviderResource, disabled: boolean) => Promise;
- mutating: boolean;
- refreshSnapshot: () => void;
-}
-
-/* -------------------------------------------------------------------------- */
-/* form -> backend config 转换 */
-/* -------------------------------------------------------------------------- */
-
-const parseTextList = (text: string): string[] =>
- text
- .split(/[\n,]+/)
- .map((item) => item.trim())
- .filter(Boolean);
-
-const headersFromEntries = (
- entries: Array<{ key: string; value: string }>
-): Record => {
- const out: Record = {};
- entries.forEach((entry) => {
- const key = entry.key.trim();
- if (!key) return;
- out[key] = entry.value;
- });
- return out;
-};
-
-const parseThinkingJson = (value: string | undefined): Record | undefined => {
- const trimmed = (value ?? '').trim();
- if (!trimmed) return undefined;
- const parsed = JSON.parse(trimmed) as unknown;
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
- throw new Error('Thinking config must be a JSON object');
- }
- return parsed as Record;
-};
-
-/**
- * `'*'` 是「该 provider 已停用」的编码,其唯一所有者是 `form.disabled`:
- * 载入时 `stripDisableAllModelsRule` 把它剥进该 flag,保存时仅凭该 flag 重新追加。
- * 因此这里必须过滤掉用户在文本里手打的 `'*'`——排除模型的编辑面永远不该能开关停用。
- * 导出仅为让 tests/providerExcludedModelsDisableRule.test.ts 钉住这个不变量。
- */
-export const buildExcludedModels = (
- textValue: string,
- disabled: boolean,
- brand: ProviderBrand
-): string[] | undefined => {
- const list = parseTextList(textValue);
- const filtered = list.filter((v) => v !== '*');
- if (brand === 'openaiCompatibility') {
- return filtered.length ? filtered : undefined;
- }
- if (disabled) {
- return withDisableAllModelsRule(filtered);
- }
- return filtered.length ? filtered : undefined;
-};
-
-const buildModelAliases = (
- models: ProviderEntryFormInput['models'] | undefined,
- includeImage = false
-): ModelAlias[] =>
- (models ?? [])
- .map((m) => {
- const entry: ModelAlias = {
- name: m.name.trim(),
- alias: m.alias?.trim() || undefined,
- priority: m.priority,
- testModel: m.testModel,
- thinking: m.thinkingLevelsTouched
- ? buildThinkingFromLevels(m.thinkingLevels)
- : parseThinkingJson(m.thinkingJson),
- };
- if (includeImage) {
- entry.image = m.image === true;
- }
- return entry;
- })
- .filter((m) => m.name);
-
-const buildProviderKeyConfig = (
- brand: 'gemini' | 'interactions' | 'codex' | 'xai' | 'claude' | 'vertex',
- input: ProviderEntryFormInput,
- existing?: ProviderKeyConfig | GeminiKeyConfig | null
-): ProviderKeyConfig | GeminiKeyConfig => {
- const headers = headersFromEntries(input.headers);
- const models = buildModelAliases(input.models);
- const excluded = buildExcludedModels(input.excludedModelsText, input.disabled, brand);
- const apiKeyChanged = input.apiKey.trim().length > 0;
- const next: ProviderKeyConfig = {
- apiKey: apiKeyChanged ? input.apiKey.trim() : (existing?.apiKey ?? ''),
- priority: input.priority,
- weight: input.weight,
- prefix: input.prefix.trim() || undefined,
- baseUrl: input.baseUrl.trim() || undefined,
- proxyUrl: input.proxyUrl.trim() || undefined,
- models: models.length ? models : undefined,
- headers: Object.keys(headers).length ? headers : undefined,
- excludedModels: excluded,
- disableCooling: input.disableCooling === true,
- authIndex: existing?.authIndex,
- };
- if ((brand === 'codex' || brand === 'xai') && input.websockets !== undefined) {
- next.websockets = input.websockets;
- }
- if (brand === 'claude' && input.cloak) {
- next.cloak = {
- mode: input.cloak.mode.trim() || undefined,
- strictMode: input.cloak.strictMode,
- sensitiveWords: parseTextList(input.cloak.sensitiveWordsText),
- cacheUserId: input.cloak.cacheUserId === true,
- };
- }
- if (brand === 'claude') {
- next.experimentalCchSigning = input.experimentalCchSigning === true;
- }
- return next;
-};
-
-const buildClaudeApiConfig = (
- input: ProviderEntryFormInput,
- existing?: ProviderKeyConfig | null
-): ProviderKeyConfig =>
- buildProviderKeyConfig(
- 'claude',
- {
- ...input,
- baseUrl: CLAUDE_API_BASE_URL,
- },
- existing
- ) as ProviderKeyConfig;
-
-const buildOpenAIConfig = (
- input: ProviderEntryFormInput,
- existing?: OpenAIProviderConfig | null
-): OpenAIProviderConfig => {
- const headers = headersFromEntries(input.headers);
- const models = buildModelAliases(input.models, true);
- const apiKeyEntries =
- input.apiKeyEntries
- ?.map((entry, index) => {
- const fallbackApiKey =
- entry.existingApiKey?.trim() || existing?.apiKeyEntries?.[index]?.apiKey?.trim() || '';
- return {
- apiKey: entry.apiKey.trim() || fallbackApiKey,
- proxyUrl: entry.proxyUrl.trim() || undefined,
- weight: entry.weight,
- authIndex: entry.authIndex?.trim() || undefined,
- };
- })
- .filter((entry) => entry.apiKey) ?? [];
-
- return {
- ...(existing ?? {}),
- name: input.name.trim(),
- baseUrl: input.baseUrl.trim(),
- prefix: input.prefix.trim() || undefined,
- apiKeyEntries,
- disabled: input.disabled,
- disableCooling: input.disableCooling === true,
- headers: Object.keys(headers).length ? headers : undefined,
- models: models.length ? models : undefined,
- priority: input.priority,
- testModel: input.testModel?.trim() || undefined,
- };
-};
-
-const sponsorEntryApiKey = (entry: SponsorKeyEntryInput): string =>
- entry.apiKey.trim() || entry.existingApiKey?.trim() || '';
-
-const buildSponsorOpenAIConfig = (
- entry: SponsorKeyEntryInput,
- providerName: string,
- getProtocolUrls: (value: string | undefined | null) => SponsorProtocolUrls,
- existing?: OpenAIProviderConfig
-): OpenAIProviderConfig => {
- const urls = getProtocolUrls(entry.baseUrl);
- const models = buildModelAliases(entry.models, true);
- const apiKey = sponsorEntryApiKey(entry);
- const firstExistingEntry = existing?.apiKeyEntries?.[0];
- const apiKeyEntries = apiKey
- ? [
- {
- ...(firstExistingEntry ?? {}),
- apiKey,
- proxyUrl: entry.proxyUrl.trim() || undefined,
- weight: entry.weight,
- },
- ]
- : [];
-
- return {
- ...(existing ?? {}),
- name: providerName,
- baseUrl: urls.openai,
- prefix: entry.prefix.trim() || undefined,
- disabled: entry.disabled,
- disableCooling: entry.disableCooling === true,
- priority: entry.priority,
- apiKeyEntries,
- models: models.length ? models : undefined,
- };
-};
-
-const buildSponsorProviderKeyConfig = (
- entry: SponsorKeyEntryInput,
- protocol: 'claude' | 'codex',
- getProtocolUrls: (value: string | undefined | null) => SponsorProtocolUrls,
- existing?: ProviderKeyConfig
-): ProviderKeyConfig => {
- const urls = getProtocolUrls(entry.baseUrl);
- const models = buildModelAliases(entry.models);
- const apiKey = sponsorEntryApiKey(entry);
- const excluded = entry.disabled
- ? withDisableAllModelsRule(stripDisableAllModelsRule(existing?.excludedModels))
- : withoutDisableAllModelsRule(existing?.excludedModels);
-
- return {
- ...(existing ?? {}),
- apiKey,
- baseUrl: protocol === 'claude' ? urls.anthropic : urls.codex,
- proxyUrl: entry.proxyUrl.trim() || undefined,
- prefix: entry.prefix.trim() || undefined,
- priority: entry.priority,
- weight: entry.weight,
- disableCooling: entry.disableCooling === true,
- excludedModels: excluded,
- models: models.length ? models : undefined,
- };
-};
-
-const buildSponsorGeminiConfig = (
- entry: SponsorKeyEntryInput,
- getProtocolUrls: (value: string | undefined | null) => SponsorProtocolUrls,
- existing?: GeminiKeyConfig
-): GeminiKeyConfig => {
- const urls = getProtocolUrls(entry.baseUrl);
- const models = buildModelAliases(entry.models);
- const apiKey = sponsorEntryApiKey(entry);
- const excluded = entry.disabled
- ? withDisableAllModelsRule(stripDisableAllModelsRule(existing?.excludedModels))
- : withoutDisableAllModelsRule(existing?.excludedModels);
-
- return {
- ...(existing ?? {}),
- apiKey,
- baseUrl: urls.gemini,
- proxyUrl: entry.proxyUrl.trim() || undefined,
- prefix: entry.prefix.trim() || undefined,
- priority: entry.priority,
- weight: entry.weight,
- disableCooling: entry.disableCooling === true,
- excludedModels: excluded,
- models: models.length ? models : undefined,
- };
-};
-
-const normalizeSponsorKeyEntries = (
- entries: SponsorKeyEntryInput[] | undefined
-): SponsorKeyEntryInput[] => (entries ?? []).filter((entry) => sponsorEntryApiKey(entry));
-
-const toggleSponsorConfig = async (raw: SponsorProviderRaw, disabled: boolean) => {
- for (const item of raw.gemini) {
- const excludedModels = disabled
- ? withDisableAllModelsRule(item.config.excludedModels)
- : withoutDisableAllModelsRule(item.config.excludedModels);
- await providersApi.updateGeminiKey(item.config.apiKey, item.config.baseUrl, {
- ...item.config,
- excludedModels,
- });
- }
- for (const item of raw.codex) {
- const excludedModels = disabled
- ? withDisableAllModelsRule(item.config.excludedModels)
- : withoutDisableAllModelsRule(item.config.excludedModels);
- await providersApi.updateCodexConfig(item.config.apiKey, item.config.baseUrl, {
- ...item.config,
- excludedModels,
- });
- }
- for (const item of raw.claude) {
- const excludedModels = disabled
- ? withDisableAllModelsRule(item.config.excludedModels)
- : withoutDisableAllModelsRule(item.config.excludedModels);
- await providersApi.updateClaudeConfig(item.config.apiKey, item.config.baseUrl, {
- ...item.config,
- excludedModels,
- });
- }
- for (const item of raw.openai) {
- await providersApi.updateOpenAIProviderDisabled(item.index, disabled);
- }
-};
-
-/* -------------------------------------------------------------------------- */
-/* hook */
-/* -------------------------------------------------------------------------- */
-
-export function useProviderWorkbench(): UseProviderWorkbenchResult {
- const connectionStatus = useAuthStore((s) => s.connectionStatus);
- const config = useConfigStore((s) => s.config);
- const fetchConfig = useConfigStore((s) => s.fetchConfig);
- const updateConfigValue = useConfigStore((s) => s.updateConfigValue);
- const isCacheValid = useConfigStore((s) => s.isCacheValid);
-
- const [isPending, setIsPending] = useState(() => !isCacheValid());
- const [isFetching, setIsFetching] = useState(false);
- const [errorMessage, setErrorMessage] = useState(null);
- const [mutating, setMutating] = useState(false);
- const [fetchedAt, setFetchedAt] = useState(() => new Date().toISOString());
-
- const hasFetchedRef = useRef(false);
-
- const connected = connectionStatus === 'connected';
-
- const refetch = useCallback(async () => {
- setIsFetching(true);
- setErrorMessage(null);
- try {
- const [configResult, vertexResult, openaiResult] = await Promise.allSettled([
- fetchConfig(true),
- providersApi.getVertexConfigs(),
- providersApi.getOpenAIProviders(),
- ]);
- if (configResult.status !== 'fulfilled') {
- throw configResult.reason;
- }
- if (vertexResult.status === 'fulfilled') {
- updateConfigValue('vertex-api-key', vertexResult.value || []);
- }
- if (openaiResult.status === 'fulfilled') {
- updateConfigValue('openai-compatibility', openaiResult.value || []);
- }
- setFetchedAt(new Date().toISOString());
- } catch (err) {
- setErrorMessage(getErrorMessage(err) || 'Failed to load providers');
- } finally {
- setIsPending(false);
- setIsFetching(false);
- }
- }, [fetchConfig, updateConfigValue]);
-
- const refreshSnapshot = useCallback(() => {
- setFetchedAt(new Date().toISOString());
- }, []);
-
- useEffect(() => {
- if (hasFetchedRef.current) return;
- if (!connected) return;
- hasFetchedRef.current = true;
- refetch().catch(() => {});
- }, [connected, refetch]);
-
- /* ------------------- snapshot 计算 ------------------- */
-
- const snapshot = useMemo(() => {
- if (!config) return null;
- // 临时隐藏的赞助商:不排除其协议配置,让各协议分组接管显示(见 sponsorDefinitions.ts)
- const fennoAIHidden = TEMPORARILY_HIDDEN_SPONSOR_BRANDS.has('fennoAI');
- const qiniuCloudHidden = TEMPORARILY_HIDDEN_SPONSOR_BRANDS.has('qiniuCloud');
- const groups: ProviderGroup[] = PROVIDER_BRAND_ORDER.map((brand) => {
- let resources: ProviderResource[] = [];
- switch (brand) {
- case 'gemini':
- resources = (config.geminiApiKeys ?? []).reduce(
- (out, item, index) => {
- if (
- !isCode0GeminiProvider(item) &&
- (qiniuCloudHidden || !isQiniuCloudGeminiProvider(item)) &&
- !isLmuAIGeminiProvider(item) &&
- !isInfistarGeminiProvider(item)
- ) {
- out.push(geminiToResource(item, index));
- }
- return out;
- },
- []
- );
- break;
- case 'interactions':
- resources = (config.interactionsApiKeys ?? []).map((item, index) =>
- interactionsToResource(item, index)
- );
- break;
- case 'codex':
- resources = (config.codexApiKeys ?? []).reduce((out, item, index) => {
- if (
- !isApiKeyFunCodexProvider(item) &&
- !isCode0CodexProvider(item) &&
- (fennoAIHidden || !isFennoAICodexProvider(item)) &&
- (qiniuCloudHidden || !isQiniuCloudCodexProvider(item)) &&
- !isLmuAICodexProvider(item) &&
- !isInfistarCodexProvider(item)
- ) {
- out.push(codexToResource(item, index));
- }
- return out;
- }, []);
- break;
- case 'xai':
- resources = (config.xaiApiKeys ?? []).map((item, index) => xaiToResource(item, index));
- break;
- case 'claude':
- resources = (config.claudeApiKeys ?? []).reduce(
- (out, item, index) => {
- if (
- !isApiKeyFunClaudeProvider(item) &&
- !isCode0ClaudeProvider(item) &&
- (fennoAIHidden || !isFennoAIClaudeProvider(item)) &&
- (qiniuCloudHidden || !isQiniuCloudClaudeProvider(item)) &&
- !isLmuAIClaudeProvider(item) &&
- !isInfistarClaudeProvider(item) &&
- !isKimiClaudeProvider(item) &&
- !isClaudeApiProvider(item)
- ) {
- out.push(claudeToResource(item, index));
- }
- return out;
- },
- []
- );
- break;
- case 'claudeApi':
- resources = (config.claudeApiKeys ?? []).reduce(
- (out, item, index) => {
- if (isClaudeApiProvider(item)) {
- out.push(claudeApiToResource(item, index));
- }
- return out;
- },
- []
- );
- break;
- case 'vertex':
- resources = (config.vertexApiKeys ?? []).map((c, i) => vertexToResource(c, i));
- break;
- case 'openaiCompatibility':
- resources = (config.openaiCompatibility ?? []).reduce(
- (out, item, index) => {
- if (
- !isApiKeyFunOpenAIProvider(item) &&
- !isCode0OpenAIProvider(item) &&
- (qiniuCloudHidden || !isQiniuCloudOpenAIProvider(item)) &&
- !isLmuAIOpenAIProvider(item) &&
- !isInfistarOpenAIProvider(item) &&
- !isKimiOpenAIProvider(item)
- ) {
- out.push(openaiToResource(item, index));
- }
- return out;
- },
- []
- );
- break;
- case 'apikeyFun': {
- const sponsorResource = apiKeyFunToResource(buildApiKeyFunRaw(config));
- resources = sponsorResource ? [sponsorResource] : [];
- break;
- }
- case 'code0': {
- const sponsorResource = code0ToResource(buildCode0Raw(config));
- resources = sponsorResource ? [sponsorResource] : [];
- break;
- }
- case 'fennoAI': {
- const sponsorResource = fennoAIToResource(buildFennoAIRaw(config));
- resources = sponsorResource ? [sponsorResource] : [];
- break;
- }
- case 'qiniuCloud': {
- const sponsorResource = qiniuCloudToResource(buildQiniuCloudRaw(config));
- resources = sponsorResource ? [sponsorResource] : [];
- break;
- }
- case 'lmuAI': {
- const sponsorResource = lmuAIToResource(buildLmuAIRaw(config));
- resources = sponsorResource ? [sponsorResource] : [];
- break;
- }
- case 'infistar': {
- const sponsorResource = infistarToResource(buildInfistarRaw(config));
- resources = sponsorResource ? [sponsorResource] : [];
- break;
- }
- case 'kimi': {
- const sponsorResource = kimiToResource(buildKimiRaw(config));
- resources = sponsorResource ? [sponsorResource] : [];
- break;
- }
- }
- return {
- id: brand,
- resources,
- };
- });
- return {
- fetchedAt,
- groups: groups.filter((group) => !isTemporarilyHiddenSponsorBrand(group.id)),
- };
- }, [config, fetchedAt]);
-
- /* ------------------- mutations ------------------- */
-
- const persistSponsorConfig = useCallback(
- async (brand: SponsorProviderBrand, input: ProviderEntryFormInput) => {
- const definition = getSponsorProviderDefinition(brand);
- const raw =
- brand === 'apikeyFun'
- ? buildApiKeyFunRaw(config)
- : brand === 'code0'
- ? buildCode0Raw(config)
- : brand === 'fennoAI'
- ? buildFennoAIRaw(config)
- : brand === 'qiniuCloud'
- ? buildQiniuCloudRaw(config)
- : brand === 'lmuAI'
- ? buildLmuAIRaw(config)
- : brand === 'infistar'
- ? buildInfistarRaw(config)
- : buildKimiRaw(config);
- const entries = normalizeSponsorKeyEntries(input.sponsorKeyEntries);
- const openaiEntry = entries.find((entry) => entry.protocol === 'openai');
- const claudeEntry = entries.find((entry) => entry.protocol === 'claude');
- const codexEntry = entries.find((entry) => entry.protocol === 'codex');
- const geminiEntry = entries.find((entry) => entry.protocol === 'gemini');
-
- if (definition.protocols.includes('gemini')) {
- const current = raw.gemini[0];
- if (geminiEntry) {
- const next = buildSponsorGeminiConfig(
- geminiEntry,
- definition.getProtocolUrls,
- current?.config
- );
- if (current) {
- await providersApi.updateGeminiKey(current.config.apiKey, current.config.baseUrl, next);
- } else {
- await providersApi.createGeminiKey(next);
- }
- } else {
- for (const item of raw.gemini) {
- await providersApi.deleteGeminiKey(item.config.apiKey, item.config.baseUrl);
- }
- }
- }
-
- const currentCodex = raw.codex[0];
- if (codexEntry) {
- const next = buildSponsorProviderKeyConfig(
- codexEntry,
- 'codex',
- definition.getProtocolUrls,
- currentCodex?.config
- );
- if (currentCodex) {
- await providersApi.updateCodexConfig(
- currentCodex.config.apiKey,
- currentCodex.config.baseUrl,
- next
- );
- } else {
- await providersApi.createCodexConfig(next);
- }
- } else {
- for (const item of raw.codex) {
- await providersApi.deleteCodexConfig(item.config.apiKey, item.config.baseUrl);
- }
- }
-
- const currentClaude = raw.claude[0];
- if (claudeEntry) {
- const next = buildSponsorProviderKeyConfig(
- claudeEntry,
- 'claude',
- definition.getProtocolUrls,
- currentClaude?.config
- );
- if (currentClaude) {
- await providersApi.updateClaudeConfig(
- currentClaude.config.apiKey,
- currentClaude.config.baseUrl,
- next
- );
- } else {
- await providersApi.createClaudeConfig(next);
- }
- } else {
- for (const item of raw.claude) {
- await providersApi.deleteClaudeConfig(item.config.apiKey, item.config.baseUrl);
- }
- }
-
- const currentOpenAI = raw.openai[0];
- if (openaiEntry) {
- const next = buildSponsorOpenAIConfig(
- openaiEntry,
- definition.providerName,
- definition.getProtocolUrls,
- currentOpenAI?.config
- );
- if (currentOpenAI) {
- await providersApi.updateOpenAIProvider(
- currentOpenAI.config.name,
- currentOpenAI.index,
- next
- );
- } else {
- await providersApi.createOpenAIProvider(next);
- }
- } else if (currentOpenAI) {
- await providersApi.deleteOpenAIProvider(currentOpenAI.index);
- }
- },
- [config]
- );
-
- const createProvider = useCallback(
- async (brand: ProviderBrand, input: ProviderEntryFormInput) => {
- setMutating(true);
- try {
- if (brand === 'gemini') {
- await providersApi.createGeminiKey(
- buildProviderKeyConfig('gemini', input) as GeminiKeyConfig
- );
- } else if (brand === 'interactions') {
- await providersApi.createInteractionsKey(
- buildProviderKeyConfig('interactions', input) as GeminiKeyConfig
- );
- } else if (brand === 'codex') {
- await providersApi.createCodexConfig(
- buildProviderKeyConfig('codex', input) as ProviderKeyConfig
- );
- } else if (brand === 'xai') {
- await providersApi.createXAIConfig(
- buildProviderKeyConfig('xai', input) as ProviderKeyConfig
- );
- } else if (brand === 'claude') {
- await providersApi.createClaudeConfig(
- buildProviderKeyConfig('claude', input) as ProviderKeyConfig
- );
- } else if (brand === 'claudeApi') {
- await providersApi.createClaudeConfig(buildClaudeApiConfig(input));
- } else if (brand === 'vertex') {
- await providersApi.createVertexConfig(
- buildProviderKeyConfig('vertex', input) as ProviderKeyConfig
- );
- } else if (brand === 'openaiCompatibility') {
- await providersApi.createOpenAIProvider(buildOpenAIConfig(input));
- } else if (
- brand === 'apikeyFun' ||
- brand === 'code0' ||
- brand === 'fennoAI' ||
- brand === 'qiniuCloud' ||
- brand === 'lmuAI' ||
- brand === 'infistar' ||
- brand === 'kimi'
- ) {
- await runSponsorMutationWithRecovery(() => persistSponsorConfig(brand, input), refetch);
- }
- await refetch();
- } finally {
- setMutating(false);
- }
- },
- [persistSponsorConfig, refetch]
- );
-
- const updateProvider = useCallback(
- async (resource: ProviderResource, input: ProviderEntryFormInput) => {
- setMutating(true);
- try {
- const brand = resource.brand;
- const selector = resource.selector;
- if (brand === 'gemini' && selector.brand === 'gemini') {
- const existing = resource.raw as GeminiKeyConfig;
- await providersApi.updateGeminiKey(
- selector.apiKey,
- selector.baseUrl,
- buildProviderKeyConfig('gemini', input, existing) as GeminiKeyConfig
- );
- } else if (brand === 'interactions' && selector.brand === 'interactions') {
- const existing = resource.raw as GeminiKeyConfig;
- await providersApi.updateInteractionsKey(
- selector.apiKey,
- selector.baseUrl,
- buildProviderKeyConfig('interactions', input, existing) as GeminiKeyConfig
- );
- } else if (brand === 'codex' && selector.brand === 'codex') {
- const existing = resource.raw as ProviderKeyConfig;
- await providersApi.updateCodexConfig(
- selector.apiKey,
- selector.baseUrl,
- buildProviderKeyConfig('codex', input, existing) as ProviderKeyConfig
- );
- } else if (brand === 'xai' && selector.brand === 'xai') {
- const existing = resource.raw as ProviderKeyConfig;
- await providersApi.updateXAIConfig(
- selector.apiKey,
- selector.baseUrl,
- buildProviderKeyConfig('xai', input, existing) as ProviderKeyConfig
- );
- } else if (brand === 'claude' && selector.brand === 'claude') {
- const existing = resource.raw as ProviderKeyConfig;
- await providersApi.updateClaudeConfig(
- selector.apiKey,
- selector.baseUrl,
- buildProviderKeyConfig('claude', input, existing) as ProviderKeyConfig
- );
- } else if (brand === 'claudeApi' && selector.brand === 'claudeApi') {
- await providersApi.updateClaudeConfig(
- selector.apiKey,
- selector.baseUrl,
- buildClaudeApiConfig(input, resource.raw as ProviderKeyConfig)
- );
- } else if (brand === 'vertex' && selector.brand === 'vertex') {
- const existing = resource.raw as ProviderKeyConfig;
- await providersApi.updateVertexConfig(
- selector.apiKey,
- selector.baseUrl,
- buildProviderKeyConfig('vertex', input, existing) as ProviderKeyConfig
- );
- } else if (brand === 'openaiCompatibility' && selector.brand === 'openaiCompatibility') {
- await providersApi.updateOpenAIProvider(
- selector.name,
- selector.index,
- buildOpenAIConfig(input, resource.raw as OpenAIProviderConfig)
- );
- } else if (
- brand === 'apikeyFun' ||
- brand === 'code0' ||
- brand === 'fennoAI' ||
- brand === 'qiniuCloud' ||
- brand === 'lmuAI' ||
- brand === 'infistar' ||
- brand === 'kimi'
- ) {
- await runSponsorMutationWithRecovery(() => persistSponsorConfig(brand, input), refetch);
- }
- await refetch();
- } finally {
- setMutating(false);
- }
- },
- [persistSponsorConfig, refetch]
- );
-
- const deleteProvider = useCallback(
- async (resource: ProviderResource) => {
- setMutating(true);
- try {
- const sel = resource.selector;
- if (sel.brand === 'gemini') {
- await providersApi.deleteGeminiKey(sel.apiKey, sel.baseUrl);
- const next = (config?.geminiApiKeys ?? []).filter((_, i) => i !== sel.index);
- updateConfigValue('gemini-api-key', next);
- } else if (sel.brand === 'interactions') {
- await providersApi.deleteInteractionsKey(sel.apiKey, sel.baseUrl);
- const next = (config?.interactionsApiKeys ?? []).filter((_, i) => i !== sel.index);
- updateConfigValue('interactions-api-key', next);
- } else if (sel.brand === 'codex') {
- await providersApi.deleteCodexConfig(sel.apiKey, sel.baseUrl);
- const next = (config?.codexApiKeys ?? []).filter((_, i) => i !== sel.index);
- updateConfigValue('codex-api-key', next);
- } else if (sel.brand === 'xai') {
- await providersApi.deleteXAIConfig(sel.apiKey, sel.baseUrl);
- const next = (config?.xaiApiKeys ?? []).filter((_, i) => i !== sel.index);
- updateConfigValue('xai-api-key', next);
- } else if (sel.brand === 'claude') {
- await providersApi.deleteClaudeConfig(sel.apiKey, sel.baseUrl);
- const next = (config?.claudeApiKeys ?? []).filter((_, i) => i !== sel.index);
- updateConfigValue('claude-api-key', next);
- } else if (sel.brand === 'claudeApi') {
- await providersApi.deleteClaudeConfig(sel.apiKey, sel.baseUrl);
- const next = (config?.claudeApiKeys ?? []).filter((_, i) => i !== sel.index);
- updateConfigValue('claude-api-key', next);
- } else if (sel.brand === 'vertex') {
- await providersApi.deleteVertexConfig(sel.apiKey, sel.baseUrl);
- const next = (config?.vertexApiKeys ?? []).filter((_, i) => i !== sel.index);
- updateConfigValue('vertex-api-key', next);
- } else if (sel.brand === 'openaiCompatibility') {
- await providersApi.deleteOpenAIProvider(sel.index);
- const next = (config?.openaiCompatibility ?? []).filter(
- (item, index) => (item.sourceIndex ?? index) !== sel.index
- );
- updateConfigValue('openai-compatibility', next);
- } else if (
- sel.brand === 'apikeyFun' ||
- sel.brand === 'code0' ||
- sel.brand === 'fennoAI' ||
- sel.brand === 'qiniuCloud' ||
- sel.brand === 'lmuAI' ||
- sel.brand === 'infistar' ||
- sel.brand === 'kimi'
- ) {
- await runSponsorMutationWithRecovery(async () => {
- const raw = resource.raw as SponsorProviderRaw;
- for (const item of raw.gemini) {
- await providersApi.deleteGeminiKey(item.config.apiKey, item.config.baseUrl);
- }
- for (const item of raw.codex) {
- await providersApi.deleteCodexConfig(item.config.apiKey, item.config.baseUrl);
- }
- for (const item of raw.claude) {
- await providersApi.deleteClaudeConfig(item.config.apiKey, item.config.baseUrl);
- }
- const openAIIndices = raw.openai
- .map((item) => item.index)
- .sort((left, right) => right - left);
- for (const index of openAIIndices) {
- await providersApi.deleteOpenAIProvider(index);
- }
- }, refetch);
- }
- await refetch();
- } finally {
- setMutating(false);
- }
- },
- [config, refetch, updateConfigValue]
- );
-
- const toggleDisabled = useCallback(
- async (resource: ProviderResource, disabled: boolean) => {
- setMutating(true);
- try {
- const brand = resource.brand;
- const selector = resource.selector;
- if (brand === 'gemini' && selector.brand === 'gemini') {
- const current = resource.raw as GeminiKeyConfig;
- const excluded = disabled
- ? withDisableAllModelsRule(current.excludedModels)
- : withoutDisableAllModelsRule(current.excludedModels);
- await providersApi.updateGeminiKey(selector.apiKey, selector.baseUrl, {
- ...current,
- excludedModels: excluded,
- });
- } else if (brand === 'interactions' && selector.brand === 'interactions') {
- const current = resource.raw as GeminiKeyConfig;
- const excluded = disabled
- ? withDisableAllModelsRule(current.excludedModels)
- : withoutDisableAllModelsRule(current.excludedModels);
- await providersApi.updateInteractionsKey(selector.apiKey, selector.baseUrl, {
- ...current,
- excludedModels: excluded,
- });
- } else if (
- (brand === 'codex' && selector.brand === 'codex') ||
- (brand === 'xai' && selector.brand === 'xai') ||
- (brand === 'claude' && selector.brand === 'claude') ||
- (brand === 'claudeApi' && selector.brand === 'claudeApi') ||
- (brand === 'vertex' && selector.brand === 'vertex')
- ) {
- const current = resource.raw as ProviderKeyConfig;
- const excluded = disabled
- ? withDisableAllModelsRule(current.excludedModels)
- : withoutDisableAllModelsRule(current.excludedModels);
- const next = { ...current, excludedModels: excluded };
- if (selector.brand === 'codex') {
- await providersApi.updateCodexConfig(selector.apiKey, selector.baseUrl, next);
- } else if (selector.brand === 'xai') {
- await providersApi.updateXAIConfig(selector.apiKey, selector.baseUrl, next);
- } else if (selector.brand === 'claude' || selector.brand === 'claudeApi') {
- await providersApi.updateClaudeConfig(selector.apiKey, selector.baseUrl, next);
- } else if (selector.brand === 'vertex') {
- await providersApi.updateVertexConfig(selector.apiKey, selector.baseUrl, next);
- }
- } else if (brand === 'openaiCompatibility' && selector.brand === 'openaiCompatibility') {
- await providersApi.updateOpenAIProviderDisabled(selector.index, disabled);
- } else if (
- brand === 'apikeyFun' ||
- brand === 'code0' ||
- brand === 'fennoAI' ||
- brand === 'qiniuCloud' ||
- brand === 'lmuAI' ||
- brand === 'infistar' ||
- brand === 'kimi'
- ) {
- await runSponsorMutationWithRecovery(
- () => toggleSponsorConfig(resource.raw as SponsorProviderRaw, disabled),
- refetch
- );
- }
- await refetch();
- } finally {
- setMutating(false);
- }
- },
- [refetch]
- );
-
- return {
- connected,
- isPending,
- isFetching,
- isError: Boolean(errorMessage),
- errorMessage,
- snapshot,
- refetch,
- createProvider,
- updateProvider,
- deleteProvider,
- toggleDisabled,
- mutating,
- refreshSnapshot,
- };
-}
diff --git a/frontend/src/features/quota/QuotaPage.module.scss b/frontend/src/features/quota/QuotaPage.module.scss
deleted file mode 100644
index 9553f60..0000000
--- a/frontend/src/features/quota/QuotaPage.module.scss
+++ /dev/null
@@ -1,99 +0,0 @@
-@use '../../styles/mixins' as *;
-
-/* ============================================================
- * 额度页壳层:纯布局,视觉细节都在各组件的 colocated 模块里。
- * ============================================================ */
-
-.page {
- display: flex;
- flex-direction: column;
- gap: 20px;
- width: 100%;
- min-width: 0;
-}
-
-/* ---------- 工作区(tabs + 网格 + 分页) ---------- */
-
-.workbench {
- display: flex;
- flex-direction: column;
- gap: 14px;
- min-width: 0;
-}
-
-/* tabs 与排序同一行:tabs 会自己横向滚动,所以排序固定不压缩。 */
-
-.tabsRow {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- min-width: 0;
-
- > :first-child {
- min-width: 0;
- }
-}
-
-.sort {
- flex: 0 0 auto;
- min-width: 148px;
-}
-
-@include mobile {
- .tabsRow {
- flex-direction: column;
- align-items: stretch;
- }
-
- .sort {
- width: 100%;
- }
-}
-
-.errorBanner {
- font-size: 12.5px;
- line-height: 1.5;
- color: var(--danger-color);
- background: var(--bg-error-light);
- border: 1px solid var(--warning-border);
- border-radius: 10px;
- padding: 9px 12px;
- overflow-wrap: anywhere;
-}
-
-/* ---------- 卡片网格 ----------
- * 列宽由 CSS 决定(不再用 JS 测量):额度卡承载水位条,取 340px 下限,
- * 与认证文件页配额模式(.gridQuota)同一档。 */
-
-.grid {
- display: grid;
- gap: 16px;
- grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
- align-items: stretch;
-}
-
-/* ---------- 分页 ---------- */
-
-.pagination {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 14px;
- flex-wrap: wrap;
-}
-
-.pageInfo {
- font-family: $font-mono;
- font-size: 12px;
- font-variant-numeric: tabular-nums;
- letter-spacing: 0.02em;
- color: var(--text-tertiary);
- white-space: nowrap;
-}
-
-@include mobile {
- .page {
- gap: 16px;
- }
-}
diff --git a/frontend/src/features/quota/QuotaPage.tsx b/frontend/src/features/quota/QuotaPage.tsx
deleted file mode 100644
index f1c07f3..0000000
--- a/frontend/src/features/quota/QuotaPage.tsx
+++ /dev/null
@@ -1,373 +0,0 @@
-/**
- * 额度查询页:提供商 tabs + 统一卡网格。
- *
- * 保留的行为契约(重设计不改):
- * - 点击加载:卡片挂载为 idle,额度只在用户点击/刷新时才打上游;
- * - cacheGeneration 会话隔离 + request-id 去重(见 useQuotaBatchLoader);
- * - 文件列表变化后按 provider 剪枝额度缓存(已删文件不残留);
- * - useHeaderRefresh 单槽位:本页唯一注册者,全局刷新 = 重取文件列表。
- */
-
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { authFilesApi } from '@/services/api';
-import { Button } from '@/components/ui/Button';
-import { EmptyState } from '@/components/ui/EmptyState';
-import { Select } from '@/components/ui/Select';
-import { Skeleton } from '@/components/ui/Skeleton';
-import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
-import { useNow } from '@/hooks/useNow';
-import { useRevealGroup } from '@/hooks/motion';
-import { useAuthStore, useQuotaStore, useThemeStore } from '@/stores';
-import type { AuthFileItem, ResolvedTheme } from '@/types';
-import { ProviderTabs } from '@/features/authFiles/components/ProviderTabs';
-import { QuotaHeader } from './components/QuotaHeader';
-import { QuotaCard } from './components/QuotaCard';
-import { QuotaTimeline } from './components/QuotaTimeline';
-import {
- CARD_ENTRANCE_BUDGET_MS,
- QUOTA_PAGE_SIZE,
- QUOTA_SORT_MODES,
- QUOTA_TAB_ORDER,
- type QuotaSortMode,
- type QuotaTabId,
-} from './constants';
-import {
- buildTabCounts,
- classifyQuotaFiles,
- filterEntriesByTab,
- paginate,
- sortQuotaEntries,
- type QuotaFileEntry,
-} from './logic';
-import { nextRecoveryMs } from './resetSchedule';
-import { QUOTA_ADAPTERS, getQuotaSetter, type QuotaCardState } from './providers';
-import type { QuotaProviderType } from './providers/types';
-import { useQuotaActions } from './hooks/useQuotaActions';
-import { useQuotaBatchLoader } from './hooks/useQuotaBatchLoader';
-import { readQuotaUiState, writeQuotaUiState } from './uiState';
-import styles from './QuotaPage.module.scss';
-
-const TAB_IDS: string[] = ['all', ...QUOTA_TAB_ORDER];
-const SKELETON_CARD_COUNT = 6;
-
-/**
- * 时间线泳道名 = 卡片标题,两者必须一致。卡片显示的就是文件名,所以这里是恒等。
- * 提到模块级是为了引用稳定 —— 它进了泳道 memo 的依赖数组。
- */
-const displayNameFor = (name: string) => name;
-
-export function QuotaPage() {
- const { t } = useTranslation();
- const connectionStatus = useAuthStore((state) => state.connectionStatus);
- const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
-
- const [files, setFiles] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState('');
- const [tab, setTab] = useState(() => readQuotaUiState()?.tab ?? 'all');
- const [sortMode, setSortMode] = useState(
- () => readQuotaUiState()?.sortMode ?? 'default'
- );
- const [page, setPage] = useState(1);
- // 页头 + tabs 的入场级联(标题 → meta → 动作 → tabs,级差 70ms)
- const revealRef = useRevealGroup();
-
- const disableControls = connectionStatus !== 'connected';
-
- /* ---------- 文件列表 ---------- */
-
- const loadFiles = useCallback(async () => {
- setLoading(true);
- setError('');
- try {
- const data = await authFilesApi.list();
- setFiles(data?.files || []);
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : t('notification.refresh_failed');
- setError(message);
- } finally {
- setLoading(false);
- }
- }, [t]);
-
- useHeaderRefresh(loadFiles);
-
- useEffect(() => {
- void loadFiles();
- }, [loadFiles]);
-
- /* ---------- 额度缓存 ----------
- * 排在归类/排序之前:「最快恢复优先」要读它算排序键。 */
-
- const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
- const claudeQuota = useQuotaStore((state) => state.claudeQuota);
- const codexQuota = useQuotaStore((state) => state.codexQuota);
- const kimiQuota = useQuotaStore((state) => state.kimiQuota);
- const xaiQuota = useQuotaStore((state) => state.xaiQuota);
-
- const quotaByType = useMemo>>(
- () =>
- ({
- antigravity: antigravityQuota,
- claude: claudeQuota,
- codex: codexQuota,
- kimi: kimiQuota,
- xai: xaiQuota,
- }) as unknown as Record>,
- [antigravityQuota, claudeQuota, codexQuota, kimiQuota, xaiQuota]
- );
-
- const getQuota = useCallback(
- (entry: QuotaFileEntry): QuotaCardState | undefined => quotaByType[entry.type][entry.file.name],
- [quotaByType]
- );
-
- /* ---------- 归类 / 过滤 / 排序 / 分页 ---------- */
-
- // 只在「最快恢复优先」下订阅分钟时钟。默认序下不门控的话,pageItems 每分钟
- // 换一次身份,会反复空转下面那个「刷新全部」的 loading 下降沿 effect。
- const tick = useNow(sortMode !== 'default');
- const sortNow = sortMode === 'default' ? 0 : tick;
-
- const entries = useMemo(() => classifyQuotaFiles(files), [files]);
- const tabCounts = useMemo(() => buildTabCounts(entries), [entries]);
- const filteredEntries = useMemo(() => filterEntriesByTab(entries, tab), [entries, tab]);
-
- const resolveNextRecovery = useCallback(
- (entry: QuotaFileEntry) => nextRecoveryMs(entry.type, getQuota(entry), sortNow),
- [getQuota, sortNow]
- );
- // 排序在分页之前:否则「最快恢复」只在当前页内成立。
- const sortedEntries = useMemo(
- () => sortQuotaEntries(filteredEntries, sortMode, resolveNextRecovery),
- [filteredEntries, sortMode, resolveNextRecovery]
- );
-
- const { pageItems, currentPage, totalPages } = useMemo(
- () => paginate(sortedEntries, page, QUOTA_PAGE_SIZE),
- [sortedEntries, page]
- );
-
- const handleTabChange = useCallback((next: string) => {
- setTab(next as QuotaTabId);
- setPage(1);
- writeQuotaUiState({ tab: next as QuotaTabId });
- }, []);
-
- const handleSortModeChange = useCallback((next: string) => {
- setSortMode(next as QuotaSortMode);
- setPage(1);
- writeQuotaUiState({ sortMode: next as QuotaSortMode });
- }, []);
-
- const sortOptions = useMemo(
- () =>
- QUOTA_SORT_MODES.map((mode) => ({ value: mode, label: t(`quota_management.sort_${mode}`) })),
- [t]
- );
-
- const { loadedCount, attentionCount } = useMemo(() => {
- let loaded = 0;
- let attention = 0;
- entries.forEach((entry) => {
- const status = quotaByType[entry.type][entry.file.name]?.status;
- if (status === 'success') loaded += 1;
- else if (status === 'error') attention += 1;
- });
- return { loadedCount: loaded, attentionCount: attention };
- }, [entries, quotaByType]);
-
- // 剪枝:文件列表落定后,各 provider 缓存只保留仍存在的凭证
- useEffect(() => {
- if (loading) return;
- const survivorsByType = new Map>(
- QUOTA_TAB_ORDER.map((type) => [type, new Set()])
- );
- entries.forEach((entry) => survivorsByType.get(entry.type)?.add(entry.file.name));
-
- QUOTA_TAB_ORDER.forEach((type) => {
- const survivors = survivorsByType.get(type) ?? new Set();
- const setQuota = getQuotaSetter(QUOTA_ADAPTERS[type]);
- setQuota((prev) => {
- const staleKeys = Object.keys(prev).filter((name) => !survivors.has(name));
- if (staleKeys.length === 0) return prev;
- const next = { ...prev };
- staleKeys.forEach((name) => delete next[name]);
- return next;
- });
- });
- }, [entries, loading]);
-
- /* ---------- 加载与操作 ---------- */
-
- const { batchLoading, loadQuota } = useQuotaBatchLoader();
- const { resettingQuotaName, refreshQuota, resetQuota } = useQuotaActions(disableControls);
-
- const pendingRefreshRef = useRef(false);
- const prevLoadingRef = useRef(loading);
-
- // 刷新全部:先重取文件列表,待其落定(loading 下降沿)再批量拉当前页额度
- const handleRefreshAll = useCallback(() => {
- if (disableControls) return;
- pendingRefreshRef.current = true;
- void loadFiles();
- }, [disableControls, loadFiles]);
-
- useEffect(() => {
- const wasLoading = prevLoadingRef.current;
- prevLoadingRef.current = loading;
-
- if (!pendingRefreshRef.current) return;
- if (loading || !wasLoading) return;
-
- pendingRefreshRef.current = false;
- void loadQuota(pageItems);
- }, [loading, loadQuota, pageItems]);
-
- const canUseActions = !disableControls && !loading;
-
- /* ---------- 首屏卡片一次性级联入场 ----------
- * 首批数据渲染后立即翻转 cardsAnimated;已挂载的卡片在挂载时捕获过自己的
- * 延迟(QuotaCard 内 useState 初始化),后续切 tab/翻页/刷新新挂载的卡片
- * 拿到 null —— 不重播。 */
-
- const [cardsAnimated, setCardsAnimated] = useState(false);
- const enableCardEntrance = !cardsAnimated && !loading && pageItems.length > 0;
- useEffect(() => {
- if (enableCardEntrance) {
- setCardsAnimated(true);
- }
- }, [enableCardEntrance]);
- const cardEntranceDelay = (index: number): number | null => {
- if (!enableCardEntrance) return null;
- if (pageItems.length <= 1) return 0;
- return Math.round((index / (pageItems.length - 1)) * CARD_ENTRANCE_BUDGET_MS);
- };
-
- /* ---------- 渲染 ---------- */
-
- const isEmpty = !loading && filteredEntries.length === 0;
-
- return (
-
-
-
-
- {/* tabs + 排序作为一个整体入场(useRevealGroup 会给每个 [data-reveal]
- 后代加一级级差,所以排序控件放在同一个节点里而不是做兄弟) */}
-
-
- {error && (
-
- {error}
-
- )}
-
- {loading ? (
-
- {Array.from({ length: SKELETON_CARD_COUNT }, (_, index) => (
-
- ))}
-
- ) : isEmpty ? (
- handleTabChange('all')}>
- {t('auth_files.filter_all')}
-
- )
- }
- />
- ) : (
-
- {pageItems.map((entry, index) => (
- void refreshQuota(entry.file, QUOTA_ADAPTERS[entry.type])}
- onReset={() => resetQuota(entry.file, QUOTA_ADAPTERS[entry.type])}
- />
- ))}
-
- )}
-
- {!loading && filteredEntries.length > QUOTA_PAGE_SIZE && (
-
-
-
- {t('auth_files.pagination_info', {
- current: currentPage,
- total: totalPages,
- count: filteredEntries.length,
- })}
-
-
-
- )}
-
- {/* 时间线只比较当前页凭证,避免大量凭证一次性生成无界泳道。 */}
-
-
-
- );
-}
diff --git a/frontend/src/features/quota/components/QuotaBody.module.scss b/frontend/src/features/quota/components/QuotaBody.module.scss
deleted file mode 100644
index ed48121..0000000
--- a/frontend/src/features/quota/components/QuotaBody.module.scss
+++ /dev/null
@@ -1,796 +0,0 @@
-@use '../../../styles/mixins' as *;
-
-/* ============================================================
- * 额度卡片 body 的全页外衣(类型化契约见 features/quota/types.ts)。
- *
- * 两段构成:
- * 1. 额度行 / 水位条 / Antigravity 分组 —— mono 遥测风,与紧凑外衣同族;
- * 2. 套餐徽章区(.codexPlan* 起至文件末尾)—— 从 pages/QuotaPage.module.scss
- * 第 429–1023 行逐字节搬迁的定稿资产(金卡 + Pro 20x 液态铂金,含暗色 /
- * HDR / 减动效冻结帧 / 移动端降级)。**该区块禁止改动**:改配方等于重设计
- * 已验收的徽章,验证方式是与 git 历史做 diff(应为空)。
- * ============================================================ */
-
-/* ---------- 额度行 ---------- */
-
-.quotaRow {
- display: flex;
- flex-direction: column;
- gap: 5px;
- min-width: 0;
-}
-
-.quotaRowHeader {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 8px;
- min-width: 0;
-}
-
-.quotaModel {
- min-width: 0;
- font-size: 12.5px;
- font-weight: 600;
- color: var(--text-secondary);
- @include text-ellipsis;
-}
-
-/* 遥测:mono + 等宽数字,刷新时数字不跳位 */
-.quotaMeta {
- display: inline-flex;
- align-items: baseline;
- gap: 7px;
- flex-shrink: 0;
- font-family: $font-mono;
- font-size: 11.5px;
- font-variant-numeric: tabular-nums;
- letter-spacing: 0.01em;
-}
-
-/* 百分比穿墨色而非填充色 —— 文字不承担状态编码,颜色留给水位条 */
-.quotaPercent {
- font-weight: 650;
- color: var(--text-primary);
-}
-
-.quotaReset {
- color: var(--text-quaternary);
-}
-
-/* Countdown beside the absolute reset time. The separator is a pseudo-element
- so the two spans stay independently styleable (the relative half turns
- warning-coloured when this is the row that recovers first). */
-.quotaResetRelative {
- color: var(--text-tertiary);
-
- &::before {
- content: '·';
- margin: 0 4px;
- color: var(--text-quaternary);
- }
-}
-
-.quotaResetRelativeSoon {
- color: var(--warning-text);
- font-weight: 600;
-}
-
-.quotaAmount {
- font-family: $font-mono;
- font-variant-numeric: tabular-nums;
- color: var(--text-secondary);
-}
-
-.quotaMessage {
- font-family: $font-mono;
- font-size: 11.5px;
- line-height: 1.5;
- color: var(--text-tertiary);
- text-align: center;
- padding: 6px 4px;
-}
-
-/* ---------- 水位条(QuotaMeter)----------
- * 轨道退居背景(8% 墨色),填充按剩余量三档着色;
- * percent === null 时宽度为 0 —— 未知不着色。 */
-
-.quotaBar {
- height: 6px;
- border-radius: $radius-full;
- background: color-mix(in srgb, var(--text-primary) 8%, transparent);
- overflow: hidden;
-}
-
-.quotaBarFill {
- height: 100%;
- border-radius: $radius-full;
- /* 刷新时平滑重定向到新水位(可中断) */
- transition: width 360ms var(--ease-out-strong, ease);
- /* 数据到达是本页的高潮时刻:水位逐行铺开(每行 40ms 级差)。
- * 走 scaleX 而非 width,避免与上面的 width 过渡互相抢夺同一属性。 */
- transform-origin: left center;
- animation: quota-meter-fill 480ms var(--ease-out-strong, ease-out) both;
- animation-delay: calc(var(--meter-index, 0) * 40ms);
-}
-
-@keyframes quota-meter-fill {
- from {
- transform: scaleX(0);
- }
-}
-
-.quotaBarFillHigh {
- background: var(--viz-success);
-}
-
-.quotaBarFillMedium {
- background: var(--quota-medium-color);
-}
-
-.quotaBarFillLow {
- background: var(--viz-failure);
-}
-
-/* ---------- Antigravity 分组 ---------- */
-
-.antigravityQuotaGroup {
- display: flex;
- flex-direction: column;
- gap: 7px;
-
- & + & {
- margin-top: 3px;
- padding-top: 9px;
- border-top: 1px dashed color-mix(in srgb, var(--border-color) 80%, transparent);
- }
-}
-
-.antigravityQuotaGroupHeader {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
-}
-
-.antigravityQuotaGroupTitle {
- font-family: $font-mono;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--text-tertiary);
-}
-
-.antigravityQuotaGroupDescription {
- font-size: 11px;
- line-height: 1.4;
- color: var(--text-quaternary);
-}
-
-@include mobile {
- .quotaRowHeader {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .quotaModel {
- width: 100%;
- white-space: normal;
- overflow: visible;
- text-overflow: clip;
- overflow-wrap: anywhere;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .quotaBarFill {
- transition: none;
- animation: none;
- }
-}
-
-/* ============================================================
- * 以下至文件末尾:套餐徽章定稿区块(逐字节搬迁,禁止改动)
- * ============================================================ */
-
-.codexPlan {
- display: flex;
- align-items: center;
- gap: 6px 12px;
- font-size: 12px;
- color: var(--text-secondary);
- flex-wrap: wrap;
-}
-
-.codexPlanItem {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- max-width: 100%;
- flex-wrap: wrap;
-}
-
-.codexPlanLabel {
- color: var(--text-tertiary);
- flex: 0 0 auto;
-}
-
-.codexPlanValue {
- font-weight: 600;
- color: var(--text-primary);
- text-transform: capitalize;
- min-width: 0;
-}
-
-.codexResetCredits {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding-top: 2px;
-}
-
-.codexResetCreditsTitle {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary);
- line-height: 1.35;
-}
-
-.codexResetCreditRow {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $spacing-sm;
- padding: 6px 8px;
- border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
- border-radius: $radius-sm;
- background-color: color-mix(in srgb, var(--bg-secondary) 72%, transparent);
- font-size: 12px;
- line-height: 1.35;
- min-width: 0;
-}
-
-.codexResetCreditRowSoon {
- border-color: var(--warning-border);
- background-color: var(--warning-bg);
-}
-
-.codexResetCreditLabel {
- color: var(--text-tertiary);
- flex: 0 0 auto;
- white-space: nowrap;
-}
-
-.codexResetCreditTime {
- color: var(--text-primary);
- font-weight: 600;
- text-align: right;
- overflow-wrap: anywhere;
- min-width: 0;
-}
-
-.codexResetCreditsError {
- font-size: 12px;
- line-height: 1.4;
- color: var(--warning-text);
- background-color: var(--warning-bg);
- border: 1px solid var(--warning-border);
- border-radius: $radius-sm;
- padding: $spacing-xs $spacing-sm;
-}
-
-.codexPlanSeparator {
- width: 1px;
- height: 12px;
- background-color: var(--border-color);
-}
-
-.premiumPlanValue {
- position: relative;
- display: inline-flex;
- align-items: center;
- font-weight: 700;
- font-size: 12px;
- padding: 2px 8px;
- border-radius: 999px;
- white-space: nowrap;
- flex: 0 0 auto;
- overflow: visible;
- isolation: isolate;
- background:
- radial-gradient(
- circle at 18% 24%,
- rgba(255, 255, 255, 0.96) 0%,
- rgba(255, 255, 255, 0.72) 18%,
- rgba(255, 255, 255, 0) 42%
- ),
- linear-gradient(135deg, #fff9e3 0%, #ffe07f 52%, #e0aa14 100%);
- border: 1px solid rgba(217, 165, 22, 0.72);
- box-shadow:
- 0 1px 3px rgba(133, 92, 0, 0.16),
- 0 0 0 1px rgba(255, 255, 255, 0.22) inset,
- 0 0 16px rgba(255, 214, 98, 0.28);
- color: #6b4b00;
- text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
- text-transform: capitalize;
-
- &::before {
- content: '';
- position: absolute;
- inset: -6px -9px;
- border-radius: inherit;
- background: radial-gradient(
- circle at 18% 22%,
- rgba(255, 255, 255, 0.9) 0%,
- rgba(255, 237, 158, 0.58) 32%,
- rgba(255, 215, 91, 0) 72%
- );
- --glass-blur: 9px;
- filter: var(--glass-filter);
- opacity: 0.75;
- pointer-events: none;
- z-index: -1;
- }
-
- @media (dynamic-range: high) {
- background:
- radial-gradient(
- circle at 18% 24%,
- color(display-p3 1 0.99 0.94) 0%,
- color(display-p3 1 0.97 0.82 / 0.82) 18%,
- color(display-p3 1 0.95 0.7 / 0) 42%
- ),
- linear-gradient(
- 135deg,
- color(display-p3 1 0.98 0.88),
- color(display-p3 0.99 0.86 0.34),
- color(display-p3 0.92 0.68 0.05)
- );
- border-color: color(display-p3 0.9 0.73 0.12 / 0.85);
- box-shadow:
- 0 1px 4px color(display-p3 0.45 0.28 0 / 0.22),
- 0 0 0 1px color(display-p3 1 0.98 0.86 / 0.3) inset,
- 0 0 18px color(display-p3 1 0.89 0.2 / 0.36);
- color: color(display-p3 0.43 0.3 0);
- }
-
- @supports (color: #{'color(rec2100-linear 1 1 1)'}) {
- @media (dynamic-range: high) {
- dynamic-range-limit: no-limit;
- box-shadow:
- 0 1px 4px color(display-p3 0.45 0.28 0 / 0.22),
- 0 0 0 1px color(display-p3 1 0.98 0.86 / 0.3) inset,
- 0 0 14px color(display-p3 1 0.89 0.2 / 0.36),
- 0 0 30px #{'color(rec2100-linear 3.6 2.9 0.7 / 0.48)'};
-
- &::before {
- background: radial-gradient(
- circle at 18% 22%,
- #{'color(rec2100-linear 6.5 6.2 5.4 / 0.92)'} 0%,
- #{'color(rec2100-linear 2.4 2 0.6 / 0.62)'} 34%,
- #{'color(rec2100-linear 1 0.85 0.12 / 0)'} 76%
- );
- opacity: 0.92;
- }
- }
- }
-}
-
-:global([data-theme='dark']) .premiumPlanValue {
- background:
- radial-gradient(
- circle at 18% 24%,
- rgba(255, 229, 138, 0.32) 0%,
- rgba(255, 214, 98, 0.18) 18%,
- rgba(255, 214, 98, 0) 44%
- ),
- linear-gradient(135deg, #4f3d0b 0%, #6e5510 48%, #8f6d10 100%);
- border-color: rgba(226, 180, 50, 0.7);
- box-shadow:
- 0 1px 6px rgba(0, 0, 0, 0.28),
- 0 0 0 1px rgba(255, 220, 120, 0.16) inset,
- 0 0 18px rgba(255, 196, 44, 0.22);
- color: #fff0a8;
- text-shadow: 0 0 10px rgba(255, 212, 79, 0.22);
-
- &::before {
- background: radial-gradient(
- circle at 18% 24%,
- rgba(255, 229, 138, 0.42) 0%,
- rgba(255, 197, 66, 0.28) 34%,
- rgba(255, 187, 0, 0) 74%
- );
- opacity: 0.8;
- }
-
- @media (dynamic-range: high) {
- background:
- radial-gradient(
- circle at 18% 24%,
- color(display-p3 1 0.89 0.38 / 0.38) 0%,
- color(display-p3 1 0.82 0.2 / 0.2) 18%,
- color(display-p3 1 0.82 0.2 / 0) 44%
- ),
- linear-gradient(
- 135deg,
- color(display-p3 0.33 0.25 0.04),
- color(display-p3 0.48 0.36 0.08),
- color(display-p3 0.62 0.46 0.09)
- );
- border-color: color(display-p3 0.78 0.6 0.16 / 0.78);
- box-shadow:
- 0 1px 8px color(display-p3 0 0 0 / 0.32),
- 0 0 0 1px color(display-p3 0.95 0.8 0.24 / 0.18) inset,
- 0 0 20px color(display-p3 1 0.76 0.1 / 0.28);
- color: color(display-p3 1 0.93 0.58);
- }
-
- @supports (color: #{'color(rec2100-linear 1 1 1)'}) {
- @media (dynamic-range: high) {
- dynamic-range-limit: no-limit;
- box-shadow:
- 0 1px 8px color(display-p3 0 0 0 / 0.32),
- 0 0 0 1px color(display-p3 0.95 0.8 0.24 / 0.18) inset,
- 0 0 16px color(display-p3 1 0.76 0.1 / 0.28),
- 0 0 28px #{'color(rec2100-linear 4.8 3.5 0.45 / 0.4)'};
-
- &::before {
- background: radial-gradient(
- circle at 18% 24%,
- #{'color(rec2100-linear 5.6 4.1 0.75 / 0.64)'} 0%,
- #{'color(rec2100-linear 2.2 1.5 0.2 / 0.34)'} 34%,
- #{'color(rec2100-linear 1.2 0.9 0.06 / 0)'} 74%
- );
- opacity: 0.95;
- }
- }
- }
-}
-
-/* Codex Pro 20x —— 液态铂金(B2:精修液态铬 + 呼吸光晕,2026-07-31 定稿)。
- * 与金卡(premiumPlanValue)的档差来自「材质行为」而非颜色:金是静止的贵金属,
- * 20x 是一枚会流动的镜面铬 —— 三层异速反光带永续对流(12s)+ 光晕呼吸(7s)
- * + 每 7.5s 一道巡回扫光。全程零彩色,唯一的色彩暗示是第三层几乎无色的冰蓝微光。
- *
- * 试过并被推翻的方向(勿回退):MC 青绿换色(太保守)、切面宝石区(20px 糊成噪点)、
- * 黑曜/紫晶/全息黑钻(弃暗体)、棱镜彩虹环、极光/油膜/星云晶体(太花)。
- * 口味锚点:单色金属 + 慢动态。文字两主题同为深灰蓝 —— 铬体恒亮,
- * 主题只微调石体明度与光晕强度。 */
-.elitePlanValue {
- position: relative;
- display: inline-flex;
- align-items: center;
- font-weight: 700;
- font-size: 12px;
- letter-spacing: 0.03em;
- padding: 2px 9px;
- border-radius: 999px;
- white-space: nowrap;
- flex: 0 0 auto;
- overflow: visible;
- isolation: isolate;
- border: 1px solid transparent;
- background:
- radial-gradient(
- circle at 20% 20%,
- rgba(255, 255, 255, 0.9) 0%,
- rgba(255, 255, 255, 0.3) 22%,
- rgba(255, 255, 255, 0) 46%
- )
- padding-box,
- linear-gradient(
- 100deg,
- transparent 40%,
- rgba(255, 255, 255, 0.38) 46%,
- rgba(255, 255, 255, 0.96) 50%,
- rgba(255, 255, 255, 0.38) 54%,
- transparent 60%
- )
- padding-box,
- linear-gradient(78deg, transparent 28%, rgba(80, 96, 118, 0.42) 50%, transparent 72%)
- padding-box,
- linear-gradient(92deg, transparent 44%, rgba(196, 226, 252, 0.32) 50%, transparent 56%)
- padding-box,
- linear-gradient(
- to bottom,
- #ffffff 0%,
- #e3eaf1 26%,
- #a5b2c2 45%,
- #78879a 54%,
- #c3cdd8 78%,
- #f2f6fa 100%
- )
- padding-box,
- linear-gradient(135deg, #ffffff 0%, rgba(140, 158, 178, 0.6) 42%, rgba(56, 66, 80, 0.95) 100%)
- border-box;
- background-repeat: no-repeat;
- background-size:
- auto,
- 200% 100%,
- 260% 100%,
- 300% 100%,
- auto,
- auto;
- background-position:
- 0 0,
- -50% 0,
- 170% 0,
- -80% 0,
- 0 0,
- 0 0;
- animation: eliteLiquid 12s ease-in-out infinite alternate;
- box-shadow:
- 0 1px 3px rgba(30, 42, 58, 0.28),
- inset 0 1px 1px rgba(255, 255, 255, 0.92),
- inset 0 -1.5px 2px rgba(60, 74, 92, 0.4),
- 0 0 14px rgba(130, 160, 200, 0.36);
- color: #1f2a38;
- text-shadow: 0 1px 0 rgba(255, 255, 255, 0.65);
- text-transform: capitalize;
-
- /* 呼吸光晕:与金卡同一套 blur 光晕构造,外加 7s 极缓呼吸 */
- &::before {
- content: '';
- position: absolute;
- inset: -6px -9px;
- border-radius: inherit;
- background: radial-gradient(
- circle at 24% 30%,
- rgba(255, 255, 255, 0.95) 0%,
- rgba(196, 220, 244, 0.5) 38%,
- rgba(178, 205, 238, 0) 74%
- );
- --glass-blur: 9px;
- filter: var(--glass-filter);
- opacity: 0.75;
- animation: eliteBreathe 7s ease-in-out infinite alternate;
- pointer-events: none;
- z-index: -1;
- }
-
- /* 巡回扫光:一道冷白亮带扫过镜面,只占 7.5s 周期的前 13%,其余时间静止 */
- &::after {
- content: '';
- position: absolute;
- inset: 1px;
- border-radius: inherit;
- background-image: linear-gradient(
- 105deg,
- transparent 38%,
- rgba(255, 255, 255, 0.08) 44%,
- rgba(255, 255, 255, 0.75) 50%,
- rgba(255, 255, 255, 0.08) 56%,
- transparent 62%
- );
- background-repeat: no-repeat;
- background-size: 260% 100%;
- background-position: 240% 0;
- animation: eliteSheen 7.5s cubic-bezier(0.4, 0, 0.2, 1) 1.2s infinite;
- pointer-events: none;
- }
-
- /* 中性铬没有彩度可扩,P3 分支无收益(金卡需要它是因为暖金有色域外的彩度)。
- * HDR 增益全部走 rec2100 超白:扫光与外发光在 HDR 屏上亮过 SDR 白。 */
- @supports (color: #{'color(rec2100-linear 1 1 1)'}) {
- @media (dynamic-range: high) {
- dynamic-range-limit: no-limit;
- box-shadow:
- 0 1px 3px rgba(30, 42, 58, 0.28),
- inset 0 1px 1px rgba(255, 255, 255, 0.92),
- inset 0 -1.5px 2px rgba(60, 74, 92, 0.4),
- 0 0 22px #{'color(rec2100-linear 1.5 1.8 2.3 / 0.42)'};
-
- &::after {
- background-image: linear-gradient(
- 105deg,
- transparent 38%,
- #{'color(rec2100-linear 1.2 1.3 1.5 / 0.1)'} 44%,
- #{'color(rec2100-linear 2.6 2.8 3.2 / 0.8)'} 50%,
- #{'color(rec2100-linear 1.2 1.3 1.5 / 0.1)'} 56%,
- transparent 62%
- );
- }
- }
- }
-}
-
-@keyframes eliteLiquid {
- from {
- background-position:
- 0 0,
- -50% 0,
- 170% 0,
- -80% 0,
- 0 0,
- 0 0;
- }
- to {
- background-position:
- 0 0,
- 150% 0,
- -70% 0,
- 190% 0,
- 0 0,
- 0 0;
- }
-}
-
-@keyframes eliteSheen {
- 0% {
- background-position: 240% 0;
- }
- 13% {
- background-position: -140% 0;
- }
- 100% {
- background-position: -140% 0;
- }
-}
-
-@keyframes eliteBreathe {
- from {
- opacity: 0.55;
- }
- to {
- opacity: 0.9;
- }
-}
-
-/* 暗色:铬体不换血统只提亮 —— 暗卡上它自己就是光源;文字保持深灰蓝。 */
-:global([data-theme='dark']) .elitePlanValue {
- background:
- radial-gradient(
- circle at 20% 20%,
- rgba(255, 255, 255, 0.9) 0%,
- rgba(255, 255, 255, 0.3) 22%,
- rgba(255, 255, 255, 0) 46%
- )
- padding-box,
- linear-gradient(
- 100deg,
- transparent 40%,
- rgba(255, 255, 255, 0.4) 46%,
- #ffffff 50%,
- rgba(255, 255, 255, 0.4) 54%,
- transparent 60%
- )
- padding-box,
- linear-gradient(78deg, transparent 28%, rgba(80, 96, 118, 0.46) 50%, transparent 72%)
- padding-box,
- linear-gradient(92deg, transparent 44%, rgba(196, 226, 252, 0.34) 50%, transparent 56%)
- padding-box,
- linear-gradient(
- to bottom,
- #ffffff 0%,
- #eef2f6 30%,
- #c2ccd7 47%,
- #8b98a8 54%,
- #d6dee6 80%,
- #f7fafc 100%
- )
- padding-box,
- linear-gradient(
- 135deg,
- rgba(255, 255, 255, 0.98) 0%,
- rgba(155, 175, 195, 0.55) 45%,
- rgba(90, 104, 120, 0.9) 100%
- )
- border-box;
- background-repeat: no-repeat;
- background-size:
- auto,
- 200% 100%,
- 260% 100%,
- 300% 100%,
- auto,
- auto;
- background-position:
- 0 0,
- -50% 0,
- 170% 0,
- -80% 0,
- 0 0,
- 0 0;
- box-shadow:
- 0 1px 5px rgba(0, 0, 0, 0.4),
- inset 0 1px 1px rgba(255, 255, 255, 0.92),
- inset 0 -1.5px 2px rgba(74, 90, 110, 0.4),
- 0 0 18px rgba(190, 218, 250, 0.36);
-
- @supports (color: #{'color(rec2100-linear 1 1 1)'}) {
- @media (dynamic-range: high) {
- dynamic-range-limit: no-limit;
- box-shadow:
- 0 1px 5px rgba(0, 0, 0, 0.4),
- inset 0 1px 1px rgba(255, 255, 255, 0.92),
- inset 0 -1.5px 2px rgba(74, 90, 110, 0.4),
- 0 0 26px #{'color(rec2100-linear 1.7 2.1 2.6 / 0.44)'};
- }
- }
-}
-
-/* 减动效:三条动画全停,亮带冻结在压字前的一帧 —— 静态也是完成品。 */
-@media (prefers-reduced-motion: reduce) {
- .elitePlanValue,
- :global([data-theme='dark']) .elitePlanValue {
- animation: none;
- background-position:
- 0 0,
- 42% 0,
- 60% 0,
- 30% 0,
- 0 0,
- 0 0;
-
- &::before {
- animation: none;
- opacity: 0.75;
- }
-
- &::after {
- content: none;
- }
- }
-}
-
-@include mobile {
- .premiumPlanValue {
- overflow: hidden;
- background:
- radial-gradient(
- circle at 18% 24%,
- rgba(255, 255, 255, 0.88) 0%,
- rgba(255, 255, 255, 0.58) 18%,
- rgba(255, 255, 255, 0) 42%
- ),
- linear-gradient(135deg, #fff9e3 0%, #ffe07f 52%, #e0aa14 100%);
- box-shadow:
- 0 1px 3px rgba(133, 92, 0, 0.16),
- 0 0 0 1px rgba(255, 255, 255, 0.22) inset,
- 0 0 10px rgba(255, 214, 98, 0.22);
-
- &::before {
- content: none;
- }
- }
-
- :global([data-theme='dark']) .premiumPlanValue {
- background:
- radial-gradient(
- circle at 18% 24%,
- rgba(255, 229, 138, 0.28) 0%,
- rgba(255, 214, 98, 0.14) 18%,
- rgba(255, 214, 98, 0) 44%
- ),
- linear-gradient(135deg, #4f3d0b 0%, #6e5510 48%, #8f6d10 100%);
- box-shadow:
- 0 1px 6px rgba(0, 0, 0, 0.28),
- 0 0 0 1px rgba(255, 220, 120, 0.16) inset,
- 0 0 10px rgba(255, 196, 44, 0.18);
- }
-
- // 铂金徽章移动端:与金卡同一刀 —— 砍模糊光晕层(呼吸随之消失)、收一档外发光。
- // 液态对流与扫光只是 background-position 重绘,~70×20px 成本可忽略,保留。
- .elitePlanValue {
- overflow: hidden;
- box-shadow:
- 0 1px 3px rgba(30, 42, 58, 0.28),
- inset 0 1px 1px rgba(255, 255, 255, 0.92),
- inset 0 -1.5px 2px rgba(60, 74, 92, 0.4),
- 0 0 9px rgba(130, 160, 200, 0.3);
-
- &::before {
- content: none;
- }
- }
-
- :global([data-theme='dark']) .elitePlanValue {
- box-shadow:
- 0 1px 5px rgba(0, 0, 0, 0.4),
- inset 0 1px 1px rgba(255, 255, 255, 0.92),
- inset 0 -1.5px 2px rgba(74, 90, 110, 0.4),
- 0 0 10px rgba(190, 218, 250, 0.3);
- }
-}
diff --git a/frontend/src/features/quota/components/QuotaCard.module.scss b/frontend/src/features/quota/components/QuotaCard.module.scss
deleted file mode 100644
index c80841f..0000000
--- a/frontend/src/features/quota/components/QuotaCard.module.scss
+++ /dev/null
@@ -1,310 +0,0 @@
-@use '../../../styles/mixins' as *;
-
-/* ============================================================
- * 额度卡片 — 继承凭证卡 statTile 配方(14px 圆角 / 1px 边框 / 82% 透感纸面)。
- * 提供商身份只由图标承载(混合网格下五色渐变卡底会互相打架,已退役)。
- * 遥测内容一律 mono + tabular-nums。
- * ============================================================ */
-
-.card {
- position: relative;
- display: flex;
- flex-direction: column;
- gap: 11px;
- padding: 15px 16px;
- border-radius: 14px;
- border: 1px solid var(--border-color);
- background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
- transition:
- transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- box-shadow var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
-}
-
-@media (hover: hover) and (pointer: fine) {
- .card:hover {
- transform: translateY(-2px);
- border-color: var(--border-hover);
- box-shadow: 0 12px 26px -14px rgb(0 0 0 / 0.16);
- }
-}
-
-/* 首次数据到达时的一次性级联入场;切 tab / 翻页 / 刷新不重播 */
-.cardEnter {
- animation: quota-card-in 0.45s var(--ease-out-strong, ease-out) both;
- animation-delay: var(--card-delay, 0s);
-}
-
-@keyframes quota-card-in {
- from {
- opacity: 0;
- transform: translate3d(0, 16px, 0);
- }
-}
-
-/* ---------- 头部:品牌图标 + 文件名 ---------- */
-
-.head {
- display: flex;
- align-items: center;
- gap: 9px;
- min-width: 0;
-}
-
-.iconWrap {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- border-radius: 8px;
- background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
-}
-
-.icon {
- width: 16px;
- height: 16px;
- object-fit: contain;
- display: block;
-}
-
-.iconFallback {
- font-size: 11px;
- font-weight: 700;
- color: var(--text-secondary);
-}
-
-.fileName {
- min-width: 0;
- font-family: $font-mono;
- font-size: 12.5px;
- font-weight: 600;
- letter-spacing: 0.01em;
- color: var(--text-primary);
- @include text-ellipsis;
-}
-
-/* ---------- body 容器 ---------- */
-
-.body {
- display: flex;
- flex-direction: column;
- gap: 9px;
- min-width: 0;
-}
-
-/* ---------- idle:整块 body 即点击加载的落点 ---------- */
-
-.idleBody {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 6px;
- width: 100%;
- min-height: 76px;
- padding: 12px 10px;
- cursor: pointer;
- border: 1px dashed var(--border-color);
- border-radius: 10px;
- background: none;
- color: var(--text-tertiary);
- transition:
- transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
- border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.55;
- }
-
- &:active:not(:disabled) {
- transform: scale(0.98);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-}
-
-@media (hover: hover) and (pointer: fine) {
- .idleBody:hover:not(:disabled) {
- border-color: color-mix(in srgb, var(--primary-color) 35%, var(--border-color));
- border-style: solid;
- color: var(--text-secondary);
- }
-}
-
-.idleGlyph {
- flex-shrink: 0;
- opacity: 0.8;
-}
-
-.idleHint {
- font-family: $font-mono;
- font-size: 11.5px;
- line-height: 1.5;
- text-align: center;
-}
-
-/* ---------- loading:双幽灵行骨架 ---------- */
-
-.skeleton {
- display: flex;
- flex-direction: column;
- gap: 11px;
- padding: 4px 0 2px;
-}
-
-.skeletonRow {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-/* 骨架:底色 + 一道 1.2s 匀速扫光(linear —— 环境性匀速运动的唯一例外) */
-.skeletonLabel,
-.skeletonTrack {
- display: block;
- border-radius: $radius-full;
- background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);
- background-image: linear-gradient(
- 90deg,
- transparent 20%,
- color-mix(in srgb, var(--text-primary) 6%, transparent) 50%,
- transparent 80%
- );
- background-repeat: no-repeat;
- background-size: 220% 100%;
- animation: quota-skeleton-sweep 1.2s linear infinite;
-}
-
-@keyframes quota-skeleton-sweep {
- from {
- background-position: -110% 0;
- }
- to {
- background-position: 210% 0;
- }
-}
-
-.skeletonLabel {
- width: 40%;
- height: 10px;
-}
-
-.skeletonTrack {
- width: 100%;
- height: 6px;
-}
-
-.srOnly {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
- border: 0;
-}
-
-/* ---------- error ---------- */
-
-.errorStrip {
- font-size: 11.5px;
- line-height: 1.5;
- color: var(--viz-failure);
- background: color-mix(in srgb, var(--viz-failure) 8%, transparent);
- border: 1px solid color-mix(in srgb, var(--viz-failure) 32%, transparent);
- border-radius: 9px;
- padding: 8px 10px;
- overflow-wrap: anywhere;
-}
-
-/* ---------- footer 动作 ---------- */
-
-.actionRow {
- display: flex;
- justify-content: flex-end;
- gap: 6px;
- flex-wrap: wrap;
-}
-
-/* 安静描边药丸(与凭证卡图标动作同族:hover 才显色块) */
-.actionPill {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- cursor: pointer;
- border: 1px solid var(--border-color);
- border-radius: $radius-full;
- background: none;
- padding: 5px 11px;
- font-size: 11.5px;
- font-weight: 600;
- line-height: 1.4;
- color: var(--text-secondary);
- white-space: nowrap;
- transition:
- transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
- border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.5;
- }
-
- &:active:not(:disabled) {
- transform: scale(0.97);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-}
-
-@media (hover: hover) and (pointer: fine) {
- .actionPill:hover:not(:disabled) {
- border-color: var(--border-hover);
- background: color-mix(in srgb, var(--bg-tertiary) 55%, transparent);
- color: var(--text-primary);
- }
-}
-
-.spinning {
- animation: quota-card-spin 0.8s linear infinite;
-}
-
-@keyframes quota-card-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .card,
- .idleBody,
- .actionPill {
- transition: none;
- }
-
- .idleBody:active:not(:disabled),
- .actionPill:active:not(:disabled) {
- transform: none;
- }
-
- .cardEnter,
- .skeletonLabel,
- .skeletonTrack,
- .spinning {
- animation: none;
- }
-}
diff --git a/frontend/src/features/quota/components/QuotaCard.tsx b/frontend/src/features/quota/components/QuotaCard.tsx
deleted file mode 100644
index 0c5303a..0000000
--- a/frontend/src/features/quota/components/QuotaCard.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * 额度卡片:头部(提供商图标 + mono 文件名)+ 四态 body + 动作 footer。
- *
- * - idle:整个 body 是一个点击加载按钮(上游直连有速率考虑,不自动拉取);
- * - loading:双幽灵行骨架(aria-busy,文字等价视觉隐藏);
- * - error:失败色条 + footer 刷新即重试;
- * - success:provider Body(穿 QuotaBody.module.scss 全页外衣)。
- */
-
-import { useState, type CSSProperties } from 'react';
-import { useTranslation } from 'react-i18next';
-import { IconRefreshCw } from '@/components/ui/icons';
-import type { ResolvedTheme } from '@/types';
-import { resolveQuotaErrorMessage } from '@/utils/quota';
-import {
- getAuthFileIcon,
- getThemeSurfaceIconBackground,
- getTypeLabel,
- isThemeSurfaceIconProvider,
-} from '@/features/authFiles/constants';
-import { bindQuotaClasses } from '../types';
-import { QUOTA_ADAPTERS, type QuotaCardState } from '../providers';
-import { isQuotaRefreshDisabled, type QuotaFileEntry } from '../logic';
-import bodyStyles from './QuotaBody.module.scss';
-import styles from './QuotaCard.module.scss';
-
-/** 额度页全页外衣:QuotaBody 模块绑定成类型化契约(缺键在模块初始化即抛)。 */
-const quotaClasses = bindQuotaClasses(bodyStyles, 'QuotaBody.module.scss');
-
-export type QuotaCardProps = {
- entry: QuotaFileEntry;
- quota?: QuotaCardState;
- resolvedTheme: ResolvedTheme;
- canRefresh: boolean;
- resetting: boolean;
- /** 首屏级联入场延迟;null = 不入场(切 tab / 翻页 / 刷新新挂载的卡片)。 */
- entranceDelayMs?: number | null;
- onRefresh: () => void;
- onReset: () => void;
-};
-
-export function QuotaCard(props: QuotaCardProps) {
- const {
- entry,
- quota,
- resolvedTheme,
- canRefresh,
- resetting,
- entranceDelayMs,
- onRefresh,
- onReset,
- } = props;
- const { t } = useTranslation();
- const adapter = QUOTA_ADAPTERS[entry.type];
- const file = entry.file;
-
- // 挂载时捕获一次延迟:后续 props 变 null 不影响本卡(React 19 禁渲染期读 ref)
- const [mountEntranceDelayMs] = useState(entranceDelayMs ?? null);
- const entranceStyle =
- mountEntranceDelayMs === null
- ? undefined
- : ({ '--card-delay': `${mountEntranceDelayMs}ms` } as CSSProperties);
-
- const status = quota?.status ?? 'idle';
- const loading = status === 'loading';
- const iconSrc = getAuthFileIcon(entry.type, resolvedTheme);
- const typeLabel = getTypeLabel(t, entry.type);
- const errorMessage = resolveQuotaErrorMessage(
- t,
- quota?.errorStatus,
- quota?.error || t('common.unknown_error')
- );
- const showReset =
- status === 'success' &&
- Boolean(adapter.resetQuota) &&
- quota !== undefined &&
- Boolean(adapter.canResetQuota?.(quota));
-
- return (
-
-
-
- {iconSrc ? (
-
- ) : (
- {typeLabel.slice(0, 1).toUpperCase()}
- )}
-
-
- {file.name}
-
-
-
-
- {status === 'idle' ? (
-
- ) : loading ? (
-
-
{t(`${adapter.i18nPrefix}.loading`)}
- {[0, 1].map((row) => (
-
-
-
-
- ))}
-
- ) : status === 'error' ? (
-
- {t(`${adapter.i18nPrefix}.load_failed`, { message: errorMessage })}
-
- ) : quota ? (
-
- ) : (
-
{t(`${adapter.i18nPrefix}.idle`)}
- )}
-
-
- {status !== 'idle' && (
-
- )}
-
- );
-}
diff --git a/frontend/src/features/quota/components/QuotaHeader.module.scss b/frontend/src/features/quota/components/QuotaHeader.module.scss
deleted file mode 100644
index 7dcfff7..0000000
--- a/frontend/src/features/quota/components/QuotaHeader.module.scss
+++ /dev/null
@@ -1,159 +0,0 @@
-@use '../../../styles/mixins' as *;
-
-/* 额度页头部:与凭证库/仪表盘同语汇(紧排标题 / ▍mono 遥测 / 墨色药丸)。
- * meta 行遥测语义:已加载数是「活数据」(绿),需关注数走失败色,其余中性。 */
-
-.header {
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- gap: 16px 24px;
- flex-wrap: wrap;
-}
-
-.copy {
- display: flex;
- flex-direction: column;
- gap: 7px;
- min-width: 0;
-}
-
-.title {
- margin: 0;
- font-size: clamp(26px, 3.2vw, 30px);
- line-height: 1.15;
- font-weight: 700;
- letter-spacing: -0.02em;
- color: var(--text-primary);
-}
-
-.meta {
- margin: 0;
- display: flex;
- align-items: baseline;
- flex-wrap: wrap;
- gap: 4px 8px;
- font-family: $font-mono;
- font-size: 13px;
- font-weight: 500;
- font-variant-numeric: tabular-nums;
- letter-spacing: 0.02em;
- color: var(--text-secondary);
-
- /* 终端游标:结构记号,与凭证库 meta 行同语汇 */
- &::before {
- content: '▍';
- color: var(--viz-success);
- font-size: 13px;
- line-height: 1;
- align-self: center;
- margin-right: 1px;
- }
-}
-
-.metaDot {
- color: var(--text-quaternary);
- user-select: none;
-}
-
-.metaTotal {
- color: var(--text-secondary);
-}
-
-.metaLoaded {
- color: var(--viz-success);
-}
-
-.metaMuted {
- color: var(--text-tertiary);
-}
-
-.metaAttention {
- color: var(--viz-failure);
-}
-
-/* ---------- 动作区 ---------- */
-
-.actions {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-shrink: 0;
-}
-
-/* 墨色药丸主按钮(深色主题自动反相) */
-.primaryAction {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- border: 0;
- cursor: pointer;
- background: var(--text-primary);
- color: var(--bg-secondary);
- border-radius: $radius-full;
- padding: 10px 18px;
- font-size: 13.5px;
- font-weight: 600;
- line-height: 1;
- transition:
- transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
- background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
- box-shadow var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.55;
- }
-
- &:active:not(:disabled) {
- transform: translateY(0) scale(0.97);
- }
-
- &:focus-visible {
- outline: 2px solid var(--primary-color);
- outline-offset: 2px;
- }
-}
-
-@media (hover: hover) and (pointer: fine) {
- .primaryAction:hover:not(:disabled) {
- transform: translateY(-1px);
- background: color-mix(in srgb, var(--text-primary) 86%, var(--bg-secondary));
- box-shadow: 0 12px 26px color-mix(in srgb, var(--text-primary) 22%, transparent);
- }
-}
-
-.spinning {
- animation: quota-spin 0.8s linear infinite;
-}
-
-@keyframes quota-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-@include mobile {
- .header {
- align-items: stretch;
- flex-direction: column;
- }
-
- .actions {
- justify-content: flex-start;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .primaryAction {
- transition: none;
- }
-
- .primaryAction:active:not(:disabled) {
- transform: none;
- }
-
- .spinning {
- animation: none;
- }
-}
diff --git a/frontend/src/features/quota/components/QuotaHeader.tsx b/frontend/src/features/quota/components/QuotaHeader.tsx
deleted file mode 100644
index f930319..0000000
--- a/frontend/src/features/quota/components/QuotaHeader.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { IconRefreshCw } from '@/components/ui/icons';
-import { useCountUp } from '@/hooks/motion';
-import styles from './QuotaHeader.module.scss';
-
-export type QuotaHeaderProps = {
- totalCount: number;
- loadedCount: number;
- attentionCount: number;
- refreshing: boolean;
- disableControls: boolean;
- onRefreshAll: () => void;
-};
-
-/**
- * 额度页头部:标题领衔 + ▍mono 遥测 meta 行 + 墨色药丸「刷新全部」。
- * 与凭证库头部同语汇(无 eyebrow —— ▍游标挂在 meta 行开头)。
- *
- * 入场:三处 `data-reveal` 交给页面壳的 useRevealGroup 统一编排
- * (标题 0ms → meta 70ms → 动作 140ms → tabs 210ms)。
- */
-export function QuotaHeader(props: QuotaHeaderProps) {
- const { totalCount, loadedCount, attentionCount, refreshing, disableControls, onRefreshAll } =
- props;
- const { t } = useTranslation();
- // 批量结果陆续落地时,「已加载」是页面上唯一滚动的数字
- const displayLoadedCount = useCountUp(loadedCount);
-
- return (
-
-
-
- {t('quota_management.title')}
-
-
-
- {t('quota_management.meta_credentials', { count: totalCount })}
-
-
- ·
-
- 0 ? styles.metaLoaded : styles.metaMuted}>
- {t('quota_management.meta_loaded', { count: displayLoadedCount })}
-
- {attentionCount > 0 && (
- <>
-
- ·
-
-
- {t('quota_management.meta_attention', { count: attentionCount })}
-
- >
- )}
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/quota/components/QuotaMeter.tsx b/frontend/src/features/quota/components/QuotaMeter.tsx
deleted file mode 100644
index a07df6b..0000000
--- a/frontend/src/features/quota/components/QuotaMeter.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * 额度水位条(原 QuotaProgressBar 的类型化后继)。
- *
- * dataviz 语法:细轨道退居背景,填充按剩余量三档着色(≥70 绿 / ≥30 琥珀 / <30 红),
- * percent === null 渲染空轨道 —— 未知不着色(Medium 类在 width 0 下不可见,行为与旧版一致)。
- * `index` 写入 `--meter-index`,供全页外衣做逐行入场级差;紧凑外衣不消费该变量。
- */
-
-import type { CSSProperties } from 'react';
-import type { QuotaClassMap } from '../types';
-
-export const QUOTA_PROGRESS_HIGH_THRESHOLD = 70;
-export const QUOTA_PROGRESS_MEDIUM_THRESHOLD = 30;
-
-export interface QuotaMeterProps {
- percent: number | null;
- classes: QuotaClassMap;
- index?: number;
-}
-
-export function QuotaMeter({ percent, classes, index }: QuotaMeterProps) {
- const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
- const normalized = percent === null ? null : clamp(percent, 0, 100);
- const fillClass =
- normalized === null
- ? classes.quotaBarFillMedium
- : normalized >= QUOTA_PROGRESS_HIGH_THRESHOLD
- ? classes.quotaBarFillHigh
- : normalized >= QUOTA_PROGRESS_MEDIUM_THRESHOLD
- ? classes.quotaBarFillMedium
- : classes.quotaBarFillLow;
- const widthPercent = Math.round((normalized ?? 0) * 100) / 100;
- const style: CSSProperties & { '--meter-index'?: number } = { width: `${widthPercent}%` };
- if (index !== undefined) {
- style['--meter-index'] = index;
- }
-
- return (
-
- );
-}
diff --git a/frontend/src/features/quota/components/QuotaResetLabel.tsx b/frontend/src/features/quota/components/QuotaResetLabel.tsx
deleted file mode 100644
index dc24cd0..0000000
--- a/frontend/src/features/quota/components/QuotaResetLabel.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * `08-13 14:30 · in 11 days` — the absolute instant plus its countdown.
- *
- * Shared by every provider body so the two halves can never drift apart in
- * markup or spacing. The separator lives in CSS (`.quotaResetRelative::before`)
- * rather than here, so the relative half stays independently styleable.
- */
-
-import type { ResetDisplay } from '@/utils/quota';
-import type { QuotaClassMap } from '../types';
-
-export interface QuotaResetLabelProps {
- display: ResetDisplay;
- classes: QuotaClassMap;
- /** True on the row that recovers first for this credential. */
- soon?: boolean;
-}
-
-export function QuotaResetLabel({ display, classes, soon = false }: QuotaResetLabelProps) {
- return (
- <>
- {display.absolute}
- {display.relative && (
-
- {display.relative}
-
- )}
- >
- );
-}
diff --git a/frontend/src/features/quota/components/QuotaTimeline.module.scss b/frontend/src/features/quota/components/QuotaTimeline.module.scss
deleted file mode 100644
index 5cb3a81..0000000
--- a/frontend/src/features/quota/components/QuotaTimeline.module.scss
+++ /dev/null
@@ -1,396 +0,0 @@
-@use '../../../styles/variables' as *;
-@use '../../../styles/mixins' as *;
-
-// Fixed-width lane column so every row's bars start at the same x — the whole
-// point of the chart is comparing when lanes end relative to each other.
-$lane-width: 210px;
-
-.timeline {
- display: flex;
- flex-direction: column;
- gap: $spacing-md;
-}
-
-.head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: $spacing-md;
- flex-wrap: wrap;
-}
-
-.title {
- margin: 0;
- font-size: 18px;
- font-weight: 650;
- color: var(--text-primary);
-}
-
-.range {
- margin: 2px 0 0;
- font-size: 12px;
- color: var(--text-quaternary);
- font-variant-numeric: tabular-nums;
-}
-
-.controls {
- display: flex;
- align-items: center;
- gap: $spacing-sm;
- flex-wrap: wrap;
-}
-
-.nav,
-.modes {
- display: inline-flex;
- align-items: center;
- gap: 2px;
- padding: 3px;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 999px;
-
- button {
- padding: 4px 12px;
- font: inherit;
- font-size: 12.5px;
- color: var(--text-secondary);
- background: transparent;
- border: 1px solid transparent;
- border-radius: 999px;
- cursor: pointer;
- transition: all $transition-fast;
-
- &:hover:not(:disabled) {
- color: var(--text-primary);
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: default;
- }
-
- &[aria-pressed='true'] {
- background: var(--floating-surface, var(--bg-primary));
- border-color: var(--border-hover);
- color: var(--text-primary);
- }
- }
-}
-
-.chart {
- border: 1px solid var(--border-color);
- border-radius: $radius-lg;
- background: var(--bg-primary);
- overflow: hidden;
-}
-
-.empty {
- min-height: 96px;
- display: grid;
- place-items: center;
- padding: $spacing-lg;
- color: var(--text-quaternary);
- font-size: 12px;
- text-align: center;
-}
-
-.axis,
-.lane {
- display: grid;
- grid-template-columns: $lane-width 1fr;
-}
-
-.axis {
- border-bottom: 1px solid var(--border-color);
-}
-
-.axisLabel {
- padding: 8px 12px;
- font-size: 10.5px;
- font-weight: 600;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--text-quaternary);
- display: flex;
- align-items: flex-end;
-}
-
-.axisCells {
- display: flex;
-}
-
-.axisCell {
- flex: 1 1 0;
- min-width: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 1px;
- padding: 6px 2px;
- border-left: 1px solid var(--border-color);
-
- &[data-weekend='1'] {
- background: rgba(255, 255, 255, 0.014);
- }
-
- // The current day is the reference point for reading the whole chart.
- &[data-today='1'] {
- background: rgba(255, 255, 255, 0.03);
- }
-}
-
-.axisWeekday {
- font-size: 10px;
- color: var(--text-quaternary);
- min-height: 12px;
-}
-
-.axisDate {
- font-size: 11.5px;
- font-weight: 600;
- color: var(--text-secondary);
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
-}
-
-.lane {
- border-top: 1px solid var(--border-color);
-
- &:hover {
- background: rgba(255, 255, 255, 0.012);
- }
-}
-
-.laneHead {
- display: flex;
- flex-direction: column;
- gap: 3px;
- padding: 9px 12px;
- min-width: 0;
-}
-
-.laneTop {
- display: flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
-}
-
-.laneDot {
- flex: none;
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--provider-accent, var(--text-quaternary));
-}
-
-.laneName {
- font-size: 12.5px;
- font-weight: 600;
- color: var(--text-primary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.lanePeriod {
- flex: none;
- padding: 0 6px;
- border-radius: 999px;
- font-size: 10px;
- font-weight: 600;
- color: var(--count-badge-text, var(--text-secondary));
- background: var(--count-badge-bg, var(--bg-tertiary));
-}
-
-.laneLimits {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
-}
-
-.laneLimit {
- padding: 1px 6px;
- border-radius: $radius-sm;
- font-size: 10.5px;
- color: var(--text-quaternary);
- background: var(--bg-tertiary);
-
- b {
- color: var(--text-secondary);
- font-variant-numeric: tabular-nums;
- }
-}
-
-.track {
- position: relative;
- min-height: 46px;
- display: flex;
- align-items: center;
-}
-
-.trackGrid {
- position: absolute;
- inset: 0;
- display: flex;
-
- > span {
- flex: 1 1 0;
- border-left: 1px solid var(--border-color);
-
- &[data-weekend='1'] {
- background: rgba(255, 255, 255, 0.014);
- }
- }
-}
-
-// Vertical "now" marker, above the grid but below the bars so it never hides
-// a label.
-.nowLine {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 1px;
- background: var(--text-tertiary, var(--text-quaternary));
- opacity: 0.8;
- z-index: 1;
-}
-
-.window {
- position: relative;
- z-index: 2;
- height: 22px;
- display: flex;
- align-items: center;
- padding: 0 8px;
- border-radius: 999px;
- overflow: hidden;
- font-size: 10.5px;
- white-space: nowrap;
- position: absolute;
-}
-
-// Current window: solid, in the provider's colour — the row's focal point.
-.windowLive {
- background: color-mix(in srgb, var(--provider-accent) 26%, transparent);
- border: 1px solid color-mix(in srgb, var(--provider-accent) 45%, transparent);
- color: var(--text-primary);
-}
-
-// Upcoming: dashed outline, nothing consumed yet.
-.windowNext {
- background: transparent;
- border: 1px dashed color-mix(in srgb, var(--provider-accent) 32%, transparent);
- color: var(--text-quaternary);
-}
-
-// Elapsed: recedes.
-.windowPast {
- background: color-mix(in srgb, var(--provider-accent) 8%, transparent);
- border: 1px solid transparent;
- color: var(--text-quaternary);
-}
-
-// Consumed portion of the current window, drawn behind its label.
-.windowFill {
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- background: color-mix(in srgb, var(--provider-accent) 34%, transparent);
- pointer-events: none;
-}
-
-.windowLabel {
- position: relative;
- overflow: hidden;
- text-overflow: ellipsis;
- font-variant-numeric: tabular-nums;
-}
-
-// A reset credit is use-it-or-lose-it, so its expiry sits above the window bar
-// as a narrow amber tick without competing with the bar's own label.
-.resetCreditTick {
- position: absolute;
- z-index: 3;
- top: 6px;
- bottom: 6px;
- width: 2px;
- transform: translateX(-50%);
- border-radius: 999px;
- background: var(--amber-color);
- box-shadow: 0 0 0 1px var(--bg-primary);
- cursor: help;
-
- &:hover {
- width: 4px;
- }
-}
-
-.laneIdle {
- position: relative;
- z-index: 2;
- padding-left: 10px;
- font-size: 11px;
- color: var(--text-quaternary);
-}
-
-.legend {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: $spacing-md;
- font-size: 11px;
- color: var(--text-quaternary);
-}
-
-.legendItem {
- display: inline-flex;
- align-items: center;
- gap: 6px;
-}
-
-.swatch {
- width: 22px;
- height: 10px;
- border-radius: 999px;
-}
-
-.swatchLive {
- background: color-mix(in srgb, var(--text-tertiary, #888) 34%, transparent);
- border: 1px solid color-mix(in srgb, var(--text-tertiary, #888) 50%, transparent);
-}
-
-.swatchNext {
- border: 1px dashed color-mix(in srgb, var(--text-tertiary, #888) 45%, transparent);
-}
-
-.swatchPast {
- background: color-mix(in srgb, var(--text-tertiary, #888) 12%, transparent);
-}
-
-.swatchCredit {
- width: 2px;
- height: 14px;
- border-radius: 999px;
- background: var(--amber-color);
-}
-
-.legendNote {
- flex: 1 1 320px;
- min-width: 0;
-}
-
-@include mobile {
- // The lane column can't shrink much before names become unreadable, so the
- // chart scrolls horizontally rather than compressing to illegibility.
- .chart {
- overflow-x: auto;
- }
-
- .axis,
- .lane {
- min-width: 720px;
- }
-}
diff --git a/frontend/src/features/quota/components/QuotaTimeline.tsx b/frontend/src/features/quota/components/QuotaTimeline.tsx
deleted file mode 100644
index 9914777..0000000
--- a/frontend/src/features/quota/components/QuotaTimeline.tsx
+++ /dev/null
@@ -1,457 +0,0 @@
-/**
- * Quota windows timeline.
- *
- * The cards answer "how much is left"; this answers "when does it come back,
- * and does it all come back at once". Four credentials resetting the same
- * evening is a very different position from four staggered across a week, and
- * no per-card percentage shows that.
- *
- * All projection maths lives in quotaTimelineModel.ts — this file is layout
- * only. (The model is named ...Model rather than matching this component,
- * because a case-insensitive filesystem cannot hold both QuotaTimeline.tsx and
- * quotaTimeline.ts.)
- */
-
-import { useMemo, useState } from 'react';
-import type { CSSProperties } from 'react';
-import { useTranslation } from 'react-i18next';
-import { formatRelativeInstant, TYPE_COLORS } from '@/utils/quota';
-import { useNow } from '@/hooks/useNow';
-import type { ResolvedTheme, ThemeColors } from '@/types';
-import {
- buildTimelineLane,
- laneHasWindow,
- projectLane,
- projectResetCredits,
- timelineSpan,
- DAY_MS,
-} from '../quotaTimelineModel';
-import type { TimelineLane, TimelineMode } from '../quotaTimelineModel';
-import type { QuotaFileEntry } from '../logic';
-import type { QuotaCardState } from '../providers';
-import styles from './QuotaTimeline.module.scss';
-
-const WEEKDAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
-
-const pad = (value: number) => String(value).padStart(2, '0');
-const formatDay = (ms: number) => {
- const d = new Date(ms);
- return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
-};
-const formatTime = (ms: number) => {
- const d = new Date(ms);
- return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
-};
-
-export interface QuotaTimelineProps {
- entries: QuotaFileEntry[];
- /**
- * Quota state for an entry. An entry carries only the file and its provider —
- * loaded quota lives in the store — so the lookup is injected rather than read
- * off the entry, and lanes see exactly what the cards see.
- */
- quotaFor: (entry: QuotaFileEntry) => QuotaCardState | undefined;
- displayNameFor: (name: string) => string;
- resolvedTheme: ResolvedTheme;
- /** Injectable for tests/screenshots; defaults to the real clock. */
- now?: number;
- /** Injectable initial zoom for tests/screenshots; defaults to the weekly view. */
- initialMode?: TimelineMode;
- /** Injectable initial date offset for tests/screenshots; defaults to the current period. */
- initialOffset?: number;
-}
-
-export function QuotaTimeline({
- entries,
- quotaFor,
- displayNameFor,
- resolvedTheme,
- now: nowProp,
- initialMode = 'weekly',
- initialOffset = 0,
-}: QuotaTimelineProps) {
- const { t } = useTranslation();
- const [mode, setMode] = useState(initialMode);
- const [offset, setOffset] = useState(initialOffset);
-
- // The clock has to advance on its own: bars are classified past/live/next
- // against it and the marker is positioned by it, so a long-lived tab would
- // quietly go stale. Shared app-wide so the cards above tick in lockstep with
- // the chart rather than each running its own timer.
- const tick = useNow(nowProp === undefined); // fixed clock: tests and screenshots
- const now = nowProp ?? tick;
-
- const span = useMemo(() => timelineSpan(mode, offset, now), [mode, offset, now]);
- const todayLabel = t('quota_management.windows_today', { defaultValue: 'Today' });
- // This button doubles as the selected-period indicator and the shortcut back
- // to the current period. Keeping its visible text hard-coded to "Today" made
- // successful previous/next navigation look as though the date never changed.
- const navigationLabel = offset === 0 ? todayLabel : formatDay(span.startMs);
-
- const laneInputs = useMemo(
- () =>
- entries.map((entry) => ({
- name: entry.file.name,
- displayName: displayNameFor(entry.file.name),
- provider: entry.type,
- quota: quotaFor(entry),
- })),
- [entries, quotaFor, displayNameFor]
- );
-
- // Keep the timeline hidden until at least one loaded credential exposes a
- // real quota window. Once there is timeline data, however, changing zoom must
- // never remove the whole panel just because that mode has no matching lanes.
- const hasAnyLane = useMemo(
- () => laneInputs.some((input) => laneHasWindow(buildTimelineLane(input))),
- [laneInputs]
- );
-
- const lanes = useMemo(
- () =>
- laneInputs
- .map((input) =>
- buildTimelineLane({
- ...input,
- // Weekly mode prefers the longest readable window. Session mode
- // asks specifically for a real 5-hour window; longer periods must
- // not be reinterpreted as 5-hour resets.
- maxPeriodHours: mode === 'session' ? 5 : span.days * 24,
- })
- )
- .filter((lane) => laneHasWindow(lane) && (mode !== 'session' || lane.periodHours === 5)),
- [laneInputs, mode, span.days]
- );
-
- /** Weekly: one cell per day. Session: one per 6 hours. */
- const cells = useMemo(() => {
- const zoomed = mode === 'session';
- const count = zoomed ? span.days * 4 : span.days;
- const cellMs = (span.endMs - span.startMs) / count;
- const todayStart = new Date(now).setHours(0, 0, 0, 0);
-
- return Array.from({ length: count }, (_, index) => {
- const at = span.startMs + index * cellMs;
- const date = new Date(at);
- const isDayStart = !zoomed || date.getHours() === 0;
- return {
- at,
- isDayStart,
- isToday: new Date(at).setHours(0, 0, 0, 0) === todayStart,
- isWeekend: date.getDay() === 0 || date.getDay() === 6,
- weekday: t(`quota_management.weekday_${WEEKDAY_KEYS[date.getDay()]}`, {
- defaultValue: WEEKDAY_KEYS[date.getDay()],
- }),
- label: isDayStart ? formatDay(at) : `${pad(date.getHours())}:00`,
- };
- });
- }, [mode, span, now, t]);
-
- // Only draw the marker when the current moment is actually on screen.
- const nowPercent =
- now >= span.startMs && now < span.endMs
- ? ((now - span.startMs) / (span.endMs - span.startMs)) * 100
- : null;
-
- if (!hasAnyLane) return null;
-
- return (
-
-
-
-
- {t('quota_management.windows_title', { defaultValue: 'Quota windows' })}
-
-
- {formatDay(span.startMs)} – {formatDay(span.endMs - DAY_MS)}
- {' · '}
- {mode === 'weekly'
- ? t('quota_management.windows_span_weekly', { defaultValue: 'two weeks' })
- : t('quota_management.windows_span_session', { defaultValue: 'three days' })}
- {offset === 0 &&
- ` · ${t('quota_management.windows_current', { defaultValue: 'current' })}`}
-
-
-
-
-
-
-
-
-
-
-
- {(['weekly', 'session'] as const).map((value) => (
-
- ))}
-
-
-
-
-
- {lanes.length === 0 ? (
-
- {t('quota_management.windows_empty_session', {
- defaultValue: 'No credentials on this page report a 5-hour quota window.',
- })}
-
- ) : (
- <>
-
-
- {t('quota_management.windows_credential', { defaultValue: 'Credential' })}
-
-
- {cells.map((cell) => (
-
-
- {cell.isDayStart ? cell.weekday : ''}
-
- {cell.label}
-
- ))}
-
-
-
- {lanes.map((lane) => (
-
- ))}
- >
- )}
-
-
- {lanes.length > 0 && (
-
- )}
-
- );
-}
-
-interface LaneProps {
- lane: TimelineLane;
- span: { startMs: number; endMs: number; days: number };
- now: number;
- mode: TimelineMode;
- cells: { at: number; isWeekend: boolean; isDayStart: boolean }[];
- nowPercent: number | null;
- resolvedTheme: ResolvedTheme;
-}
-
-function Lane({ lane, span, now, mode, cells, nowPercent, resolvedTheme }: LaneProps) {
- const { t, i18n } = useTranslation();
-
- const windows = useMemo(
- () => projectLane(lane, span.startMs, span.endMs, now, mode),
- [lane, span, now, mode]
- );
- const resetCredits = useMemo(
- () => projectResetCredits(lane, span.startMs, span.endMs, now),
- [lane, span, now]
- );
-
- const colorSet = TYPE_COLORS[lane.provider] || TYPE_COLORS.unknown;
- const color: ThemeColors =
- resolvedTheme === 'dark' && colorSet.dark ? colorSet.dark : colorSet.light;
-
- // Sub-day windows are labelled in hours — rounding 5h to days gives "0d".
- const periodLabel =
- mode === 'session'
- ? '5h'
- : !lane.periodHours
- ? ''
- : lane.periodHours < 24
- ? `${Math.round(lane.periodHours)}h`
- : `${Math.round(lane.periodHours / 24)}d`;
-
- return (
-
-
-
-
-
- {lane.displayName}
-
- {periodLabel && {periodLabel}}
-
-
- {lane.limits.map((limit) => (
-
- {limit.label} {limit.remaining}%
-
- ))}
-
-
-
-
-
- {cells.map((cell) => (
-
- ))}
-
-
- {nowPercent !== null && (
-
- )}
-
- {windows.length === 0 ? (
-
- {t('quota_management.windows_idle', {
- defaultValue: 'no window counting down',
- })}
-
- ) : (
- windows.map((window) => {
- // A label needs room to read; below that the bar speaks for itself
- // and the detail lives in the tooltip.
- const showLabel = window.widthPercent > (mode === 'session' ? 4.5 : 9);
- const endText =
- mode === 'session'
- ? formatTime(window.endMs)
- : `${formatDay(window.endMs)} ${formatTime(window.endMs)}`;
-
- return (
-
- {/* Only the API-reported current window has meaningful usage;
- projected windows intentionally have no fill. */}
- {window.remaining !== null && (
-
- )}
- {showLabel && (
-
- {window.remaining !== null ? `${window.remaining}% · ` : ''}
- {endText}
-
- )}
-
- );
- })
- )}
-
- {resetCredits.map((credit, index) => {
- const grantedLabel = t('quota_management.windows_credit_granted', {
- defaultValue: 'Granted',
- });
- const expiresLabel = t('quota_management.windows_credit_expires', {
- defaultValue: 'Expires',
- });
- const title = [
- t('quota_management.windows_reset_credit', { defaultValue: 'Manual reset' }),
- credit.grantedAtMs !== null
- ? `${grantedLabel}: ${formatDay(credit.grantedAtMs)} ${formatTime(credit.grantedAtMs)}`
- : null,
- `${expiresLabel}: ${formatDay(credit.expiresAtMs)} ${formatTime(credit.expiresAtMs)}`,
- formatRelativeInstant(credit.expiresAtMs, now, i18n.resolvedLanguage),
- ]
- .filter((line): line is string => line !== null)
- .join('\n');
-
- return (
-
- );
- })}
-
-
- );
-}
-
-const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
diff --git a/frontend/src/features/quota/constants.ts b/frontend/src/features/quota/constants.ts
deleted file mode 100644
index ca29a05..0000000
--- a/frontend/src/features/quota/constants.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { QuotaProviderType } from './providers/types';
-
-/** tab 顺序 = 旧页五分区的纵向顺序,'全部' tab 下卡片也按此分组排列。 */
-export const QUOTA_TAB_ORDER: readonly QuotaProviderType[] = [
- 'claude',
- 'antigravity',
- 'codex',
- 'xai',
- 'kimi',
-];
-
-export type QuotaTabId = 'all' | QuotaProviderType;
-
-/** 页级分页固定 20/页,同时把「刷新全部」的上游并发限制在 20。 */
-export const QUOTA_PAGE_SIZE = 20;
-
-/** 卡片排序:默认 = provider 分组序;soonest = 最快恢复优先。 */
-export const QUOTA_SORT_MODES = ['default', 'soonest'] as const;
-
-export type QuotaSortMode = (typeof QUOTA_SORT_MODES)[number];
-
-/** 与 useRevealGroup 的 GROUP_MAX_TOTAL 一致:卡片级联总预算 360ms。 */
-export const CARD_ENTRANCE_BUDGET_MS = 360;
diff --git a/frontend/src/features/quota/hooks/useQuotaActions.ts b/frontend/src/features/quota/hooks/useQuotaActions.ts
deleted file mode 100644
index 818c3bf..0000000
--- a/frontend/src/features/quota/hooks/useQuotaActions.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * 单卡额度操作:刷新 + Codex 重置积分。
- * 流程 1:1 移植旧 QuotaSection(confirm modal、resetting 再入守卫、
- * generation-guarded commit、成功/失败通知),仅把 config 换成 adapter。
- */
-
-import { useCallback, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
- captureQuotaCacheGeneration,
- commitIfQuotaCacheCurrent,
- useNotificationStore,
-} from '@/stores';
-import type { AuthFileItem } from '@/types';
-import { getStatusFromError } from '@/utils/quota';
-import { getQuotaMap, getQuotaSetter, type QuotaAdapter, type QuotaCardState } from '../providers';
-
-const getQuotaState = (adapter: QuotaAdapter, name: string): QuotaCardState | undefined =>
- getQuotaMap(adapter)[name];
-
-export function useQuotaActions(disableControls: boolean) {
- const { t } = useTranslation();
- const showNotification = useNotificationStore((state) => state.showNotification);
- const showConfirmation = useNotificationStore((state) => state.showConfirmation);
- const [resettingQuotaName, setResettingQuotaName] = useState(null);
-
- const refreshQuota = useCallback(
- async (file: AuthFileItem, adapter: QuotaAdapter) => {
- if (disableControls || file.disabled) return;
- if (resettingQuotaName === file.name) return;
- if (getQuotaState(adapter, file.name)?.status === 'loading') return;
- const cacheGeneration = captureQuotaCacheGeneration();
- const setQuota = getQuotaSetter(adapter);
-
- setQuota((prev) => ({
- ...prev,
- [file.name]: adapter.buildLoadingState(),
- }));
-
- try {
- const data = await adapter.fetchQuota(file, t);
- commitIfQuotaCacheCurrent(cacheGeneration, () => {
- setQuota((prev) => ({
- ...prev,
- [file.name]: adapter.buildSuccessState(data),
- }));
- showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
- });
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : t('common.unknown_error');
- const status = getStatusFromError(err);
- commitIfQuotaCacheCurrent(cacheGeneration, () => {
- setQuota((prev) => ({
- ...prev,
- [file.name]: adapter.buildErrorState(message, status),
- }));
- showNotification(
- t('auth_files.quota_refresh_failed', { name: file.name, message }),
- 'error'
- );
- });
- }
- },
- [disableControls, resettingQuotaName, showNotification, t]
- );
-
- const resetQuota = useCallback(
- (file: AuthFileItem, adapter: QuotaAdapter) => {
- const resetQuotaFn = adapter.resetQuota;
- if (!resetQuotaFn) return;
- if (disableControls || file.disabled) return;
- if (getQuotaState(adapter, file.name)?.status === 'loading') return;
- if (resettingQuotaName === file.name) return;
-
- showConfirmation({
- title: t('codex_quota.reset_confirm_title'),
- message: t('codex_quota.reset_confirm_message', { name: file.name }),
- confirmText: t('codex_quota.reset_confirm_button'),
- variant: 'primary',
- onConfirm: async () => {
- const cacheGeneration = captureQuotaCacheGeneration();
- const setQuota = getQuotaSetter(adapter);
- setResettingQuotaName(file.name);
- try {
- const data = await resetQuotaFn(file, t);
- commitIfQuotaCacheCurrent(cacheGeneration, () => {
- setQuota((prev) => ({
- ...prev,
- [file.name]: adapter.buildSuccessState(data),
- }));
- showNotification(t('codex_quota.reset_success', { name: file.name }), 'success');
- });
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : t('common.unknown_error');
- commitIfQuotaCacheCurrent(cacheGeneration, () => {
- showNotification(
- t('codex_quota.reset_failed', { name: file.name, message }),
- 'error'
- );
- });
- } finally {
- setResettingQuotaName((current) => (current === file.name ? null : current));
- }
- },
- });
- },
- [disableControls, resettingQuotaName, showConfirmation, showNotification, t]
- );
-
- return { resettingQuotaName, refreshQuota, resetQuota };
-}
diff --git a/frontend/src/features/quota/hooks/useQuotaBatchLoader.ts b/frontend/src/features/quota/hooks/useQuotaBatchLoader.ts
deleted file mode 100644
index 377e174..0000000
--- a/frontend/src/features/quota/hooks/useQuotaBatchLoader.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * 混合提供商批量额度加载(原 useQuotaLoader 的跨分区泛化)。
- *
- * 保留的三道守卫与旧实现逐一对应:
- * - loadingRef:并发批量加载去重;
- * - requestIdRef:被超越的响应直接丢弃;
- * - cacheGeneration:断线重连后过期请求不得写入新会话缓存。
- * 提交按 provider 分组进行 —— 快的提供商先落地,不等慢的。
- */
-
-import { useCallback, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { captureQuotaCacheGeneration, commitIfQuotaCacheCurrent } from '@/stores';
-import { getStatusFromError } from '@/utils/quota';
-import type { QuotaFileEntry } from '../logic';
-import { QUOTA_ADAPTERS, getQuotaSetter } from '../providers';
-import type { QuotaProviderType } from '../providers/types';
-
-interface BatchFetchResult {
- name: string;
- status: 'success' | 'error';
- data?: unknown;
- error?: string;
- errorStatus?: number;
-}
-
-export function useQuotaBatchLoader() {
- const { t } = useTranslation();
- const [batchLoading, setBatchLoading] = useState(false);
- const loadingRef = useRef(false);
- const requestIdRef = useRef(0);
-
- const loadQuota = useCallback(
- async (targets: QuotaFileEntry[]) => {
- if (loadingRef.current) return;
- if (targets.length === 0) return;
- loadingRef.current = true;
- const requestId = ++requestIdRef.current;
- const cacheGeneration = captureQuotaCacheGeneration();
- setBatchLoading(true);
-
- try {
- const groups = new Map();
- targets.forEach((entry) => {
- const group = groups.get(entry.type) ?? [];
- group.push(entry);
- groups.set(entry.type, group);
- });
-
- await Promise.all(
- Array.from(groups.entries()).map(async ([type, entries]) => {
- const adapter = QUOTA_ADAPTERS[type];
- const setQuota = getQuotaSetter(adapter);
-
- commitIfQuotaCacheCurrent(cacheGeneration, () => {
- setQuota((prev) => {
- const nextState = { ...prev };
- entries.forEach(({ file }) => {
- nextState[file.name] = adapter.buildLoadingState();
- });
- return nextState;
- });
- });
-
- const results = await Promise.all(
- entries.map(async ({ file }): Promise => {
- try {
- const data = await adapter.fetchQuota(file, t);
- return { name: file.name, status: 'success', data };
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : t('common.unknown_error');
- return {
- name: file.name,
- status: 'error',
- error: message,
- errorStatus: getStatusFromError(err),
- };
- }
- })
- );
-
- if (requestId !== requestIdRef.current) return;
-
- commitIfQuotaCacheCurrent(cacheGeneration, () => {
- setQuota((prev) => {
- const nextState = { ...prev };
- results.forEach((result) => {
- nextState[result.name] =
- result.status === 'success'
- ? adapter.buildSuccessState(result.data)
- : adapter.buildErrorState(
- result.error || t('common.unknown_error'),
- result.errorStatus
- );
- });
- return nextState;
- });
- });
- })
- );
- } finally {
- if (requestId === requestIdRef.current) {
- setBatchLoading(false);
- loadingRef.current = false;
- }
- }
- },
- [t]
- );
-
- return { batchLoading, loadQuota };
-}
diff --git a/frontend/src/features/quota/logic.ts b/frontend/src/features/quota/logic.ts
deleted file mode 100644
index 2b4b0ce..0000000
--- a/frontend/src/features/quota/logic.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * 额度页纯逻辑:文件归类、tab 过滤、计数、分页。
- * React-free —— 由 tests/quotaPageLogic.test.ts 直接消费。
- */
-
-import type { AuthFileItem } from '@/types';
-import { ANTIGRAVITY_CONFIG } from './providers/antigravity/data';
-import { CLAUDE_CONFIG } from './providers/claude/data';
-import { CODEX_CONFIG } from './providers/codex/data';
-import { KIMI_CONFIG } from './providers/kimi/data';
-import { XAI_CONFIG } from './providers/xai/data';
-import type { QuotaProviderType } from './providers/types';
-import { QUOTA_TAB_ORDER, type QuotaSortMode, type QuotaTabId } from './constants';
-
-const QUOTA_FILTER_MAP: Record boolean> = {
- antigravity: ANTIGRAVITY_CONFIG.filterFn,
- claude: CLAUDE_CONFIG.filterFn,
- codex: CODEX_CONFIG.filterFn,
- kimi: KIMI_CONFIG.filterFn,
- xai: XAI_CONFIG.filterFn,
-};
-
-export interface QuotaFileEntry {
- file: AuthFileItem;
- type: QuotaProviderType;
-}
-
-export const resolveQuotaProviderType = (file: AuthFileItem): QuotaProviderType | null =>
- QUOTA_TAB_ORDER.find((type) => QUOTA_FILTER_MAP[type](file)) ?? null;
-
-/**
- * 把文件列表归类为额度条目:不支持额度或已停用的文件被过滤,
- * 结果按 QUOTA_TAB_ORDER 分组排列('全部' tab 的卡片顺序即由此决定)。
- */
-export function classifyQuotaFiles(files: AuthFileItem[]): QuotaFileEntry[] {
- const groups = new Map(
- QUOTA_TAB_ORDER.map((type) => [type, []])
- );
- for (const file of files) {
- const type = resolveQuotaProviderType(file);
- if (!type) continue;
- groups.get(type)?.push({ file, type });
- }
- return QUOTA_TAB_ORDER.flatMap((type) => groups.get(type) ?? []);
-}
-
-export function filterEntriesByTab(entries: QuotaFileEntry[], tab: QuotaTabId): QuotaFileEntry[] {
- if (tab === 'all') return entries;
- return entries.filter((entry) => entry.type === tab);
-}
-
-/**
- * Order the grid by whichever credential recovers first.
- *
- * The instant is injected rather than read here: quota lives in the store and
- * arrives asynchronously, and keeping this function store-free is what makes
- * the ordering rules directly testable.
- *
- * Credentials with no instant — not loaded yet, failed, or reporting no
- * upcoming reset — sink to the bottom rather than sorting as "now". They keep
- * their incoming provider-grouped order, so the unloaded tail still reads like
- * the default view instead of an arbitrary shuffle. Because loading is
- * click-to-fetch, that tail is most of the list until the user asks for data.
- *
- * The original index is the final tiebreak, making stability an asserted
- * property rather than an assumption about the engine's sort.
- */
-export function sortQuotaEntries(
- entries: QuotaFileEntry[],
- mode: QuotaSortMode,
- resolveNextRecoveryMs: (entry: QuotaFileEntry) => number | null
-): QuotaFileEntry[] {
- if (mode !== 'soonest') return [...entries];
-
- // Decorate once — resolving pokes at provider-shaped state per entry.
- return entries
- .map((entry, index) => ({ entry, index, atMs: resolveNextRecoveryMs(entry) }))
- .sort((a, b) => {
- if (a.atMs === null && b.atMs === null) return a.index - b.index;
- if (a.atMs === null) return 1;
- if (b.atMs === null) return -1;
- return a.atMs - b.atMs || a.index - b.index;
- })
- .map((decorated) => decorated.entry);
-}
-
-export function buildTabCounts(entries: QuotaFileEntry[]): Record {
- const counts: Record = { all: entries.length };
- for (const type of QUOTA_TAB_ORDER) {
- counts[type] = 0;
- }
- for (const entry of entries) {
- counts[entry.type] += 1;
- }
- return counts;
-}
-
-export const isQuotaRefreshDisabled = (
- canRefresh: boolean,
- loading: boolean,
- resetting: boolean
-): boolean => !canRefresh || loading || resetting;
-
-export interface QuotaPagination {
- pageItems: T[];
- currentPage: number;
- totalPages: number;
-}
-
-/** 页码越界时收敛到有效区间(列表缩短后停留在最后一页而不是空页)。 */
-export function paginate(items: T[], page: number, pageSize: number): QuotaPagination {
- const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
- const currentPage = Math.min(Math.max(1, page), totalPages);
- const start = (currentPage - 1) * pageSize;
- return {
- pageItems: items.slice(start, start + pageSize),
- currentPage,
- totalPages,
- };
-}
diff --git a/frontend/src/features/quota/providers/antigravity/AntigravityQuotaBody.tsx b/frontend/src/features/quota/providers/antigravity/AntigravityQuotaBody.tsx
deleted file mode 100644
index 71dc5e5..0000000
--- a/frontend/src/features/quota/providers/antigravity/AntigravityQuotaBody.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-/**
- * Antigravity 额度渲染体:套餐 chip 行(ultra/ultra-lite=金卡)+ 分组水位条。
- */
-
-import { useEffect, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import type { TFunction } from 'i18next';
-import type { AntigravityQuotaState, AntigravityQuotaSubscription } from '@/types';
-import { QuotaMeter } from '../../components/QuotaMeter';
-import { collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
-import type { QuotaBodyProps } from '../../types';
-import { getNextAntigravityCountdownUpdateDelay } from './countdown';
-
-const formatAntigravityDuration = (t: TFunction, deltaMs: number): string => {
- const totalMinutes = Math.max(1, Math.ceil(deltaMs / 60000));
- const days = Math.floor(totalMinutes / 1440);
- const hours = Math.floor((totalMinutes % 1440) / 60);
- const minutes = totalMinutes % 60;
-
- if (days > 0) {
- return t('antigravity_quota.duration_day_hour', {
- days,
- hours,
- });
- }
- if (hours > 0) {
- return t('antigravity_quota.duration_hour_minute', {
- hours,
- minutes,
- });
- }
- if (minutes > 0) {
- return t('antigravity_quota.duration_minute', {
- minutes,
- });
- }
- return t('antigravity_quota.duration_less_than_minute');
-};
-
-const formatAntigravityResetLabel = (
- resetTime: string | undefined,
- t: TFunction,
- nowMs: number
-): string => {
- if (!resetTime) return '-';
- const resetMs = new Date(resetTime).getTime();
- if (Number.isNaN(resetMs)) return '-';
- const deltaMs = resetMs - nowMs;
- if (deltaMs <= 0) return t('antigravity_quota.refresh_available');
- return t('antigravity_quota.refreshes_in', {
- duration: formatAntigravityDuration(t, deltaMs),
- });
-};
-
-const ANTIGRAVITY_GROUP_LABEL_KEYS = new Map([
- ['gemini models', 'group_gemini_models'],
- ['claude and gpt models', 'group_claude_gpt_models'],
-]);
-
-const ANTIGRAVITY_BUCKET_LABEL_KEYS = new Map([
- ['weekly limit', 'weekly_limit'],
- ['daily limit', 'daily_limit'],
- ['5 hour limit', 'five_hour_limit'],
- ['5-hour limit', 'five_hour_limit'],
- ['five hour limit', 'five_hour_limit'],
- ['monthly limit', 'monthly_limit'],
-]);
-
-const normalizeAntigravityQuotaText = (value: string): string =>
- value.trim().toLowerCase().replace(/\s+/g, ' ');
-
-const translateAntigravityQuotaLabel = (
- value: string,
- keys: Map,
- t: TFunction
-): string => {
- const key = keys.get(normalizeAntigravityQuotaText(value));
- return key ? t(`antigravity_quota.${key}`) : value;
-};
-
-const translateAntigravityQuotaDescription = (
- value: string | undefined,
- t: TFunction
-): string | undefined => {
- if (!value) return undefined;
- const modelsMatch = value.match(/^models within this group:\s*(.+)$/i);
- if (modelsMatch) {
- return t('antigravity_quota.group_models_description', {
- models: modelsMatch[1].trim(),
- });
- }
- return value;
-};
-
-const getAntigravityPlanLabel = (
- subscription: AntigravityQuotaSubscription | null | undefined,
- t: TFunction
-): string | null => {
- if (!subscription) return null;
- if (subscription.plan === 'free') return t('antigravity_subscription.plan_free');
- if (subscription.plan === 'pro') return t('antigravity_subscription.plan_pro');
- if (subscription.plan === 'ultra') return t('antigravity_subscription.plan_ultra');
- if (subscription.plan === 'ultra-lite') return t('antigravity_subscription.plan_ultra_lite');
- return (
- subscription.tierName ||
- subscription.tierId ||
- (subscription.plan === 'unknown' ? t('antigravity_subscription.plan_unknown') : null)
- );
-};
-
-export function AntigravityQuotaBody({ quota, classes }: QuotaBodyProps) {
- const { t } = useTranslation();
- const groups = quota.groups ?? [];
- const planLabel = getAntigravityPlanLabel(quota.subscription, t);
- const normalizedPlan = quota.subscription?.plan?.toLowerCase() ?? '';
- const isPremiumPlan = normalizedPlan === 'ultra' || normalizedPlan === 'ultra-lite';
- const serverTimeOffsetMs = quota.serverTimeOffsetMs ?? 0;
- const resetTimestamps = useMemo(
- () =>
- (quota.groups ?? []).flatMap((group) =>
- group.buckets
- .map((bucket) => (bucket.resetTime ? new Date(bucket.resetTime).getTime() : Number.NaN))
- .filter(Number.isFinite)
- ),
- [quota.groups]
- );
- // 首屏直接显示准确文案;后续 effect 会在最近的分钟边界更新并重新排程。
- const [nowMs, setNowMs] = useState(() => Date.now() + serverTimeOffsetMs);
-
- useEffect(() => {
- let timeoutId: ReturnType | undefined;
-
- const updateCountdown = () => {
- const currentNowMs = Date.now() + serverTimeOffsetMs;
- setNowMs(currentNowMs);
- const delay = getNextAntigravityCountdownUpdateDelay(resetTimestamps, currentNowMs);
- if (delay !== null) {
- timeoutId = setTimeout(updateCountdown, delay);
- }
- };
-
- updateCountdown();
- return () => {
- if (timeoutId !== undefined) clearTimeout(timeoutId);
- };
- }, [resetTimestamps, serverTimeOffsetMs]);
-
- // Ranked against this provider's own server-corrected clock rather than the
- // shared one, so the final-hour warning and the countdown always agree.
- const soonestRowId = useMemo(
- () => pickUrgentRowId(collectQuotaRowInstants('antigravity', quota), nowMs),
- [quota, nowMs]
- );
-
- return (
- <>
- {planLabel && (
-
-
- {t('antigravity_quota.plan_label')}
-
- {planLabel}
-
-
-
- )}
- {groups.length === 0 ? (
- {t('antigravity_quota.empty_models')}
- ) : (
- groups.map((group) => {
- const groupLabel = translateAntigravityQuotaLabel(
- group.label,
- ANTIGRAVITY_GROUP_LABEL_KEYS,
- t
- );
- const groupDescription = translateAntigravityQuotaDescription(group.description, t);
-
- return (
-
-
- {groupLabel}
- {groupDescription && (
-
- {groupDescription}
-
- )}
-
- {group.buckets.map((bucket, index) => {
- const clamped = Math.max(0, Math.min(1, bucket.remainingFraction));
- const percent = clamped * 100;
- const percentLabel =
- bucket.remainingFraction === 1
- ? t('antigravity_quota.quota_available')
- : t('antigravity_quota.remaining_percent', {
- percent: Math.round(percent),
- });
- const resetLabel = formatAntigravityResetLabel(bucket.resetTime, t, nowMs);
- const bucketLabel = translateAntigravityQuotaLabel(
- bucket.label,
- ANTIGRAVITY_BUCKET_LABEL_KEYS,
- t
- );
- const bucketDescription = translateAntigravityQuotaDescription(
- bucket.description,
- t
- );
-
- const soon = bucket.id === soonestRowId;
-
- return (
-
-
-
- {bucketLabel}
-
-
- {percentLabel}
-
- {resetLabel}
-
-
-
-
-
- );
- })}
-
- );
- })
- )}
- >
- );
-}
diff --git a/frontend/src/features/quota/providers/antigravity/countdown.ts b/frontend/src/features/quota/providers/antigravity/countdown.ts
deleted file mode 100644
index 92dd29e..0000000
--- a/frontend/src/features/quota/providers/antigravity/countdown.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-const MINUTE_MS = 60_000;
-
-/**
- * 返回下一个可见倒计时文案发生变化的等待时间。
- *
- * 文案以向上取整的分钟展示,所以按最近的分钟边界唤醒即可;已经到期或
- * 无效的时间不再创建定时器。
- */
-export function getNextAntigravityCountdownUpdateDelay(
- resetTimestamps: readonly number[],
- nowMs: number
-): number | null {
- let nextDelay: number | null = null;
-
- resetTimestamps.forEach((resetMs) => {
- if (!Number.isFinite(resetMs)) return;
- const deltaMs = resetMs - nowMs;
- if (deltaMs <= 0) return;
-
- const remainder = deltaMs % MINUTE_MS;
- const delay = Math.max(1, Math.ceil(remainder === 0 ? MINUTE_MS : remainder));
- nextDelay = nextDelay === null ? delay : Math.min(nextDelay, delay);
- });
-
- return nextDelay;
-}
diff --git a/frontend/src/features/quota/providers/antigravity/data.ts b/frontend/src/features/quota/providers/antigravity/data.ts
deleted file mode 100644
index 0a04a57..0000000
--- a/frontend/src/features/quota/providers/antigravity/data.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-/**
- * Antigravity 额度数据层:分组配额 + 订阅信息 + 服务器时钟偏移。
- * React-free / SCSS-free。
- */
-
-import type { TFunction } from 'i18next';
-import type {
- AntigravityQuotaGroup,
- AntigravityQuotaSubscription,
- AntigravityQuotaSummaryPayload,
- AntigravityQuotaState,
- AuthFileItem,
-} from '@/types';
-import {
- antigravitySubscriptionApi,
- apiCallApi,
- authFilesApi,
- getApiCallErrorMessage,
- type AntigravitySubscriptionSummary,
-} from '@/services/api';
-import {
- ANTIGRAVITY_QUOTA_URLS,
- ANTIGRAVITY_REQUEST_HEADERS,
- normalizeStringValue,
- parseAntigravityPayload,
- buildAntigravityQuotaGroups,
- createStatusError,
- getStatusFromError,
- isAntigravityFile,
- isDisabledAuthFile,
-} from '@/utils/quota';
-import { normalizeAuthIndex } from '@/utils/authIndex';
-import type { QuotaProviderData } from '../types';
-
-export type AntigravityQuotaData = {
- groups: AntigravityQuotaGroup[];
- subscription: AntigravityQuotaSubscription | null;
- serverTimeOffsetMs: number | null;
-};
-
-const resolveAntigravityProjectId = async (file: AuthFileItem): Promise => {
- const directProjectId = normalizeStringValue(file.project_id ?? file.projectId);
- if (directProjectId) return directProjectId;
-
- const metadata =
- file.metadata && typeof file.metadata === 'object' && file.metadata !== null
- ? (file.metadata as Record)
- : null;
- const metadataProjectId = metadata
- ? normalizeStringValue(metadata.project_id ?? metadata.projectId)
- : null;
- if (metadataProjectId) return metadataProjectId;
-
- const attributes =
- file.attributes && typeof file.attributes === 'object' && file.attributes !== null
- ? (file.attributes as Record)
- : null;
- const attributesProjectId = attributes
- ? normalizeStringValue(
- attributes.project_id ?? attributes.projectId ?? attributes.gemini_virtual_project
- )
- : null;
- if (attributesProjectId) return attributesProjectId;
-
- try {
- const text = await authFilesApi.downloadText(file.name);
- const trimmed = text.trim();
- if (!trimmed) return '';
-
- const parsed = JSON.parse(trimmed) as Record;
- const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
- if (topLevel) return topLevel;
-
- const installed =
- parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
- ? (parsed.installed as Record)
- : null;
- const installedProjectId = installed
- ? normalizeStringValue(installed.project_id ?? installed.projectId)
- : null;
- if (installedProjectId) return installedProjectId;
-
- const web =
- parsed.web && typeof parsed.web === 'object' && parsed.web !== null
- ? (parsed.web as Record)
- : null;
- const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
- if (webProjectId) return webProjectId;
- } catch {
- return '';
- }
-
- return '';
-};
-
-const resolveResponseServerTimeOffsetMs = (
- header: Record | undefined
-): number | null => {
- if (!header) return null;
- const dateEntry = Object.entries(header).find(([key]) => key.toLowerCase() === 'date');
- const rawDate = dateEntry?.[1]?.[0];
- if (!rawDate) return null;
- const serverTime = new Date(rawDate).getTime();
- if (Number.isNaN(serverTime)) return null;
- return serverTime - Date.now();
-};
-
-const toAntigravityQuotaSubscription = (
- summary: AntigravitySubscriptionSummary | null
-): AntigravityQuotaSubscription | null => {
- if (!summary) return null;
- return {
- plan: summary.plan,
- tierName: summary.tierName,
- tierId: summary.tierId,
- };
-};
-
-const fetchAntigravityQuota = async (
- file: AuthFileItem,
- t: TFunction
-): Promise => {
- const rawAuthIndex = file['auth_index'] ?? file.authIndex;
- const authIndex = normalizeAuthIndex(rawAuthIndex);
- if (!authIndex) {
- throw new Error(t('antigravity_quota.missing_auth_index'));
- }
-
- const projectId = await resolveAntigravityProjectId(file);
- if (!projectId) {
- throw new Error(t('antigravity_quota.missing_project_id'));
- }
- const requestBody = JSON.stringify({ project: projectId });
- const subscriptionPromise = antigravitySubscriptionApi
- .get(authIndex)
- .then(toAntigravityQuotaSubscription)
- .catch(() => null);
-
- let lastError = '';
- let lastStatus: number | undefined;
- let priorityStatus: number | undefined;
- let hadSuccess = false;
-
- for (const url of ANTIGRAVITY_QUOTA_URLS) {
- try {
- const result = await apiCallApi.request({
- authIndex,
- method: 'POST',
- url,
- header: { ...ANTIGRAVITY_REQUEST_HEADERS },
- data: requestBody,
- });
-
- if (result.statusCode < 200 || result.statusCode >= 300) {
- lastError = getApiCallErrorMessage(result);
- lastStatus = result.statusCode;
- if (result.statusCode === 403 || result.statusCode === 404) {
- priorityStatus ??= result.statusCode;
- }
- continue;
- }
-
- hadSuccess = true;
- const payload = parseAntigravityPayload(
- result.body ?? result.bodyText
- ) as AntigravityQuotaSummaryPayload | null;
- if (!payload || !Array.isArray(payload.groups)) {
- lastError = t('antigravity_quota.empty_models');
- continue;
- }
-
- const groups = buildAntigravityQuotaGroups(payload);
- if (groups.length === 0) {
- lastError = t('antigravity_quota.empty_models');
- continue;
- }
-
- return {
- groups,
- subscription: await subscriptionPromise,
- serverTimeOffsetMs: resolveResponseServerTimeOffsetMs(result.header),
- };
- } catch (err: unknown) {
- lastError = err instanceof Error ? err.message : t('common.unknown_error');
- const status = getStatusFromError(err);
- if (status) {
- lastStatus = status;
- if (status === 403 || status === 404) {
- priorityStatus ??= status;
- }
- }
- }
- }
-
- if (hadSuccess) {
- return { groups: [], subscription: await subscriptionPromise, serverTimeOffsetMs: null };
- }
-
- throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
-};
-
-export const ANTIGRAVITY_CONFIG: QuotaProviderData = {
- type: 'antigravity',
- i18nPrefix: 'antigravity_quota',
- filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
- fetchQuota: fetchAntigravityQuota,
- storeSelector: (state) => state.antigravityQuota,
- storeSetter: 'setAntigravityQuota',
- buildLoadingState: () => ({
- status: 'loading',
- groups: [],
- subscription: null,
- serverTimeOffsetMs: null,
- }),
- buildSuccessState: (data) => ({
- status: 'success',
- groups: data.groups,
- subscription: data.subscription,
- serverTimeOffsetMs: data.serverTimeOffsetMs,
- }),
- buildErrorState: (message, status) => ({
- status: 'error',
- groups: [],
- subscription: null,
- serverTimeOffsetMs: null,
- error: message,
- errorStatus: status,
- }),
-};
diff --git a/frontend/src/features/quota/providers/claude/ClaudeQuotaBody.tsx b/frontend/src/features/quota/providers/claude/ClaudeQuotaBody.tsx
deleted file mode 100644
index af347df..0000000
--- a/frontend/src/features/quota/providers/claude/ClaudeQuotaBody.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * Claude 额度渲染体:套餐/额外用量 chip 行 + 用量窗口水位条。
- */
-
-import { useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import type { ClaudeQuotaState } from '@/types';
-import { buildResetDisplay } from '@/utils/quota';
-import { useNow } from '@/hooks/useNow';
-import { QuotaMeter } from '../../components/QuotaMeter';
-import { QuotaResetLabel } from '../../components/QuotaResetLabel';
-import { collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
-import type { QuotaBodyProps } from '../../types';
-
-export function ClaudeQuotaBody({ quota, classes }: QuotaBodyProps) {
- const { t, i18n } = useTranslation();
- const now = useNow();
- const soonestRowId = useMemo(
- () => pickUrgentRowId(collectQuotaRowInstants('claude', quota), now),
- [quota, now]
- );
- const windows = quota.windows ?? [];
- const extraUsage = quota.extraUsage ?? null;
- const planType = quota.planType ?? null;
-
- return (
- <>
- {planType && (
-
- {t('claude_quota.plan_label')}
- {t(`claude_quota.${planType}`)}
-
- )}
- {extraUsage && extraUsage.is_enabled && (
-
- {t('claude_quota.extra_usage_label')}
-
- {`$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`}
-
-
- )}
- {windows.length === 0 ? (
- {t('claude_quota.empty_windows')}
- ) : (
- windows.map((window, index) => {
- const used = window.usedPercent;
- const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
- const remaining =
- clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
- const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
- const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
- const resetDisplay = buildResetDisplay(
- window.resetLabel,
- window.resetAtMs,
- now,
- i18n.resolvedLanguage
- );
-
- const soon = window.id === soonestRowId;
-
- return (
-
-
-
{windowLabel}
-
- {percentLabel}
- {resetDisplay && (
-
- )}
-
-
-
-
- );
- })
- )}
- >
- );
-}
diff --git a/frontend/src/features/quota/providers/claude/data.ts b/frontend/src/features/quota/providers/claude/data.ts
deleted file mode 100644
index 70f095c..0000000
--- a/frontend/src/features/quota/providers/claude/data.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-/**
- * Claude 额度数据层:用量窗口 + 套餐 + 额外用量。
- * React-free / SCSS-free —— 由 tests/claudeFableQuota.test.ts 直接消费。
- */
-
-import type { TFunction } from 'i18next';
-import type {
- AuthFileItem,
- ClaudeExtraUsage,
- ClaudeProfileResponse,
- ClaudeQuotaState,
- ClaudeQuotaWindow,
- ClaudeUsagePayload,
-} from '@/types';
-import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
-import {
- CLAUDE_PROFILE_URL,
- CLAUDE_USAGE_URL,
- CLAUDE_REQUEST_HEADERS,
- CLAUDE_USAGE_WINDOW_KEYS,
- claudePeriodHours,
- normalizeNumberValue,
- normalizeStringValue,
- parseClaudeUsagePayload,
- formatQuotaResetTime,
- resolveResetMs,
- createStatusError,
- isClaudeFile,
- isDisabledAuthFile,
-} from '@/utils/quota';
-import { normalizeAuthIndex } from '@/utils/authIndex';
-import type { QuotaProviderData } from '../types';
-
-export type ClaudeQuotaData = {
- windows: ClaudeQuotaWindow[];
- extraUsage?: ClaudeExtraUsage | null;
- planType?: string | null;
-};
-
-const findFableUsageLimit = (payload: ClaudeUsagePayload) => {
- if (!Array.isArray(payload.limits)) return null;
-
- const candidates = payload.limits.filter((limit) => {
- const kind = (normalizeStringValue(limit?.kind) ?? '').trim().toLowerCase();
- const modelName = (normalizeStringValue(limit?.scope?.model?.display_name) ?? '')
- .trim()
- .toLowerCase();
- const isFable = modelName === 'fable' || modelName === 'fable 5';
- return kind === 'weekly_scoped' && isFable && normalizeNumberValue(limit?.percent) !== null;
- });
-
- return candidates.find((limit) => limit.is_active === true) ?? candidates[0] ?? null;
-};
-
-export const buildClaudeQuotaWindows = (
- payload: ClaudeUsagePayload,
- t: TFunction
-): ClaudeQuotaWindow[] => {
- const windows: ClaudeQuotaWindow[] = [];
- const fableLimit = findFableUsageLimit(payload);
-
- for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
- if (key === 'iguana_necktie' && fableLimit) continue;
- const window = payload[key as keyof ClaudeUsagePayload];
- if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
- const typedWindow = window as { utilization: number; resets_at: string | null };
- const usedPercent = normalizeNumberValue(typedWindow.utilization);
- const resetLabel = formatQuotaResetTime(typedWindow.resets_at ?? undefined);
- windows.push({
- id,
- label: t(labelKey),
- labelKey,
- usedPercent,
- resetLabel,
- // Claude states the period nowhere in the payload, so it comes from the
- // key: `five_hour` is the rolling window, everything else is weekly.
- resetAtMs: resolveResetMs([typedWindow.resets_at]),
- periodHours: claudePeriodHours(key),
- });
- }
-
- if (fableLimit) {
- const usedPercent = normalizeNumberValue(fableLimit.percent);
- if (usedPercent !== null) {
- windows.push({
- id: 'seven-day-fable',
- label: t('claude_quota.seven_day_fable'),
- labelKey: 'claude_quota.seven_day_fable',
- usedPercent,
- resetLabel: formatQuotaResetTime(fableLimit.resets_at ?? undefined),
- // `weekly_scoped` is a 7-day window by definition, so the timeline can
- // place this row alongside the ones derived from the named keys.
- resetAtMs: resolveResetMs([fableLimit.resets_at]),
- periodHours: claudePeriodHours('seven_day'),
- });
- }
- }
-
- return windows;
-};
-
-const normalizeFlagValue = (value: unknown): boolean | undefined => {
- if (value === undefined || value === null) return undefined;
- if (typeof value === 'boolean') return value;
- if (typeof value === 'number') return value !== 0;
- if (typeof value === 'string') {
- const trimmed = value.trim().toLowerCase();
- if (['true', '1', 'yes', 'y', 'on'].includes(trimmed)) return true;
- if (['false', '0', 'no', 'n', 'off'].includes(trimmed)) return false;
- }
- return undefined;
-};
-
-const parseClaudeProfilePayload = (payload: unknown): ClaudeProfileResponse | null => {
- if (payload === undefined || payload === null) return null;
- if (typeof payload === 'string') {
- const trimmed = payload.trim();
- if (!trimmed) return null;
- try {
- return JSON.parse(trimmed) as ClaudeProfileResponse;
- } catch {
- return null;
- }
- }
- if (typeof payload === 'object') {
- return payload as ClaudeProfileResponse;
- }
- return null;
-};
-
-const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string | null => {
- if (!profile) return null;
-
- const hasClaudeMax = normalizeFlagValue(profile.account?.has_claude_max);
- if (hasClaudeMax) return 'plan_max';
-
- const hasClaudePro = normalizeFlagValue(profile.account?.has_claude_pro);
- if (hasClaudePro) return 'plan_pro';
-
- const organizationType = normalizeStringValue(
- profile.organization?.organization_type
- )?.toLowerCase();
- const subscriptionStatus = normalizeStringValue(
- profile.organization?.subscription_status
- )?.toLowerCase();
-
- if (organizationType === 'claude_team' && subscriptionStatus === 'active') {
- return 'plan_team';
- }
-
- if (hasClaudeMax === false && hasClaudePro === false) return 'plan_free';
-
- return null;
-};
-
-const fetchClaudeQuota = async (file: AuthFileItem, t: TFunction): Promise => {
- const rawAuthIndex = file['auth_index'] ?? file.authIndex;
- const authIndex = normalizeAuthIndex(rawAuthIndex);
- if (!authIndex) {
- throw new Error(t('claude_quota.missing_auth_index'));
- }
-
- const [usageResult, profileResult] = await Promise.allSettled([
- apiCallApi.request({
- authIndex,
- method: 'GET',
- url: CLAUDE_USAGE_URL,
- header: { ...CLAUDE_REQUEST_HEADERS },
- }),
- apiCallApi.request({
- authIndex,
- method: 'GET',
- url: CLAUDE_PROFILE_URL,
- header: { ...CLAUDE_REQUEST_HEADERS },
- }),
- ]);
-
- if (usageResult.status === 'rejected') {
- throw usageResult.reason;
- }
-
- const result = usageResult.value;
-
- if (result.statusCode < 200 || result.statusCode >= 300) {
- throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
- }
-
- const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
- if (!payload) {
- throw new Error(t('claude_quota.empty_windows'));
- }
-
- const windows = buildClaudeQuotaWindows(payload, t);
- const planType =
- profileResult.status === 'fulfilled' &&
- profileResult.value.statusCode >= 200 &&
- profileResult.value.statusCode < 300
- ? resolveClaudePlanType(
- parseClaudeProfilePayload(profileResult.value.body ?? profileResult.value.bodyText)
- )
- : null;
-
- return { windows, extraUsage: payload.extra_usage, planType };
-};
-
-export const CLAUDE_CONFIG: QuotaProviderData = {
- type: 'claude',
- i18nPrefix: 'claude_quota',
- filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
- fetchQuota: fetchClaudeQuota,
- storeSelector: (state) => state.claudeQuota,
- storeSetter: 'setClaudeQuota',
- buildLoadingState: () => ({ status: 'loading', windows: [] }),
- buildSuccessState: (data) => ({
- status: 'success',
- windows: data.windows,
- extraUsage: data.extraUsage,
- planType: data.planType,
- }),
- buildErrorState: (message, status) => ({
- status: 'error',
- windows: [],
- error: message,
- errorStatus: status,
- }),
-};
diff --git a/frontend/src/features/quota/providers/codex/CodexQuotaBody.tsx b/frontend/src/features/quota/providers/codex/CodexQuotaBody.tsx
deleted file mode 100644
index 8e8d44a..0000000
--- a/frontend/src/features/quota/providers/codex/CodexQuotaBody.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-/**
- * Codex 额度渲染体:套餐 chip 行(elite=Pro 20x 液态铂金 / premium=金卡)、
- * 重置积分明细、用量窗口水位条。
- */
-
-import { useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import type { CodexQuotaState } from '@/types';
-import {
- normalizePlanType,
- resolvePlanTier,
- PREMIUM_CODEX_PLAN_TYPES,
- buildResetDisplay,
- formatInstantShort,
- parseIsoToMs,
- resolveResetMs,
-} from '@/utils/quota';
-import { resolveTimeZoneLabel } from '@/utils/time/timezone';
-import { formatDateTimeValue } from '@/utils/format';
-import { useNow } from '@/hooks/useNow';
-import { QuotaMeter } from '../../components/QuotaMeter';
-import { QuotaResetLabel } from '../../components/QuotaResetLabel';
-import { collectQuotaRowInstants, pickUrgentRowId, resetCreditRowId } from '../../resetSchedule';
-import type { QuotaBodyProps, QuotaClassMap } from '../../types';
-
-const getPlanValueClass = (planType: string | null, classes: QuotaClassMap): string => {
- // elite/premium 顺序契约由 resolvePlanTier 承载(tests/quotaPlanTier.test.ts 守护)。
- const tier = resolvePlanTier(planType);
- if (tier === 'elite') return classes.elitePlanValue;
- if (tier === 'premium') return classes.premiumPlanValue;
- return classes.codexPlanValue;
-};
-
-export function CodexQuotaBody({ quota, classes }: QuotaBodyProps) {
- const { t, i18n } = useTranslation();
- const now = useNow();
- const locale = i18n.resolvedLanguage;
- // Windows and reset credits compete for the same emphasis, but only during
- // the final hour before the reset or expiry.
- const soonestRowId = useMemo(
- () => pickUrgentRowId(collectQuotaRowInstants('codex', quota), now),
- [quota, now]
- );
- const windows = quota.windows ?? [];
- const planType = quota.planType ?? null;
- const subscriptionActiveUntil = quota.subscriptionActiveUntil ?? null;
- const rateLimitResetCreditsAvailableCount = quota.rateLimitResetCreditsAvailableCount ?? null;
- const rateLimitResetCredits = quota.rateLimitResetCredits ?? [];
- const rateLimitResetCreditsError = quota.rateLimitResetCreditsError ?? '';
-
- const getPlanLabel = (pt?: string | null): string | null => {
- const normalized = normalizePlanType(pt);
- if (!normalized) return null;
- if (normalized === 'pro') return t('codex_quota.plan_pro');
- if (PREMIUM_CODEX_PLAN_TYPES.has(normalized) && normalized !== 'pro') {
- return t('codex_quota.plan_prolite');
- }
- if (normalized === 'plus') return t('codex_quota.plan_plus');
- if (normalized === 'team') return t('codex_quota.plan_team');
- if (normalized === 'free') return t('codex_quota.plan_free');
- return pt || normalized;
- };
-
- const planLabel = getPlanLabel(planType);
- const planValueClass = getPlanValueClass(planType, classes);
-
- // Renewal was the one date on this card in a different shape (a full
- // toLocaleString). Reformatted from the instant so it reads like the rest,
- // falling back to the old rendering when the payload isn't parseable.
- const subscriptionMs = resolveResetMs([subscriptionActiveUntil]);
- const expiryDisplay = subscriptionActiveUntil
- ? buildResetDisplay(
- subscriptionMs === null ? formatDateTimeValue(subscriptionActiveUntil) : null,
- subscriptionMs,
- now,
- locale
- )
- : null;
-
- return (
- <>
- {(planLabel || expiryDisplay || rateLimitResetCreditsAvailableCount !== null) && (
-
- {planLabel && (
-
- {t('codex_quota.plan_label')}
- {planLabel}
-
- )}
- {expiryDisplay && (
-
- {t('codex_quota.expires_label')}
- {expiryDisplay.absolute}
- {expiryDisplay.relative && (
- {expiryDisplay.relative}
- )}
-
- )}
- {rateLimitResetCreditsAvailableCount !== null && (
-
- {t('codex_quota.reset_credits_label')}
-
- {rateLimitResetCreditsAvailableCount.toString()}
-
-
- )}
-
- )}
- {rateLimitResetCredits.length > 0 ? (
-
-
- {t('codex_quota.reset_credits_expiry_label', { timezone: resolveTimeZoneLabel() })}
-
- {rateLimitResetCredits.map((credit, index) => {
- const expiresAtMs = parseIsoToMs(credit.expiresAt);
- const expiresDisplay = buildResetDisplay(
- expiresAtMs === null ? credit.expiresAt : formatInstantShort(expiresAtMs),
- expiresAtMs,
- now,
- locale
- );
- // One expression for both the key and the highlight — two copies
- // that drift would emphasize the wrong row.
- const rowId = resetCreditRowId(credit, index);
- const soon = rowId === soonestRowId;
- return (
-
-
- {t('codex_quota.reset_credit_number', { index: index + 1 })}
-
-
- {expiresDisplay && (
-
- )}
-
-
- );
- })}
-
- ) : rateLimitResetCreditsError ? (
-
- {t('codex_quota.reset_credits_expiry_failed', {
- message: rateLimitResetCreditsError,
- })}
-
- ) : null}
- {windows.length === 0 ? (
- {t('codex_quota.empty_windows')}
- ) : (
- windows.map((window, index) => {
- const used = window.usedPercent;
- const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
- const remaining =
- clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
- const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
- const windowLabel = window.labelKey
- ? t(window.labelKey, window.labelParams as Record)
- : window.label;
- const resetDisplay = buildResetDisplay(window.resetLabel, window.resetAtMs, now, locale);
-
- const soon = window.id === soonestRowId;
-
- return (
-
-
-
{windowLabel}
-
- {percentLabel}
- {resetDisplay && (
-
- )}
-
-
-
-
- );
- })
- )}
- >
- );
-}
diff --git a/frontend/src/features/quota/providers/codex/data.ts b/frontend/src/features/quota/providers/codex/data.ts
deleted file mode 100644
index dfbb10c..0000000
--- a/frontend/src/features/quota/providers/codex/data.ts
+++ /dev/null
@@ -1,485 +0,0 @@
-/**
- * Codex 额度数据层:用量窗口 + 套餐 + 重置积分(含消费流程)。
- * React-free / SCSS-free —— 由 tests/codexQuota.test.ts 直接消费。
- */
-
-import type { TFunction } from 'i18next';
-import type {
- AuthFileItem,
- CodexRateLimitInfo,
- CodexRateLimitResetCredit,
- CodexQuotaState,
- CodexUsageWindow,
- CodexQuotaWindow,
- CodexUsagePayload,
-} from '@/types';
-import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
-import {
- CODEX_RATE_LIMIT_RESET_CREDITS_URL,
- CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL,
- CODEX_USAGE_URL,
- CODEX_REQUEST_HEADERS,
- normalizeNumberValue,
- normalizePlanType,
- normalizeStringValue,
- normalizeCodexResetCreditsPayload,
- parseCodexUsagePayload,
- parseOffsetSecondsToMs,
- periodHoursFromSeconds,
- resolveResetMs,
- resolveCodexChatgptAccountId,
- resolveCodexPlanType,
- resolveCodexSubscriptionActiveUntil,
- formatCodexResetLabel,
- createStatusError,
- isCodexFile,
- isDisabledAuthFile,
-} from '@/utils/quota';
-import { normalizeAuthIndex } from '@/utils/authIndex';
-import type { QuotaProviderData } from '../types';
-
-const CODEX_RESET_CREDITS_REQUEST_TIMEOUT_MS = 8000;
-
-type CodexResetCreditsData = {
- availableCount: number | null;
- applicableAvailableCount: number | null;
- credits: CodexRateLimitResetCredit[];
- error: string;
-};
-
-export type CodexQuotaData = {
- planType: string | null;
- subscriptionActiveUntil: string | number | null;
- rateLimitResetCreditsAvailableCount: number | null;
- rateLimitResetCreditsApplicableAvailableCount: number | null;
- rateLimitResetCredits: CodexRateLimitResetCredit[];
- rateLimitResetCreditsError: string;
- windows: CodexQuotaWindow[];
-};
-
-export const buildCodexQuotaWindows = (
- payload: CodexUsagePayload,
- t: TFunction
-): CodexQuotaWindow[] => {
- const FIVE_HOUR_SECONDS = 18000;
- const WEEK_SECONDS = 604800;
- const MIN_MONTH_SECONDS = 28 * 24 * 60 * 60;
- const MAX_MONTH_SECONDS = 31 * 24 * 60 * 60;
- const WINDOW_META = {
- codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
- codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
- codeMonthly: { id: 'monthly', labelKey: 'codex_quota.team_secondary_window' },
- codeReviewFiveHour: {
- id: 'code-review-five-hour',
- labelKey: 'codex_quota.code_review_primary_window',
- },
- codeReviewWeekly: {
- id: 'code-review-weekly',
- labelKey: 'codex_quota.code_review_secondary_window',
- },
- codeReviewMonthly: {
- id: 'code-review-monthly',
- labelKey: 'codex_quota.code_review_team_secondary_window',
- },
- } as const;
-
- const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
- const codeReviewLimit =
- payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
- const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? [];
- const windows: CodexQuotaWindow[] = [];
-
- const addWindow = (
- id: string,
- label: string,
- labelKey: string | undefined,
- labelParams: Record | undefined,
- window?: CodexUsageWindow | null,
- limitReached?: boolean,
- allowed?: boolean
- ) => {
- if (!window) return;
- const resetLabel = formatCodexResetLabel(window);
- const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
- const isLimitReached = Boolean(limitReached) || allowed === false;
- const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
- // Keep the raw instant beside the label — see utils/quota/resetInstants.
- const resetAtMs =
- resolveResetMs([window.reset_at, window.resetAt]) ??
- parseOffsetSecondsToMs(window.reset_after_seconds ?? window.resetAfterSeconds, Date.now());
- const periodHours = periodHoursFromSeconds(
- window.limit_window_seconds ?? window.limitWindowSeconds
- );
- windows.push({
- id,
- label,
- labelKey,
- labelParams,
- usedPercent,
- resetAtMs,
- periodHours,
- resetLabel,
- });
- };
-
- const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => {
- if (!window) return null;
- return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
- };
-
- const isMonthlyWindow = (window?: CodexUsageWindow | null): boolean => {
- const seconds = getWindowSeconds(window);
- return seconds !== null && seconds >= MIN_MONTH_SECONDS && seconds <= MAX_MONTH_SECONDS;
- };
-
- const selectSecondaryWindowMeta = <
- TWeekly extends { id: string; labelKey: string },
- TMonthly extends { id: string; labelKey: string },
- >(
- window: CodexUsageWindow | null | undefined,
- weeklyMeta: TWeekly,
- monthlyMeta: TMonthly
- ): TWeekly | TMonthly => (isMonthlyWindow(window) ? monthlyMeta : weeklyMeta);
-
- const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
- const rawAllowed = rateLimit?.allowed;
-
- const pickClassifiedWindows = (
- limitInfo?: CodexRateLimitInfo | null,
- options?: { allowOrderFallback?: boolean }
- ): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
- const allowOrderFallback = options?.allowOrderFallback ?? true;
- const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null;
- const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null;
- const rawWindows = [primaryWindow, secondaryWindow];
-
- let fiveHourWindow: CodexUsageWindow | null = null;
- let weeklyWindow: CodexUsageWindow | null = null;
-
- for (const window of rawWindows) {
- if (!window) continue;
- const seconds = getWindowSeconds(window);
- if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
- fiveHourWindow = window;
- } else if ((seconds === WEEK_SECONDS || isMonthlyWindow(window)) && !weeklyWindow) {
- weeklyWindow = window;
- }
- }
-
- // For legacy payloads without window duration, fallback to primary/secondary ordering.
- if (allowOrderFallback) {
- if (!fiveHourWindow) {
- fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null;
- }
- if (!weeklyWindow) {
- weeklyWindow =
- secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null;
- }
- }
-
- return { fiveHourWindow, weeklyWindow };
- };
-
- const rateWindows = pickClassifiedWindows(rateLimit);
- addWindow(
- WINDOW_META.codeFiveHour.id,
- t(WINDOW_META.codeFiveHour.labelKey),
- WINDOW_META.codeFiveHour.labelKey,
- undefined,
- rateWindows.fiveHourWindow,
- rawLimitReached,
- rawAllowed
- );
- const codeSecondaryWindowMeta = selectSecondaryWindowMeta(
- rateWindows.weeklyWindow,
- WINDOW_META.codeWeekly,
- WINDOW_META.codeMonthly
- );
- addWindow(
- codeSecondaryWindowMeta.id,
- t(codeSecondaryWindowMeta.labelKey),
- codeSecondaryWindowMeta.labelKey,
- undefined,
- rateWindows.weeklyWindow,
- rawLimitReached,
- rawAllowed
- );
-
- const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
- const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
- const codeReviewAllowed = codeReviewLimit?.allowed;
- addWindow(
- WINDOW_META.codeReviewFiveHour.id,
- t(WINDOW_META.codeReviewFiveHour.labelKey),
- WINDOW_META.codeReviewFiveHour.labelKey,
- undefined,
- codeReviewWindows.fiveHourWindow,
- codeReviewLimitReached,
- codeReviewAllowed
- );
- const codeReviewSecondaryWindowMeta = selectSecondaryWindowMeta(
- codeReviewWindows.weeklyWindow,
- WINDOW_META.codeReviewWeekly,
- WINDOW_META.codeReviewMonthly
- );
- addWindow(
- codeReviewSecondaryWindowMeta.id,
- t(codeReviewSecondaryWindowMeta.labelKey),
- codeReviewSecondaryWindowMeta.labelKey,
- undefined,
- codeReviewWindows.weeklyWindow,
- codeReviewLimitReached,
- codeReviewAllowed
- );
-
- const normalizeWindowId = (raw: string) =>
- raw
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '');
-
- if (Array.isArray(additionalRateLimits)) {
- additionalRateLimits.forEach((limitItem, index) => {
- const rateInfo = limitItem?.rate_limit ?? limitItem?.rateLimit ?? null;
- if (!rateInfo) return;
-
- const limitName =
- normalizeStringValue(limitItem?.limit_name ?? limitItem?.limitName) ??
- normalizeStringValue(limitItem?.metered_feature ?? limitItem?.meteredFeature) ??
- `additional-${index + 1}`;
-
- const idPrefix = normalizeWindowId(limitName) || `additional-${index + 1}`;
- const additionalWindows = pickClassifiedWindows(rateInfo);
- const additionalLimitReached = rateInfo.limit_reached ?? rateInfo.limitReached;
- const additionalAllowed = rateInfo.allowed;
-
- addWindow(
- `${idPrefix}-five-hour-${index}`,
- t('codex_quota.additional_primary_window', { name: limitName }),
- 'codex_quota.additional_primary_window',
- { name: limitName },
- additionalWindows.fiveHourWindow,
- additionalLimitReached,
- additionalAllowed
- );
- const additionalSecondaryMeta = selectSecondaryWindowMeta(
- additionalWindows.weeklyWindow,
- { id: 'weekly', labelKey: 'codex_quota.additional_secondary_window' },
- { id: 'monthly', labelKey: 'codex_quota.additional_team_secondary_window' }
- );
- addWindow(
- `${idPrefix}-${additionalSecondaryMeta.id}-${index}`,
- t(additionalSecondaryMeta.labelKey, { name: limitName }),
- additionalSecondaryMeta.labelKey,
- { name: limitName },
- additionalWindows.weeklyWindow,
- additionalLimitReached,
- additionalAllowed
- );
- });
- }
-
- return windows;
-};
-
-const buildCodexRequestHeader = (file: AuthFileItem): Record => {
- const accountId = resolveCodexChatgptAccountId(file);
- const requestHeader: Record = {
- ...CODEX_REQUEST_HEADERS,
- };
- if (accountId) {
- requestHeader['Chatgpt-Account-Id'] = accountId;
- }
- return requestHeader;
-};
-
-const fetchCodexResetCredits = async (
- authIndex: string,
- requestHeader: Record,
- t: TFunction
-): Promise => {
- try {
- const result = await apiCallApi.request(
- {
- authIndex,
- method: 'GET',
- url: CODEX_RATE_LIMIT_RESET_CREDITS_URL,
- header: {
- ...requestHeader,
- Accept: 'application/json',
- 'OpenAI-Beta': 'codex-1',
- Originator: 'Codex Desktop',
- },
- },
- { timeout: CODEX_RESET_CREDITS_REQUEST_TIMEOUT_MS }
- );
-
- if (result.statusCode < 200 || result.statusCode >= 300) {
- return {
- availableCount: null,
- applicableAvailableCount: null,
- credits: [],
- error: getApiCallErrorMessage(result),
- };
- }
-
- const summary = normalizeCodexResetCreditsPayload(result.body ?? result.bodyText);
- if (summary.invalidPayload) {
- return {
- availableCount: null,
- applicableAvailableCount: null,
- credits: [],
- error: t('codex_quota.reset_credits_invalid_payload'),
- };
- }
-
- return {
- availableCount: summary.availableCount,
- applicableAvailableCount: summary.applicableAvailableCount,
- credits: summary.credits,
- error: '',
- };
- } catch (err: unknown) {
- return {
- availableCount: null,
- applicableAvailableCount: null,
- credits: [],
- error: err instanceof Error ? err.message : t('common.unknown_error'),
- };
- }
-};
-
-const fetchCodexQuota = async (file: AuthFileItem, t: TFunction): Promise => {
- const rawAuthIndex = file['auth_index'] ?? file.authIndex;
- const authIndex = normalizeAuthIndex(rawAuthIndex);
- if (!authIndex) {
- throw new Error(t('codex_quota.missing_auth_index'));
- }
-
- const planTypeFromFile = resolveCodexPlanType(file);
- const subscriptionActiveUntil = resolveCodexSubscriptionActiveUntil(file);
- const requestHeader = buildCodexRequestHeader(file);
-
- const result = await apiCallApi.request({
- authIndex,
- method: 'GET',
- url: CODEX_USAGE_URL,
- header: requestHeader,
- });
-
- if (result.statusCode < 200 || result.statusCode >= 300) {
- throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
- }
-
- const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
- if (!payload) {
- throw new Error(t('codex_quota.empty_windows'));
- }
-
- const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
- const resetCredits = payload.rate_limit_reset_credits ?? payload.rateLimitResetCredits ?? null;
- const usageResetCreditsData = normalizeCodexResetCreditsPayload(resetCredits);
- const resetCreditsData = await fetchCodexResetCredits(authIndex, requestHeader, t);
- const resetCreditsCountFromDetails =
- resetCreditsData.credits.length > 0 ? resetCreditsData.credits.length : null;
- const rateLimitResetCreditsAvailableCount =
- resetCreditsData.availableCount ??
- resetCreditsCountFromDetails ??
- usageResetCreditsData.availableCount;
- const rateLimitResetCreditsApplicableAvailableCount =
- usageResetCreditsData.applicableAvailableCount ??
- resetCreditsData.applicableAvailableCount ??
- rateLimitResetCreditsAvailableCount;
- const planType = planTypeFromUsage ?? planTypeFromFile;
- const windows = buildCodexQuotaWindows(payload, t);
- return {
- planType,
- subscriptionActiveUntil,
- rateLimitResetCreditsAvailableCount,
- rateLimitResetCreditsApplicableAvailableCount,
- rateLimitResetCredits: resetCreditsData.credits,
- rateLimitResetCreditsError: resetCreditsData.error,
- windows,
- };
-};
-
-const createCodexRedeemRequestId = (): string => {
- if (typeof globalThis.crypto?.randomUUID === 'function') {
- return globalThis.crypto.randomUUID();
- }
-
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
- const value = Math.floor(Math.random() * 16);
- const segment = char === 'x' ? value : (value & 0x3) | 0x8;
- return segment.toString(16);
- });
-};
-
-const consumeCodexRateLimitResetCredit = async (
- file: AuthFileItem,
- t: TFunction
-): Promise => {
- const rawAuthIndex = file['auth_index'] ?? file.authIndex;
- const authIndex = normalizeAuthIndex(rawAuthIndex);
- if (!authIndex) {
- throw new Error(t('codex_quota.missing_auth_index'));
- }
-
- const requestHeader = buildCodexRequestHeader(file);
-
- const result = await apiCallApi.request({
- authIndex,
- method: 'POST',
- url: CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL,
- header: requestHeader,
- data: JSON.stringify({
- redeem_request_id: createCodexRedeemRequestId(),
- }),
- });
-
- if (result.statusCode < 200 || result.statusCode >= 300) {
- throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
- }
-};
-
-const resetCodexQuota = async (file: AuthFileItem, t: TFunction): Promise => {
- await consumeCodexRateLimitResetCredit(file, t);
- return fetchCodexQuota(file, t);
-};
-
-export const CODEX_CONFIG: QuotaProviderData = {
- type: 'codex',
- i18nPrefix: 'codex_quota',
- filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
- fetchQuota: fetchCodexQuota,
- resetQuota: resetCodexQuota,
- canResetQuota: (quota) => (quota.rateLimitResetCreditsAvailableCount ?? 0) > 0,
- storeSelector: (state) => state.codexQuota,
- storeSetter: 'setCodexQuota',
- buildLoadingState: () => ({
- status: 'loading',
- windows: [],
- rateLimitResetCredits: [],
- rateLimitResetCreditsError: '',
- }),
- buildSuccessState: (data) => ({
- status: 'success',
- windows: data.windows,
- planType: data.planType,
- subscriptionActiveUntil: data.subscriptionActiveUntil,
- rateLimitResetCreditsAvailableCount: data.rateLimitResetCreditsAvailableCount,
- rateLimitResetCreditsApplicableAvailableCount:
- data.rateLimitResetCreditsApplicableAvailableCount,
- rateLimitResetCredits: data.rateLimitResetCredits,
- rateLimitResetCreditsError: data.rateLimitResetCreditsError,
- }),
- buildErrorState: (message, status) => ({
- status: 'error',
- windows: [],
- rateLimitResetCredits: [],
- rateLimitResetCreditsError: '',
- error: message,
- errorStatus: status,
- }),
-};
diff --git a/frontend/src/features/quota/providers/index.ts b/frontend/src/features/quota/providers/index.ts
deleted file mode 100644
index b24942c..0000000
--- a/frontend/src/features/quota/providers/index.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * 额度提供商适配器 = 数据层(data.ts,React-free)+ 渲染体(*QuotaBody.tsx)。
- *
- * 页面侧以擦除泛型的 QuotaAdapter 视图统一消费(与 AuthFileQuotaSection 的
- * 窄接口 cast 同一模式);具体状态类型由各 data.ts 的强类型导出承载。
- */
-
-import type { ComponentType } from 'react';
-import type { TFunction } from 'i18next';
-import { useQuotaStore } from '@/stores';
-import type { AuthFileItem } from '@/types';
-import type { QuotaBodyProps } from '../types';
-import type { QuotaProviderType, QuotaStore } from './types';
-import { ANTIGRAVITY_CONFIG } from './antigravity/data';
-import { AntigravityQuotaBody } from './antigravity/AntigravityQuotaBody';
-import { CLAUDE_CONFIG } from './claude/data';
-import { ClaudeQuotaBody } from './claude/ClaudeQuotaBody';
-import { CODEX_CONFIG } from './codex/data';
-import { CodexQuotaBody } from './codex/CodexQuotaBody';
-import { KIMI_CONFIG } from './kimi/data';
-import { KimiQuotaBody } from './kimi/KimiQuotaBody';
-import { XAI_CONFIG } from './xai/data';
-import { XaiQuotaBody } from './xai/XaiQuotaBody';
-
-/** 所有 provider 额度状态的公共骨架(各 *QuotaState 的结构子集)。 */
-export interface QuotaCardState {
- status: 'idle' | 'loading' | 'success' | 'error';
- error?: string;
- errorStatus?: number;
-}
-
-export interface QuotaAdapter {
- type: QuotaProviderType;
- i18nPrefix: string;
- filterFn: (file: AuthFileItem) => boolean;
- fetchQuota: (file: AuthFileItem, t: TFunction) => Promise;
- resetQuota?: (file: AuthFileItem, t: TFunction) => Promise;
- canResetQuota?: (quota: QuotaCardState) => boolean;
- storeSelector: (state: QuotaStore) => Record;
- storeSetter: keyof QuotaStore;
- buildLoadingState: () => QuotaCardState;
- buildSuccessState: (data: unknown) => QuotaCardState;
- buildErrorState: (message: string, status?: number) => QuotaCardState;
- Body: ComponentType>;
-}
-
-export const QUOTA_ADAPTERS: Record = {
- antigravity: {
- ...ANTIGRAVITY_CONFIG,
- Body: AntigravityQuotaBody,
- } as unknown as QuotaAdapter,
- claude: { ...CLAUDE_CONFIG, Body: ClaudeQuotaBody } as unknown as QuotaAdapter,
- codex: { ...CODEX_CONFIG, Body: CodexQuotaBody } as unknown as QuotaAdapter,
- kimi: { ...KIMI_CONFIG, Body: KimiQuotaBody } as unknown as QuotaAdapter,
- xai: { ...XAI_CONFIG, Body: XaiQuotaBody } as unknown as QuotaAdapter,
-};
-
-export type QuotaMapUpdater = (
- updater: (prev: Record) => Record
-) => void;
-
-/** 取 adapter 对应的 store setter(getState 直读,不建立订阅)。 */
-export const getQuotaSetter = (adapter: QuotaAdapter): QuotaMapUpdater =>
- useQuotaStore.getState()[adapter.storeSetter] as unknown as QuotaMapUpdater;
-
-/** 取 adapter 对应的额度缓存快照(getState 直读,不建立订阅)。 */
-export const getQuotaMap = (adapter: QuotaAdapter): Record