Big update

This commit is contained in:
Alois 2026-08-27 15:02:32 +02:00
commit 2e6afc460b
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
474 changed files with 934 additions and 86159 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ backend/internal/managementasset/dist/
backend/.dev/
result
result-*
.direnv

View file

@ -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.

View file

@ -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"]

View file

@ -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
)
wd, err := os.Getwd()
if err != nil {
log.Errorf("failed to get working directory: %v", err)
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")
}
// 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")
cfg, errLoadConfig := config.LoadConfig(configPath)
if errLoadConfig != nil {
log.WithError(errLoadConfig).Error("failed to load configuration")
return
}
resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir)
if errResolveAuthDir != nil {
log.WithError(errResolveAuthDir).Error("failed to resolve auth directory")
return
}
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
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)
if errConfigureLogging := logging.ConfigureLogOutput(cfg); errConfigureLogging != nil {
log.WithError(errConfigureLogging).Error("failed to configure log output")
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)
return
}
if cfg == nil {
cfg = &config.Config{}
}
// 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
}
}
}
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, "")
}

View file

@ -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)
}
})
}
}

View file

@ -10,7 +10,3 @@ auth-dir: ".dev/auths"
api-keys:
- "dev-api-key"
plugins:
enabled: true
dir: ".dev/plugins"

View file

@ -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.<pluginID>.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__<server>__<tool> 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

View file

@ -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

View file

@ -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.
//

View file

@ -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
}

View file

@ -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 len(names) != 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "exactly one account is required"})
return
}
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"})
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,
})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted": len(deletedFiles), "files": deletedFiles})
}
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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
)

View file

@ -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

View file

@ -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{

View file

@ -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)
}

View file

@ -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";
};
}
);

View file

@ -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

View file

@ -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 }}

View file

@ -1,11 +1,10 @@
<!doctype html>
<html lang="zh-CN" translate="no" class="notranslate">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="google" content="notranslate" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLI Proxy API Management Center</title>
<meta name="theme-color" content="#f2f5ef" />
<title>Vibe Proxy Accounts</title>
</head>
<body>
<div id="root"></div>

View file

@ -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"
}
}

View file

@ -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 (
<>
<NotificationContainer />
<ConfirmationModal />
<Outlet />
</>
<main className="login-shell">
<section className="login-card">
<div className="mark" aria-hidden="true">V</div>
<p className="eyebrow">Vibe Proxy</p>
<h1>Account management</h1>
<p className="lede">Sign in with the management key for this server.</p>
<form onSubmit={submit}>
<label htmlFor="management-key">Management key</label>
<input
id="management-key"
autoFocus
autoComplete="current-password"
type="password"
value={key}
onChange={(event) => setKey(event.target.value)}
placeholder="Enter management key"
/>
{error && <p className="form-error" role="alert">{error}</p>}
<button className="primary wide" disabled={loading || !key.trim()}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</section>
</main>
);
}
const router = createHashRouter([
{
element: <RootShell />,
children: [
{ path: '/login', element: <LoginPage /> },
{
path: '/*',
element: (
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
),
},
],
},
]);
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 <RouterProvider router={router} />;
function QuotaView({ quota }: { quota: CodexQuota }) {
return (
<div className="quota">
{quota.planType && <span className="plan">{quota.planType}</span>}
{quota.windows.length === 0 ? (
<p className="secondary">No quota windows returned.</p>
) : (
quota.windows.map((window) => (
<div className="quota-row" key={window.id}>
<div className="quota-label">
<span>{window.label}</span>
<span>{window.remaining === null ? '--' : `${Math.round(window.remaining)}%`}</span>
</div>
<div className="meter" aria-label={`${window.label} quota remaining`}>
<span style={{ width: `${window.remaining ?? 0}%` }} />
</div>
{window.resetAt && (
<small>Resets {new Date(window.resetAt).toLocaleString()}</small>
)}
</div>
))
)}
</div>
);
}
export default App;
function AccountCard({
account,
managementKey,
onDelete,
}: {
account: CodexAccount;
managementKey: string;
onDelete: () => void;
}) {
const [quota, setQuota] = useState<CodexQuota>();
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 (
<article className="account-card">
<div className="account-head">
<div className="account-identity">
<div className="avatar" aria-hidden="true">{(account.email || 'C')[0].toUpperCase()}</div>
<div>
<h2>{account.email || 'Email unavailable'}</h2>
<span className={`status ${status.tone}`}>{status.label}</span>
</div>
</div>
<button className="danger" onClick={remove} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
{account.statusMessage && <p className="account-message">{account.statusMessage}</p>}
{quota && <QuotaView quota={quota} />}
{quotaError && <p className="form-error" role="alert">{quotaError}</p>}
<button className="secondary-button" onClick={refreshQuota} disabled={loadingQuota || account.disabled}>
{loadingQuota ? 'Refreshing quota...' : quota ? 'Refresh quota' : 'View quota'}
</button>
</article>
);
}
function Management({ managementKey, onLogout }: { managementKey: string; onLogout: () => void }) {
const [accounts, setAccounts] = useState<CodexAccount[]>([]);
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<number | undefined>(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 (
<main className="management-shell">
<header className="topbar">
<a className="brand" href="/" aria-label="Vibe Proxy home">
<span className="mark small" aria-hidden="true">V</span>
<span>Vibe Proxy</span>
</a>
<button className="text-button" onClick={onLogout}>Sign out</button>
</header>
<section className="page-intro">
<div>
<p className="eyebrow">OpenAI Codex</p>
<h1>Accounts</h1>
<p className="lede">Connect accounts and check their current usage limits.</p>
</div>
<button className="primary" onClick={addAccount} disabled={adding}>
{adding ? 'Waiting for authorization...' : 'Add account'}
</button>
</section>
{oauth && (
<section className="oauth-panel">
<div>
<strong>Finish authorization in the OpenAI window.</strong>
<p>If it did not open, <a href={oauth.url} target="_blank" rel="noreferrer">open the authorization link</a>.</p>
</div>
<form onSubmit={submitCallback}>
<label htmlFor="callback-url">Remote server? Paste the full callback URL</label>
<div className="inline-form">
<input
id="callback-url"
value={callbackUrl}
onChange={(event) => setCallbackUrl(event.target.value)}
placeholder="http://localhost:1455/auth/callback?code=..."
/>
<button className="secondary-button" disabled={!callbackUrl.trim()}>Submit</button>
</div>
</form>
</section>
)}
{oauthStatus && <p className="notice">{oauthStatus}</p>}
{error && <p className="form-error" role="alert">{error}</p>}
<section className="account-list" aria-live="polite">
{loading ? (
<div className="empty">Loading accounts...</div>
) : accounts.length === 0 ? (
<div className="empty">
<h2>No Codex accounts yet</h2>
<p>Add an OpenAI account to start routing Codex requests.</p>
</div>
) : (
accounts.map((account) => (
<AccountCard
key={`${account.name}:${String(account.auth_index ?? account.authIndex ?? '')}`}
account={account}
managementKey={managementKey}
onDelete={loadAccounts}
/>
))
)}
</section>
</main>
);
}
export default function App() {
const [managementKey, setManagementKey] = useState(() => sessionStorage.getItem(SESSION_KEY) || '');
function logout() {
sessionStorage.removeItem(SESSION_KEY);
setManagementKey('');
}
return managementKey ? (
<Management managementKey={managementKey} onLogout={logout} />
) : (
<Login onLogin={setManagementKey} />
);
}

95
frontend/src/api.ts Normal file
View file

@ -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<T>(path: string, managementKey: string, init?: RequestInit): Promise<T> {
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<CodexAccount[]> {
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<Record<string, unknown>>('/codex-quota', managementKey, {
method: 'POST',
body: JSON.stringify({ auth_index: authIndex }),
});
},
};

View file

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 0.6.4 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="59">
<path d="M0,0 L8,0 L14,4 L19,14 L27,40 L32,50 L36,54 L35,59 L30,59 L22,52 L11,35 L6,33 L-1,34 L-6,39 L-14,52 L-22,59 L-28,59 L-27,53 L-22,47 L-17,34 L-10,12 L-5,3 Z " fill="#3789F9" transform="translate(28,0)"/>
<path d="M0,0 L8,0 L14,4 L19,14 L25,35 L21,34 L16,29 L11,26 L7,20 L7,18 L2,16 L-3,15 L-8,18 L-12,19 L-9,9 L-4,2 Z " fill="#6D80D8" transform="translate(28,0)"/>
<path d="M0,0 L8,0 L14,4 L19,14 L20,19 L13,15 L10,12 L3,10 L-1,8 L-7,7 L-4,2 Z " fill="#D78240" transform="translate(28,0)"/>
<path d="M0,0 L5,1 L10,4 L12,9 L1,8 L-5,13 L-10,21 L-13,26 L-16,26 L-9,5 L-4,2 Z M6,7 Z " fill="#3294CC" transform="translate(25,14)"/>
<path d="M0,0 L5,2 L10,10 L12,18 L5,14 L1,10 L0,4 L-3,3 L0,2 Z " fill="#E45C49" transform="translate(36,1)"/>
<path d="M0,0 L9,1 L12,3 L12,5 L7,6 L4,8 L-1,11 L-5,12 L-2,2 Z " fill="#90AE64" transform="translate(21,7)"/>
<path d="M0,0 L5,1 L5,4 L-2,7 L-7,11 L-11,10 L-9,5 L-4,2 Z " fill="#53A89A" transform="translate(25,14)"/>
<path d="M0,0 L5,0 L16,9 L17,13 L12,12 L8,9 L8,7 L4,5 L0,2 Z " fill="#B5677D" transform="translate(33,11)"/>
<path d="M0,0 L6,0 L14,6 L19,11 L23,12 L22,15 L15,12 L10,8 L10,6 L4,5 Z " fill="#778998" transform="translate(27,12)"/>
<path d="M0,0 L4,2 L-11,17 L-12,14 L-5,4 Z " fill="#3390DF" transform="translate(26,21)"/>
<path d="M0,0 L2,1 L-4,5 L-9,9 L-13,13 L-14,10 L-13,7 L-6,4 L-3,1 Z " fill="#3FA1B7" transform="translate(27,18)"/>
<path d="M0,0 L4,0 L9,5 L13,6 L12,9 L5,6 L0,2 Z " fill="#8277BB" transform="translate(37,18)"/>
<path d="M0,0 L5,1 L7,6 L-2,5 Z M1,4 Z " fill="#4989CF" transform="translate(30,17)"/>
<path d="M0,0 L5,1 L2,3 L-3,6 L-7,7 L-6,3 Z " fill="#71B774" transform="translate(23,12)"/>
<path d="M0,0 L7,1 L9,7 L5,6 L0,1 Z " fill="#6687E9" transform="translate(44,28)"/>
<path d="M0,0 L7,0 L5,1 L5,3 L8,4 L4,5 L-2,4 Z " fill="#C7AF38" transform="translate(23,3)"/>
<path d="M0,0 L8,0 L8,3 L4,4 L-4,3 Z " fill="#EF842A" transform="translate(28,0)"/>
<path d="M0,0 L7,4 L7,6 L10,6 L11,10 L4,6 L0,2 Z " fill="#CD5D67" transform="translate(37,9)"/>
<path d="M0,0 L5,2 L9,8 L8,11 L2,3 L0,2 Z " fill="#F35241" transform="translate(36,1)"/>
<path d="M0,0 L8,2 L9,6 L4,5 L0,2 Z " fill="#A667A2" transform="translate(41,18)"/>
<path d="M0,0 L9,1 L8,3 L-2,3 Z " fill="#A4B34C" transform="translate(21,7)"/>
<path d="M0,0 L2,0 L7,5 L8,7 L3,6 L0,2 Z " fill="#617FCF" transform="translate(35,18)"/>
<path d="M0,0 L5,2 L8,7 L4,5 L0,2 Z " fill="#9D7784" transform="translate(33,11)"/>
<path d="M0,0 L6,2 L6,4 L0,3 Z " fill="#BC7F59" transform="translate(31,7)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-fill" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1 +0,0 @@
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

Before

Width:  |  Height:  |  Size: 752 B

View file

@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

Before

Width:  |  Height:  |  Size: 756 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="32" height="32" viewBox="0 0 32 32"><defs><filter id="master_svg0_278_51503" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="0" y="0" width="1" height="1"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur in="BackgroundImageFix" stdDeviation="1.3333334922790527"/><feComposite in2="SourceAlpha" operator="in" result="effect1_foregroundBlur"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_foregroundBlur" result="shape"/></filter><linearGradient x1="0.07353696972131729" y1="0.12899449467658997" x2="0.9907095821060244" y2="0.9383787344260006" id="master_svg1_93_40276"><stop offset="0%" stop-color="#5C5CFF" stop-opacity="1"/><stop offset="100%" stop-color="#AE5CFF" stop-opacity="1"/></linearGradient></defs><g><g filter="url(#master_svg0_278_51503)"><rect x="0" y="0" width="32" height="32" rx="16" fill="#F0F2F5" fill-opacity="0"/></g><g><g><path d="M31.843111328125,14.751C31.315411328125,7.18121,25.497411328125,1.04691,17.966011328125,0.119698C10.434711328125,-0.807512,3.302541328125,3.73244,0.954596328125,10.9482C0.345662328125,12.8248,1.732821328125,14.751,3.705641328125,14.751C4.950051328125,14.7517,6.055631328125,13.9569,6.451401328125,12.7772C7.497331328125,9.65101,10.504411328125,3.91401,18.482011328125,3.91401Q29.445911328125,3.91401,31.843111328125,14.751ZM9.127681328125,17.3314L9.127681328125,13.0862Q9.127681328125,13.0022,9.144081328125,12.9198Q9.160481328125,12.8373,9.192641328125,12.7597Q9.224801328125,12.682,9.271501328125,12.6122Q9.318191328125,12.5423,9.377621328125,12.4828Q9.437051328125,12.4234,9.506931328125,12.3767Q9.576811328125,12.33,9.654461328125,12.2979Q9.732111328125,12.2657,9.814541328125,12.2493Q9.896971328125,12.2329,9.981021328125,12.2329L11.049211328125,12.2329Q11.133211328125,12.2329,11.215711328125,12.2493Q11.298111328125,12.2657,11.375811328125,12.2979Q11.453411328125,12.33,11.523311328125,12.3767Q11.593211328125,12.4234,11.652611328125,12.4828Q11.712011328125,12.5423,11.758711328125,12.6122Q11.805411328125,12.682,11.837611328125,12.7597Q11.869711328125,12.8373,11.886111328125,12.9198Q11.902511328125,13.0022,11.902511328125,13.0862L11.902511328125,17.3314Q11.902511328125,17.4154,11.886111328125,17.4978Q11.869711328125,17.5803,11.837611328125,17.6579Q11.805411328125,17.7356,11.758711328125,17.8055Q11.712011328125,17.8753,11.652611328125,17.9348Q11.593211328125,17.9942,11.523311328125,18.0409Q11.453411328125,18.0876,11.375811328125,18.1197Q11.298111328125,18.1519,11.215711328125,18.1683Q11.133211328125,18.1847,11.049211328125,18.1847L9.981021328125,18.1847Q9.896971328125,18.1847,9.814541328125,18.1683Q9.732111328125,18.1519,9.654461328125,18.1197Q9.576811328125,18.0876,9.506931328125,18.0409Q9.437051328125,17.9942,9.377621328125,17.9348Q9.318191328125,17.8753,9.271501328125,17.8055Q9.224801328125,17.7356,9.192641328125,17.6579Q9.160481328125,17.5803,9.144081328125,17.4978Q9.127681328125,17.4154,9.127681328125,17.3314ZM17.273611328125,17.3295C17.272611328125,17.8015,17.654911328125,18.1847,18.126911328125,18.1847L19.408411328125,18.1847C19.879011328125,18.1847,20.260711328125,17.8038,20.261811328125,17.3332L20.266411328125,15.2107L20.266411328125,15.2069L20.261811328125,13.0844C20.260711328125,12.6138,19.879011328125,12.2329,19.408411328125,12.2329L18.126911328125,12.2329C17.654911328125,12.2329,17.272611328125,12.6161,17.273611328125,13.0881L17.278211328125,15.2069L17.278211328125,15.2107L17.273611328125,17.3295ZM13.574711328125,28.0523C21.552211328125,28.0523,24.559311328125,22.3153,25.605811328125,19.1897C26.001411328125,18.0098,27.107111328125,17.215,28.351511328125,17.2158C30.323811328125,17.2158,31.711511328125,19.1416,31.102611328125,21.0181C30.552411328125,22.7189,29.716211328125,24.3134,28.629811328125,25.733L30.137611328125,30.2235L24.775211328125,29.3432C14.645911328125,36.0484,1.048779328125,29.3346,0.214111328125,17.2158Q2.611231328125,28.0523,13.574711328125,28.0523Z" fill-rule="evenodd" fill="url(#master_svg1_93_40276)" fill-opacity="1"/></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><rect width="24" height="24" rx="6" fill="#000"></rect><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></svg>

Before

Width:  |  Height:  |  Size: 829 B

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><rect width="24" height="24" rx="6" fill="#fff"></rect><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#000"></path></svg>

Before

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1 +0,0 @@
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1 +0,0 @@
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

122
frontend/src/codexQuota.ts Normal file
View file

@ -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<string, unknown> | null {
if (typeof payload === 'string' && payload.trim()) {
try {
return JSON.parse(payload) as Record<string, unknown>;
} catch {
return null;
}
}
return payload !== null && typeof payload === 'object'
? (payload as Record<string, unknown>)
: 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<CodexQuota> {
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,
};
}

View file

@ -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 (
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
{typeof message === 'string' ? (
<p style={{ margin: '1rem 0' }}>{message}</p>
) : (
<div style={{ margin: '1rem 0' }}>{message}</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
{cancelText || t('common.cancel')}
</Button>
<Button variant={variant} onClick={handleConfirm} loading={isLoading}>
{confirmText || t('common.confirm')}
</Button>
</div>
</Modal>
);
}

View file

@ -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<AnimatedNotification[]>([]);
const prevNotificationsRef = useRef<Notification[]>([]);
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 (
<div className="notification-container">
{animatedNotifications.map((notification) => (
<div
key={notification.id}
className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`}
>
<div className="message">{notification.message}</div>
<button
type="button"
className="close-btn"
onClick={() => handleClose(notification.id)}
aria-label={t('common.close')}
>
<IconX size={16} />
</button>
</div>
))}
</div>
);
}

View file

@ -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;
}
}

View file

@ -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<HTMLElement | null>;
}
// 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<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null);
const transitionDirectionRef = useRef<TransitionDirection>('forward');
const transitionVariantRef = useRef<TransitionVariant>('vertical');
const exitScrollOffsetRef = useRef(0);
const enterScrollOffsetRef = useRef(0);
const scrollPositionsRef = useRef(new Map<string, number>());
const nextLayersRef = useRef<Layer[] | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [layers, setLayers] = useState<Layer[]>(() => [
{
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 (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{(() => {
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 (
<div
key={layer.key}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden={layer.status !== 'current'}
inert={layer.status !== 'current'}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
<PageTransitionLayerContext.Provider
value={{
...PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status],
isAnimating,
}}
>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
);
});
})()}
</div>
);
}

View file

@ -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<PageTransitionLayerContextValue | null>(
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);
}

View file

@ -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;
}

View file

@ -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<HTMLDivElement, SecondaryScreenShellProps>(
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 (
<div className={containerClassName} ref={ref}>
<div className={styles.topBar}>
{onBack ? (
<Button
variant="ghost"
size="sm"
onClick={onBack}
className={styles.backButton}
aria-label={resolvedBackAriaLabel}
>
<span className={styles.backIcon}>
<IconChevronLeft size={18} />
</span>
<span className={styles.backText}>{backLabel}</span>
</Button>
) : (
<div />
)}
<div className={styles.topBarTitle} title={titleTooltip}>
{title}
</div>
<div className={styles.rightSlot}>{rightAction}</div>
</div>
{isLoading ? (
<div className={styles.loadingState}>
<LoadingSpinner size={16} />
<span>{loadingLabel}</span>
</div>
) : (
<div className={contentClasses}>{children}</div>
)}
</div>
);
}
);

View file

@ -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;
}
}

View file

@ -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 <div className={styles.chipRow}>{children}</div>;
}
export function ExcludedModelRuleChip({
label,
variant = 'exact',
detail,
onRemove,
removeAriaLabel,
disabled = false,
title,
}: ExcludedModelRuleChipProps) {
return (
<span
className={`${styles.chip} ${styles[variant]}`}
title={title ?? (detail ? `${label}${detail}` : label)}
>
<span className={styles.label}>{label}</span>
{detail ? <span className={styles.detail}>{detail}</span> : null}
{onRemove ? (
<button
type="button"
className={styles.remove}
onClick={onRemove}
disabled={disabled}
aria-label={removeAriaLabel ?? label}
>
<IconX size={12} />
</button>
) : null}
</span>
);
}

View file

@ -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<HTMLInputElement | null>(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<HTMLInputElement>) => {
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 (
<div className={styles.panel}>
<div className={styles.searchRow}>
<IconSearch size={14} className={styles.searchIcon} aria-hidden="true" />
<input
ref={inputRef}
type="text"
className={styles.search}
value={query}
onChange={(event) => {
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}
/>
</div>
<div
id={listboxId}
className={styles.list}
role="listbox"
aria-multiselectable="true"
aria-label={t('excluded_models.list_aria')}
>
{visible.length === 0 ? (
<p className={styles.noResults}>
{query.trim()
? t('excluded_models.no_results', { query: query.trim() })
: t('excluded_models.catalog_empty')}
</p>
) : (
visible.map((candidate, index) => (
<ExcludedModelRow
key={candidate.id.toLowerCase()}
id={`${listboxId}-opt-${index}`}
candidate={candidate}
state={getModelExclusionState(rules, candidate.id)}
highlighted={index === activeIndex}
onHover={() => setHighlight(index)}
onToggle={() => toggleAt(index)}
/>
))
)}
</div>
<div className={styles.footer}>
<span className={styles.footerCount}>
{t('excluded_models.footer_count', { excluded: stats.excluded, total: stats.total })}
</span>
<span className={styles.footerActions}>
<button
type="button"
className={styles.footerButton}
onClick={onSelectAll}
disabled={disabled || candidates.length === 0}
>
{t('excluded_models.select_all')}
</button>
<button
type="button"
className={styles.footerButton}
onClick={onClear}
disabled={disabled}
aria-label={t('excluded_models.clear_aria')}
>
{t('excluded_models.clear')}
</button>
</span>
</div>
</div>
);
}
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 (
<div
id={id}
role="option"
// 行永不进 tab 序:外层 Sheet 的焦点陷阱每次 Tab 都枚举全部可聚焦元素,
// 几十个可聚焦的行会把它拖垮。漫游全靠 aria-activedescendant。
tabIndex={-1}
aria-selected={excluded}
aria-disabled={lockedByRule || undefined}
className={rowClass}
onMouseEnter={onHover}
onClick={onToggle}
>
<span className={styles.checkbox} aria-hidden="true">
{excluded ? <IconCheck size={12} /> : null}
</span>
<span className={styles.rowText}>
<span className={styles.rowId}>{candidate.id}</span>
{candidate.displayName && candidate.displayName !== candidate.id ? (
<span className={styles.rowDisplayName}>{candidate.displayName}</span>
) : null}
{wildcardReason ? <span className={styles.rowReason}>{wildcardReason.text}</span> : null}
</span>
{wildcardReason ? (
<span className={`${styles.badge} ${wildcardReason.muted ? styles.badgeMuted : ''}`.trim()}>
{t('excluded_models.badge_wildcard')}
</span>
) : null}
</div>
);
}

View file

@ -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;
}
}

View file

@ -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<HTMLButtonElement | null>(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 (
<div className={`${styles.root} ${className ?? ''}`.trim()}>
<button
ref={triggerRef}
type="button"
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`.trim()}
onClick={() => setOpen((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && !open) {
event.preventDefault();
setOpen(true);
} else if (event.key === 'Escape' && open) {
event.preventDefault();
event.stopPropagation();
setOpen(false);
}
}}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
aria-labelledby={labelledBy}
disabled={disabled}
>
<span className={styles.triggerText}>
{catalogState === 'loading' ? (
<IconLoader2 size={13} className={styles.triggerSpinner} aria-hidden="true" />
) : null}
{summaryText()}
</span>
<IconChevronDown size={14} className={styles.chevron} aria-hidden="true" />
{hasCatalog ? (
<span
className={styles.meter}
role="img"
aria-label={t('excluded_models.meter_aria', {
excluded: stats.excluded,
total: stats.total,
})}
>
<span
className={styles.meterFill}
style={{ width: `${stats.total ? (stats.excluded / stats.total) * 100 : 0}%` }}
/>
</span>
) : null}
</button>
<div
id={panelId}
className={`${styles.disclosure} ${open ? styles.disclosureOpen : ''}`.trim()}
>
<div className={styles.disclosureInner} inert={!open}>
{catalogState === 'ready' || candidates.length > 0 ? (
<ExcludedModelsPanel
rules={rules}
candidates={candidates}
stats={stats}
onToggle={handleToggle}
onSelectAll={handleSelectAll}
onClear={handleClear}
disabled={disabled}
listboxId={listboxId}
autoFocus={open}
onDismiss={dismissPanel}
/>
) : (
<div className={styles.catalogNotice}>
<span>
{catalogState === 'loading'
? t('excluded_models.catalog_loading')
: catalogState === 'error'
? t('excluded_models.catalog_error')
: t('excluded_models.catalog_unavailable')}
</span>
{onRetryCatalog && catalogState !== 'loading' ? (
<button type="button" className={styles.retryButton} onClick={onRetryCatalog}>
{t('excluded_models.catalog_retry')}
</button>
) : null}
</div>
)}
</div>
</div>
{exactRules.length > 0 || derivedModels.length > 0 || unknownRules.length > 0 ? (
<ExcludedModelChipRow>
{exactRules.map((rule) => (
<ExcludedModelRuleChip
key={`exact-${rule.toLowerCase()}`}
label={rule}
variant="exact"
onRemove={() => commit(toggleExcludedRule(rules, rule, false))}
removeAriaLabel={t('excluded_models.chip_remove', { rule })}
disabled={disabled}
/>
))}
{derivedModels.slice(0, DERIVED_CHIP_LIMIT).map((item) => (
<ExcludedModelRuleChip
key={`derived-${item.id.toLowerCase()}`}
label={item.id}
variant="wildcard"
detail={item.rule}
title={t('excluded_models.wildcard_reason', { rule: item.rule })}
/>
))}
{derivedModels.length > DERIVED_CHIP_LIMIT ? (
<span className={styles.chipsMore}>
{t('excluded_models.chips_more', { n: derivedModels.length - DERIVED_CHIP_LIMIT })}
</span>
) : null}
{unknownRules.map((rule) => (
<ExcludedModelRuleChip
key={`unknown-${rule.toLowerCase()}`}
label={rule}
variant="unknown"
detail={t('excluded_models.badge_unknown')}
onRemove={() => commit(toggleExcludedRule(rules, rule, false))}
removeAriaLabel={t('excluded_models.chip_remove', { rule })}
disabled={disabled}
/>
))}
</ExcludedModelChipRow>
) : null}
{showRuleEditor ? (
<div className={styles.ruleEditor}>
<label className={styles.ruleLabel} htmlFor={`${baseId}-rules`}>
{t('excluded_models.rules_label')}
</label>
<textarea
id={`${baseId}-rules`}
className="input"
value={formatExcludedRulesText(customRules)}
placeholder={t('excluded_models.rules_placeholder')}
rows={3}
disabled={disabled}
spellCheck={false}
onChange={(event) => handleRuleEditorChange(event.target.value)}
/>
{reservedHit ? (
<p className={styles.ruleWarning}>
<IconAlertTriangle size={12} aria-hidden="true" />
{reservedRuleMessage ?? t('excluded_models.rules_reserved')}
</p>
) : null}
{hasCatalog ? (
<ul className={styles.ruleMatches}>
{ruleSummaries.map((summary) => (
<li
key={summary.rule.toLowerCase()}
className={summary.matchCount === 0 ? styles.ruleMatchNone : undefined}
>
<code>{summary.rule}</code>
{summary.matchCount === 0
? t('excluded_models.rules_match_none')
: t('excluded_models.rules_match_count', { n: summary.matchCount })}
</li>
))}
</ul>
) : null}
<p className="hint">{t('excluded_models.rules_hint')}</p>
</div>
) : null}
</div>
);
}

View file

@ -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>): string[] {
const seen = new Set<string>();
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<string>, modelId: string): boolean =>
Array.from(rules).some((rule) => isWildcardRule(rule) && matchesExcludedRule(rule, modelId));
/** 规则列表里是否存在与 candidate 字面相等(忽略大小写)的一条。不做通配符展开。 */
export function hasExcludedRule(rules: Iterable<string>, 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<string>,
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<string>,
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<string>,
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 };
}

View file

@ -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';

File diff suppressed because it is too large Load diff

View file

@ -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);
}
}

View file

@ -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<string, OAuthModelAliasEntry[]>;
allProviderModels?: Record<string, AuthFileModelItem[]>;
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<ModelMappingDiagramRef, ModelMappingDiagramProps>(
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<HTMLDivElement>(null);
const [lines, setLines] = useState<DiagramLine[]>([]);
const [draggedSource, setDraggedSource] = useState<SourceNode | null>(null);
const [draggedAlias, setDraggedAlias] = useState<string | null>(null);
const [dropTargetAlias, setDropTargetAlias] = useState<string | null>(null);
const [dropTargetSource, setDropTargetSource] = useState<string | null>(null);
const [tapSourceId, setTapSourceId] = useState<string | null>(null);
const [tapAlias, setTapAlias] = useState<string | null>(null);
const [extraAliases, setExtraAliases] = useState<string[]>([]);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
const [providerGroupHeights, setProviderGroupHeights] = useState<Record<string, number>>({});
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<string | null>(null);
const [settingsSourceId, setSettingsSourceId] = useState<string | null>(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<string, boolean> }
>();
const aliasSet = new Set<string>();
// 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<string, SourceNode[]>();
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<Map<string, HTMLDivElement>>(new Map());
const sourceRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const aliasRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const toggleProviderCollapse = (provider: string) => {
setCollapsedProviders((prev) => {
const next = new Set(prev);
if (next.has(provider)) next.delete(provider);
else next.add(provider);
return next;
});
};
// Calculate lines: provider→source, source→alias (when expanded); midpoint + linkData for source→alias
const updateLines = useCallback(() => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newLines: { path: string; color: string; id: string }[] = [];
const nextProviderGroupHeights: Record<string, number> = {};
const bezier = (x1: number, y1: number, x2: number, y2: number) => {
const cpx1 = x1 + (x2 - x1) * 0.5;
const cpx2 = x2 - (x2 - x1) * 0.5;
return `M ${x1} ${y1} C ${cpx1} ${y1}, ${cpx2} ${y2}, ${x2} ${y2}`;
};
providerNodes.forEach(({ provider, sources }) => {
const collapsed = collapsedProviders.has(provider);
if (collapsed) return;
if (sources.length > 0) {
const firstEl = sourceRefs.current.get(sources[0].id);
const lastEl = sourceRefs.current.get(sources[sources.length - 1].id);
if (firstEl && lastEl) {
const height = Math.max(
0,
Math.round(
lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top
)
);
if (height > 0) nextProviderGroupHeights[provider] = height;
}
}
const providerEl = providerRefs.current.get(provider);
if (!providerEl) return;
const providerRect = providerEl.getBoundingClientRect();
const px = providerRect.right - containerRect.left;
const py = providerRect.top + providerRect.height / 2 - containerRect.top;
const color = getProviderColor(provider);
// Provider → Source (branch link, no dot)
sources.forEach((source) => {
const sourceEl = sourceRefs.current.get(source.id);
if (!sourceEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
const sx = sourceRect.left - containerRect.left;
const sy = sourceRect.top + sourceRect.height / 2 - containerRect.top;
newLines.push({
id: `provider-${provider}-source-${source.id}`,
path: bezier(px, py, sx, sy),
color,
});
});
// Source → Alias: one line per alias
sources.forEach((source) => {
if (!source.aliases || source.aliases.length === 0) return;
source.aliases.forEach((aliasEntry) => {
const sourceEl = sourceRefs.current.get(source.id);
const aliasEl = aliasRefs.current.get(aliasEntry.alias);
if (!sourceEl || !aliasEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
const aliasRect = aliasEl.getBoundingClientRect();
// Calculate coordinates relative to the container
const x1 = sourceRect.right - containerRect.left;
const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
const x2 = aliasRect.left - containerRect.left;
const y2 = aliasRect.top + aliasRect.height / 2 - containerRect.top;
newLines.push({
id: `${source.id}-${aliasEntry.alias}`,
path: bezier(x1, y1, x2, y2),
color,
});
});
});
});
setLines(newLines);
setProviderGroupHeights((prev) => {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(nextProviderGroupHeights);
if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights;
for (const key of nextKeys) {
if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) {
return nextProviderGroupHeights;
}
}
return prev;
});
}, [providerNodes, collapsedProviders]);
useImperativeHandle(
ref,
() => ({
collapseAll: () => setCollapsedProviders(new Set(providerNodes.map((p) => p.provider))),
refreshLayout: () => updateLines(),
}),
[providerNodes, updateLines]
);
useLayoutEffect(() => {
// updateLines is called after layout is calculated, ensuring elements are in place.
const raf = requestAnimationFrame(updateLines);
window.addEventListener('resize', updateLines);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', updateLines);
};
}, [updateLines, aliasNodes]);
useLayoutEffect(() => {
const raf = requestAnimationFrame(updateLines);
return () => cancelAnimationFrame(raf);
}, [providerGroupHeights, updateLines]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => updateLines());
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [updateLines]);
// Drag and Drop handlers
// 1. Source -> Alias
const handleDragStart = (e: DragEvent, source: SourceNode) => {
setTapSourceId(null);
setTapAlias(null);
setDraggedSource(source);
e.dataTransfer.setData('text/plain', source.id);
e.dataTransfer.effectAllowed = 'link';
};
const handleDragOver = (e: DragEvent, alias: string) => {
if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return;
e.preventDefault(); // Allow drop
e.dataTransfer.dropEffect = 'link';
setDropTargetAlias(alias);
};
const handleDragLeave = () => {
setDropTargetAlias(null);
};
const handleDrop = (e: DragEvent, alias: string) => {
e.preventDefault();
if (
draggedSource &&
!draggedSource.aliases.some((entry) => entry.alias === alias) &&
onUpdate
) {
onUpdate(draggedSource.provider, draggedSource.name, alias);
}
setDraggedSource(null);
setDropTargetAlias(null);
};
// 2. Alias -> Source
const handleDragStartAlias = (e: DragEvent, alias: string) => {
setTapSourceId(null);
setTapAlias(null);
setDraggedAlias(alias);
e.dataTransfer.setData('text/plain', alias);
e.dataTransfer.effectAllowed = 'link';
};
const handleDragOverSource = (e: DragEvent, source: SourceNode) => {
if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
setDropTargetSource(source.id);
};
const handleDragLeaveSource = () => {
setDropTargetSource(null);
};
const handleDropOnSource = (e: DragEvent, source: SourceNode) => {
e.preventDefault();
if (
draggedAlias &&
!source.aliases.some((entry) => entry.alias === draggedAlias) &&
onUpdate
) {
onUpdate(source.provider, source.name, draggedAlias);
}
setDraggedAlias(null);
setDropTargetSource(null);
};
const handleContextMenu = (
e: ReactMouseEvent,
type: 'alias' | 'background' | 'provider' | 'source',
data?: string
) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
type,
data,
});
};
const closeContextMenu = () => setContextMenu(null);
const resolveSourceById = useCallback(
(id: string | null) => {
if (!id) return null;
for (const { sources } of providerNodes) {
const found = sources.find((source) => source.id === id);
if (found) return found;
}
return null;
},
[providerNodes]
);
const handleTapSelectSource = (source: SourceNode) => {
if (!onUpdate) return;
if (tapSourceId === source.id) {
setTapSourceId(null);
return;
}
if (tapAlias) {
onUpdate(source.provider, source.name, tapAlias);
setTapSourceId(null);
setTapAlias(null);
return;
}
setTapSourceId(source.id);
setTapAlias(null);
};
const handleTapSelectAlias = (alias: string) => {
if (!onUpdate) return;
if (tapAlias === alias) {
setTapAlias(null);
return;
}
if (tapSourceId) {
const source = resolveSourceById(tapSourceId);
if (source) {
onUpdate(source.provider, source.name, alias);
}
setTapSourceId(null);
setTapAlias(null);
return;
}
setTapAlias(alias);
setTapSourceId(null);
};
const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => {
if (onDeleteLink) onDeleteLink(provider, sourceModel, alias);
};
const handleToggleFork = (
provider: string,
sourceModel: string,
alias: string,
value: boolean
) => {
if (onToggleFork) onToggleFork(provider, sourceModel, alias, value);
};
const handleAddAlias = () => {
closeContextMenu();
setAddAliasOpen(true);
setAddAliasValue('');
setAddAliasError('');
};
const handleAddAliasSubmit = () => {
const trimmed = addAliasValue.trim();
if (!trimmed) {
setAddAliasError(t('oauth_model_alias.diagram_please_enter_alias'));
return;
}
if (
hasModelAliasConflict(
aliasNodes.map((alias) => alias.alias),
trimmed
)
) {
setAddAliasError(t('oauth_model_alias.diagram_alias_exists'));
return;
}
setExtraAliases((prev) => [...prev, trimmed]);
setAddAliasOpen(false);
};
const handleRenameClick = (oldAlias: string) => {
closeContextMenu();
setRenameState({ oldAlias });
setRenameValue(oldAlias);
setRenameError('');
};
const handleRenameSubmit = () => {
const trimmed = renameValue.trim();
if (!trimmed) {
setRenameError(t('oauth_model_alias.diagram_please_enter_alias'));
return;
}
if (trimmed === renameState?.oldAlias) {
setRenameState(null);
return;
}
if (
hasModelAliasConflict(
aliasNodes.map((alias) => alias.alias),
trimmed,
renameState?.oldAlias
)
) {
setRenameError(t('oauth_model_alias.diagram_alias_exists'));
return;
}
if (onRenameAlias && renameState) onRenameAlias(renameState.oldAlias, trimmed);
if (extraAliases.includes(renameState?.oldAlias ?? '')) {
setExtraAliases((prev) => prev.map((a) => (a === renameState?.oldAlias ? trimmed : a)));
}
setRenameState(null);
};
const handleDeleteClick = (alias: string) => {
closeContextMenu();
const node = aliasNodes.find((n) => n.alias === alias);
if (!node) return;
if (node.sources.length === 0) {
setExtraAliases((prev) => prev.filter((a) => a !== alias));
} else {
if (onDeleteAlias) onDeleteAlias(alias);
}
};
return (
<div className={[styles.scrollContainer, className].filter(Boolean).join(' ')}>
{enableTapLinking && onUpdate && (
<div className={styles.tapHint}>{t('oauth_model_alias.diagram_tap_hint')}</div>
)}
<div
className={styles.container}
ref={containerRef}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, 'background');
}}
>
<svg className={styles.connections}>
{lines.map((line) => (
<path
key={line.id}
d={line.path}
stroke={line.color}
strokeOpacity={isDark ? 0.4 : 0.3}
/>
))}
</svg>
<ProviderColumn
providerNodes={providerNodes}
collapsedProviders={collapsedProviders}
getProviderColor={getProviderColor}
providerGroupHeights={providerGroupHeights}
providerRefs={providerRefs}
onToggleCollapse={toggleProviderCollapse}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_providers')}
expandLabel={t('oauth_model_alias.diagram_expand')}
collapseLabel={t('oauth_model_alias.diagram_collapse')}
/>
<SourceColumn
providerNodes={providerNodes}
collapsedProviders={collapsedProviders}
sourceRefs={sourceRefs}
getProviderColor={getProviderColor}
selectedSourceId={enableTapLinking ? tapSourceId : null}
onSelectSource={enableTapLinking ? handleTapSelectSource : undefined}
draggedSource={draggedSource}
dropTargetSource={dropTargetSource}
draggable={!!onUpdate}
onDragStart={handleDragStart}
onDragEnd={() => {
setDraggedSource(null);
setDropTargetAlias(null);
}}
onDragOver={handleDragOverSource}
onDragLeave={handleDragLeaveSource}
onDrop={handleDropOnSource}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_source_models')}
/>
<AliasColumn
aliasNodes={aliasNodes}
aliasRefs={aliasRefs}
dropTargetAlias={dropTargetAlias}
draggedAlias={draggedAlias}
selectedAlias={enableTapLinking ? tapAlias : null}
onSelectAlias={enableTapLinking ? handleTapSelectAlias : undefined}
draggable={!!onUpdate}
onDragStart={handleDragStartAlias}
onDragEnd={() => {
setDraggedAlias(null);
setDropTargetSource(null);
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_aliases')}
/>
</div>
<DiagramContextMenu
contextMenu={contextMenu}
t={t}
onRequestClose={() => setContextMenu(null)}
onAddAlias={handleAddAlias}
onRenameAlias={handleRenameClick}
onOpenAliasSettings={(alias) => {
setContextMenu(null);
setSettingsAlias(alias);
}}
onDeleteAlias={handleDeleteClick}
onEditProvider={(provider) => {
setContextMenu(null);
onEditProvider?.(provider);
}}
onDeleteProvider={(provider) => {
setContextMenu(null);
onDeleteProvider?.(provider);
}}
onOpenSourceSettings={(sourceId) => {
setContextMenu(null);
setSettingsSourceId(sourceId);
}}
/>
<RenameAliasModal
open={!!renameState}
t={t}
value={renameValue}
error={renameError}
onChange={(value) => {
setRenameValue(value);
setRenameError('');
}}
onClose={() => setRenameState(null)}
onSubmit={handleRenameSubmit}
/>
<AddAliasModal
open={addAliasOpen}
t={t}
value={addAliasValue}
error={addAliasError}
onChange={(value) => {
setAddAliasValue(value);
setAddAliasError('');
}}
onClose={() => setAddAliasOpen(false)}
onSubmit={handleAddAliasSubmit}
/>
<SettingsAliasModal
open={Boolean(settingsAlias)}
t={t}
alias={settingsAlias}
aliasNodes={aliasNodes}
onClose={() => setSettingsAlias(null)}
onToggleFork={handleToggleFork}
onUnlink={handleUnlinkSource}
/>
<SettingsSourceModal
open={Boolean(settingsSourceId)}
t={t}
source={resolveSourceById(settingsSourceId)}
onClose={() => setSettingsSourceId(null)}
onToggleFork={handleToggleFork}
onUnlink={handleUnlinkSource}
/>
</div>
);
}
);

View file

@ -1,251 +0,0 @@
import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react';
import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface ProviderColumnProps {
providerNodes: ProviderNode[];
collapsedProviders: Set<string>;
getProviderColor: (provider: string) => string;
providerGroupHeights?: Record<string, number>;
providerRefs: RefObject<Map<string, HTMLDivElement>>;
onToggleCollapse: (provider: string) => void;
onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void;
label: string;
expandLabel: string;
collapseLabel: string;
}
export function ProviderColumn({
providerNodes,
collapsedProviders,
getProviderColor,
providerGroupHeights = {},
providerRefs,
onToggleCollapse,
onContextMenu,
label,
expandLabel,
collapseLabel,
}: ProviderColumnProps) {
return (
<div
className={`${styles.column} ${styles.providers}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{providerNodes.map(({ provider, sources }) => {
const collapsed = collapsedProviders.has(provider);
const groupHeight = collapsed ? undefined : providerGroupHeights[provider];
return (
<div
key={provider}
className={styles.providerGroup}
style={groupHeight ? { height: groupHeight } : undefined}
>
<div
ref={(el) => {
if (el) providerRefs.current?.set(provider, el);
else providerRefs.current?.delete(provider);
}}
className={`${styles.item} ${styles.providerItem}`}
style={{ borderLeftColor: getProviderColor(provider) }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'provider', provider);
}}
>
<button
type="button"
className={styles.collapseBtn}
onClick={() => onToggleCollapse(provider)}
aria-label={collapsed ? expandLabel : collapseLabel}
title={collapsed ? expandLabel : collapseLabel}
>
<span className={collapsed ? styles.chevronRight : styles.chevronDown} />
</button>
<span className={styles.providerLabel} style={{ color: getProviderColor(provider) }}>
{provider}
</span>
<span className={styles.itemCount}>{sources.length}</span>
</div>
</div>
);
})}
</div>
);
}
interface SourceColumnProps {
providerNodes: ProviderNode[];
collapsedProviders: Set<string>;
sourceRefs: RefObject<Map<string, HTMLDivElement>>;
getProviderColor: (provider: string) => string;
selectedSourceId?: string | null;
onSelectSource?: (source: SourceNode) => void;
draggedSource: SourceNode | null;
dropTargetSource: string | null;
draggable: boolean;
onDragStart: (e: DragEvent, source: SourceNode) => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent, source: SourceNode) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent, source: SourceNode) => void;
onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void;
label: string;
}
export function SourceColumn({
providerNodes,
collapsedProviders,
sourceRefs,
getProviderColor,
selectedSourceId,
onSelectSource,
draggedSource,
dropTargetSource,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onContextMenu,
label,
}: SourceColumnProps) {
return (
<div
className={`${styles.column} ${styles.sources}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{providerNodes.flatMap(({ provider, sources }) => {
if (collapsedProviders.has(provider)) return [];
return sources.map((source) => (
<div
key={source.id}
ref={(el) => {
if (el) sourceRefs.current?.set(source.id, el);
else sourceRefs.current?.delete(source.id);
}}
className={`${styles.item} ${styles.sourceItem} ${
draggedSource?.id === source.id ? styles.dragging : ''
} ${dropTargetSource === source.id ? styles.dropTarget : ''} ${
selectedSourceId === source.id ? styles.selected : ''
}`}
onClick={() => onSelectSource?.(source)}
draggable={draggable}
onDragStart={(e) => onDragStart(e, source)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, source)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, source)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'source', source.id);
}}
>
<span className={styles.itemName} title={source.name}>
{source.name}
</span>
<div
className={styles.dot}
style={{
background: getProviderColor(source.provider),
opacity: source.aliases.length > 0 ? 1 : 0.3,
}}
/>
</div>
));
})}
</div>
);
}
interface AliasColumnProps {
aliasNodes: AliasNode[];
aliasRefs: RefObject<Map<string, HTMLDivElement>>;
dropTargetAlias: string | null;
draggedAlias: string | null;
selectedAlias?: string | null;
onSelectAlias?: (alias: string) => void;
draggable: boolean;
onDragStart: (e: DragEvent, alias: string) => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent, alias: string) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent, alias: string) => void;
onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void;
label: string;
}
export function AliasColumn({
aliasNodes,
aliasRefs,
dropTargetAlias,
draggedAlias,
selectedAlias,
onSelectAlias,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onContextMenu,
label,
}: AliasColumnProps) {
return (
<div
className={`${styles.column} ${styles.aliases}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{aliasNodes.map((node) => (
<div
key={node.id}
ref={(el) => {
if (el) aliasRefs.current?.set(node.id, el);
else aliasRefs.current?.delete(node.id);
}}
className={`${styles.item} ${styles.aliasItem} ${
dropTargetAlias === node.alias ? styles.dropTarget : ''
} ${draggedAlias === node.alias ? styles.dragging : ''} ${
selectedAlias === node.alias ? styles.selected : ''
}`}
onClick={() => onSelectAlias?.(node.alias)}
draggable={draggable}
onDragStart={(e) => onDragStart(e, node.alias)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, node.alias)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, node.alias)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'alias', node.alias);
}}
>
<div className={`${styles.dot} ${styles.dotLeft}`} />
<span className={styles.itemName} title={node.alias}>
{node.alias}
</span>
<span className={styles.itemCount}>{node.sources.length}</span>
</div>
))}
</div>
);
}

View file

@ -1,114 +0,0 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { TFunction } from 'i18next';
import type { ContextMenuState } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface DiagramContextMenuProps {
contextMenu: ContextMenuState | null;
t: TFunction;
onRequestClose: () => void;
onAddAlias: () => void;
onRenameAlias: (alias: string) => void;
onOpenAliasSettings: (alias: string) => void;
onDeleteAlias: (alias: string) => void;
onEditProvider: (provider: string) => void;
onDeleteProvider: (provider: string) => void;
onOpenSourceSettings: (sourceId: string) => void;
}
export function DiagramContextMenu({
contextMenu,
t,
onRequestClose,
onAddAlias,
onRenameAlias,
onOpenAliasSettings,
onDeleteAlias,
onEditProvider,
onDeleteProvider,
onOpenSourceSettings,
}: DiagramContextMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!contextMenu) return;
const handleClick = (event: globalThis.MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
onRequestClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [contextMenu, onRequestClose]);
if (!contextMenu) return null;
const { type, data } = contextMenu;
const renderBackground = () => (
<div className={styles.menuItem} onClick={onAddAlias}>
<span>{t('oauth_model_alias.diagram_add_alias')}</span>
</div>
);
const renderAlias = () => {
if (!data) return null;
return (
<>
<div className={styles.menuItem} onClick={() => onRenameAlias(data)}>
<span>{t('oauth_model_alias.diagram_rename')}</span>
</div>
<div className={styles.menuItem} onClick={() => onOpenAliasSettings(data)}>
<span>{t('oauth_model_alias.diagram_settings')}</span>
</div>
<div className={styles.menuDivider} />
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteAlias(data)}>
<span>{t('oauth_model_alias.diagram_delete_alias')}</span>
</div>
</>
);
};
const renderProvider = () => {
if (!data) return null;
return (
<>
<div className={styles.menuItem} onClick={() => onEditProvider(data)}>
<span>{t('common.edit')}</span>
</div>
<div className={styles.menuDivider} />
<div
className={`${styles.menuItem} ${styles.danger}`}
onClick={() => onDeleteProvider(data)}
>
<span>{t('oauth_model_alias.delete')}</span>
</div>
</>
);
};
const renderSource = () => {
if (!data) return null;
return (
<div className={styles.menuItem} onClick={() => onOpenSourceSettings(data)}>
<span>{t('oauth_model_alias.diagram_settings')}</span>
</div>
);
};
return createPortal(
<div
ref={menuRef}
className={styles.contextMenu}
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{type === 'background' && renderBackground()}
{type === 'alias' && renderAlias()}
{type === 'provider' && renderProvider()}
{type === 'source' && renderSource()}
</div>,
document.body
);
}

View file

@ -1,277 +0,0 @@
import type { KeyboardEvent } from 'react';
import type { TFunction } from 'i18next';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconTrash2 } from '@/components/ui/icons';
import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface RenameAliasModalProps {
open: boolean;
t: TFunction;
value: string;
error: string;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export function RenameAliasModal({
open,
t,
value,
error,
onChange,
onClose,
onSubmit,
}: RenameAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_rename_alias_title')}
width={400}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_rename_btn')}</Button>
</>
}
>
<Input
label={t('oauth_model_alias.diagram_rename_alias_label')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') onSubmit();
}}
error={error}
placeholder={t('oauth_model_alias.diagram_rename_placeholder')}
autoFocus
/>
</Modal>
);
}
interface AddAliasModalProps {
open: boolean;
t: TFunction;
value: string;
error: string;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export function AddAliasModal({
open,
t,
value,
error,
onChange,
onClose,
onSubmit,
}: AddAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_add_alias_title')}
width={400}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_add_btn')}</Button>
</>
}
>
<Input
label={t('oauth_model_alias.diagram_add_alias_label')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') onSubmit();
}}
error={error}
placeholder={t('oauth_model_alias.diagram_add_placeholder')}
autoFocus
/>
</Modal>
);
}
interface SettingsAliasModalProps {
open: boolean;
t: TFunction;
alias: string | null;
aliasNodes: AliasNode[];
onClose: () => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
}
export function SettingsAliasModal({
open,
t,
alias,
aliasNodes,
onClose,
onToggleFork,
onUnlink,
}: SettingsAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_settings_title', { alias: alias ?? '' })}
width={720}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{alias
? (() => {
const node = aliasNodes.find((n) => n.alias === alias);
if (!node || node.sources.length === 0) {
return (
<div className={styles.settingsEmpty}>
{t('oauth_model_alias.diagram_settings_empty')}
</div>
);
}
return (
<div className={styles.settingsList}>
{node.sources.map((source) => {
const entry = source.aliases.find((item) => item.alias === alias);
const forkEnabled = entry?.fork === true;
return (
<div key={source.id} className={styles.settingsRow}>
<div className={styles.settingsNames}>
<span className={styles.settingsSource}>{source.name}</span>
<span className={styles.settingsArrow}></span>
<span className={styles.settingsAlias}>{alias}</span>
</div>
<div className={styles.settingsActions}>
<span className={styles.settingsLabel}>
{t('oauth_model_alias.alias_fork_label')}
</span>
<ToggleSwitch
checked={forkEnabled}
onChange={(value) =>
onToggleFork(source.provider, source.name, alias, value)
}
ariaLabel={t('oauth_model_alias.alias_fork_label')}
/>
<button
type="button"
className={styles.settingsDelete}
onClick={() => onUnlink(source.provider, source.name, alias)}
aria-label={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name,
})}
title={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name,
})}
>
<IconTrash2 size={14} />
</button>
</div>
</div>
);
})}
</div>
);
})()
: null}
</Modal>
);
}
interface SettingsSourceModalProps {
open: boolean;
t: TFunction;
source: SourceNode | null;
onClose: () => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
}
export function SettingsSourceModal({
open,
t,
source,
onClose,
onToggleFork,
onUnlink,
}: SettingsSourceModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_settings_source_title')}
width={720}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{source ? (
source.aliases.length === 0 ? (
<div className={styles.settingsEmpty}>
{t('oauth_model_alias.diagram_settings_empty')}
</div>
) : (
<div className={styles.settingsList}>
{source.aliases.map((entry) => (
<div key={`${source.id}-${entry.alias}`} className={styles.settingsRow}>
<div className={styles.settingsNames}>
<span className={styles.settingsSource}>{source.name}</span>
<span className={styles.settingsArrow}></span>
<span className={styles.settingsAlias}>{entry.alias}</span>
</div>
<div className={styles.settingsActions}>
<span className={styles.settingsLabel}>
{t('oauth_model_alias.alias_fork_label')}
</span>
<ToggleSwitch
checked={entry.fork === true}
onChange={(value) =>
onToggleFork(source.provider, source.name, entry.alias, value)
}
ariaLabel={t('oauth_model_alias.alias_fork_label')}
/>
<button
type="button"
className={styles.settingsDelete}
onClick={() => onUnlink(source.provider, source.name, entry.alias)}
aria-label={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name,
})}
title={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name,
})}
>
<IconTrash2 size={14} />
</button>
</div>
</div>
))}
</div>
)
) : null}
</Modal>
);
}

View file

@ -1,33 +0,0 @@
export interface AuthFileModelItem {
id: string;
display_name?: string;
type?: string;
owned_by?: string;
}
export interface SourceNode {
id: string; // unique: provider::name
provider: string;
name: string;
aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to
}
export interface AliasNode {
id: string; // alias
alias: string;
sources: SourceNode[];
}
export interface ProviderNode {
provider: string;
sources: SourceNode[];
}
export interface ContextMenuState {
x: number;
y: number;
type: 'alias' | 'background' | 'provider' | 'source';
data?: string;
}
export type DiagramLine = { path: string; color: string; id: string };

View file

@ -1,19 +0,0 @@
const normalizeModelAliasKey = (value: string): string => value.trim().toLowerCase();
export function hasModelAliasConflict(
aliases: string[],
candidate: string,
excludedAlias?: string
): boolean {
const candidateKey = normalizeModelAliasKey(candidate);
if (!candidateKey) return false;
let excluded = false;
return aliases.some((alias) => {
if (!excluded && excludedAlias !== undefined && alias === excludedAlias) {
excluded = true;
return false;
}
return normalizeModelAliasKey(alias) === candidateKey;
});
}

View file

@ -1,2 +0,0 @@
export { ModelMappingDiagram } from './ModelMappingDiagram';
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';

View file

@ -1,155 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { StatusBarData, StatusBlockDetail } from '@/utils/recentRequests';
const defaultStyles: Record<string, string> = {};
/**
* (01) RGB 线
* 0 (#ef4444) 0.5 (#facc15) 1 绿 (#22c55e)
*/
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
const from = COLOR_STOPS[segment];
const to = COLOR_STOPS[segment + 1];
const r = Math.round(from.r + (to.r - from.r) * localT);
const g = Math.round(from.g + (to.g - from.g) * localT);
const b = Math.round(from.b + (to.b - from.b) * localT);
return `rgb(${r}, ${g}, ${b})`;
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
}
function formatSuccessRate(rate: number): string {
const rounded = rate.toFixed(1);
return `${rounded.endsWith('.0') ? rounded.slice(0, -2) : rounded}%`;
}
type StylesModule = Record<string, string>;
interface ProviderStatusBarProps {
statusData: StatusBarData;
styles?: StylesModule;
}
export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderStatusBarProps) {
const { t } = useTranslation();
const s = (stylesProp || defaultStyles) as StylesModule;
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const blocksRef = useRef<HTMLDivElement>(null);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? s.statusRateHigh
: statusData.successRate >= 50
? s.statusRateMedium
: s.statusRateLow;
// 点击外部关闭 tooltip移动端
useEffect(() => {
if (activeTooltip === null) return;
const handler = (e: PointerEvent) => {
if (blocksRef.current && !blocksRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(null);
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const getTooltipPositionClass = (idx: number, total: number): string => {
if (idx <= 2) return s.statusTooltipLeft;
if (idx >= total - 3) return s.statusTooltipRight;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx, statusData.blockDetails.length);
const timeRange = `${formatTime(detail.startTime)} ${formatTime(detail.endTime)}`;
return (
<div className={`${s.statusTooltip} ${posClass}`}>
<span className={s.tooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={s.tooltipStats}>
<span className={s.tooltipSuccess}>
{t('status_bar.success_short')} {detail.success}
</span>
<span className={s.tooltipFailure}>
{t('status_bar.failure_short')} {detail.failure}
</span>
<span className={s.tooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={s.tooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
};
return (
<div className={s.statusBar}>
<div className={s.statusBlocks} ref={blocksRef}>
{statusData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
return (
<div
key={idx}
className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${s.statusBlock} ${isIdle ? s.statusBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
<span className={`${s.statusRate} ${rateClass}`}>
{hasData ? formatSuccessRate(statusData.successRate) : '--'}
</span>
</div>
);
}

View file

@ -1,191 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useInterval } from '@/hooks/useInterval';
import { apiKeyUsageApi } from '@/services/api';
import { useAuthStore } from '@/stores';
import {
normalizeRecentRequestUsageEntry,
type ApiKeyUsageResponse,
type RecentRequestUsageEntry,
} from '@/utils/recentRequests';
const PROVIDER_RECENT_REQUESTS_STALE_TIME_MS = 240_000;
export type ProviderRecentRequests = Map<string, Map<string, RecentRequestUsageEntry>>;
export type UseProviderRecentRequestsOptions = {
enabled?: boolean;
};
const EMPTY_USAGE_BY_PROVIDER: ProviderRecentRequests = new Map();
type ProviderRecentRequestsCache = {
cachedUsageByProvider: ProviderRecentRequests;
cachedAt: number;
inFlightRequest: Promise<ProviderRecentRequests> | null;
};
const createProviderRecentRequestsCache = (): ProviderRecentRequestsCache => ({
cachedUsageByProvider: EMPTY_USAGE_BY_PROVIDER,
cachedAt: 0,
inFlightRequest: null,
});
export const createProviderRecentRequestsCacheController = () => {
let currentApiBase = '';
let currentManagementKey = '';
let currentCache = createProviderRecentRequestsCache();
return {
forScope(apiBase: string, managementKey: string): ProviderRecentRequestsCache {
if (apiBase !== currentApiBase || managementKey !== currentManagementKey) {
currentApiBase = apiBase;
currentManagementKey = managementKey;
currentCache = createProviderRecentRequestsCache();
}
return currentCache;
},
};
};
const providerRecentRequestsCacheController = createProviderRecentRequestsCacheController();
const normalizeProviderKey = (value: unknown): string =>
String(value ?? '')
.trim()
.toLowerCase();
const normalizeApiKeyUsageResponse = (payload: ApiKeyUsageResponse): ProviderRecentRequests => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return EMPTY_USAGE_BY_PROVIDER;
}
const usageByProvider: ProviderRecentRequests = new Map();
Object.entries(payload).forEach(([provider, entries]) => {
const providerKey = normalizeProviderKey(provider);
if (!providerKey || !entries || typeof entries !== 'object' || Array.isArray(entries)) {
return;
}
const usageByCompositeKey = new Map<string, RecentRequestUsageEntry>();
Object.entries(entries).forEach(([compositeKey, entry]) => {
usageByCompositeKey.set(compositeKey, normalizeRecentRequestUsageEntry(entry));
});
usageByProvider.set(providerKey, usageByCompositeKey);
});
return usageByProvider;
};
const fetchProviderRecentRequests = async (
cache: ProviderRecentRequestsCache
): Promise<ProviderRecentRequests> => {
if (!cache.inFlightRequest) {
const request = apiKeyUsageApi
.getUsage()
.then((payload) => {
const normalized = normalizeApiKeyUsageResponse(payload);
cache.cachedUsageByProvider = normalized;
cache.cachedAt = Date.now();
return normalized;
})
.finally(() => {
if (cache.inFlightRequest === request) {
cache.inFlightRequest = null;
}
});
cache.inFlightRequest = request;
}
return cache.inFlightRequest;
};
export function useProviderRecentRequests(options: UseProviderRecentRequestsOptions = {}) {
const enabled = options.enabled ?? true;
const apiBase = useAuthStore((state) => state.apiBase);
const managementKey = useAuthStore((state) => state.managementKey);
const cache = useMemo(
() => providerRecentRequestsCacheController.forScope(apiBase, managementKey),
[apiBase, managementKey]
);
const [usageState, setUsageState] = useState(() => ({
cache,
value: cache.cachedUsageByProvider,
}));
const [loadingState, setLoadingState] = useState(() => ({ cache, value: false }));
const setUsageForCurrentScope = useCallback(
(value: ProviderRecentRequests) => setUsageState({ cache, value }),
[cache]
);
const setLoadingForCurrentScope = useCallback(
(value: boolean) => setLoadingState({ cache, value }),
[cache]
);
const loadRecentRequests = useCallback(
async (loadOptions: { force?: boolean } = {}) => {
if (!enabled) {
return EMPTY_USAGE_BY_PROVIDER;
}
const hasFreshCache =
cache.cachedAt > 0 &&
Date.now() - cache.cachedAt < PROVIDER_RECENT_REQUESTS_STALE_TIME_MS;
if (!loadOptions.force && hasFreshCache) {
setUsageForCurrentScope(cache.cachedUsageByProvider);
return cache.cachedUsageByProvider;
}
setLoadingForCurrentScope(true);
try {
const nextUsage = await fetchProviderRecentRequests(cache);
setUsageForCurrentScope(nextUsage);
return nextUsage;
} catch {
if (cache.cachedAt > 0) {
setUsageForCurrentScope(cache.cachedUsageByProvider);
}
return cache.cachedUsageByProvider;
} finally {
setLoadingForCurrentScope(false);
}
},
[cache, enabled, setLoadingForCurrentScope, setUsageForCurrentScope]
);
const refreshRecentRequests = useCallback(
async () => loadRecentRequests({ force: true }),
[loadRecentRequests]
);
useEffect(() => {
if (!enabled) {
setUsageForCurrentScope(EMPTY_USAGE_BY_PROVIDER);
return;
}
void loadRecentRequests().catch(() => {});
}, [enabled, loadRecentRequests, setUsageForCurrentScope]);
useInterval(
() => {
void refreshRecentRequests().catch(() => {});
},
enabled ? PROVIDER_RECENT_REQUESTS_STALE_TIME_MS : null
);
const usageByProvider =
usageState.cache === cache ? usageState.value : cache.cachedUsageByProvider;
const isLoading =
loadingState.cache === cache ? loadingState.value : cache.inFlightRequest !== null;
return {
usageByProvider: enabled ? usageByProvider : EMPTY_USAGE_BY_PROVIDER,
isLoading: enabled ? isLoading : false,
loadRecentRequests,
refreshRecentRequests,
};
}

View file

@ -1,258 +0,0 @@
import type { OpenAIProviderConfig } from '@/types';
import {
buildRecentRequestCompositeKey,
mergeRecentRequestBucketGroups,
statusBarDataFromRecentRequests,
sumRecentRequests,
type RecentRequestBucket,
type RecentRequestUsageEntry,
type StatusBarData,
} from '@/utils/recentRequests';
const DISABLE_ALL_MODELS_RULE = '*';
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
export const hasDisableAllModelsRule = (models?: string[]) =>
Array.isArray(models) &&
models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE);
export const stripDisableAllModelsRule = (models?: string[]) =>
Array.isArray(models)
? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE)
: [];
export const withDisableAllModelsRule = (models?: string[]) => {
const base = stripDisableAllModelsRule(models);
return [...base, DISABLE_ALL_MODELS_RULE];
};
export const withoutDisableAllModelsRule = (models?: string[]) => stripDisableAllModelsRule(models);
const normalizeUpstreamBaseUrl = (baseUrl: string, fallback = ''): string => {
let trimmed = String(baseUrl || '').trim();
if (!trimmed) return fallback;
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
trimmed = trimmed.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `http://${trimmed}`;
}
return trimmed;
};
const buildGeminiModelResource = (model: string): string => {
const trimmed = String(model || '')
.trim()
.replace(/^\/+/g, '')
.replace(/:generateContent$/i, '');
if (!trimmed) return '';
if (/^(models|tunedModels)\//i.test(trimmed)) {
return trimmed.split('/').map(encodeURIComponent).join('/');
}
return `models/${encodeURIComponent(trimmed)}`;
};
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeUpstreamBaseUrl(baseUrl);
if (!trimmed) return '';
if (trimmed.endsWith('/chat/completions')) {
return trimmed;
}
return `${trimmed}/chat/completions`;
};
export const buildCodexResponsesEndpoint = (baseUrl: string): string => {
const trimmed = normalizeUpstreamBaseUrl(baseUrl);
if (!trimmed) return '';
if (/\/v1\/responses$/i.test(trimmed)) {
return trimmed;
}
if (/\/v1\/models$/i.test(trimmed)) {
return trimmed.replace(/\/models$/i, '/responses');
}
if (/\/v1$/i.test(trimmed)) {
return `${trimmed}/responses`;
}
return `${trimmed}/v1/responses`;
};
export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
const trimmed = normalizeUpstreamBaseUrl(baseUrl, 'https://api.anthropic.com');
if (!trimmed) return '';
if (trimmed.endsWith('/v1/messages')) {
return trimmed;
}
if (trimmed.endsWith('/v1')) {
return `${trimmed}/messages`;
}
return `${trimmed}/v1/messages`;
};
export const INTERACTIONS_API_REVISION = '2026-05-20';
export const buildInteractionsProbePayload = (model: string) => ({
model,
input: 'Hi',
});
export const buildInteractionsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeUpstreamBaseUrl(baseUrl, DEFAULT_GEMINI_BASE_URL);
if (!trimmed) return '';
if (/\/v1beta\/interactions$/i.test(trimmed)) {
return trimmed;
}
let root = trimmed.replace(/\/+$/g, '');
root = root.replace(/\/v1beta\/models$/i, '');
if (/\/v1beta$/i.test(root)) {
return `${root}/interactions`;
}
root = root.replace(/\/v1beta(?:\/.*)?$/i, '');
return `${root}/v1beta/interactions`;
};
export const buildGeminiGenerateContentEndpoint = (baseUrl: string, model: string): string => {
const resource = buildGeminiModelResource(model);
if (!resource) return '';
const trimmed = normalizeUpstreamBaseUrl(baseUrl, DEFAULT_GEMINI_BASE_URL);
if (!trimmed) return '';
if (/:generateContent$/i.test(trimmed)) {
return trimmed;
}
let root = trimmed.replace(/\/+$/g, '');
if (/\/v1beta\/models$/i.test(root)) {
root = root.replace(/\/models$/i, '');
} else if (!/\/v1beta$/i.test(root)) {
root = root.replace(/\/v1beta(?:\/.*)?$/i, '');
root = `${root}/v1beta`;
}
return `${root}/${resource}:generateContent`;
};
export const getProviderUsageKey = (provider: string): string => {
if (provider === 'claudeApi') return 'claude';
if (provider === 'interactions') return 'gemini-interactions';
return provider;
};
export type ProviderRecentUsageMap = Map<string, Map<string, RecentRequestUsageEntry>>;
const EMPTY_RECENT_USAGE_ENTRY: RecentRequestUsageEntry = {
success: 0,
failed: 0,
recentRequests: [],
};
const normalizeProviderRecentKey = (value: unknown): string =>
String(value ?? '')
.trim()
.toLowerCase();
const getProviderRecentUsageEntry = (
usageByProvider: ProviderRecentUsageMap,
provider: string,
apiKey?: string,
baseUrl?: string
): RecentRequestUsageEntry => {
if (!String(apiKey ?? '').trim()) {
return EMPTY_RECENT_USAGE_ENTRY;
}
const providerKey = normalizeProviderRecentKey(provider);
const compositeKey = buildRecentRequestCompositeKey(baseUrl, apiKey);
return usageByProvider.get(providerKey)?.get(compositeKey) ?? EMPTY_RECENT_USAGE_ENTRY;
};
const getProviderRecentBuckets = (
usageByProvider: ProviderRecentUsageMap,
provider: string,
apiKey?: string,
baseUrl?: string
): RecentRequestBucket[] =>
getProviderRecentUsageEntry(usageByProvider, provider, apiKey, baseUrl).recentRequests;
export function getProviderRecentStatusData(
usageByProvider: ProviderRecentUsageMap,
provider: string,
apiKey?: string,
baseUrl?: string
): StatusBarData {
return statusBarDataFromRecentRequests(
getProviderRecentBuckets(usageByProvider, provider, apiKey, baseUrl)
);
}
export function getProviderTotalStats(
usageByProvider: ProviderRecentUsageMap,
provider: string,
apiKey?: string,
baseUrl?: string
): { success: number; failure: number } {
const entry = getProviderRecentUsageEntry(usageByProvider, provider, apiKey, baseUrl);
return { success: entry.success, failure: entry.failed };
}
export function getProviderRecentWindowStats(
usageByProvider: ProviderRecentUsageMap,
provider: string,
apiKey?: string,
baseUrl?: string
): { success: number; failure: number } {
return sumRecentRequests(getProviderRecentBuckets(usageByProvider, provider, apiKey, baseUrl));
}
const collectOpenAIProviderRecentBuckets = (
provider: OpenAIProviderConfig,
usageByProvider: ProviderRecentUsageMap
): RecentRequestBucket[] => {
if (!provider.apiKeyEntries?.length) {
return [];
}
const groups = provider.apiKeyEntries.map((entry) =>
getProviderRecentBuckets(usageByProvider, provider.name, entry.apiKey, provider.baseUrl)
);
return mergeRecentRequestBucketGroups(groups);
};
export function getOpenAIProviderRecentWindowStats(
provider: OpenAIProviderConfig,
usageByProvider: ProviderRecentUsageMap
): { success: number; failure: number } {
return sumRecentRequests(collectOpenAIProviderRecentBuckets(provider, usageByProvider));
}
export function getOpenAIProviderTotalStats(
provider: OpenAIProviderConfig,
usageByProvider: ProviderRecentUsageMap
): { success: number; failure: number } {
return (provider.apiKeyEntries || []).reduce(
(total, entry) => {
const usageEntry = getProviderRecentUsageEntry(
usageByProvider,
provider.name,
entry.apiKey,
provider.baseUrl
);
return {
success: total.success + usageEntry.success,
failure: total.failure + usageEntry.failed,
};
},
{ success: 0, failure: 0 }
);
}
export function getOpenAIProviderRecentStatusData(
provider: OpenAIProviderConfig,
usageByProvider: ProviderRecentUsageMap
): StatusBarData {
return statusBarDataFromRecentRequests(
collectOpenAIProviderRecentBuckets(provider, usageByProvider)
);
}

View file

@ -1,190 +0,0 @@
import {
useEffect,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
} from 'react';
import { IconChevronDown } from './icons';
interface AutocompleteInputProps {
label?: string;
value: string;
onChange: (value: string) => void;
options: string[] | { value: string; label?: string }[];
placeholder?: string;
disabled?: boolean;
hint?: string;
error?: string;
className?: string;
wrapperClassName?: string;
wrapperStyle?: React.CSSProperties;
id?: string;
rightElement?: ReactNode;
}
export function AutocompleteInput({
label,
value,
onChange,
options,
placeholder,
disabled,
hint,
error,
className = '',
wrapperClassName = '',
wrapperStyle,
id,
rightElement,
}: AutocompleteInputProps) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const normalizedOptions = options.map((opt) =>
typeof opt === 'string'
? { value: opt, label: opt }
: { value: opt.value, label: opt.label || opt.value }
);
const filteredOptions = normalizedOptions.filter((opt) => {
const v = value.toLowerCase();
return (
opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v))
);
});
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
};
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setIsOpen(false);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
return;
}
setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
} else if (e.key === 'Enter') {
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
e.preventDefault();
handleSelect(filteredOptions[highlightedIndex].value);
} else if (isOpen) {
e.preventDefault();
setIsOpen(false);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
} else if (e.key === 'Tab') {
setIsOpen(false);
}
};
return (
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
{label && <label htmlFor={id}>{label}</label>}
<div style={{ position: 'relative' }}>
<input
id={id}
className={`input ${className}`.trim()}
value={value}
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
style={{ paddingRight: 32 }}
/>
<div
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
pointerEvents: disabled ? 'none' : 'auto',
cursor: 'pointer',
height: '100%',
}}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
{rightElement}
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
</div>
{isOpen && filteredOptions.length > 0 && !disabled && (
<div
className="autocomplete-dropdown"
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-md)',
maxHeight: 200,
overflowY: 'auto',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
}}
>
{filteredOptions.map((opt, index) => (
<div
key={`${opt.value}-${index}`}
onClick={() => handleSelect(opt.value)}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor:
index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
color: 'var(--text-primary)',
display: 'flex',
flexDirection: 'column',
fontSize: '0.9rem',
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<span style={{ fontWeight: 500 }}>{opt.value}</span>
{opt.label && opt.label !== opt.value && (
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>
{opt.label}
</span>
)}
</div>
))}
</div>
)}
</div>
{hint && <div className="hint">{hint}</div>}
{error && <div className="error-box">{error}</div>}
</div>
);
}

View file

@ -1,40 +0,0 @@
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'md' | 'sm';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
loading?: boolean;
}
export function Button({
children,
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
className = '',
disabled,
...rest
}: PropsWithChildren<ButtonProps>) {
const hasChildren = children !== null && children !== undefined && children !== false;
const classes = [
'btn',
`btn-${variant}`,
size === 'sm' ? 'btn-sm' : '',
fullWidth ? 'btn-full' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<button className={classes} disabled={disabled || loading} {...rest}>
{loading && <span className="loading-spinner" aria-hidden="true" />}
{hasChildren && <span>{children}</span>}
</button>
);
}

View file

@ -1,21 +0,0 @@
import type { PropsWithChildren, ReactNode } from 'react';
interface CardProps {
title?: ReactNode;
extra?: ReactNode;
className?: string;
}
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
return (
<div className={className ? `card ${className}` : 'card'}>
{(title || extra) && (
<div className="card-header">
<div className="title">{title}</div>
{extra}
</div>
)}
{children}
</div>
);
}

View file

@ -1,96 +0,0 @@
@use '../../../styles/mixins' as *;
@use '../../../styles/variables' as *;
.root {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
overflow: hidden;
}
.summary {
list-style: none;
cursor: pointer;
padding: 10px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
user-select: none;
&::-webkit-details-marker {
display: none;
}
&:hover {
background: color-mix(in srgb, var(--accent-bg) 35%, transparent);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
}
.summaryLabel {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.summaryHint {
font-size: 12px;
color: var(--muted-foreground);
font-weight: 400;
}
.chevron {
width: 16px;
height: 16px;
color: var(--muted-foreground);
transition: transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
flex-shrink: 0;
.root[open] & {
transform: rotate(180deg);
}
}
.content {
padding: 14px;
border-top: 1px solid var(--border-color);
}
.contentFlush {
padding: 0;
border-top: 1px solid var(--border-color);
}
/* 展开时内容 160ms 淡入只做透明度不做高度动画
与程序化 details.open = true搜索跳转强制展开不会互相打架 */
.root[open] > .content,
.root[open] > .contentFlush {
animation: collapsible-content-in var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
}
@keyframes collapsible-content-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.chevron {
transition: none;
}
.root[open] > .content,
.root[open] > .contentFlush {
animation: none;
}
}

View file

@ -1,54 +0,0 @@
import { useState, type HTMLAttributes, type PropsWithChildren, type ReactNode } from 'react';
import { IconChevronDown } from '../icons';
import styles from './Collapsible.module.scss';
interface CollapsibleProps extends HTMLAttributes<HTMLDetailsElement> {
label: ReactNode;
hint?: ReactNode;
defaultOpen?: boolean;
open?: boolean;
onToggle?: (event: React.SyntheticEvent<HTMLDetailsElement>) => void;
flush?: boolean;
}
export function Collapsible({
label,
hint,
defaultOpen = false,
open,
onToggle,
flush,
children,
className,
...rest
}: PropsWithChildren<CollapsibleProps>) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const resolvedOpen = open ?? uncontrolledOpen;
const cls = [styles.root, className].filter(Boolean).join(' ');
const contentCls = flush ? styles.contentFlush : styles.content;
return (
<details
className={cls}
open={resolvedOpen}
onToggle={(event) => {
if (open === undefined) {
setUncontrolledOpen(event.currentTarget.open);
}
onToggle?.(event);
}}
{...rest}
>
<summary className={styles.summary}>
<span className={styles.summaryLabel}>
<span>{label}</span>
{hint ? <span className={styles.summaryHint}>{hint}</span> : null}
</span>
<span className={styles.chevron} aria-hidden="true">
<IconChevronDown size={16} />
</span>
</summary>
<div className={contentCls}>{children}</div>
</details>
);
}

View file

@ -1 +0,0 @@
export { Collapsible } from './Collapsible';

View file

@ -1,25 +0,0 @@
import type { ReactNode } from 'react';
import { IconInbox } from './icons';
interface EmptyStateProps {
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="empty-state">
<div className="empty-content">
<div className="empty-icon" aria-hidden="true">
<IconInbox size={20} />
</div>
<div>
<div className="empty-title">{title}</div>
{description && <div className="empty-desc">{description}</div>}
</div>
</div>
{action && <div className="empty-action">{action}</div>}
</div>
);
}

View file

@ -1,65 +0,0 @@
import { useId, type InputHTMLAttributes, type ReactNode } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
/** 渲染在标签正下方的小字行(如赞助跳转链接)。 */
labelExtra?: ReactNode;
/** 渲染在标签上方的占位行(用于与同排带 labelExtra 的字段保持输入框对齐)。 */
topExtra?: ReactNode;
hint?: string;
error?: string;
rightElement?: ReactNode;
}
export function Input({
label,
labelExtra,
topExtra,
hint,
error,
rightElement,
className = '',
id,
...rest
}: InputProps) {
const generatedId = useId();
const inputId = id ?? generatedId;
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy =
[rest['aria-describedby'], errorId, hintId].filter(Boolean).join(' ') || undefined;
return (
<div className="form-group">
{topExtra}
{label && <label htmlFor={inputId}>{label}</label>}
{labelExtra}
<div style={{ position: 'relative' }}>
<input
id={inputId}
className={`input ${className}`.trim()}
aria-invalid={Boolean(error) || rest['aria-invalid']}
aria-describedby={describedBy}
{...rest}
/>
{rightElement && (
<div
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}
>
{rightElement}
</div>
)}
</div>
{hint && (
<div id={hintId} className="hint">
{hint}
</div>
)}
{error && (
<div id={errorId} className="error-box">
{error}
</div>
)}
</div>
);
}

View file

@ -1,16 +0,0 @@
export function LoadingSpinner({
size = 20,
className = '',
}: {
size?: number;
className?: string;
}) {
return (
<div
className={`loading-spinner${className ? ` ${className}` : ''}`}
style={{ width: size, height: size, borderWidth: size / 7 }}
role="status"
aria-live="polite"
/>
);
}

View file

@ -1,220 +0,0 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type PropsWithChildren,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { IconX } from './icons';
import { FOCUSABLE_SELECTOR, lockScroll, unlockScroll } from './scrollLock';
interface ModalProps {
open: boolean;
title?: ReactNode;
onClose: () => void;
footer?: ReactNode;
width?: number | string;
className?: string;
closeDisabled?: boolean;
}
const CLOSE_ANIMATION_DURATION = 350;
export function Modal({
open,
title,
onClose,
footer,
width = 520,
className,
closeDisabled = false,
children,
}: PropsWithChildren<ModalProps>) {
const { t } = useTranslation();
const titleId = useId();
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const modalRef = useRef<HTMLDivElement | null>(null);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const getFocusableElements = useCallback(() => {
if (!modalRef.current) return [] as HTMLElement[];
return Array.from(modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(element) => !element.hasAttribute('disabled') && element.tabIndex !== -1
);
}, []);
const startClose = useCallback(
(notifyParent: boolean) => {
if (closeTimerRef.current !== null) return;
setIsClosing(true);
closeTimerRef.current = window.setTimeout(() => {
setIsVisible(false);
setIsClosing(false);
closeTimerRef.current = null;
if (notifyParent) {
onClose();
}
}, CLOSE_ANIMATION_DURATION);
},
[onClose]
);
useEffect(() => {
let cancelled = false;
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
queueMicrotask(() => {
if (cancelled) return;
setIsVisible(true);
setIsClosing(false);
});
} else if (isVisible) {
queueMicrotask(() => {
if (cancelled) return;
startClose(false);
});
}
return () => {
cancelled = true;
};
}, [open, isVisible, startClose]);
const handleClose = useCallback(() => {
startClose(true);
}, [startClose]);
useEffect(() => {
return () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
}
};
}, []);
const shouldLockScroll = open || isVisible;
useEffect(() => {
if (!shouldLockScroll) return;
lockScroll();
return () => unlockScroll();
}, [shouldLockScroll]);
useEffect(() => {
if (!open) return;
previouslyFocusedRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusTimer = window.setTimeout(() => {
const firstFocusable = getFocusableElements()[0];
(firstFocusable ?? closeButtonRef.current ?? modalRef.current)?.focus();
}, 0);
return () => {
window.clearTimeout(focusTimer);
};
}, [getFocusableElements, open]);
useEffect(() => {
if (open || isVisible) return;
previouslyFocusedRef.current?.focus();
previouslyFocusedRef.current = null;
}, [isVisible, open]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (closeDisabled) return;
event.preventDefault();
handleClose();
return;
}
if (event.key !== 'Tab') return;
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) {
event.preventDefault();
modalRef.current?.focus();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement as HTMLElement | null;
if (event.shiftKey) {
if (activeElement === firstElement || activeElement === modalRef.current) {
event.preventDefault();
lastElement.focus();
}
return;
}
if (activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [closeDisabled, getFocusableElements, handleClose, open]);
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
const modalContent = (
<div className={overlayClass}>
<div
ref={modalRef}
className={modalClass}
style={{ width, maxWidth: '100%' }}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
>
<button
ref={closeButtonRef}
type="button"
className="modal-close-floating"
onClick={closeDisabled ? undefined : handleClose}
aria-label={t('common.close')}
disabled={closeDisabled}
>
<IconX size={20} />
</button>
<div className="modal-header">
<div className="modal-title" id={title ? titleId : undefined}>
{title}
</div>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
);
if (typeof document === 'undefined') {
return modalContent;
}
return createPortal(modalContent, document.body);
}

View file

@ -1,124 +0,0 @@
@use '../../styles/mixins' as *;
.wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.wrapFullWidth {
width: 100%;
}
.trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
box-sizing: border-box;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow:
var(--shadow),
0 0 0 3px rgba($primary-color, 0.18);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow:
var(--shadow),
0 0 0 3px rgba($primary-color, 0.18);
}
}
.triggerSm {
height: 28px;
padding: 0 10px;
font-size: 12px;
box-shadow: none;
}
.triggerText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text-tertiary);
}
.triggerIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.dropdown {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.option {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
flex-shrink: 0;
&:hover {
background: var(--bg-secondary);
}
}
.optionActive {
border-color: rgba($primary-color, 0.5);
background: rgba($primary-color, 0.1);
font-weight: 600;
}
.optionHighlighted {
background: var(--bg-secondary);
}

View file

@ -1,342 +0,0 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from 'react';
import { createPortal } from 'react-dom';
import { IconChevronDown } from './icons';
import styles from './Select.module.scss';
export interface SelectOption {
value: string;
label: string;
}
interface SelectProps {
value: string;
options: ReadonlyArray<SelectOption>;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
fullWidth?: boolean;
size?: 'sm' | 'md';
id?: string;
}
const VIEWPORT_MARGIN = 8;
const DROPDOWN_OFFSET = 6;
const DROPDOWN_MAX_HEIGHT = 240;
const DROPDOWN_Z_INDEX = 2010;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const resolveDropdownStyle = (element: HTMLElement): CSSProperties => {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2));
const left = clamp(
rect.left,
VIEWPORT_MARGIN,
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN)
);
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
const direction = spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up';
const maxHeight = Math.max(
0,
Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove)
);
return direction === 'down'
? {
position: 'fixed',
top: rect.bottom + DROPDOWN_OFFSET,
left,
width,
maxHeight,
zIndex: DROPDOWN_Z_INDEX,
}
: {
position: 'fixed',
bottom: viewportHeight - rect.top + DROPDOWN_OFFSET,
left,
width,
maxHeight,
zIndex: DROPDOWN_Z_INDEX,
};
};
export function Select({
value,
options,
onChange,
placeholder,
className,
disabled = false,
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
fullWidth = true,
size = 'md',
id,
}: SelectProps) {
const generatedId = useId();
const selectId = id ?? generatedId;
const listboxId = `${selectId}-listbox`;
const [open, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const wrapRef = useRef<HTMLDivElement | null>(null);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
const isOpen = open && !disabled;
useEffect(() => {
if (!open || disabled) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [disabled, open]);
const updateDropdownStyle = useCallback(() => {
if (!wrapRef.current) return;
setDropdownStyle(resolveDropdownStyle(wrapRef.current));
}, []);
const scheduleDropdownStyleUpdate = useCallback(() => {
if (typeof window === 'undefined') return;
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
}
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
updateDropdownStyle();
});
}, [updateDropdownStyle]);
useLayoutEffect(() => {
if (!isOpen) {
if (rafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
updateDropdownStyle();
const handleViewportChange = () => {
scheduleDropdownStyleUpdate();
};
const resizeObserver =
typeof ResizeObserver !== 'undefined' && wrapRef.current
? new ResizeObserver(() => {
scheduleDropdownStyleUpdate();
})
: null;
if (resizeObserver && wrapRef.current) {
resizeObserver.observe(wrapRef.current);
}
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
resizeObserver?.disconnect();
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]);
const selectedIndex = useMemo(
() => options.findIndex((option) => option.value === value),
[options, value]
);
const resolvedHighlightedIndex =
highlightedIndex >= 0
? highlightedIndex
: selectedIndex >= 0
? selectedIndex
: options.length > 0
? 0
: -1;
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
const displayText = selected?.label ?? placeholder ?? '';
const isPlaceholder = !selected && placeholder;
const commitSelection = useCallback(
(nextIndex: number) => {
const nextOption = options[nextIndex];
if (!nextOption) return;
onChange(nextOption.value);
setOpen(false);
setHighlightedIndex(nextIndex);
},
[onChange, options]
);
const moveHighlight = useCallback(
(direction: 1 | -1) => {
if (options.length === 0) return;
const nextIndex = (resolvedHighlightedIndex + direction + options.length) % options.length;
setHighlightedIndex(nextIndex);
},
[options.length, resolvedHighlightedIndex]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (!isOpen) {
setOpen(true);
return;
}
moveHighlight(1);
return;
case 'ArrowUp':
event.preventDefault();
if (!isOpen) {
setOpen(true);
return;
}
moveHighlight(-1);
return;
case 'Home':
if (!isOpen || options.length === 0) return;
event.preventDefault();
setHighlightedIndex(0);
return;
case 'End':
if (!isOpen || options.length === 0) return;
event.preventDefault();
setHighlightedIndex(options.length - 1);
return;
case 'Enter':
case ' ': {
event.preventDefault();
if (!isOpen) {
setOpen(true);
return;
}
if (resolvedHighlightedIndex >= 0) {
commitSelection(resolvedHighlightedIndex);
}
return;
}
case 'Escape':
if (!isOpen) return;
event.preventDefault();
setOpen(false);
return;
case 'Tab':
if (isOpen) setOpen(false);
return;
default:
return;
}
},
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
);
useEffect(() => {
if (!isOpen || resolvedHighlightedIndex < 0) return;
const highlightedOption = document.getElementById(
`${selectId}-option-${resolvedHighlightedIndex}`
);
highlightedOption?.scrollIntoView({ block: 'nearest' });
}, [isOpen, resolvedHighlightedIndex, selectId]);
const dropdown =
isOpen && dropdownStyle ? (
<div
ref={dropdownRef}
className={styles.dropdown}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
style={dropdownStyle}
>
{options.map((opt, index) => {
const active = opt.value === value;
const highlighted = index === resolvedHighlightedIndex;
return (
<button
key={opt.value}
id={`${selectId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onKeyDown={handleKeyDown}
onClick={() => commitSelection(index)}
>
{opt.label}
</button>
);
})}
</div>
) : null;
return (
<>
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
id={selectId}
type="button"
className={`${styles.trigger} ${size === 'sm' ? styles.triggerSm : ''}`.trim()}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={
isOpen && resolvedHighlightedIndex >= 0
? `${selectId}-option-${resolvedHighlightedIndex}`
: undefined
}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
</div>
{dropdown &&
(typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))}
</>
);
}

View file

@ -1,87 +0,0 @@
@use '../../styles/variables' as *;
.root {
position: relative;
display: inline-flex;
align-items: center;
gap: $spacing-sm;
cursor: pointer;
user-select: none;
}
.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.box {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 7px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
color: var(--primary-contrast, #fff);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
border-color $transition-fast,
background-color $transition-fast,
box-shadow $transition-fast,
transform $transition-fast;
}
.root:hover .box {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
}
.root:active .box {
transform: scale(0.95);
}
.disabled:hover .box {
border-color: var(--border-color);
box-shadow: none;
}
.disabled:active .box {
transform: none;
}
.input:focus-visible + .box {
border-color: var(--primary-color);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent),
0 0 0 1px color-mix(in srgb, var(--primary-color) 50%, transparent);
}
.boxChecked {
border-color: var(--primary-color);
background: var(--primary-color);
}
.boxChecked svg {
display: block;
stroke-width: 2.4;
}
.label {
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
}

View file

@ -1,50 +0,0 @@
import type { ChangeEvent, ReactNode } from 'react';
import { IconCheck } from './icons';
import styles from './SelectionCheckbox.module.scss';
interface SelectionCheckboxProps {
checked: boolean;
onChange: (value: boolean) => void;
label?: ReactNode;
ariaLabel?: string;
title?: string;
disabled?: boolean;
className?: string;
labelClassName?: string;
}
export function SelectionCheckbox({
checked,
onChange,
label,
ariaLabel,
title,
disabled = false,
className,
labelClassName,
}: SelectionCheckboxProps) {
const rootClassName = [styles.root, disabled ? styles.disabled : '', className]
.filter(Boolean)
.join(' ');
const boxClassName = [styles.box, checked ? styles.boxChecked : ''].filter(Boolean).join(' ');
const textClassName = [styles.label, labelClassName].filter(Boolean).join(' ');
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.checked);
};
return (
<label className={rootClassName} title={title}>
<input
className={styles.input}
type="checkbox"
checked={checked}
onChange={handleChange}
aria-label={ariaLabel}
disabled={disabled}
/>
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
{label ? <div className={textClassName}>{label}</div> : null}
</label>
);
}

View file

@ -1,150 +0,0 @@
@use '../../../styles/mixins' as *;
@use '../../../styles/variables' as *;
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 200ms ease;
z-index: 2000;
display: flex;
justify-content: flex-end;
&.entering {
opacity: 1;
}
&.exiting {
opacity: 0;
}
}
.content {
position: relative;
background: var(--bg-primary);
border-left: 1px solid var(--border-color);
box-shadow: var(--floating-shadow);
transform: translateX(100%);
transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1);
display: flex;
flex-direction: column;
height: 100%;
outline: none;
&.entering {
transform: translateX(0);
}
&.exiting {
transform: translateX(100%);
}
}
.sizeMd {
width: min(640px, 100vw);
}
.sizeLg {
width: min(720px, 100vw);
}
.sizeXl {
width: min(960px, 100vw);
}
.header {
flex-shrink: 0;
padding: 20px 56px 20px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 4px;
}
.eyebrow {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-foreground);
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.description {
font-size: 13px;
color: var(--muted-foreground);
line-height: 1.5;
margin: 0;
}
.body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.footer {
flex-shrink: 0;
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
gap: 12px;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
}
.closeBtn {
position: absolute;
top: 14px;
right: 14px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius-md);
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: 2px;
}
}
@media (max-width: 640px) {
.content {
width: 100vw !important;
border-left: none;
}
.header {
padding: 16px 52px 16px 16px;
}
.body {
padding: 16px;
}
.footer {
padding: 12px 16px;
}
}

View file

@ -1,261 +0,0 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
type PropsWithChildren,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { IconX } from '../icons';
import { FOCUSABLE_SELECTOR, lockScroll, unlockScroll } from '../scrollLock';
import styles from './Sheet.module.scss';
export type SheetSize = 'md' | 'lg' | 'xl';
interface SheetProps {
open: boolean;
onClose: () => void;
size?: SheetSize;
eyebrow?: ReactNode;
title?: ReactNode;
description?: ReactNode;
footer?: ReactNode;
closeDisabled?: boolean;
className?: string;
ariaLabel?: string;
/**
* If provided, called before starting the close animation when the user
* triggers a close (Escape, overlay click, or close button). Return false
* (or a Promise that resolves to false) to keep the sheet open.
*/
confirmClose?: () => boolean | Promise<boolean>;
}
const CLOSE_ANIMATION_DURATION = 280;
const SIZE_CLASS: Record<SheetSize, string> = {
md: styles.sizeMd,
lg: styles.sizeLg,
xl: styles.sizeXl,
};
export function Sheet({
open,
onClose,
size = 'md',
eyebrow,
title,
description,
footer,
closeDisabled = false,
className,
ariaLabel,
confirmClose,
children,
}: PropsWithChildren<SheetProps>) {
const { t } = useTranslation();
const titleId = useId();
const descId = useId();
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sheetRef = useRef<HTMLDivElement | null>(null);
const bodyRef = useRef<HTMLDivElement | null>(null);
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const getFocusableElements = useCallback(() => {
if (!sheetRef.current) return [] as HTMLElement[];
return Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute('disabled') && el.tabIndex !== -1
);
}, []);
const startClose = useCallback(
(notifyParent: boolean) => {
if (closeTimerRef.current !== null) return;
setIsClosing(true);
closeTimerRef.current = window.setTimeout(() => {
setIsVisible(false);
setIsClosing(false);
closeTimerRef.current = null;
if (notifyParent) {
onClose();
}
}, CLOSE_ANIMATION_DURATION);
},
[onClose]
);
useEffect(() => {
let cancelled = false;
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
queueMicrotask(() => {
if (cancelled) return;
setIsVisible(true);
setIsClosing(false);
});
} else if (isVisible) {
queueMicrotask(() => {
if (cancelled) return;
startClose(false);
});
}
return () => {
cancelled = true;
};
}, [open, isVisible, startClose]);
const handleClose = useCallback(async () => {
if (confirmClose) {
try {
const ok = await confirmClose();
if (ok === false) return;
} catch {
return;
}
}
startClose(true);
}, [confirmClose, startClose]);
useEffect(() => {
return () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
}
};
}, []);
const shouldLockScroll = open || isVisible;
useEffect(() => {
if (!shouldLockScroll) return;
lockScroll();
return () => unlockScroll();
}, [shouldLockScroll]);
useEffect(() => {
if (!open) return;
previouslyFocusedRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
const t = window.setTimeout(() => {
if (bodyRef.current) bodyRef.current.scrollTop = 0;
const first = getFocusableElements()[0];
(first ?? closeBtnRef.current ?? sheetRef.current)?.focus({ preventScroll: true });
}, 0);
return () => window.clearTimeout(t);
}, [getFocusableElements, open]);
useEffect(() => {
if (open || isVisible) return;
previouslyFocusedRef.current?.focus();
previouslyFocusedRef.current = null;
}, [isVisible, open]);
useEffect(() => {
if (!open) return;
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (closeDisabled) return;
event.preventDefault();
handleClose();
return;
}
if (event.key !== 'Tab') return;
const focusables = getFocusableElements();
if (focusables.length === 0) {
event.preventDefault();
sheetRef.current?.focus();
return;
}
const firstEl = focusables[0];
const lastEl = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (event.shiftKey) {
if (active === firstEl || active === sheetRef.current) {
event.preventDefault();
lastEl.focus();
}
return;
}
if (active === lastEl) {
event.preventDefault();
firstEl.focus();
}
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [closeDisabled, getFocusableElements, handleClose, open]);
if (!open && !isVisible) return null;
const stateClass = isClosing ? styles.exiting : styles.entering;
const overlayCls = `${styles.overlay} ${stateClass}`.trim();
const contentCls = [styles.content, SIZE_CLASS[size], stateClass, className]
.filter(Boolean)
.join(' ');
const content = (
<div
className={overlayCls}
role="presentation"
onMouseDown={(e) => {
if (closeDisabled) return;
if (e.target === e.currentTarget) handleClose();
}}
>
<div
ref={sheetRef}
className={contentCls}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descId : undefined}
aria-label={!title && ariaLabel ? ariaLabel : undefined}
tabIndex={-1}
onMouseDown={(e) => e.stopPropagation()}
>
<button
ref={closeBtnRef}
type="button"
className={styles.closeBtn}
onClick={closeDisabled ? undefined : handleClose}
disabled={closeDisabled}
aria-label={t('common.close')}
>
<IconX size={18} />
</button>
{(eyebrow || title || description) && (
<div className={styles.header}>
{eyebrow ? <div className={styles.eyebrow}>{eyebrow}</div> : null}
{title ? (
<h2 id={titleId} className={styles.title}>
{title}
</h2>
) : null}
{description ? (
<p id={descId} className={styles.description}>
{description}
</p>
) : null}
</div>
)}
<div ref={bodyRef} className={styles.body}>
{children}
</div>
{footer ? <div className={styles.footer}>{footer}</div> : null}
</div>
</div>
);
if (typeof document === 'undefined') return content;
return createPortal(content, document.body);
}

View file

@ -1,2 +0,0 @@
export { Sheet } from './Sheet';
export type { SheetSize } from './Sheet';

View file

@ -1,31 +0,0 @@
@use '../../../styles/mixins' as *;
@use '../../../styles/variables' as *;
.skeleton {
background: linear-gradient(
90deg,
var(--bg-tertiary) 0%,
var(--bg-hover) 50%,
var(--bg-tertiary) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-md);
display: block;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: var(--bg-tertiary);
}
}

View file

@ -1,19 +0,0 @@
import type { CSSProperties, HTMLAttributes } from 'react';
import styles from './Skeleton.module.scss';
interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
width?: number | string;
height?: number | string;
rounded?: number | string;
}
export function Skeleton({ width, height, rounded, className, style, ...rest }: SkeletonProps) {
const merged: CSSProperties = {
...style,
width: width ?? style?.width,
height: height ?? style?.height,
borderRadius: rounded ?? style?.borderRadius,
};
const cls = [styles.skeleton, className].filter(Boolean).join(' ');
return <div className={cls} style={merged} aria-hidden="true" {...rest} />;
}

View file

@ -1 +0,0 @@
export { Skeleton } from './Skeleton';

View file

@ -1,70 +0,0 @@
@use '../../../styles/mixins' as *;
@use '../../../styles/variables' as *;
.wrap {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-primary);
}
.scroll {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
color: var(--text-primary);
}
.head {
background: var(--muted-bg);
th {
color: var(--muted-foreground);
font-weight: 500;
font-size: 12px;
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
}
th.alignRight {
text-align: right;
}
}
.body {
background: var(--bg-primary);
}
.row {
transition: background-color $transition-fast;
td {
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
vertical-align: top;
line-height: 1.5;
}
&:hover td {
background: color-mix(in srgb, var(--accent-bg) 50%, transparent);
}
&.selected td {
background: var(--primary-8);
}
}
.body .row:last-child td {
border-bottom: none;
}
.alignRight {
text-align: right;
}

Some files were not shown because too many files have changed in this diff Show more