diff --git a/.github/workflows/build_and_release.yml b/.github/workflows/build_and_release.yml index b58dc7c..cab6796 100644 --- a/.github/workflows/build_and_release.yml +++ b/.github/workflows/build_and_release.yml @@ -34,7 +34,7 @@ jobs: - name: Build frontend run: | cd web - npm ci + npm install npm run build - name: Copy frontend for Go embed diff --git a/.gitignore b/.gitignore index 741a841..2ae4440 100644 --- a/.gitignore +++ b/.gitignore @@ -14,21 +14,26 @@ vendor/ # build *.exe internal/web/dist/ -copilot-proxy +/copilot-proxy *.exe *.exe~ # node -web/node_modules/ -web/dist/ +node_modules/ +dist/ +package-lock.json +yarn.lock # config -config/ +/config/ -# test scripts +# tmp +test/ test* - +tmp/ +*.tmp +*.temp # logs logs/ -*.log \ No newline at end of file +*.log diff --git a/Makefile b/Makefile index 6e10dc2..9a7f795 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ GO_BUILD=CGO_ENABLED=0 go build -ldflags="-s -w" all: build frontend: - cd web && npm ci && npm run build + cd web && npm i && npm run build internal/web/dist: frontend rm -rf internal/web/dist diff --git a/README.md b/README.md index 932028d..9301e0a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Copilot Proxy Expose GitHub Copilot as an OpenAI, Anthropic, and Gemini compatible API -Built with Go + Vue 3, single binary with embedded WebUI, zero external runtime dependencies +Built with Go + Vue 3, single binary with embedded WebUI and native OS credential storage ## Features @@ -11,7 +11,7 @@ Built with Go + Vue 3, single binary with embedded WebUI, zero external runtime - Anthropic Messages API compatibility at `http://localhost:15432/v1/messages` - Gemini API compatibility at `http://localhost:15432/v1beta/models/{model}:generateContent` - Single binary — Go backend with embedded Vue 3 + Naive UI frontend via `go:embed` -- AES-GCM encrypted token storage using machine-specific key derivation, no system keyring required +- System keyring token storage on desktop platforms, with `0600` file fallback for headless Linux - Multi-account support — add and switch between multiple GitHub accounts via WebUI - Request statistics and real-time quota display - Streaming support with proper SSE handling @@ -228,15 +228,14 @@ curl "http://localhost:15432/v1beta/models/gemini-pro:generateContent?key=dummy" ## Token Security -GitHub tokens are encrypted at rest using **AES-256-GCM** before being written to `config.json` -The encryption key is derived from the machine's hardware fingerprint: +GitHub tokens are stored in the system credential store by default, and `config.json` only keeps a `github_token_ref` reference: -- **Windows**: `MachineGuid` from registry -- **Linux**: `/etc/machine-id` -- **macOS**: `IOPlatformUUID` from I/O Kit +- **Windows**: Credential Manager +- **macOS**: Keychain +- **Linux desktop sessions**: Secret Service / libsecret +- **Linux headless**: falls back to `github_token` in `config.json`, protected by `0600` file permissions -This means the config file is tied to a specific machine and cannot be decrypted on another device -No system keyring, no third-party credential managers, no plaintext token files +The old machine-fingerprint AES token format has been removed. When upgrading from an older version, delete the old `config.json` and re-authorize to generate the new format. ## Architecture @@ -245,8 +244,8 @@ cmd/copilot-proxy/ # Entry point (CLI subcommands, signal handling, ser internal/ ├── config/ │ ├── config.go # Config load/save/validation (defaults, hot-reload) +│ ├── token_store.go # System keyring / headless file token storage │ ├── config_test.go # Config tests -│ └── crypto.go # AES-GCM encrypt/decrypt (machine-fingerprint key) ├── auth/ │ ├── auth.go # Account manager (multi-account CRUD + token lifecycle) │ └── oauth.go # GitHub Device Code Flow client diff --git a/README_zh-CN.md b/README_zh-CN.md index 6ab93c2..abc8ea3 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -3,7 +3,7 @@ # Copilot Proxy 将 GitHub Copilot 转为 OpenAI、Anthropic 和 Gemini 兼容 API -Go + Vue 3 实现,单二进制嵌入 WebUI,零外部运行时依赖 +Go + Vue 3 实现,单二进制嵌入 WebUI,并使用系统原生凭据存储 ## 特性 @@ -11,7 +11,7 @@ Go + Vue 3 实现,单二进制嵌入 WebUI,零外部运行时依赖 - Anthropic Messages API 兼容:`http://localhost:15432/v1/messages` - Gemini API 兼容:`http://localhost:15432/v1beta/models/{model}:generateContent` - 单文件二进制 —— Go 后端通过 `go:embed` 嵌入 Vue 3 + Naive UI 前端 -- Token 使用 AES-256-GCM 加密存储,密钥从机器硬件指纹派生,无需系统 keyring +- 桌面平台使用系统 keyring 存储 Token,Linux headless 回退到 `0600` 配置文件 - 多账号支持:WebUI 内添加和切换多个 GitHub 账号 - 请求统计和实时额度展示 - 完善的 SSE 流式响应支持 @@ -222,15 +222,14 @@ curl "http://localhost:15432/v1beta/models/gemini-pro:generateContent?key=dummy" ## Token 安全 -GitHub Token 在写入 `config.json` 前会使用 **AES-256-GCM** 加密 -加密密钥从机器硬件指纹派生: +GitHub Token 默认存入系统凭据库,`config.json` 只保存 `github_token_ref` 引用: -- **Windows**:注册表中的 `MachineGuid` -- **Linux**:`/etc/machine-id` -- **macOS**:I/O Kit 的 `IOPlatformUUID` +- **Windows**:Credential Manager +- **macOS**:Keychain +- **Linux 桌面环境**:Secret Service / libsecret +- **Linux headless**:回退到 `config.json` 中的 `github_token`,配置文件权限为 `0600` -这意味着配置文件与特定机器绑定,无法在其他设备上解密 -无需系统 keyring、无需第三方凭据管理器、无明文 Token 文件 +本版本已移除旧的机器指纹 AES 加密格式。升级旧版本时,请先删除旧 `config.json`,启动后重新授权生成新配置。 ## 项目结构 @@ -239,8 +238,8 @@ cmd/copilot-proxy/ # 入口(CLI 子命令、信号处理、服务器 internal/ ├── config/ │ ├── config.go # 配置加载/保存/验证(默认值、热重载) +│ ├── token_store.go # 系统 keyring / headless 文件 token 存储 │ ├── config_test.go # 配置测试 -│ └── crypto.go # AES-GCM 加密/解密(机器指纹派生密钥) ├── auth/ │ ├── auth.go # 账号管理器(多账号增删改查 + Token 生命周期) │ └── oauth.go # GitHub Device Code Flow 客户端 diff --git a/background/dark.png b/background/dark.png new file mode 100644 index 0000000..ae8adb0 Binary files /dev/null and b/background/dark.png differ diff --git a/background/light.png b/background/light.png new file mode 100644 index 0000000..975bbec Binary files /dev/null and b/background/light.png differ diff --git a/cmd/copilot-proxy/main.go b/cmd/copilot-proxy/main.go index cebee91..79eaf7c 100644 --- a/cmd/copilot-proxy/main.go +++ b/cmd/copilot-proxy/main.go @@ -32,9 +32,9 @@ func main() { func run(args []string) error { opts, cmds := parseGlobalOptions(args) - cfg, resolvedPath, err := config.LoadWithDecryptedTokens(opts.configPath) + cfg, resolvedPath, err := config.LoadWithResolvedTokens(opts.configPath) if err != nil { - // 解密警告是非致命的:WebUI 仍可启动,用户可重刷 token 或重新授权 + // Token storage warnings are non-fatal: WebUI can still start and re-authorize. fmt.Fprintln(os.Stderr, "[!]", err) } logger := log.New(os.Stdout, "", 0) @@ -341,7 +341,7 @@ func printBanner() { | | / _ \| '_ \| | |/ _ \| __| | |_) | '__/ _ \ \/ / | | | | |__| (_) | |_) | | | (_) | |_ | __/| | | (_) > <| |_| | \____\___/| .__/|_|_|\___/ \__| |_| |_| \___/_/\_\\__, | - |_| |___/ + |_| |___/ `) } diff --git a/config.example.json b/config.example.json index 964328f..575e751 100644 --- a/config.example.json +++ b/config.example.json @@ -26,7 +26,8 @@ "admin_password": "admin" }, "runtime": { - "proxy_disabled": false + "proxy_disabled": false, + "denied_vendors": ["github-copilot"] }, "auth": { "active_account_id": "", diff --git a/copilot_proxy.png b/copilot_proxy.png deleted file mode 100644 index 867cb7b..0000000 Binary files a/copilot_proxy.png and /dev/null differ diff --git a/go.mod b/go.mod index e6f0057..2a2421d 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,10 @@ module github.com/Open-Copilot-Proxy/Copilot_Proxy go 1.23 -// 零外部依赖 — 全部使用 Go 标准库 (crypto, net/http, encoding/json 等) +require github.com/zalando/go-keyring v0.2.8 + +require ( + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + golang.org/x/sys v0.27.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f598cdf --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 38d74bf..d78c5b8 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -190,6 +190,7 @@ func (m *Manager) RemoveAccount(ctx context.Context, id string) error { return fmt.Errorf("account %q not found", id) } + removedAccount := m.cfg.Auth.Accounts[idx] m.cfg.Auth.Accounts = append(m.cfg.Auth.Accounts[:idx], m.cfg.Auth.Accounts[idx+1:]...) if m.activeID == id { @@ -208,6 +209,9 @@ func (m *Manager) RemoveAccount(ctx context.Context, id string) error { if err := config.Save(m.configPath, *m.cfg); err != nil { return err } + if err := config.DeleteStoredGitHubToken(removedAccount); err != nil { + return fmt.Errorf("delete stored github token: %w", err) + } if m.HasGitHubToken() && m.activeID != "" { if err := m.RefreshCopilotToken(ctx); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 12237c8..75dff5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "net" "os" "path/filepath" @@ -58,7 +57,8 @@ type SecurityConfig struct { } type RuntimeConfig struct { - ProxyDisabled bool `json:"proxy_disabled"` + ProxyDisabled bool `json:"proxy_disabled"` + DeniedVendors []string `json:"denied_vendors,omitempty"` } type AccountConfig struct { @@ -66,6 +66,7 @@ type AccountConfig struct { Name string `json:"name"` GitHubUserLogin string `json:"github_user_login,omitempty"` GitHubToken string `json:"github_token,omitempty"` + GitHubTokenRef string `json:"github_token_ref,omitempty"` } type AuthConfig struct { @@ -103,10 +104,10 @@ func Default() Config { UserAgent: "GithubCopilot/1.246.0", }, Security: SecurityConfig{ - APIKey: "dummy", - AdminPassword: "admin", + APIKey: "dummy", + AdminPassword: "admin", }, - Runtime: RuntimeConfig{ProxyDisabled: false}, + Runtime: RuntimeConfig{ProxyDisabled: false, DeniedVendors: []string{"github-copilot"}}, Auth: AuthConfig{ ActiveAccountID: "", Accounts: []AccountConfig{}, @@ -170,76 +171,75 @@ func Save(path string, cfg Config) error { return err } - // 加密所有账号的 github_token - encrypted := cfg - // 深拷贝 Accounts 切片,避免加密 ciphertext 回写时修改调用者 cfg 的共享底层数组 - encrypted.Auth.Accounts = append([]AccountConfig{}, encrypted.Auth.Accounts...) - for i := range encrypted.Auth.Accounts { - token := encrypted.Auth.Accounts[i].GitHubToken - if token != "" && !IsProbablyEncrypted(token) { - ciphertext, err := EncryptToken(token) + persisted := cfg + // 深拷贝 Accounts 切片,避免持久化时修改调用者 cfg 的共享底层数组。 + persisted.Auth.Accounts = append([]AccountConfig{}, persisted.Auth.Accounts...) + storage := EffectiveTokenStorage() + for i := range persisted.Auth.Accounts { + account := &persisted.Auth.Accounts[i] + token := account.GitHubToken + if token == "" { + continue + } + switch storage { + case TokenStorageKeyring: + ref, err := StoreGitHubToken(account.ID, token) if err != nil { - return fmt.Errorf("encrypt token for account %s: %w", encrypted.Auth.Accounts[i].ID, err) + return fmt.Errorf("store token in keyring for account %s: %w", account.ID, err) } - encrypted.Auth.Accounts[i].GitHubToken = ciphertext + account.GitHubToken = "" + account.GitHubTokenRef = ref + case TokenStorageFile: + account.GitHubToken = token + account.GitHubTokenRef = "" } } - raw, err := json.MarshalIndent(encrypted, "", " ") + raw, err := json.MarshalIndent(persisted, "", " ") if err != nil { return err } return os.WriteFile(path, append(raw, '\n'), 0o600) } -// LoadWithDecryptedTokens 加载配置并解密所有 github_token。 -// 解密失败的 token 会被清空(而非继续以密文体作为凭据使用),并通过错误返回汇总警告。 -func LoadWithDecryptedTokens(path string) (Config, string, error) { +// LoadWithResolvedTokens 加载配置并解析 GitHub token。 +// 桌面平台从系统 keyring 读取 github_token_ref;headless Linux 使用 0600 配置文件中的明文 token。 +func LoadWithResolvedTokens(path string) (Config, string, error) { cfg, resolved, err := Load(path) if err != nil { return cfg, resolved, err } - // Reload raw to decrypt tokens - raw, err := os.ReadFile(resolved) - if err != nil { - return cfg, resolved, err - } - // 从原始 JSON 解密 token - var rawCfg Config - if err := json.Unmarshal(raw, &rawCfg); err != nil { - return cfg, resolved, err - } - - var decryptErrs []error - for i := range rawCfg.Auth.Accounts { - token := rawCfg.Auth.Accounts[i].GitHubToken - if token != "" && IsProbablyEncrypted(token) { - log.Printf("[DEBUG] LoadWithDecryptedTokens: attempting decrypt for account %q (token prefix: %s len=%d)", - rawCfg.Auth.Accounts[i].ID, token[:min(len(token), 20)], len(token)) - plaintext, err := DecryptToken(token) + storage := EffectiveTokenStorage() + var resolveErrs []error + for i := range cfg.Auth.Accounts { + account := &cfg.Auth.Accounts[i] + switch storage { + case TokenStorageKeyring: + if account.GitHubTokenRef == "" { + if account.GitHubToken != "" { + account.GitHubToken = "" + resolveErrs = append(resolveErrs, fmt.Errorf("account %q: legacy github_token is no longer supported with keyring storage; delete config.json and re-authorize", account.ID)) + } + continue + } + plaintext, err := LoadGitHubToken(account.GitHubTokenRef) if err != nil { - // 解密失败:不清除现有配置,仅把内存中的 token 置空; - // 绝不保留密文字符串作为后续鉴权的凭据。 - cfg.Auth.Accounts[i].GitHubToken = "" - log.Printf("[DEBUG] LoadWithDecryptedTokens: decrypt FAILED for account %q: %v", - rawCfg.Auth.Accounts[i].ID, err) - decryptErrs = append(decryptErrs, fmt.Errorf("account %q: %w", rawCfg.Auth.Accounts[i].ID, err)) + account.GitHubToken = "" + resolveErrs = append(resolveErrs, fmt.Errorf("account %q: load keyring token: %w", account.ID, err)) continue } - log.Printf("[DEBUG] LoadWithDecryptedTokens: decrypt OK for account %q (plaintext len=%d)", - rawCfg.Auth.Accounts[i].ID, len(plaintext)) - cfg.Auth.Accounts[i].GitHubToken = plaintext - } else if token != "" && !IsProbablyEncrypted(token) { - log.Printf("[DEBUG] LoadWithDecryptedTokens: token for account %q is not encrypted, using as-is (prefix=%s len=%d)", - rawCfg.Auth.Accounts[i].ID, token[:min(len(token), 20)], len(token)) - cfg.Auth.Accounts[i].GitHubToken = token + account.GitHubToken = plaintext + case TokenStorageFile: + if account.GitHubTokenRef != "" && account.GitHubToken == "" { + resolveErrs = append(resolveErrs, fmt.Errorf("account %q: keyring token reference is unavailable in file token storage; delete config.json and re-authorize", account.ID)) + } } } - if len(decryptErrs) > 0 { - return cfg, resolved, fmt.Errorf("the following accounts had token decryption failures and were cleared: %s", - errors.Join(decryptErrs...)) + if len(resolveErrs) > 0 { + return cfg, resolved, fmt.Errorf("the following accounts had token load failures and were cleared: %s", + errors.Join(resolveErrs...)) } return cfg, resolved, nil } @@ -289,6 +289,17 @@ func (c Config) HasAdminPassword() bool { return strings.TrimSpace(c.Security.AdminPassword) != "" } +// IsVendorDenied 检查 vendor 是否以 DeniedVendors 中任意一项为前缀(不区分大小写)。 +func (c *Config) IsVendorDenied(vendor string) bool { + vendor = strings.ToLower(vendor) + for _, denied := range c.Runtime.DeniedVendors { + if strings.HasPrefix(vendor, strings.ToLower(denied)) { + return true + } + } + return false +} + func (c *Config) applyDefaults() { d := Default() if c.Server.Host == "" { @@ -348,6 +359,9 @@ func (c *Config) applyDefaults() { if c.UI.Theme == "" { c.UI.Theme = d.UI.Theme } + if c.Runtime.DeniedVendors == nil { + c.Runtime.DeniedVendors = d.Runtime.DeniedVendors + } } func (c Config) Validate() error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c9b75dc..b9f1848 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,9 +1,13 @@ package config import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" + + "github.com/zalando/go-keyring" ) func TestLoadCreatesDefaultConfig(t *testing.T) { @@ -39,3 +43,133 @@ func TestValidateRejectsInvalidPort(t *testing.T) { t.Fatal("Validate() error = nil, want invalid port error") } } + +func TestDefaultDeniedVendors(t *testing.T) { + cfg := Default() + if len(cfg.Runtime.DeniedVendors) != 1 { + t.Fatalf("len(DeniedVendors) = %d, want 1", len(cfg.Runtime.DeniedVendors)) + } + if cfg.Runtime.DeniedVendors[0] != "github-copilot" { + t.Fatalf("DeniedVendors[0] = %q, want %q", cfg.Runtime.DeniedVendors[0], "github-copilot") + } +} + +func TestIsVendorDenied(t *testing.T) { + cfg := Default() + tests := []struct { + vendor string + want bool + }{ + {"github-copilot", true}, + {"GitHub-Copilot", true}, // case insensitive + {"openai", false}, + {"", false}, + } + for _, tt := range tests { + if got := cfg.IsVendorDenied(tt.vendor); got != tt.want { + t.Errorf("IsVendorDenied(%q) = %v, want %v", tt.vendor, got, tt.want) + } + } +} + +func TestIsVendorDeniedCustom(t *testing.T) { + cfg := Default() + cfg.Runtime.DeniedVendors = []string{"custom-vendor", "blocked"} + tests := []struct { + vendor string + want bool + }{ + {"custom-vendor", true}, + {"blocked", true}, + {"custom-vendor/gpt", true}, + {"openai", false}, + {"", false}, + } + for _, tt := range tests { + if got := cfg.IsVendorDenied(tt.vendor); got != tt.want { + t.Errorf("IsVendorDenied(%q) = %v, want %v", tt.vendor, got, tt.want) + } + } +} + +func TestSaveUsesPlainFileStorageWhenForced(t *testing.T) { + t.Setenv(tokenStorageEnv, TokenStorageFile) + path := filepath.Join(t.TempDir(), "config.json") + cfg := Default() + cfg.Auth.ActiveAccountID = "alice" + cfg.Auth.Accounts = []AccountConfig{{ + ID: "alice", + Name: "alice", + GitHubUserLogin: "alice", + GitHubToken: "gho_plain", + }} + + if err := Save(path, cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + var persisted Config + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got := persisted.Auth.Accounts[0].GitHubToken; got != "gho_plain" { + t.Fatalf("persisted token = %q, want plaintext fallback token", got) + } + if got := persisted.Auth.Accounts[0].GitHubTokenRef; got != "" { + t.Fatalf("token ref = %q, want empty for file storage", got) + } + + loaded, _, err := LoadWithResolvedTokens(path) + if err != nil { + t.Fatalf("LoadWithResolvedTokens() error = %v", err) + } + if got := loaded.Auth.Accounts[0].GitHubToken; got != "gho_plain" { + t.Fatalf("loaded token = %q, want plaintext token", got) + } +} + +func TestSaveUsesKeyringStorageWhenForced(t *testing.T) { + t.Setenv(tokenStorageEnv, TokenStorageKeyring) + keyring.MockInit() + path := filepath.Join(t.TempDir(), "config.json") + cfg := Default() + cfg.Auth.ActiveAccountID = "alice" + cfg.Auth.Accounts = []AccountConfig{{ + ID: "alice", + Name: "alice", + GitHubUserLogin: "alice", + GitHubToken: "gho_secret", + }} + + if err := Save(path, cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(raw) == "" || strings.Contains(string(raw), "gho_secret") { + t.Fatalf("persisted config leaked token: %s", raw) + } + var persisted Config + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got := persisted.Auth.Accounts[0].GitHubToken; got != "" { + t.Fatalf("persisted token = %q, want empty for keyring storage", got) + } + if got := persisted.Auth.Accounts[0].GitHubTokenRef; got != "keyring:github:alice" { + t.Fatalf("token ref = %q, want keyring ref", got) + } + + loaded, _, err := LoadWithResolvedTokens(path) + if err != nil { + t.Fatalf("LoadWithResolvedTokens() error = %v", err) + } + if got := loaded.Auth.Accounts[0].GitHubToken; got != "gho_secret" { + t.Fatalf("loaded token = %q, want keyring token", got) + } +} diff --git a/internal/config/crypto.go b/internal/config/crypto.go deleted file mode 100644 index b1aa02d..0000000 --- a/internal/config/crypto.go +++ /dev/null @@ -1,200 +0,0 @@ -package config - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "log" - "os" - "os/exec" - "runtime" - "strings" -) - -// deriveMachineKey 从机器指纹派生 AES-256 密钥。 -// 使用 SHA-256 确保输出总是 32 字节。 -func deriveMachineKey() []byte { - var material string - - switch runtime.GOOS { - case "windows": - material = readWindowsMachineGUID() - case "linux": - material = readLinuxMachineID() - case "darwin": - material = readMacIOPlatformUUID() - default: - // 兜底:使用 hostname(不跨重启持久,但至少每次运行一致) - host, _ := os.Hostname() - material = host - } - - if os.Getenv("COPILOT_PROXY_DEBUG") != "" { - log.Printf("[DEBUG] deriveMachineKey: platform=%s, material=%q (len=%d)", runtime.GOOS, material, len(material)) - } - hash := sha256.Sum256([]byte(material)) - return hash[:] -} - -func readWindowsMachineGUID() string { - // reg query "HKLM\SOFTWARE\Microsoft\Cryptography" /v MachineGuid - out, err := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid").Output() - if err != nil { - host, _ := os.Hostname() - log.Printf("[DEBUG] readWindowsMachineGUID: reg query failed (%v), fallback to hostname=%q", err, host) - return host - } - // 输出示例: - // HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography - // MachineGuid REG_SZ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - lines := strings.Split(string(out), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.Contains(line, "REG_SZ") { - parts := strings.Fields(line) - if len(parts) >= 3 { - guid := parts[len(parts)-1] - log.Printf("[DEBUG] readWindowsMachineGUID: extracted MachineGuid=%q", guid) - return guid - } - } - } - host, _ := os.Hostname() - log.Printf("[DEBUG] readWindowsMachineGUID: REG_SZ line not found in reg output (lines=%d), fallback to hostname=%q", len(lines), host) - return host -} - -func readLinuxMachineID() string { - data, err := os.ReadFile("/etc/machine-id") - if err != nil { - data, err = os.ReadFile("/var/lib/dbus/machine-id") - } - if err != nil { - host, _ := os.Hostname() - return host - } - return strings.TrimSpace(string(data)) -} - -func readMacIOPlatformUUID() string { - out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output() - if err != nil { - host, _ := os.Hostname() - return host - } - // 搜索 "IOPlatformUUID" = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - for _, line := range strings.Split(string(out), "\n") { - line = strings.TrimSpace(line) - if strings.Contains(line, "IOPlatformUUID") { - // 提取引号中的值 - start := strings.Index(line, `"`) - if start < 0 { - continue - } - rest := line[start+1:] - end := strings.Index(rest, `"`) - if end < 0 { - continue - } - val := rest[:end] - // 跳过键名本身 - if strings.EqualFold(val, "IOPlatformUUID") { - rest2 := rest[end+1:] - start2 := strings.Index(rest2, `"`) - if start2 >= 0 { - rest3 := rest2[start2+1:] - end2 := strings.Index(rest3, `"`) - if end2 >= 0 { - return rest3[:end2] - } - } - } - } - } - host, _ := os.Hostname() - return host -} - -const encryptedPrefix = "enc:v1:" - -// EncryptToken 用 AES-GCM 加密明文,返回带 enc:v1: 前缀的 base64 编码密文。 -func EncryptToken(plaintext string) (string, error) { - if plaintext == "" { - return "", errors.New("cannot encrypt empty token") - } - key := deriveMachineKey() - block, err := aes.NewCipher(key) - if err != nil { - return "", fmt.Errorf("aes new cipher: %w", err) - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", fmt.Errorf("gcm: %w", err) - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return "", fmt.Errorf("rand nonce: %w", err) - } - ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) - encoded := base64.StdEncoding.EncodeToString(ciphertext) - return encryptedPrefix + encoded, nil -} - -// DecryptToken 解密 enc:v1: 前缀或传统 base64 编码的 AES-GCM 密文。 -// 如果密钥不匹配(机器指纹变化),返回明确的错误。 -func DecryptToken(encoded string) (string, error) { - if encoded == "" { - return "", errors.New("cannot decrypt empty token") - } - // 剥离 enc:v1: 前缀(新格式);传统格式无前缀直接 base64 解码 - payload := encoded - if strings.HasPrefix(encoded, encryptedPrefix) { - payload = strings.TrimPrefix(encoded, encryptedPrefix) - } - data, err := base64.StdEncoding.DecodeString(payload) - if err != nil { - return "", fmt.Errorf("base64 decode: %w", err) - } - key := deriveMachineKey() - block, err := aes.NewCipher(key) - if err != nil { - return "", fmt.Errorf("aes new cipher: %w", err) - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", fmt.Errorf("gcm: %w", err) - } - nonceSize := gcm.NonceSize() - if len(data) < nonceSize { - return "", errors.New("ciphertext too short") - } - nonce, ciphertext := data[:nonceSize], data[nonceSize:] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", fmt.Errorf("decrypt (machine fingerprint may have changed): %w", err) - } - return string(plaintext), nil -} - -// IsProbablyEncrypted 判断字符串是否已加密。 -// 新版加密使用显式 enc:v1: 前缀;老版加密使用无前缀 base64(通过解码+最小长度校验避免误判)。 -func IsProbablyEncrypted(s string) bool { - // 显式前缀:新格式 - if strings.HasPrefix(s, encryptedPrefix) { - return true - } - // 长明文 PAT(无前缀 base64)也可能触发启发式;通过 base64 解码验证排除误判 - if len(s) > 40 && strings.TrimSpace(s) == s && !strings.ContainsAny(s, " \t\n") { - decoded, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return false - } - // AES-GCM 至少 12 字节 nonce + 1 字节密文 - return len(decoded) >= 13 - } - return false -} diff --git a/internal/config/token_store.go b/internal/config/token_store.go new file mode 100644 index 0000000..62fc0dc --- /dev/null +++ b/internal/config/token_store.go @@ -0,0 +1,100 @@ +package config + +import ( + "errors" + "fmt" + "os" + "runtime" + "strings" + + "github.com/zalando/go-keyring" +) + +const ( + TokenStorageKeyring = "keyring" + TokenStorageFile = "file" + + tokenStorageEnv = "COPILOT_PROXY_TOKEN_STORAGE" + keyringService = "copilot-proxy" + keyringPrefix = "keyring:" +) + +// EffectiveTokenStorage selects where GitHub OAuth/PAT tokens are stored. +// Windows, macOS, and Linux desktop sessions use the system keyring. Headless +// Linux falls back to the config file, which is written with 0600 permissions. +func EffectiveTokenStorage() string { + switch strings.ToLower(strings.TrimSpace(os.Getenv(tokenStorageEnv))) { + case TokenStorageKeyring: + return TokenStorageKeyring + case TokenStorageFile: + return TokenStorageFile + } + if runtime.GOOS == "linux" && isHeadlessLinux() { + return TokenStorageFile + } + return TokenStorageKeyring +} + +func isHeadlessLinux() bool { + if runtime.GOOS != "linux" { + return false + } + hasDisplay := os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" + hasSessionBus := os.Getenv("DBUS_SESSION_BUS_ADDRESS") != "" + return !hasDisplay || !hasSessionBus +} + +func tokenRefForAccount(accountID string) string { + return keyringPrefix + keyringUserForAccount(accountID) +} + +func keyringUserForAccount(accountID string) string { + return "github:" + accountID +} + +func keyringUserFromRef(ref string) string { + return strings.TrimPrefix(ref, keyringPrefix) +} + +// StoreGitHubToken writes a GitHub token to the OS keyring and returns the +// stable reference that should be persisted in config.json. +func StoreGitHubToken(accountID, token string) (string, error) { + if strings.TrimSpace(accountID) == "" { + return "", errors.New("account id is required") + } + if token == "" { + return "", errors.New("github token is required") + } + user := keyringUserForAccount(accountID) + if err := keyring.Set(keyringService, user, token); err != nil { + return "", err + } + return keyringPrefix + user, nil +} + +// LoadGitHubToken resolves a keyring reference into its stored token. +func LoadGitHubToken(ref string) (string, error) { + if !strings.HasPrefix(ref, keyringPrefix) { + return "", fmt.Errorf("unsupported token reference %q", ref) + } + return keyring.Get(keyringService, keyringUserFromRef(ref)) +} + +// DeleteStoredGitHubToken removes a token from the OS keyring when the account +// used keyring storage. Missing entries are treated as already deleted. +func DeleteStoredGitHubToken(account AccountConfig) error { + ref := account.GitHubTokenRef + if ref == "" { + if EffectiveTokenStorage() != TokenStorageKeyring { + return nil + } + ref = tokenRefForAccount(account.ID) + } + if !strings.HasPrefix(ref, keyringPrefix) { + return nil + } + if err := keyring.Delete(keyringService, keyringUserFromRef(ref)); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return err + } + return nil +} diff --git a/internal/proxy/anthropic.go b/internal/proxy/anthropic.go index 1eb9420..c9591c9 100644 --- a/internal/proxy/anthropic.go +++ b/internal/proxy/anthropic.go @@ -15,29 +15,74 @@ var anthropicFinishReasonMap = map[string]string{ "content_filter": "content_filter", } +var anthropicModelMap = map[string]string{ + "claude-opus-4-7": "claude-opus-4.7", + "claude-opus-4.7": "claude-opus-4.7", + "claude-sonnet-4-6": "claude-sonnet-4.6", + "claude-sonnet-4.6": "claude-sonnet-4.6", + "claude-opus-4-6": "claude-opus-4.6", + "claude-opus-4.6": "claude-opus-4.6", + "claude-haiku-4-5": "claude-haiku-4.5", + "claude-haiku-4.5": "claude-haiku-4.5", + "claude-haiku-4-5-20251001": "claude-haiku-4.5", + "claude-sonnet-4-5": "claude-sonnet-4.5", + "claude-sonnet-4.5": "claude-sonnet-4.5", + "claude-sonnet-4-5-20250929": "claude-sonnet-4.5", + "claude-opus-4-5": "claude-opus-4.5", + "claude-opus-4.5": "claude-opus-4.5", + "claude-opus-4-5-20251101": "claude-opus-4.5", + "claude-opus-4-1": "claude-opus-4.1", + "claude-opus-4.1": "claude-opus-4.1", + "claude-opus-4-1-20250805": "claude-opus-4.1", + "claude-sonnet-4-0": "claude-sonnet-4.0", + "claude-sonnet-4.0": "claude-sonnet-4.0", + "claude-sonnet-4-20250514": "claude-sonnet-4.0", + "claude-opus-4-0": "claude-opus-4.0", + "claude-opus-4.0": "claude-opus-4.0", + "claude-opus-4-20250514": "claude-opus-4.0", + "claude-3-7-sonnet-latest": "claude-3.7-sonnet", + "claude-3-7-sonnet-20250219": "claude-3.7-sonnet", + "claude-3.7-sonnet": "claude-3.7-sonnet", + "claude-3-5-sonnet-latest": "claude-3.5-sonnet", + "claude-3-5-sonnet-20241022": "claude-3.5-sonnet", + "claude-3-5-sonnet-20240620": "claude-3.5-sonnet", + "claude-3.5-sonnet": "claude-3.5-sonnet", + "claude-3-5-haiku-latest": "claude-3.5-haiku", + "claude-3-5-haiku-20241022": "claude-3.5-haiku", + "claude-3-5-haiku": "claude-3.5-haiku", + "claude-3.5-haiku": "claude-3.5-haiku", + "claude-3-opus-latest": "claude-3-opus", + "claude-3-opus-20240229": "claude-3-opus", + "claude-3-opus": "claude-3-opus", + "claude-3-sonnet-20240229": "claude-3-sonnet", + "claude-3-sonnet": "claude-3-sonnet", + "claude-3-haiku-20240307": "claude-3-haiku", + "claude-3-haiku": "claude-3-haiku", +} + func (h *Handler) ServeAnthropicMessages(w http.ResponseWriter, r *http.Request) { start := time.Now() if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusMethodNotAllowed, Error: "method not allowed"}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusMethodNotAllowed, DurationMs: time.Since(start).Milliseconds(), Error: "method not allowed"}) return } if h.proxyDisabled() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "proxy service is disabled"}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusServiceUnavailable, Error: "proxy disabled"}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusServiceUnavailable, DurationMs: time.Since(start).Milliseconds(), Error: "proxy disabled"}) return } if !h.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid api key"}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusUnauthorized, Error: "invalid api key"}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusUnauthorized, DurationMs: time.Since(start).Milliseconds(), Error: "invalid api key"}) return } token := h.auth.CopilotToken() if token == "" { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Copilot token 未就绪,请检查授权状态"}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusServiceUnavailable, Error: "missing copilot token"}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusServiceUnavailable, DurationMs: time.Since(start).Milliseconds(), Error: "missing copilot token"}) return } @@ -45,16 +90,28 @@ func (h *Handler) ServeAnthropicMessages(w http.ResponseWriter, r *http.Request) r.Body = http.MaxBytesReader(w, r.Body, 10<<20) if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "无效的 JSON 请求体"}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusBadRequest, Error: "invalid json"}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Status: http.StatusBadRequest, DurationMs: time.Since(start).Milliseconds(), Error: "invalid json"}) return } if _, ok := payload["messages"]; !ok { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "缺少必填字段: messages"}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(payload["model"]), Status: http.StatusBadRequest, Error: "missing messages"}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(payload["model"]), Status: http.StatusBadRequest, DurationMs: time.Since(start).Milliseconds(), Error: "missing messages"}) + return + } + + model := stringValue(payload["model"]) + if !h.checkModelAllowed(model) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": ModelNotAllowedError(model)}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: model, Status: http.StatusForbidden, DurationMs: time.Since(start).Milliseconds(), Error: ModelNotAllowedError(model)}) return } - oaiPayload := anthropicToOpenAI(payload) + oaiPayload, err := anthropicToOpenAI(payload) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: model, Status: http.StatusBadRequest, DurationMs: time.Since(start).Milliseconds(), Error: err.Error()}) + return + } if stream, _ := payload["stream"].(bool); stream { oaiPayload["stream"] = true ensureStreamUsage(oaiPayload) @@ -65,14 +122,14 @@ func (h *Handler) ServeAnthropicMessages(w http.ResponseWriter, r *http.Request) body, err := json.Marshal(oaiPayload) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadRequest, Error: err.Error()}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadRequest, DurationMs: time.Since(start).Milliseconds(), Error: err.Error()}) return } copilotURL := h.chatCompletionsURL() resp, err := h.forward(r.Context(), http.MethodPost, copilotURL, token, "application/json", body) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadGateway, Error: err.Error()}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadGateway, DurationMs: time.Since(start).Milliseconds(), Error: err.Error()}) return } defer resp.Body.Close() @@ -80,14 +137,14 @@ func (h *Handler) ServeAnthropicMessages(w http.ResponseWriter, r *http.Request) if resp.StatusCode != http.StatusOK { body := readErrorPreview(resp) anthropicError(w, resp.StatusCode, body) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: resp.StatusCode, Error: body}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: resp.StatusCode, DurationMs: time.Since(start).Milliseconds(), Error: body}) return } var oaiData map[string]any if err := json.NewDecoder(resp.Body).Decode(&oaiData); err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadGateway, Error: err.Error()}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadGateway, DurationMs: time.Since(start).Milliseconds(), Error: err.Error()}) return } record := RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusOK, DurationMs: time.Since(start).Milliseconds()} @@ -101,37 +158,52 @@ func (h *Handler) ServeAnthropicMessages(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, openAIToAnthropicResponse(oaiData)) } -func anthropicToOpenAI(payload map[string]any) map[string]any { +func anthropicToOpenAI(payload map[string]any) (map[string]any, error) { + model, err := normalizeAnthropicModel(stringValue(payload["model"])) + if err != nil { + return nil, err + } + messages := make([]map[string]any, 0) if system, ok := payload["system"]; ok && system != nil { - if text := anthropicContentToText(system); text != "" { + text, err := anthropicContentToText(system) + if err != nil { + return nil, err + } + if text != "" { messages = append(messages, map[string]any{"role": "system", "content": text}) } } - if rawMessages, ok := payload["messages"].([]any); ok { - for _, item := range rawMessages { - msg, ok := item.(map[string]any) - if !ok { - continue - } - role, _ := msg["role"].(string) - if role == "" { - role = "user" - } - // 角色映射:Anthropic "assistant" → OpenAI "assistant",其余映射为 "user" - // 之前 Bug: 所有非 assistant 角色(包括正确的 "user")都被强制映射成了 "user",没有走这个分支 - if role != "user" && role != "assistant" { - role = "user" - } - messages = append(messages, map[string]any{ - "role": role, - "content": anthropicContentToText(msg["content"]), - }) + rawMessages, ok := payload["messages"].([]any) + if !ok { + return nil, fmt.Errorf("invalid Anthropic messages: expected array, got %T", payload["messages"]) + } + for _, item := range rawMessages { + msg, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid Anthropic message: expected object, got %T", item) } + role, _ := msg["role"].(string) + if role == "" { + role = "user" + } + // 角色映射:Anthropic "assistant" → OpenAI "assistant",其余映射为 "user" + // 之前 Bug: 所有非 assistant 角色(包括正确的 "user")都被强制映射成了 "user",没有走这个分支 + if role != "user" && role != "assistant" { + role = "user" + } + content, err := anthropicContentToText(msg["content"]) + if err != nil { + return nil, err + } + messages = append(messages, map[string]any{ + "role": role, + "content": content, + }) } result := map[string]any{ - "model": stringValue(payload["model"]), + "model": model, "messages": messages, "max_tokens": valueOrDefault(payload, "max_tokens", float64(4096)), "stream": valueOrDefault(payload, "stream", false), @@ -140,27 +212,44 @@ func anthropicToOpenAI(payload map[string]any) map[string]any { copyIfPresent(result, payload, "top_p", "top_p") copyIfPresent(result, payload, "stop_sequences", "stop") copyIfPresent(result, payload, "metadata", "metadata") - return result + return result, nil } -func anthropicContentToText(content any) string { +func normalizeAnthropicModel(model string) (string, error) { + if extractVendor(model) != "" { + return model, nil + } + canonical, ok := anthropicModelMap[strings.ToLower(model)] + if !ok { + return "", fmt.Errorf("unsupported Anthropic model: %s", model) + } + return canonical, nil +} + +func anthropicContentToText(content any) (string, error) { switch v := content.(type) { case string: - return v + return v, nil case []any: texts := make([]string, 0, len(v)) for _, item := range v { block, ok := item.(map[string]any) if !ok { - continue + return "", fmt.Errorf("invalid Anthropic content block: expected object, got %T", item) } - if blockType, _ := block["type"].(string); blockType == "text" { - texts = append(texts, stringValue(block["text"])) + blockType, _ := block["type"].(string) + if blockType != "text" { + return "", fmt.Errorf("unsupported Anthropic content type: %s", blockType) } + textVal, ok := block["text"].(string) + if !ok { + return "", fmt.Errorf("invalid Anthropic text block: 'text' field is missing or not a string") + } + texts = append(texts, textVal) } - return strings.Join(texts, "\n") + return strings.Join(texts, "\n"), nil default: - return "" + return "", fmt.Errorf("invalid Anthropic content: expected string or array, got %T", v) } } @@ -199,21 +288,22 @@ func (h *Handler) serveAnthropicStream(w http.ResponseWriter, r *http.Request, t body, err := json.Marshal(oaiPayload) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadRequest, Error: err.Error()}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadRequest, DurationMs: time.Since(start).Milliseconds(), Error: err.Error()}) return } copilotURL := h.chatCompletionsURL() resp, err := h.forward(r.Context(), http.MethodPost, copilotURL, token, "application/json", body) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadGateway, Error: err.Error()}) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: http.StatusBadGateway, DurationMs: time.Since(start).Milliseconds(), Error: err.Error()}) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - anthropicError(w, resp.StatusCode, readErrorPreview(resp)) - h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: resp.StatusCode, Error: "upstream error"}) + body := readErrorPreview(resp) + anthropicError(w, resp.StatusCode, body) + h.record(RequestRecord{Time: start, Protocol: "anthropic", Method: r.Method, Path: r.URL.Path, Model: stringValue(oaiPayload["model"]), Status: resp.StatusCode, DurationMs: time.Since(start).Milliseconds(), Error: body}) return } diff --git a/internal/proxy/anthropic_test.go b/internal/proxy/anthropic_test.go new file mode 100644 index 0000000..577e3f9 --- /dev/null +++ b/internal/proxy/anthropic_test.go @@ -0,0 +1,453 @@ +package proxy + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Open-Copilot-Proxy/Copilot_Proxy/internal/auth" + "github.com/Open-Copilot-Proxy/Copilot_Proxy/internal/config" +) + +func TestAnthropicToOpenAI_KnownModelNormalized(t *testing.T) { + tests := []struct { + name string + model string + wantModel string + }{ + {name: "canonical", model: "claude-sonnet-4.6", wantModel: "claude-sonnet-4.6"}, + {name: "dash alias", model: "claude-sonnet-4-6", wantModel: "claude-sonnet-4.6"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := map[string]any{ + "model": tt.model, + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + + got, err := anthropicToOpenAI(payload) + if err != nil { + t.Fatalf("anthropicToOpenAI() error = %v, want nil", err) + } + if got["model"] != tt.wantModel { + t.Fatalf("model = %v, want %s", got["model"], tt.wantModel) + } + }) + } +} + +func TestAnthropicToOpenAI_UnknownModelRejected(t *testing.T) { + payload := map[string]any{ + "model": "not-a-claude-model", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + + _, err := anthropicToOpenAI(payload) + if err == nil { + t.Fatal("anthropicToOpenAI() error = nil, want unsupported model error") + } + if !strings.Contains(err.Error(), "unsupported Anthropic model: not-a-claude-model") { + t.Fatalf("error = %q, want unsupported Anthropic model message", err.Error()) + } +} + +func TestAnthropicToOpenAI_VendorPrefixedModelPassesThrough(t *testing.T) { + payload := map[string]any{ + "model": "github-copilot/not-a-claude-model", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + + got, err := anthropicToOpenAI(payload) + if err != nil { + t.Fatalf("anthropicToOpenAI() error = %v, want nil", err) + } + if got["model"] != "github-copilot/not-a-claude-model" { + t.Fatalf("model = %v, want vendor-prefixed model unchanged", got["model"]) + } +} + +func TestAnthropicToOpenAI_ContentBlocks(t *testing.T) { + tests := []struct { + name string + payload map[string]any + wantContent string + wantErr string + }{ + { + name: "text content blocks are joined", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "messages": []any{ + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "text", "text": "hello"}, + map[string]any{"type": "text", "text": "world"}, + }}, + }, + }, + wantContent: "hello\nworld", + }, + { + name: "system string still works", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "system": "be helpful", + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + }, + wantContent: "be helpful", + }, + { + name: "message tool use block is rejected", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "messages": []any{ + map[string]any{"role": "assistant", "content": []any{ + map[string]any{"type": "tool_use", "id": "toolu_1", "name": "lookup"}, + }}, + }, + }, + wantErr: "unsupported Anthropic content type: tool_use", + }, + { + name: "system image block is rejected", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "system": []any{ + map[string]any{"type": "image", "source": map[string]any{"type": "base64"}}, + }, + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + }, + wantErr: "unsupported Anthropic content type: image", + }, + { + name: "string content block is rejected", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "messages": []any{ + map[string]any{"role": "user", "content": []any{"not an object"}}, + }, + }, + wantErr: "invalid Anthropic content block: expected object, got string", + }, + { + name: "content is a number (not string or array)", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "messages": []any{ + map[string]any{"role": "user", "content": 42}, + }, + }, + wantErr: "invalid Anthropic content", + }, + { + name: "text block with missing text field", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "messages": []any{ + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "text"}, + }}, + }, + }, + wantErr: "missing or not a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := anthropicToOpenAI(tt.payload) + if tt.wantErr != "" { + if err == nil { + t.Fatal("anthropicToOpenAI() error = nil, want content type error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("anthropicToOpenAI() error = %v, want nil", err) + } + messages, ok := got["messages"].([]map[string]any) + if !ok || len(messages) == 0 { + t.Fatalf("messages = %#v, want non-empty []map[string]any", got["messages"]) + } + if messages[0]["content"] != tt.wantContent { + t.Fatalf("content = %v, want %q", messages[0]["content"], tt.wantContent) + } + }) + } +} + +func TestAnthropicToOpenAI_InvalidMessagesShape(t *testing.T) { + tests := []struct { + name string + payload map[string]any + wantErr string + }{ + { + name: "messages field string is rejected", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "messages": "not an array", + }, + wantErr: "invalid Anthropic messages: expected array, got string", + }, + { + name: "message string item is rejected", + payload: map[string]any{ + "model": "claude-sonnet-4.6", + "messages": []any{ + "not an object", + }, + }, + wantErr: "invalid Anthropic message: expected object, got string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := anthropicToOpenAI(tt.payload) + if err == nil { + t.Fatal("anthropicToOpenAI() error = nil, want invalid messages error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestAnthropicStreamUpstreamErrorRecordsBodyAndDuration(t *testing.T) { + const upstreamBody = `{"error":"unsupported model"}` + handler := newAnthropicIntegrationHandler(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("upstream path = %q, want /chat/completions", r.URL.Path) + } + time.Sleep(10 * time.Millisecond) + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(upstreamBody)) + }) + + resp := serveAnthropicTestRequest(t, handler, map[string]any{ + "model": "claude-sonnet-4.6", + "max_tokens": 64, + "stream": true, + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + }) + + if resp.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want %d; body = %s", resp.Code, http.StatusUnprocessableEntity, resp.Body.String()) + } + assertAnthropicErrorResponseBody(t, resp, upstreamBody) + record := latestAnthropicRecord(t, handler) + assertRecordedUpstreamError(t, record, http.StatusUnprocessableEntity, upstreamBody) +} + +func TestAnthropicNonStreamUpstreamErrorRecordsBodyAndDuration(t *testing.T) { + const upstreamBody = `{"error":"unsupported model"}` + handler := newAnthropicIntegrationHandler(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("upstream path = %q, want /chat/completions", r.URL.Path) + } + time.Sleep(10 * time.Millisecond) + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(upstreamBody)) + }) + + resp := serveAnthropicTestRequest(t, handler, map[string]any{ + "model": "claude-sonnet-4.6", + "max_tokens": 64, + "stream": false, + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + }) + + if resp.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want %d; body = %s", resp.Code, http.StatusUnprocessableEntity, resp.Body.String()) + } + assertAnthropicErrorResponseBody(t, resp, upstreamBody) + record := latestAnthropicRecord(t, handler) + assertRecordedUpstreamError(t, record, http.StatusUnprocessableEntity, upstreamBody) +} + +func TestAnthropicNonStreamHappyPathRecordsDuration(t *testing.T) { + handler := newAnthropicIntegrationHandler(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("upstream path = %q, want /chat/completions", r.URL.Path) + } + time.Sleep(10 * time.Millisecond) + writeJSON(w, http.StatusOK, map[string]any{ + "id": "chatcmpl-test", + "model": "claude-sonnet-4.6", + "choices": []map[string]any{ + { + "message": map[string]any{"role": "assistant", "content": "hello from upstream"}, + "finish_reason": "stop", + }, + }, + "usage": map[string]any{"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}, + }) + }) + + resp := serveAnthropicTestRequest(t, handler, map[string]any{ + "model": "claude-sonnet-4.6", + "max_tokens": 64, + "messages": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + }) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", resp.Code, http.StatusOK, resp.Body.String()) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode Anthropic response: %v", err) + } + if body["type"] != "message" { + t.Fatalf("type = %v, want message", body["type"]) + } + content, ok := body["content"].([]any) + if !ok || len(content) != 1 { + t.Fatalf("content = %#v, want one Anthropic text block", body["content"]) + } + block, ok := content[0].(map[string]any) + if !ok || block["type"] != "text" || block["text"] != "hello from upstream" { + t.Fatalf("content block = %#v, want Anthropic text block", content[0]) + } + + record := latestAnthropicRecord(t, handler) + if record.Status != http.StatusOK { + t.Fatalf("record status = %d, want %d", record.Status, http.StatusOK) + } + if record.Error != "" { + t.Fatalf("record error = %q, want empty", record.Error) + } + if record.DurationMs <= 0 { + t.Fatalf("record DurationMs = %d, want > 0", record.DurationMs) + } + if record.PromptTokens != 7 || record.CompletionTokens != 3 || record.TotalTokens != 10 { + t.Fatalf("record tokens = (%d, %d, %d), want (7, 3, 10)", record.PromptTokens, record.CompletionTokens, record.TotalTokens) + } +} + +func newAnthropicIntegrationHandler(t *testing.T, chatHandler http.HandlerFunc) *Handler { + t.Helper() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/copilot-token": + writeJSON(w, http.StatusOK, map[string]any{"token": "test-copilot-token", "expires_at": time.Now().Add(time.Hour).Unix()}) + case "/chat/completions": + chatHandler(w, r) + default: + t.Fatalf("unexpected upstream path %q", r.URL.Path) + } + })) + t.Cleanup(upstream.Close) + + cfg := config.Default() + cfg.Copilot.APIBase = upstream.URL + cfg.GitHub.CopilotTokenURL = upstream.URL + "/copilot-token" + cfg.Auth.Accounts = []config.AccountConfig{{ID: "test", Name: "test", GitHubToken: "github-token"}} + cfg.Auth.ActiveAccountID = "test" + + authManager := auth.NewManager(&cfg, t.TempDir()+"/config.json", upstream.Client()) + if err := authManager.RefreshCopilotToken(context.Background()); err != nil { + t.Fatalf("RefreshCopilotToken() error = %v", err) + } + + return NewHandler(cfg, authManager, upstream.Client(), nil, NewStats(100)) +} + +func serveAnthropicTestRequest(t *testing.T, handler *Handler, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal request payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", "dummy") + + resp := httptest.NewRecorder() + handler.ServeAnthropicMessages(resp, req) + return resp +} + +func latestAnthropicRecord(t *testing.T, handler *Handler) RequestRecord { + t.Helper() + + snapshot := handler.Stats() + if len(snapshot.Recent) == 0 { + t.Fatal("stats recent records are empty") + } + record := snapshot.Recent[0] + if record.Protocol != "anthropic" { + t.Fatalf("record protocol = %q, want anthropic", record.Protocol) + } + return record +} + +func assertAnthropicErrorResponseBody(t *testing.T, resp *httptest.ResponseRecorder, upstreamBody string) { + t.Helper() + + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode Anthropic error response: %v", err) + } + if body["type"] != "error" { + t.Fatalf("error response type = %v, want error", body["type"]) + } + errorSection, ok := body["error"].(map[string]any) + if !ok { + t.Fatalf("error response error section = %#v, want object", body["error"]) + } + message, ok := errorSection["message"].(string) + if !ok { + t.Fatalf("error response message = %#v, want string", errorSection["message"]) + } + if !strings.Contains(message, upstreamBody) { + t.Fatalf("error response message = %q, want to contain upstream body %q", message, upstreamBody) + } + if !strings.Contains(message, "unsupported model") { + t.Fatalf("error response message = %q, want to contain unsupported model", message) + } +} + +func assertRecordedUpstreamError(t *testing.T, record RequestRecord, status int, upstreamBody string) { + t.Helper() + + if record.Status != status { + t.Fatalf("record status = %d, want %d", record.Status, status) + } + if record.Error == "upstream error" { + t.Fatal("record error used generic upstream error, want real upstream body") + } + if !strings.Contains(record.Error, upstreamBody) { + t.Fatalf("record error = %q, want to contain upstream body %q", record.Error, upstreamBody) + } + if record.DurationMs <= 0 { + t.Fatalf("record DurationMs = %d, want > 0", record.DurationMs) + } +} diff --git a/internal/proxy/filter.go b/internal/proxy/filter.go new file mode 100644 index 0000000..dd2d1e3 --- /dev/null +++ b/internal/proxy/filter.go @@ -0,0 +1,44 @@ +package proxy + +import ( + "fmt" + "strings" + + "github.com/Open-Copilot-Proxy/Copilot_Proxy/internal/config" +) + +// extractVendor 从 model 字符串中提取 vendor prefix('/' 之前的部分)。 +// 例如 "github-copilot/gpt-5-mini" → "github-copilot","gpt-4.1" → ""。 +func extractVendor(model string) string { + before, _, found := strings.Cut(model, "/") + if !found { + return "" + } + return strings.ToLower(before) +} + +// IsModelAllowed 检查 model 是否被当前配置允许。 +// 无 vendor 前缀的 model 视为允许(无法判断供应商)。 +// cfg 为 nil 时视为允许(防御性编程)。 +func IsModelAllowed(model string, cfg *config.Config) bool { + if cfg == nil { + return true + } + vendor := extractVendor(model) + if vendor == "" { + return true + } + return !cfg.IsVendorDenied(vendor) +} + +// ModelNotAllowedError 返回禁止访问的错误消息。 +func ModelNotAllowedError(model string) string { + return fmt.Sprintf("model not allowed: %s", model) +} + +// checkModelAllowed 是 Handler 辅助方法,返回 true 表示允许。 +func (h *Handler) checkModelAllowed(model string) bool { + h.mu.RLock() + defer h.mu.RUnlock() + return IsModelAllowed(model, &h.cfg) +} diff --git a/internal/proxy/filter_test.go b/internal/proxy/filter_test.go new file mode 100644 index 0000000..ce1260a --- /dev/null +++ b/internal/proxy/filter_test.go @@ -0,0 +1,63 @@ +package proxy + +import ( + "testing" + + "github.com/Open-Copilot-Proxy/Copilot_Proxy/internal/config" +) + +func TestIsModelAllowed_NoVendor(t *testing.T) { + cfg := config.Default() + if !IsModelAllowed("gpt-4.1", &cfg) { + t.Error("IsModelAllowed(gpt-4.1) = false, want true (no vendor prefix)") + } +} + +func TestIsModelAllowed_AllowedVendor(t *testing.T) { + cfg := config.Default() + if !IsModelAllowed("openai/gpt-4.1", &cfg) { + t.Error("IsModelAllowed(openai/gpt-4.1) = false, want true") + } +} + +func TestIsModelAllowed_DeniedVendor(t *testing.T) { + cfg := config.Default() + if IsModelAllowed("github-copilot/gpt-5-mini", &cfg) { + t.Error("IsModelAllowed(github-copilot/gpt-5-mini) = true, want false") + } +} + +func TestIsModelAllowed_DeniedVendorCustom(t *testing.T) { + cfg := config.Default() + cfg.Runtime.DeniedVendors = []string{"my-blocked"} + if IsModelAllowed("my-blocked/gpt-5", &cfg) { + t.Error("IsModelAllowed(my-blocked/gpt-5) = true, want false") + } + if !IsModelAllowed("openai/gpt-5", &cfg) { + t.Error("IsModelAllowed(openai/gpt-5) = false, want true") + } +} + +func TestIsModelAllowed_NilConfig(t *testing.T) { + if !IsModelAllowed("github-copilot/gpt-5", nil) { + t.Error("IsModelAllowed(..., nil) = false, want true (nil cfg is safe)") + } +} + +func TestExtractVendor(t *testing.T) { + tests := []struct { + model string + vendor string + }{ + {"github-copilot/gpt-5-mini", "github-copilot"}, + {"openai/gpt-4.1", "openai"}, + {"gpt-4.1", ""}, + {"", ""}, + {"a/b/c", "a"}, + } + for _, tt := range tests { + if got := extractVendor(tt.model); got != tt.vendor { + t.Errorf("extractVendor(%q) = %q, want %q", tt.model, got, tt.vendor) + } + } +} diff --git a/internal/proxy/gemini.go b/internal/proxy/gemini.go index 260537d..1b7da60 100644 --- a/internal/proxy/gemini.go +++ b/internal/proxy/gemini.go @@ -47,6 +47,11 @@ func (h *Handler) ServeGeminiModels(w http.ResponseWriter, r *http.Request, gemi h.record(RequestRecord{Time: start, Protocol: "gemini", Method: r.Method, Path: r.URL.Path, Status: http.StatusBadRequest, Error: "invalid path"}) return } + if !h.checkModelAllowed(model) { + geminiError(w, http.StatusForbidden, ModelNotAllowedError(model)) + h.record(RequestRecord{Time: start, Protocol: "gemini", Method: r.Method, Path: r.URL.Path, Model: model, Status: http.StatusForbidden, DurationMs: time.Since(start).Milliseconds(), Error: ModelNotAllowedError(model)}) + return + } var payload map[string]any r.Body = http.MaxBytesReader(w, r.Body, 10<<20) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index e6270e3..eac6436 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -88,6 +88,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } body = cleanBody(body) model := modelFromJSONBody(body) + if !h.checkModelAllowed(model) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": ModelNotAllowedError(model)}) + h.record(RequestRecord{Time: start, Protocol: "openai", Method: r.Method, Path: r.URL.Path, Model: model, Status: http.StatusForbidden, DurationMs: time.Since(start).Milliseconds(), Error: ModelNotAllowedError(model)}) + return + } resp, err := h.forward(r.Context(), r.Method, upstreamURL, token, r.Header.Get("Content-Type"), body) if err != nil { @@ -203,7 +208,8 @@ func (h *Handler) copyAndRecordResponse(w http.ResponseWriter, resp *http.Respon _, _ = fmt.Fprintln(w, line) flush(flusher) if strings.HasPrefix(line, "data: ") { - data := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + _, data, _ := strings.Cut(line, "data: ") + data = strings.TrimSpace(data) if data != "" && data != "[DONE]" { var chunk map[string]any if err := json.Unmarshal([]byte(data), &chunk); err == nil { @@ -301,6 +307,41 @@ func (h *Handler) record(record RequestRecord) { } } +func (h *Handler) ServeChatTest(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + token := h.auth.CopilotToken() + if token == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Copilot token not ready"}) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 10<<20) + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + body = cleanBody(body) + + model := modelFromJSONBody(body) + if !h.checkModelAllowed(model) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": ModelNotAllowedError(model)}) + h.record(RequestRecord{Time: start, Protocol: "openai", Method: "POST", Path: "/api/chat/test", Model: model, Status: http.StatusForbidden, DurationMs: time.Since(start).Milliseconds(), Error: ModelNotAllowedError(model)}) + return + } + + resp, err := h.forward(r.Context(), "POST", h.chatCompletionsURL(), token, "application/json", body) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + h.record(RequestRecord{Time: start, Protocol: "openai", Method: "POST", Path: "/api/chat/test", Model: model, Status: http.StatusBadGateway, DurationMs: time.Since(start).Milliseconds(), Error: err.Error()}) + return + } + defer resp.Body.Close() + + h.copyAndRecordResponse(w, resp, RequestRecord{Time: start, Protocol: "openai", Method: "POST", Path: "/api/chat/test", Model: model}) +} + func modelFromJSONBody(body []byte) string { if len(body) == 0 { return "" diff --git a/internal/server/server.go b/internal/server/server.go index 7e901a4..eca431d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -60,7 +60,8 @@ type responseWriterWrapper struct { func (w responseWriterWrapper) ServeHTTP(rw http.ResponseWriter, r *http.Request) { isSSE := r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/messages" || - strings.HasPrefix(r.URL.Path, "/v1beta/models/") + strings.HasPrefix(r.URL.Path, "/v1beta/models/") || + r.URL.Path == "/api/chat/test" if isSSE { rw.Header().Set("X-Accel-Buffering", "no") } @@ -83,6 +84,8 @@ func (a *App) route(w http.ResponseWriter, r *http.Request) { a.api(w, r) case r.URL.Path == "/favicon.ico": a.serveFavicon(w, r) + case strings.HasPrefix(r.URL.Path, "/background/"): + a.serveBackground(w, r) case strings.HasPrefix(r.URL.Path, "/ui") || r.URL.Path == "/ui": a.serveFrontend(w, r) default: @@ -152,6 +155,8 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { a.switchAccount(w, r) case strings.HasPrefix(r.URL.Path, "/api/accounts/") && r.Method == http.MethodDelete: a.deleteAccount(w, r) + case r.URL.Path == "/api/chat/test" && r.Method == http.MethodPost: + a.proxy.ServeChatTest(w, r) case r.URL.Path == "/api/auth/device/start" && r.Method == http.MethodPost: a.startDevice(w, r) case r.URL.Path == "/api/auth/device/poll" && r.Method == http.MethodPost: @@ -266,7 +271,7 @@ func (a *App) updateConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - reloaded, _, err := config.LoadWithDecryptedTokens(a.configPath) + reloaded, _, err := config.LoadWithResolvedTokens(a.configPath) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return @@ -499,12 +504,52 @@ func (a *App) models(w http.ResponseWriter, r *http.Request) { return } defer resp.Body.Close() - var payload any - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + var raw any + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } - writeJSON(w, resp.StatusCode, payload) + writeJSON(w, resp.StatusCode, a.filterModels(raw)) +} + +// filterModels 从 models API 响应中移除 DeniedVendors 下的模型。 +func (a *App) filterModels(raw any) any { + obj, ok := raw.(map[string]any) + if !ok { + return raw + } + data, ok := obj["data"] + if !ok { + return raw + } + models, ok := data.([]any) + if !ok { + return raw + } + + a.mu.RLock() + defer a.mu.RUnlock() + + filtered := make([]any, 0, len(models)) + for _, m := range models { + modelObj, ok := m.(map[string]any) + if !ok { + filtered = append(filtered, m) + continue + } + id, _ := modelObj["id"].(string) + if id == "" { + name, _ := modelObj["name"].(string) + id = name + } + if id != "" && !proxy.IsModelAllowed(id, a.cfg) { + continue + } + filtered = append(filtered, m) + } + + obj["data"] = filtered + return obj } func (a *App) quota(w http.ResponseWriter, r *http.Request) { @@ -819,6 +864,25 @@ func (a *App) serveFavicon(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +func (a *App) serveBackground(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + filename := path.Clean(strings.TrimPrefix(r.URL.Path, "/background/")) + if filename == "" || strings.Contains(filename, "/") { + http.NotFound(w, r) + return + } + filePath := web.ServeBackgroundPath(filename) + if filePath == "" { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + http.ServeFile(w, r, filePath) +} + func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/web/background.go b/internal/web/background.go new file mode 100644 index 0000000..1376596 --- /dev/null +++ b/internal/web/background.go @@ -0,0 +1,79 @@ +package web + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +var ( + bgOnce sync.Once + bgDir string + bgSource string + bgErr error +) + +// AllowedBackgroundFiles returns the set of filenames that may be served +// via the /background/ route. +func AllowedBackgroundFiles() map[string]bool { + return map[string]bool{ + "light.png": true, + "dark.png": true, + } +} + +// BackgroundDir returns the resolved background directory path and a human- +// readable source label. It searches in order: +// 1. $COPILOT_PROXY_BACKGROUND_DIR +// 2. /background +// 3. /background +// +// When the directory is missing a non-nil error is returned — callers should +// treat this as non-fatal and fall back gracefully. +func BackgroundDir() (dir, source string, err error) { + bgOnce.Do(func() { + bgDir, bgSource, bgErr = resolveBackgroundDir() + }) + return bgDir, bgSource, bgErr +} + +func resolveBackgroundDir() (string, string, error) { + if dir := os.Getenv("COPILOT_PROXY_BACKGROUND_DIR"); dir != "" { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir, fmt.Sprintf("$COPILOT_PROXY_BACKGROUND_DIR (%s)", dir), nil + } + return "", "", fmt.Errorf("$COPILOT_PROXY_BACKGROUND_DIR=%q: not a directory", dir) + } + + if cwd, err := os.Getwd(); err == nil { + dir := filepath.Join(cwd, "background") + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir, dir, nil + } + } + + if exec, err := os.Executable(); err == nil { + dir := filepath.Join(filepath.Dir(exec), "background") + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir, dir, nil + } + } + + return "", "", fmt.Errorf("background directory not found") +} + +// ServeBackgroundPath returns the absolute filesystem path for a background +// image filename. The filename must be in the allowlist returned by +// AllowedBackgroundFiles. Returns an empty string if the file is unknown or +// the background directory is not available. +func ServeBackgroundPath(filename string) string { + if !AllowedBackgroundFiles()[filename] { + return "" + } + dir, _, err := BackgroundDir() + if err != nil { + return "" + } + return filepath.Join(dir, filename) +} diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index 9ebdb69..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,1693 +0,0 @@ -{ - "name": "copilot-proxy-webui", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "copilot-proxy-webui", - "version": "1.0.0", - "dependencies": { - "naive-ui": "^2.38.0", - "pinia": "^2.1.7", - "vue": "^3.4.21", - "vue-router": "^4.3.0" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^5.0.4", - "typescript": "^5.4.0", - "vite": "^5.2.0", - "vue-tsc": "^2.0.6" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@css-render/plugin-bem": { - "version": "0.15.14", - "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", - "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", - "license": "MIT", - "peerDependencies": { - "css-render": "~0.15.14" - } - }, - "node_modules/@css-render/vue3-ssr": { - "version": "0.15.14", - "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", - "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", - "license": "MIT", - "peerDependencies": { - "vue": "^3.0.11" - } - }, - "node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", - "license": "Apache-2.0" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.15", - "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", - "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.15" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.15", - "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", - "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.15", - "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", - "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.15", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz", - "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@vue/shared": "3.5.34", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", - "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.34", - "@vue/shared": "3.5.34" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", - "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@vue/compiler-core": "3.5.34", - "@vue/compiler-dom": "3.5.34", - "@vue/compiler-ssr": "3.5.34", - "@vue/shared": "3.5.34", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.14", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", - "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.34", - "@vue/shared": "3.5.34" - } - }, - "node_modules/@vue/compiler-vue2": { - "version": "2.7.16", - "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", - "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, - "node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/@vue/language-core": { - "version": "2.2.12", - "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", - "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.15", - "@vue/compiler-dom": "^3.5.0", - "@vue/compiler-vue2": "^2.7.16", - "@vue/shared": "^3.5.0", - "alien-signals": "^1.0.3", - "minimatch": "^9.0.3", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz", - "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.34" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz", - "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.34", - "@vue/shared": "3.5.34" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", - "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.34", - "@vue/runtime-core": "3.5.34", - "@vue/shared": "3.5.34", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz", - "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.34", - "@vue/shared": "3.5.34" - }, - "peerDependencies": { - "vue": "3.5.34" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz", - "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", - "license": "MIT" - }, - "node_modules/alien-signals": { - "version": "1.0.13", - "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", - "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-validator": { - "version": "4.2.5", - "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", - "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/css-render": { - "version": "0.15.14", - "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz", - "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "~0.8.0", - "csstype": "~3.0.5" - } - }, - "node_modules/css-render/node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/date-fns-tz": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", - "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", - "license": "MIT", - "peerDependencies": { - "date-fns": "^3.0.0 || ^4.0.0" - } - }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/evtd": { - "version": "0.2.4", - "resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz", - "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/naive-ui": { - "version": "2.44.1", - "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.44.1.tgz", - "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", - "license": "MIT", - "dependencies": { - "@css-render/plugin-bem": "^0.15.14", - "@css-render/vue3-ssr": "^0.15.14", - "@types/lodash": "^4.17.20", - "@types/lodash-es": "^4.17.12", - "async-validator": "^4.2.5", - "css-render": "^0.15.14", - "csstype": "^3.1.3", - "date-fns": "^4.1.0", - "date-fns-tz": "^3.2.0", - "evtd": "^0.2.4", - "highlight.js": "^11.8.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "seemly": "^0.3.10", - "treemate": "^0.3.11", - "vdirs": "^0.1.8", - "vooks": "^0.2.12", - "vueuc": "^0.4.65" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/pinia": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", - "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.3", - "vue-demi": "^0.14.10" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.4.4", - "vue": "^2.7.0 || ^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/seemly": { - "version": "0.3.10", - "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz", - "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", - "license": "MIT" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/treemate": { - "version": "0.3.11", - "resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", - "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vdirs": { - "version": "0.1.8", - "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", - "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", - "license": "MIT", - "dependencies": { - "evtd": "^0.2.2" - }, - "peerDependencies": { - "vue": "^3.0.11" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vooks": { - "version": "0.2.12", - "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", - "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", - "license": "MIT", - "dependencies": { - "evtd": "^0.2.2" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue": { - "version": "3.5.34", - "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz", - "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.34", - "@vue/compiler-sfc": "3.5.34", - "@vue/runtime-dom": "3.5.34", - "@vue/server-renderer": "3.5.34", - "@vue/shared": "3.5.34" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/vue-router": { - "version": "4.6.4", - "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.4" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/vue-tsc": { - "version": "2.2.12", - "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", - "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/typescript": "2.4.15", - "@vue/language-core": "2.2.12" - }, - "bin": { - "vue-tsc": "bin/vue-tsc.js" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - } - }, - "node_modules/vueuc": { - "version": "0.4.65", - "resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz", - "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", - "license": "MIT", - "dependencies": { - "@css-render/vue3-ssr": "^0.15.10", - "@juggle/resize-observer": "^3.3.1", - "css-render": "^0.15.10", - "evtd": "^0.2.4", - "seemly": "^0.3.6", - "vdirs": "^0.1.4", - "vooks": "^0.2.4" - }, - "peerDependencies": { - "vue": "^3.0.11" - } - } - } -} diff --git a/web/package.json b/web/package.json index b66dd65..2b2a62f 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" }, "dependencies": { "naive-ui": "^2.38.0", @@ -16,8 +18,11 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.4", + "@vue/test-utils": "^2.4.10", + "happy-dom": "^20.9.0", "typescript": "^5.4.0", "vite": "^5.2.0", + "vitest": "^4.1.7", "vue-tsc": "^2.0.6" } } diff --git a/web/src/App.vue b/web/src/App.vue index cc33b87..718051b 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -45,58 +45,62 @@ const naiveDateLocale = computed(() => { --cp-radius-sm: 8px; --cp-radius-md: 12px; --cp-radius-lg: 18px; - --cp-font-size-xs: 12px; - --cp-font-size-sm: 13px; - --cp-font-size-md: 15px; - --cp-font-size-lg: 18px; - --cp-font-size-xl: 24px; - --cp-font-size-2xl: 32px; + --cp-font-size-xs: 15px; + --cp-font-size-sm: 16px; + --cp-font-size-md: 18px; + --cp-font-size-lg: 21px; + --cp-font-size-xl: 27px; + --cp-font-size-2xl: 35px; --cp-color-bg: #f4f7fb; - --cp-color-surface: rgba(255, 255, 255, 0.86); - --cp-color-card: #ffffff; - --cp-color-border: rgba(23, 35, 61, 0.1); + --cp-color-surface: rgba(255, 255, 255, 0.12); + --cp-color-card: rgba(255, 255, 255, 0.18); + --cp-color-border: rgba(255, 255, 255, 0.22); --cp-color-text: #1f2937; --cp-color-text-secondary: rgba(31, 41, 55, 0.72); --cp-color-text-muted: rgba(31, 41, 55, 0.5); --cp-color-primary: #2563eb; - --cp-color-primary-soft: rgba(37, 99, 235, 0.1); + --cp-color-primary-soft: rgba(37, 99, 235, 0.08); --cp-color-primary-hover: #1d4ed8; --cp-color-success: #18a058; - --cp-color-success-soft: rgba(24, 160, 88, 0.1); + --cp-color-success-soft: rgba(24, 160, 88, 0.08); --cp-color-warning: #f0a020; - --cp-color-warning-soft: rgba(240, 160, 32, 0.12); + --cp-color-warning-soft: rgba(240, 160, 32, 0.10); --cp-color-error: #d03050; - --cp-color-error-soft: rgba(208, 48, 80, 0.1); - --cp-shadow-card: 0 2px 16px rgba(28, 45, 78, 0.06); + --cp-color-error-soft: rgba(208, 48, 80, 0.08); + --cp-shadow-card: 0 4px 24px rgba(28, 45, 78, 0.08); --cp-shadow-float: 0 12px 48px rgba(20, 30, 54, 0.12); --cp-transition-fast: 160ms ease; --cp-transition-med: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --cp-wallpaper-url: url('/background/light.png'); } :root[data-theme='dark'] { --cp-color-bg: #080e1a; - --cp-color-surface: rgba(18, 26, 44, 0.82); - --cp-color-card: #111b30; - --cp-color-border: rgba(255, 255, 255, 0.08); + --cp-color-surface: rgba(18, 26, 44, 0.10); + --cp-color-card: rgba(18, 26, 44, 0.15); + --cp-color-border: rgba(255, 255, 255, 0.10); --cp-color-text: rgba(235, 238, 245, 0.94); --cp-color-text-secondary: rgba(235, 238, 245, 0.7); --cp-color-text-muted: rgba(235, 238, 245, 0.48); --cp-color-primary: #6ea8fe; - --cp-color-primary-soft: rgba(110, 168, 254, 0.12); + --cp-color-primary-soft: rgba(110, 168, 254, 0.10); --cp-color-primary-hover: #8bb9fe; - --cp-color-success-soft: rgba(24, 160, 88, 0.15); - --cp-color-warning-soft: rgba(240, 160, 32, 0.15); - --cp-color-error-soft: rgba(208, 48, 80, 0.15); - --cp-shadow-card: 0 2px 20px rgba(0, 0, 0, 0.28); + --cp-color-success-soft: rgba(24, 160, 88, 0.12); + --cp-color-warning-soft: rgba(240, 160, 32, 0.12); + --cp-color-error-soft: rgba(208, 48, 80, 0.12); + --cp-shadow-card: 0 4px 24px rgba(0, 0, 0, 0.22); --cp-shadow-float: 0 16px 56px rgba(0, 0, 0, 0.36); + --cp-wallpaper-url: url('/background/dark.png'); } html, body, #app { margin: 0; padding: 0; height: 100%; + overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + font-weight: 600; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background: var(--cp-color-bg); @@ -104,6 +108,10 @@ html, body, #app { transition: background var(--cp-transition-med), color var(--cp-transition-med); } +body { + min-height: 100%; +} + * { box-sizing: border-box; } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 650c35f..1b96db4 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -14,6 +14,35 @@ export interface Account { github_token?: string } +export interface ModelInfo { + id: string + name?: string + vendor?: string + capabilities?: Record + object?: string + created?: number + owned_by?: string + model_picker_enabled?: boolean + policy?: { state?: string; terms?: string } +} + +export interface ModelsResponse { + data: ModelInfo[] + object?: string +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string + attachments?: { type: string; data: string; name: string }[] +} + +export interface ChatTestRequest { + model: string + messages: ChatMessage[] + stream?: boolean +} + export interface Config { server: { host: string; port: number; read_timeout_seconds: number; write_timeout_seconds: number } github: Record @@ -202,3 +231,19 @@ export const serviceApi = { body: JSON.stringify({ enabled }), }), } + +export const chatApi = { + models: () => api('/api/models', { headers: authHeaders() }), + test: (body: ChatTestRequest) => { + const token = getAuthToken() + const headers: Record = { 'Content-Type': 'application/json' } + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + return fetch('/api/chat/test', { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + }, +} diff --git a/web/src/components/AccountPanel.vue b/web/src/components/AccountPanel.vue index 9a0c550..a82461c 100644 --- a/web/src/components/AccountPanel.vue +++ b/web/src/components/AccountPanel.vue @@ -1,41 +1,31 @@