Big update
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ backend/internal/managementasset/dist/
|
|||
backend/.dev/
|
||||
result
|
||||
result-*
|
||||
.direnv
|
||||
|
|
|
|||
53
README.md
|
|
@ -1 +1,54 @@
|
|||
# Vibe Proxy
|
||||
|
||||
Vibe Proxy connects multiple OpenAI Codex OAuth accounts, displays their quotas, and exposes them through an OpenAI-compatible API.
|
||||
|
||||
The management UI is available at `/management.html`. It supports adding and removing Codex accounts and refreshing their quota. Proxy API keys and server settings are deployment configuration, not UI settings.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/chat/completions`
|
||||
- `POST /v1/responses`
|
||||
- `GET /v1/responses` for WebSocket transport
|
||||
- `GET /healthz`
|
||||
|
||||
## NixOS
|
||||
|
||||
Add the module and package from the flake:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.vibe-proxy.url = "github:methanium/vibe-proxy";
|
||||
|
||||
outputs = { nixpkgs, vibe-proxy, ... }: {
|
||||
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
vibe-proxy.nixosModules.default
|
||||
({ ... }: {
|
||||
services.vibe-proxy = {
|
||||
enable = true;
|
||||
host = "127.0.0.1";
|
||||
port = 8317;
|
||||
settings.api-keys = [ "replace-with-a-random-api-key" ];
|
||||
environmentFiles = [ "/run/secrets/vibe-proxy" ];
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The environment file should contain:
|
||||
|
||||
```sh
|
||||
MANAGEMENT_PASSWORD=replace-with-a-random-management-key
|
||||
```
|
||||
|
||||
The module stores OAuth credentials under `/var/lib/vibe-proxy/auths` by default. Set `openFirewall = true` only when the service should be reachable directly from other machines.
|
||||
|
||||
Build or run the combined package with `nix build` or `nix run`.
|
||||
|
||||
## Other deployments
|
||||
|
||||
Copy `backend/config.example.yaml` to `config.yaml`, replace the example API key, and set `MANAGEMENT_PASSWORD`. Docker Compose and the standalone binary both use local JSON credential storage.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,3 @@ auth-dir: ".dev/auths"
|
|||
|
||||
api-keys:
|
||||
- "dev-api-key"
|
||||
|
||||
plugins:
|
||||
enabled: true
|
||||
dir: ".dev/plugins"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
32
frontend/.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
67
frontend/.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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 |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
|
@ -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 |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 115 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 75 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 16 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -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 |
|
|
@ -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 |
122
frontend/src/codexQuota.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { ModelMappingDiagram } from './ModelMappingDiagram';
|
||||
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';
|
||||
|
|
@ -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> = {};
|
||||
|
||||
/**
|
||||
* 根据成功率 (0–1) 在三个色标之间做 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { Collapsible } from './Collapsible';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { Sheet } from './Sheet';
|
||||
export type { SheetSize } from './Sheet';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { Skeleton } from './Skeleton';
|
||||
|
|
@ -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;
|
||||
}
|
||||