From eb81bd975cdc5411186965cff6cf0f991a35635b Mon Sep 17 00:00:00 2001 From: Sergey Skorokhod Date: Sun, 20 Sep 2026 11:25:37 +0300 Subject: [PATCH] refactor: translate comments and logs into English to improve clarity and consistency in code documentation --- .env.example | 24 +- .gitignore | 10 +- cmd/aistudio2api/main.go | 1 - internal/aistudio/accounts.go | 418 +++++------ internal/aistudio/auth.go | 90 +-- internal/aistudio/benefit.go | 22 +- internal/aistudio/bidi.go | 112 +-- internal/aistudio/client.go | 62 +- internal/aistudio/compatibility_test.go | 6 +- internal/aistudio/decoder.go | 44 +- internal/aistudio/event.go | 16 +- internal/aistudio/generate.go | 34 +- internal/aistudio/local_defaults.go | 4 +- internal/aistudio/login_native.go | 26 +- internal/aistudio/media.go | 4 +- internal/aistudio/models.go | 16 +- internal/aistudio/quota.go | 16 +- internal/aistudio/request.go | 38 +- internal/aistudio/request_phase.go | 10 +- internal/aistudio/runtime_native.go | 28 +- internal/aistudio/schema.go | 52 +- internal/aistudio/service.go | 96 +-- internal/aistudio/signer.go | 24 +- internal/aistudio/stream_activity.go | 2 +- internal/aistudio/tool_events.go | 12 +- internal/aistudio/tool_validation.go | 12 +- internal/aistudio/tools.go | 28 +- internal/aistudio/transcribe.go | 4 +- internal/aistudio/transcription_service.go | 22 +- internal/aistudio/transport_browser.go | 16 +- internal/aistudio/transport_http.go | 70 +- internal/aistudio/types.go | 114 +-- internal/aistudio/upload.go | 134 ++-- internal/aistudio/usage.go | 4 +- internal/aistudio/video.go | 44 +- internal/aistudio/waa.go | 22 +- internal/aistudio/webchannel.go | 114 +-- internal/aistudio/youtube.go | 2 +- internal/api/admin.go | 32 +- internal/api/anthropic.go | 2 +- internal/api/errors.go | 10 +- internal/api/files.go | 4 +- internal/api/live.go | 30 +- internal/api/media.go | 6 +- internal/api/media_test.go | 2 +- internal/api/middleware.go | 28 +- internal/api/openai.go | 4 +- internal/api/responses.go | 2 +- internal/api/responses_google_tools.go | 6 +- internal/api/router.go | 4 +- internal/app/admin.go | 407 +++++++--- internal/app/app.go | 72 +- internal/app/auth_retry.go | 115 ++- internal/app/bidi.go | 41 +- internal/app/files.go | 21 +- internal/app/lifecycle.go | 202 +++-- internal/app/request_logging.go | 92 ++- internal/app/runtime.go | 834 ++++++++++++++++----- internal/app/transcriptions.go | 39 +- internal/camoufoxnative/bidi.go | 34 +- internal/camoufoxnative/download.go | 42 +- internal/camoufoxnative/executable.go | 10 +- internal/camoufoxnative/fingerprint.go | 28 +- internal/camoufoxnative/launcher.go | 28 +- internal/camoufoxnative/login.go | 54 +- internal/camoufoxnative/page.go | 32 +- internal/camoufoxnative/process_other.go | 4 +- internal/camoufoxnative/process_windows.go | 8 +- internal/camoufoxnative/protected.go | 24 +- internal/camoufoxnative/types.go | 18 +- internal/camoufoxnative/worker.go | 80 +- internal/chromeauth/abe_unsupported.go | 2 +- internal/chromeauth/abe_windows_amd64.go | 74 +- internal/chromeauth/auth.go | 42 +- internal/chromeauth/native/abe_helper.c | 14 +- internal/chromeauth/ncrypt_other.go | 2 +- internal/chromeauth/ncrypt_windows.go | 18 +- internal/chromeauth/platform_other.go | 8 +- internal/chromeauth/platform_windows.go | 26 +- internal/chromeauth/protocol.go | 52 +- internal/chromeauth/verify.go | 18 +- internal/config/config.go | 130 +++- internal/setup/setup.go | 162 ++-- internal/webui/embed.go | 18 +- 84 files changed, 2865 insertions(+), 1769 deletions(-) diff --git a/.env.example b/.env.example index 8928dae..712bce2 100644 --- a/.env.example +++ b/.env.example @@ -1,35 +1,35 @@ -# 账户状态文件、目录或逗号分隔的多个路径 +# Account state file, directory, or comma-separated paths AISTUDIO_AUTH_STATES=auth -# 服务监听地址 +# Service listen address LISTEN_ADDR=127.0.0.1:2048 -# 本地 API 认证密钥 +# Local API authentication key PROXY_API_KEY= -# setup 与未指定账户共同使用的 HTTP、HTTPS 或 SOCKS5 代理 +# HTTP, HTTPS, or SOCKS5 proxy shared by setup and accounts without a dedicated proxy PROXY= -# 单账户初始化超时 +# Per-account initialization timeout INIT_TIMEOUT=2m -# 普通请求最大执行时间 +# Maximum execution time for standard requests REQUEST_TIMEOUT=5m -# 常驻预热账户数 +# Number of resident warmed accounts WARM_WORKER_LIMIT=5 -# 高峰期最多同时运行的 Worker 数 +# Maximum concurrently running workers during peak load MAX_ACTIVE_WORKERS=10 -# 服务启动时同时预热的账户数 +# Concurrently warmed accounts during service startup WARM_STARTUP_CONCURRENCY=2 -# 单账号同时处理的请求数 +# Concurrently processed requests per account PER_ACCOUNT_CONCURRENCY=2 -# 账户选择策略:round-robin 或 fill-first +# Account routing strategy: round-robin or fill-first ROUTING_STRATEGY=round-robin -# WAA 预热是否使用临时对话 +# Whether WAA warmup uses temporary chat TEMPORARY_CHAT=false diff --git a/.gitignore b/.gitignore index 71ca21f..2f82284 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# 构建产物 +# Build artifacts *.exe *.dll *.so @@ -6,20 +6,20 @@ /aistudio2api /dist/ -# 本地配置与运行状态 +# Local configuration and runtime state .env /auth/ /runtime/ *.log -# 前端产物 +# Frontend artifacts /web/node_modules/ /internal/webui/dist/ -# Go 工作区 +# Go workspace go.work -# 编辑器与系统文件 +# Editor and system files .vscode/ .idea/ .DS_Store diff --git a/cmd/aistudio2api/main.go b/cmd/aistudio2api/main.go index c09fe60..dec9bb3 100644 --- a/cmd/aistudio2api/main.go +++ b/cmd/aistudio2api/main.go @@ -6,7 +6,6 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/app" ) -// main 执行 aistudio2api 命令入口 func main() { os.Exit(app.Run(os.Args[1:])) } diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index da6672b..368e60c 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -30,41 +30,41 @@ const ( runtimeLockLimit = 2 * time.Second ) -// AccountState 表示账户当前是否可调度 +// AccountState indicates whether an account is currently schedulable type AccountState string const ( - // AccountReady 表示账户可以接收请求 + // AccountReady indicates that the account can accept requests AccountReady AccountState = "ready" - // AccountBusy 表示账户存在活动请求 + // AccountBusy indicates that the account has active requests AccountBusy AccountState = "busy" - // AccountCooldown 表示账户或模型处于冷却期 + // AccountCooldown indicates that the account or model is in a cooldown period AccountCooldown AccountState = "cooldown" - // AccountAuthRequired 表示账户需要重新登录 + // AccountAuthRequired indicates that the account requires re-authentication AccountAuthRequired AccountState = "auth_required" - // AccountUnavailable 表示账户初始化或运行失败 + // AccountUnavailable indicates that account initialization or operation failed AccountUnavailable AccountState = "unavailable" - // AccountDisabled 表示账户已停用 + // AccountDisabled indicates that the account is disabled AccountDisabled AccountState = "disabled" ) var ( - // ErrInvalidArgument 表示请求参数在发送前已确定无效 - ErrInvalidArgument = errors.New("AI Studio 请求参数无效") - // ErrModelNotFound 表示实时目录中不存在请求模型 - ErrModelNotFound = errors.New("AI Studio 实时目录中没有请求模型") - // ErrNoEligibleAccount 表示没有账户具备请求所需能力 - ErrNoEligibleAccount = errors.New("没有符合条件的 AI Studio 账户") - // ErrAccountNotFound 表示稳定账户 ID 不存在 - ErrAccountNotFound = errors.New("账户不存在") - // ErrAccountLeased 表示账户当前存在进程内或跨进程租约 - ErrAccountLeased = errors.New("账户正在使用") - // ErrResourceNotFound 表示资源没有创建账户映射 - ErrResourceNotFound = errors.New("资源账户映射不存在") + // ErrInvalidArgument indicates that request arguments were determined invalid before sending + ErrInvalidArgument = errors.New("invalid AI Studio request arguments") + // ErrModelNotFound indicates that the requested model does not exist in the live catalog + ErrModelNotFound = errors.New("requested model not found in AI Studio live catalog") + // ErrNoEligibleAccount indicates that no account has the capabilities required for the request + ErrNoEligibleAccount = errors.New("no eligible AI Studio account available") + // ErrAccountNotFound indicates that the stable account ID does not exist + ErrAccountNotFound = errors.New("account not found") + // ErrAccountLeased indicates that the account currently has an in-process or cross-process lease + ErrAccountLeased = errors.New("account is busy") + // ErrResourceNotFound indicates that no account mapping was created for the resource + ErrResourceNotFound = errors.New("resource account mapping not found") errAccountLeaseBusy = ErrAccountLeased ) -// AccountConfig 表示账户目录中的固定最小配置 +// AccountConfig represents the fixed minimal configuration in an account directory type AccountConfig struct { Label string `json:"label"` Enabled bool `json:"enabled"` @@ -73,7 +73,7 @@ type AccountConfig struct { Timezone string `json:"timezone"` } -// ResourceBinding 记录上游资源的创建账户 +// ResourceBinding records the creator account for an upstream resource type ResourceBinding struct { Kind string `json:"kind,omitempty"` Name string `json:"name,omitempty"` @@ -84,7 +84,7 @@ type ResourceBinding struct { Video *VideoResourceMetadata `json:"video,omitempty"` } -// VideoResourceMetadata 保存 OpenAI 视频对象的持久字段 +// VideoResourceMetadata stores persistent fields for an OpenAI video object type VideoResourceMetadata struct { Model string `json:"model"` Seconds string `json:"seconds"` @@ -99,22 +99,22 @@ type accountRuntimeState struct { CatalogFingerprint string `json:"catalog_fingerprint,omitempty"` } -// ModelAccessState 表示账户对单个模型的实测调用资格 +// ModelAccessState represents the tested access qualification of an account for a single model type ModelAccessState string const ( - // ModelAccessVerified 表示账户已成功调用模型 + // ModelAccessVerified indicates that the account has successfully called the model ModelAccessVerified ModelAccessState = "verified" ) -// ModelAccess 保存账户模型资格的实测结果 +// ModelAccess stores the tested result of an account's model access qualification type ModelAccess struct { State ModelAccessState `json:"state"` CheckedAt time.Time `json:"checked_at"` Reason string `json:"reason,omitempty"` } -// Account 表示一个稳定目录对应的 AI Studio 账户 +// Account represents an AI Studio account corresponding to a stable directory type Account struct { ID string `json:"id"` Directory string `json:"-"` @@ -143,7 +143,7 @@ type Account struct { initializedAt time.Time } -// AccountStatus 表示管理界面使用的脱敏账户状态 +// AccountStatus represents the sanitized account status used by the management interface type AccountStatus struct { ID string `json:"id"` Label string `json:"label"` @@ -159,7 +159,7 @@ type AccountStatus struct { Message string `json:"message,omitempty"` } -// AccountSelection 描述账户调度所需的能力或粘性条件 +// AccountSelection describes the capability or stickiness requirements for account scheduling type AccountSelection struct { ModelID string ModelAccessScope string @@ -172,7 +172,7 @@ type AccountSelection struct { const preferredBootstrapModelID = "gemini-flash-latest" -// ModelAccessKey 返回关联真实目录模型的独立资格键 +// ModelAccessKey returns an independent qualification key for associating real catalog models func ModelAccessKey(scope string, modelID string) string { scope = strings.TrimSpace(scope) modelID = strings.TrimPrefix(strings.TrimSpace(modelID), "models/") @@ -182,7 +182,7 @@ func ModelAccessKey(scope string, modelID string) string { return scope + ":" + modelID } -// AccountCandidateGroups 表示 warm 与 standby 账户的实时可调度状态 +// AccountCandidateGroups represents the real-time schedulable status of warm and standby accounts type AccountCandidateGroups struct { WarmReady []string WarmAvailable []string @@ -193,19 +193,19 @@ type AccountCandidateGroups struct { Eligible bool } -// AccountCandidateState 表示账户候选的实时调度指标 +// AccountCandidateState represents real-time scheduling metrics for an account candidate type AccountCandidateState struct { ModelAccess ModelAccessState Active int AvailableSlot int } -// AccountStore 从一个或多个账户文件或目录加载账户 +// AccountStore loads accounts from one or more account files or directories type AccountStore struct { paths []string } -// AccountPool 在账户之间执行能力与并发槽位调度 +// AccountPool performs capability and concurrency slot scheduling across accounts type AccountPool struct { mu sync.Mutex accounts []*Account @@ -217,7 +217,7 @@ type AccountPool struct { changed chan struct{} } -// AccountLease 表示一个账户请求槽位 +// AccountLease represents an account request slot type AccountLease struct { pool *AccountPool account *Account @@ -232,14 +232,14 @@ type AccountLease struct { err error } -// AccountRuntimeLease 保证同一邮箱只有一个 WAA runtime +// AccountRuntimeLease ensures that only one WAA runtime exists for the same email type AccountRuntimeLease struct { lock *flock.Flock once sync.Once err error } -// AccountPublishLease 保护新账户从稳定目录发布到运行时 +// AccountPublishLease protects the publishing of new accounts from stable directories to the runtime type AccountPublishLease struct { account *Account requestLock *flock.Flock @@ -248,7 +248,7 @@ type AccountPublishLease struct { err error } -// DefaultAccountConfig 返回新账户的最小配置 +// DefaultAccountConfig returns the minimal configuration for a new account func DefaultAccountConfig(label string) AccountConfig { return AccountConfig{ Label: strings.TrimSpace(label), @@ -258,7 +258,7 @@ func DefaultAccountConfig(label string) AccountConfig { } } -// NewAccountStore 创建账户目录存储 +// NewAccountStore creates an account directory store func NewAccountStore(paths ...string) *AccountStore { if len(paths) == 0 { paths = []string{"auth"} @@ -273,27 +273,27 @@ func NewAccountStore(paths ...string) *AccountStore { return &AccountStore{paths: cleaned} } -// Load 扫描账户目录并恢复冷却与资源粘性 +// Load scans account directories and restores cooldown and resource stickiness func (s *AccountStore) Load() ([]*Account, error) { if s == nil || len(s.paths) == 0 { - return nil, fmt.Errorf("账户路径为空") + return nil, fmt.Errorf("account path is empty") } directories := make([]string, 0) for _, source := range s.paths { absolute, err := filepath.Abs(source) if err != nil { - return nil, fmt.Errorf("解析账户路径 %q: %w", source, err) + return nil, fmt.Errorf("resolve account path %q: %w", source, err) } info, err := os.Stat(absolute) if os.IsNotExist(err) { continue } if err != nil { - return nil, fmt.Errorf("读取账户路径 %q: %w", source, err) + return nil, fmt.Errorf("read account path %q: %w", source, err) } if !info.IsDir() { if filepath.Base(absolute) != storageStateName { - return nil, fmt.Errorf("账户文件必须命名为 %s", storageStateName) + return nil, fmt.Errorf("account file must be named %s", storageStateName) } directories = append(directories, filepath.Dir(absolute)) continue @@ -304,7 +304,7 @@ func (s *AccountStore) Load() ([]*Account, error) { } entries, err := os.ReadDir(absolute) if err != nil { - return nil, fmt.Errorf("扫描账户目录 %q: %w", source, err) + return nil, fmt.Errorf("scan account directory %q: %w", source, err) } for _, entry := range entries { if !entry.IsDir() { @@ -330,12 +330,12 @@ func (s *AccountStore) Load() ([]*Account, error) { return nil, err } if _, exists := ids[account.ID]; exists { - return nil, fmt.Errorf("账户 ID 重复: %s", account.ID) + return nil, fmt.Errorf("duplicate account ID: %s", account.ID) } ids[account.ID] = struct{}{} for resourceID := range account.runtime.Resources { if owner, exists := resources[resourceID]; exists { - return nil, fmt.Errorf("资源 %s 同时绑定账户 %s 和 %s", resourceID, owner, account.ID) + return nil, fmt.Errorf("resource %s bound to both accounts %s and %s", resourceID, owner, account.ID) } resources[resourceID] = account.ID } @@ -344,10 +344,10 @@ func (s *AccountStore) Load() ([]*Account, error) { return accounts, nil } -// Create 创建并锁定以认证邮箱命名的账户目录 +// Create creates and locks an account directory named after the authenticated email func (s *AccountStore) Create(accountConfig AccountConfig, state StorageState) (*Account, *AccountPublishLease, error) { if s == nil || len(s.paths) != 1 { - return nil, nil, fmt.Errorf("创建账户需要一个账户根目录") + return nil, nil, fmt.Errorf("creating an account requires an account root directory") } if err := accountConfig.Validate(); err != nil { return nil, nil, err @@ -357,10 +357,10 @@ func (s *AccountStore) Create(accountConfig AccountConfig, state StorageState) ( } root, err := filepath.Abs(s.paths[0]) if err != nil { - return nil, nil, fmt.Errorf("解析账户根目录: %w", err) + return nil, nil, fmt.Errorf("resolve account root directory: %w", err) } if err := os.MkdirAll(root, 0o755); err != nil { - return nil, nil, fmt.Errorf("创建账户根目录: %w", err) + return nil, nil, fmt.Errorf("create account root directory: %w", err) } id, err := accountEmailID(accountConfig, state) if err != nil { @@ -369,7 +369,7 @@ func (s *AccountStore) Create(accountConfig AccountConfig, state StorageState) ( accountConfig.Label = id temporary, err := os.MkdirTemp(root, ".account-*.tmp") if err != nil { - return nil, nil, fmt.Errorf("创建临时账户目录: %w", err) + return nil, nil, fmt.Errorf("create temporary account directory: %w", err) } defer os.RemoveAll(temporary) if err := writeAccountConfig(filepath.Join(temporary, accountConfigName), accountConfig); err != nil { @@ -393,7 +393,7 @@ func (s *AccountStore) Create(accountConfig AccountConfig, state StorageState) ( return nil, nil, err } if err := os.Rename(temporary, directory); err != nil { - return nil, nil, errors.Join(fmt.Errorf("保存账户目录: %w", err), publishLease.Release()) + return nil, nil, errors.Join(fmt.Errorf("save account directory: %w", err), publishLease.Release()) } if err := validatePersistentAccountFiles(account); err != nil { return nil, nil, errors.Join(err, os.RemoveAll(directory), publishLease.Release()) @@ -401,41 +401,41 @@ func (s *AccountStore) Create(accountConfig AccountConfig, state StorageState) ( return account, publishLease, nil } -// Delete 删除属于当前存储的稳定账户目录 +// Delete deletes a stable account directory belonging to the current store func (s *AccountStore) Delete(account *Account) error { if account == nil || strings.TrimSpace(account.ID) == "" || strings.TrimSpace(account.Directory) == "" { - return fmt.Errorf("账户未初始化") + return fmt.Errorf("account is not initialized") } directory, err := filepath.Abs(account.Directory) if err != nil { - return fmt.Errorf("解析账户目录: %w", err) + return fmt.Errorf("resolve account directory: %w", err) } if filepath.Base(directory) != account.ID { - return fmt.Errorf("账户目录与稳定 ID 不匹配") + return fmt.Errorf("account directory does not match stable ID") } owned, err := s.ownsDirectory(directory) if err != nil { return err } if !owned { - return fmt.Errorf("账户目录不属于当前 AccountStore: %s", directory) + return fmt.Errorf("account directory does not belong to current AccountStore: %s", directory) } info, err := os.Stat(directory) if err != nil { if os.IsNotExist(err) { return nil } - return fmt.Errorf("读取账户目录: %w", err) + return fmt.Errorf("read account directory: %w", err) } if !info.IsDir() { - return fmt.Errorf("账户路径不是目录: %s", directory) + return fmt.Errorf("account path is not a directory: %s", directory) } account.storageMu.Lock() lockedByPool := account.persistenceLocked account.storageMu.Unlock() if lockedByPool { if err := os.RemoveAll(directory); err != nil { - return fmt.Errorf("删除账户目录: %w", err) + return fmt.Errorf("delete account directory: %w", err) } return nil } @@ -464,26 +464,26 @@ func (s *AccountStore) Delete(account *Account) error { deleteErr = errors.Join(deleteErr, leaseLock.Unlock()) } if deleteErr != nil { - return fmt.Errorf("删除账户目录: %w", deleteErr) + return fmt.Errorf("delete account directory: %w", deleteErr) } return nil } func (s *AccountStore) ownsDirectory(directory string) (bool, error) { if s == nil || len(s.paths) == 0 { - return false, fmt.Errorf("账户路径为空") + return false, fmt.Errorf("account path is empty") } for _, source := range s.paths { absolute, err := filepath.Abs(source) if err != nil { - return false, fmt.Errorf("解析账户路径 %q: %w", source, err) + return false, fmt.Errorf("resolve account path %q: %w", source, err) } info, err := os.Stat(absolute) if os.IsNotExist(err) { continue } if err != nil { - return false, fmt.Errorf("读取账户路径 %q: %w", source, err) + return false, fmt.Errorf("read account path %q: %w", source, err) } root := absolute if !info.IsDir() { @@ -491,7 +491,7 @@ func (s *AccountStore) ownsDirectory(directory string) (bool, error) { } relative, err := filepath.Rel(root, directory) if err != nil { - return false, fmt.Errorf("比较账户路径 %q: %w", source, err) + return false, fmt.Errorf("compare account path %q: %w", source, err) } if relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { return true, nil @@ -500,24 +500,24 @@ func (s *AccountStore) ownsDirectory(directory string) (bool, error) { return false, nil } -// Validate 校验账户固定配置 +// Validate validates the fixed configuration of an account func (c AccountConfig) Validate() error { if _, err := normalizeAccountEmail(c.Label); err != nil { return err } if strings.TrimSpace(c.Locale) == "" { - return fmt.Errorf("账户 locale 不能为空") + return fmt.Errorf("account locale cannot be empty") } if strings.TrimSpace(c.Timezone) == "" { - return fmt.Errorf("账户 timezone 不能为空") + return fmt.Errorf("account timezone cannot be empty") } if err := appconfig.ValidateProxy(c.Proxy); err != nil { - return fmt.Errorf("账户 proxy 无效: %w", err) + return fmt.Errorf("account proxy is invalid: %w", err) } return nil } -// EffectiveProxy 返回账户固定代理或全局代理 +// EffectiveProxy returns the account fixed proxy or global proxy func (a *Account) EffectiveProxy(globalProxy string) string { if a != nil && strings.TrimSpace(a.Config.Proxy) != "" { return strings.TrimSpace(a.Config.Proxy) @@ -525,7 +525,7 @@ func (a *Account) EffectiveProxy(globalProxy string) string { return strings.TrimSpace(globalProxy) } -// AcceptLanguage 返回账户 locale 对应的请求语言头 +// AcceptLanguage returns the request language header corresponding to the account locale func (a *Account) AcceptLanguage() string { if a == nil { return "" @@ -538,7 +538,7 @@ func (a *Account) AcceptLanguage() string { return locale + "," + strings.ToLower(language) + ";q=0.9" } -// SupportsModel 判断账户实时目录是否包含模型 +// SupportsModel checks whether the account's live catalog contains the model func (a *Account) SupportsModel(modelID string) bool { modelID = strings.TrimPrefix(strings.TrimSpace(modelID), "models/") if modelID == "" { @@ -552,7 +552,7 @@ func (a *Account) SupportsModel(modelID string) bool { return false } -// SupportsMethod 判断账户模型是否声明目标方法 +// SupportsMethod checks whether the account model declares the target method func (a *Account) SupportsMethod(modelID string, method string) bool { if strings.TrimSpace(method) == "" { return a.SupportsModel(modelID) @@ -606,7 +606,7 @@ func modelMatchesID(model Model, modelID string) bool { return false } -// NewAccountPool 创建账户独占调度池 +// NewAccountPool creates an exclusive account scheduling pool func NewAccountPool(accounts []*Account, perAccountConcurrency int) *AccountPool { p := &AccountPool{ accounts: append([]*Account(nil), accounts...), byID: make(map[string]*Account, len(accounts)), @@ -634,7 +634,7 @@ func NewAccountPool(accounts []*Account, perAccountConcurrency int) *AccountPool return p } -// Account 返回稳定 ID 对应的账户 +// Account returns the account corresponding to the stable ID func (p *AccountPool) Account(accountID string) (*Account, error) { if p == nil { return nil, ErrAccountNotFound @@ -648,10 +648,10 @@ func (p *AccountPool) Account(accountID string) (*Account, error) { return account, nil } -// Add 将新账户加入当前调度池 +// Add adds a new account to the current scheduling pool func (p *AccountPool) Add(account *Account) (resultErr error) { if p == nil || account == nil || strings.TrimSpace(account.ID) == "" { - return fmt.Errorf("账户未初始化") + return fmt.Errorf("account is not initialized") } if account.ConfigPath != "" { account.storageMu.Lock() @@ -695,7 +695,7 @@ func (p *AccountPool) Add(account *Account) (resultErr error) { p.mu.Lock() defer p.mu.Unlock() if _, exists := p.byID[account.ID]; exists { - return fmt.Errorf("账户已存在: %s", account.ID) + return fmt.Errorf("account already exists: %s", account.ID) } if account.runtime.Cooldowns == nil { account.runtime.Cooldowns = make(map[string]CooldownState) @@ -708,7 +708,7 @@ func (p *AccountPool) Add(account *Account) (resultErr error) { } for resourceID := range account.runtime.Resources { if owner, exists := p.resources[resourceID]; exists { - return fmt.Errorf("资源 %s 已绑定账户 %s", resourceID, owner) + return fmt.Errorf("resource %s is already bound to account %s", resourceID, owner) } } p.accounts = append(p.accounts, account) @@ -720,7 +720,7 @@ func (p *AccountPool) Add(account *Account) (resultErr error) { return nil } -// Remove 在账户空闲时删除持久目录并移出调度池 +// Remove deletes the persistent directory and removes the account from the scheduling pool when idle func (p *AccountPool) Remove(accountID string, deleteDirectory func(*Account) error) (*Account, error) { if p == nil { return nil, ErrAccountNotFound @@ -738,7 +738,7 @@ func (p *AccountPool) Remove(accountID string, deleteDirectory func(*Account) er } if deleteDirectory == nil { p.mu.Unlock() - return nil, fmt.Errorf("账户目录删除函数为空") + return nil, fmt.Errorf("account directory delete function is nil") } account.exclusive = true p.notifyLocked() @@ -807,12 +807,12 @@ func (p *AccountPool) Remove(accountID string, deleteDirectory func(*Account) er return account, releaseErr } -// Acquire 为模型轮询获取一个账户槽位 +// Acquire acquires an account slot for model round-robin func (p *AccountPool) Acquire(ctx context.Context, model string) (*AccountLease, error) { return p.AcquireFor(ctx, AccountSelection{ModelID: model}) } -// AcquireAccount 为管理操作获取不受调度状态限制的指定账户租约 +// AcquireAccount acquires a lease for a specified account unrestricted by scheduling state for management operations func (p *AccountPool) AcquireAccount(ctx context.Context, accountID string) (*AccountLease, error) { if p == nil { return nil, ErrAccountNotFound @@ -873,7 +873,7 @@ func (p *AccountPool) AcquireAccount(ctx context.Context, accountID string) (*Ac } } -// AcquireFor 按模型方法账户或资源粘性获取账户槽位 +// AcquireFor acquires an account slot by model, method, account, or resource stickiness func (p *AccountPool) AcquireFor(ctx context.Context, selection AccountSelection) (*AccountLease, error) { if p == nil { return nil, ErrNoEligibleAccount @@ -962,7 +962,7 @@ func (p *AccountPool) AcquireFor(ctx context.Context, selection AccountSelection } } -// TryAcquireFor 尝试获取账户槽位并立即返回当前结果 +// TryAcquireFor attempts to acquire an account slot and returns the current result immediately func (p *AccountPool) TryAcquireFor(ctx context.Context, selection AccountSelection) (*AccountLease, bool, error) { if p == nil { return nil, false, ErrNoEligibleAccount @@ -1082,20 +1082,20 @@ func (p *AccountPool) validateSelectionLocked(selection AccountSelection) error return fmt.Errorf("%w: %s", ErrModelNotFound, modelID) } if selection.Method != "" && !p.hasModelMethodLocked(modelID, selection.Method) { - return fmt.Errorf("%w: 模型 %s 不支持 %s", ErrModelNotFound, modelID, selection.Method) + return fmt.Errorf("%w: model %s does not support %s", ErrModelNotFound, modelID, selection.Method) } if selection.Capability != "" && !p.hasModelCapabilityLocked(modelID, selection.Capability) { - return fmt.Errorf("%w: 模型 %s 不支持 %s", ErrModelNotFound, modelID, selection.Capability) + return fmt.Errorf("%w: model %s does not support %s", ErrModelNotFound, modelID, selection.Capability) } return nil } -// AcquireResource 获取创建资源的固定账户 +// AcquireResource retrieves the fixed account that created the resource func (p *AccountPool) AcquireResource(ctx context.Context, resourceID string) (*AccountLease, error) { return p.AcquireFor(ctx, AccountSelection{ResourceID: resourceID}) } -// Account 返回当前租约持有的账户 +// Account returns the account held by the current lease func (l *AccountLease) Account() *Account { if l == nil { return nil @@ -1103,7 +1103,7 @@ func (l *AccountLease) Account() *Account { return l.account } -// ModelAccessGeneration 返回租约开始时的模型资格目录代际 +// ModelAccessGeneration returns the model access catalog generation at the start of the lease func (l *AccountLease) ModelAccessGeneration() uint64 { if l == nil { return 0 @@ -1111,7 +1111,7 @@ func (l *AccountLease) ModelAccessGeneration() uint64 { return l.modelAccessGeneration } -// CheckedAt 返回当前账户请求取得租约的时间 +// CheckedAt returns the time when the lease was acquired for the current account request func (l *AccountLease) CheckedAt() time.Time { if l == nil { return time.Time{} @@ -1119,30 +1119,30 @@ func (l *AccountLease) CheckedAt() time.Time { return l.checkedAt } -// MarkAuthenticationValid 保存当前租约确认的认证成功状态 +// MarkAuthenticationValid records the authenticated success state confirmed by the current lease func (l *AccountLease) MarkAuthenticationValid() error { return l.markAuthenticationStateAt(false, "", l.CheckedAt()) } -// MarkAuthenticationRequired 保存当前租约确认的认证失败状态 +// MarkAuthenticationRequired records the authentication failure state confirmed by the current lease func (l *AccountLease) MarkAuthenticationRequired(reason string) error { return l.markAuthenticationStateAt(true, reason, l.CheckedAt()) } -// markAuthenticationValidAt 保存长连接中指定轮次的认证成功状态 +// markAuthenticationValidAt records the authentication success state for a specific round in a persistent connection func (l *AccountLease) markAuthenticationValidAt(checkedAt time.Time) error { return l.markAuthenticationStateAt(false, "", checkedAt) } -// markAuthenticationRequiredAt 保存长连接中指定轮次的认证失败状态 +// markAuthenticationRequiredAt records the authentication failure state for a specific round in a persistent connection func (l *AccountLease) markAuthenticationRequiredAt(reason string, checkedAt time.Time) error { return l.markAuthenticationStateAt(true, reason, checkedAt) } -// markAuthenticationStateAt 写回指定顺序时间的账户认证状态 +// markAuthenticationStateAt writes back the account authentication state for the specified sequential time func (l *AccountLease) markAuthenticationStateAt(required bool, reason string, checkedAt time.Time) error { if l == nil || l.account == nil || l.pool == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } l.operation.Lock() authGeneration := l.authGeneration @@ -1173,7 +1173,7 @@ func (l *AccountLease) markAuthenticationStateAt(required bool, reason string, c return nil } -// ModelAccessGeneration 返回账户当前模型资格目录代际 +// ModelAccessGeneration returns the current model access catalog generation of the account func (p *AccountPool) ModelAccessGeneration(accountID string) uint64 { if p == nil { return 0 @@ -1187,15 +1187,15 @@ func (p *AccountPool) ModelAccessGeneration(accountID string) uint64 { return account.modelAccessGeneration } -// SaveStorageState 在租约内原子写回认证状态 +// SaveStorageState atomically writes back the storage state within the lease func (l *AccountLease) SaveStorageState(state StorageState) error { if l == nil || l.account == nil || l.pool == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return fmt.Errorf("账户租约已释放") + return fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() @@ -1208,18 +1208,18 @@ func (l *AccountLease) SaveStorageState(state StorageState) error { return nil } -// RefreshStorageState 保证并发认证失效只提交一次 +// RefreshStorageState ensures concurrent auth invalidations are committed only once func (l *AccountLease) RefreshStorageState( update func(*StorageState) error, prepareCommit func() (func(bool), error), ) error { if l == nil || l.account == nil || l.pool == nil || update == nil || prepareCommit == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return fmt.Errorf("账户租约已释放") + return fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() @@ -1255,7 +1255,7 @@ func (l *AccountLease) RefreshStorageState( return nil } -// BeginAuthRefresh 为当前账户取得认证刷新独占窗口 +// BeginAuthRefresh acquires an exclusive auth refresh window for the current account func (l *AccountLease) BeginAuthRefresh() (func(), bool) { if l == nil || l.account == nil || l.pool == nil { return nil, false @@ -1291,15 +1291,15 @@ func (l *AccountLease) BeginAuthRefresh() (func(), bool) { }, true } -// SaveConfig 在租约内原子写回账户固定配置 +// SaveConfig atomically writes back the fixed account configuration within the lease func (l *AccountLease) SaveConfig(value AccountConfig) error { if l == nil || l.account == nil || l.pool == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return fmt.Errorf("账户租约已释放") + return fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() @@ -1321,15 +1321,15 @@ func (l *AccountLease) SaveConfig(value AccountConfig) error { return nil } -// ReloadStorageState 在租约内重新读取认证状态 +// ReloadStorageState reloads the storage state within the lease func (l *AccountLease) ReloadStorageState() (StorageState, error) { if l == nil || l.account == nil || l.pool == nil { - return StorageState{}, fmt.Errorf("账户租约未初始化") + return StorageState{}, fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return StorageState{}, fmt.Errorf("账户租约已释放") + return StorageState{}, fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() @@ -1343,10 +1343,10 @@ func (l *AccountLease) ReloadStorageState() (StorageState, error) { return state, nil } -// ReplaceCookies 以固定指纹浏览器当前 Cookie 替换账户最新持久状态 +// ReplaceCookies replaces the account's latest persistent state with the fixed-fingerprint browser's current cookies func (l *AccountLease) ReplaceCookies(cookies []StateCookie) error { if l == nil || l.account == nil || l.pool == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } if len(cookies) == 0 { return nil @@ -1354,7 +1354,7 @@ func (l *AccountLease) ReplaceCookies(cookies []StateCookie) error { l.operation.Lock() defer l.operation.Unlock() if l.released { - return fmt.Errorf("账户租约已释放") + return fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() @@ -1372,44 +1372,44 @@ func (l *AccountLease) ReplaceCookies(cookies []StateCookie) error { return nil } -// BindResource 将资源固定到当前租约账户 +// BindResource binds a resource to the current lease account func (l *AccountLease) BindResource(resourceID string, kind string) error { if l == nil || l.account == nil || l.pool == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return fmt.Errorf("账户租约已释放") + return fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() return l.pool.BindResourceKind(resourceID, l.account.ID, kind) } -// BindVideoOperation 保存视频任务账户与公开对象元数据 +// BindVideoOperation saves video operation account and public object metadata func (l *AccountLease) BindVideoOperation( ctx context.Context, resourceID string, metadata VideoResourceMetadata, ) (ResourceBinding, error) { if l == nil || l.account == nil || l.pool == nil { - return ResourceBinding{}, fmt.Errorf("账户租约未初始化") + return ResourceBinding{}, fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return ResourceBinding{}, fmt.Errorf("账户租约已释放") + return ResourceBinding{}, fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() return l.pool.bindVideoOperation(ctx, resourceID, l.account.ID, metadata) } -// VideoOperationBinding 返回当前账户持有的视频任务元数据 +// VideoOperationBinding returns video operation metadata held by the current account func (l *AccountLease) VideoOperationBinding(resourceID string) (ResourceBinding, error) { if l == nil || l.account == nil || l.pool == nil { - return ResourceBinding{}, fmt.Errorf("账户租约未初始化") + return ResourceBinding{}, fmt.Errorf("account lease is not initialized") } resourceID = strings.TrimSpace(resourceID) l.pool.mu.Lock() @@ -1426,30 +1426,30 @@ func (l *AccountLease) VideoOperationBinding(resourceID string) (ResourceBinding return binding, nil } -// ReplaceResource 原子替换当前租约账户的单个资源绑定 +// ReplaceResource atomically replaces a single resource binding of the current lease account func (l *AccountLease) ReplaceResource(previousResourceID string, resourceID string, kind string) error { if l == nil || l.account == nil || l.pool == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return fmt.Errorf("账户租约已释放") + return fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() return l.pool.replaceResource(previousResourceID, resourceID, l.account.ID, kind) } -// MergeSetCookieHeaders 将响应 Cookie 合并到账户最新持久状态 +// MergeSetCookieHeaders merges response cookies into the account's latest persistent state func (l *AccountLease) MergeSetCookieHeaders(headers []string, requestURL string, now time.Time) error { if l == nil || l.account == nil || l.pool == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return fmt.Errorf("账户租约已释放") + return fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() @@ -1469,7 +1469,7 @@ func (l *AccountLease) MergeSetCookieHeaders(headers []string, requestURL string return nil } -// Release 释放账户文件和进程内租约 +// Release releases account file and in-process leases func (l *AccountLease) Release() error { if l == nil { return nil @@ -1497,7 +1497,7 @@ func (l *AccountLease) Release() error { return l.err } -// SetCatalog 替换账户的权益等级与实时模型目录 +// SetCatalog replaces the benefit tier and live model catalog of the account func (p *AccountPool) SetCatalog(accountID string, tier BenefitTier, models []Model) error { fingerprint, err := accountCatalogFingerprint(tier, models) if err != nil { @@ -1543,7 +1543,7 @@ func (p *AccountPool) SetCatalog(accountID string, tier BenefitTier, models []Mo return nil } -// MarkModelAccessVerifiedIfGeneration 保存当前目录代际中的模型生成成功记录 +// MarkModelAccessVerifiedIfGeneration records successful model generation in the current catalog generation func (p *AccountPool) MarkModelAccessVerifiedIfGeneration( accountID string, modelID string, @@ -1553,7 +1553,7 @@ func (p *AccountPool) MarkModelAccessVerifiedIfGeneration( return p.markModelAccessVerified(accountID, modelID, generation, checkedAt) } -// ForgetModelAccessVerifiedIfGeneration 删除当前目录代际中过期的模型成功记录 +// ForgetModelAccessVerifiedIfGeneration deletes expired model success records in the current catalog generation func (p *AccountPool) ForgetModelAccessVerifiedIfGeneration( accountID string, modelID string, @@ -1562,11 +1562,11 @@ func (p *AccountPool) ForgetModelAccessVerifiedIfGeneration( ) (bool, error) { modelID = strings.TrimPrefix(strings.TrimSpace(modelID), "models/") if modelID == "" { - return false, fmt.Errorf("模型 ID 不能为空") + return false, fmt.Errorf("model ID cannot be empty") } checkedAt = checkedAt.UTC() if checkedAt.IsZero() { - return false, fmt.Errorf("模型资格检查时间不能为空") + return false, fmt.Errorf("model access check time cannot be zero") } forgotten := false _, err := p.updateRuntime(accountID, func(account *Account, runtimeState *accountRuntimeState) (bool, func(*Account), error) { @@ -1593,17 +1593,17 @@ func (p *AccountPool) markModelAccessVerified( ) (bool, error) { modelID = strings.TrimPrefix(strings.TrimSpace(modelID), "models/") if modelID == "" { - return false, fmt.Errorf("模型 ID 不能为空") + return false, fmt.Errorf("model ID cannot be empty") } checkedAt = checkedAt.UTC() if checkedAt.IsZero() { - return false, fmt.Errorf("模型资格检查时间不能为空") + return false, fmt.Errorf("model access check time cannot be zero") } p.mu.Lock() account := p.byID[accountID] if account == nil { p.mu.Unlock() - return false, fmt.Errorf("账户不存在: %s", accountID) + return false, fmt.Errorf("account not found: %s", accountID) } canonicalModelID := canonicalAccountModelID(account, modelID) current := account.runtime.ModelAccess[canonicalModelID] @@ -1658,7 +1658,7 @@ func (p *AccountPool) markModelAccessVerified( return changed, nil } -// ResetModelAccess 清空账户的实测模型资格 +// ResetModelAccess clears tested model qualifications of the account func (p *AccountPool) ResetModelAccess(accountID string) error { _, err := p.updateRuntime(accountID, func(account *Account, runtimeState *accountRuntimeState) (bool, func(*Account), error) { if len(runtimeState.ModelAccess) == 0 { @@ -1673,12 +1673,12 @@ func (p *AccountPool) ResetModelAccess(accountID string) error { return nil } -// CandidateStates 返回候选账户的权益与实时负载 +// CandidateStates returns the benefit tier and real-time load of candidate accounts func (p *AccountPool) CandidateStates(accountIDs []string, modelID string) map[string]AccountCandidateState { return p.CandidateStatesForScope(accountIDs, modelID, "") } -// CandidateStatesForScope 返回指定资格范围的候选账户状态 +// CandidateStatesForScope returns candidate account states for the specified qualification scope func (p *AccountPool) CandidateStatesForScope( accountIDs []string, modelID string, @@ -1706,7 +1706,7 @@ func (p *AccountPool) CandidateStatesForScope( return result } -// PreferWarmPool 按权益分层轮选初始热账户 +// PreferWarmPool round-robins initial warm accounts stratified by benefit tier func (p *AccountPool) PreferWarmPool(accountIDs []string) []string { type warmCandidate struct { id string @@ -1757,7 +1757,7 @@ func (p *AccountPool) PreferWarmPool(accountIDs []string) []string { return result } -// MarkCooldownIfGeneration 保存当前目录代际中的作用域冷却 +// MarkCooldownIfGeneration records scope cooldown in the current catalog generation func (p *AccountPool) MarkCooldownIfGeneration( accountID string, modelAccessScope string, @@ -1767,11 +1767,11 @@ func (p *AccountPool) MarkCooldownIfGeneration( reason string, ) error { if !until.After(time.Now()) { - return fmt.Errorf("冷却期限必须在未来") + return fmt.Errorf("cooldown expiration must be in the future") } checkedAt = checkedAt.UTC() if checkedAt.IsZero() { - return fmt.Errorf("冷却检查时间不能为空") + return fmt.Errorf("cooldown check time cannot be zero") } modelAccessScope = strings.TrimSpace(modelAccessScope) if modelAccessScope == "" { @@ -1795,7 +1795,7 @@ func (p *AccountPool) MarkCooldownIfGeneration( return err } -// ClearCooldownIfGeneration 清除当前目录代际中的作用域冷却 +// ClearCooldownIfGeneration clears scope cooldown in the current catalog generation func (p *AccountPool) ClearCooldownIfGeneration( accountID string, modelAccessScope string, @@ -1804,7 +1804,7 @@ func (p *AccountPool) ClearCooldownIfGeneration( ) error { checkedAt = checkedAt.UTC() if checkedAt.IsZero() { - return fmt.Errorf("冷却检查时间不能为空") + return fmt.Errorf("cooldown check time cannot be zero") } modelAccessScope = strings.TrimSpace(modelAccessScope) if modelAccessScope == "" { @@ -1843,20 +1843,20 @@ func (p *AccountPool) ClearCooldownIfGeneration( return err } -// BindResource 将资源 ID 固定到创建账户 +// BindResource binds a resource ID to the creator account func (p *AccountPool) BindResource(resourceID string, accountID string) error { return p.BindResourceKind(resourceID, accountID, "") } -// BindResourceKind 将带类型的资源 ID 固定到创建账户 +// BindResourceKind binds a typed resource ID to the creator account func (p *AccountPool) BindResourceKind(resourceID string, accountID string, kind string) error { resourceID = strings.TrimSpace(resourceID) if resourceID == "" { - return fmt.Errorf("资源 ID 不能为空") + return fmt.Errorf("resource ID cannot be empty") } _, err := p.updateRuntime(accountID, func(_ *Account, runtimeState *accountRuntimeState) (bool, func(*Account), error) { if owner, exists := p.resources[resourceID]; exists && owner != accountID { - return false, nil, fmt.Errorf("资源 %s 已绑定账户 %s", resourceID, owner) + return false, nil, fmt.Errorf("resource %s is already bound to account %s", resourceID, owner) } if _, exists := runtimeState.Resources[resourceID]; exists { return false, nil, nil @@ -1881,16 +1881,16 @@ func (p *AccountPool) bindVideoOperation( metadata.Seconds = strings.TrimSpace(metadata.Seconds) metadata.Size = strings.TrimSpace(metadata.Size) if resourceID == "" || metadata.Model == "" || metadata.Seconds == "" || metadata.Size == "" { - return ResourceBinding{}, fmt.Errorf("视频任务元数据不完整") + return ResourceBinding{}, fmt.Errorf("video operation metadata is incomplete") } var bound ResourceBinding _, err := p.updateRuntimeContext(ctx, accountID, func(_ *Account, runtimeState *accountRuntimeState) (bool, func(*Account), error) { if owner, exists := p.resources[resourceID]; exists && owner != accountID { - return false, nil, fmt.Errorf("资源 %s 已绑定账户 %s", resourceID, owner) + return false, nil, fmt.Errorf("resource %s is already bound to account %s", resourceID, owner) } if existing, exists := runtimeState.Resources[resourceID]; exists { if existing.Kind != "video-operation" || existing.Video == nil { - return false, nil, fmt.Errorf("资源 %s 不是视频任务", resourceID) + return false, nil, fmt.Errorf("resource %s is not a video operation", resourceID) } bound = existing return false, nil, nil @@ -1912,7 +1912,7 @@ func (p *AccountPool) replaceResource(previousResourceID string, resourceID stri resourceID = strings.TrimSpace(resourceID) kind = strings.TrimSpace(kind) if resourceID == "" { - return fmt.Errorf("资源 ID 不能为空") + return fmt.Errorf("resource ID cannot be empty") } _, err := p.updateRuntime(accountID, func(_ *Account, runtimeState *accountRuntimeState) (bool, func(*Account), error) { for _, candidate := range []string{previousResourceID, resourceID} { @@ -1920,7 +1920,7 @@ func (p *AccountPool) replaceResource(previousResourceID string, resourceID stri continue } if owner, exists := p.resources[candidate]; exists && owner != accountID { - return false, nil, fmt.Errorf("资源 %s 已绑定账户 %s", candidate, owner) + return false, nil, fmt.Errorf("resource %s is already bound to account %s", candidate, owner) } } changed := false @@ -1948,7 +1948,7 @@ func (p *AccountPool) replaceResource(previousResourceID string, resourceID stri return nil } -// UnbindResource 删除终态资源的账户映射 +// UnbindResource deletes account mapping for terminal resources func (p *AccountPool) UnbindResource(resourceID string) error { return p.unbindResourceContext(context.Background(), resourceID) } @@ -1981,22 +1981,22 @@ func (p *AccountPool) unbindResourceContext(ctx context.Context, resourceID stri return nil } -// MarkAuthRequired 将账户标记为需要重新登录 +// MarkAuthRequired marks an account as requiring re-authentication func (p *AccountPool) MarkAuthRequired(accountID string, reason string) error { return p.setAccountState(accountID, AccountAuthRequired, reason) } -// MarkUnavailable 将账户标记为初始化或运行失败 +// MarkUnavailable marks an account as failed during initialization or runtime func (p *AccountPool) MarkUnavailable(accountID string, reason string) error { return p.setAccountState(accountID, AccountUnavailable, reason) } -// MarkReady 将账户恢复为可调度状态 +// MarkReady restores an account to schedulable state func (p *AccountPool) MarkReady(accountID string) error { return p.setAccountState(accountID, AccountReady, "") } -// Status 返回账户池的脱敏状态 +// Status returns the sanitized status of the account pool func (p *AccountPool) Status() []AccountStatus { if p == nil { return nil @@ -2044,7 +2044,7 @@ func (p *AccountPool) Status() []AccountStatus { return statuses } -// ClassifyCandidates 按 warm 集合分类目标请求的候选账户 +// ClassifyCandidates classifies candidate accounts for the target request according to the warm pool func (p *AccountPool) ClassifyCandidates( ctx context.Context, selection AccountSelection, @@ -2093,7 +2093,7 @@ func (p *AccountPool) classifyCandidatesLocked( return AccountCandidateGroups{}, fmt.Errorf("%w: %s", ErrModelNotFound, modelID) } if selection.Method != "" && !p.hasModelMethodLocked(modelID, selection.Method) { - return AccountCandidateGroups{}, fmt.Errorf("%w: 模型 %s 不支持 %s", ErrModelNotFound, modelID, selection.Method) + return AccountCandidateGroups{}, fmt.Errorf("%w: model %s does not support %s", ErrModelNotFound, modelID, selection.Method) } } indices, err := p.selectionIndicesLocked(selection) @@ -2246,34 +2246,34 @@ func (p *AccountPool) hasModelCapabilityLocked(modelID string, capability string return false } -// BootstrapModels 返回账户实时目录中的 WAA 初始化模型 +// BootstrapModels returns WAA initialization models from the account's live catalog func (p *AccountPool) BootstrapModels(accountID string) ([]string, error) { p.mu.Lock() defer p.mu.Unlock() account := p.byID[strings.TrimSpace(accountID)] if account == nil { - return nil, fmt.Errorf("账户不存在: %s", accountID) + return nil, fmt.Errorf("account not found: %s", accountID) } models := accountBootstrapModels(account) if len(models) == 0 { - return nil, fmt.Errorf("账户 %s 的实时目录没有可用 WAA 初始化模型", account.ID) + return nil, fmt.Errorf("no usable WAA bootstrap model found in live catalog for account %s", account.ID) } return models, nil } -// BootstrapModel 返回账户使用的通用 WAA 初始化模型 +// BootstrapModel returns the general WAA bootstrap model used by the account func (p *AccountPool) BootstrapModel(accountID string) (string, error) { p.mu.Lock() defer p.mu.Unlock() account := p.byID[strings.TrimSpace(accountID)] if account == nil { - return "", fmt.Errorf("账户不存在: %s", accountID) + return "", fmt.Errorf("account not found: %s", accountID) } models := accountBootstrapModels(account) if len(models) > 0 { return models[0], nil } - return "", fmt.Errorf("账户 %s 的实时目录没有可用 WAA 初始化模型", account.ID) + return "", fmt.Errorf("no usable WAA bootstrap model found in live catalog for account %s", account.ID) } func accountBootstrapModels(account *Account) []string { @@ -2321,7 +2321,7 @@ func (p *AccountPool) selectionIndicesLocked(selection AccountSelection) ([]int, return nil, ErrResourceNotFound } if accountID != "" && accountID != owner { - return nil, fmt.Errorf("资源 %s 绑定账户 %s", selection.ResourceID, owner) + return nil, fmt.Errorf("resource %s is bound to account %s", selection.ResourceID, owner) } accountID = owner } @@ -2361,14 +2361,14 @@ func (p *AccountPool) selectionIndicesLocked(selection AccountSelection) ([]int, return indices, nil } -// SetRoutingStrategy 设置账户轮询或优先填满策略 +// SetRoutingStrategy sets the account round-robin or fill-first routing strategy func (p *AccountPool) SetRoutingStrategy(strategy string) { p.mu.Lock() p.routingStrategy = strategy p.mu.Unlock() } -// OrderCandidates 按当前策略排列候选账户而不推进轮询位置 +// OrderCandidates orders candidate accounts according to the current strategy without advancing the round-robin position func (p *AccountPool) OrderCandidates(accountIDs []string, modelAccessScope string) []string { if len(accountIDs) == 0 { return nil @@ -2388,7 +2388,7 @@ func (p *AccountPool) setAccountState(accountID string, state AccountState, reas defer p.mu.Unlock() account := p.byID[accountID] if account == nil { - return fmt.Errorf("账户不存在: %s", accountID) + return fmt.Errorf("account not found: %s", accountID) } if !account.Config.Enabled { account.State = AccountDisabled @@ -2408,26 +2408,26 @@ func (p *AccountPool) notifyLocked() { func loadAccount(directory string) (*Account, error) { directory, err := filepath.Abs(directory) if err != nil { - return nil, fmt.Errorf("解析账户目录: %w", err) + return nil, fmt.Errorf("resolve account directory: %w", err) } id := filepath.Base(directory) if id == "." || id == string(filepath.Separator) || strings.TrimSpace(id) == "" { - return nil, fmt.Errorf("账户目录缺少稳定 ID") + return nil, fmt.Errorf("account directory missing stable ID") } configPath := filepath.Join(directory, accountConfigName) storagePath := filepath.Join(directory, storageStateName) accountConfig, err := readAccountConfig(configPath) if err != nil { - return nil, fmt.Errorf("账户 %s: %w", id, err) + return nil, fmt.Errorf("account %s: %w", id, err) } state, err := LoadStorageState(storagePath) if err != nil { - return nil, fmt.Errorf("账户 %s: %w", id, err) + return nil, fmt.Errorf("account %s: %w", id, err) } runtimePath := filepath.Join(directory, runtimeStateName) runtimeState, err := readRuntime(runtimePath) if err != nil { - return nil, fmt.Errorf("账户 %s: %w", id, err) + return nil, fmt.Errorf("account %s: %w", id, err) } return &Account{ ID: id, @@ -2459,17 +2459,17 @@ func initialAccountState(accountConfig AccountConfig, state StorageState) Accoun func readAccountConfig(filePath string) (AccountConfig, error) { file, err := os.Open(filePath) if err != nil { - return AccountConfig{}, fmt.Errorf("读取 %s: %w", accountConfigName, err) + return AccountConfig{}, fmt.Errorf("read %s: %w", accountConfigName, err) } defer file.Close() decoder := json.NewDecoder(file) decoder.DisallowUnknownFields() var value AccountConfig if err := decoder.Decode(&value); err != nil { - return AccountConfig{}, fmt.Errorf("解析 %s: %w", accountConfigName, err) + return AccountConfig{}, fmt.Errorf("parse %s: %w", accountConfigName, err) } if err := ensureJSONEnd(decoder); err != nil { - return AccountConfig{}, fmt.Errorf("解析 %s: %w", accountConfigName, err) + return AccountConfig{}, fmt.Errorf("parse %s: %w", accountConfigName, err) } if err := value.Validate(); err != nil { return AccountConfig{}, err @@ -2483,7 +2483,7 @@ func writeAccountConfig(filePath string, value AccountConfig) error { } data, err := json.MarshalIndent(value, "", " ") if err != nil { - return fmt.Errorf("编码 %s: %w", accountConfigName, err) + return fmt.Errorf("encode %s: %w", accountConfigName, err) } return atomicWriteFile(filePath, append(data, '\n'), 0o600) } @@ -2499,16 +2499,16 @@ func readRuntime(filePath string) (accountRuntimeState, error) { return value, nil } if err != nil { - return accountRuntimeState{}, fmt.Errorf("读取 %s: %w", runtimeStateName, err) + return accountRuntimeState{}, fmt.Errorf("read %s: %w", runtimeStateName, err) } defer file.Close() decoder := json.NewDecoder(file) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { - return accountRuntimeState{}, fmt.Errorf("解析 %s: %w", runtimeStateName, err) + return accountRuntimeState{}, fmt.Errorf("parse %s: %w", runtimeStateName, err) } if err := ensureJSONEnd(decoder); err != nil { - return accountRuntimeState{}, fmt.Errorf("解析 %s: %w", runtimeStateName, err) + return accountRuntimeState{}, fmt.Errorf("parse %s: %w", runtimeStateName, err) } if value.Cooldowns == nil { value.Cooldowns = make(map[string]CooldownState) @@ -2528,7 +2528,7 @@ func writeRuntime(filePath string, value accountRuntimeState) error { } data, err := json.MarshalIndent(value, "", " ") if err != nil { - return fmt.Errorf("编码 %s: %w", runtimeStateName, err) + return fmt.Errorf("encode %s: %w", runtimeStateName, err) } return atomicWriteFile(filePath, append(data, '\n'), 0o600) } @@ -2548,7 +2548,7 @@ func (p *AccountPool) updateRuntimeContext( account := p.byID[strings.TrimSpace(accountID)] p.mu.Unlock() if account == nil { - return false, fmt.Errorf("账户不存在: %s", accountID) + return false, fmt.Errorf("account not found: %s", accountID) } account.runtimeMu.Lock() @@ -2557,7 +2557,7 @@ func (p *AccountPool) updateRuntimeContext( currentAccount := p.byID[account.ID] p.mu.Unlock() if currentAccount != account { - return false, fmt.Errorf("账户不存在: %s", account.ID) + return false, fmt.Errorf("account not found: %s", account.ID) } runtimeLock, err := lockRuntimeState(ctx, account) if err != nil { @@ -2587,7 +2587,7 @@ func (p *AccountPool) updateRuntimeContext( p.mu.Lock() if p.byID[account.ID] != account { p.mu.Unlock() - return false, fmt.Errorf("账户不存在: %s", account.ID) + return false, fmt.Errorf("account not found: %s", account.ID) } refreshed, err := p.syncAccountRuntimeLocked(account, current) if err != nil { @@ -2609,7 +2609,7 @@ func (p *AccountPool) updateRuntimeContext( p.mu.Lock() defer p.mu.Unlock() if p.byID[account.ID] != account { - return false, fmt.Errorf("账户不存在: %s", account.ID) + return false, fmt.Errorf("account not found: %s", account.ID) } synced, err := p.syncAccountRuntimeLocked(account, working) if err != nil { @@ -2627,7 +2627,7 @@ func (p *AccountPool) updateRuntimeContext( func (p *AccountPool) syncAccountRuntimeLocked(account *Account, runtimeState accountRuntimeState) (bool, error) { for resourceID := range runtimeState.Resources { if owner, exists := p.resources[resourceID]; exists && owner != account.ID { - return false, fmt.Errorf("资源 %s 已绑定账户 %s", resourceID, owner) + return false, fmt.Errorf("resource %s is already bound to account %s", resourceID, owner) } } changed := account.BenefitTier != runtimeState.BenefitTier || !reflect.DeepEqual(account.runtime, runtimeState) @@ -2680,14 +2680,14 @@ func lockRuntimeState(ctx context.Context, account *Account) (*flock.Flock, erro } leaseDirectory := filepath.Join(filepath.Dir(accountDirectory), ".leases") if err := os.MkdirAll(leaseDirectory, 0o700); err != nil { - return nil, fmt.Errorf("创建账户状态锁目录: %w", err) + return nil, fmt.Errorf("create account state lock directory: %w", err) } lock := flock.New(filepath.Join(leaseDirectory, filepath.Base(accountDirectory)+".runtime.lock")) lockCtx, cancel := context.WithTimeout(ctx, runtimeLockLimit) defer cancel() _, err := lock.TryLockContext(lockCtx, runtimeLockPoll) if err != nil { - return nil, fmt.Errorf("锁定账户运行状态: %w", err) + return nil, fmt.Errorf("lock account runtime state: %w", err) } return lock, nil } @@ -2705,7 +2705,7 @@ func validatePersistentAccountFiles(account *Account) error { if os.IsNotExist(err) { return fmt.Errorf("%w: %s", ErrAccountNotFound, account.ID) } - return fmt.Errorf("读取账户持久文件: %w", err) + return fmt.Errorf("read account persistent file: %w", err) } if !info.Mode().IsRegular() { return fmt.Errorf("%w: %s", ErrAccountNotFound, account.ID) @@ -2756,13 +2756,13 @@ func (p *AccountPool) refreshAccountRuntime(ctx context.Context, account *Accoun return err } -// accountRuntimeRefreshResult 保存单账户运行态刷新结果 +// accountRuntimeRefreshResult stores the runtime state refresh result for a single account type accountRuntimeRefreshResult struct { account *Account err error } -// refreshAccountRuntimes 并发刷新独立账户运行态 +// refreshAccountRuntimes concurrently refreshes runtime states of independent accounts func (p *AccountPool) refreshAccountRuntimes(ctx context.Context, accounts []*Account) []accountRuntimeRefreshResult { results := make([]accountRuntimeRefreshResult, len(accounts)) var refreshes sync.WaitGroup @@ -2905,7 +2905,7 @@ func accountCatalogFingerprint(tier BenefitTier, models []Model) (string, error) Models []Model `json:"models"` }{Tier: tier, Models: catalog}) if err != nil { - return "", fmt.Errorf("编码账户模型目录指纹: %w", err) + return "", fmt.Errorf("encode account model catalog fingerprint: %w", err) } return fmt.Sprintf("%x", sha256.Sum256(data)), nil } @@ -2941,13 +2941,13 @@ func acquireAccountFileLease(storagePath string) (*flock.Flock, string, error) { accountDirectory := filepath.Dir(storagePath) leaseDirectory := filepath.Join(filepath.Dir(accountDirectory), ".leases") if err := os.MkdirAll(leaseDirectory, 0o700); err != nil { - return nil, "", fmt.Errorf("创建账户租约目录: %w", err) + return nil, "", fmt.Errorf("create account lease directory: %w", err) } leasePath := filepath.Join(leaseDirectory, filepath.Base(accountDirectory)+".lock") leaseLock := flock.New(leasePath) locked, err := leaseLock.TryLock() if err != nil { - return nil, leasePath, fmt.Errorf("锁定账户租约: %w", err) + return nil, leasePath, fmt.Errorf("lock account lease: %w", err) } if !locked { return nil, leasePath, errAccountLeaseBusy @@ -2957,7 +2957,7 @@ func acquireAccountFileLease(storagePath string) (*flock.Flock, string, error) { func acquireAccountPublishLease(account *Account, validate bool) (*AccountPublishLease, error) { if account == nil || strings.TrimSpace(account.ID) == "" { - return nil, fmt.Errorf("账户未初始化") + return nil, fmt.Errorf("account is not initialized") } requestLock, _, err := acquireAccountFileLease(account.StoragePath) if errors.Is(err, errAccountLeaseBusy) { @@ -2993,7 +2993,7 @@ func acquireAccountPublishLease(account *Account, validate bool) (*AccountPublis return &AccountPublishLease{account: account, requestLock: requestLock, runtimeLock: runtimeLock}, nil } -// Release 结束新账户运行时发布窗口 +// Release ends the publishing window for a new account runtime func (lease *AccountPublishLease) Release() error { if lease == nil || lease.account == nil { return nil @@ -3013,7 +3013,7 @@ func (lease *AccountPublishLease) Release() error { return lease.err } -// AcquireAccountRuntimeLease 锁定当前用户下的账户 WAA runtime +// AcquireAccountRuntimeLease locks the account WAA runtime under the current user func AcquireAccountRuntimeLease(accountID string) (*AccountRuntimeLease, error) { accountID, err := normalizeAccountEmail(accountID) if err != nil { @@ -3021,24 +3021,24 @@ func AcquireAccountRuntimeLease(accountID string) (*AccountRuntimeLease, error) } cacheRoot, err := os.UserCacheDir() if err != nil { - return nil, fmt.Errorf("读取用户缓存目录: %w", err) + return nil, fmt.Errorf("read user cache directory: %w", err) } directory := filepath.Join(cacheRoot, "AIStudio2API", "runtime-leases") if err := os.MkdirAll(directory, 0o700); err != nil { - return nil, fmt.Errorf("创建 WAA runtime 租约目录: %w", err) + return nil, fmt.Errorf("create WAA runtime lease directory: %w", err) } lock := flock.New(filepath.Join(directory, accountID+".lock")) locked, err := lock.TryLock() if err != nil { - return nil, fmt.Errorf("锁定账户 WAA runtime: %w", err) + return nil, fmt.Errorf("lock account WAA runtime: %w", err) } if !locked { - return nil, fmt.Errorf("%w: %s 已由另一个 AIStudio2API runtime 使用", ErrAccountLeased, accountID) + return nil, fmt.Errorf("%w: %s is already in use by another AIStudio2API runtime", ErrAccountLeased, accountID) } return &AccountRuntimeLease{lock: lock}, nil } -// Release 释放账户 WAA runtime 锁 +// Release releases the account WAA runtime lock func (lease *AccountRuntimeLease) Release() error { if lease == nil || lease.lock == nil { return nil @@ -3056,7 +3056,7 @@ func ensureJSONEnd(decoder *json.Decoder) error { return nil } if err == nil { - return fmt.Errorf("文件包含多个 JSON 值") + return fmt.Errorf("file contains multiple JSON values") } return err } @@ -3075,11 +3075,11 @@ func normalizeAccountEmail(candidate string) (string, error) { candidate = strings.TrimSpace(candidate) address, err := mail.ParseAddress(candidate) if err != nil || !strings.EqualFold(strings.TrimSpace(address.Address), candidate) { - return "", fmt.Errorf("账户必须填写 Google 邮箱") + return "", fmt.Errorf("account must have a Google email") } id := strings.ToLower(strings.TrimSpace(address.Address)) if id == "." || id == ".." || strings.ContainsAny(id, `<>:"/\|?*`) { - return "", fmt.Errorf("账户邮箱不能用作目录名: %s", id) + return "", fmt.Errorf("account email cannot be used as directory name: %s", id) } return id, nil } diff --git a/internal/aistudio/auth.go b/internal/aistudio/auth.go index 81ccd7b..1fe8cad 100644 --- a/internal/aistudio/auth.go +++ b/internal/aistudio/auth.go @@ -15,11 +15,11 @@ import ( ) const ( - // LoginMethodIsolatedBrowser 表示独立浏览器登录 + // LoginMethodIsolatedBrowser indicates isolated browser login LoginMethodIsolatedBrowser = "isolated_browser" ) -// StateCookie 表示 Playwright storage state 中的 Cookie +// StateCookie represents a cookie in Playwright storage state type StateCookie struct { Name string `json:"name"` Value string `json:"value"` @@ -32,40 +32,40 @@ type StateCookie struct { PartitionKey string `json:"partitionKey,omitempty"` } -// StorageItem 表示浏览器本地存储项 +// StorageItem represents a browser local storage item type StorageItem struct { Name string `json:"name"` Value string `json:"value"` } -// StorageOrigin 表示 Playwright storage state 中的站点数据 +// StorageOrigin represents origin data in Playwright storage state type StorageOrigin struct { Origin string `json:"origin"` LocalStorage []StorageItem `json:"localStorage"` } -// StorageState 表示可原样写回的 Playwright storage state +// StorageState represents Playwright storage state that can be written back as-is type StorageState struct { Cookies []StateCookie `json:"cookies"` Origins []StorageOrigin `json:"origins"` extra map[string]json.RawMessage } -// AuthSource 记录认证状态的来源 +// AuthSource records the source of authentication state type AuthSource struct { Browser string `json:"browser"` Profile string `json:"profile,omitempty"` Email string `json:"email,omitempty"` } -// ChromeOAuthMaterial 保存 Chrome DBSC 续签材料 +// ChromeOAuthMaterial stores Chrome DBSC renewal material type ChromeOAuthMaterial struct { GaiaID string `json:"gaia_id"` RefreshToken string `json:"refresh_token"` WrappedBindingKey []byte `json:"wrapped_binding_key"` } -// AuthExtension 保存 aistudio2api 认证扩展 +// AuthExtension stores aistudio2api auth extensions type AuthExtension struct { Source AuthSource `json:"source"` OAuth *ChromeOAuthMaterial `json:"oauth,omitempty"` @@ -73,14 +73,14 @@ type AuthExtension struct { const authExtensionKey = "aistudio2api" -// SetAuthExtension 写入 aistudio2api 认证扩展 +// SetAuthExtension writes an aistudio2api auth extension func (s *StorageState) SetAuthExtension(extension AuthExtension) error { if s == nil { - return fmt.Errorf("storage state 为空") + return fmt.Errorf("storage state is empty") } raw, err := json.Marshal(extension) if err != nil { - return fmt.Errorf("编码认证扩展: %w", err) + return fmt.Errorf("encode auth extension: %w", err) } if s.extra == nil { s.extra = make(map[string]json.RawMessage) @@ -89,7 +89,7 @@ func (s *StorageState) SetAuthExtension(extension AuthExtension) error { return nil } -// AuthExtension 返回 aistudio2api 认证扩展 +// AuthExtension returns the aistudio2api auth extension func (s StorageState) AuthExtension() (AuthExtension, bool, error) { raw, exists := s.extra[authExtensionKey] if !exists { @@ -97,18 +97,18 @@ func (s StorageState) AuthExtension() (AuthExtension, bool, error) { } var extension AuthExtension if err := json.Unmarshal(raw, &extension); err != nil { - return AuthExtension{}, true, fmt.Errorf("解析认证扩展: %w", err) + return AuthExtension{}, true, fmt.Errorf("parse auth extension: %w", err) } return extension, true, nil } -// LoginMethod 描述可发布的账户登录入口 +// LoginMethod describes a publishable account login method type LoginMethod struct { ID string `json:"id"` Interactive bool `json:"interactive"` } -// IsolatedLoginRequest 描述独立浏览器登录所需的稳定环境 +// IsolatedLoginRequest describes the stable environment required for isolated browser login type IsolatedLoginRequest struct { AccountID string Directory string @@ -117,40 +117,40 @@ type IsolatedLoginRequest struct { Timezone string } -// IsolatedLoginResult 返回独立浏览器导出的认证状态 +// IsolatedLoginResult returns authentication state exported from an isolated browser type IsolatedLoginResult struct { StorageState StorageState Email string VerifiedAt time.Time } -// LoginVerification 表示隔离运行时对登录态的验证结果 +// LoginVerification represents verification result of login state by an isolated runtime type LoginVerification struct { Authenticated bool `json:"authenticated"` VerifiedAt time.Time `json:"verified_at"` Reason string `json:"reason,omitempty"` } -// IsolatedLoginDriver 定义独立浏览器登录与验证合同 +// IsolatedLoginDriver defines the contract for isolated browser login and verification type IsolatedLoginDriver interface { Login(context.Context, IsolatedLoginRequest) (IsolatedLoginResult, error) Verify(context.Context, IsolatedLoginRequest, StorageState) (LoginVerification, error) } -// SupportedLoginMethods 返回当前可发布的登录入口 +// SupportedLoginMethods returns the login methods currently available for publishing func SupportedLoginMethods() []LoginMethod { return []LoginMethod{{ID: LoginMethodIsolatedBrowser, Interactive: true}} } -// LoadStorageState 读取并校验 Playwright storage state +// LoadStorageState reads and validates Playwright storage state func LoadStorageState(filePath string) (StorageState, error) { data, err := os.ReadFile(filePath) if err != nil { - return StorageState{}, fmt.Errorf("读取 storage state: %w", err) + return StorageState{}, fmt.Errorf("read storage state: %w", err) } var state StorageState if err := json.Unmarshal(data, &state); err != nil { - return StorageState{}, fmt.Errorf("解析 storage state: %w", err) + return StorageState{}, fmt.Errorf("parse storage state: %w", err) } if err := state.Validate(); err != nil { return StorageState{}, err @@ -158,48 +158,48 @@ func LoadStorageState(filePath string) (StorageState, error) { return state, nil } -// WriteStorageState 原子写回 Playwright storage state +// WriteStorageState atomically writes back Playwright storage state func WriteStorageState(filePath string, state StorageState) error { if err := state.Validate(); err != nil { return err } data, err := json.MarshalIndent(state, "", " ") if err != nil { - return fmt.Errorf("编码 storage state: %w", err) + return fmt.Errorf("encode storage state: %w", err) } data = append(data, '\n') return atomicWriteFile(filePath, data, 0o600) } -// Validate 校验 storage state 的浏览器字段 +// Validate validates browser fields of storage state func (s StorageState) Validate() error { for index, cookie := range s.Cookies { if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Domain) == "" { - return fmt.Errorf("storage state Cookie %d 缺少名称或域", index) + return fmt.Errorf("storage state cookie %d missing name or domain", index) } if cookie.Path == "" || cookie.Path[0] != '/' { - return fmt.Errorf("storage state Cookie %s 的路径无效", cookie.Name) + return fmt.Errorf("storage state cookie %s has invalid path", cookie.Name) } switch cookie.SameSite { case "", "Lax", "Strict", "None": default: - return fmt.Errorf("storage state Cookie %s 的 SameSite 无效", cookie.Name) + return fmt.Errorf("storage state cookie %s has invalid SameSite", cookie.Name) } } for index, origin := range s.Origins { parsed, err := url.Parse(origin.Origin) if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return fmt.Errorf("storage state origin %d 无效", index) + return fmt.Errorf("storage state origin %d is invalid", index) } } return nil } -// CookieHeader 为目标 URL 构造当前有效的 Cookie 请求头 +// CookieHeader constructs currently valid Cookie request header for the target URL func (s StorageState) CookieHeader(targetURL string, now time.Time) (string, error) { target, err := url.Parse(targetURL) if err != nil || target.Scheme == "" || target.Hostname() == "" { - return "", fmt.Errorf("目标 URL 无效") + return "", fmt.Errorf("target URL is invalid") } type candidate struct { cookie StateCookie @@ -231,7 +231,7 @@ func (s StorageState) CookieHeader(targetURL string, now time.Time) (string, err return strings.Join(parts, "; "), nil } -// CookieValue 返回目标 URL 下最具体的同名 Cookie +// CookieValue returns the most specific cookie with the given name for the target URL func (s StorageState) CookieValue(name string, targetURL string, now time.Time) (string, bool) { target, err := url.Parse(targetURL) if err != nil { @@ -260,17 +260,17 @@ func (s StorageState) CookieValue(name string, targetURL string, now time.Time) return selected, selectedPath >= 0 } -// MergeSetCookieHeaders 将响应轮换的 Cookie 合并到 storage state +// MergeSetCookieHeaders merges response rotated cookies into storage state func (s *StorageState) MergeSetCookieHeaders(headers []string, sourceURL string, now time.Time) error { source, err := url.Parse(sourceURL) if err != nil || source.Scheme == "" || source.Hostname() == "" { - return fmt.Errorf("来源 URL 无效") + return fmt.Errorf("source URL is invalid") } for _, header := range headers { response := http.Response{Header: http.Header{"Set-Cookie": []string{header}}} cookies := response.Cookies() if len(cookies) != 1 { - return fmt.Errorf("Set-Cookie 格式无效") + return fmt.Errorf("invalid Set-Cookie format") } incoming := cookies[0] domain := strings.ToLower(incoming.Domain) @@ -314,7 +314,7 @@ func (s *StorageState) MergeSetCookieHeaders(headers []string, sourceURL string, return nil } -// MarshalJSON 保留 storage state 的扩展根字段 +// MarshalJSON preserves extension root fields of storage state func (s StorageState) MarshalJSON() ([]byte, error) { value := make(map[string]json.RawMessage, len(s.extra)+2) for key, raw := range s.extra { @@ -341,7 +341,7 @@ func (s StorageState) MarshalJSON() ([]byte, error) { return json.Marshal(value) } -// UnmarshalJSON 解析 storage state 并保存扩展根字段 +// UnmarshalJSON parses storage state and preserves extension root fields func (s *StorageState) UnmarshalJSON(data []byte) error { var value map[string]json.RawMessage if err := json.Unmarshal(data, &value); err != nil { @@ -411,34 +411,34 @@ func sameSiteText(value http.SameSite) string { func atomicWriteFile(filePath string, data []byte, mode os.FileMode) error { target, err := filepath.Abs(filePath) if err != nil { - return fmt.Errorf("解析文件路径: %w", err) + return fmt.Errorf("resolve file path: %w", err) } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("创建文件目录: %w", err) + return fmt.Errorf("create file directory: %w", err) } temporary, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+"-*.tmp") if err != nil { - return fmt.Errorf("创建临时文件: %w", err) + return fmt.Errorf("create temporary file: %w", err) } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(mode); err != nil { temporary.Close() - return fmt.Errorf("设置文件权限: %w", err) + return fmt.Errorf("set file permissions: %w", err) } if _, err := temporary.Write(data); err != nil { temporary.Close() - return fmt.Errorf("写入临时文件: %w", err) + return fmt.Errorf("write temporary file: %w", err) } if err := temporary.Sync(); err != nil { temporary.Close() - return fmt.Errorf("同步临时文件: %w", err) + return fmt.Errorf("sync temporary file: %w", err) } if err := temporary.Close(); err != nil { - return fmt.Errorf("关闭临时文件: %w", err) + return fmt.Errorf("close temporary file: %w", err) } if err := os.Rename(temporaryPath, target); err != nil { - return fmt.Errorf("替换文件: %w", err) + return fmt.Errorf("replace file: %w", err) } return nil } diff --git a/internal/aistudio/benefit.go b/internal/aistudio/benefit.go index 16bb1ae..618fb42 100644 --- a/internal/aistudio/benefit.go +++ b/internal/aistudio/benefit.go @@ -8,17 +8,17 @@ import ( "strings" ) -// BenefitTier 表示 AI Studio 返回的账户权益等级 +// BenefitTier represents the account benefit tier returned by AI Studio type BenefitTier int64 const ( - // BenefitTierFree 表示账户没有 Google AI 订阅权益 + // BenefitTierFree indicates that the account has no Google AI subscription benefit BenefitTierFree BenefitTier = 0 - // BenefitTierPro 表示 Google AI Pro 权益 + // BenefitTierPro indicates Google AI Pro benefit BenefitTierPro BenefitTier = 1 - // BenefitTierUltra 表示 Google AI Ultra 权益 + // BenefitTierUltra indicates Google AI Ultra benefit BenefitTierUltra BenefitTier = 2 - // BenefitTierPlus 表示 Google AI Plus 权益 + // BenefitTierPlus indicates Google AI Plus benefit BenefitTierPlus BenefitTier = 3 ) @@ -36,7 +36,7 @@ var tieredRPCMethods = map[string]struct{}{ "StreamExtractVideoFrames": {}, } -// String 返回账户权益的稳定显示名称 +// String returns the stable display name of the benefit tier func (tier BenefitTier) String() string { switch tier { case BenefitTierPro: @@ -50,7 +50,7 @@ func (tier BenefitTier) String() string { } } -// HeaderValue 返回官网 RPC 使用的权益请求头值 +// HeaderValue returns the header value used for official website RPC func (tier BenefitTier) HeaderValue() string { switch tier { case BenefitTierPro: @@ -64,11 +64,11 @@ func (tier BenefitTier) HeaderValue() string { } } -// BenefitTierForAccount 读取并缓存指定账户的官网权益等级 +// BenefitTierForAccount reads and caches the official benefit tier for the specified account func (c *Client) BenefitTierForAccount(ctx context.Context, accountID string) (BenefitTier, error) { accountID = strings.TrimSpace(accountID) if accountID == "" { - return BenefitTierFree, fmt.Errorf("GetAiStudioBenefitTier 缺少账户 ID") + return BenefitTierFree, fmt.Errorf("GetAiStudioBenefitTier missing account ID") } response, err := c.do(ctx, "GetAiStudioBenefitTier", accountID, "", []byte("[]"), false) if err != nil { @@ -77,7 +77,7 @@ func (c *Client) BenefitTierForAccount(ctx context.Context, accountID string) (B defer response.Body.Close() raw, err := io.ReadAll(response.Body) if err != nil { - return BenefitTierFree, fmt.Errorf("读取 GetAiStudioBenefitTier: %w", err) + return BenefitTierFree, fmt.Errorf("read GetAiStudioBenefitTier: %w", err) } tier, err := decodeBenefitTier(raw) if err != nil { @@ -110,7 +110,7 @@ func decodeBenefitTier(raw []byte) (BenefitTier, error) { return BenefitTierFree, &ProtocolEvidenceError{ Method: "GetAiStudioBenefitTier", Path: "$[0]", - Detail: fmt.Sprintf("未识别的账户权益枚举 %d", wire), + Detail: fmt.Sprintf("unrecognized benefit tier enum %d", wire), Raw: append([]byte(nil), raw...), } } diff --git a/internal/aistudio/bidi.go b/internal/aistudio/bidi.go index 8f4a447..54ea95f 100644 --- a/internal/aistudio/bidi.go +++ b/internal/aistudio/bidi.go @@ -8,17 +8,17 @@ import ( "strings" ) -// BidiMode 区分 Gemini Live 与 Robotics Streaming 的独立会话配置 +// BidiMode distinguishes independent session configuration between Gemini Live and Robotics Streaming type BidiMode string const ( - // BidiModeLive 表示音频输出的 Gemini Live 会话 + // BidiModeLive indicates an audio-output Gemini Live session BidiModeLive BidiMode = "live" - // BidiModeRobotics 表示文本输出的 Robotics Streaming 会话 + // BidiModeRobotics indicates a text-output Robotics Streaming session BidiModeRobotics BidiMode = "robotics" ) -// BidiRequest 定义一条双向实时会话 +// BidiRequest defines a bidirectional real-time session type BidiRequest struct { Model string Mode BidiMode @@ -33,45 +33,45 @@ type BidiRequest struct { ObserveAccountFailure func(string, error) } -// BidiEventKind 表示双向实时协议事件 +// BidiEventKind represents bidirectional real-time protocol event kinds type BidiEventKind string const ( - // BidiEventSetupComplete 表示上游已接受会话配置 + // BidiEventSetupComplete indicates that upstream accepted the session setup BidiEventSetupComplete BidiEventKind = "setup_complete" - // BidiEventText 表示模型文本增量 + // BidiEventText indicates model text delta BidiEventText BidiEventKind = "text" - // BidiEventMedia 表示模型媒体增量 + // BidiEventMedia indicates model media delta BidiEventMedia BidiEventKind = "media" - // BidiEventInputTranscription 表示输入转写增量 + // BidiEventInputTranscription indicates input transcription delta BidiEventInputTranscription BidiEventKind = "input_transcription" - // BidiEventOutputTranscription 表示输出转写增量 + // BidiEventOutputTranscription indicates output transcription delta BidiEventOutputTranscription BidiEventKind = "output_transcription" - // BidiEventGenerationComplete 表示当前生成已完成 + // BidiEventGenerationComplete indicates that current generation is complete BidiEventGenerationComplete BidiEventKind = "generation_complete" - // BidiEventTurnComplete 表示当前对话轮次已完成 + // BidiEventTurnComplete indicates that current conversation turn is complete BidiEventTurnComplete BidiEventKind = "turn_complete" - // BidiEventInterrupted 表示当前模型输出被打断 + // BidiEventInterrupted indicates that current model output was interrupted BidiEventInterrupted BidiEventKind = "interrupted" - // BidiEventToolCall 表示模型发起函数调用 + // BidiEventToolCall indicates that the model initiated a function call BidiEventToolCall BidiEventKind = "tool_call" - // BidiEventToolCallCancellation 表示模型取消尚未完成的函数调用 + // BidiEventToolCallCancellation indicates that the model canceled an uncompleted function call BidiEventToolCallCancellation BidiEventKind = "tool_call_cancellation" - // BidiEventSessionResumption 表示上游更新恢复令牌 + // BidiEventSessionResumption indicates that upstream updated the session resumption token BidiEventSessionResumption BidiEventKind = "session_resumption" - // BidiEventGoAway 表示上游要求结束当前连接 + // BidiEventGoAway indicates that upstream requested terminating the current connection BidiEventGoAway BidiEventKind = "go_away" - // BidiEventUsage 表示上游返回用量字段 + // BidiEventUsage indicates upstream returned usage fields BidiEventUsage BidiEventKind = "usage" - // BidiEventProvider 表示已保留的未归一化上游字段 + // BidiEventProvider indicates preserved unnormalized upstream fields BidiEventProvider BidiEventKind = "provider" - // BidiEventClosed 表示 WebChannel 已结束 + // BidiEventClosed indicates that the WebChannel connection closed BidiEventClosed BidiEventKind = "closed" - // BidiEventError 表示双向实时协议错误 + // BidiEventError represents a bidirectional real-time protocol error BidiEventError BidiEventKind = "error" ) -// BidiTranscription 保存实时转写字段 +// BidiTranscription stores real-time transcription fields type BidiTranscription struct { Text string `json:"text"` Finished bool `json:"finished,omitempty"` @@ -79,7 +79,7 @@ type BidiTranscription struct { LanguageCode string `json:"language_code,omitempty"` } -// BidiEvent 保存按上游顺序输出的实时事件 +// BidiEvent stores real-time events emitted in upstream order type BidiEvent struct { Kind BidiEventKind `json:"kind"` Text string `json:"text,omitempty"` @@ -93,11 +93,11 @@ type BidiEvent struct { Err error `json:"-"` } -// EncodeBidiSetupRequest 编码 Live 或 Robotics 的已验证 setup 帧 +// EncodeBidiSetupRequest encodes verified setup frame for Live or Robotics func EncodeBidiSetupRequest(request BidiRequest, runtime RequestContext) ([]byte, string, error) { model := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") if model == "" { - return nil, "", fmt.Errorf("%w: bidi model 不能为空", ErrInvalidArgument) + return nil, "", fmt.Errorf("%w: bidi model cannot be empty", ErrInvalidArgument) } configuration := make([]any, 18) setup := make([]any, 16) @@ -110,7 +110,7 @@ func EncodeBidiSetupRequest(request BidiRequest, runtime RequestContext) ([]byte configuration[14] = []any{int64(1)} configuration[16] = []any{int64(1), nil, nil, int64(3)} default: - return nil, "", fmt.Errorf("%w: 未识别的 bidi mode %q", ErrInvalidArgument, request.Mode) + return nil, "", fmt.Errorf("%w: unrecognized bidi mode %q", ErrInvalidArgument, request.Mode) } configuration[17] = int64(2) wireModel := wireModelName(model) @@ -122,7 +122,7 @@ func EncodeBidiSetupRequest(request BidiRequest, runtime RequestContext) ([]byte for _, declaration := range request.Tools { encoded, err := encodeFunctionDeclaration(declaration) if err != nil { - return nil, "", fmt.Errorf("编码 bidi function declaration: %w", err) + return nil, "", fmt.Errorf("encode bidi function declaration: %w", err) } declarations = append(declarations, encoded) bindingParts = append(bindingParts, declaration.Name+" "+declaration.Description) @@ -146,30 +146,30 @@ func EncodeBidiSetupRequest(request BidiRequest, runtime RequestContext) ([]byte wire[6] = setup body, err := json.Marshal(wire) if err != nil { - return nil, "", fmt.Errorf("编码 bidi setup: %w", err) + return nil, "", fmt.Errorf("encode bidi setup: %w", err) } return body, strings.Join(bindingParts, " "), nil } -// EncodeBidiTextRequest 编码官网文本输入帧 +// EncodeBidiTextRequest encodes official text input frame func EncodeBidiTextRequest(text string) ([]byte, string, error) { if strings.TrimSpace(text) == "" { - return nil, "", fmt.Errorf("%w: bidi text 不能为空", ErrInvalidArgument) + return nil, "", fmt.Errorf("%w: bidi text cannot be empty", ErrInvalidArgument) } wire := make([]any, 6) wire[2] = []any{nil, nil, nil, nil, text} body, err := json.Marshal(wire) if err != nil { - return nil, "", fmt.Errorf("编码 bidi text: %w", err) + return nil, "", fmt.Errorf("encode bidi text: %w", err) } return body, "", nil } -// EncodeBidiMediaRequest 编码官网实时音频或图像输入帧 +// EncodeBidiMediaRequest encodes official real-time audio or image input frame func EncodeBidiMediaRequest(mimeType string, data []byte) ([]byte, string, error) { mimeType = strings.TrimSpace(mimeType) if len(data) == 0 { - return nil, "", fmt.Errorf("%w: bidi media 不能为空", ErrInvalidArgument) + return nil, "", fmt.Errorf("%w: bidi media cannot be empty", ErrInvalidArgument) } encoded := base64.StdEncoding.EncodeToString(data) var realtimeInput []any @@ -181,40 +181,40 @@ func EncodeBidiMediaRequest(mimeType string, data []byte) ([]byte, string, error realtimeInput = make([]any, 4) realtimeInput[3] = []any{mimeType, encoded} default: - return nil, "", fmt.Errorf("%w: 未识别的 bidi media type %q", ErrInvalidArgument, mimeType) + return nil, "", fmt.Errorf("%w: unrecognized bidi media type %q", ErrInvalidArgument, mimeType) } wire := make([]any, 6) wire[2] = realtimeInput body, err := json.Marshal(wire) if err != nil { - return nil, "", fmt.Errorf("编码 bidi media: %w", err) + return nil, "", fmt.Errorf("encode bidi media: %w", err) } return body, "", nil } -// EncodeBidiMediaEndRequest 编码官网实时媒体结束帧 +// EncodeBidiMediaEndRequest encodes official real-time media end frame func EncodeBidiMediaEndRequest() ([]byte, string, error) { wire := make([]any, 6) wire[2] = []any{nil, nil, int64(1)} body, err := json.Marshal(wire) if err != nil { - return nil, "", fmt.Errorf("编码 bidi media end: %w", err) + return nil, "", fmt.Errorf("encode bidi media end: %w", err) } return body, "", nil } -// EncodeBidiToolResponseRequest 编码官网函数响应帧 +// EncodeBidiToolResponseRequest encodes official function response frame func EncodeBidiToolResponseRequest(results []FunctionResult) ([]byte, string, error) { if len(results) == 0 { - return nil, "", fmt.Errorf("%w: bidi function response 列表为空", ErrInvalidArgument) + return nil, "", fmt.Errorf("%w: bidi function response list is empty", ErrInvalidArgument) } functionResponses := make([]any, 0, len(results)) for _, result := range results { if strings.TrimSpace(result.ID) == "" { - return nil, "", fmt.Errorf("%w: bidi function response 缺少调用 ID", ErrInvalidArgument) + return nil, "", fmt.Errorf("%w: bidi function response missing call ID", ErrInvalidArgument) } if strings.TrimSpace(result.Name) == "" { - return nil, "", fmt.Errorf("%w: bidi function response 缺少函数名", ErrInvalidArgument) + return nil, "", fmt.Errorf("%w: bidi function response missing function name", ErrInvalidArgument) } response, err := encodeWireStructJSON(result.Content) if err != nil { @@ -228,12 +228,12 @@ func EncodeBidiToolResponseRequest(results []FunctionResult) ([]byte, string, er wire[3] = responses body, err := json.Marshal(wire) if err != nil { - return nil, "", fmt.Errorf("编码 bidi function response: %w", err) + return nil, "", fmt.Errorf("encode bidi function response: %w", err) } return body, results[0].ID, nil } -// ParseBidiServerPayload 解码一条 WebChannel 业务 payload +// ParseBidiServerPayload decodes a WebChannel business payload func ParseBidiServerPayload(raw json.RawMessage) ([]BidiEvent, error) { if event, matched, err := parseBidiStatusPayload(raw); matched { if err != nil { @@ -284,31 +284,31 @@ func parseBidiStatusPayload(raw json.RawMessage) (BidiEvent, bool, error) { var sm map[string]json.RawMessage if err := json.Unmarshal(smRaw, &sm); err != nil { return BidiEvent{}, true, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$.__sm__", Detail: "期望对象", Raw: cloneRaw(raw), + Method: "BidiGenerateContent", Path: "$.__sm__", Detail: "expected object", Raw: cloneRaw(raw), } } statusRaw, exists := sm["status"] if !exists { return BidiEvent{}, true, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$.__sm__.status", Detail: "缺少状态", Raw: cloneRaw(raw), + Method: "BidiGenerateContent", Path: "$.__sm__.status", Detail: "missing status", Raw: cloneRaw(raw), } } outer, err := rawArray(statusRaw, "$.__sm__.status", raw) if err != nil || len(outer) != 1 { return BidiEvent{}, true, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$.__sm__.status", Detail: "状态 envelope 无效", Raw: cloneRaw(raw), + Method: "BidiGenerateContent", Path: "$.__sm__.status", Detail: "invalid status envelope", Raw: cloneRaw(raw), } } middle, err := rawArray(outer[0], "$.__sm__.status[0]", raw) if err != nil || len(middle) != 1 { return BidiEvent{}, true, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$.__sm__.status[0]", Detail: "状态 envelope 无效", Raw: cloneRaw(raw), + Method: "BidiGenerateContent", Path: "$.__sm__.status[0]", Detail: "invalid status envelope", Raw: cloneRaw(raw), } } status, err := rawArray(middle[0], "$.__sm__.status[0][0]", raw) if err != nil || len(status) < 2 { return BidiEvent{}, true, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$.__sm__.status[0][0]", Detail: "状态字段不足", Raw: cloneRaw(raw), + Method: "BidiGenerateContent", Path: "$.__sm__.status[0][0]", Detail: "insufficient status fields", Raw: cloneRaw(raw), } } code, err := rawInt64(status[0], "$.__sm__.status[0][0][0]", raw) @@ -328,7 +328,7 @@ func parseBidiStatusPayload(raw json.RawMessage) (BidiEvent, bool, error) { default: return BidiEvent{}, true, &ProtocolEvidenceError{ Method: "BidiGenerateContent", Path: "$.__sm__.status[0][0][0]", - Detail: fmt.Sprintf("未识别的状态码 %d", code), Raw: cloneRaw(raw), + Detail: fmt.Sprintf("unrecognized status code %d", code), Raw: cloneRaw(raw), } } return BidiEvent{ @@ -394,7 +394,7 @@ func parseBidiToolCalls(raw json.RawMessage, evidence json.RawMessage) ([]BidiEv callsRaw := rawAt(values, 1) if isJSONNull(callsRaw) { return nil, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$message[3][1]", Detail: "tool call 列表为空", Raw: cloneRaw(raw), + Method: "BidiGenerateContent", Path: "$message[3][1]", Detail: "tool call list is empty", Raw: cloneRaw(raw), } } calls, err := rawArray(callsRaw, "$message[3][1]", evidence) @@ -403,7 +403,7 @@ func parseBidiToolCalls(raw json.RawMessage, evidence json.RawMessage) ([]BidiEv } if len(calls) == 0 { return nil, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$message[3][1]", Detail: "tool call 列表为空", Raw: cloneRaw(callsRaw), + Method: "BidiGenerateContent", Path: "$message[3][1]", Detail: "tool call list is empty", Raw: cloneRaw(callsRaw), } } events := make([]BidiEvent, 0, len(calls)) @@ -415,7 +415,7 @@ func parseBidiToolCalls(raw json.RawMessage, evidence json.RawMessage) ([]BidiEv } if strings.TrimSpace(call.ID) == "" { return nil, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: path + "[2]", Detail: "tool call 缺少调用 ID", Raw: cloneRaw(callRaw), + Method: "BidiGenerateContent", Path: path + "[2]", Detail: "tool call missing call ID", Raw: cloneRaw(callRaw), } } events = append(events, BidiEvent{Kind: BidiEventToolCall, ToolCall: &call}) @@ -431,7 +431,7 @@ func parseBidiToolCallCancellation(raw json.RawMessage, evidence json.RawMessage idsRaw := rawAt(values, 0) if isJSONNull(idsRaw) { return BidiEvent{}, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$message[4][0]", Detail: "tool call cancellation 列表为空", Raw: cloneRaw(raw), + Method: "BidiGenerateContent", Path: "$message[4][0]", Detail: "tool call cancellation list is empty", Raw: cloneRaw(raw), } } encodedIDs, err := rawArray(idsRaw, "$message[4][0]", evidence) @@ -440,7 +440,7 @@ func parseBidiToolCallCancellation(raw json.RawMessage, evidence json.RawMessage } if len(encodedIDs) == 0 { return BidiEvent{}, &ProtocolEvidenceError{ - Method: "BidiGenerateContent", Path: "$message[4][0]", Detail: "tool call cancellation 列表为空", Raw: cloneRaw(idsRaw), + Method: "BidiGenerateContent", Path: "$message[4][0]", Detail: "tool call cancellation list is empty", Raw: cloneRaw(idsRaw), } } ids := make([]string, 0, len(encodedIDs)) @@ -452,7 +452,7 @@ func parseBidiToolCallCancellation(raw json.RawMessage, evidence json.RawMessage if id == "" { return BidiEvent{}, &ProtocolEvidenceError{ Method: "BidiGenerateContent", Path: fmt.Sprintf("$message[4][0][%d]", index), - Detail: "tool call cancellation ID 为空", Raw: cloneRaw(encoded), + Detail: "tool call cancellation ID is empty", Raw: cloneRaw(encoded), } } ids = append(ids, id) @@ -549,7 +549,7 @@ func parseBidiContent(raw json.RawMessage, evidence json.RawMessage) ([]BidiEven default: encoded, marshalErr := json.Marshal(event) if marshalErr != nil { - return nil, fmt.Errorf("编码 bidi provider event: %w", marshalErr) + return nil, fmt.Errorf("encode bidi provider event: %w", marshalErr) } events = append(events, BidiEvent{Kind: BidiEventProvider, Raw: encoded}) } diff --git a/internal/aistudio/client.go b/internal/aistudio/client.go index e7353e9..7d93b1d 100644 --- a/internal/aistudio/client.go +++ b/internal/aistudio/client.go @@ -13,13 +13,13 @@ import ( ) const ( - // MakerSuiteRPCBase 是现场确认的 AI Studio RPC 根地址 + // MakerSuiteRPCBase is the live-confirmed AI Studio RPC base URL MakerSuiteRPCBase = "https://alkalimakersuite-pa.clients6.google.com/$rpc/google.internal.alkali.applications.makersuite.v1.MakerSuiteService/" - // JSONProtobufContentType 是 MakerSuite 使用的数组协议媒体类型 + // JSONProtobufContentType is the array protocol media type used by MakerSuite JSONProtobufContentType = "application/json+protobuf" ) -// RPCRequest 描述交给认证传输层的单次 MakerSuite 请求 +// RPCRequest describes a single MakerSuite request passed to the authenticated transport layer type RPCRequest struct { Method string URL string @@ -30,62 +30,62 @@ type RPCRequest struct { Streaming bool } -// RPCResponse 描述认证传输层返回的响应头和实时正文 +// RPCResponse describes response headers and live body returned by the authenticated transport layer type RPCResponse struct { StatusCode int Header http.Header Body io.ReadCloser } -// RPCTransport 负责认证、账户租约、Cookie 写回和真实网络发送 +// RPCTransport is responsible for authentication, account lease, cookie write-back, and physical network dispatch type RPCTransport interface { Do(context.Context, RPCRequest) (*RPCResponse, error) } -// ProtectedTransport 原子完成 fresh WAA proof、field 5 写入和同 context 发送 +// ProtectedTransport atomically generates fresh WAA proof, writes field 5, and dispatches in the same context type ProtectedTransport interface { DoProtected(context.Context, GenerateRequest, RPCRequest) (*RPCResponse, error) } -// VideoProtectedTransport 原子完成 Veo fresh WAA proof、field 8 写入和同 context 发送 +// VideoProtectedTransport atomically generates Veo fresh WAA proof, writes field 8, and dispatches in the same context type VideoProtectedTransport interface { DoProtectedVideo(context.Context, VideoRequest, RPCRequest) (*RPCResponse, error) } -// ProtectedTransportFunc 将函数适配为 ProtectedTransport +// ProtectedTransportFunc adapts a function to ProtectedTransport type ProtectedTransportFunc func(context.Context, GenerateRequest, RPCRequest) (*RPCResponse, error) -// DoProtected 调用受保护传输函数 +// DoProtected invokes the protected transport function func (f ProtectedTransportFunc) DoProtected(ctx context.Context, request GenerateRequest, rpc RPCRequest) (*RPCResponse, error) { return f(ctx, request, rpc) } -// RequestContext 保存账户运行时提供的协议上下文 +// RequestContext stores protocol context provided by account runtime type RequestContext struct { Timezone string } -// RequestContextProvider 按账户返回当前协议上下文 +// RequestContextProvider returns the current protocol context for an account type RequestContextProvider interface { RequestContext(context.Context, string) (RequestContext, error) } -// RequestContextProviderFunc 将函数适配为 RequestContextProvider +// RequestContextProviderFunc adapts a function to RequestContextProvider type RequestContextProviderFunc func(context.Context, string) (RequestContext, error) -// RequestContext 调用上下文函数 +// RequestContext invokes the context function func (f RequestContextProviderFunc) RequestContext(ctx context.Context, accountID string) (RequestContext, error) { return f(ctx, accountID) } -// ClientOptions 定义协议客户端的窄依赖 +// ClientOptions defines narrow dependencies for the protocol client type ClientOptions struct { Transport RPCTransport Protected ProtectedTransport ContextProvider RequestContextProvider } -// Client 实现 AI Studio 私有协议核心 +// Client implements the AI Studio private protocol core type Client struct { transport RPCTransport protected ProtectedTransport @@ -98,13 +98,13 @@ type Client struct { var _ Service = (*Client)(nil) -// NewClient 创建协议客户端 +// NewClient creates a protocol client func NewClient(options ClientOptions) (*Client, error) { if options.Transport == nil { - return nil, fmt.Errorf("AI Studio transport 不能为空") + return nil, fmt.Errorf("AI Studio transport cannot be nil") } if options.Protected == nil { - return nil, fmt.Errorf("AI Studio protected transport 不能为空") + return nil, fmt.Errorf("AI Studio protected transport cannot be nil") } return &Client{ transport: options.Transport, @@ -115,7 +115,7 @@ func NewClient(options ClientOptions) (*Client, error) { }, nil } -// RPCError 保存上游状态和协议错误码 +// RPCError stores upstream status and protocol error code type RPCError struct { Method string StatusCode int @@ -124,15 +124,15 @@ type RPCError struct { Metadata map[string]string } -// Error 返回结构化上游错误 +// Error returns a structured upstream error func (e *RPCError) Error() string { if e.Code != 0 { - return fmt.Sprintf("AI Studio %s 返回 HTTP %d、协议错误码 %d: %s", e.Method, e.StatusCode, e.Code, e.Message) + return fmt.Sprintf("AI Studio %s returned HTTP %d, protocol error code %d: %s", e.Method, e.StatusCode, e.Code, e.Message) } - return fmt.Sprintf("AI Studio %s 返回 HTTP %d: %s", e.Method, e.StatusCode, e.Message) + return fmt.Sprintf("AI Studio %s returned HTTP %d: %s", e.Method, e.StatusCode, e.Message) } -// HTTPStatus 返回上游 HTTP 状态 +// HTTPStatus returns the upstream HTTP status func (e *RPCError) HTTPStatus() int { return e.StatusCode } @@ -142,7 +142,7 @@ func (c *Client) do(ctx context.Context, method string, accountID string, reques c.applyBenefitTier(method, accountID, rpc.Header) response, err := c.transport.Do(ctx, rpc) if err != nil { - return nil, fmt.Errorf("发送 AI Studio %s: %w", method, err) + return nil, fmt.Errorf("send AI Studio %s: %w", method, err) } return validateRPCResponse(method, response) } @@ -152,7 +152,7 @@ func (c *Client) doProtected(ctx context.Context, request GenerateRequest, body c.applyBenefitTier(rpc.Method, request.AccountID, rpc.Header) response, err := c.protected.DoProtected(ctx, request, rpc) if err != nil { - return nil, fmt.Errorf("发送 AI Studio GenerateContent: %w", err) + return nil, fmt.Errorf("send AI Studio GenerateContent: %w", err) } return validateRPCResponse("GenerateContent", response) } @@ -160,13 +160,13 @@ func (c *Client) doProtected(ctx context.Context, request GenerateRequest, body func (c *Client) doProtectedVideo(ctx context.Context, request VideoRequest, body []byte) (*RPCResponse, error) { transport, ok := c.protected.(VideoProtectedTransport) if !ok { - return nil, fmt.Errorf("AI Studio protected transport 不支持 GenerateVideo") + return nil, fmt.Errorf("AI Studio protected transport does not support GenerateVideo") } rpc := newRPCRequest("GenerateVideo", request.AccountID, "", body, false) c.applyBenefitTier(rpc.Method, request.AccountID, rpc.Header) response, err := transport.DoProtectedVideo(ctx, request, rpc) if err != nil { - return nil, fmt.Errorf("发送 AI Studio GenerateVideo: %w", err) + return nil, fmt.Errorf("send AI Studio GenerateVideo: %w", err) } return validateRPCResponse("GenerateVideo", response) } @@ -187,13 +187,13 @@ func newRPCRequest(method string, accountID string, requestID string, body []byt func validateRPCResponse(method string, response *RPCResponse) (*RPCResponse, error) { if response == nil || response.Body == nil { - return nil, fmt.Errorf("AI Studio %s transport 返回空响应", method) + return nil, fmt.Errorf("AI Studio %s transport returned empty response", method) } if response.StatusCode != http.StatusOK { defer response.Body.Close() raw, readErr := io.ReadAll(response.Body) if readErr != nil { - return nil, fmt.Errorf("读取 AI Studio %s 错误响应: %w", method, readErr) + return nil, fmt.Errorf("read AI Studio %s error response: %w", method, readErr) } return nil, DecodeRPCError(method, response.StatusCode, raw) } @@ -201,12 +201,12 @@ func validateRPCResponse(method string, response *RPCResponse) (*RPCResponse, er mediaType, _, err := mime.ParseMediaType(contentType) if err != nil || !strings.EqualFold(mediaType, JSONProtobufContentType) { response.Body.Close() - return nil, fmt.Errorf("AI Studio %s 返回未识别的 Content-Type %q", method, contentType) + return nil, fmt.Errorf("AI Studio %s returned unrecognized Content-Type %q", method, contentType) } return response, nil } -// DecodeRPCError 解析独立状态和流式封装中的上游错误 +// DecodeRPCError parses upstream errors in standalone status and streaming envelopes func DecodeRPCError(method string, statusCode int, raw []byte) *RPCError { rpcError := &RPCError{ Method: method, diff --git a/internal/aistudio/compatibility_test.go b/internal/aistudio/compatibility_test.go index 56966c0..c07ca9d 100644 --- a/internal/aistudio/compatibility_test.go +++ b/internal/aistudio/compatibility_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -// TestAgentToolSchemaCompatibility 验证常见 Agent 工具 Schema 的稳定转换规则 +// TestAgentToolSchemaCompatibility verifies stable transformation rules for common agent tool schemas func TestAgentToolSchemaCompatibility(t *testing.T) { t.Run("string const", func(t *testing.T) { wire, err := encodeJSONSchema(json.RawMessage(`{ @@ -71,7 +71,7 @@ func TestAgentToolSchemaCompatibility(t *testing.T) { }) } -// TestFunctionCallThoughtSignature 验证历史工具调用的签名补齐与原值保留 +// TestFunctionCallThoughtSignature verifies thought signature completion and original value preservation for historical tool calls func TestFunctionCallThoughtSignature(t *testing.T) { for _, test := range []struct { name string @@ -96,7 +96,7 @@ func TestFunctionCallThoughtSignature(t *testing.T) { } } -// TestRPCErrorCompatibility 验证真实错误帧解码和 Drive 授权边界 +// TestRPCErrorCompatibility verifies decoding of real error frames and Drive authorization boundaries func TestRPCErrorCompatibility(t *testing.T) { t.Run("direct", func(t *testing.T) { err := DecodeRPCError("GenerateAccessToken", http.StatusUnauthorized, []byte( diff --git a/internal/aistudio/decoder.go b/internal/aistudio/decoder.go index 302c912..e38e668 100644 --- a/internal/aistudio/decoder.go +++ b/internal/aistudio/decoder.go @@ -10,7 +10,7 @@ import ( "unicode" ) -// ProtocolEvidenceError 保存无法解释的协议位置和原始值 +// ProtocolEvidenceError stores unexplainable protocol positions and raw values type ProtocolEvidenceError struct { Method string Path string @@ -18,26 +18,26 @@ type ProtocolEvidenceError struct { Raw json.RawMessage } -// PromptFeedbackError 表示上游拒绝当前输入且没有返回候选 +// PromptFeedbackError indicates that upstream rejected the prompt and returned no candidates type PromptFeedbackError struct { Reason string Raw json.RawMessage } -// Error 返回协议证据错误 +// Error returns the protocol evidence error func (e *ProtocolEvidenceError) Error() string { if e.Method == "" { - return fmt.Sprintf("协议位置 %s: %s", e.Path, e.Detail) + return fmt.Sprintf("protocol path %s: %s", e.Path, e.Detail) } - return fmt.Sprintf("AI Studio %s 协议位置 %s: %s", e.Method, e.Path, e.Detail) + return fmt.Sprintf("AI Studio %s protocol path %s: %s", e.Method, e.Path, e.Detail) } -// Error 返回上游输入拒绝原因 +// Error returns the reason for upstream prompt rejection func (e *PromptFeedbackError) Error() string { - return fmt.Sprintf("AI Studio 拒绝当前输入: %s", e.Reason) + return fmt.Sprintf("AI Studio rejected prompt: %s", e.Reason) } -// Unwrap 将输入拒绝映射为无效请求 +// Unwrap maps prompt rejection to invalid argument func (e *PromptFeedbackError) Unwrap() error { return ErrInvalidArgument } @@ -118,7 +118,7 @@ func decodeJSONValue(raw []byte) (json.RawMessage, error) { var extra json.RawMessage if err := decoder.Decode(&extra); err != io.EOF { if err == nil { - return nil, fmt.Errorf("JSON+protobuf 包含多个根值") + return nil, fmt.Errorf("JSON+protobuf contains multiple root values") } return nil, err } @@ -133,17 +133,17 @@ func decodeGenerateItems(source io.Reader, consume func(json.RawMessage) error) return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: err.Error()} } if delimiter, ok := rootStart.(json.Delim); !ok || delimiter != '[' { - return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: "根值不是数组"} + return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: "root value is not an array"} } if !decoder.More() { - return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: "根数组缺少 field 1"} + return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: "root array missing field 1"} } fieldStart, err := decoder.Token() if err != nil { return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$[0]", Detail: err.Error()} } if delimiter, ok := fieldStart.(json.Delim); !ok || delimiter != '[' { - return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$[0]", Detail: "field 1 不是 repeated 数组"} + return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$[0]", Detail: "field 1 is not a repeated array"} } index := 0 for decoder.More() { @@ -160,7 +160,7 @@ func decodeGenerateItems(source io.Reader, consume func(json.RawMessage) error) if err != nil { return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$[0]", Detail: err.Error()} } - return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$[0]", Detail: "field 1 没有正常结束"} + return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$[0]", Detail: "field 1 did not close properly"} } fieldIndex := 1 for decoder.More() { @@ -174,12 +174,12 @@ func decodeGenerateItems(source io.Reader, consume func(json.RawMessage) error) if err != nil { return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: err.Error()} } - return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: "根数组没有正常结束"} + return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: "root array did not close properly"} } var extra json.RawMessage if err := decoder.Decode(&extra); err != io.EOF { if err == nil { - return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: "响应后存在第二个根值", Raw: extra} + return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: "extra root value after response", Raw: extra} } return &ProtocolEvidenceError{Method: "GenerateContent", Path: "$", Detail: err.Error()} } @@ -191,7 +191,7 @@ func rawArray(raw json.RawMessage, path string, evidence json.RawMessage) ([]jso decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&values); err != nil { - return nil, &ProtocolEvidenceError{Path: path, Detail: "期望数组", Raw: cloneEvidence(raw, evidence)} + return nil, &ProtocolEvidenceError{Path: path, Detail: "expected array", Raw: cloneEvidence(raw, evidence)} } return values, nil } @@ -199,7 +199,7 @@ func rawArray(raw json.RawMessage, path string, evidence json.RawMessage) ([]jso func rawString(raw json.RawMessage, path string, evidence json.RawMessage) (string, error) { var value string if err := json.Unmarshal(raw, &value); err != nil { - return "", &ProtocolEvidenceError{Path: path, Detail: "期望字符串", Raw: cloneEvidence(raw, evidence)} + return "", &ProtocolEvidenceError{Path: path, Detail: "expected string", Raw: cloneEvidence(raw, evidence)} } return value, nil } @@ -209,11 +209,11 @@ func rawInt64(raw json.RawMessage, path string, evidence json.RawMessage) (int64 decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&number); err != nil { - return 0, &ProtocolEvidenceError{Path: path, Detail: "期望整数", Raw: cloneEvidence(raw, evidence)} + return 0, &ProtocolEvidenceError{Path: path, Detail: "expected integer", Raw: cloneEvidence(raw, evidence)} } value, err := strconv.ParseInt(number.String(), 10, 64) if err != nil { - return 0, &ProtocolEvidenceError{Path: path, Detail: "期望整数", Raw: cloneEvidence(raw, evidence)} + return 0, &ProtocolEvidenceError{Path: path, Detail: "expected integer", Raw: cloneEvidence(raw, evidence)} } return value, nil } @@ -223,11 +223,11 @@ func rawFloat64(raw json.RawMessage, path string, evidence json.RawMessage) (flo decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&number); err != nil { - return 0, &ProtocolEvidenceError{Path: path, Detail: "期望数字", Raw: cloneEvidence(raw, evidence)} + return 0, &ProtocolEvidenceError{Path: path, Detail: "expected number", Raw: cloneEvidence(raw, evidence)} } value, err := strconv.ParseFloat(number.String(), 64) if err != nil { - return 0, &ProtocolEvidenceError{Path: path, Detail: "期望数字", Raw: cloneEvidence(raw, evidence)} + return 0, &ProtocolEvidenceError{Path: path, Detail: "expected number", Raw: cloneEvidence(raw, evidence)} } return value, nil } @@ -241,7 +241,7 @@ func rawBool(raw json.RawMessage, path string, evidence json.RawMessage) (bool, } var value bool if err := json.Unmarshal(raw, &value); err != nil { - return false, &ProtocolEvidenceError{Path: path, Detail: "期望布尔值", Raw: cloneEvidence(raw, evidence)} + return false, &ProtocolEvidenceError{Path: path, Detail: "expected boolean", Raw: cloneEvidence(raw, evidence)} } return value, nil } diff --git a/internal/aistudio/event.go b/internal/aistudio/event.go index 5d4515d..4be4067 100644 --- a/internal/aistudio/event.go +++ b/internal/aistudio/event.go @@ -5,19 +5,19 @@ import ( "fmt" ) -// FrameDecoder 将 field 1 repeated 帧转换为规范事件 +// FrameDecoder converts field 1 repeated frames into canonical events type FrameDecoder struct { usage *Usage finished bool lastFrame json.RawMessage } -// NewFrameDecoder 创建单次 GenerateContent 的有状态解码器 +// NewFrameDecoder creates a stateful decoder for a single GenerateContent call func NewFrameDecoder() *FrameDecoder { return &FrameDecoder{} } -// Decode 解码一条 repeated 流帧 +// Decode decodes a single repeated stream frame func (d *FrameDecoder) Decode(raw json.RawMessage) ([]Event, error) { d.lastFrame = append(json.RawMessage(nil), raw...) frame, err := rawArray(raw, "$[0][]", raw) @@ -25,7 +25,7 @@ func (d *FrameDecoder) Decode(raw json.RawMessage) ([]Event, error) { return nil, withMethod(err, "GenerateContent") } if len(frame) == 0 { - return nil, d.protocolError("$[0][]", "空流帧", raw) + return nil, d.protocolError("$[0][]", "empty stream frame", raw) } if isJSONNull(frame[0]) { if feedbackRaw := rawAt(frame, 1); !isJSONNull(feedbackRaw) { @@ -38,7 +38,7 @@ func (d *FrameDecoder) Decode(raw json.RawMessage) ([]Event, error) { return nil, withMethod(err, "GenerateContent") } if len(candidates) != 1 { - return nil, d.protocolError("$[0][][0]", "候选数量不是现场确认的 1", raw) + return nil, d.protocolError("$[0][][0]", "candidate count is not the live-confirmed 1", raw) } candidate, err := rawArray(candidates[0], "$[0][][0][0]", raw) if err != nil { @@ -173,12 +173,12 @@ func decodeFinishReason(code int64) string { } } -// End 校验流已经出现正常完成帧 +// End validates that the stream encountered a normal completion frame func (d *FrameDecoder) End() error { if d.finished { return nil } - return d.protocolError("$", "流结束前没有完成帧", d.lastFrame) + return d.protocolError("$", "stream ended without a completion frame", d.lastFrame) } func (d *FrameDecoder) decodeContent(raw json.RawMessage, evidence json.RawMessage) ([]Event, error) { @@ -194,7 +194,7 @@ func (d *FrameDecoder) decodeContent(raw json.RawMessage, evidence json.RawMessa return nil, withMethod(err, "GenerateContent") } if role != "model" { - return nil, d.protocolError("$[0][][0][0][0][1]", "响应角色不是 model", rawAt(content, 1)) + return nil, d.protocolError("$[0][][0][0][0][1]", "response role is not model", rawAt(content, 1)) } partsRaw := rawAt(content, 0) if isJSONNull(partsRaw) { diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index accc953..4a3253c 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -12,13 +12,13 @@ import ( var errStopSequenceMatched = errors.New("stop sequence matched") -// tokenCountResult 保存并发输入计数结果 +// tokenCountResult stores concurrent input count results type tokenCountResult struct { count TokenCount err error } -// EncodeGenerateContentRequest 编码当前成功基线的 GenerateContent 数组 +// EncodeGenerateContentRequest encodes the GenerateContent array for the current baseline func EncodeGenerateContentRequest(request GenerateRequest, defaults GenerationDefaults, runtime RequestContext) ([]byte, error) { tools, explicitTools, err := encodeRequestedTools(request.Tools) if err != nil { @@ -29,7 +29,7 @@ func EncodeGenerateContentRequest(request GenerateRequest, defaults GenerationDe return nil, err } if len(contents) == 0 { - return nil, fmt.Errorf("GenerateContent contents 不能为空") + return nil, fmt.Errorf("GenerateContent contents cannot be empty") } config, err := encodeGenerationConfig(request.Config, defaults) if err != nil { @@ -80,20 +80,20 @@ func encodeGenerationConfig(config GenerationConfig, defaults GenerationDefaults case "minimal": thinkingLevel = 4 default: - return nil, fmt.Errorf("reasoning effort 必须是 minimal、low、medium 或 high") + return nil, fmt.Errorf("reasoning effort must be minimal, low, medium, or high") } if hasReasoningEffort && defaults.ThinkingLevel { thinkingLevel = closestSupportedThinkingLevel(thinkingLevel, defaults.ThinkingLevels) } if hasReasoningEffort && !defaults.ThinkingLevel { if thinkingBudget == nil || !defaults.ThinkingBudget { - return nil, fmt.Errorf("模型不支持 thinking level") + return nil, fmt.Errorf("model does not support thinking level") } hasReasoningEffort = false } if thinkingBudget != nil && !defaults.ThinkingBudget { if !hasReasoningEffort || !defaults.ThinkingLevel { - return nil, fmt.Errorf("模型不支持 thinking budget") + return nil, fmt.Errorf("model does not support thinking budget") } thinkingBudget = nil } @@ -103,31 +103,31 @@ func encodeGenerationConfig(config GenerationConfig, defaults GenerationDefaults maxOutput = *config.MaxOutputTokens } if includeMaxOutput && maxOutput <= 0 { - return nil, fmt.Errorf("模型目录缺少有效 output token limit") + return nil, fmt.Errorf("model catalog missing valid output token limit") } if includeMaxOutput && maxOutput > defaults.MaxOutputTokens { - return nil, fmt.Errorf("max output tokens %d 超过模型上限 %d", maxOutput, defaults.MaxOutputTokens) + return nil, fmt.Errorf("max output tokens %d exceeds model limit %d", maxOutput, defaults.MaxOutputTokens) } temperature := defaults.Temperature if config.Temperature != nil { temperature = config.Temperature } if temperature != nil && (*temperature < 0 || *temperature > 2) { - return nil, fmt.Errorf("temperature 必须在 0 到 2 之间") + return nil, fmt.Errorf("temperature must be between 0 and 2") } topP := defaults.TopP if config.TopP != nil { topP = config.TopP } if topP != nil && (*topP < 0 || *topP > 1) { - return nil, fmt.Errorf("top_p 必须在 0 到 1 之间") + return nil, fmt.Errorf("top_p must be between 0 and 1") } topK := defaults.TopK if config.TopK != nil { topK = config.TopK } if topK != nil && *topK < 0 { - return nil, fmt.Errorf("top_k 不能为负数") + return nil, fmt.Errorf("top_k cannot be negative") } responseModalities, err := encodeResponseModalities(config.ResponseModalities) if err != nil { @@ -256,11 +256,11 @@ func encodeResponseModalities(modalities []ResponseModality) ([]int64, error) { case ResponseModalityAudio: hasAudio = true default: - return nil, fmt.Errorf("response modality %q 不受支持", modality) + return nil, fmt.Errorf("response modality %q is not supported", modality) } } if hasAudio && (hasText || hasImage) { - return nil, fmt.Errorf("AUDIO 不能和其他 response modality 同时使用") + return nil, fmt.Errorf("AUDIO cannot be used together with other response modalities") } switch { case hasAudio: @@ -301,7 +301,7 @@ func encodeSpeechConfig(config *SpeechConfig) ([]any, error) { } voiceName := strings.TrimSpace(config.VoiceName) if voiceName != "" && len(config.Speakers) > 0 { - return nil, fmt.Errorf("speech config 不能同时设置 voice 和 multi-speaker") + return nil, fmt.Errorf("speech config cannot set both voice and multi-speaker") } var wire []any if voiceName != "" { @@ -313,7 +313,7 @@ func encodeSpeechConfig(config *SpeechConfig) ([]any, error) { name := strings.TrimSpace(speaker.Speaker) voice := strings.TrimSpace(speaker.VoiceName) if name == "" || voice == "" { - return nil, fmt.Errorf("speech config speakers[%d] 需要 speaker 和 voiceName", index) + return nil, fmt.Errorf("speech config speakers[%d] requires speaker and voiceName", index) } speakers = append(speakers, []any{name, []any{[]any{voice}}}) } @@ -387,7 +387,7 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan if c.contextProvider != nil { runtime, err = c.contextProvider.RequestContext(ctx, request.AccountID) if err != nil { - return nil, fmt.Errorf("读取 AI Studio 请求上下文: %w", err) + return nil, fmt.Errorf("read AI Studio request context: %w", err) } } wireRequest := request @@ -537,7 +537,7 @@ func (c *Client) Generate(ctx context.Context, request GenerateRequest) (<-chan return events, nil } -// DecodeGenerateStream 按网络到达顺序解码 GenerateContent repeated 帧 +// DecodeGenerateStream decodes GenerateContent repeated frames in network arrival order func DecodeGenerateStream(source io.Reader, decoder *FrameDecoder, emit func(Event) error) error { return decodeGenerateItems(source, func(raw json.RawMessage) error { events, err := decoder.Decode(raw) diff --git a/internal/aistudio/local_defaults.go b/internal/aistudio/local_defaults.go index 6b186e8..5649da0 100644 --- a/internal/aistudio/local_defaults.go +++ b/internal/aistudio/local_defaults.go @@ -5,12 +5,12 @@ const ( fallbackAccountTimezone = "UTC" ) -// DefaultAccountLocale 返回当前用户的系统语言 +// DefaultAccountLocale returns the current user's system locale func DefaultAccountLocale() string { return localAccountLocale() } -// DefaultAccountTimezone 返回当前用户的 IANA 时区 +// DefaultAccountTimezone returns the current user's IANA timezone func DefaultAccountTimezone() string { return localAccountTimezone() } diff --git a/internal/aistudio/login_native.go b/internal/aistudio/login_native.go index 8f063b7..09dfff4 100644 --- a/internal/aistudio/login_native.go +++ b/internal/aistudio/login_native.go @@ -13,7 +13,7 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/camoufoxnative" ) -// NativeLoginDriver 通过纯 Go WebDriver BiDi 完成隔离登录 +// NativeLoginDriver completes isolated login via pure-Go WebDriver BiDi type NativeLoginDriver struct { camoufox string timeout time.Duration @@ -21,33 +21,33 @@ type NativeLoginDriver struct { var _ IsolatedLoginDriver = (*NativeLoginDriver)(nil) -// NewNativeLoginDriver 创建纯 Go Camoufox 登录驱动 +// NewNativeLoginDriver creates a pure-Go Camoufox login driver func NewNativeLoginDriver(camoufoxPath string, timeout time.Duration) (*NativeLoginDriver, error) { camoufoxPath = strings.TrimSpace(camoufoxPath) if camoufoxPath == "" { - return nil, errors.New("缺少 Camoufox 路径") + return nil, errors.New("missing Camoufox path") } absolute, err := filepath.Abs(camoufoxPath) if err != nil { - return nil, fmt.Errorf("解析 Camoufox 路径: %w", err) + return nil, fmt.Errorf("resolve Camoufox path: %w", err) } info, err := os.Stat(absolute) if err != nil { - return nil, fmt.Errorf("读取 Camoufox: %w", err) + return nil, fmt.Errorf("stat Camoufox: %w", err) } if info.IsDir() { - return nil, errors.New("Camoufox 路径是目录") + return nil, errors.New("Camoufox path is a directory") } if timeout <= 0 { - return nil, errors.New("Camoufox 登录超时必须为正数") + return nil, errors.New("Camoufox login timeout must be positive") } return &NativeLoginDriver{camoufox: absolute, timeout: timeout}, nil } -// Login 启动可见隔离 Camoufox 并导出认证状态 +// Login starts visible isolated Camoufox and exports authentication state func (driver *NativeLoginDriver) Login(ctx context.Context, request IsolatedLoginRequest) (IsolatedLoginResult, error) { if driver == nil { - return IsolatedLoginResult{}, errors.New("纯 Go Camoufox 登录驱动未初始化") + return IsolatedLoginResult{}, errors.New("pure-Go Camoufox login driver is not initialized") } result, err := camoufoxnative.Login(ctx, driver.options(request)) if err != nil { @@ -55,7 +55,7 @@ func (driver *NativeLoginDriver) Login(ctx context.Context, request IsolatedLogi } var state StorageState if err := json.Unmarshal(result.StorageStateJSON, &state); err != nil { - return IsolatedLoginResult{}, fmt.Errorf("解析隔离登录状态: %w", err) + return IsolatedLoginResult{}, fmt.Errorf("parse isolated login state: %w", err) } if err := state.Validate(); err != nil { return IsolatedLoginResult{}, err @@ -68,17 +68,17 @@ func (driver *NativeLoginDriver) Login(ctx context.Context, request IsolatedLogi return IsolatedLoginResult{StorageState: state, Email: result.Email, VerifiedAt: result.VerifiedAt}, nil } -// Verify 使用无头隔离 Camoufox 验证已有认证状态 +// Verify uses headless isolated Camoufox to verify existing authentication state func (driver *NativeLoginDriver) Verify(ctx context.Context, request IsolatedLoginRequest, state StorageState) (LoginVerification, error) { if driver == nil { - return LoginVerification{}, errors.New("纯 Go Camoufox 登录驱动未初始化") + return LoginVerification{}, errors.New("pure-Go Camoufox login driver is not initialized") } if err := state.Validate(); err != nil { return LoginVerification{}, err } encoded, err := json.Marshal(state) if err != nil { - return LoginVerification{}, fmt.Errorf("编码隔离验证状态: %w", err) + return LoginVerification{}, fmt.Errorf("encode isolated verification state: %w", err) } verification, err := camoufoxnative.Verify(ctx, driver.options(request), encoded) if err != nil { diff --git a/internal/aistudio/media.go b/internal/aistudio/media.go index 52ecdec..f5e4b7e 100644 --- a/internal/aistudio/media.go +++ b/internal/aistudio/media.go @@ -11,7 +11,7 @@ func decodeInlineMedia(raw json.RawMessage, path string, evidence json.RawMessag return Media{}, withMethod(err, "GenerateContent") } if len(values) < 2 { - return Media{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "inline media 字段不足", Raw: raw} + return Media{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "insufficient inline media fields", Raw: raw} } mime, err := rawString(values[0], path+"[0]", raw) if err != nil { @@ -23,7 +23,7 @@ func decodeInlineMedia(raw json.RawMessage, path string, evidence json.RawMessag } data, err := base64.StdEncoding.DecodeString(encoded) if err != nil { - return Media{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[1]", Detail: "inline media 不是有效 Base64", Raw: raw} + return Media{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[1]", Detail: "inline media is not valid Base64", Raw: raw} } return Media{MIME: mime, Data: data}, nil } diff --git a/internal/aistudio/models.go b/internal/aistudio/models.go index 7e1cc0d..0dd6db5 100644 --- a/internal/aistudio/models.go +++ b/internal/aistudio/models.go @@ -49,7 +49,7 @@ var imageResolutions = map[int64]string{1: "1K", 2: "2K", 3: "4K", 4: "512"} var videoDurations = map[int64]string{1: "5", 2: "6", 3: "7", 4: "8", 5: "4"} var videoResolutions = map[int64]string{1: "720p", 2: "1080p", 3: "4k", 4: "368p", 5: "360p"} -// GenerationDefaults 保存 ListModels 返回的生成默认值 +// GenerationDefaults stores generation defaults returned by ListModels type GenerationDefaults struct { MaxOutputTokens int64 Temperature *float64 @@ -72,7 +72,7 @@ type modelCatalog struct { entries map[string]modelEntry } -// ParseModels 解码 ListModels 的现场数组协议 +// ParseModels decodes the live array protocol of ListModels func ParseModels(source io.Reader) ([]Model, error) { catalog, err := parseModelCatalog(source) if err != nil { @@ -84,14 +84,14 @@ func ParseModels(source io.Reader) ([]Model, error) { func parseModelCatalog(source io.Reader) (modelCatalog, error) { raw, err := io.ReadAll(newSparseJSONReader(source)) if err != nil { - return modelCatalog{}, fmt.Errorf("读取 ListModels: %w", err) + return modelCatalog{}, fmt.Errorf("read ListModels: %w", err) } root, err := rawArray(raw, "$", raw) if err != nil { return modelCatalog{}, withMethod(err, "ListModels") } if len(root) == 0 || isJSONNull(root[0]) { - return modelCatalog{}, &ProtocolEvidenceError{Method: "ListModels", Path: "$[0]", Detail: "缺少模型列表", Raw: raw} + return modelCatalog{}, &ProtocolEvidenceError{Method: "ListModels", Path: "$[0]", Detail: "missing model list", Raw: raw} } rows, err := rawArray(root[0], "$[0]", raw) if err != nil { @@ -104,7 +104,7 @@ func parseModelCatalog(source io.Reader) (modelCatalog, error) { return modelCatalog{}, err } if _, exists := catalog.entries[entry.model.ID]; exists { - return modelCatalog{}, &ProtocolEvidenceError{Method: "ListModels", Path: fmt.Sprintf("$[0][%d][0]", index), Detail: "模型 ID 重复", Raw: rowRaw} + return modelCatalog{}, &ProtocolEvidenceError{Method: "ListModels", Path: fmt.Sprintf("$[0][%d][0]", index), Detail: "duplicate model ID", Raw: rowRaw} } catalog.models = append(catalog.models, entry.model) catalog.entries[entry.model.ID] = entry @@ -178,7 +178,7 @@ func decodeModelRow(raw json.RawMessage, rowIndex int) (modelEntry, error) { } id := strings.TrimPrefix(wireName, "models/") if id == "" { - return modelEntry{}, &ProtocolEvidenceError{Method: "ListModels", Path: path + "[0]", Detail: "模型 ID 为空", Raw: raw} + return modelEntry{}, &ProtocolEvidenceError{Method: "ListModels", Path: path + "[0]", Detail: "model ID is empty", Raw: raw} } model := Model{ ID: id, @@ -306,7 +306,7 @@ func (c *Client) Models(ctx context.Context) ([]Model, error) { return c.ModelsForAccount(ctx, "") } -// ModelsForAccount 读取指定账户的实时模型目录 +// ModelsForAccount reads the live model catalog for the specified account func (c *Client) ModelsForAccount(ctx context.Context, accountID string) ([]Model, error) { catalog, err := c.loadModels(ctx, accountID) if err != nil { @@ -371,7 +371,7 @@ func requiredStringField(row []json.RawMessage, index int, path string, evidence return "", err } if value == "" { - return "", &ProtocolEvidenceError{Method: "ListModels", Path: fmt.Sprintf("%s[%d]", path, index), Detail: "必需字符串为空", Raw: evidence} + return "", &ProtocolEvidenceError{Method: "ListModels", Path: fmt.Sprintf("%s[%d]", path, index), Detail: "required string is empty", Raw: evidence} } return value, nil } diff --git a/internal/aistudio/quota.go b/internal/aistudio/quota.go index 380d4c5..40d4e98 100644 --- a/internal/aistudio/quota.go +++ b/internal/aistudio/quota.go @@ -11,18 +11,18 @@ import ( const quotaResetTimezone = "America/Los_Angeles" -// CooldownState 表示账户或模型暂时不可调度的状态 +// CooldownState represents the state where an account or model is temporarily unschedulable type CooldownState struct { Until time.Time `json:"until"` Reason string `json:"reason,omitempty"` } -// Active 判断冷却状态当前是否生效 +// Active checks whether the cooldown state is currently active func (c CooldownState) Active(now time.Time) bool { return !c.Until.IsZero() && now.Before(c.Until) } -// QuotaCooldown 表示上游额度限制对应的调度冷却 +// QuotaCooldown represents the scheduling cooldown corresponding to upstream quota limits type QuotaCooldown struct { Until time.Time Global bool @@ -30,7 +30,7 @@ type QuotaCooldown struct { Reason string } -// QuotaCooldownForError 解析上游分钟或每日额度限制 +// QuotaCooldownForError parses upstream minute or daily quota limits func QuotaCooldownForError(err error, now time.Time) (QuotaCooldown, bool) { var rpcError *RPCError if !errors.As(err, &rpcError) || rpcError.StatusCode != http.StatusTooManyRequests { @@ -48,14 +48,14 @@ func QuotaCooldownForError(err error, now time.Time) (QuotaCooldown, bool) { global := strings.Contains(metadata, "_global") || strings.Contains(metadata, "perprojectperuser") || !strings.Contains(evidence, "per_model") && !strings.Contains(evidence, "per model") return QuotaCooldown{ - Until: until, Global: global, Kind: "分钟限额", - Reason: "分钟限额: " + err.Error(), + Until: until, Global: global, Kind: "minute_quota", + Reason: "minute quota exceeded: " + err.Error(), }, true } if dailyQuotaEvidence(evidence) || strings.Contains(message, "you exceeded your current quota") { return QuotaCooldown{ - Until: nextQuotaDay(now), Kind: "每日限额", - Reason: "每日限额: " + err.Error(), + Until: nextQuotaDay(now), Kind: "daily_quota", + Reason: "daily quota exceeded: " + err.Error(), }, true } return QuotaCooldown{}, false diff --git a/internal/aistudio/request.go b/internal/aistudio/request.go index 1423c7c..9f027af 100644 --- a/internal/aistudio/request.go +++ b/internal/aistudio/request.go @@ -9,17 +9,17 @@ import ( "strings" ) -// UnverifiedProtocolError 表示当前现场证据尚不足以发送某项能力 +// UnverifiedProtocolError indicates that current live evidence is insufficient to transmit a capability type UnverifiedProtocolError struct { Feature string } -// Error 返回未验证协议边界 +// Error returns the unverified protocol boundary func (e *UnverifiedProtocolError) Error() string { - return "AI Studio 协议能力尚无成功现场证据: " + e.Feature + return "AI Studio protocol feature lacks verified live evidence: " + e.Feature } -// EncodeCountTokensRequest 编码现场确认的 CountTokens 请求 +// EncodeCountTokensRequest encodes the live-confirmed CountTokens request func EncodeCountTokensRequest(request TokenCountRequest) ([]byte, error) { tools, explicitTools, err := encodeRequestedTools(request.Tools) if err != nil { @@ -30,7 +30,7 @@ func EncodeCountTokensRequest(request TokenCountRequest) ([]byte, error) { return nil, err } if len(contents) == 0 && request.System == "" { - return nil, fmt.Errorf("CountTokens contents 不能为空") + return nil, fmt.Errorf("CountTokens contents cannot be empty") } if request.System != "" || explicitTools || countTokensNeedsGenerateRequest(request.Contents) { length := 2 @@ -56,18 +56,18 @@ func EncodeCountTokensRequest(request TokenCountRequest) ([]byte, error) { return json.Marshal([]any{wireModelName(request.Model), contents}) } -// ParseTokenCount 解码现场确认的 CountTokens field 1 +// ParseTokenCount decodes field 1 of live-confirmed CountTokens func ParseTokenCount(source io.Reader) (TokenCount, error) { raw, err := io.ReadAll(newSparseJSONReader(source)) if err != nil { - return TokenCount{}, fmt.Errorf("读取 CountTokens: %w", err) + return TokenCount{}, fmt.Errorf("read CountTokens: %w", err) } root, err := rawArray(raw, "$", raw) if err != nil { return TokenCount{}, withMethod(err, "CountTokens") } if len(root) == 0 || isJSONNull(root[0]) { - return TokenCount{}, &ProtocolEvidenceError{Method: "CountTokens", Path: "$[0]", Detail: "缺少权威 token 总数", Raw: raw} + return TokenCount{}, &ProtocolEvidenceError{Method: "CountTokens", Path: "$[0]", Detail: "missing authoritative total token count", Raw: raw} } count, err := rawInt64(root[0], "$[0]", raw) if err != nil { @@ -80,7 +80,7 @@ func (c *Client) CountTokens(ctx context.Context, request TokenCountRequest) (To return c.CountTokensForAccount(ctx, "", request) } -// CountTokensForAccount 使用指定账户调用权威 token 计数 +// CountTokensForAccount calls authoritative token counting using the specified account func (c *Client) CountTokensForAccount(ctx context.Context, accountID string, request TokenCountRequest) (TokenCount, error) { body, err := EncodeCountTokensRequest(request) if err != nil { @@ -100,7 +100,7 @@ func encodeContents(contents []Content) ([]any, error) { for index, content := range contents { encoded, err := encodeContent(content, functionNames) if err != nil { - return nil, fmt.Errorf("编码 content %d: %w", index, err) + return nil, fmt.Errorf("encode content %d: %w", index, err) } wire = append(wire, encoded) } @@ -118,10 +118,10 @@ func encodeContent(content Content, functionNames map[string]string) ([]any, err case RoleTool: role = "user" default: - return nil, fmt.Errorf("未知 content role %q", content.Role) + return nil, fmt.Errorf("unknown content role %q", content.Role) } if len(content.Parts) == 0 { - return nil, fmt.Errorf("content parts 不能为空") + return nil, fmt.Errorf("content parts cannot be empty") } parts := make([]any, 0, len(content.Parts)) for index, part := range content.Parts { @@ -134,7 +134,7 @@ func encodeContent(content Content, functionNames map[string]string) ([]any, err } encoded, err := encodePart(part) if err != nil { - return nil, fmt.Errorf("编码 part %d: %w", index, err) + return nil, fmt.Errorf("encode part %d: %w", index, err) } parts = append(parts, encoded) } @@ -171,7 +171,7 @@ func encodePart(part Part) ([]any, error) { return setPartThoughtSignature([]any{}, part.ThoughtSignature), nil } if variants != 1 { - return nil, fmt.Errorf("part 必须且只能设置一种内容") + return nil, fmt.Errorf("part must set exactly one variant") } if part.Text != "" { wire := []any{nil, part.Text} @@ -185,14 +185,14 @@ func encodePart(part Part) ([]any, error) { } if part.InlineData != nil { if part.InlineData.MIME == "" || len(part.InlineData.Data) == 0 { - return nil, fmt.Errorf("inline data 缺少 MIME 或数据") + return nil, fmt.Errorf("inline data missing MIME or data") } wire := []any{nil, nil, []any{part.InlineData.MIME, base64.StdEncoding.EncodeToString(part.InlineData.Data)}} return setPartThoughtSignature(wire, part.ThoughtSignature), nil } if part.ExternalMedia != nil { if part.ExternalMedia.MIME == "" || part.ExternalMedia.URL == "" { - return nil, fmt.Errorf("外部媒体缺少 MIME 或 URL") + return nil, fmt.Errorf("external media missing MIME or URL") } wire := make([]any, 7) wire[6] = []any{part.ExternalMedia.MIME, part.ExternalMedia.URL} @@ -230,7 +230,7 @@ func encodePart(part Part) ([]any, error) { } if part.FunctionResult != nil { if part.FunctionResult.Name == "" { - return nil, fmt.Errorf("function result 缺少名称且无法按 call ID 解析") + return nil, fmt.Errorf("function result missing name and cannot be resolved by call ID") } response, err := encodeWireStructJSON(part.FunctionResult.Content) if err != nil { @@ -247,7 +247,7 @@ func encodePart(part Part) ([]any, error) { if part.ExecutableCode != nil { language, ok := map[string]int64{"LANGUAGE_UNSPECIFIED": 0, "PYTHON": 1}[part.ExecutableCode.Language] if !ok { - return nil, fmt.Errorf("未识别的 executable code language %q", part.ExecutableCode.Language) + return nil, fmt.Errorf("unrecognized executable code language %q", part.ExecutableCode.Language) } wire := make([]any, 8) wire[7] = []any{language, part.ExecutableCode.Code} @@ -260,7 +260,7 @@ func encodePart(part Part) ([]any, error) { "OUTCOME_DEADLINE_EXCEEDED": 3, }[part.CodeExecutionResult.Outcome] if !ok { - return nil, fmt.Errorf("未识别的 code execution outcome %q", part.CodeExecutionResult.Outcome) + return nil, fmt.Errorf("unrecognized code execution outcome %q", part.CodeExecutionResult.Outcome) } result := []any{outcome} value := part.CodeExecutionResult.Output diff --git a/internal/aistudio/request_phase.go b/internal/aistudio/request_phase.go index d069228..e7f2f9e 100644 --- a/internal/aistudio/request_phase.go +++ b/internal/aistudio/request_phase.go @@ -2,21 +2,21 @@ package aistudio import "context" -// RequestPhase 表示受保护请求的当前准备阶段 +// RequestPhase represents the current preparation phase of a protected request type RequestPhase string const ( - // RequestPhasePreparingWAA 表示正在生成 fresh WAA proof + // RequestPhasePreparingWAA indicates generating a fresh WAA proof RequestPhasePreparingWAA RequestPhase = "preparing_waa" - // RequestPhaseSendingUpstream 表示正在等待 AI Studio 响应头 + // RequestPhaseSendingUpstream indicates waiting for AI Studio response headers RequestPhaseSendingUpstream RequestPhase = "sending_upstream" - // RequestPhaseStreaming 表示 AI Studio 已经返回流式响应 + // RequestPhaseStreaming indicates AI Studio has returned a streaming response RequestPhaseStreaming RequestPhase = "streaming" ) type requestPhaseContextKey struct{} -// ContextWithRequestPhaseObserver 记录受保护请求阶段 +// ContextWithRequestPhaseObserver records the protected request phase func ContextWithRequestPhaseObserver(ctx context.Context, observer func(RequestPhase)) context.Context { return context.WithValue(ctx, requestPhaseContextKey{}, observer) } diff --git a/internal/aistudio/runtime_native.go b/internal/aistudio/runtime_native.go index 1ff5f4d..430d411 100644 --- a/internal/aistudio/runtime_native.go +++ b/internal/aistudio/runtime_native.go @@ -11,7 +11,7 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/camoufoxnative" ) -// NativeWorker 将纯 Go Camoufox runtime 适配为 WAA preparer +// NativeWorker adapts pure-Go Camoufox runtime as a WAA preparer type NativeWorker struct { accountID string runtime *camoufoxnative.Worker @@ -23,10 +23,10 @@ type NativeWorker struct { var _ ProtectedPreparer = (*NativeWorker)(nil) var _ ProtocolHeaderProvider = (*NativeWorker)(nil) -// NewNativeWorker 启动单个账户的纯 Go Camoufox runtime +// NewNativeWorker starts a pure-Go Camoufox runtime for a single account func NewNativeWorker(ctx context.Context, accountID string, options camoufoxnative.Options) (*NativeWorker, error) { if accountID == "" { - return nil, fmt.Errorf("缺少账户 ID") + return nil, fmt.Errorf("missing account ID") } runtime, err := camoufoxnative.Start(ctx, options) if err != nil { @@ -46,7 +46,7 @@ func NewNativeWorker(ctx context.Context, accountID string, options camoufoxnati }, nil } -// Prepare 生成 fresh proof 并写入 GenerateContent 第五槽 +// Prepare generates fresh proof and writes to GenerateContent slot 5 func (worker *NativeWorker) Prepare(ctx context.Context, request ProtectedRequest) (PreparedProtectedRequest, error) { worker.operationMu.Lock() defer worker.operationMu.Unlock() @@ -64,10 +64,10 @@ func (worker *NativeWorker) Prepare(ctx context.Context, request ProtectedReques var payload []any if err := json.Unmarshal(request.Body, &payload); err != nil { worker.fail(err) - return PreparedProtectedRequest{}, fmt.Errorf("解析受保护请求: %w", err) + return PreparedProtectedRequest{}, fmt.Errorf("parse protected request: %w", err) } if request.ProofField < 1 || len(payload) < request.ProofField { - err := fmt.Errorf("受保护请求缺少 WAA field %d", request.ProofField) + err := fmt.Errorf("protected request missing WAA field %d", request.ProofField) worker.fail(err) return PreparedProtectedRequest{}, err } @@ -75,7 +75,7 @@ func (worker *NativeWorker) Prepare(ctx context.Context, request ProtectedReques body, err := json.Marshal(payload) if err != nil { worker.fail(err) - return PreparedProtectedRequest{}, fmt.Errorf("编码受保护请求: %w", err) + return PreparedProtectedRequest{}, fmt.Errorf("encode protected request: %w", err) } headers, err := worker.runtime.ProtocolHeaders(ctx) if err != nil { @@ -91,7 +91,7 @@ func (worker *NativeWorker) Prepare(ctx context.Context, request ProtectedReques }, nil } -// SendProtected 通过账户固定指纹 Camoufox 流式发送已准备的请求 +// SendProtected streams the prepared request via the account's fixed-fingerprint Camoufox func (worker *NativeWorker) SendProtected(ctx context.Context, request ProtectedRequest) (*RPCResponse, error) { response, err := worker.runtime.SendProtected(ctx, request.URL, request.Headers, request.Body) if err != nil { @@ -105,7 +105,7 @@ func (worker *NativeWorker) SendProtected(ctx context.Context, request Protected }, nil } -// BrowserStorageState 返回固定指纹浏览器当前 Cookie 状态 +// BrowserStorageState returns current cookie state of fixed-fingerprint browser func (worker *NativeWorker) BrowserStorageState(ctx context.Context) (StorageState, error) { encoded, err := worker.runtime.StorageCookies(ctx) if err != nil { @@ -113,7 +113,7 @@ func (worker *NativeWorker) BrowserStorageState(ctx context.Context) (StorageSta } var cookies []StateCookie if err := json.Unmarshal(encoded, &cookies); err != nil { - return StorageState{}, fmt.Errorf("解析浏览器 Cookie: %w", err) + return StorageState{}, fmt.Errorf("parse browser cookies: %w", err) } state := StorageState{Cookies: cookies} if err := state.Validate(); err != nil { @@ -122,22 +122,22 @@ func (worker *NativeWorker) BrowserStorageState(ctx context.Context) (StorageSta return state, nil } -// ProtocolHeaders 返回当前账户官网请求的动态公共头 +// ProtocolHeaders returns dynamic common headers for official requests of the current account func (worker *NativeWorker) ProtocolHeaders(ctx context.Context, accountID string) (http.Header, error) { if accountID != "" && accountID != worker.accountID { - return nil, fmt.Errorf("runtime 账户不匹配") + return nil, fmt.Errorf("runtime account mismatch") } return worker.runtime.ProtocolHeaders(ctx) } -// State 返回纯 Go runtime 状态 +// State returns pure-Go runtime state func (worker *NativeWorker) State() WorkerState { worker.stateMu.RLock() defer worker.stateMu.RUnlock() return worker.state } -// Close 关闭纯 Go runtime +// Close shuts down the pure-Go runtime func (worker *NativeWorker) Close() error { worker.operationMu.Lock() defer worker.operationMu.Unlock() diff --git a/internal/aistudio/schema.go b/internal/aistudio/schema.go index df723dd..99a2d57 100644 --- a/internal/aistudio/schema.go +++ b/internal/aistudio/schema.go @@ -20,7 +20,7 @@ var schemaTypeCodes = map[string]int64{ func encodeJSONSchema(raw json.RawMessage) ([]any, error) { var schema map[string]json.RawMessage if err := json.Unmarshal(raw, &schema); err != nil || schema == nil { - return nil, fmt.Errorf("schema 必须是 JSON object") + return nil, fmt.Errorf("schema must be a JSON object") } if err := normalizeConstAndMetadata(schema); err != nil { return nil, err @@ -39,7 +39,7 @@ func encodeJSONSchema(raw json.RawMessage) ([]any, error) { } for name := range schema { if !allowed[name] { - return nil, &UnverifiedProtocolError{Feature: "JSON schema 字段 " + name} + return nil, &UnverifiedProtocolError{Feature: "JSON schema field " + name} } } typeName, err := schemaType(schema) @@ -49,7 +49,7 @@ func encodeJSONSchema(raw json.RawMessage) ([]any, error) { typeName = strings.ToLower(typeName) typeCode, ok := schemaTypeCodes[typeName] if !ok { - return nil, fmt.Errorf("未知 schema.type %q", typeName) + return nil, fmt.Errorf("unknown schema.type %q", typeName) } wire := []any{typeCode} if value, ok := schema["format"]; ok { @@ -69,7 +69,7 @@ func encodeJSONSchema(raw json.RawMessage) ([]any, error) { if value, ok := schema["nullable"]; ok { var nullable bool if err := json.Unmarshal(value, &nullable); err != nil { - return nil, fmt.Errorf("schema.nullable 必须是布尔值") + return nil, fmt.Errorf("schema.nullable must be a boolean") } wire = setWireField(wire, 3, nullable) } @@ -109,7 +109,7 @@ func encodeJSONSchema(raw json.RawMessage) ([]any, error) { if value, ok := schema["properties"]; ok { var properties map[string]json.RawMessage if err := json.Unmarshal(value, &properties); err != nil || properties == nil { - return nil, fmt.Errorf("schema.properties 必须是 JSON object") + return nil, fmt.Errorf("schema.properties must be a JSON object") } names := make([]string, 0, len(properties)) for name := range properties { @@ -160,7 +160,7 @@ func encodeJSONSchema(raw json.RawMessage) ([]any, error) { if value, ok := schema["example"]; ok { var example any if err := json.Unmarshal(value, &example); err != nil { - return nil, fmt.Errorf("schema.example 必须是 JSON value") + return nil, fmt.Errorf("schema.example must be a JSON value") } wire = setWireField(wire, 15, encodeWireValue(example)) } @@ -199,14 +199,14 @@ func encodeJSONSchema(raw json.RawMessage) ([]any, error) { return wire, nil } -// normalizeNullableVariants 将 JSON Schema null 联合映射为 AI Studio nullable +// normalizeNullableVariants maps JSON Schema null unions to AI Studio nullable func normalizeNullableVariants(schema map[string]json.RawMessage) error { if raw, ok := schema["type"]; ok { var typeName string if err := json.Unmarshal(raw, &typeName); err != nil { var typeNames []string if arrayErr := json.Unmarshal(raw, &typeNames); arrayErr != nil || len(typeNames) == 0 { - return fmt.Errorf("schema.type 必须是字符串或字符串数组") + return fmt.Errorf("schema.type must be a string or string array") } nonNull := make([]string, 0, len(typeNames)) nullable := false @@ -218,7 +218,7 @@ func normalizeNullableVariants(schema map[string]json.RawMessage) error { nonNull = append(nonNull, name) } if len(nonNull) == 0 { - return fmt.Errorf("schema.type 必须包含非 null 类型") + return fmt.Errorf("schema.type must contain a non-null type") } encodedType, marshalErr := json.Marshal(nonNull[0]) if marshalErr != nil { @@ -248,14 +248,14 @@ func normalizeNullableVariants(schema map[string]json.RawMessage) error { } var variants []json.RawMessage if err := json.Unmarshal(raw, &variants); err != nil { - return fmt.Errorf("schema.%s 必须是 JSON object 数组", name) + return fmt.Errorf("schema.%s must be an array of JSON objects", name) } filtered := variants[:0] nullable := false for _, variant := range variants { var value map[string]json.RawMessage if err := json.Unmarshal(variant, &value); err != nil || value == nil { - return fmt.Errorf("schema.%s 必须是 JSON object 数组", name) + return fmt.Errorf("schema.%s must be an array of JSON objects", name) } typeValue, exists := value["type"] if exists { @@ -274,7 +274,7 @@ func normalizeNullableVariants(schema map[string]json.RawMessage) error { continue } if len(filtered) == 0 { - return fmt.Errorf("schema.%s 必须包含非 null 类型", name) + return fmt.Errorf("schema.%s must contain a non-null type", name) } encoded, err := json.Marshal(filtered) if err != nil { @@ -286,7 +286,7 @@ func normalizeNullableVariants(schema map[string]json.RawMessage) error { return nil } -// normalizeConstAndMetadata 将字符串常量和说明字段转换为可发送结构 +// normalizeConstAndMetadata converts string constants and metadata fields into sendable structures func normalizeConstAndMetadata(schema map[string]json.RawMessage) error { delete(schema, "title") delete(schema, "$id") @@ -294,16 +294,16 @@ func normalizeConstAndMetadata(schema map[string]json.RawMessage) error { if raw, ok := schema["const"]; ok { var decoded any if err := json.Unmarshal(raw, &decoded); err != nil { - return fmt.Errorf("schema.const 必须是字符串") + return fmt.Errorf("schema.const must be a string") } value, ok := decoded.(string) if !ok { - return fmt.Errorf("schema.const 只支持字符串") + return fmt.Errorf("schema.const only supports strings") } if rawType, exists := schema["type"]; exists { typeName, err := schemaString(rawType, "type") if err != nil || !strings.EqualFold(typeName, "string") { - return fmt.Errorf("schema.const 只支持 string 类型") + return fmt.Errorf("schema.const only supports string type") } } else { schema["type"] = json.RawMessage(`"string"`) @@ -322,12 +322,12 @@ func normalizeConstAndMetadata(schema map[string]json.RawMessage) error { } var variants []json.RawMessage if err := json.Unmarshal(raw, &variants); err != nil { - return fmt.Errorf("schema.%s 必须是 JSON object 数组", name) + return fmt.Errorf("schema.%s must be an array of JSON objects", name) } for index, variant := range variants { var subSchema map[string]json.RawMessage if err := json.Unmarshal(variant, &subSchema); err != nil || subSchema == nil { - return fmt.Errorf("schema.%s 必须是 JSON object 数组", name) + return fmt.Errorf("schema.%s must be an array of JSON objects", name) } if err := normalizeConstAndMetadata(subSchema); err != nil { return fmt.Errorf("schema.%s[%d]: %w", name, index, err) @@ -351,7 +351,7 @@ func schemaType(schema map[string]json.RawMessage) (string, error) { if value, ok := schema["type"]; ok { typeName, err := schemaString(value, "type") if err != nil || typeName == "" { - return "", fmt.Errorf("schema.type 必须是字符串") + return "", fmt.Errorf("schema.type must be a string") } return typeName, nil } @@ -362,7 +362,7 @@ func schemaType(schema map[string]json.RawMessage) (string, error) { } var variants []map[string]json.RawMessage if err := json.Unmarshal(value, &variants); err != nil { - return "", fmt.Errorf("schema.%s 必须是 JSON object 数组", name) + return "", fmt.Errorf("schema.%s must be an array of JSON objects", name) } for _, variant := range variants { if typeValue, exists := variant["type"]; exists { @@ -370,13 +370,13 @@ func schemaType(schema map[string]json.RawMessage) (string, error) { } } } - return "", fmt.Errorf("schema.type 必须是字符串") + return "", fmt.Errorf("schema.type must be a string") } func schemaInteger(raw json.RawMessage, name string) (int64, error) { value, err := strconv.ParseInt(string(raw), 10, 64) if err != nil || value < 0 { - return 0, fmt.Errorf("schema.%s 必须是非负整数", name) + return 0, fmt.Errorf("schema.%s must be a non-negative integer", name) } return value, nil } @@ -384,7 +384,7 @@ func schemaInteger(raw json.RawMessage, name string) (int64, error) { func schemaNumber(raw json.RawMessage, name string) (float64, error) { value, err := strconv.ParseFloat(string(raw), 64) if err != nil { - return 0, fmt.Errorf("schema.%s 必须是数字", name) + return 0, fmt.Errorf("schema.%s must be a number", name) } return value, nil } @@ -392,7 +392,7 @@ func schemaNumber(raw json.RawMessage, name string) (float64, error) { func encodeSchemaVariants(raw json.RawMessage, name string) ([]any, error) { var variants []json.RawMessage if err := json.Unmarshal(raw, &variants); err != nil { - return nil, fmt.Errorf("schema.%s 必须是 JSON object 数组", name) + return nil, fmt.Errorf("schema.%s must be an array of JSON objects", name) } encoded := make([]any, 0, len(variants)) for index, variant := range variants { @@ -408,7 +408,7 @@ func encodeSchemaVariants(raw json.RawMessage, name string) ([]any, error) { func schemaString(raw json.RawMessage, name string) (string, error) { var value string if err := json.Unmarshal(raw, &value); err != nil { - return "", fmt.Errorf("schema.%s 必须是字符串", name) + return "", fmt.Errorf("schema.%s must be a string", name) } return value, nil } @@ -416,7 +416,7 @@ func schemaString(raw json.RawMessage, name string) (string, error) { func schemaStrings(raw json.RawMessage, name string) ([]string, error) { var values []string if err := json.Unmarshal(raw, &values); err != nil { - return nil, fmt.Errorf("schema.%s 必须是字符串数组", name) + return nil, fmt.Errorf("schema.%s must be an array of strings", name) } return values, nil } diff --git a/internal/aistudio/service.go b/internal/aistudio/service.go index f0b4525..00bf493 100644 --- a/internal/aistudio/service.go +++ b/internal/aistudio/service.go @@ -13,45 +13,45 @@ import ( "time" ) -// PooledService 在账户租约内调用协议客户端 +// PooledService calls the protocol client within an account lease type PooledService struct { pool *AccountPool client *Client } -// PoolRequestContextProvider 从租约账户读取协议上下文 +// PoolRequestContextProvider reads protocol context from a leased account type PoolRequestContextProvider struct { pool *AccountPool } -// ProtectedPreparer 为一次请求写入 fresh WAA proof 并通过账户固定指纹浏览器发送 +// ProtectedPreparer writes fresh WAA proof for a request and transmits via the account's fixed-fingerprint browser type ProtectedPreparer interface { Prepare(context.Context, ProtectedRequest) (PreparedProtectedRequest, error) BrowserStorageState(context.Context) (StorageState, error) SendProtected(context.Context, ProtectedRequest) (*RPCResponse, error) } -// ProtectedPreparerProvider 按账户返回 lazy WAA preparer +// ProtectedPreparerProvider returns a lazy WAA preparer for an account type ProtectedPreparerProvider interface { Worker(context.Context, string, string) (ProtectedPreparer, error) } -// ProtectedPreparerProviderFunc 将函数适配为 ProtectedPreparerProvider +// ProtectedPreparerProviderFunc adapts a function to ProtectedPreparerProvider type ProtectedPreparerProviderFunc func(context.Context, string, string) (ProtectedPreparer, error) -// Worker 返回账户的 lazy WAA preparer +// Worker returns the lazy WAA preparer for an account func (f ProtectedPreparerProviderFunc) Worker(ctx context.Context, accountID string, modelID string) (ProtectedPreparer, error) { return f(ctx, accountID, modelID) } -// WorkerProtectedTransportOptions 定义受保护请求的 proof 与 HTTP 依赖 +// WorkerProtectedTransportOptions defines proof and HTTP dependencies for protected requests type WorkerProtectedTransportOptions struct { Transport *MakerSuiteHTTPTransport Workers ProtectedPreparerProvider SetupTimeout time.Duration } -// WorkerProtectedTransport 将 fresh proof 交给同租约 HTTP 传输 +// WorkerProtectedTransport passes fresh proof to same-lease HTTP transport type WorkerProtectedTransport struct { transport *MakerSuiteHTTPTransport workers ProtectedPreparerProvider @@ -63,42 +63,42 @@ var _ RequestContextProvider = (*PoolRequestContextProvider)(nil) var _ ProtectedTransport = (*WorkerProtectedTransport)(nil) var _ VideoProtectedTransport = (*WorkerProtectedTransport)(nil) -// NewPooledService 创建多账户协议服务 +// NewPooledService creates a multi-account protocol service func NewPooledService(pool *AccountPool, client *Client) (*PooledService, error) { if pool == nil { - return nil, fmt.Errorf("AI Studio account pool 不能为空") + return nil, fmt.Errorf("AI Studio account pool cannot be nil") } if client == nil { - return nil, fmt.Errorf("AI Studio client 不能为空") + return nil, fmt.Errorf("AI Studio client cannot be nil") } return &PooledService{pool: pool, client: client}, nil } -// NewPoolRequestContextProvider 创建账户协议上下文提供者 +// NewPoolRequestContextProvider creates an account protocol context provider func NewPoolRequestContextProvider(pool *AccountPool) (*PoolRequestContextProvider, error) { if pool == nil { - return nil, fmt.Errorf("AI Studio account pool 不能为空") + return nil, fmt.Errorf("AI Studio account pool cannot be nil") } return &PoolRequestContextProvider{pool: pool}, nil } -// NewWorkerProtectedTransport 创建基于 lazy WAA preparer 的受保护传输 +// NewWorkerProtectedTransport creates protected transport based on lazy WAA preparer func NewWorkerProtectedTransport(options WorkerProtectedTransportOptions) (*WorkerProtectedTransport, error) { if options.Transport == nil { - return nil, fmt.Errorf("MakerSuite HTTP transport 不能为空") + return nil, fmt.Errorf("MakerSuite HTTP transport cannot be nil") } if options.Workers == nil { - return nil, fmt.Errorf("WAA preparer provider 不能为空") + return nil, fmt.Errorf("WAA preparer provider cannot be nil") } if options.SetupTimeout <= 0 { - return nil, fmt.Errorf("Bidi setup timeout 必须是正数时长") + return nil, fmt.Errorf("bidi setup timeout must be a positive duration") } return &WorkerProtectedTransport{ transport: options.Transport, workers: options.Workers, setupTimeout: options.SetupTimeout, }, nil } -// DoProtected 写入 fresh proof 后通过 Camoufox 发送 GenerateContent +// DoProtected writes fresh proof and sends GenerateContent via Camoufox func (t *WorkerProtectedTransport) DoProtected(ctx context.Context, request GenerateRequest, rpc RPCRequest) (*RPCResponse, error) { prompt, err := bindingPrompt(request) if err != nil { @@ -122,7 +122,7 @@ func (t *WorkerProtectedTransport) doBrowserPrepared( } browserState, err := worker.BrowserStorageState(ctx) if err != nil { - return nil, fmt.Errorf("读取浏览器 Cookie: %w", err) + return nil, fmt.Errorf("read browser cookies: %w", err) } authorization, err := t.transport.signer.Authorization(browserState) if err != nil { @@ -139,10 +139,10 @@ func (t *WorkerProtectedTransport) doBrowserPrepared( } browserState, err = worker.BrowserStorageState(ctx) if err != nil { - return nil, errors.Join(fmt.Errorf("导出浏览器 Cookie: %w", err), response.Body.Close()) + return nil, errors.Join(fmt.Errorf("export browser cookies: %w", err), response.Body.Close()) } if err := lease.ReplaceCookies(browserState.Cookies); err != nil { - return nil, errors.Join(fmt.Errorf("保存浏览器 Cookie: %w", err), response.Body.Close()) + return nil, errors.Join(fmt.Errorf("save browser cookies: %w", err), response.Body.Close()) } reportRequestPhase(ctx, RequestPhaseStreaming) return response, nil @@ -157,14 +157,14 @@ func (t *WorkerProtectedTransport) prepareProtectedRequest( ) (*AccountLease, ProtectedPreparer, RPCRequest, error) { lease, ok := AccountLeaseFromContext(ctx) if !ok { - return nil, nil, RPCRequest{}, fmt.Errorf("受保护请求缺少账户租约") + return nil, nil, RPCRequest{}, fmt.Errorf("protected request missing account lease") } if err := validateLeaseSelection(lease, selection); err != nil { return nil, nil, RPCRequest{}, err } worker, err := t.workers.Worker(ctx, lease.Account().ID, selection.ModelID) if err != nil { - return nil, nil, RPCRequest{}, fmt.Errorf("获取账户 WAA preparer: %w", err) + return nil, nil, RPCRequest{}, fmt.Errorf("get account WAA preparer: %w", err) } reportRequestPhase(ctx, RequestPhasePreparingWAA) prepared, err := worker.Prepare(ctx, ProtectedRequest{ @@ -172,10 +172,10 @@ func (t *WorkerProtectedTransport) prepareProtectedRequest( Prompt: prompt, ProofField: proofField, }) if err != nil { - return nil, nil, RPCRequest{}, fmt.Errorf("准备 fresh WAA proof: %w", err) + return nil, nil, RPCRequest{}, fmt.Errorf("prepare fresh WAA proof: %w", err) } if prepared.Headers == nil || len(prepared.Body) == 0 { - return nil, nil, RPCRequest{}, fmt.Errorf("WAA preparer 返回空请求") + return nil, nil, RPCRequest{}, fmt.Errorf("WAA preparer returned empty request") } requestHeaders := rpc.Header rpc.AccountID = lease.Account().ID @@ -191,7 +191,7 @@ func (t *WorkerProtectedTransport) prepareProtectedRequest( return lease, worker, rpc, nil } -// DoProtectedVideo 写入 Veo fresh proof 后发送请求 +// DoProtectedVideo writes Veo fresh proof and sends request func (t *WorkerProtectedTransport) DoProtectedVideo(ctx context.Context, request VideoRequest, rpc RPCRequest) (*RPCResponse, error) { modelID := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") return t.doPrepared(ctx, request.Prompt, 8, AccountSelection{ @@ -221,7 +221,7 @@ func (t *WorkerProtectedTransport) doPrepared( func bindingPrompt(request GenerateRequest) (string, error) { if len(request.Contents) == 0 { - return "", fmt.Errorf("GenerateContent contents 不能为空") + return "", fmt.Errorf("GenerateContent contents cannot be empty") } values := make([]string, 0) for _, content := range request.Contents { @@ -242,11 +242,11 @@ func bindingPrompt(request GenerateRequest) (string, error) { return strings.Join(values, " "), nil } -// RequestContext 返回账户时区 +// RequestContext returns the account timezone func (p *PoolRequestContextProvider) RequestContext(_ context.Context, accountID string) (RequestContext, error) { accountID = strings.TrimSpace(accountID) if accountID == "" { - return RequestContext{}, fmt.Errorf("AI Studio 请求上下文缺少账户 ID") + return RequestContext{}, fmt.Errorf("AI Studio request context missing account ID") } p.pool.mu.Lock() account := p.pool.byID[accountID] @@ -256,12 +256,12 @@ func (p *PoolRequestContextProvider) RequestContext(_ context.Context, accountID } p.pool.mu.Unlock() if account == nil { - return RequestContext{}, fmt.Errorf("账户不存在: %s", accountID) + return RequestContext{}, fmt.Errorf("account not found: %s", accountID) } return RequestContext{Timezone: timezone}, nil } -// Models 刷新可用账户并返回实时模型并集 +// Models refreshes available accounts and returns the union of live models func (s *PooledService) Models(ctx context.Context) ([]Model, error) { if lease, ok := AccountLeaseFromContext(ctx); ok { return s.modelsForLease(ctx, lease) @@ -297,7 +297,7 @@ func (s *PooledService) Models(ctx context.Context) ([]Model, error) { return models, nil } -// RefreshAccountModels 刷新指定账户的权益与模型目录 +// RefreshAccountModels refreshes the benefit tier and model catalog of the specified account func (s *PooledService) RefreshAccountModels(ctx context.Context, accountID string) ([]Model, error) { accountID = strings.TrimSpace(accountID) lease, err := s.pool.AcquireAccount(ctx, accountID) @@ -307,16 +307,16 @@ func (s *PooledService) RefreshAccountModels(ctx context.Context, accountID stri models, requestErr := s.modelsForLease(ContextWithAccountLease(ctx, lease), lease) releaseErr := lease.Release() if requestErr != nil { - failure := fmt.Errorf("刷新账户 %s 的模型目录: %w", accountID, errors.Join(requestErr, releaseErr)) + failure := fmt.Errorf("refresh model catalog for account %s: %w", accountID, errors.Join(requestErr, releaseErr)) return nil, failure } if releaseErr != nil { - return models, fmt.Errorf("释放账户 %s 的模型目录租约: %w", accountID, releaseErr) + return models, fmt.Errorf("release model catalog lease for account %s: %w", accountID, releaseErr) } return models, nil } -// CachedModels 返回启用账户最近同步目录的并集 +// CachedModels returns the union of recently synced catalogs for enabled accounts func (s *PooledService) CachedModels() []Model { s.pool.mu.Lock() defer s.pool.mu.Unlock() @@ -362,25 +362,25 @@ func (s *PooledService) modelsForStatus(ctx context.Context, status AccountStatu if len(cached) > 0 { return accountModelsResult{models: cached, available: true} } - return accountModelsResult{err: fmt.Errorf("账户 %s 正在使用且没有缓存模型目录", status.ID)} + return accountModelsResult{err: fmt.Errorf("account %s is busy and has no cached model catalog", status.ID)} } lease, err := s.pool.AcquireFor(ctx, AccountSelection{AccountID: status.ID}) if err != nil { return accountModelsResult{ models: cached, available: len(cached) > 0, - err: fmt.Errorf("获取账户 %s 的模型目录租约: %w", status.ID, err), + err: fmt.Errorf("acquire model catalog lease for account %s: %w", status.ID, err), } } accountModels, requestErr := s.modelsForLease(ContextWithAccountLease(ctx, lease), lease) releaseErr := lease.Release() if requestErr != nil { - failure := fmt.Errorf("刷新账户 %s 的模型目录: %w", status.ID, errors.Join(requestErr, releaseErr)) + failure := fmt.Errorf("refresh model catalog for account %s: %w", status.ID, errors.Join(requestErr, releaseErr)) return accountModelsResult{models: cached, available: len(cached) > 0, err: failure} } if releaseErr != nil { return accountModelsResult{ models: accountModels, available: true, - err: fmt.Errorf("释放账户 %s 的模型目录租约: %w", status.ID, releaseErr), + err: fmt.Errorf("release model catalog lease for account %s: %w", status.ID, releaseErr), } } return accountModelsResult{models: accountModels, available: true} @@ -396,13 +396,13 @@ func (s *PooledService) cachedModels(accountID string) []Model { return cloneAccountModels(account.Models) } -// DefinitiveAuthenticationFailure 判断上游是否明确要求重新认证 +// DefinitiveAuthenticationFailure determines whether upstream definitively requested re-authentication func DefinitiveAuthenticationFailure(err error) bool { var rpcError *RPCError return errors.As(err, &rpcError) && rpcError.StatusCode == 401 } -// DefinitiveWAARuntimeFailure 判断上游是否明确拒绝当前 WAA 运行态 +// DefinitiveWAARuntimeFailure determines whether upstream definitively rejected current WAA runtime func DefinitiveWAARuntimeFailure(err error) bool { var rpcError *RPCError return errors.As(err, &rpcError) && modelBoundRPCMethod(rpcError.Method) && @@ -470,11 +470,11 @@ func (s *PooledService) modelsForLease(ctx context.Context, lease *AccountLease) return models, nil } -// CountTokens 使用支持目标模型的独占账户计数 +// CountTokens counts tokens using an exclusive account supporting the target model func (s *PooledService) CountTokens(ctx context.Context, request TokenCountRequest) (TokenCount, error) { modelID := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") if modelID == "" { - return TokenCount{}, fmt.Errorf("%w: CountTokens model 不能为空", ErrInvalidArgument) + return TokenCount{}, fmt.Errorf("%w: CountTokens model cannot be empty", ErrInvalidArgument) } modelAccessScope := ModelAccessKey("count-tokens", modelID) selection := AccountSelection{ModelID: modelID, ModelAccessScope: modelAccessScope, Method: "countTokens"} @@ -522,11 +522,11 @@ func (s *PooledService) CountTokens(ctx context.Context, request TokenCountReque return count, requestErr } -// Generate 使用支持目标模型的独占账户生成事件流 +// Generate generates an event stream using an exclusive account supporting the target model func (s *PooledService) Generate(ctx context.Context, request GenerateRequest) (<-chan Event, error) { modelID := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") if modelID == "" { - return nil, fmt.Errorf("%w: GenerateContent model 不能为空", ErrInvalidArgument) + return nil, fmt.Errorf("%w: GenerateContent model cannot be empty", ErrInvalidArgument) } resourceID, err := s.pool.ResourceIDForContents(ctx, request.Contents) if err != nil { @@ -609,7 +609,7 @@ func retryableAccountError(err error) bool { rpcError.StatusCode == http.StatusTooManyRequests || rpcError.StatusCode >= http.StatusInternalServerError } -// forwardEventsWithLease 转发事件并在结束或取消时释放账户租约 +// forwardEventsWithLease forwards events and releases account lease upon completion or cancellation func forwardEventsWithLease( ctx context.Context, source <-chan Event, @@ -657,7 +657,7 @@ func forwardEventsWithLease( if event.Kind != EventError && !verified { verified = true if err := lease.MarkAuthenticationValid(); err != nil { - slog.Error("账户认证状态保存失败", "account", accountID, "error", err) + slog.Error("failed to save account authentication state", "account", accountID, "error", err) } } if event.Kind == EventFinish { @@ -665,7 +665,7 @@ func forwardEventsWithLease( if _, err := pool.MarkModelAccessVerifiedIfGeneration( accountID, modelID, accessGeneration, checkedAt, ); err != nil { - slog.Error("账户模型资格保存失败", "account", accountID, "model", modelID, "error", err) + slog.Error("failed to save account model access qualification", "account", accountID, "model", modelID, "error", err) } }() } diff --git a/internal/aistudio/signer.go b/internal/aistudio/signer.go index fefaae3..9673214 100644 --- a/internal/aistudio/signer.go +++ b/internal/aistudio/signer.go @@ -20,18 +20,18 @@ var signatureCookies = [...]struct { {label: "SAPISID3PHASH", name: "__Secure-3PAPISID"}, } -// Signer 为 AI Studio 请求生成三段 SAPISID 授权头 +// Signer generates 3-part SAPISID authorization headers for AI Studio requests type Signer struct { origin string now func() time.Time } -// NewSigner 创建 AI Studio 官方来源的签名器 +// NewSigner creates a signer with the official AI Studio origin func NewSigner() *Signer { return &Signer{origin: aiStudioOrigin, now: time.Now} } -// NewSignerForOrigin 创建指定来源的签名器 +// NewSignerForOrigin creates a signer for the specified origin func NewSignerForOrigin(origin string) (*Signer, error) { normalized, err := normalizeOrigin(origin) if err != nil { @@ -40,28 +40,28 @@ func NewSignerForOrigin(origin string) (*Signer, error) { return &Signer{origin: normalized, now: time.Now}, nil } -// Sign 使用当前时间生成授权头 +// Sign generates an authorization header using current time func (s *Signer) Sign(state StorageState) (string, error) { return s.Authorization(state) } -// Authorization 使用当前时间生成授权头 +// Authorization generates an authorization header using current time func (s *Signer) Authorization(state StorageState) (string, error) { if s == nil || s.now == nil { - return "", fmt.Errorf("签名器未初始化") + return "", fmt.Errorf("signer is not initialized") } return s.AuthorizationAt(state, s.now()) } -// AuthorizationAt 使用指定时间生成授权头 +// AuthorizationAt generates an authorization header at the specified time func (s *Signer) AuthorizationAt(state StorageState, now time.Time) (string, error) { if s == nil || s.origin == "" { - return "", fmt.Errorf("签名器未初始化") + return "", fmt.Errorf("signer is not initialized") } return SignAuthorization(state.Cookies, s.origin, now.Unix()) } -// SignAuthorization 为指定来源和时间生成三段授权头 +// SignAuthorization generates a 3-part authorization header for the specified origin and time func SignAuthorization(cookies []StateCookie, origin string, timestamp int64) (string, error) { normalized, err := normalizeOrigin(origin) if err != nil { @@ -73,7 +73,7 @@ func SignAuthorization(cookies []StateCookie, origin string, timestamp int64) (s for _, item := range signatureCookies { value, ok := state.CookieValue(item.name, normalized+"/", now) if !ok { - return "", fmt.Errorf("storage state 缺少有效 Cookie: %s", item.name) + return "", fmt.Errorf("storage state missing valid cookie: %s", item.name) } source := fmt.Sprintf("%d %s %s", timestamp, value, normalized) digest := sha1.Sum([]byte(source)) @@ -85,10 +85,10 @@ func SignAuthorization(cookies []StateCookie, origin string, timestamp int64) (s func normalizeOrigin(origin string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(origin)) if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" { - return "", fmt.Errorf("签名来源必须是 HTTPS origin") + return "", fmt.Errorf("signing origin must be an HTTPS origin") } if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" { - return "", fmt.Errorf("签名来源必须是 HTTPS origin") + return "", fmt.Errorf("signing origin must be an HTTPS origin") } return parsed.Scheme + "://" + parsed.Host, nil } diff --git a/internal/aistudio/stream_activity.go b/internal/aistudio/stream_activity.go index 500bd2b..2aca222 100644 --- a/internal/aistudio/stream_activity.go +++ b/internal/aistudio/stream_activity.go @@ -12,7 +12,7 @@ type streamActivityReader struct { notify func(int) } -// ContextWithStreamActivityObserver 记录上游响应体实际到达的字节 +// ContextWithStreamActivityObserver records actual arriving bytes of upstream response body func ContextWithStreamActivityObserver(ctx context.Context, observer func(int)) context.Context { if observer == nil { return ctx diff --git a/internal/aistudio/tool_events.go b/internal/aistudio/tool_events.go index a88cd05..1bb075e 100644 --- a/internal/aistudio/tool_events.go +++ b/internal/aistudio/tool_events.go @@ -11,7 +11,7 @@ func decodeExecutableCode(raw json.RawMessage, path string, evidence json.RawMes return ExecutableCode{}, withMethod(err, "GenerateContent") } if len(values) < 2 { - return ExecutableCode{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "executable code 字段不足", Raw: raw} + return ExecutableCode{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "insufficient executable code fields", Raw: raw} } languageCode, err := rawInt64(values[0], path+"[0]", evidence) if err != nil { @@ -19,7 +19,7 @@ func decodeExecutableCode(raw json.RawMessage, path string, evidence json.RawMes } language, ok := map[int64]string{0: "LANGUAGE_UNSPECIFIED", 1: "PYTHON"}[languageCode] if !ok { - return ExecutableCode{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[0]", Detail: fmt.Sprintf("未识别的 executable code language %d", languageCode), Raw: raw} + return ExecutableCode{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[0]", Detail: fmt.Sprintf("unrecognized executable code language %d", languageCode), Raw: raw} } code, err := rawString(values[1], path+"[1]", evidence) if err != nil { @@ -34,7 +34,7 @@ func decodeCodeExecutionResult(raw json.RawMessage, path string, evidence json.R return CodeExecutionResult{}, withMethod(err, "GenerateContent") } if len(values) == 0 { - return CodeExecutionResult{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "code execution result 字段不足", Raw: raw} + return CodeExecutionResult{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "insufficient code execution result fields", Raw: raw} } outcomeCode, err := rawInt64(values[0], path+"[0]", evidence) if err != nil { @@ -47,7 +47,7 @@ func decodeCodeExecutionResult(raw json.RawMessage, path string, evidence json.R 3: "OUTCOME_DEADLINE_EXCEEDED", }[outcomeCode] if !ok { - return CodeExecutionResult{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[0]", Detail: fmt.Sprintf("未识别的 code execution outcome %d", outcomeCode), Raw: raw} + return CodeExecutionResult{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[0]", Detail: fmt.Sprintf("unrecognized code execution outcome %d", outcomeCode), Raw: raw} } value := "" if valueRaw := rawAt(values, 1); !isJSONNull(valueRaw) { @@ -186,13 +186,13 @@ func decodeGroundingChunk(raw json.RawMessage, path string, evidence json.RawMes for index := 0; index < 3; index++ { if !isJSONNull(rawAt(values, index)) { if variant >= 0 { - return GroundingChunk{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "grounding chunk 同时设置多个来源", Raw: raw} + return GroundingChunk{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "grounding chunk has multiple sources set", Raw: raw} } variant = index } } if variant < 0 { - return GroundingChunk{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "grounding chunk 缺少来源", Raw: raw} + return GroundingChunk{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "grounding chunk missing source", Raw: raw} } fields, err := rawArray(values[variant], fmt.Sprintf("%s[%d]", path, variant), evidence) if err != nil { diff --git a/internal/aistudio/tool_validation.go b/internal/aistudio/tool_validation.go index ba7b035..a2d383b 100644 --- a/internal/aistudio/tool_validation.go +++ b/internal/aistudio/tool_validation.go @@ -7,14 +7,14 @@ func validateRequestedTools(tools Tools, model Model) error { return nil } if len(tools.Functions) > 0 && !model.Capabilities["function_declarations"] { - return fmt.Errorf("模型 %q 不支持 function declarations", model.ID) + return fmt.Errorf("model %q does not support function declarations", model.ID) } if tools.GoogleSearch != nil { if (tools.GoogleSearch.WebSearch || !tools.GoogleSearch.ImageSearch) && !model.Capabilities["google_search"] { - return fmt.Errorf("模型 %q 不支持 google_search", model.ID) + return fmt.Errorf("model %q does not support google_search", model.ID) } if tools.GoogleSearch.ImageSearch && !model.Capabilities["image_search"] { - return fmt.Errorf("模型 %q 不支持 image_search", model.ID) + return fmt.Errorf("model %q does not support image_search", model.ID) } } hasMaps := false @@ -28,17 +28,17 @@ func validateRequestedTools(tools Tools, model Model) error { case "url_context": capability = "browse" default: - return fmt.Errorf("未知 Google tool %q", tool) + return fmt.Errorf("unknown Google tool %q", tool) } if !model.Capabilities[capability] { - return fmt.Errorf("模型 %q 不支持 %s", model.ID, tool) + return fmt.Errorf("model %q does not support %s", model.ID, tool) } hasMaps = hasMaps || tool == "google_maps" hasCode = hasCode || tool == "code_execution" hasURLContext = hasURLContext || tool == "url_context" } if hasMaps && (hasCode || hasURLContext) { - return fmt.Errorf("google_maps 不能与 code_execution 或 url_context 同时使用") + return fmt.Errorf("google_maps cannot be used together with code_execution or url_context") } return nil } diff --git a/internal/aistudio/tools.go b/internal/aistudio/tools.go index 1ef8601..cd34dd3 100644 --- a/internal/aistudio/tools.go +++ b/internal/aistudio/tools.go @@ -14,7 +14,7 @@ func encodeRequestedTools(tools Tools) ([]any, bool, error) { return nil, true, nil case "", "auto": default: - return nil, false, fmt.Errorf("tool choice 只支持 auto 或 none") + return nil, false, fmt.Errorf("tool choice only supports auto or none") } if len(tools.Functions) == 0 && len(tools.Google) == 0 && tools.GoogleSearch == nil { return nil, false, nil @@ -25,7 +25,7 @@ func encodeRequestedTools(tools Tools) ([]any, bool, error) { for index, declaration := range tools.Functions { encoded, err := encodeFunctionDeclaration(declaration) if err != nil { - return nil, false, fmt.Errorf("编码 function declaration %d: %w", index, err) + return nil, false, fmt.Errorf("encode function declaration %d: %w", index, err) } declarations = append(declarations, encoded) } @@ -63,7 +63,7 @@ func encodeRequestedTools(tools Tools) ([]any, bool, error) { tool[10] = []any{} wire = append(wire, tool) default: - return nil, false, fmt.Errorf("未知 Google tool %q", name) + return nil, false, fmt.Errorf("unknown Google tool %q", name) } } if searchRequested && !searchEncoded { @@ -108,7 +108,7 @@ func encodeGoogleTimestamp(value time.Time) []any { func encodeFunctionDeclaration(declaration FunctionDeclaration) ([]any, error) { if declaration.Name == "" { - return nil, fmt.Errorf("function declaration 缺少名称") + return nil, fmt.Errorf("function declaration missing name") } length := 1 if declaration.Description != "" { @@ -143,7 +143,7 @@ func hasMethod(model Model, method string) bool { func validateFunctionCall(call *FunctionCall) error { if call == nil || call.Name == "" { - return fmt.Errorf("function call 缺少名称") + return fmt.Errorf("function call missing name") } return nil } @@ -154,7 +154,7 @@ func decodeFunctionCall(raw json.RawMessage, path string, evidence json.RawMessa return FunctionCall{}, withMethod(err, "GenerateContent") } if len(values) == 0 { - return FunctionCall{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "function call 缺少名称", Raw: raw} + return FunctionCall{}, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "function call missing name", Raw: raw} } name, err := rawString(values[0], path+"[0]", raw) if err != nil { @@ -184,7 +184,7 @@ func decodeFunctionCall(raw json.RawMessage, path string, evidence json.RawMessa func encodeWireStructJSON(raw json.RawMessage) ([]any, error) { var object map[string]any if err := json.Unmarshal(raw, &object); err != nil || object == nil { - return nil, fmt.Errorf("必须是 JSON object") + return nil, fmt.Errorf("must be a JSON object") } return encodeWireStruct(object), nil } @@ -224,7 +224,7 @@ func encodeWireValue(value any) []any { } return []any{nil, nil, nil, nil, nil, []any{values}} default: - panic(fmt.Sprintf("json.Unmarshal 返回未识别类型 %T", value)) + panic(fmt.Sprintf("json.Unmarshal returned unrecognized type %T", value)) } } @@ -235,7 +235,7 @@ func decodeWireStruct(raw json.RawMessage, path string, evidence json.RawMessage } encoded, err := json.Marshal(object) if err != nil { - return nil, fmt.Errorf("编码 function arguments: %w", err) + return nil, fmt.Errorf("encode function arguments: %w", err) } return encoded, nil } @@ -261,7 +261,7 @@ func decodeWireStructObject(raw json.RawMessage, path string, evidence json.RawM return nil, withMethod(err, "GenerateContent") } if len(entry) != 2 { - return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: entryPath, Detail: "Struct map entry 字段数量错误", Raw: evidence} + return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: entryPath, Detail: "invalid field count for Struct map entry", Raw: evidence} } key, err := rawString(entry[0], entryPath+"[0]", evidence) if err != nil { @@ -285,7 +285,7 @@ func decodeWireValue(raw json.RawMessage, path string, evidence json.RawMessage) for index := 0; index < 6; index++ { if !isJSONNull(rawAt(fields, index)) { if variant >= 0 { - return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "Value 同时设置多个 oneof 字段", Raw: evidence} + return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "Value has multiple oneof fields set", Raw: evidence} } variant = index } @@ -294,13 +294,13 @@ func decodeWireValue(raw json.RawMessage, path string, evidence json.RawMessage) case 0: code, err := rawInt64(fields[0], path+"[0]", evidence) if err != nil || code != 0 { - return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[0]", Detail: "null Value 枚举无效", Raw: evidence} + return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[0]", Detail: "invalid null Value enum", Raw: evidence} } return nil, nil case 1: var number float64 if err := json.Unmarshal(fields[1], &number); err != nil { - return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[1]", Detail: "number Value 无效", Raw: evidence} + return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: path + "[1]", Detail: "invalid number Value", Raw: evidence} } return number, nil case 2: @@ -320,7 +320,7 @@ func decodeWireValue(raw json.RawMessage, path string, evidence json.RawMessage) case 5: return decodeWireList(fields[5], path+"[5]", evidence) default: - return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "Value 缺少 oneof 字段", Raw: evidence} + return nil, &ProtocolEvidenceError{Method: "GenerateContent", Path: path, Detail: "Value missing oneof field", Raw: evidence} } } diff --git a/internal/aistudio/transcribe.go b/internal/aistudio/transcribe.go index bab8ed1..1a186e6 100644 --- a/internal/aistudio/transcribe.go +++ b/internal/aistudio/transcribe.go @@ -22,7 +22,7 @@ func validateTranscriptionConfig(config *TranscriptionConfig, model Model) error } for _, check := range checks { if check.requested && !model.Capabilities[check.capability] { - return fmt.Errorf("模型 %s 不支持 %s", model.ID, check.setting) + return fmt.Errorf("model %s does not support %s", model.ID, check.setting) } } return nil @@ -33,7 +33,7 @@ func encodeTranscriptionConfig(config *TranscriptionConfig) ([]any, error) { return nil, nil } if config.SmartTranscription && (config.WordTimestamps || config.SpeakerLabels) { - return nil, fmt.Errorf("smart transcription 不能同时启用 word timestamps 或 speaker labels") + return nil, fmt.Errorf("smart transcription cannot enable word timestamps or speaker labels at the same time") } length := 0 if config.WordTimestamps || config.SpeakerLabels { diff --git a/internal/aistudio/transcription_service.go b/internal/aistudio/transcription_service.go index d53d808..cae3777 100644 --- a/internal/aistudio/transcription_service.go +++ b/internal/aistudio/transcription_service.go @@ -28,13 +28,13 @@ func (err *transcriptionStageError) Unwrap() error { return err.err } -// TranscriptionGenerationFailure 判断错误是否来自转录生成阶段 +// TranscriptionGenerationFailure determines whether an error originated from the transcription generation stage func TranscriptionGenerationFailure(err error) bool { var stageError *transcriptionStageError return errors.As(err, &stageError) && stageError.stage == "generate" } -// TranscriptionRequest 表示一次音频上传与转录请求 +// TranscriptionRequest represents an audio upload and transcription request type TranscriptionRequest struct { ID string Model string @@ -48,7 +48,7 @@ type TranscriptionRequest struct { ObserveAccountFailure func(string, error) } -// TranscriptionResult 表示完整转录及其上游元数据 +// TranscriptionResult represents full transcription and its upstream metadata type TranscriptionResult struct { Text string Segments []TranscriptMetadata @@ -60,19 +60,19 @@ type TranscriptionResult struct { accessCheckedAt time.Time } -// TranscriptionService 定义音频转录公开端点所需能力 +// TranscriptionService defines capabilities required by the public audio transcription endpoint type TranscriptionService interface { Transcribe(context.Context, TranscriptionRequest) (TranscriptionResult, error) } -// Transcribe 在同一账户租约内完成上传与生成 +// Transcribe completes upload and generation within the same account lease func (s *PooledService) Transcribe(ctx context.Context, request TranscriptionRequest) (TranscriptionResult, error) { modelID := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") if modelID == "" { modelID = DefaultTranscriptionModel } if strings.TrimSpace(request.Name) == "" || strings.TrimSpace(request.MIME) == "" || request.Size <= 0 || request.Reader == nil { - return TranscriptionResult{}, fmt.Errorf("%w: 转录文件需要名称、MIME 和数据", ErrInvalidArgument) + return TranscriptionResult{}, fmt.Errorf("%w: transcription file requires name, MIME, and data", ErrInvalidArgument) } request.Model = modelID selection := AccountSelection{ @@ -121,7 +121,7 @@ func (s *PooledService) Transcribe(ctx context.Context, request TranscriptionReq if _, err := s.pool.MarkModelAccessVerifiedIfGeneration( accountID, modelID, accessGeneration, checkedAt, ); err != nil { - slog.Error("账户模型资格保存失败", "account", accountID, "model", modelID, "error", err) + slog.Error("failed to save account model access qualification", "account", accountID, "model", modelID, "error", err) } }() } @@ -180,7 +180,7 @@ func (s *PooledService) transcribeWithLease( startedAt time.Time, ) (result TranscriptionResult, resultErr error) { if _, err := request.Reader.Seek(0, io.SeekStart); err != nil { - return TranscriptionResult{}, fmt.Errorf("重置转录文件: %w", err) + return TranscriptionResult{}, fmt.Errorf("reset transcription file: %w", err) } accountID := lease.Account().ID attemptCtx := ContextWithAccountLease(ctx, lease) @@ -201,9 +201,9 @@ func (s *PooledService) transcribeWithLease( if cleanupErr == nil { return } - cleanupErr = &transcriptionStageError{stage: "cleanup", err: fmt.Errorf("清理转录临时文件: %w", cleanupErr)} + cleanupErr = &transcriptionStageError{stage: "cleanup", err: fmt.Errorf("cleanup transcription temporary file: %w", cleanupErr)} if resultErr != nil { - slog.Warn("转录临时文件回收失败", "account", accountID, "file", file.ID, "error", cleanupErr) + slog.Warn("failed to recycle transcription temporary file", "account", accountID, "file", file.ID, "error", cleanupErr) return } resultErr = cleanupErr @@ -309,7 +309,7 @@ func transcriptionRetryableAccountError(ctx context.Context, err error) bool { func incompleteTranscriptionStream(err error) bool { var protocolError *ProtocolEvidenceError return errors.As(err, &protocolError) && protocolError.Method == "GenerateContent" && protocolError.Path == "$" && - protocolError.Detail == "流结束前没有完成帧" + protocolError.Detail == "stream ended without a completion frame" } var _ TranscriptionService = (*PooledService)(nil) diff --git a/internal/aistudio/transport_browser.go b/internal/aistudio/transport_browser.go index a8d1eaf..e24ff57 100644 --- a/internal/aistudio/transport_browser.go +++ b/internal/aistudio/transport_browser.go @@ -62,19 +62,19 @@ type browserResponseBody struct { trailer stdhttp.Header } -// newBrowserRoundTripper 创建与当前 Camoufox 网络形状一致的传输 +// newBrowserRoundTripper creates transport matching current Camoufox network profile func newBrowserRoundTripper(proxyURL string) (stdhttp.RoundTripper, error) { proxyURL = strings.TrimSpace(proxyURL) var proxyDialer proxy.ContextDialer if proxyURL != "" { parsed, err := url.Parse(proxyURL) if err != nil || parsed.Hostname() == "" { - return nil, fmt.Errorf("代理 URL 无效") + return nil, fmt.Errorf("invalid proxy URL") } switch strings.ToLower(parsed.Scheme) { case "http", "https", "socks5": default: - return nil, fmt.Errorf("代理协议必须是 http、https 或 socks5") + return nil, fmt.Errorf("proxy scheme must be http, https, or socks5") } proxyDialer, err = newBrowserProxyDialer(parsed) if err != nil { @@ -91,7 +91,7 @@ func newBrowserRoundTripper(proxyURL string) (stdhttp.RoundTripper, error) { } client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) if err != nil { - return nil, fmt.Errorf("创建浏览器网络传输: %w", err) + return nil, fmt.Errorf("create browser network transport: %w", err) } return &browserRoundTripper{client: client}, nil } @@ -158,7 +158,7 @@ func newBrowserProxyDialer(proxyURL *url.URL) (proxy.ContextDialer, error) { case "https": proxyTarget = net.JoinHostPort(proxyURL.Hostname(), "443") case "socks5": - return nil, fmt.Errorf("SOCKS5 代理 URL 缺少端口") + return nil, fmt.Errorf("SOCKS5 proxy URL missing port") } } if strings.EqualFold(proxyURL.Scheme, "socks5") { @@ -169,11 +169,11 @@ func newBrowserProxyDialer(proxyURL *url.URL) (proxy.ContextDialer, error) { } dialer, err := proxy.SOCKS5("tcp", proxyTarget, auth, direct) if err != nil { - return nil, fmt.Errorf("创建 SOCKS5 代理连接: %w", err) + return nil, fmt.Errorf("create SOCKS5 proxy connection: %w", err) } contextDialer, ok := dialer.(proxy.ContextDialer) if !ok { - return nil, fmt.Errorf("SOCKS5 代理不支持上下文取消") + return nil, fmt.Errorf("SOCKS5 proxy does not support context cancellation") } return contextDialer, nil } @@ -245,7 +245,7 @@ func (dialer *browserConnectDialer) DialContext(ctx context.Context, network, ad if response.Body != nil { _ = response.Body.Close() } - return nil, fmt.Errorf("代理 CONNECT 返回 %s", response.Status) + return nil, fmt.Errorf("proxy CONNECT returned %s", response.Status) } if err := connection.SetDeadline(time.Time{}); err != nil { return nil, err diff --git a/internal/aistudio/transport_http.go b/internal/aistudio/transport_http.go index 230541d..2af7912 100644 --- a/internal/aistudio/transport_http.go +++ b/internal/aistudio/transport_http.go @@ -19,20 +19,20 @@ const publicDiscoveryUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:1 var makerSuiteAPIKeyPattern = regexp.MustCompile(`"WIu0Nc":"([^"]+)"`) -// ProtocolHeaderProvider 按账户提供官方运行时发现的动态公共头 +// ProtocolHeaderProvider provides dynamic common headers discovered by official runtimes per account type ProtocolHeaderProvider interface { ProtocolHeaders(context.Context, string) (http.Header, error) } -// ProtocolHeaderProviderFunc 将函数适配为 ProtocolHeaderProvider +// ProtocolHeaderProviderFunc adapts a function to ProtocolHeaderProvider type ProtocolHeaderProviderFunc func(context.Context, string) (http.Header, error) -// ProtocolHeaders 调用动态公共头函数 +// ProtocolHeaders invokes the dynamic common headers function func (f ProtocolHeaderProviderFunc) ProtocolHeaders(ctx context.Context, accountID string) (http.Header, error) { return f(ctx, accountID) } -// HTTPTransportOptions 定义普通 MakerSuite RPC 的账户与网络依赖 +// HTTPTransportOptions defines account and network dependencies for standard MakerSuite RPC type HTTPTransportOptions struct { Pool *AccountPool Signer *Signer @@ -40,7 +40,7 @@ type HTTPTransportOptions struct { GlobalProxy string } -// MakerSuiteHTTPTransport 使用账户固定出口发送普通 RPC +// MakerSuiteHTTPTransport sends standard RPC via account fixed egress type MakerSuiteHTTPTransport struct { pool *AccountPool signer *Signer @@ -54,18 +54,18 @@ type MakerSuiteHTTPTransport struct { type accountLeaseContextKey struct{} type accountSelectionObserverContextKey struct{} -// ContextWithAccountLease 将上层已持有的租约传给协议传输 +// ContextWithAccountLease passes an existing account lease to protocol transport func ContextWithAccountLease(ctx context.Context, lease *AccountLease) context.Context { return context.WithValue(ctx, accountLeaseContextKey{}, lease) } -// AccountLeaseFromContext 返回当前请求唯一的账户租约 +// AccountLeaseFromContext returns the unique account lease for the current request func AccountLeaseFromContext(ctx context.Context) (*AccountLease, bool) { lease, ok := ctx.Value(accountLeaseContextKey{}).(*AccountLease) return lease, ok && lease != nil && lease.Account() != nil } -// ContextWithAccountSelectionObserver 观察请求最终选择的账户 +// ContextWithAccountSelectionObserver observes the account finally selected for the request func ContextWithAccountSelectionObserver(ctx context.Context, observer func(*Account)) context.Context { return context.WithValue(ctx, accountSelectionObserverContextKey{}, observer) } @@ -77,13 +77,13 @@ func observeAccountSelection(ctx context.Context, account *Account) { } } -// NewMakerSuiteHTTPTransport 创建普通 MakerSuite RPC 传输 +// NewMakerSuiteHTTPTransport creates standard MakerSuite RPC transport func NewMakerSuiteHTTPTransport(options HTTPTransportOptions) (*MakerSuiteHTTPTransport, error) { if options.Pool == nil { - return nil, fmt.Errorf("AI Studio account pool 不能为空") + return nil, fmt.Errorf("AI Studio account pool cannot be nil") } if options.Headers == nil { - return nil, fmt.Errorf("AI Studio protocol header provider 不能为空") + return nil, fmt.Errorf("AI Studio protocol header provider cannot be nil") } signer := options.Signer if signer == nil { @@ -103,10 +103,10 @@ func NewMakerSuiteHTTPTransport(options HTTPTransportOptions) (*MakerSuiteHTTPTr return transport, nil } -// DiscoverPublicHeaders 从 AI Studio 首页读取普通 RPC 所需的公开头 +// DiscoverPublicHeaders reads public headers needed for standard RPC from the AI Studio home page func DiscoverPublicHeaders(ctx context.Context, client *http.Client) (http.Header, error) { if client == nil { - return nil, fmt.Errorf("HTTP client 不能为空") + return nil, fmt.Errorf("HTTP client cannot be nil") } request, err := http.NewRequestWithContext(ctx, http.MethodGet, aiStudioOrigin+"/", nil) if err != nil { @@ -116,19 +116,19 @@ func DiscoverPublicHeaders(ctx context.Context, client *http.Client) (http.Heade request.Header.Set("User-Agent", publicDiscoveryUserAgent) response, err := client.Do(request) if err != nil { - return nil, fmt.Errorf("读取 AI Studio 首页: %w", err) + return nil, fmt.Errorf("read AI Studio home page: %w", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("AI Studio 首页返回 HTTP %d", response.StatusCode) + return nil, fmt.Errorf("AI Studio home page returned HTTP %d", response.StatusCode) } body, err := io.ReadAll(response.Body) if err != nil { - return nil, fmt.Errorf("读取 AI Studio 首页正文: %w", err) + return nil, fmt.Errorf("read AI Studio home page body: %w", err) } match := makerSuiteAPIKeyPattern.FindSubmatch(body) if len(match) != 2 || len(match[1]) == 0 { - return nil, fmt.Errorf("AI Studio 首页缺少 WIu0Nc") + return nil, fmt.Errorf("AI Studio home page missing WIu0Nc") } visitID, err := newVisitID() if err != nil { @@ -146,7 +146,7 @@ func DiscoverPublicHeaders(ctx context.Context, client *http.Client) (http.Heade func newVisitID() (string, error) { value := make([]byte, 16) if _, err := rand.Read(value); err != nil { - return "", fmt.Errorf("生成 AI Studio visit ID: %w", err) + return "", fmt.Errorf("generate AI Studio visit ID: %w", err) } value[6] = value[6]&0x0f | 0x40 value[8] = value[8]&0x3f | 0x80 @@ -156,7 +156,7 @@ func newVisitID() (string, error) { return "v1_" + base64.StdEncoding.EncodeToString([]byte(uuid)), nil } -// NewProxyHTTPClient 创建普通 HTTP、HTTPS 或 SOCKS5 固定出口客户端 +// NewProxyHTTPClient creates standard HTTP, HTTPS, or SOCKS5 fixed egress client func NewProxyHTTPClient(proxyURL string) (*http.Client, error) { roundTripper, err := newBrowserRoundTripper(proxyURL) if err != nil { @@ -165,7 +165,7 @@ func NewProxyHTTPClient(proxyURL string) (*http.Client, error) { return &http.Client{Transport: roundTripper}, nil } -// Do 发送普通 MakerSuite RPC 并让响应 body 持有租约 +// Do sends standard MakerSuite RPC and keeps the lease held by the response body func (t *MakerSuiteHTTPTransport) Do(ctx context.Context, rpc RPCRequest) (*RPCResponse, error) { lease, owned, err := resolveAccountLease(ctx, t.pool, AccountSelection{AccountID: rpc.AccountID}) if err != nil { @@ -184,7 +184,7 @@ func (t *MakerSuiteHTTPTransport) Do(ctx context.Context, rpc RPCRequest) (*RPCR } request, err := http.NewRequestWithContext(ctx, http.MethodPost, rpc.URL, bytes.NewReader(rpc.Body)) if err != nil { - return nil, releaseOnError(fmt.Errorf("创建 MakerSuite %s 请求: %w", rpc.Method, err)) + return nil, releaseOnError(fmt.Errorf("create MakerSuite %s request: %w", rpc.Method, err)) } request.Header = headers client, err := t.clientForProxy(account.EffectiveProxy(t.globalProxy)) @@ -193,13 +193,13 @@ func (t *MakerSuiteHTTPTransport) Do(ctx context.Context, rpc RPCRequest) (*RPCR } response, err := client.Do(request) if err != nil { - return nil, releaseOnError(fmt.Errorf("执行 MakerSuite %s 请求: %w", rpc.Method, err)) + return nil, releaseOnError(fmt.Errorf("execute MakerSuite %s request: %w", rpc.Method, err)) } setCookies := append([]string(nil), response.Header.Values("Set-Cookie")...) if len(setCookies) > 0 { if err := lease.MergeSetCookieHeaders(setCookies, rpc.URL, t.now()); err != nil { _ = response.Body.Close() - return nil, releaseOnError(fmt.Errorf("合并 MakerSuite %s 响应 Cookie: %w", rpc.Method, err)) + return nil, releaseOnError(fmt.Errorf("merge MakerSuite %s response cookies: %w", rpc.Method, err)) } } body := &leaseResponseBody{ @@ -215,7 +215,7 @@ func (t *MakerSuiteHTTPTransport) Do(ctx context.Context, rpc RPCRequest) (*RPCR return &RPCResponse{StatusCode: response.StatusCode, Header: response.Header.Clone(), Body: body}, nil } -// CloseIdleConnections 关闭全部固定出口的空闲连接 +// CloseIdleConnections closes all idle connections for fixed egresses func (t *MakerSuiteHTTPTransport) CloseIdleConnections() { t.clientsMu.Lock() defer t.clientsMu.Unlock() @@ -250,15 +250,15 @@ func prepareProtocolHeaders( ) (StorageState, http.Header, error) { state, err := lease.ReloadStorageState() if err != nil { - return StorageState{}, nil, fmt.Errorf("读取账户 storage state: %w", err) + return StorageState{}, nil, fmt.Errorf("read account storage state: %w", err) } account := lease.Account() publicHeaders, err := provider.ProtocolHeaders(ctx, account.ID) if err != nil { - return StorageState{}, nil, fmt.Errorf("读取账户动态公共头: %w", err) + return StorageState{}, nil, fmt.Errorf("read account dynamic headers: %w", err) } if publicHeaders == nil { - return StorageState{}, nil, fmt.Errorf("账户动态公共头为空") + return StorageState{}, nil, fmt.Errorf("account dynamic headers are empty") } headers := publicHeaders.Clone() for name, values := range rpc.Header { @@ -269,7 +269,7 @@ func prepareProtocolHeaders( } for _, name := range []string{"User-Agent", "X-Goog-Api-Key", "X-Goog-Authuser", "X-User-Agent"} { if strings.TrimSpace(headers.Get(name)) == "" { - return StorageState{}, nil, fmt.Errorf("账户动态公共头缺少 %s", name) + return StorageState{}, nil, fmt.Errorf("account dynamic headers missing %s", name) } } authorization, err := signer.Authorization(state) @@ -314,16 +314,16 @@ func resolveAccountLease(ctx context.Context, pool *AccountPool, selection Accou func validateLeaseSelection(lease *AccountLease, selection AccountSelection) error { if lease == nil || lease.pool == nil || lease.account == nil { - return fmt.Errorf("context 账户租约未初始化") + return fmt.Errorf("context account lease is not initialized") } lease.pool.mu.Lock() defer lease.pool.mu.Unlock() account := lease.Account() if lease.pool.byID[account.ID] != account { - return fmt.Errorf("context 租约账户不存在: %s", account.ID) + return fmt.Errorf("context leased account not found: %s", account.ID) } if accountID := strings.TrimSpace(selection.AccountID); accountID != "" && account.ID != accountID { - return fmt.Errorf("context 租约账户 %s 与请求账户 %s 不一致", account.ID, accountID) + return fmt.Errorf("context leased account %s does not match requested account %s", account.ID, accountID) } if selection.AllowedAccountIDs != nil { allowed := false @@ -343,15 +343,15 @@ func validateLeaseSelection(lease *AccountLease, selection AccountSelection) err return ErrResourceNotFound } if owner != account.ID { - return fmt.Errorf("资源 %s 绑定账户 %s", resourceID, owner) + return fmt.Errorf("resource %s is bound to account %s", resourceID, owner) } } modelID := strings.TrimPrefix(strings.TrimSpace(selection.ModelID), "models/") if modelID != "" && !account.SupportsModel(modelID) { - return fmt.Errorf("context 租约账户 %s 不支持模型 %s", account.ID, modelID) + return fmt.Errorf("context leased account %s does not support model %s", account.ID, modelID) } if selection.Method != "" && !account.SupportsMethod(modelID, selection.Method) { - return fmt.Errorf("context 租约账户 %s 不支持方法 %s", account.ID, selection.Method) + return fmt.Errorf("context leased account %s does not support method %s", account.ID, selection.Method) } capability := strings.TrimSpace(selection.Capability) if capability != "" { @@ -360,7 +360,7 @@ func validateLeaseSelection(lease *AccountLease, selection AccountSelection) err return nil } } - return fmt.Errorf("context 租约账户 %s 不支持能力 %s", account.ID, capability) + return fmt.Errorf("context leased account %s does not support capability %s", account.ID, capability) } return nil } diff --git a/internal/aistudio/types.go b/internal/aistudio/types.go index 3dd7a81..46448cd 100644 --- a/internal/aistudio/types.go +++ b/internal/aistudio/types.go @@ -6,38 +6,38 @@ import ( "time" ) -// Role 表示规范消息角色 +// Role represents a canonical message role type Role string const ( - // RoleUser 表示用户消息 + // RoleUser indicates a user message RoleUser Role = "user" - // RoleAssistant 表示模型消息 + // RoleAssistant indicates a model message RoleAssistant Role = "assistant" - // RoleTool 表示工具结果消息 + // RoleTool indicates a tool result message RoleTool Role = "tool" ) -// Blob 表示内联二进制内容 +// Blob represents inline binary content type Blob struct { MIME string `json:"mime"` Data []byte `json:"data"` } -// ExternalMedia 表示可由 AI Studio 直接读取的外部媒体 +// ExternalMedia represents external media directly readable by AI Studio type ExternalMedia struct { MIME string `json:"mime"` URL string `json:"url"` } -// FileRef 表示已上传文件引用 +// FileRef represents an uploaded file reference type FileRef struct { ID string `json:"id"` Name string `json:"name,omitempty"` MIME string `json:"mime,omitempty"` } -// FunctionCall 表示模型发起的函数调用 +// FunctionCall represents a function call initiated by the model type FunctionCall struct { ID string `json:"id"` Name string `json:"name"` @@ -45,33 +45,33 @@ type FunctionCall struct { ThoughtSignature string `json:"thought_signature,omitempty"` } -// FunctionResult 表示客户端返回的函数结果 +// FunctionResult represents a function result returned by the client type FunctionResult struct { ID string `json:"id"` Name string `json:"name,omitempty"` Content json.RawMessage `json:"content"` } -// ExecutableCode 表示 AI Studio 内置代码执行器生成的代码 +// ExecutableCode represents code generated by AI Studio's built-in code execution type ExecutableCode struct { Language string `json:"language"` Code string `json:"code"` } -// CodeExecutionResult 表示 AI Studio 内置代码执行器返回的结果 +// CodeExecutionResult represents the outcome returned by AI Studio's built-in code execution type CodeExecutionResult struct { Outcome string `json:"outcome"` Output string `json:"output,omitempty"` Error string `json:"error,omitempty"` } -// SearchEntryPoint 表示 Google Search 返回的搜索入口 +// SearchEntryPoint represents the search entry point returned by Google Search type SearchEntryPoint struct { RenderedContent string `json:"rendered_content,omitempty"` SDKBlob string `json:"sdk_blob,omitempty"` } -// GroundingChunk 表示 Google 工具检索到的一个来源 +// GroundingChunk represents a source retrieved by Google tools type GroundingChunk struct { Source string `json:"source"` URI string `json:"uri,omitempty"` @@ -80,7 +80,7 @@ type GroundingChunk struct { PlaceID string `json:"place_id,omitempty"` } -// GroundingSegment 表示正文中由来源支撑的片段 +// GroundingSegment represents a text segment supported by sources type GroundingSegment struct { PartIndex int `json:"part_index,omitempty"` StartIndex int `json:"start_index,omitempty"` @@ -88,14 +88,14 @@ type GroundingSegment struct { Text string `json:"text,omitempty"` } -// GroundingSupport 表示正文片段与来源下标的关联 +// GroundingSupport represents the association between text segments and source indices type GroundingSupport struct { Segment GroundingSegment `json:"segment"` ChunkIndices []int `json:"chunk_indices,omitempty"` ConfidenceScores []float64 `json:"confidence_scores,omitempty"` } -// GroundingMetadata 表示 Google Search、URL Context 和 Maps 的来源信息 +// GroundingMetadata represents grounding information for Google Search, URL Context, and Maps type GroundingMetadata struct { SearchEntryPoint *SearchEntryPoint `json:"search_entry_point,omitempty"` Chunks []GroundingChunk `json:"chunks,omitempty"` @@ -105,7 +105,7 @@ type GroundingMetadata struct { MapsWidgetContextToken string `json:"maps_widget_context_token,omitempty"` } -// Part 表示规范消息中的一个内容块 +// Part represents a content block in a canonical message type Part struct { Text string `json:"text,omitempty"` InlineData *Blob `json:"inline_data,omitempty"` @@ -119,38 +119,38 @@ type Part struct { ThoughtSignature string `json:"thought_signature,omitempty"` } -// Content 表示一条规范消息 +// Content represents a canonical message type Content struct { Role Role `json:"role"` Parts []Part `json:"parts"` } -// FunctionDeclaration 表示客户端声明的函数 +// FunctionDeclaration represents a function declared by the client type FunctionDeclaration struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters json.RawMessage `json:"parameters,omitempty"` } -// ToolConfig 表示工具启用策略 +// ToolConfig represents a tool enablement policy type ToolConfig struct { Mode string `json:"mode,omitempty"` } -// GoogleSearchTimeRange 表示 Google Search 的检索时间范围 +// GoogleSearchTimeRange represents the retrieval time range for Google Search type GoogleSearchTimeRange struct { StartTime time.Time `json:"start_time,omitzero"` EndTime time.Time `json:"end_time,omitzero"` } -// GoogleSearchOptions 表示 Google Search 的检索类型与时间范围 +// GoogleSearchOptions represents retrieval types and time ranges for Google Search type GoogleSearchOptions struct { WebSearch bool `json:"web_search,omitempty"` ImageSearch bool `json:"image_search,omitempty"` TimeRange *GoogleSearchTimeRange `json:"time_range,omitempty"` } -// Tools 表示一次请求启用的工具 +// Tools represents tools enabled for a request type Tools struct { Functions []FunctionDeclaration `json:"functions,omitempty"` Google []string `json:"google,omitempty"` @@ -158,37 +158,37 @@ type Tools struct { ToolConfig ToolConfig `json:"tool_config,omitempty"` } -// ResponseModality 表示模型输出模态 +// ResponseModality represents a model output modality type ResponseModality string const ( - // ResponseModalityText 表示文本输出 + // ResponseModalityText indicates text output ResponseModalityText ResponseModality = "TEXT" - // ResponseModalityImage 表示图片输出 + // ResponseModalityImage indicates image output ResponseModalityImage ResponseModality = "IMAGE" - // ResponseModalityAudio 表示音频输出 + // ResponseModalityAudio indicates audio output ResponseModalityAudio ResponseModality = "AUDIO" ) -// ImageConfig 表示图片生成参数 +// ImageConfig represents image generation parameters type ImageConfig struct { AspectRatio string `json:"aspect_ratio,omitempty"` ImageSize string `json:"image_size,omitempty"` } -// SpeakerVoiceConfig 表示多说话人的声音选择 +// SpeakerVoiceConfig represents voice selection for multi-speaker synthesis type SpeakerVoiceConfig struct { Speaker string `json:"speaker"` VoiceName string `json:"voice_name"` } -// SpeechConfig 表示语音生成参数 +// SpeechConfig represents speech generation parameters type SpeechConfig struct { VoiceName string `json:"voice_name,omitempty"` Speakers []SpeakerVoiceConfig `json:"speakers,omitempty"` } -// TranscriptionConfig 表示音频转录参数 +// TranscriptionConfig represents audio transcription parameters type TranscriptionConfig struct { WordTimestamps bool `json:"word_timestamps,omitempty"` SpeakerLabels bool `json:"speaker_labels,omitempty"` @@ -197,7 +197,7 @@ type TranscriptionConfig struct { SmartTranscription bool `json:"smart_transcription,omitempty"` } -// GenerationConfig 表示已验证的生成参数 +// GenerationConfig represents validated generation parameters type GenerationConfig struct { Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` @@ -215,7 +215,7 @@ type GenerationConfig struct { Seed *int64 `json:"seed,omitempty"` } -// GenerateRequest 表示供应商无关的生成请求 +// GenerateRequest represents a provider-agnostic generation request type GenerateRequest struct { ID string `json:"id"` Model string `json:"model"` @@ -226,7 +226,7 @@ type GenerateRequest struct { AccountID string `json:"account_id,omitempty"` } -// TokenCountRequest 表示计数请求 +// TokenCountRequest represents a token counting request type TokenCountRequest struct { Model string `json:"model"` System string `json:"system,omitempty"` @@ -234,12 +234,12 @@ type TokenCountRequest struct { Tools Tools `json:"tools,omitempty"` } -// TokenCount 表示上游返回的权威计数 +// TokenCount represents authoritative token counts returned by upstream type TokenCount struct { InputTokens int64 `json:"input_tokens"` } -// Model 表示实时模型目录中的模型 +// Model represents a model in the live model catalog type Model struct { ID string `json:"id"` Name string `json:"name"` @@ -253,7 +253,7 @@ type Model struct { Paid bool `json:"paid,omitempty"` } -// Usage 表示一次生成的 token 用量 +// Usage represents token usage for a single generation type Usage struct { InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` @@ -263,7 +263,7 @@ type Usage struct { OutputTokensMissing bool `json:"-"` } -// Citation 表示模型返回的来源 +// Citation represents a source citation returned by the model type Citation struct { URL string `json:"url"` Title string `json:"title,omitempty"` @@ -272,7 +272,7 @@ type Citation struct { Publisher string `json:"publisher,omitempty"` } -// Media 表示生成的媒体产物 +// Media represents a generated media artifact type Media struct { URL string `json:"url,omitempty"` Data []byte `json:"data,omitempty"` @@ -283,56 +283,56 @@ type Media struct { Duration int64 `json:"duration_ms,omitempty"` } -// TranscriptDuration 表示转录时间值 +// TranscriptDuration represents a transcription duration value type TranscriptDuration struct { Seconds int64 `json:"seconds"` Nanos int64 `json:"nanos,omitempty"` } -// TranscriptTimestamp 表示转录文本的时间范围 +// TranscriptTimestamp represents a time range for transcript text type TranscriptTimestamp struct { Start TranscriptDuration `json:"start"` End TranscriptDuration `json:"end"` } -// TranscriptMetadata 表示转录正文附带的说话人和时间信息 +// TranscriptMetadata represents speaker and timing information attached to transcript text type TranscriptMetadata struct { Text string `json:"text"` Speaker string `json:"speaker,omitempty"` Timestamps []TranscriptTimestamp `json:"timestamps,omitempty"` } -// EventKind 表示规范流事件类型 +// EventKind represents canonical stream event kinds type EventKind string const ( - // EventText 表示正文增量 + // EventText indicates text delta EventText EventKind = "text" - // EventReasoning 表示思考摘要增量 + // EventReasoning indicates reasoning summary delta EventReasoning EventKind = "reasoning" - // EventToolCall 表示函数调用 + // EventToolCall indicates a function call EventToolCall EventKind = "tool_call" - // EventExecutableCode 表示内置代码执行器生成代码 + // EventExecutableCode indicates code generated by the built-in code executor EventExecutableCode EventKind = "executable_code" - // EventCodeExecutionResult 表示内置代码执行结果 + // EventCodeExecutionResult indicates execution result from the built-in code executor EventCodeExecutionResult EventKind = "code_execution_result" - // EventGrounding 表示 Google 工具来源信息 + // EventGrounding indicates grounding info from Google tools EventGrounding EventKind = "grounding" - // EventCitation 表示来源信息 + // EventCitation indicates citation information EventCitation EventKind = "citation" - // EventMedia 表示媒体产物 + // EventMedia indicates a media artifact EventMedia EventKind = "media" - // EventThoughtSignature 表示独立思考签名 + // EventThoughtSignature indicates an independent thought signature EventThoughtSignature EventKind = "thought_signature" - // EventUsage 表示权威用量 + // EventUsage indicates authoritative usage EventUsage EventKind = "usage" - // EventFinish 表示正常完成 + // EventFinish indicates normal completion EventFinish EventKind = "finish" - // EventError 表示上游错误 + // EventError indicates an upstream error EventError EventKind = "error" ) -// Event 表示协议核心输出的规范事件 +// Event represents a canonical event emitted by protocol core type Event struct { Kind EventKind `json:"kind"` Text string `json:"text,omitempty"` @@ -351,7 +351,7 @@ type Event struct { Err error `json:"-"` } -// Service 定义公开协议适配器依赖的最小能力 +// Service defines the minimal capabilities required by public protocol adapters type Service interface { Models(context.Context) ([]Model, error) CountTokens(context.Context, TokenCountRequest) (TokenCount, error) diff --git a/internal/aistudio/upload.go b/internal/aistudio/upload.go index 1899fcc..be1a5fc 100644 --- a/internal/aistudio/upload.go +++ b/internal/aistudio/upload.go @@ -25,7 +25,7 @@ const driveResumableUploadURL = "https://www.googleapis.com/upload/drive/v3/file const driveUploadChunkSize = 8 << 20 const driveCleanupTimeout = 5 * time.Second -// UploadRequest 表示一次 Drive 文件上传 +// UploadRequest represents a Drive file upload type UploadRequest struct { AccountID string Name string @@ -87,7 +87,7 @@ func (reader *boundedUploadReader) Read(target []byte) (int, error) { return 0, err } -// FileMetadata 表示已上传文件的持久元数据 +// FileMetadata represents persistent metadata for an uploaded file type FileMetadata struct { ID string Name string @@ -97,7 +97,7 @@ type FileMetadata struct { CreatedAt time.Time } -// MediaStream 表示需要调用方关闭的媒体响应流 +// MediaStream represents a media response stream that must be closed by the caller type MediaStream struct { Body io.ReadCloser MIME string @@ -129,7 +129,7 @@ type temporaryDriveCopy struct { bound bool } -// TemporaryFileCopies 保存一次请求创建的临时 Drive 文件 +// TemporaryFileCopies stores temporary Drive files created for a request type TemporaryFileCopies struct { client *Client lease *AccountLease @@ -139,7 +139,7 @@ type TemporaryFileCopies struct { err error } -// Count 返回本次请求创建的临时文件数 +// Count returns the number of temporary files created for this request func (copies *TemporaryFileCopies) Count() int { if copies == nil { return 0 @@ -147,7 +147,7 @@ func (copies *TemporaryFileCopies) Count() int { return len(copies.copies) } -// SourceAccountIDs 返回临时文件的来源账户 +// SourceAccountIDs returns the source accounts of temporary files func (copies *TemporaryFileCopies) SourceAccountIDs() []string { if copies == nil { return nil @@ -160,14 +160,14 @@ func (copies *TemporaryFileCopies) SourceAccountIDs() []string { return result } -// Cleanup 在目标账户租约释放前删除全部临时 Drive 副本 +// Cleanup deletes all temporary Drive copies before releasing the target account lease func (copies *TemporaryFileCopies) Cleanup() error { if copies == nil { return nil } copies.once.Do(func() { if copies.client == nil || copies.lease == nil || copies.lease.Account() == nil { - copies.err = fmt.Errorf("临时文件副本未初始化") + copies.err = fmt.Errorf("temporary file copies are not initialized") return } for index := len(copies.copies) - 1; index >= 0; index-- { @@ -207,7 +207,7 @@ type fileReferenceNotFoundError struct { } func (err *fileReferenceNotFoundError) Error() string { - return fmt.Sprintf("文件引用不存在: %s", err.fileID) + return fmt.Sprintf("file reference not found: %s", err.fileID) } func (err *fileReferenceNotFoundError) Unwrap() error { @@ -222,7 +222,7 @@ func (err *fileReferenceNotFoundError) ErrorCode() string { return "file_not_found" } -// FileService 定义公开文件 API 依赖的能力 +// FileService defines capabilities required by the public file API type FileService interface { UploadFile(context.Context, UploadRequest) (FileRef, error) FileMetadata(context.Context, string) (FileMetadata, error) @@ -230,14 +230,14 @@ type FileService interface { DeleteFile(context.Context, string) error } -// DriveTransport 负责使用账户固定出口访问 Google Drive +// DriveTransport is responsible for accessing Google Drive via account fixed egress type DriveTransport interface { UploadDrive(context.Context, string, string, UploadRequest) (FileRef, error) DownloadDrive(context.Context, string, string, string) (MediaStream, error) DeleteDrive(context.Context, string, string, string) error } -// GenerateAccessToken 获取网页账户授权的短期 bearer token +// GenerateAccessToken gets a short-lived bearer token authorized by web account func (c *Client) GenerateAccessToken(ctx context.Context, accountID string) (string, error) { body, err := json.Marshal([]any{"users/me"}) if err != nil { @@ -254,26 +254,26 @@ func (c *Client) GenerateAccessToken(ctx context.Context, accountID string) (str func parseAccessToken(source io.Reader) (string, error) { raw, err := io.ReadAll(newSparseJSONReader(source)) if err != nil { - return "", fmt.Errorf("读取 GenerateAccessToken: %w", err) + return "", fmt.Errorf("read GenerateAccessToken: %w", err) } root, err := rawArray(raw, "$", raw) if err != nil { return "", withMethod(err, "GenerateAccessToken") } if len(root) == 0 || isJSONNull(root[0]) { - return "", &ProtocolEvidenceError{Method: "GenerateAccessToken", Path: "$[0]", Detail: "缺少 bearer token", Raw: raw} + return "", &ProtocolEvidenceError{Method: "GenerateAccessToken", Path: "$[0]", Detail: "missing bearer token", Raw: raw} } token, err := rawString(root[0], "$[0]", raw) if err != nil { return "", withMethod(err, "GenerateAccessToken") } if strings.TrimSpace(token) == "" { - return "", &ProtocolEvidenceError{Method: "GenerateAccessToken", Path: "$[0]", Detail: "bearer token 为空", Raw: raw} + return "", &ProtocolEvidenceError{Method: "GenerateAccessToken", Path: "$[0]", Detail: "bearer token is empty", Raw: raw} } return token, nil } -// UploadFile 上传文件并返回可用于 GenerateContent 的 Drive 引用 +// UploadFile uploads a file and returns a Drive reference usable for GenerateContent func (c *Client) UploadFile(ctx context.Context, request UploadRequest) (FileRef, error) { file, _, err := c.uploadFile(ctx, request) return file, err @@ -281,10 +281,10 @@ func (c *Client) UploadFile(ctx context.Context, request UploadRequest) (FileRef func (c *Client) uploadFile(ctx context.Context, request UploadRequest) (FileRef, string, error) { if strings.TrimSpace(request.Name) == "" || strings.TrimSpace(request.MIME) == "" || request.Size == 0 || request.Reader == nil { - return FileRef{}, "", fmt.Errorf("上传文件需要名称、MIME 和数据") + return FileRef{}, "", fmt.Errorf("upload file requires name, MIME, and data") } if request.MaxSize < 0 { - return FileRef{}, "", fmt.Errorf("上传文件大小上限无效") + return FileRef{}, "", fmt.Errorf("invalid max upload file size") } if request.Size > 0 && request.MaxSize > 0 && request.Size > request.MaxSize { return FileRef{}, "", &uploadTooLargeError{limit: request.MaxSize} @@ -295,7 +295,7 @@ func (c *Client) uploadFile(ctx context.Context, request UploadRequest) (FileRef } drive, ok := c.transport.(DriveTransport) if !ok { - return FileRef{}, "", fmt.Errorf("AI Studio transport 不支持 Drive") + return FileRef{}, "", fmt.Errorf("AI Studio transport does not support Drive") } file, err := drive.UploadDrive(ctx, request.AccountID, token, request) return file, token, err @@ -303,16 +303,16 @@ func (c *Client) uploadFile(ctx context.Context, request UploadRequest) (FileRef func (c *Client) deleteDriveFile(ctx context.Context, accountID string, token string, fileID string) error { if strings.TrimSpace(fileID) == "" { - return fmt.Errorf("Drive 文件 ID 为空") + return fmt.Errorf("Drive file ID is empty") } drive, ok := c.transport.(DriveTransport) if !ok { - return fmt.Errorf("AI Studio transport 不支持 Drive") + return fmt.Errorf("AI Studio transport does not support Drive") } return drive.DeleteDrive(ctx, accountID, token, fileID) } -// UploadFile 使用一个独占账户完成上传并保存资源绑定 +// UploadFile completes upload using an exclusive account and saves the resource binding func (s *PooledService) UploadFile(ctx context.Context, request UploadRequest) (FileRef, error) { purpose := strings.TrimSpace(request.Purpose) if purpose == "" && request.ResolvePurpose == nil { @@ -366,7 +366,7 @@ func (s *PooledService) UploadFile(ctx context.Context, request UploadRequest) ( cleanupErr := s.client.deleteDriveFile(cleanupCtx, request.AccountID, token, file.ID) cancel() if cleanupErr != nil { - slog.Warn("Drive 文件回收失败", "account", request.AccountID, "file", file.ID, "error", cleanupErr) + slog.Warn("failed to recycle Drive file", "account", request.AccountID, "file", file.ID, "error", cleanupErr) } } authFailure := DefinitiveAuthenticationFailure(uploadErr) @@ -414,7 +414,7 @@ func (p *AccountPool) fileUploadAccountIDs() []string { return append(available, busy...) } -// FileMetadata 返回公开上传文件的持久元数据 +// FileMetadata returns persistent metadata for a publicly uploaded file func (s *PooledService) FileMetadata(ctx context.Context, fileID string) (FileMetadata, error) { if err := ctx.Err(); err != nil { return FileMetadata{}, err @@ -422,15 +422,15 @@ func (s *PooledService) FileMetadata(ctx context.Context, fileID string) (FileMe return s.pool.FileMetadata(ctx, fileID) } -// BindFileResource 保存上传文件的账户绑定与公开元数据 +// BindFileResource saves account binding and public metadata for uploaded files func (l *AccountLease) BindFileResource(ctx context.Context, file FileRef, size int64, purpose string) error { if l == nil || l.account == nil || l.pool == nil { - return fmt.Errorf("账户租约未初始化") + return fmt.Errorf("account lease is not initialized") } l.operation.Lock() defer l.operation.Unlock() if l.released { - return fmt.Errorf("账户租约已释放") + return fmt.Errorf("account lease has been released") } l.account.storageMu.Lock() defer l.account.storageMu.Unlock() @@ -449,11 +449,11 @@ func (p *AccountPool) bindFileResource( file.MIME = strings.TrimSpace(file.MIME) purpose = strings.TrimSpace(purpose) if file.ID == "" || file.Name == "" || file.MIME == "" || size <= 0 || purpose == "" { - return fmt.Errorf("文件元数据不完整") + return fmt.Errorf("file metadata is incomplete") } _, err := p.updateRuntimeContext(ctx, accountID, func(_ *Account, runtimeState *accountRuntimeState) (bool, func(*Account), error) { if owner, exists := p.resources[file.ID]; exists && owner != accountID { - return false, nil, fmt.Errorf("资源 %s 已绑定账户 %s", file.ID, owner) + return false, nil, fmt.Errorf("resource %s is already bound to account %s", file.ID, owner) } createdAt := time.Now().UTC() if existing, exists := runtimeState.Resources[file.ID]; exists && !existing.CreatedAt.IsZero() { @@ -467,11 +467,11 @@ func (p *AccountPool) bindFileResource( return err } -// FileMetadata 返回 runtime-state 中的公开文件元数据 +// FileMetadata returns public file metadata from runtime state func (p *AccountPool) FileMetadata(ctx context.Context, fileID string) (FileMetadata, error) { fileID = strings.TrimSpace(fileID) if fileID == "" { - return FileMetadata{}, fmt.Errorf("%w: 文件 ID 为空", ErrResourceNotFound) + return FileMetadata{}, fmt.Errorf("%w: file ID is empty", ErrResourceNotFound) } if err := p.refreshResource(ctx, fileID); err != nil { return FileMetadata{}, err @@ -484,7 +484,7 @@ func (p *AccountPool) FileMetadata(ctx context.Context, fileID string) (FileMeta } account := p.byID[accountID] if account == nil { - return FileMetadata{}, fmt.Errorf("资源账户不存在: %s", accountID) + return FileMetadata{}, fmt.Errorf("resource account not found: %s", accountID) } binding, exists := account.runtime.Resources[fileID] if !exists || binding.Kind != "drive-file" || binding.Name == "" || binding.MIME == "" || binding.Size <= 0 || binding.Purpose == "" { @@ -496,7 +496,7 @@ func (p *AccountPool) FileMetadata(ctx context.Context, fileID string) (FileMeta }, nil } -// DownloadFile 使用资源创建账户下载 Drive 文件 +// DownloadFile downloads a Drive file using the resource creator account func (s *PooledService) DownloadFile(ctx context.Context, fileID string) (MediaStream, error) { lease, owned, err := resolveAccountLease(ctx, s.pool, AccountSelection{ResourceID: strings.TrimSpace(fileID)}) if err != nil { @@ -508,7 +508,7 @@ func (s *PooledService) DownloadFile(ctx context.Context, fileID string) (MediaS if downloadErr == nil { drive, ok := s.client.transport.(DriveTransport) if !ok { - downloadErr = fmt.Errorf("AI Studio transport 不支持 Drive") + downloadErr = fmt.Errorf("AI Studio transport does not support Drive") } else { media, downloadErr = drive.DownloadDrive(ContextWithAccountLease(ctx, lease), accountID, token, fileID) } @@ -538,7 +538,7 @@ func (s *PooledService) DownloadFile(ctx context.Context, fileID string) (MediaS return media, downloadErr } -// DeleteFile 使用资源创建账户删除 Drive 文件和持久绑定 +// DeleteFile deletes a Drive file and persistent binding using the resource creator account func (s *PooledService) DeleteFile(ctx context.Context, fileID string) error { fileID = strings.TrimSpace(fileID) if _, err := s.pool.FileMetadata(ctx, fileID); err != nil { @@ -588,7 +588,7 @@ func driveFileNotFound(err error, method string) bool { return errors.As(err, &rpcError) && rpcError.Method == method && rpcError.StatusCode == http.StatusNotFound } -// driveAuthorizationMissing 判断账户仅缺少 Google Drive 授权 +// driveAuthorizationMissing checks whether the account only lacks Google Drive authorization func driveAuthorizationMissing(err error) bool { var rpcError *RPCError return errors.As(err, &rpcError) && rpcError.Method == "GenerateAccessToken" && @@ -596,17 +596,17 @@ func driveAuthorizationMissing(err error) bool { strings.Contains(strings.ToLower(rpcError.Message), "unauthorized_client") } -// CopyFileReferencesToLease 将文件引用复制到目标账户并返回改写内容 +// CopyFileReferencesToLease copies file references to target account and returns rewritten contents func (s *PooledService) CopyFileReferencesToLease( ctx context.Context, target *AccountLease, contents []Content, ) ([]Content, *TemporaryFileCopies, error) { if s == nil || s.pool == nil || s.client == nil { - return nil, nil, fmt.Errorf("文件引用服务未初始化") + return nil, nil, fmt.Errorf("file reference service is not initialized") } if target == nil || target.Account() == nil || target.pool != s.pool { - return nil, nil, fmt.Errorf("目标账户租约未初始化") + return nil, nil, fmt.Errorf("target account lease is not initialized") } rewritten := cloneContentsForFileCopies(contents) copies := &TemporaryFileCopies{ @@ -623,7 +623,7 @@ func (s *PooledService) CopyFileReferencesToLease( fileID := strings.TrimSpace(part.File.ID) if fileID == "" { return nil, nil, errors.Join( - fmt.Errorf("%w: 文件引用缺少 ID", ErrInvalidArgument), copies.Cleanup(), + fmt.Errorf("%w: file reference missing ID", ErrInvalidArgument), copies.Cleanup(), ) } owner, metadata, err := s.pool.fileReferenceMetadata(ctx, fileID, targetID) @@ -661,7 +661,7 @@ func (s *PooledService) CopyFileReferencesToLease( copy.MIME = metadata.MIME } if uploadErr == nil && closeErr == nil && copy.ID == "" { - uploadErr = fmt.Errorf("临时 Drive 副本缺少 ID") + uploadErr = fmt.Errorf("temporary Drive copy missing ID") } if copy.ID != "" { copies.copies = append(copies.copies, temporaryDriveCopy{id: copy.ID, token: token}) @@ -700,7 +700,7 @@ func (s *PooledService) CopyFileReferencesToLease( return rewritten, copies, nil } -// UploadInlineMediaToLease 将内联附件上传到目标账户并改写为临时 Drive 引用 +// UploadInlineMediaToLease uploads inline attachments to target account and rewrites as temporary Drive references func (s *PooledService) UploadInlineMediaToLease( ctx context.Context, target *AccountLease, @@ -708,17 +708,17 @@ func (s *PooledService) UploadInlineMediaToLease( temporary *TemporaryFileCopies, ) ([]Content, *TemporaryFileCopies, error) { if s == nil || s.pool == nil || s.client == nil { - return nil, nil, fmt.Errorf("内联附件上传服务未初始化") + return nil, nil, fmt.Errorf("inline attachment upload service is not initialized") } if target == nil || target.Account() == nil || target.pool != s.pool { - return nil, nil, fmt.Errorf("目标账户租约未初始化") + return nil, nil, fmt.Errorf("target account lease is not initialized") } if temporary == nil { temporary = &TemporaryFileCopies{ client: s.client, lease: target, sources: make(map[string]struct{}), } } else if temporary.client != s.client || temporary.lease != target { - return nil, nil, errors.Join(fmt.Errorf("临时文件账户不匹配"), temporary.Cleanup()) + return nil, nil, errors.Join(fmt.Errorf("temporary file account mismatch"), temporary.Cleanup()) } rewritten := cloneContentsForFileCopies(contents) targetID := target.Account().ID @@ -743,7 +743,7 @@ func (s *PooledService) UploadInlineMediaToLease( continue } if part.InlineData.MIME == "" || len(part.InlineData.Data) == 0 { - return nil, nil, errors.Join(fmt.Errorf("%w: inline data 缺少 MIME 或数据", ErrInvalidArgument), temporary.Cleanup()) + return nil, nil, errors.Join(fmt.Errorf("%w: inline data missing MIME or data", ErrInvalidArgument), temporary.Cleanup()) } mediaIndex++ jobs = append(jobs, uploadJob{ @@ -757,7 +757,7 @@ func (s *PooledService) UploadInlineMediaToLease( token, uploadErr := s.client.GenerateAccessToken(targetCtx, targetID) drive, ok := s.client.transport.(DriveTransport) if uploadErr == nil && !ok { - uploadErr = fmt.Errorf("AI Studio transport 不支持 Drive") + uploadErr = fmt.Errorf("AI Studio transport does not support Drive") } if uploadErr != nil { if driveAuthorizationMissing(uploadErr) { @@ -781,7 +781,7 @@ func (s *PooledService) UploadInlineMediaToLease( }) file.ID = strings.TrimSpace(file.ID) if err == nil && file.ID == "" { - err = fmt.Errorf("临时 Drive 附件缺少 ID") + err = fmt.Errorf("temporary Drive attachment missing ID") } resultChannel <- uploadResult{index: index, file: file, err: err} }(index, job) @@ -840,7 +840,7 @@ func cloneContentsForFileCopies(contents []Content) []Content { func (p *AccountPool) fileReferenceMetadata(ctx context.Context, fileID, targetID string) (string, FileMetadata, error) { fileID = strings.TrimSpace(fileID) if fileID == "" { - return "", FileMetadata{}, fmt.Errorf("%w: 文件 ID 为空", ErrResourceNotFound) + return "", FileMetadata{}, fmt.Errorf("%w: file ID is empty", ErrResourceNotFound) } if err := p.refreshResource(ctx, fileID); err != nil { return "", FileMetadata{}, err @@ -853,7 +853,7 @@ func (p *AccountPool) fileReferenceMetadata(ctx context.Context, fileID, targetI } account := p.byID[owner] if account == nil { - return "", FileMetadata{}, fmt.Errorf("资源账户不存在: %s", owner) + return "", FileMetadata{}, fmt.Errorf("resource account not found: %s", owner) } binding, exists := account.runtime.Resources[fileID] if exists && owner == targetID && binding.Kind == "video-file" { @@ -869,7 +869,7 @@ func (p *AccountPool) fileReferenceMetadata(ctx context.Context, fileID, targetI }, nil } -// UploadDrive 通过当前账户固定出口上传 Drive 文件 +// UploadDrive uploads a Drive file via current account fixed egress func (t *MakerSuiteHTTPTransport) UploadDrive(ctx context.Context, accountID string, token string, request UploadRequest) (FileRef, error) { lease, owned, err := resolveAccountLease(ctx, t.pool, AccountSelection{AccountID: accountID}) if err != nil { @@ -921,7 +921,7 @@ func uploadDriveMultipart(ctx context.Context, client *http.Client, token string httpRequest.Header.Set("Content-Type", "multipart/related; boundary="+writer.Boundary()) response, err := client.Do(httpRequest) if err != nil { - return FileRef{}, fmt.Errorf("上传 Drive 文件: %w", err) + return FileRef{}, fmt.Errorf("upload Drive file: %w", err) } return parseDriveUploadResponse(response, request) } @@ -940,7 +940,7 @@ func uploadDriveResumable(ctx context.Context, client *http.Client, token string initRequest.Header.Set("X-Upload-Content-Type", request.MIME) initResponse, err := client.Do(initRequest) if err != nil { - return FileRef{}, fmt.Errorf("创建 Drive 上传会话: %w", err) + return FileRef{}, fmt.Errorf("create Drive upload session: %w", err) } initBody, readErr := io.ReadAll(initResponse.Body) closeErr := initResponse.Body.Close() @@ -952,7 +952,7 @@ func uploadDriveResumable(ctx context.Context, client *http.Client, token string } sessionURL := strings.TrimSpace(initResponse.Header.Get("Location")) if sessionURL == "" { - return FileRef{}, fmt.Errorf("Drive 上传会话缺少 Location") + return FileRef{}, fmt.Errorf("Drive upload session missing Location") } source := request.Reader if request.MaxSize > 0 { @@ -974,7 +974,7 @@ func uploadDriveResumable(ctx context.Context, client *http.Client, token string buffered = 1 case errors.Is(readErr, io.EOF), errors.Is(readErr, io.ErrUnexpectedEOF): if buffered == 0 { - return FileRef{}, fmt.Errorf("上传文件不能为空") + return FileRef{}, fmt.Errorf("upload file cannot be empty") } total := offset + int64(buffered) response, err := sendDriveUploadChunk(ctx, client, token, sessionURL, request.MIME, buffer[:buffered], offset, total) @@ -1038,7 +1038,7 @@ func sendDriveUploadChunk( request.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%s", offset, end, totalValue)) response, err := client.Do(request) if err != nil { - return nil, fmt.Errorf("上传 Drive 文件块: %w", err) + return nil, fmt.Errorf("upload Drive file chunk: %w", err) } return response, nil } @@ -1056,10 +1056,10 @@ func parseDriveUploadResponse(response *http.Response, request UploadRequest) (F ID string `json:"id"` } if err := json.Unmarshal(responseBody, &result); err != nil { - return FileRef{}, fmt.Errorf("解析 Drive 上传响应: %w", err) + return FileRef{}, fmt.Errorf("parse Drive upload response: %w", err) } if strings.TrimSpace(result.ID) == "" { - return FileRef{}, fmt.Errorf("Drive 上传响应缺少文件 ID") + return FileRef{}, fmt.Errorf("Drive upload response missing file ID") } return FileRef{ID: result.ID, Name: request.Name, MIME: request.MIME}, nil } @@ -1072,7 +1072,7 @@ func driveUploadRPCError(method string, status int, body []byte) error { return &RPCError{Method: method, StatusCode: status, Message: message} } -// DownloadDrive 通过当前账户固定出口下载 Drive 文件 +// DownloadDrive downloads a Drive file via current account fixed egress func (t *MakerSuiteHTTPTransport) DownloadDrive(ctx context.Context, accountID string, token string, fileID string) (MediaStream, error) { lease, owned, err := resolveAccountLease(ctx, t.pool, AccountSelection{AccountID: accountID}) if err != nil { @@ -1096,7 +1096,7 @@ func (t *MakerSuiteHTTPTransport) DownloadDrive(ctx context.Context, accountID s } response, err := client.Do(httpRequest) if err != nil { - return MediaStream{}, release(fmt.Errorf("下载 Drive 文件: %w", err)) + return MediaStream{}, release(fmt.Errorf("download Drive file: %w", err)) } if response.StatusCode != http.StatusOK { data, readErr := io.ReadAll(response.Body) @@ -1122,7 +1122,7 @@ func (t *MakerSuiteHTTPTransport) DownloadDrive(ctx context.Context, accountID s }, nil } -// DeleteDrive 通过当前账户固定出口删除 Drive 文件 +// DeleteDrive deletes a Drive file via current account fixed egress func (t *MakerSuiteHTTPTransport) DeleteDrive(ctx context.Context, accountID string, token string, fileID string) error { lease, owned, err := resolveAccountLease(ctx, t.pool, AccountSelection{AccountID: accountID}) if err != nil { @@ -1146,7 +1146,7 @@ func (t *MakerSuiteHTTPTransport) DeleteDrive(ctx context.Context, accountID str } response, err := client.Do(request) if err != nil { - return release(fmt.Errorf("删除 Drive 文件: %w", err)) + return release(fmt.Errorf("delete Drive file: %w", err)) } defer response.Body.Close() responseBody, err := io.ReadAll(response.Body) @@ -1167,7 +1167,7 @@ func driveFilename(disposition string) string { return parameters["filename"] } -// ResourceIDForContents 校验全部文件引用并返回首个资源供生成账户选择 +// ResourceIDForContents validates all file references and returns the first resource for generator account selection func (pool *AccountPool) ResourceIDForContents(ctx context.Context, contents []Content) (string, error) { for _, content := range contents { for _, part := range content.Parts { @@ -1176,7 +1176,7 @@ func (pool *AccountPool) ResourceIDForContents(ctx context.Context, contents []C } id := strings.TrimSpace(part.File.ID) if id == "" { - return "", fmt.Errorf("%w: 文件引用缺少 ID", ErrInvalidArgument) + return "", fmt.Errorf("%w: file reference missing ID", ErrInvalidArgument) } if err := pool.refreshResource(ctx, id); err != nil { return "", err @@ -1193,7 +1193,7 @@ func (pool *AccountPool) ResourceIDForContents(ctx context.Context, contents []C } id := strings.TrimSpace(part.File.ID) if id == "" { - return "", fmt.Errorf("%w: 文件引用缺少 ID", ErrInvalidArgument) + return "", fmt.Errorf("%w: file reference missing ID", ErrInvalidArgument) } accountID, exists := pool.resources[id] if !exists { @@ -1202,7 +1202,7 @@ func (pool *AccountPool) ResourceIDForContents(ctx context.Context, contents []C account := pool.byID[accountID] binding, bound := account.runtime.Resources[id] if !bound || binding.Kind != "drive-file" && binding.Kind != "video-file" { - return "", fmt.Errorf("%w: 资源 %s 不能作为文件引用", ErrInvalidArgument, id) + return "", fmt.Errorf("%w: resource %s cannot be used as a file reference", ErrInvalidArgument, id) } if resourceID == "" { resourceID = id @@ -1214,7 +1214,7 @@ func (pool *AccountPool) ResourceIDForContents(ctx context.Context, contents []C func encodeFilePart(file *FileRef) ([]any, error) { if file == nil || file.ID == "" { - return nil, fmt.Errorf("文件引用缺少 ID") + return nil, fmt.Errorf("file reference missing ID") } wire := make([]any, 6) wire[5] = []any{file.ID} diff --git a/internal/aistudio/usage.go b/internal/aistudio/usage.go index 6d74f58..a87eec3 100644 --- a/internal/aistudio/usage.go +++ b/internal/aistudio/usage.go @@ -90,7 +90,7 @@ func localCompleteUsage(request GenerateRequest, output generatedOutputParts) *U } } -// countedCompleteUsage 使用权威输入总数补全本地停止用量 +// countedCompleteUsage completes local stop usage with authoritative total input count func countedCompleteUsage(request GenerateRequest, output generatedOutputParts, count TokenCount) *Usage { toolTokens := localToolTokens(request.Tools) if toolTokens > count.InputTokens { @@ -105,7 +105,7 @@ func countedCompleteUsage(request GenerateRequest, output generatedOutputParts, } } -// EstimatedInputTokens 返回文本、工具和引用元数据的本地输入 Token 估算 +// EstimatedInputTokens returns local input token estimation for text, tools, and citation metadata func EstimatedInputTokens(request GenerateRequest) int64 { inputTokens := localContentsTokens(request.Contents) if request.System != "" { diff --git a/internal/aistudio/video.go b/internal/aistudio/video.go index 94717d1..697bc46 100644 --- a/internal/aistudio/video.go +++ b/internal/aistudio/video.go @@ -12,20 +12,20 @@ import ( "time" ) -// VideoService 定义长任务视频适配器依赖的能力 +// VideoService defines capabilities required by the long-running video adapter type VideoService interface { GenerateVideo(context.Context, VideoRequest) (VideoOperation, error) GetGenerateVideoOperation(context.Context, string) (VideoOperation, error) DownloadFile(context.Context, string) (MediaStream, error) } -// VideoImage 表示 Veo 起始帧 +// VideoImage represents a Veo start frame type VideoImage struct { InlineData *Blob File *FileRef } -// VideoRequest 表示一次 Veo 长任务请求 +// VideoRequest represents a Veo long-running operation request type VideoRequest struct { Model string Prompt string @@ -39,7 +39,7 @@ type VideoRequest struct { RecoverWAARuntime func(context.Context, string, error) (bool, error) } -// VideoOperation 表示 Veo 私有长任务状态 +// VideoOperation represents Veo private long-running operation state type VideoOperation struct { ID string Done bool @@ -51,19 +51,19 @@ type VideoOperation struct { accessCheckedAt time.Time } -// ModelAccessCheckedAt 返回上游接受视频任务的资格时间 +// ModelAccessCheckedAt returns the qualification time when upstream accepted the video operation func (operation VideoOperation) ModelAccessCheckedAt() time.Time { return operation.accessCheckedAt } -// EncodeGenerateVideoRequest 编码当前网页 GenerateVideo 数组协议 +// EncodeGenerateVideoRequest encodes the current web GenerateVideo array protocol func EncodeGenerateVideoRequest(request VideoRequest) ([]byte, error) { if strings.TrimSpace(request.Model) == "" || strings.TrimSpace(request.Prompt) == "" { - return nil, fmt.Errorf("GenerateVideo 需要模型和提示词") + return nil, fmt.Errorf("GenerateVideo requires model and prompt") } request = normalizeVideoRequest(request) if request.Count != 1 { - return nil, fmt.Errorf("GenerateVideo 当前模型只支持一个结果") + return nil, fmt.Errorf("GenerateVideo currently only supports a single result for this model") } config := []any{ int64(request.Count), @@ -91,16 +91,16 @@ func encodeVideoImage(image *VideoImage) (any, any, error) { return nil, nil, nil } if (image.InlineData == nil) == (image.File == nil) { - return nil, nil, fmt.Errorf("Veo 起始帧必须且只能设置 inline data 或 Drive file") + return nil, nil, fmt.Errorf("Veo start frame must set exactly one of inline data or Drive file") } if image.InlineData != nil { if !strings.HasPrefix(strings.ToLower(image.InlineData.MIME), "image/") || len(image.InlineData.Data) == 0 { - return nil, nil, fmt.Errorf("Veo inline 起始帧需要图片 MIME 和数据") + return nil, nil, fmt.Errorf("Veo inline start frame requires image MIME and data") } return []any{image.InlineData.MIME, base64.StdEncoding.EncodeToString(image.InlineData.Data)}, nil, nil } if strings.TrimSpace(image.File.ID) == "" { - return nil, nil, fmt.Errorf("Veo Drive 起始帧缺少文件 ID") + return nil, nil, fmt.Errorf("Veo Drive start frame missing file ID") } return nil, []any{image.File.ID}, nil } @@ -121,19 +121,19 @@ func normalizeVideoRequest(request VideoRequest) VideoRequest { return request } -// EncodeGetGenerateVideoOperationRequest 编码当前网页轮询数组协议 +// EncodeGetGenerateVideoOperationRequest encodes the current web polling array protocol func EncodeGetGenerateVideoOperationRequest(operationID string) ([]byte, error) { if strings.TrimSpace(operationID) == "" { - return nil, fmt.Errorf("GetGenerateVideoOperation 需要 operation ID") + return nil, fmt.Errorf("GetGenerateVideoOperation requires operation ID") } return json.Marshal([]any{operationID}) } -// ParseVideoOperation 解码 GenerateVideo 与轮询返回的 operation +// ParseVideoOperation decodes operation returned by GenerateVideo and polling func ParseVideoOperation(source io.Reader, method string) (VideoOperation, error) { raw, err := io.ReadAll(newSparseJSONReader(source)) if err != nil { - return VideoOperation{}, fmt.Errorf("读取 %s: %w", method, err) + return VideoOperation{}, fmt.Errorf("read %s: %w", method, err) } root, err := rawArray(raw, "$", raw) if err != nil { @@ -148,7 +148,7 @@ func ParseVideoOperation(source io.Reader, method string) (VideoOperation, error } } if operation.ID == "" { - return VideoOperation{}, &ProtocolEvidenceError{Method: method, Path: "$[0]", Detail: "缺少 operation ID", Raw: raw} + return VideoOperation{}, &ProtocolEvidenceError{Method: method, Path: "$[0]", Detail: "missing operation ID", Raw: raw} } return operation, nil } @@ -193,7 +193,7 @@ func decodePolledVideoOperation(operation VideoOperation, root []json.RawMessage return operation, nil } -// GenerateVideo 创建 Veo 长任务 +// GenerateVideo creates a Veo long-running operation func (c *Client) GenerateVideo(ctx context.Context, request VideoRequest) (VideoOperation, error) { request = normalizeVideoRequest(request) entry, err := c.modelEntry(ctx, request.AccountID, request.Model) @@ -201,7 +201,7 @@ func (c *Client) GenerateVideo(ctx context.Context, request VideoRequest) (Video return VideoOperation{}, err } if !hasMethod(entry.model, "predictLongRunning") { - return VideoOperation{}, fmt.Errorf("%w: 模型 %q 的实时目录没有 predictLongRunning 方法", ErrInvalidArgument, entry.model.ID) + return VideoOperation{}, fmt.Errorf("%w: live catalog for model %q has no predictLongRunning method", ErrInvalidArgument, entry.model.ID) } if err := validateVideoOptions(request, entry.model); err != nil { return VideoOperation{}, fmt.Errorf("%w: %v", ErrInvalidArgument, err) @@ -223,7 +223,7 @@ func (c *Client) GenerateVideo(ctx context.Context, request VideoRequest) (Video return operation, nil } -// GetGenerateVideoOperation 读取 Veo 长任务当前状态 +// GetGenerateVideoOperation reads the current status of a Veo long-running operation func (c *Client) GetGenerateVideoOperation(ctx context.Context, accountID string, operationID string) (VideoOperation, error) { body, err := EncodeGetGenerateVideoOperationRequest(operationID) if err != nil { @@ -239,7 +239,7 @@ func (c *Client) GetGenerateVideoOperation(ctx context.Context, accountID string return operation, err } -// GenerateVideo 使用一个独占账户创建任务并保存 operation 绑定 +// GenerateVideo creates an operation using an exclusive account and saves operation binding func (s *PooledService) GenerateVideo(ctx context.Context, request VideoRequest) (VideoOperation, error) { request = normalizeVideoRequest(request) modelID := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") @@ -325,7 +325,7 @@ func (s *PooledService) GenerateVideo(ctx context.Context, request VideoRequest) return operation, generateErr } -// GetGenerateVideoOperation 使用 operation 创建账户轮询并绑定结果文件 +// GetGenerateVideoOperation polls using the operation creator account and binds result file func (s *PooledService) GetGenerateVideoOperation(ctx context.Context, operationID string) (VideoOperation, error) { lease, owned, err := resolveAccountLease(ctx, s.pool, AccountSelection{ResourceID: strings.TrimSpace(operationID)}) if err != nil { @@ -407,7 +407,7 @@ func validateVideoOptions(request VideoRequest, model Model) error { } } if !found { - return fmt.Errorf("模型 %q 不支持 %s=%s", model.ID, check.name, check.value) + return fmt.Errorf("model %q does not support %s=%s", model.ID, check.name, check.value) } } return nil diff --git a/internal/aistudio/waa.go b/internal/aistudio/waa.go index a3f6a57..822656a 100644 --- a/internal/aistudio/waa.go +++ b/internal/aistudio/waa.go @@ -2,7 +2,7 @@ package aistudio import "net/http" -// ProtectedRequest 表示需要 WAA 保护的 AI Studio 请求 +// ProtectedRequest represents an AI Studio request requiring WAA protection type ProtectedRequest struct { URL string Headers http.Header @@ -11,33 +11,33 @@ type ProtectedRequest struct { ProofField int } -// PreparedProtectedRequest 表示已写入 fresh proof 的请求 +// PreparedProtectedRequest represents a request with fresh proof written type PreparedProtectedRequest struct { Body []byte Headers http.Header } -// WorkerPhase 表示账户 runtime 当前阶段 +// WorkerPhase represents the current phase of account runtime type WorkerPhase string const ( - // WorkerStarting 表示 runtime 进程正在启动 + // WorkerStarting indicates runtime process is starting WorkerStarting WorkerPhase = "starting" - // WorkerBootstrapping 表示官方页面正在初始化 WAA + // WorkerBootstrapping indicates official page is bootstrapping WAA WorkerBootstrapping WorkerPhase = "bootstrapping" - // WorkerReady 表示 runtime 可以接受请求 + // WorkerReady indicates runtime can accept requests WorkerReady WorkerPhase = "ready" - // WorkerBusy 表示 runtime 正在处理受保护请求 + // WorkerBusy indicates runtime is handling a protected request WorkerBusy WorkerPhase = "busy" - // WorkerClosing 表示 runtime 正在关闭 + // WorkerClosing indicates runtime is closing WorkerClosing WorkerPhase = "closing" - // WorkerClosed 表示 runtime 已关闭 + // WorkerClosed indicates runtime is closed WorkerClosed WorkerPhase = "closed" - // WorkerFailed 表示 runtime 已失效 + // WorkerFailed indicates runtime has failed WorkerFailed WorkerPhase = "failed" ) -// WorkerState 表示账户 runtime 的可观察状态 +// WorkerState represents observable state of account runtime type WorkerState struct { AccountID string Phase WorkerPhase diff --git a/internal/aistudio/webchannel.go b/internal/aistudio/webchannel.go index 8adaf3c..ff4a1a5 100644 --- a/internal/aistudio/webchannel.go +++ b/internal/aistudio/webchannel.go @@ -39,17 +39,17 @@ func (err *bidiBackchannelNetworkError) Unwrap() error { return err.err } -// BidiService 创建 Gemini Live 或 Robotics Streaming 会话 +// BidiService creates Gemini Live or Robotics Streaming sessions type BidiService interface { OpenBidi(context.Context, BidiRequest) (*BidiSession, error) } -// BidiProtectedTransport 建立持有当前账户租约的 WebChannel 会话 +// BidiProtectedTransport establishes WebChannel sessions holding the current account lease type BidiProtectedTransport interface { OpenBidiProtected(context.Context, BidiRequest, RequestContext, *AccountLease, func() error) (*BidiSession, error) } -// BidiSession 保存一条 Google WebChannel 双向会话 +// BidiSession stores a Google WebChannel bidirectional session type BidiSession struct { ctx context.Context cancel context.CancelFunc @@ -91,11 +91,11 @@ type BidiSession struct { var _ BidiService = (*PooledService)(nil) var _ BidiProtectedTransport = (*WorkerProtectedTransport)(nil) -// OpenBidi 使用支持目标模型的账户创建双向会话 +// OpenBidi creates a bidirectional session using an account that supports the target model func (s *PooledService) OpenBidi(ctx context.Context, request BidiRequest) (*BidiSession, error) { modelID := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") if modelID == "" { - return nil, fmt.Errorf("%w: bidi model 不能为空", ErrInvalidArgument) + return nil, fmt.Errorf("%w: bidi model cannot be empty", ErrInvalidArgument) } modelAccessScope := strings.TrimSpace(request.ModelAccessScope) if modelAccessScope == "" { @@ -104,7 +104,7 @@ func (s *PooledService) OpenBidi(ctx context.Context, request BidiRequest) (*Bid request.ModelAccessScope = modelAccessScope transport, ok := s.client.protected.(BidiProtectedTransport) if !ok { - return nil, fmt.Errorf("AI Studio protected transport 不支持 bidiGenerateContent") + return nil, fmt.Errorf("AI Studio protected transport does not support bidiGenerateContent") } selection := AccountSelection{ ModelID: modelID, Method: "bidiGenerateContent", AccountID: strings.TrimSpace(request.AccountID), @@ -141,7 +141,7 @@ func (s *PooledService) OpenBidi(ctx context.Context, request BidiRequest) (*Bid if owned { err = errors.Join(err, lease.Release()) } - return nil, fmt.Errorf("读取 AI Studio bidi 请求上下文: %w", err) + return nil, fmt.Errorf("read AI Studio bidi request context: %w", err) } } var release func() error @@ -152,7 +152,7 @@ func (s *PooledService) OpenBidi(ctx context.Context, request BidiRequest) (*Bid session, err := transport.OpenBidiProtected(attemptCtx, request, runtime, lease, release) if err == nil { if stateErr := lease.MarkAuthenticationValid(); stateErr != nil { - slog.Error("Bidi 账户认证状态保存失败", "account", request.AccountID, "error", stateErr) + slog.Error("failed to save bidi account authentication state", "account", request.AccountID, "error", stateErr) } if modelAccessScope == modelID { checkedAt := lease.CheckedAt() @@ -163,7 +163,7 @@ func (s *PooledService) OpenBidi(ctx context.Context, request BidiRequest) (*Bid accountID, modelAccessScope, accessGeneration, checkedAt, ) if stateErr != nil { - slog.Error("Bidi 模型资格保存失败", "account", accountID, "model", modelID, "error", stateErr) + slog.Error("failed to save bidi model qualification", "account", accountID, "model", modelID, "error", stateErr) return } if changed { @@ -174,7 +174,7 @@ func (s *PooledService) OpenBidi(ctx context.Context, request BidiRequest) (*Bid if stateErr := s.pool.ClearCooldownIfGeneration( request.AccountID, "", lease.ModelAccessGeneration(), lease.CheckedAt(), ); stateErr != nil { - slog.Error("Bidi 账户冷却状态保存失败", "account", request.AccountID, "error", stateErr) + slog.Error("failed to save bidi account cooldown state", "account", request.AccountID, "error", stateErr) } } return session, nil @@ -241,7 +241,7 @@ func retryableBidiOpenError(ctx context.Context, err error) bool { return !errors.As(err, &evidenceError) } -// Events 返回上游按网络顺序产生的事件 +// Events returns events produced by upstream in network order func (s *BidiSession) Events() <-chan BidiEvent { if s == nil { return nil @@ -249,7 +249,7 @@ func (s *BidiSession) Events() <-chan BidiEvent { return s.events } -// Done 在 WebChannel 释放账户后关闭 +// Done is closed after WebChannel releases the account func (s *BidiSession) Done() <-chan struct{} { if s == nil { closed := make(chan struct{}) @@ -259,7 +259,7 @@ func (s *BidiSession) Done() <-chan struct{} { return s.done } -// Model 返回会话使用的模型 +// Model returns the model used by the session func (s *BidiSession) Model() string { if s == nil { return "" @@ -273,7 +273,7 @@ func (s *BidiSession) notifyModelAccessChanged() { } } -// SendText 发送一条官网文本输入帧 +// SendText sends an official text input frame func (s *BidiSession) SendText(ctx context.Context, text string) error { body, binding, err := EncodeBidiTextRequest(text) if err != nil { @@ -285,7 +285,7 @@ func (s *BidiSession) SendText(ctx context.Context, text string) error { ) } -// SendMedia 发送一条官网实时音频或图像输入帧 +// SendMedia sends an official real-time audio or image input frame func (s *BidiSession) SendMedia(ctx context.Context, mimeType string, data []byte) error { body, binding, err := EncodeBidiMediaRequest(mimeType, data) if err != nil { @@ -294,7 +294,7 @@ func (s *BidiSession) SendMedia(ctx context.Context, mimeType string, data []byt return s.sendProtected(ctx, body, binding, s.modelAccessScope != "") } -// SendMediaEnd 发送官网实时媒体结束帧 +// SendMediaEnd sends an official real-time media end frame func (s *BidiSession) SendMediaEnd(ctx context.Context) error { body, binding, err := EncodeBidiMediaEndRequest() if err != nil { @@ -303,7 +303,7 @@ func (s *BidiSession) SendMediaEnd(ctx context.Context) error { return s.sendProtected(ctx, body, binding, false) } -// SendToolResponses 发送官网函数响应帧 +// SendToolResponses sends official function response frames func (s *BidiSession) SendToolResponses(ctx context.Context, results []FunctionResult) error { body, binding, err := EncodeBidiToolResponseRequest(results) if err != nil { @@ -312,7 +312,7 @@ func (s *BidiSession) SendToolResponses(ctx context.Context, results []FunctionR return s.sendProtected(ctx, body, binding, false) } -// Close 取消网络读取并等待账户租约释放 +// Close cancels network reading and waits for account lease release func (s *BidiSession) Close() error { if s == nil { return nil @@ -328,14 +328,14 @@ func (s *BidiSession) Close() error { select { case <-s.done: case <-timer.C: - return errors.Join(s.closeErr, fmt.Errorf("等待 bidi WebChannel 关闭超时")) + return errors.Join(s.closeErr, fmt.Errorf("timed out waiting for bidi WebChannel to close")) } s.releaseMu.Lock() defer s.releaseMu.Unlock() return errors.Join(s.closeErr, s.releaseErr) } -// OpenBidiProtected 使用当前租约建立 WebChannel 会话 +// OpenBidiProtected establishes a WebChannel session using the current lease func (t *WorkerProtectedTransport) OpenBidiProtected( ctx context.Context, request BidiRequest, @@ -349,7 +349,7 @@ func (t *WorkerProtectedTransport) OpenBidiProtected( } worker, err := t.workers.Worker(ctx, lease.Account().ID, request.Model) if err != nil { - return nil, fmt.Errorf("获取账户 WAA preparer: %w", err) + return nil, fmt.Errorf("get account WAA preparer: %w", err) } if request.ObserveWAARuntime != nil { if observed, ok := worker.(interface{ WorkerGeneration() uint64 }); ok { @@ -361,10 +361,10 @@ func (t *WorkerProtectedTransport) OpenBidiProtected( Body: body, Prompt: binding, ProofField: 6, }) if err != nil { - return nil, fmt.Errorf("准备 bidi setup fresh WAA proof: %w", err) + return nil, fmt.Errorf("prepare bidi setup fresh WAA proof: %w", err) } if prepared.Headers == nil || len(prepared.Body) == 0 { - return nil, fmt.Errorf("WAA preparer 返回空 bidi setup") + return nil, fmt.Errorf("WAA preparer returned empty bidi setup") } headerProvider := ProtocolHeaderProviderFunc(func(context.Context, string) (http.Header, error) { return prepared.Headers.Clone(), nil @@ -449,7 +449,7 @@ func (s *BidiSession) awaitSetup(ctx context.Context) error { if event.Err != nil { return event.Err } - return errors.New("bidi setup 返回错误事件") + return errors.New("bidi setup returned error event") case BidiEventClosed: return errBidiWebChannelClosed } @@ -505,18 +505,18 @@ func (s *BidiSession) handshake(ctx context.Context, protocolHeaders http.Header requestURL := bidiWebChannelURL + "?" + query.Encode() request, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, strings.NewReader("count=0")) if err != nil { - return fmt.Errorf("创建 bidi WebChannel handshake: %w", err) + return fmt.Errorf("create bidi WebChannel handshake: %w", err) } request.Header = s.cloneHeaders() request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response, err := s.client.Do(request) if err != nil { - return fmt.Errorf("执行 bidi WebChannel handshake: %w", err) + return fmt.Errorf("execute bidi WebChannel handshake: %w", err) } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { - return fmt.Errorf("读取 bidi WebChannel handshake: %w", err) + return fmt.Errorf("read bidi WebChannel handshake: %w", err) } if response.StatusCode != http.StatusOK { return DecodeRPCError("BidiGenerateContent", response.StatusCode, body) @@ -526,7 +526,7 @@ func (s *BidiSession) handshake(ctx context.Context, protocolHeaders http.Header } s.gsessionID = strings.TrimSpace(response.Header.Get("X-HTTP-Session-Id")) if s.gsessionID == "" { - return fmt.Errorf("bidi WebChannel handshake 缺少 gsessionid") + return fmt.Errorf("bidi WebChannel handshake missing gsessionid") } sid, err := parseWebChannelHandshake(bytes.NewReader(body)) if err != nil { @@ -553,10 +553,10 @@ func (s *BidiSession) sendProtected(ctx context.Context, body []byte, binding st Body: body, Prompt: binding, ProofField: 6, }) if err != nil { - return fmt.Errorf("准备 bidi fresh WAA proof: %w", err) + return fmt.Errorf("prepare bidi fresh WAA proof: %w", err) } if len(prepared.Body) == 0 { - return fmt.Errorf("WAA preparer 返回空 bidi 请求") + return fmt.Errorf("WAA preparer returned empty bidi request") } return s.postMessage(requestCtx, prepared.Body, qualifies) } @@ -604,18 +604,18 @@ func (s *BidiSession) postMessage(ctx context.Context, payload []byte, qualifies requestURL := bidiWebChannelURL + "?" + query.Encode() request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, requestURL, strings.NewReader(form.Encode())) if err != nil { - return fmt.Errorf("创建 bidi WebChannel message: %w", err) + return fmt.Errorf("create bidi WebChannel message: %w", err) } request.Header = s.cloneHeaders() request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response, err := s.client.Do(request) if err != nil { - return fmt.Errorf("执行 bidi WebChannel message: %w", err) + return fmt.Errorf("execute bidi WebChannel message: %w", err) } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { - return fmt.Errorf("读取 bidi WebChannel ACK: %w", err) + return fmt.Errorf("read bidi WebChannel ACK: %w", err) } if response.StatusCode != http.StatusOK { return DecodeRPCError("BidiGenerateContent", response.StatusCode, body) @@ -723,7 +723,7 @@ func (s *BidiSession) readBackchannel(first bool, ready func(error)) (int, error requestURL := bidiWebChannelURL + "?" + query.Encode() request, err := http.NewRequestWithContext(s.ctx, http.MethodGet, requestURL, nil) if err != nil { - return 0, fmt.Errorf("创建 bidi WebChannel backchannel: %w", err) + return 0, fmt.Errorf("create bidi WebChannel backchannel: %w", err) } request.Header = s.cloneHeaders() response, err := s.client.Do(request) @@ -731,7 +731,7 @@ func (s *BidiSession) readBackchannel(first bool, ready func(error)) (int, error if first { ready(err) } - return 0, &bidiBackchannelNetworkError{err: fmt.Errorf("执行 bidi WebChannel backchannel: %w", err)} + return 0, &bidiBackchannelNetworkError{err: fmt.Errorf("execute bidi WebChannel backchannel: %w", err)} } defer response.Body.Close() if response.StatusCode != http.StatusOK { @@ -783,7 +783,7 @@ func (s *BidiSession) consumeBackchannelFrame(raw json.RawMessage) (int, error) if len(envelope) < 2 { return parsed, &ProtocolEvidenceError{ Method: "BidiGenerateContent", Path: fmt.Sprintf("$webchannel[%d]", index), - Detail: "WebChannel envelope 字段不足", Raw: cloneRaw(envelopeRaw), + Detail: "insufficient WebChannel envelope fields", Raw: cloneRaw(envelopeRaw), } } aid, err := rawInt64(envelope[0], fmt.Sprintf("$webchannel[%d][0]", index), raw) @@ -810,7 +810,7 @@ func (s *BidiSession) consumeBackchannelFrame(raw json.RawMessage) (int, error) s.stateMu.Unlock() if token != previous { if err := s.lease.ReplaceResource(previous, token, "bidi-session"); err != nil { - return parsed, fmt.Errorf("绑定 bidi 恢复令牌: %w", err) + return parsed, fmt.Errorf("bind bidi resumption token: %w", err) } s.stateMu.Lock() s.latestResumptionToken = token @@ -848,7 +848,7 @@ func (s *BidiSession) recordScopedModelAccess(event *BidiEvent) { return } if err := s.lease.markAuthenticationValidAt(checkedAt); err != nil { - slog.Error("Bidi 账户认证状态保存失败", "account", s.accountID, "error", err) + slog.Error("failed to save bidi account authentication state", "account", s.accountID, "error", err) } accountID := s.accountID accessScope := s.modelAccessScope @@ -858,7 +858,7 @@ func (s *BidiSession) recordScopedModelAccess(event *BidiEvent) { accountID, accessScope, generation, checkedAt, ) if err != nil { - slog.Error("Bidi 媒体资格保存失败", "account", accountID, "model", s.model, "error", err) + slog.Error("failed to save bidi media qualification", "account", accountID, "model", s.model, "error", err) return } if changed { @@ -867,7 +867,7 @@ func (s *BidiSession) recordScopedModelAccess(event *BidiEvent) { }() } -// beginQualificationAttempt 登记当前双向轮次中的资格请求 +// beginQualificationAttempt registers qualification requests in the current bidi turn func (s *BidiSession) beginQualificationAttempt() { s.stateMu.Lock() defer s.stateMu.Unlock() @@ -877,7 +877,7 @@ func (s *BidiSession) beginQualificationAttempt() { s.qualificationPending++ } -// rollbackQualificationAttempt 撤销发送失败的资格请求 +// rollbackQualificationAttempt rolls back failed qualification requests func (s *BidiSession) rollbackQualificationAttempt() { s.stateMu.Lock() defer s.stateMu.Unlock() @@ -890,7 +890,7 @@ func (s *BidiSession) rollbackQualificationAttempt() { } } -// finishQualificationAttempt 结束当前资格轮次并返回其顺序时间 +// finishQualificationAttempt concludes current qualification round and returns its sequential time func (s *BidiSession) finishQualificationAttempt(allowUnbound bool) time.Time { s.stateMu.Lock() defer s.stateMu.Unlock() @@ -912,7 +912,7 @@ func (s *BidiSession) finishQualificationAttempt(allowUnbound bool) time.Time { return time.Time{} } -// nextQualificationCheckedAtLocked 返回会话内严格递增的资格顺序时间 +// nextQualificationCheckedAtLocked returns strictly increasing qualification sequential time within session func (s *BidiSession) nextQualificationCheckedAtLocked() time.Time { last := s.qualificationLastAt if leaseCheckedAt := s.lease.CheckedAt(); last.Before(leaseCheckedAt) { @@ -946,13 +946,13 @@ func (s *BidiSession) terminate() error { defer cancel() request, err := http.NewRequestWithContext(ctx, http.MethodPost, bidiWebChannelURL+"?"+query.Encode(), nil) if err != nil { - return fmt.Errorf("创建 bidi WebChannel terminate: %w", err) + return fmt.Errorf("create bidi WebChannel terminate: %w", err) } request.Header = s.cloneHeaders() request.Header.Set("Content-Type", "text/plain;charset=UTF-8") response, err := s.client.Do(request) if err != nil { - return fmt.Errorf("执行 bidi WebChannel terminate: %w", err) + return fmt.Errorf("execute bidi WebChannel terminate: %w", err) } defer response.Body.Close() _, readErr := io.Copy(io.Discard, response.Body) @@ -974,15 +974,15 @@ func (s *BidiSession) mergeCookies(response *http.Response, requestURL string) e return nil } if err := s.lease.MergeSetCookieHeaders(setCookies, requestURL, time.Now()); err != nil { - return fmt.Errorf("合并 bidi WebChannel Cookie: %w", err) + return fmt.Errorf("merge bidi WebChannel cookies: %w", err) } state, err := s.lease.ReloadStorageState() if err != nil { - return fmt.Errorf("读取 bidi WebChannel Cookie: %w", err) + return fmt.Errorf("read bidi WebChannel cookies: %w", err) } cookie, err := state.CookieHeader(bidiWebChannelURL, time.Now()) if err != nil { - return fmt.Errorf("构造 bidi WebChannel Cookie: %w", err) + return fmt.Errorf("construct bidi WebChannel cookies: %w", err) } s.headerMu.Lock() s.headers.Set("Cookie", cookie) @@ -1032,7 +1032,7 @@ func parseWebChannelHandshake(source io.Reader) (string, error) { return "", withBidiMethod(err) } if len(root) != 1 { - return "", &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$handshake", Detail: "握手 envelope 数量无效", Raw: frame} + return "", &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$handshake", Detail: "invalid handshake envelope count", Raw: frame} } envelope, err := rawArray(root[0], "$handshake[0]", frame) if err != nil { @@ -1047,14 +1047,14 @@ func parseWebChannelHandshake(source io.Reader) (string, error) { return "", withBidiMethod(err) } if marker != "c" { - return "", &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$handshake[0][1][0]", Detail: "握手类型不是 c", Raw: frame} + return "", &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$handshake[0][1][0]", Detail: "handshake type is not c", Raw: frame} } sid, err := rawString(rawAt(control, 1), "$handshake[0][1][1]", frame) if err != nil || sid == "" { if err != nil { return "", withBidiMethod(err) } - return "", &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$handshake[0][1][1]", Detail: "握手 SID 为空", Raw: frame} + return "", &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$handshake[0][1][1]", Detail: "handshake SID is empty", Raw: frame} } return sid, nil } @@ -1074,7 +1074,7 @@ func parseWebChannelACK(source io.Reader) error { return withBidiMethod(err) } if len(ack) != 3 { - return &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$ack", Detail: "WebChannel ACK 字段数量无效", Raw: frame} + return &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$ack", Detail: "invalid WebChannel ACK field count", Raw: frame} } if _, err := rawInt64(ack[0], "$ack[0]", frame); err != nil { return withBidiMethod(err) @@ -1097,14 +1097,14 @@ func readWebChannelFrames(source io.Reader, emit func(json.RawMessage) error) er } length, err := strconv.Atoi(strings.TrimSpace(lengthLine)) if err != nil || length < 0 { - return fmt.Errorf("bidi WebChannel frame 长度无效: %q", strings.TrimSpace(lengthLine)) + return fmt.Errorf("invalid bidi WebChannel frame length: %q", strings.TrimSpace(lengthLine)) } frame := make([]byte, length) if _, err := io.ReadFull(reader, frame); err != nil { - return fmt.Errorf("读取 bidi WebChannel frame: %w", err) + return fmt.Errorf("read bidi WebChannel frame: %w", err) } if !json.Valid(frame) { - return &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$webchannel", Detail: "frame 不是有效 JSON", Raw: cloneRaw(frame)} + return &ProtocolEvidenceError{Method: "BidiGenerateContent", Path: "$webchannel", Detail: "frame is not valid JSON", Raw: cloneRaw(frame)} } if err := emit(frame); err != nil { return err @@ -1115,7 +1115,7 @@ func readWebChannelFrames(source io.Reader, emit func(json.RawMessage) error) er func newWebChannelRID() (int64, error) { var raw [4]byte if _, err := rand.Read(raw[:]); err != nil { - return 0, fmt.Errorf("生成 WebChannel RID: %w", err) + return 0, fmt.Errorf("generate WebChannel RID: %w", err) } return int64(binary.BigEndian.Uint32(raw[:])%90000) + 10000, nil } @@ -1123,7 +1123,7 @@ func newWebChannelRID() (int64, error) { func newWebChannelZX() (string, error) { var raw [8]byte if _, err := rand.Read(raw[:]); err != nil { - return "", fmt.Errorf("生成 WebChannel zx: %w", err) + return "", fmt.Errorf("generate WebChannel zx: %w", err) } return hex.EncodeToString(raw[:]), nil } diff --git a/internal/aistudio/youtube.go b/internal/aistudio/youtube.go index c59c4de..8e64fa6 100644 --- a/internal/aistudio/youtube.go +++ b/internal/aistudio/youtube.go @@ -8,7 +8,7 @@ import ( var youtubeURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`) -// ExternalMediaForURL 返回 AI Studio 可直接读取的外部媒体 +// ExternalMediaForURL returns external media directly readable by AI Studio func ExternalMediaForURL(raw string) (*ExternalMedia, bool) { raw = strings.TrimRight(strings.TrimSpace(raw), ".,;:!?)]}") parsed, err := url.Parse(raw) diff --git a/internal/api/admin.go b/internal/api/admin.go index b362b14..824a633 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -9,7 +9,7 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/aistudio" ) -// AdminService 定义管理端需要的权威状态能力 +// AdminService defines authoritative state operations required by the admin interface. type AdminService interface { Status(context.Context) (AdminStatus, error) Accounts(context.Context) ([]AdminAccount, error) @@ -33,7 +33,7 @@ type AdminService interface { RecordAccessLog(AccessLog) } -// AdminStatus 表示管理端运行状态 +// AdminStatus represents the operational status of the admin interface. type AdminStatus struct { State string `json:"state"` Running bool `json:"running"` @@ -43,7 +43,7 @@ type AdminStatus struct { Accounts AdminAccountCounts `json:"accounts"` } -// AdminLog 表示管理页面展示的一条运行日志 +// AdminLog represents a runtime log entry displayed on the admin page. type AdminLog struct { Time time.Time `json:"time"` Level string `json:"level"` @@ -53,7 +53,7 @@ type AdminLog struct { Request *RequestLog `json:"request,omitempty"` } -// RequestLog 保存可关联的请求状态、用量与诊断字段 +// RequestLog holds correlatable request status, usage, and diagnostic fields. type RequestLog struct { ID string `json:"id"` State string `json:"state"` @@ -76,7 +76,7 @@ type RequestLog struct { UpstreamBytes int64 `json:"upstream_bytes,omitempty"` } -// RequestLogUsage 区分输入、思考、回复与端到端输出速率 +// RequestLogUsage distinguishes input, thinking, reply, and end-to-end output token rates. type RequestLogUsage struct { InputTokens int64 `json:"input_tokens"` ReasoningTokens int64 `json:"reasoning_tokens"` @@ -86,7 +86,7 @@ type RequestLogUsage struct { AverageTokensPerSecond float64 `json:"average_tokens_per_second"` } -// AccessLog 表示一次公开 API 请求的最终访问记录 +// AccessLog represents the final access record for a public API request. type AccessLog struct { Status int Latency time.Duration @@ -114,7 +114,7 @@ type AccessLog struct { Generation bool } -// AdminAccountCounts 表示账户状态计数 +// AdminAccountCounts represents account status counts. type AdminAccountCounts struct { Total int `json:"total"` Ready int `json:"ready"` @@ -123,7 +123,7 @@ type AdminAccountCounts struct { AuthRequired int `json:"auth_required"` } -// AdminAccount 表示管理端账户摘要 +// AdminAccount represents an admin account summary. type AdminAccount struct { ID string `json:"id"` Label string `json:"label"` @@ -137,7 +137,7 @@ type AdminAccount struct { Message string `json:"message"` } -// AccountInput 表示已有账户配置 +// AccountInput represents an existing account configuration. type AccountInput struct { Label string `json:"label"` Enabled bool `json:"enabled"` @@ -146,14 +146,14 @@ type AccountInput struct { Timezone string `json:"timezone"` } -// AccountCreateInput 表示浏览器登录的账户环境 +// AccountCreateInput represents the account environment for browser login. type AccountCreateInput struct { Proxy string `json:"proxy"` Locale string `json:"locale"` Timezone string `json:"timezone"` } -// ChromeImportProfile 表示可从本机 Chrome 导入的账号 +// ChromeImportProfile represents an account that can be imported from local Chrome. type ChromeImportProfile struct { Profile string `json:"profile"` DisplayName string `json:"display_name"` @@ -161,7 +161,7 @@ type ChromeImportProfile struct { Locale string `json:"locale"` } -// ChromeImportInput 表示批量导入的 Chrome Profile 与账户环境 +// ChromeImportInput represents Chrome profiles and account environment for batch import. type ChromeImportInput struct { Profiles []string `json:"profiles"` Proxy string `json:"proxy"` @@ -169,7 +169,7 @@ type ChromeImportInput struct { Timezone string `json:"timezone"` } -// RuntimeConfig 表示全局运行配置 +// RuntimeConfig represents global runtime configuration. type RuntimeConfig struct { AuthStates string `json:"auth_states"` ListenAddr string `json:"listen_addr"` @@ -189,7 +189,7 @@ type RuntimeConfig struct { TemporaryChat bool `json:"temporary_chat"` } -// AdminCooldown 表示账户模型冷却 +// AdminCooldown represents account model cooldown. type AdminCooldown struct { AccountID string `json:"account_id"` AccountLabel string `json:"account_label"` @@ -198,7 +198,7 @@ type AdminCooldown struct { Reason string `json:"reason,omitempty"` } -// AdminRequest 表示活动请求摘要 +// AdminRequest represents an active request summary. type AdminRequest struct { ID string `json:"id"` Model string `json:"model"` @@ -208,7 +208,7 @@ type AdminRequest struct { StartedAt time.Time `json:"started_at"` } -// AdminEvent 表示管理端增量事件 +// AdminEvent represents an incremental admin event. type AdminEvent struct { Type string `json:"type"` Data any `json:"data"` diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index 7e7d6ff..cca61b9 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -249,7 +249,7 @@ func anthropicRole(role string) (aistudio.Role, error) { } } -// anthropicParts 转换消息块并保留工具调用的思考签名 +// anthropicParts converts message blocks and preserves thought signatures for tool calls. func anthropicParts(raw json.RawMessage) ([]aistudio.Part, error) { var text string if err := json.Unmarshal(raw, &text); err == nil { diff --git a/internal/api/errors.go b/internal/api/errors.go index fc192c9..d44a166 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -42,7 +42,7 @@ func writeJSON(w http.ResponseWriter, status int, payload any) { _ = json.NewEncoder(w).Encode(payload) } -// streamHeaders 写入并刷新流式响应头 +// streamHeaders writes and flushes streaming response headers. func streamHeaders(w http.ResponseWriter) error { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -51,7 +51,7 @@ func streamHeaders(w http.ResponseWriter) error { return http.NewResponseController(w).Flush() } -// writeSSE 写入具名事件并传播缓冲刷新错误 +// writeSSE writes a named event and propagates buffer flush errors. func writeSSE(w http.ResponseWriter, event string, payload any) error { data, err := json.Marshal(payload) if err != nil { @@ -68,7 +68,7 @@ func writeSSE(w http.ResponseWriter, event string, payload any) error { return http.NewResponseController(w).Flush() } -// writeSSEText 写入文本事件并刷新网络缓冲 +// writeSSEText writes a text event and flushes the network buffer. func writeSSEText(w http.ResponseWriter, data string) error { if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { return err @@ -76,7 +76,7 @@ func writeSSEText(w http.ResponseWriter, data string) error { return http.NewResponseController(w).Flush() } -// writeSSEHeartbeat 发送心跳并传播连接错误 +// writeSSEHeartbeat sends a heartbeat and propagates connection errors. func writeSSEHeartbeat(w http.ResponseWriter) error { if _, err := io.WriteString(w, ": ping\n\n"); err != nil { return err @@ -118,7 +118,7 @@ func statusFromError(err error) int { return http.StatusBadGateway } -// shouldWriteRequestError 判断仍在线的客户端是否需要收到结构化错误 +// shouldWriteRequestError determines whether a structured error should be written to a client that is still online. func shouldWriteRequestError(r *http.Request, err error) bool { return err != nil && (!errors.Is(err, context.Canceled) || r.Context().Err() == nil) } diff --git a/internal/api/files.go b/internal/api/files.go index 4059732..9fd0b6b 100644 --- a/internal/api/files.go +++ b/internal/api/files.go @@ -245,7 +245,7 @@ func (s *server) handleOpenAIFileGet(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, openAIFileResponse(metadata)) } -// handleOpenAIFileContent 返回上传文件内容 +// handleOpenAIFileContent returns the uploaded file content. func (s *server) handleOpenAIFileContent(w http.ResponseWriter, r *http.Request) { service, ok := s.service.(aistudio.FileService) if !ok { @@ -302,7 +302,7 @@ func (s *server) handleOpenAIFileContent(w http.ResponseWriter, r *http.Request) } } -// handleOpenAIFileDelete 删除上传文件 +// handleOpenAIFileDelete deletes an uploaded file. func (s *server) handleOpenAIFileDelete(w http.ResponseWriter, r *http.Request) { service, ok := s.service.(aistudio.FileService) if !ok { diff --git a/internal/api/live.go b/internal/api/live.go index 36455e0..92eec3c 100644 --- a/internal/api/live.go +++ b/internal/api/live.go @@ -60,7 +60,7 @@ type bidiServerMessage struct { Retryable bool `json:"retryable,omitempty"` } -// Hijack 让 WebSocket upgrade 穿过访问日志响应包装器 +// Hijack allows WebSocket upgrade to pass through the access log response wrapper. func (writer *accessLogResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { connection, readWriter, err := http.NewResponseController(writer.ResponseWriter).Hijack() if err == nil && writer.status == 0 { @@ -154,11 +154,11 @@ func bidiRequestFromSetup( setup bidiClientSetup, ) (aistudio.BidiRequest, map[string]bool, error) { if setup.Type != "setup" { - return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: 首帧 type 必须是 setup", aistudio.ErrInvalidArgument) + return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: initial frame type must be setup", aistudio.ErrInvalidArgument) } model := strings.TrimSpace(setup.Model) if model == "" { - return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: bidi model 不能为空", aistudio.ErrInvalidArgument) + return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: bidi model cannot be empty", aistudio.ErrInvalidArgument) } input, err := bidiModalitySet(setup.InputModalities) if err != nil { @@ -175,25 +175,25 @@ func bidiRequestFromSetup( case aistudio.BidiModeLive: for modality := range input { if modality != "text" && modality != "audio" && modality != "image" { - return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: live input modality %q 不可用", aistudio.ErrInvalidArgument, modality) + return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: live input modality %q is unavailable", aistudio.ErrInvalidArgument, modality) } } if len(output) != 1 || !output["audio"] { - return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: live output_modalities 必须是 [audio]", aistudio.ErrInvalidArgument) + return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: live output_modalities must be [audio]", aistudio.ErrInvalidArgument) } if input["audio"] || input["image"] { request.ModelAccessScope = aistudio.ModelAccessKey("bidi-media", model) } case aistudio.BidiModeRobotics: if len(input) != 1 || !input["text"] { - return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: robotics input_modalities 必须是 [text]", aistudio.ErrInvalidArgument) + return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: robotics input_modalities must be [text]", aistudio.ErrInvalidArgument) } if len(output) != 1 || !output["text"] { - return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: robotics output_modalities 必须是 [text]", aistudio.ErrInvalidArgument) + return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: robotics output_modalities must be [text]", aistudio.ErrInvalidArgument) } request.ModelAccessScope = aistudio.ModelAccessKey("bidi-media", model) default: - return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: bidi mode %q 无效", aistudio.ErrInvalidArgument, mode) + return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: invalid bidi mode %q", aistudio.ErrInvalidArgument, mode) } if _, _, err := aistudio.EncodeBidiSetupRequest(request, aistudio.RequestContext{}); err != nil { return aistudio.BidiRequest{}, nil, fmt.Errorf("%w: %v", aistudio.ErrInvalidArgument, err) @@ -203,13 +203,13 @@ func bidiRequestFromSetup( func bidiModalitySet(values []string) (map[string]bool, error) { if len(values) == 0 { - return nil, fmt.Errorf("%w: modality 列表不能为空", aistudio.ErrInvalidArgument) + return nil, fmt.Errorf("%w: modality list cannot be empty", aistudio.ErrInvalidArgument) } result := make(map[string]bool, len(values)) for _, value := range values { value = strings.ToLower(strings.TrimSpace(value)) if value == "" || result[value] { - return nil, fmt.Errorf("%w: modality %q 无效", aistudio.ErrInvalidArgument, value) + return nil, fmt.Errorf("%w: invalid modality %q", aistudio.ErrInvalidArgument, value) } result[value] = true } @@ -269,13 +269,13 @@ func sendBidiClientMessages( switch message.Type { case "text": if !inputModalities["text"] { - err = fmt.Errorf("%w: text 未在 setup input_modalities 中声明", aistudio.ErrInvalidArgument) + err = fmt.Errorf("%w: text is not declared in setup input_modalities", aistudio.ErrInvalidArgument) } else { err = session.SendText(ctx, message.Text) } case "audio": if !inputModalities["audio"] { - err = fmt.Errorf("%w: audio 未在 setup input_modalities 中声明", aistudio.ErrInvalidArgument) + err = fmt.Errorf("%w: audio is not declared in setup input_modalities", aistudio.ErrInvalidArgument) } else if message.MIMEType == "" { message.MIMEType = "audio/pcm" } @@ -284,7 +284,7 @@ func sendBidiClientMessages( } case "image": if !inputModalities["image"] { - err = fmt.Errorf("%w: image 未在 setup input_modalities 中声明", aistudio.ErrInvalidArgument) + err = fmt.Errorf("%w: image is not declared in setup input_modalities", aistudio.ErrInvalidArgument) } else if message.MIMEType == "" { message.MIMEType = "image/jpeg" } @@ -293,13 +293,13 @@ func sendBidiClientMessages( } case "media_end": if !inputModalities["audio"] && !inputModalities["image"] { - err = fmt.Errorf("%w: media_end 未在 setup input_modalities 中声明", aistudio.ErrInvalidArgument) + err = fmt.Errorf("%w: media_end is not declared in setup input_modalities", aistudio.ErrInvalidArgument) } else { err = session.SendMediaEnd(ctx) } case "tool_response": if !toolsEnabled { - err = fmt.Errorf("%w: tool_response 的 setup 未声明 tools", aistudio.ErrInvalidArgument) + err = fmt.Errorf("%w: setup for tool_response did not declare tools", aistudio.ErrInvalidArgument) } else { err = session.SendToolResponses(ctx, message.ToolResponses) } diff --git a/internal/api/media.go b/internal/api/media.go index a7c157a..804a392 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -272,7 +272,7 @@ func pcmWAV(pcm []byte, sampleRate int, channels int) []byte { return buffer.Bytes() } -// decodeBase64Flexible 根据字母表和填充形式解码 Base64 与 Data URL +// decodeBase64Flexible decodes Base64 and Data URLs based on alphabet and padding variant. func decodeBase64Flexible(s string) ([]byte, error) { s = strings.TrimSpace(s) if idx := strings.Index(s, ","); idx != -1 && strings.HasPrefix(s, "data:") { @@ -288,7 +288,7 @@ func decodeBase64Flexible(s string) ([]byte, error) { return encoding.DecodeString(s) } -// normalizeImagePayload 将 GIF 首帧按逻辑画布转换为 PNG 图片 +// normalizeImagePayload converts the first frame of a GIF to a PNG image based on its logical canvas. func normalizeImagePayload(mimeType string, data []byte) (string, []byte) { lowerMIME := strings.ToLower(strings.TrimSpace(mimeType)) if lowerMIME == "image/gif" || (len(data) >= 3 && string(data[:3]) == "GIF") { @@ -303,7 +303,7 @@ func normalizeImagePayload(mimeType string, data []byte) (string, []byte) { _, _, _, alpha := entry.RGBA() transparent = transparent || alpha == 0 } - // GIF 背景色来自全局色表,透明首帧保留透明画布 + // GIF background color comes from the global palette; transparent first frames keep a transparent canvas. if palette, ok := config.ColorModel.(color.Palette); ok && !transparent && int(data[11]) < len(palette) { draw.Draw(canvas, canvas.Bounds(), image.NewUniform(palette[data[11]]), image.Point{}, draw.Src) } diff --git a/internal/api/media_test.go b/internal/api/media_test.go index 9ce850d..15cb67f 100644 --- a/internal/api/media_test.go +++ b/internal/api/media_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -// TestGeminiInlineDataCompatibility 验证不同 SDK 的内联媒体字段与 Base64 形式 +// TestGeminiInlineDataCompatibility verifies inline media fields and Base64 formats across different SDKs. func TestGeminiInlineDataCompatibility(t *testing.T) { want := []byte{0xfb, 0xff, 0xef} for _, test := range []struct { diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 29471bd..f5b276a 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -92,7 +92,7 @@ func (writer *accessLogResponseWriter) Write(data []byte) (int, error) { return writer.ResponseWriter.Write(data) } -// FlushError 记录状态并向响应控制器返回底层刷新错误 +// FlushError records the status and returns the underlying flush error to the response controller. func (writer *accessLogResponseWriter) FlushError() error { if writer.status == 0 { writer.WriteHeader(http.StatusOK) @@ -257,35 +257,35 @@ func (metadata *accessLogMetadata) snapshot() accessLogSnapshot { return snapshot } -// SetAccessLogFirstEvent 写入首个上游语义事件耗时 +// SetAccessLogFirstEvent records the latency to the first upstream semantic event. func SetAccessLogFirstEvent(ctx context.Context, firstEvent time.Duration) { if metadata, ok := ctx.Value(accessLogContextKey{}).(*accessLogMetadata); ok { metadata.setFirstEvent(firstEvent) } } -// SetAccessLogGenerationConfig 写入生成请求采用的参数 +// SetAccessLogGenerationConfig records the parameters used by the generation request. func SetAccessLogGenerationConfig(ctx context.Context, config aistudio.GenerationConfig) { if metadata, ok := ctx.Value(accessLogContextKey{}).(*accessLogMetadata); ok { metadata.setGenerationConfig(config) } } -// SetAccessLogGenerationInput 写入生成请求输入摘要 +// SetAccessLogGenerationInput records the input summary of the generation request. func SetAccessLogGenerationInput(ctx context.Context, request aistudio.GenerateRequest) { if metadata, ok := ctx.Value(accessLogContextKey{}).(*accessLogMetadata); ok { metadata.setGenerationInput(request) } } -// SetAccessLogUpstreamBytes 写入上游响应体字节数 +// SetAccessLogUpstreamBytes records the byte count of the upstream response body. func SetAccessLogUpstreamBytes(ctx context.Context, bytes int64) { if metadata, ok := ctx.Value(accessLogContextKey{}).(*accessLogMetadata); ok { metadata.setUpstreamBytes(bytes) } } -// StartAccessLog 立即写入已经完成解析的请求开始记录 +// StartAccessLog immediately records the start of a parsed request. func StartAccessLog(ctx context.Context) { if metadata, ok := ctx.Value(accessLogContextKey{}).(*accessLogMetadata); ok { metadata.start(true) @@ -294,14 +294,14 @@ func StartAccessLog(ctx context.Context) { func formatLogFloat(value *float64) string { if value == nil { - return "默认" + return "default" } return strconv.FormatFloat(*value, 'f', -1, 64) } func formatLogInt(value *int64) string { if value == nil { - return "默认" + return "default" } return strconv.FormatInt(*value, 10) } @@ -311,19 +311,19 @@ func formatLogThinking(config aistudio.GenerationConfig) string { return effort } if config.ThinkingBudget != nil { - return "预算" + strconv.FormatInt(*config.ThinkingBudget, 10) + return "budget " + strconv.FormatInt(*config.ThinkingBudget, 10) } - return "默认" + return "default" } -// SetAccessLogTarget 写入请求实际使用的模型与账户 +// SetAccessLogTarget records the actual model and account used for the request. func SetAccessLogTarget(ctx context.Context, model string, account string) { if metadata, ok := ctx.Value(accessLogContextKey{}).(*accessLogMetadata); ok { metadata.setTarget(model, account) } } -// SetAccessLogError 写入请求最终错误 +// SetAccessLogError records the final error for the request. func SetAccessLogError(ctx context.Context, err error) { if err == nil { return @@ -333,14 +333,14 @@ func SetAccessLogError(ctx context.Context, err error) { } } -// SetAccessLogFinishReason 写入生成请求的上游终止原因 +// SetAccessLogFinishReason records the upstream finish reason for the generation request. func SetAccessLogFinishReason(ctx context.Context, reason string) { if metadata, ok := ctx.Value(accessLogContextKey{}).(*accessLogMetadata); ok { metadata.setFinishReason(reason) } } -// SetAccessLogGenerationResult 写入生成流的完成摘要 +// SetAccessLogGenerationResult records the completion summary of the generation stream. func SetAccessLogGenerationResult( ctx context.Context, usage *aistudio.Usage, diff --git a/internal/api/openai.go b/internal/api/openai.go index 957e18a..436ef7d 100644 --- a/internal/api/openai.go +++ b/internal/api/openai.go @@ -294,7 +294,7 @@ func openAITextContent(raw json.RawMessage) (string, error) { return text.String(), nil } -// openAIContentParts 转换文本与媒体并省略空文本占位 +// openAIContentParts converts text and media, omitting empty text placeholders. func openAIContentParts(raw json.RawMessage) ([]aistudio.Part, error) { if len(raw) == 0 || string(raw) == "null" { return nil, nil @@ -593,7 +593,7 @@ func decodeStopSequences(raw json.RawMessage) ([]string, error) { return normalizeStopSequences(multiple), nil } -// normalizeStopSequences 删除不会形成停止条件的空字符串 +// normalizeStopSequences removes empty strings that do not form stop conditions. func normalizeStopSequences(values []string) []string { var normalized []string for _, value := range values { diff --git a/internal/api/responses.go b/internal/api/responses.go index 783841d..0d5bbd4 100644 --- a/internal/api/responses.go +++ b/internal/api/responses.go @@ -386,7 +386,7 @@ func mapResponsesTools(tools []responsesTool, choice json.RawMessage) (aistudio. }) case "web_search", "web_search_2025_08_26", "web_search_preview", "web_search_preview_2025_03_11": if tool.SearchContextSize != "" || rawJSONConfigured(tool.UserLocation) || rawJSONConfigured(tool.Filters) { - return aistudio.Tools{}, fmt.Errorf("AI Studio Web 不支持 web_search 的 search_context_size、user_location 或 filters") + return aistudio.Tools{}, fmt.Errorf("web_search search_context_size, user_location, or filters are not supported by AI Studio Web") } mapped.Google = appendUnique(mapped.Google, "google_search") case "code_interpreter": diff --git a/internal/api/responses_google_tools.go b/internal/api/responses_google_tools.go index 9d74107..f056ec5 100644 --- a/internal/api/responses_google_tools.go +++ b/internal/api/responses_google_tools.go @@ -26,17 +26,17 @@ func validateResponsesCodeContainer(raw json.RawMessage) error { if name == "auto" { return nil } - return fmt.Errorf("AI Studio Web 的 code_interpreter container 只支持 auto") + return fmt.Errorf("code_interpreter container only supports auto in AI Studio Web") } var container struct { Type string `json:"type"` FileIDs []string `json:"file_ids"` } if err := json.Unmarshal(raw, &container); err != nil || container.Type != "auto" { - return fmt.Errorf("AI Studio Web 的 code_interpreter container 只支持 auto") + return fmt.Errorf("code_interpreter container only supports auto in AI Studio Web") } if len(container.FileIDs) > 0 { - return fmt.Errorf("AI Studio Web 的 code_interpreter 不支持 container file_ids") + return fmt.Errorf("code_interpreter does not support container file_ids in AI Studio Web") } return nil } diff --git a/internal/api/router.go b/internal/api/router.go index 99865aa..a7bcdc5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -9,7 +9,7 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/aistudio" ) -// Config 定义公开 API 服务配置 +// Config defines public API server configuration. type Config struct { APIKey string Admin AdminService @@ -23,7 +23,7 @@ type server struct { var idSequence atomic.Uint64 -// NewHandler 创建公开 API 路由 +// NewHandler creates public API HTTP routing. func NewHandler(service aistudio.Service, config Config) http.Handler { s := &server{service: service, config: config, responseStates: newResponseStateStore()} public := http.NewServeMux() diff --git a/internal/app/admin.go b/internal/app/admin.go index c0773df..598d43f 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -20,7 +20,7 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/config" ) -// runtimeAdmin 投影运行时权威状态 +// runtimeAdmin projects the authoritative runtime state. type runtimeAdmin struct { lifecycle context.Context pool *aistudio.AccountPool @@ -34,7 +34,7 @@ type runtimeAdmin struct { config config.Config } -// requestRegistry 保存活动请求与事件订阅 +// requestRegistry stores active requests and event subscriptions. type requestRegistry struct { mu sync.Mutex active map[string]trackedRequest @@ -85,7 +85,7 @@ func (err *adminOperationError) ErrorCode() string { return err.code } -// newRuntimeAdmin 创建管理端服务 +// newRuntimeAdmin creates an admin service instance. func newRuntimeAdmin( lifecycle context.Context, pool *aistudio.AccountPool, @@ -98,13 +98,20 @@ func newRuntimeAdmin( cfg config.Config, ) *runtimeAdmin { return &runtimeAdmin{ - lifecycle: lifecycle, pool: pool, store: store, service: service, requests: registry, login: login, - workers: workers, headers: headers, - configPath: ".env", config: cfg, + lifecycle: lifecycle, + pool: pool, + store: store, + service: service, + requests: registry, + login: login, + workers: workers, + headers: headers, + configPath: ".env", + config: cfg, } } -// newRequestRegistry 创建活动请求注册表 +// newRequestRegistry creates an active request registry. func newRequestRegistry(ctx context.Context) *requestRegistry { registry := &requestRegistry{ active: make(map[string]trackedRequest), @@ -112,12 +119,15 @@ func newRequestRegistry(ctx context.Context) *requestRegistry { subscribers: make(map[*eventSubscriber]struct{}), console: make(chan api.AdminLog, 256), } + go registry.writeConsole(ctx) + return registry } func (admin *runtimeAdmin) Status(context.Context) (api.AdminStatus, error) { counts := api.AdminAccountCounts{} + for _, account := range admin.pool.Status() { counts.Total++ switch account.State { @@ -131,8 +141,10 @@ func (admin *runtimeAdmin) Status(context.Context) (api.AdminStatus, error) { counts.AuthRequired++ } } + state := admin.service.State() running := state == "RUNNING" + return api.AdminStatus{ State: state, Running: running, @@ -146,15 +158,18 @@ func (admin *runtimeAdmin) Status(context.Context) (api.AdminStatus, error) { func (admin *runtimeAdmin) Accounts(context.Context) ([]api.AdminAccount, error) { statuses := admin.pool.Status() accounts := make([]api.AdminAccount, 0, len(statuses)) + for _, status := range statuses { accounts = append(accounts, adminAccountDTO(status)) } + return accounts, nil } func (admin *runtimeAdmin) CreateAccount(ctx context.Context, input api.AccountCreateInput) (api.AdminAccount, error) { accountConfig := aistudio.DefaultAccountConfig("") accountConfig.Proxy = strings.TrimSpace(input.Proxy) + if locale := strings.TrimSpace(input.Locale); locale != "" { accountConfig.Locale = locale } @@ -164,40 +179,52 @@ func (admin *runtimeAdmin) CreateAccount(ctx context.Context, input api.AccountC if err := config.ValidateProxy(accountConfig.Proxy); err != nil { return api.AdminAccount{}, invalidAccount(err) } + directory, err := os.MkdirTemp("", "aistudio2api-account-login-*") if err != nil { - return api.AdminAccount{}, fmt.Errorf("创建隔离登录目录: %w", err) + return api.AdminAccount{}, fmt.Errorf("create isolated login directory: %w", err) } defer os.RemoveAll(directory) + startedAt := time.Now() - admin.requests.log("auth", "INFO", "账户添加 | 1/2 | 等待隔离登录") + admin.requests.log("auth", "INFO", "Account creation | 1/2 | Awaiting isolated login") + result, err := admin.login.Login(ctx, aistudio.IsolatedLoginRequest{ - AccountID: "new", Directory: directory, Proxy: admin.effectiveProxy(accountConfig.Proxy), - Locale: accountConfig.Locale, Timezone: accountConfig.Timezone, + AccountID: "new", + Directory: directory, + Proxy: admin.effectiveProxy(accountConfig.Proxy), + Locale: accountConfig.Locale, + Timezone: accountConfig.Timezone, }) if err != nil { admin.requests.log("auth", "ERROR", fmt.Sprintf( - "账户添加失败 | 耗时=%s | 错误=%s", + "Account creation failed | duration=%s | error=%s", time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(err.Error()), )) return api.AdminAccount{}, err } - admin.requests.log("auth", "INFO", "账户添加 | 2/2 | 保存认证状态") + + admin.requests.log("auth", "INFO", "Account creation | 2/2 | Saving storage state") + if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { - return api.AdminAccount{}, fmt.Errorf("认证状态无法用于 AI Studio: %w", err) + return api.AdminAccount{}, fmt.Errorf("storage state cannot be used with AI Studio: %w", err) } + accountConfig.Label = result.Email if err := accountConfig.Validate(); err != nil { return api.AdminAccount{}, invalidAccount(err) } + created, err := admin.addAccount(ctx, accountConfig, result.StorageState, directory) if err != nil { return api.AdminAccount{}, err } + admin.requests.log("auth", "INFO", fmt.Sprintf( - "账户添加完成 | 账户=%s | 耗时=%s", + "Account creation completed | account=%s | duration=%s", created.Label, time.Since(startedAt).Round(time.Millisecond), )) + return created, nil } @@ -206,14 +233,17 @@ func (admin *runtimeAdmin) ChromeImportProfiles(context.Context) ([]api.ChromeIm if err != nil { return nil, err } + accounts, err := chromeauth.Discover(root) if err != nil { return nil, err } + existing := make(map[string]struct{}) for _, status := range admin.pool.Status() { existing[strings.ToLower(strings.TrimSpace(status.ID))] = struct{}{} } + profiles := make([]api.ChromeImportProfile, 0, len(accounts)) for _, account := range accounts { email := strings.ToLower(strings.TrimSpace(account.Email)) @@ -223,63 +253,83 @@ func (admin *runtimeAdmin) ChromeImportProfiles(context.Context) ([]api.ChromeIm if _, exists := existing[email]; exists { continue } + profiles = append(profiles, api.ChromeImportProfile{ - Profile: account.Profile, DisplayName: account.DisplayName, Email: email, Locale: account.Locale, + Profile: account.Profile, + DisplayName: account.DisplayName, + Email: email, + Locale: account.Locale, }) } + return profiles, nil } func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.ChromeImportInput) ([]api.AdminAccount, error) { if len(input.Profiles) == 0 { - return nil, invalidAccount(fmt.Errorf("未选择 Chrome 账号")) + return nil, invalidAccount(fmt.Errorf("no Chrome profile selected")) } + root, err := chromeauth.DefaultChromeRoot() if err != nil { return nil, err } + accountProxy := strings.TrimSpace(input.Proxy) results, err := chromeauth.Import(ctx, chromeauth.ImportOptions{ - ChromeRoot: root, Proxy: admin.effectiveProxy(accountProxy), Profiles: input.Profiles, + ChromeRoot: root, + Proxy: admin.effectiveProxy(accountProxy), + Profiles: input.Profiles, }) if err != nil { return nil, err } + configs := make([]aistudio.AccountConfig, len(results)) seen := make(map[string]struct{}, len(results)) + for index, result := range results { email := strings.ToLower(strings.TrimSpace(result.Email)) if _, exists := seen[email]; exists { - return nil, invalidAccount(fmt.Errorf("Chrome 账号重复: %s", email)) + return nil, invalidAccount(fmt.Errorf("duplicate Chrome account: %s", email)) } seen[email] = struct{}{} + accountConfig := aistudio.DefaultAccountConfig(email) accountConfig.Proxy = accountProxy + if locale := strings.TrimSpace(input.Locale); locale != "" { accountConfig.Locale = locale } else if locale := strings.TrimSpace(result.Locale); locale != "" { accountConfig.Locale = locale } + if timezone := strings.TrimSpace(input.Timezone); timezone != "" { accountConfig.Timezone = timezone } + if err := accountConfig.Validate(); err != nil { return nil, invalidAccount(err) } + if _, err := aistudio.NewSigner().Sign(result.State); err != nil { - return nil, fmt.Errorf("认证状态无法用于 AI Studio: %w", err) + return nil, fmt.Errorf("storage state cannot be used with AI Studio: %w", err) } + configs[index] = accountConfig } + accounts := make([]api.AdminAccount, 0, len(results)) for index, result := range results { account, err := admin.addAccount(ctx, configs[index], result.State, "") if err != nil { return nil, err } + accounts = append(accounts, account) - admin.requests.log("auth", "INFO", "Chrome 账户已导入 | 账户="+account.Label) + admin.requests.log("auth", "INFO", "Chrome account imported | account="+account.Label) } + return accounts, nil } @@ -293,22 +343,27 @@ func (admin *runtimeAdmin) addAccount( if err != nil { return api.AdminAccount{}, err } + defer func() { if publishLease != nil { resultErr = errors.Join(resultErr, publishLease.Release()) } }() + if fingerprintDirectory != "" { if err := camoufoxnative.PersistAccountFingerprint(fingerprintDirectory, account.Directory); err != nil { return api.AdminAccount{}, errors.Join(err, admin.store.Delete(account)) } } + if err := admin.headers.Add(account); err != nil { return api.AdminAccount{}, errors.Join(err, admin.store.Delete(account)) } + if err := admin.workers.Add(account); err != nil { return api.AdminAccount{}, errors.Join(err, admin.headers.Remove(account.ID), admin.store.Delete(account)) } + if err := admin.service.changeModels(func() error { return admin.pool.Add(account) }); err != nil { @@ -316,40 +371,50 @@ func (admin *runtimeAdmin) addAccount( err, admin.workers.Remove(account.ID), admin.headers.Remove(account.ID), admin.store.Delete(account), ) } + if err := publishLease.Release(); err != nil { return api.AdminAccount{}, err } publishLease = nil + admin.syncAccountModelCatalog(ctx, account) + return admin.account(account.ID) } func (admin *runtimeAdmin) UpdateAccount(ctx context.Context, accountID string, input api.AccountInput) (api.AdminAccount, error) { if !strings.EqualFold(strings.TrimSpace(input.Label), strings.TrimSpace(accountID)) { - return api.AdminAccount{}, invalidAccount(fmt.Errorf("账户邮箱不可修改")) + return api.AdminAccount{}, invalidAccount(fmt.Errorf("account email cannot be modified")) } + accountConfig := aistudio.DefaultAccountConfig(strings.ToLower(strings.TrimSpace(accountID))) accountConfig.Enabled = input.Enabled accountConfig.Proxy = strings.TrimSpace(input.Proxy) accountConfig.Locale = strings.TrimSpace(input.Locale) accountConfig.Timezone = strings.TrimSpace(input.Timezone) + if err := accountConfig.Validate(); err != nil { return api.AdminAccount{}, invalidAccount(err) } + lease, err := admin.pool.AcquireAccount(ctx, accountID) if err != nil { return api.AdminAccount{}, accountOperationError(err) } + account := lease.Account() + headerUpdate, err := admin.headers.prepareUpdate(account, accountConfig) if err != nil { return api.AdminAccount{}, errors.Join(err, lease.Release()) } + workerUpdate, err := admin.workers.prepareUpdate(account, accountConfig) if err != nil { headerUpdate.Discard() return api.AdminAccount{}, errors.Join(err, lease.Release()) } + if err := admin.service.changeModels(func() error { return lease.SaveConfig(accountConfig) }); err != nil { @@ -357,13 +422,17 @@ func (admin *runtimeAdmin) UpdateAccount(ctx context.Context, accountID string, headerUpdate.Discard() return api.AdminAccount{}, errors.Join(err, lease.Release()) } + workerUpdate.Commit() headerUpdate.Commit() + if err := lease.Release(); err != nil { return api.AdminAccount{}, err } + admin.syncModelCache() - admin.requests.log("auth", "INFO", "账户配置已更新 | 账户="+accountConfig.Label) + admin.requests.log("auth", "INFO", "Account configuration updated | account="+accountConfig.Label) + return admin.account(account.ID) } @@ -373,6 +442,7 @@ func (admin *runtimeAdmin) DeleteAccount(_ context.Context, accountID string) er return err } defer admin.syncModelCache() + err = admin.service.changeModels(func() error { _, removeErr := admin.pool.Remove(accountID, func(account *aistudio.Account) error { if workerErr := admin.workers.Reset(account.ID); workerErr != nil { @@ -385,11 +455,15 @@ func (admin *runtimeAdmin) DeleteAccount(_ context.Context, accountID string) er if err != nil { return accountOperationError(err) } + admin.service.removeAccountModelRetry(accountID) + if err := errors.Join(admin.workers.Remove(accountID), admin.headers.Remove(accountID)); err != nil { return err } - admin.requests.log("auth", "INFO", "账户已删除 | 账户="+account.Label) + + admin.requests.log("auth", "INFO", "Account deleted | account="+account.Label) + return nil } @@ -398,43 +472,54 @@ func (admin *runtimeAdmin) LoginAccount(ctx context.Context, accountID string) ( if err != nil { return api.AdminAccount{}, accountOperationError(err) } + account := lease.Account() if err := admin.workers.Reset(account.ID); err != nil { return api.AdminAccount{}, errors.Join(err, lease.Release()) } + directory, err := os.MkdirTemp("", "aistudio2api-account-login-*") if err != nil { - return api.AdminAccount{}, errors.Join(fmt.Errorf("创建隔离登录目录: %w", err), lease.Release()) + return api.AdminAccount{}, errors.Join(fmt.Errorf("create isolated login directory: %w", err), lease.Release()) } defer os.RemoveAll(directory) + if err := camoufoxnative.PersistAccountFingerprint(account.Directory, directory); err != nil { return api.AdminAccount{}, errors.Join(err, lease.Release()) } + startedAt := time.Now() - admin.requests.log(account.Config.Label, "INFO", "账户登录 | 1/2 | 等待隔离登录") + admin.requests.log(account.Config.Label, "INFO", "Account login | 1/2 | Awaiting isolated login") + result, err := admin.login.Login(ctx, admin.loginRequest(account, directory)) if err != nil { admin.requests.log(account.Config.Label, "ERROR", fmt.Sprintf( - "账户登录失败 | 耗时=%s | 错误=%s", + "Account login failed | duration=%s | error=%s", time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(err.Error()), )) return api.AdminAccount{}, errors.Join(err, lease.Release()) } + if !strings.EqualFold(strings.TrimSpace(result.Email), account.ID) { return api.AdminAccount{}, errors.Join( - invalidAccount(fmt.Errorf("登录邮箱与账户不一致: %s", result.Email)), lease.Release(), + invalidAccount(fmt.Errorf("logged in email does not match account: %s", result.Email)), lease.Release(), ) } - admin.requests.log(account.Config.Label, "INFO", "账户登录 | 2/2 | 保存认证状态") + + admin.requests.log(account.Config.Label, "INFO", "Account login | 2/2 | Saving storage state") + if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { - return api.AdminAccount{}, errors.Join(fmt.Errorf("认证状态无法用于 AI Studio: %w", err), lease.Release()) + return api.AdminAccount{}, errors.Join(fmt.Errorf("storage state cannot be used with AI Studio: %w", err), lease.Release()) } + if err := camoufoxnative.PersistAccountFingerprint(directory, account.Directory); err != nil { return api.AdminAccount{}, errors.Join(err, lease.Release()) } + if err := lease.SaveStorageState(result.StorageState); err != nil { return api.AdminAccount{}, errors.Join(err, lease.Release()) } + if err := admin.service.changeModels(func() error { return errors.Join( admin.pool.MarkReady(account.ID), @@ -444,14 +529,17 @@ func (admin *runtimeAdmin) LoginAccount(ctx context.Context, accountID string) ( }); err != nil { return api.AdminAccount{}, errors.Join(err, lease.Release()) } + if err := lease.Release(); err != nil { return api.AdminAccount{}, err } + admin.syncAccountModelCatalog(ctx, account) admin.requests.log(account.Config.Label, "INFO", fmt.Sprintf( - "账户登录完成 | 耗时=%s", + "Account login completed | duration=%s", time.Since(startedAt).Round(time.Millisecond), )) + return admin.account(account.ID) } @@ -460,17 +548,20 @@ func (admin *runtimeAdmin) VerifyAccount(ctx context.Context, accountID string) if err != nil { return api.AdminAccount{}, accountOperationError(err) } + account := lease.Account() startedAt := time.Now() - admin.requests.log(account.Config.Label, "INFO", "账户验证 | 访问 AI Studio") + admin.requests.log(account.Config.Label, "INFO", "Account verification | Visiting AI Studio") + verification, err := admin.login.Verify(ctx, admin.loginRequest(account, account.Directory), account.StorageState) if err != nil { admin.requests.log(account.Config.Label, "ERROR", fmt.Sprintf( - "账户验证失败 | 耗时=%s | 错误=%s", + "Account verification failed | duration=%s | error=%s", time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(err.Error()), )) return api.AdminAccount{}, errors.Join(err, lease.Release()) } + err = admin.service.changeModels(func() error { if verification.Authenticated { return errors.Join( @@ -479,33 +570,40 @@ func (admin *runtimeAdmin) VerifyAccount(ctx context.Context, accountID string) admin.pool.SetCatalog(account.ID, account.BenefitTier, nil), ) } + reason := strings.TrimSpace(verification.Reason) if reason == "" { - reason = "AI Studio 登录已失效" + reason = "AI Studio login expired" } + return admin.pool.MarkAuthRequired(account.ID, reason) }) if err != nil { return api.AdminAccount{}, errors.Join(err, lease.Release()) } + if err := lease.Release(); err != nil { return api.AdminAccount{}, err } + if verification.Authenticated { admin.syncAccountModelCatalog(ctx, account) } else { admin.service.publishModelAccess() } + admin.requests.log(account.Config.Label, "INFO", fmt.Sprintf( - "账户验证完成 | 已认证=%t | 耗时=%s", + "Account verification completed | authenticated=%t | duration=%s", verification.Authenticated, time.Since(startedAt).Round(time.Millisecond), )) + return admin.account(account.ID) } -// StartService 使用管理器提供的启动生命周期启动生成服务 +// StartService starts the generation service using the manager's lifecycle. func (admin *runtimeAdmin) StartService(ctx context.Context) (api.AdminStatus, error) { startedAt := time.Now() + models, started, err := admin.service.Start(ctx, func() { status, statusErr := admin.Status(ctx) if statusErr == nil { @@ -515,83 +613,97 @@ func (admin *runtimeAdmin) StartService(ctx context.Context) (api.AdminStatus, e if errors.Is(err, errServiceTransitioning) { return admin.Status(ctx) } + if err != nil { status, statusErr := admin.Status(ctx) if statusErr == nil { admin.publishRuntimeSnapshot(ctx, status) } + if errors.Is(err, context.Canceled) && admin.service.State() == "STOPPED" { admin.requests.log("service", "INFO", fmt.Sprintf( - "生成服务启动已取消 | 耗时=%s", + "Generation service startup canceled | duration=%s", time.Since(startedAt).Round(time.Millisecond), )) return status, statusErr } + admin.requests.log("service", "ERROR", fmt.Sprintf( - "生成服务启动失败 | 耗时=%s | 错误=%s", + "Generation service startup failed | duration=%s | error=%s", time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(err.Error()), )) + if errors.Is(err, aistudio.ErrNoEligibleAccount) { return api.AdminStatus{}, &adminOperationError{ - status: http.StatusBadRequest, code: "account_required", message: "请先启用一个可用账户", + status: http.StatusBadRequest, code: "account_required", message: "Please enable an available account first", } } + return api.AdminStatus{}, err } + if len(models) == 0 { admin.requests.log("service", "ERROR", fmt.Sprintf( - "生成服务启动失败 | 耗时=%s | 错误=没有可用账户", + "Generation service startup failed | duration=%s | error=no available accounts", time.Since(startedAt).Round(time.Millisecond), )) return api.AdminStatus{}, &adminOperationError{ - status: http.StatusBadRequest, code: "account_required", message: "请先添加一个可用账户", + status: http.StatusBadRequest, code: "account_required", message: "Please add an available account first", } } + if started { admin.requests.log("service", "INFO", fmt.Sprintf( - "生成服务就绪 | 模型=%d | Worker=%d/%d | 耗时=%s", + "Generation service ready | models=%d | workers=%d/%d | duration=%s", len(models), len(admin.workers.WarmAccountIDs()), admin.workers.PrewarmTarget(), time.Since(startedAt).Round(time.Millisecond), )) } else { admin.requests.log("service", "INFO", fmt.Sprintf( - "生成服务运行中 | 模型=%d | Worker=%d/%d", + "Generation service running | models=%d | workers=%d/%d", len(models), len(admin.workers.WarmAccountIDs()), admin.workers.PrewarmTarget(), )) } + status, err := admin.Status(ctx) if err == nil { admin.publishRuntimeSnapshot(ctx, status) } + return status, err } func (admin *runtimeAdmin) StopService(ctx context.Context) (api.AdminStatus, error) { startedAt := time.Now() + admin.requests.log("service", "INFO", fmt.Sprintf( - "生成服务停止 | Worker=%d", + "Stopping generation service | workers=%d", len(admin.workers.WarmAccountIDs()), )) + stopped, err := admin.service.Stop() if err != nil { admin.requests.log("service", "ERROR", fmt.Sprintf( - "生成服务停止失败 | 耗时=%s | 错误=%s", + "Generation service stop failed | duration=%s | error=%s", time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(err.Error()), )) return api.AdminStatus{}, err } + if stopped { admin.requests.log("service", "INFO", fmt.Sprintf( - "生成服务已停止 | 耗时=%s", + "Generation service stopped | duration=%s", time.Since(startedAt).Round(time.Millisecond), )) } else { - admin.requests.log("service", "INFO", "生成服务已处于停止状态") + admin.requests.log("service", "INFO", "Generation service is already stopped") } + status, err := admin.Status(ctx) if err == nil { admin.publishRuntimeSnapshot(ctx, status) } + return status, err } @@ -600,10 +712,11 @@ func (admin *runtimeAdmin) ClearLogs(context.Context) error { return nil } -// syncModelCache 在账户写入后刷新权威快照 +// syncModelCache refreshes the authoritative snapshot after account updates. func (admin *runtimeAdmin) syncModelCache() { ctx := admin.lifecycle _ = admin.service.SyncModels(ctx) + status, err := admin.Status(ctx) if err == nil { admin.publishRuntimeSnapshot(ctx, status) @@ -616,22 +729,26 @@ func (admin *runtimeAdmin) syncAccountModelCatalog(ctx context.Context, account admin.service.publishModelAccess() return } + if len(models) > 0 { - admin.requests.log(account.Config.Label, "INFO", fmt.Sprintf("账户模型目录同步完成 | 模型=%d", len(models))) + admin.requests.log(account.Config.Label, "INFO", fmt.Sprintf("Account model catalog synchronized | models=%d", len(models))) } + admin.service.publishModelAccess() } -// publishRuntimeSnapshot 推送管理页权威运行状态 +// publishRuntimeSnapshot broadcasts authoritative runtime state to admin subscribers. func (admin *runtimeAdmin) publishRuntimeSnapshot(ctx context.Context, status api.AdminStatus) { models, err := admin.service.Models(ctx) if err != nil { return } + accounts, err := admin.Accounts(ctx) if err != nil { return } + admin.requests.publish(api.AdminEvent{Type: "status", Data: status}) admin.requests.publish(api.AdminEvent{Type: "models", Data: map[string]any{"models": models}}) admin.requests.publish(api.AdminEvent{Type: "accounts", Data: map[string]any{"accounts": accounts}}) @@ -643,6 +760,7 @@ func (admin *runtimeAdmin) account(accountID string) (api.AdminAccount, error) { return adminAccountDTO(status), nil } } + return api.AdminAccount{}, accountOperationError(fmt.Errorf("%w: %s", aistudio.ErrAccountNotFound, accountID)) } @@ -655,24 +773,37 @@ func (admin *runtimeAdmin) effectiveProxy(accountProxy string) string { func (admin *runtimeAdmin) loginRequest(account *aistudio.Account, directory string) aistudio.IsolatedLoginRequest { return aistudio.IsolatedLoginRequest{ - AccountID: account.ID, Directory: directory, Proxy: admin.effectiveProxy(account.Config.Proxy), - Locale: account.Config.Locale, Timezone: account.Config.Timezone, + AccountID: account.ID, + Directory: directory, + Proxy: admin.effectiveProxy(account.Config.Proxy), + Locale: account.Config.Locale, + Timezone: account.Config.Timezone, } } func adminAccountDTO(status aistudio.AccountStatus) api.AdminAccount { models := make([]string, len(status.Models)) copy(models, status.Models) + return api.AdminAccount{ - ID: status.ID, Label: status.Label, Enabled: status.Enabled, State: string(status.State), - Proxy: status.Proxy, Locale: status.Locale, Timezone: status.Timezone, - Models: models, BenefitTier: status.BenefitTier, Message: status.Message, + ID: status.ID, + Label: status.Label, + Enabled: status.Enabled, + State: string(status.State), + Proxy: status.Proxy, + Locale: status.Locale, + Timezone: status.Timezone, + Models: models, + BenefitTier: status.BenefitTier, + Message: status.Message, } } func invalidAccount(err error) error { return &adminOperationError{ - status: http.StatusBadRequest, code: "invalid_account", message: err.Error(), + status: http.StatusBadRequest, + code: "invalid_account", + message: err.Error(), } } @@ -680,11 +811,15 @@ func accountOperationError(err error) error { switch { case errors.Is(err, aistudio.ErrAccountNotFound): return &adminOperationError{ - status: http.StatusNotFound, code: "account_not_found", message: err.Error(), + status: http.StatusNotFound, + code: "account_not_found", + message: err.Error(), } case errors.Is(err, aistudio.ErrAccountLeased): return &adminOperationError{ - status: http.StatusConflict, code: "account_busy", message: err.Error(), + status: http.StatusConflict, + code: "account_busy", + message: err.Error(), } default: return err @@ -696,31 +831,42 @@ func (admin *runtimeAdmin) RuntimeConfig(context.Context) (api.RuntimeConfig, er if err != nil { return api.RuntimeConfig{}, err } + return runtimeConfigDTO(cfg), nil } func (admin *runtimeAdmin) UpdateRuntimeConfig(_ context.Context, value api.RuntimeConfig) (api.RuntimeConfig, error) { initTimeout, err := time.ParseDuration(value.InitTimeout) if err != nil { - return api.RuntimeConfig{}, fmt.Errorf("INIT_TIMEOUT 无效: %w", err) + return api.RuntimeConfig{}, fmt.Errorf("invalid INIT_TIMEOUT: %w", err) } + requestTimeout, err := time.ParseDuration(value.RequestTimeout) if err != nil { - return api.RuntimeConfig{}, fmt.Errorf("REQUEST_TIMEOUT 无效: %w", err) + return api.RuntimeConfig{}, fmt.Errorf("invalid REQUEST_TIMEOUT: %w", err) } + cfg := config.Config{ - AuthStates: value.AuthStates, ListenAddr: value.ListenAddr, ProxyAPIKey: value.APIKey, - Proxy: value.Proxy, InitTimeout: initTimeout, RequestTimeout: requestTimeout, - WarmWorkerLimit: value.WarmWorkerLimit, MaxActiveWorkers: value.MaxActiveWorkers, + AuthStates: value.AuthStates, + ListenAddr: value.ListenAddr, + ProxyAPIKey: value.APIKey, + Proxy: value.Proxy, + InitTimeout: initTimeout, + RequestTimeout: requestTimeout, + WarmWorkerLimit: value.WarmWorkerLimit, + MaxActiveWorkers: value.MaxActiveWorkers, WarmStartupConcurrency: value.WarmStartupConcurrency, PerAccountConcurrency: value.PerAccountConcurrency, RoutingStrategy: value.RoutingStrategy, TemporaryChat: value.TemporaryChat, } + if err := cfg.Save(admin.configPath); err != nil { return api.RuntimeConfig{}, err } - admin.requests.log("service", "INFO", "服务配置已保存") + + admin.requests.log("service", "INFO", "Service configuration saved") + return runtimeConfigDTO(cfg), nil } @@ -728,17 +874,20 @@ func (admin *runtimeAdmin) Cooldowns(context.Context) ([]api.AdminCooldown, erro statuses := admin.pool.Status() cooldowns := make([]api.AdminCooldown, 0) now := time.Now() + for _, account := range statuses { models := make(map[string]struct{}, len(account.Models)) for _, modelID := range account.Models { models[modelID] = struct{}{} } + effective := make(map[string]aistudio.CooldownState) if global, ok := account.Cooldowns["*"]; ok && global.Active(now) { for modelID := range models { effective[modelID] = global } } + for modelID, cooldown := range account.Cooldowns { if modelID == "*" || !cooldown.Active(now) { continue @@ -750,19 +899,25 @@ func (admin *runtimeAdmin) Cooldowns(context.Context) ([]api.AdminCooldown, erro effective[modelID] = cooldown } } + modelIDs := make([]string, 0, len(effective)) for modelID := range effective { modelIDs = append(modelIDs, modelID) } sort.Strings(modelIDs) + for _, modelID := range modelIDs { cooldown := effective[modelID] cooldowns = append(cooldowns, api.AdminCooldown{ - AccountID: account.ID, AccountLabel: account.Label, - ModelID: modelID, Until: cooldown.Until, Reason: cooldown.Reason, + AccountID: account.ID, + AccountLabel: account.Label, + ModelID: modelID, + Until: cooldown.Until, + Reason: cooldown.Reason, }) } } + return cooldowns, nil } @@ -774,7 +929,7 @@ func (admin *runtimeAdmin) CancelRequest(_ context.Context, id string) error { return admin.requests.cancel(id) } -// adminEventSource 提供管理事件流的当前快照 +// adminEventSource provides current snapshots for the admin event stream. type adminEventSource interface { Models(context.Context) ([]aistudio.Model, error) Status(context.Context) (api.AdminStatus, error) @@ -782,7 +937,7 @@ type adminEventSource interface { Cooldowns(context.Context) ([]api.AdminCooldown, error) } -// Models 返回当前运行时模型快照 +// Models returns the current runtime model snapshot. func (admin *runtimeAdmin) Models(ctx context.Context) ([]aistudio.Model, error) { return admin.service.Models(ctx) } @@ -791,7 +946,7 @@ func (admin *runtimeAdmin) Events(ctx context.Context) (<-chan api.AdminEvent, e return openAdminEvents(ctx, admin.lifecycle, admin.requests, admin) } -// openAdminEvents 创建绑定进程生命周期的管理事件流 +// openAdminEvents creates an admin event stream bound to the process lifecycle. func openAdminEvents( ctx context.Context, lifecycle context.Context, @@ -801,30 +956,35 @@ func openAdminEvents( eventCtx, cancel := context.WithCancel(ctx) stopLifecycle := context.AfterFunc(lifecycle, cancel) subscriber := requests.subscribe(eventCtx) + models, err := source.Models(eventCtx) if err != nil { stopLifecycle() cancel() return nil, err } + status, err := source.Status(eventCtx) if err != nil { stopLifecycle() cancel() return nil, err } + accounts, err := source.Accounts(eventCtx) if err != nil { stopLifecycle() cancel() return nil, err } + cooldowns, err := source.Cooldowns(eventCtx) if err != nil { stopLifecycle() cancel() return nil, err } + live := requests.activateSubscriber( subscriber, []api.AdminEvent{ @@ -834,20 +994,25 @@ func openAdminEvents( }, []api.AdminEvent{{Type: "cooldowns", Data: cooldowns}}, ) + events := make(chan api.AdminEvent, 16) + go func() { defer stopLifecycle() defer cancel() defer close(events) + var refreshTimer *time.Timer var refresh <-chan time.Time refreshAccounts := false refreshCooldowns := false + defer func() { if refreshTimer != nil { refreshTimer.Stop() } }() + send := func(event api.AdminEvent) bool { select { case events <- event: @@ -856,6 +1021,7 @@ func openAdminEvents( return false } } + scheduleRequestRefresh := func(event api.AdminEvent) { request := event.Data.(api.AdminRequest) if request.State != "queued" { @@ -874,6 +1040,7 @@ func openAdminEvents( } refresh = refreshTimer.C } + for { select { case event, ok := <-live: @@ -886,12 +1053,14 @@ func openAdminEvents( if event.Type == "request" { scheduleRequestRefresh(event) } + case <-refresh: refresh = nil accountsChanged := refreshAccounts cooldownsChanged := refreshCooldowns refreshAccounts = false refreshCooldowns = false + updates, err := adminRequestStateUpdates(eventCtx, source, accountsChanged, cooldownsChanged) if err != nil { return @@ -901,15 +1070,17 @@ func openAdminEvents( return } } + case <-eventCtx.Done(): return } } }() + return events, nil } -// adminRequestStateUpdates 合并请求状态引起的管理页快照变化 +// adminRequestStateUpdates aggregates snapshot updates triggered by request states. func adminRequestStateUpdates( ctx context.Context, source adminEventSource, @@ -920,7 +1091,9 @@ func adminRequestStateUpdates( if err != nil { return nil, err } + updates := []api.AdminEvent{{Type: "status", Data: status}} + if accountsChanged { accounts, accountsErr := source.Accounts(ctx) if accountsErr != nil { @@ -928,6 +1101,7 @@ func adminRequestStateUpdates( } updates = append(updates, api.AdminEvent{Type: "accounts", Data: map[string]any{"accounts": accounts}}) } + if cooldownsChanged { cooldowns, cooldownsErr := source.Cooldowns(ctx) if cooldownsErr != nil { @@ -935,17 +1109,22 @@ func adminRequestStateUpdates( } updates = append(updates, api.AdminEvent{Type: "cooldowns", Data: cooldowns}) } + return updates, nil } func (registry *requestRegistry) start(request aistudio.GenerateRequest, cancel context.CancelFunc) { tracked := trackedRequest{ request: api.AdminRequest{ - ID: request.ID, Model: request.Model, AccountID: request.AccountID, - State: "queued", StartedAt: time.Now().UTC(), + ID: request.ID, + Model: request.Model, + AccountID: request.AccountID, + State: "queued", + StartedAt: time.Now().UTC(), }, cancel: cancel, } + registry.mu.Lock() registry.active[request.ID] = tracked registry.publishLocked(api.AdminEvent{Type: "request", Data: tracked.request}) @@ -954,6 +1133,7 @@ func (registry *requestRegistry) start(request aistudio.GenerateRequest, cancel func (registry *requestRegistry) markRunning(id string, accountID string, accountLabel string) { registry.mu.Lock() + tracked, exists := registry.active[id] if exists { tracked.request.AccountID = accountID @@ -962,17 +1142,20 @@ func (registry *requestRegistry) markRunning(id string, accountID string, accoun registry.active[id] = tracked registry.publishLocked(api.AdminEvent{Type: "request", Data: tracked.request}) } + registry.mu.Unlock() } func (registry *requestRegistry) finish(id string, state string, requestErr error) { registry.mu.Lock() + tracked, exists := registry.active[id] if exists { delete(registry.active, id) tracked.request.State = state registry.publishLocked(api.AdminEvent{Type: "request", Data: tracked.request}) } + registry.mu.Unlock() } @@ -983,9 +1166,11 @@ func (registry *requestRegistry) list() []api.AdminRequest { requests = append(requests, tracked.request) } registry.mu.Unlock() + sort.Slice(requests, func(left int, right int) bool { return requests[left].StartedAt.Before(requests[right].StartedAt) }) + return requests } @@ -993,6 +1178,7 @@ func (registry *requestRegistry) count() int { registry.mu.Lock() count := len(registry.active) registry.mu.Unlock() + return count } @@ -1000,12 +1186,15 @@ func (registry *requestRegistry) cancel(id string) error { registry.mu.Lock() tracked, exists := registry.active[id] registry.mu.Unlock() + if !exists { return &adminOperationError{ - status: http.StatusNotFound, code: "request_not_found", - message: fmt.Sprintf("活动请求不存在: %s", id), + status: http.StatusNotFound, + code: "request_not_found", + message: fmt.Sprintf("active request not found: %s", id), } } + tracked.cancel() return nil } @@ -1017,26 +1206,36 @@ func (registry *requestRegistry) cancelAll() { cancels = append(cancels, tracked.cancel) } registry.mu.Unlock() + for _, cancel := range cancels { cancel() } } func (registry *requestRegistry) log(source string, level string, message string) { - registry.recordLog(api.AdminLog{Source: source, Level: level, Message: message, Event: "runtime.message"}) + registry.recordLog(api.AdminLog{ + Source: source, + Level: level, + Message: message, + Event: "runtime.message", + }) } -// recordLog 将同一结构化事件发布到管理页面与控制台 +// recordLog publishes a structured event to both the admin page and the console. func (registry *requestRegistry) recordLog(entry api.AdminLog) { entry.Time = time.Now().UTC() + registry.mu.Lock() registry.logs = append(registry.logs, entry) + if len(registry.logs) >= adminLogCompactAt { copy(registry.logs, registry.logs[len(registry.logs)-adminLogRetain:]) registry.logs = registry.logs[:adminLogRetain] } + registry.publishLocked(api.AdminEvent{Type: "log", Data: entry}) registry.mu.Unlock() + select { case registry.console <- entry: default: @@ -1054,12 +1253,15 @@ func (registry *requestRegistry) writeConsole(ctx context.Context) { case "WARN": level = slog.LevelWarn } + record := slog.NewRecord(entry.Time, level, entry.Message, 0) record.AddAttrs(slog.String("event", entry.Event), slog.String("source", entry.Source)) if entry.Request != nil { record.AddAttrs(slog.Any("request", entry.Request)) } + _ = slog.Default().Handler().Handle(ctx, record) + case <-ctx.Done(): return } @@ -1072,10 +1274,12 @@ func (registry *requestRegistry) clearLogs() { registry.mu.Unlock() } -// newEventSubscriber 创建管理页有界事件队列 +// newEventSubscriber creates a bounded event queue for the admin page. func newEventSubscriber(ctx context.Context) *eventSubscriber { return &eventSubscriber{ - ctx: ctx, events: make(chan api.AdminEvent, 16), wake: make(chan struct{}, 1), + ctx: ctx, + events: make(chan api.AdminEvent, 16), + wake: make(chan struct{}, 1), pending: make([]api.AdminEvent, 0, 256), } } @@ -1084,6 +1288,7 @@ func (subscriber *eventSubscriber) enqueue(event api.AdminEvent) { subscriber.mu.Lock() subscriber.enqueueLocked(event) subscriber.mu.Unlock() + subscriber.notify() } @@ -1096,6 +1301,7 @@ func (subscriber *eventSubscriber) enqueueLocked(event api.AdminEvent) { return } } + case "request": request := event.Data.(api.AdminRequest) for index := len(subscriber.pending) - 1; index >= 0; index-- { @@ -1106,10 +1312,13 @@ func (subscriber *eventSubscriber) enqueueLocked(event api.AdminEvent) { } } subscriber.pendingRequests++ + case "log": subscriber.pendingLogs++ } + subscriber.pending = append(subscriber.pending, event) + if subscriber.pendingLogs >= adminLogCompactAt { subscriber.trimPendingLocked("log", adminLogRetain) } @@ -1123,8 +1332,10 @@ func (subscriber *eventSubscriber) trimPendingLocked(eventType string, retain in if eventType == "request" { count = subscriber.pendingRequests } + drop := count - retain compacted := subscriber.pending[:0] + for _, event := range subscriber.pending { if event.Type == eventType && drop > 0 { drop-- @@ -1132,7 +1343,9 @@ func (subscriber *eventSubscriber) trimPendingLocked(eventType string, retain in } compacted = append(compacted, event) } + subscriber.pending = compacted + if eventType == "request" { subscriber.pendingRequests = retain } else { @@ -1142,10 +1355,12 @@ func (subscriber *eventSubscriber) trimPendingLocked(eventType string, retain in func (subscriber *eventSubscriber) activate(initial []api.AdminEvent) { subscriber.mu.Lock() + buffered := append([]api.AdminEvent(nil), subscriber.pending...) subscriber.pending = subscriber.pending[:0] subscriber.pendingLogs = 0 subscriber.pendingRequests = 0 + for _, event := range initial { subscriber.enqueueLocked(event) } @@ -1154,6 +1369,7 @@ func (subscriber *eventSubscriber) activate(initial []api.AdminEvent) { subscriber.enqueueLocked(event) } } + subscriber.mu.Unlock() subscriber.notify() } @@ -1168,12 +1384,15 @@ func (subscriber *eventSubscriber) notify() { func (subscriber *eventSubscriber) next() (api.AdminEvent, bool) { subscriber.mu.Lock() defer subscriber.mu.Unlock() + if len(subscriber.pending) == 0 { return api.AdminEvent{}, false } + event := subscriber.pending[0] subscriber.pending[0] = api.AdminEvent{} subscriber.pending = subscriber.pending[1:] + if event.Type == "log" { subscriber.pendingLogs-- } @@ -1183,11 +1402,13 @@ func (subscriber *eventSubscriber) next() (api.AdminEvent, bool) { if len(subscriber.pending) == 0 { subscriber.pending = nil } + return event, true } func (subscriber *eventSubscriber) run() { defer close(subscriber.events) + for { if event, ok := subscriber.next(); ok { select { @@ -1197,6 +1418,7 @@ func (subscriber *eventSubscriber) run() { return } } + select { case <-subscriber.wake: case <-subscriber.ctx.Done(): @@ -1207,48 +1429,59 @@ func (subscriber *eventSubscriber) run() { func (registry *requestRegistry) subscribe(ctx context.Context) *eventSubscriber { subscriber := newEventSubscriber(ctx) + registry.mu.Lock() registry.subscribers[subscriber] = struct{}{} registry.mu.Unlock() + go func() { <-ctx.Done() registry.mu.Lock() delete(registry.subscribers, subscriber) registry.mu.Unlock() }() + return subscriber } -// activateSubscriber 原子衔接日志请求快照与实时事件 +// activateSubscriber atomically connects initial snapshots to live events. func (registry *requestRegistry) activateSubscriber( subscriber *eventSubscriber, prefix []api.AdminEvent, suffix []api.AdminEvent, ) <-chan api.AdminEvent { registry.mu.Lock() + initialLogs := registry.logs if len(initialLogs) > adminLogInitialEvents { initialLogs = initialLogs[len(initialLogs)-adminLogInitialEvents:] } + initial := make([]api.AdminEvent, 0, len(prefix)+len(initialLogs)+len(suffix)+len(registry.active)) initial = append(initial, prefix...) for _, entry := range initialLogs { initial = append(initial, api.AdminEvent{Type: "log", Data: entry}) } initial = append(initial, suffix...) + requests := make([]api.AdminRequest, 0, len(registry.active)) for _, tracked := range registry.active { requests = append(requests, tracked.request) } + sort.Slice(requests, func(left int, right int) bool { return requests[left].StartedAt.Before(requests[right].StartedAt) }) + for _, request := range requests { initial = append(initial, api.AdminEvent{Type: "request", Data: request}) } + subscriber.activate(initial) registry.mu.Unlock() + go subscriber.run() + return subscriber.events } @@ -1258,7 +1491,7 @@ func (registry *requestRegistry) publishLocked(event api.AdminEvent) { } } -// publish 向管理页订阅者发布增量事件 +// publish sends incremental events to admin subscribers. func (registry *requestRegistry) publish(event api.AdminEvent) { registry.mu.Lock() registry.publishLocked(event) @@ -1275,10 +1508,16 @@ func buildVersion() string { func runtimeConfigDTO(cfg config.Config) api.RuntimeConfig { return api.RuntimeConfig{ - AuthStates: cfg.AuthStates, ListenAddr: cfg.ListenAddr, APIKey: cfg.ProxyAPIKey, - ActiveListenAddr: cfg.ListenAddr, ActiveAPIKey: cfg.ProxyAPIKey, - Proxy: cfg.Proxy, InitTimeout: cfg.InitTimeout.String(), RequestTimeout: cfg.RequestTimeout.String(), - WarmWorkerLimit: cfg.WarmWorkerLimit, MaxActiveWorkers: cfg.MaxActiveWorkers, + AuthStates: cfg.AuthStates, + ListenAddr: cfg.ListenAddr, + APIKey: cfg.ProxyAPIKey, + ActiveListenAddr: cfg.ListenAddr, + ActiveAPIKey: cfg.ProxyAPIKey, + Proxy: cfg.Proxy, + InitTimeout: cfg.InitTimeout.String(), + RequestTimeout: cfg.RequestTimeout.String(), + WarmWorkerLimit: cfg.WarmWorkerLimit, + MaxActiveWorkers: cfg.MaxActiveWorkers, WarmStartupConcurrency: cfg.WarmStartupConcurrency, PerAccountConcurrency: cfg.PerAccountConcurrency, RoutingStrategy: cfg.RoutingStrategy, diff --git a/internal/app/app.go b/internal/app/app.go index 652fd3d..efa4bc8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -22,66 +22,74 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/webui" ) -// commandOptions 保存只影响本次启动的命令行选项 +// commandOptions holds command-line options that only affect the current run. type commandOptions struct { openUI bool overrides dataConfigOverrides } -// Run 执行单二进制命令入口 +// Run executes the single-binary command entry point. func Run(args []string) int { slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil))) + err := runCommand(args) if errors.Is(err, flag.ErrHelp) { return 0 } if err != nil { - slog.Error("AIStudio2API 启动失败", "error", err) + slog.Error("AIStudio2API failed to start", "error", err) return 1 } + return 0 } -// runCommand 分派首次配置与默认服务 +// runCommand dispatches setup or default server workflows. func runCommand(args []string) error { cfg, err := config.Load(".env") if err != nil { return err } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if len(args) != 0 && args[0] == "setup" { return setup.Run(ctx, cfg, args[1:]) } + options, err := parseFlags(args, &cfg) if err != nil { return err } + manager, err := newRuntimeManager(ctx, ".env", cfg, options.overrides) if err != nil { return err } + return errors.Join(runServer(ctx, cfg, options, manager), manager.Close()) } -// parseFlags 使用命令行参数覆盖本次启动配置 +// parseFlags parses CLI flags and overrides the current run configuration. func parseFlags(args []string, cfg *config.Config) (commandOptions, error) { flags := flag.NewFlagSet("aistudio2api", flag.ContinueOnError) flags.Usage = func() { - fmt.Fprintln(flags.Output(), "首次配置: aistudio2api setup") - fmt.Fprintln(flags.Output(), "日常启动: aistudio2api [参数]") + fmt.Fprintln(flags.Output(), "Initial setup: aistudio2api setup") + fmt.Fprintln(flags.Output(), "Standard run: aistudio2api [flags]") flags.PrintDefaults() } - authStates := flags.String("auth", cfg.AuthStates, "账户状态文件、目录或逗号分隔的多个路径") - listenAddr := flags.String("listen", cfg.ListenAddr, "服务监听地址") - proxy := flags.String("proxy", cfg.Proxy, "本次启动使用的 HTTP、HTTPS 或 SOCKS5 代理") - openUI := flags.Bool("open-ui", len(args) == 0, "启动后打开管理界面") + + authStates := flags.String("auth", cfg.AuthStates, "Account state file, directory, or comma-separated paths") + listenAddr := flags.String("listen", cfg.ListenAddr, "Server listen address") + proxy := flags.String("proxy", cfg.Proxy, "HTTP, HTTPS, or SOCKS5 proxy for this run") + openUI := flags.Bool("open-ui", len(args) == 0, "Open web UI after launch") + if err := flags.Parse(args); err != nil { return commandOptions{}, err } if flags.NArg() != 0 { - return commandOptions{}, fmt.Errorf("未知参数 %q", flags.Arg(0)) + return commandOptions{}, fmt.Errorf("unknown argument %q", flags.Arg(0)) } cfg.AuthStates = strings.TrimSpace(*authStates) @@ -90,6 +98,7 @@ func parseFlags(args []string, cfg *config.Config) (commandOptions, error) { if err := cfg.Validate(); err != nil { return commandOptions{}, err } + options := commandOptions{openUI: *openUI} flags.Visit(func(value *flag.Flag) { switch value.Name { @@ -101,34 +110,39 @@ func parseFlags(args []string, cfg *config.Config) (commandOptions, error) { options.overrides.proxy = &override } }) + return options, nil } -// runServer 管理 HTTP 监听与优雅退出 +// runServer manages HTTP listening and graceful shutdown. func runServer(ctx context.Context, cfg config.Config, options commandOptions, manager *runtimeManager) error { - manager.requests.log("service", "INFO", fmt.Sprintf("管理监听启动 | 地址=%s", cfg.ListenAddr)) + manager.requests.log("service", "INFO", fmt.Sprintf("Admin listener started | address=%s", cfg.ListenAddr)) + listener, err := net.Listen("tcp", cfg.ListenAddr) if err != nil { - return fmt.Errorf("监听 %s: %w", cfg.ListenAddr, err) + return fmt.Errorf("listen on %s: %w", cfg.ListenAddr, err) } + apiHandler := api.NewHandler(manager, api.Config{APIKey: cfg.ProxyAPIKey, Admin: manager}) server := &http.Server{ Handler: rootHandler(apiHandler), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute, } + serveError := make(chan error, 1) go func() { serveError <- server.Serve(listener) }() address := browserAddress(listener.Addr().String()) - manager.requests.log("service", "INFO", "管理服务就绪 | 地址=http://"+address) + manager.requests.log("service", "INFO", "Admin service ready | address=http://"+address) + if options.openUI { if err := openBrowser("http://" + address); err != nil { - manager.requests.log("service", "WARN", "管理页面打开失败 | "+err.Error()) + manager.requests.log("service", "WARN", "Failed to open admin UI | "+err.Error()) } else { - manager.requests.log("service", "INFO", "管理页面已打开 | 地址=http://"+address) + manager.requests.log("service", "INFO", "Admin UI opened | address=http://"+address) } } @@ -138,12 +152,15 @@ func runServer(ctx context.Context, cfg config.Config, options commandOptions, m return nil } return err + case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("关闭 HTTP 服务: %w", err) + return fmt.Errorf("shutdown HTTP server: %w", err) } + if err := <-serveError; err != nil && !errors.Is(err, http.ErrServerClosed) { return err } @@ -151,7 +168,7 @@ func runServer(ctx context.Context, cfg config.Config, options commandOptions, m } } -// rootHandler 将公开 API 与内嵌管理端挂载到同一服务 +// rootHandler mounts the public API and internal web UI onto the same handler. func rootHandler(apiHandler http.Handler) http.Handler { root := http.NewServeMux() root.Handle("/health", apiHandler) @@ -159,24 +176,28 @@ func rootHandler(apiHandler http.Handler) http.Handler { root.Handle("/v1/", apiHandler) root.Handle("/v1beta/", apiHandler) root.Handle("/", webui.Handler()) + return root } -// browserAddress 将通配监听地址转换为本机可访问地址 +// browserAddress converts wildcard listen addresses into a local accessible address. func browserAddress(address string) string { host, port, err := net.SplitHostPort(address) if err != nil { return address } + if host == "" || host == "0.0.0.0" || host == "::" { host = "127.0.0.1" } + return net.JoinHostPort(host, port) } -// openBrowser 使用当前平台的系统命令打开管理界面 +// openBrowser opens the administration UI using the system's default browser command. func openBrowser(url string) error { var command *exec.Cmd + switch runtime.GOOS { case "windows": command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) @@ -185,11 +206,14 @@ func openBrowser(url string) error { default: command = exec.Command("xdg-open", url) } + if err := command.Start(); err != nil { - return fmt.Errorf("打开管理界面: %w", err) + return fmt.Errorf("open admin UI: %w", err) } + if err := command.Process.Release(); err != nil { - return fmt.Errorf("释放管理界面启动进程: %w", err) + return fmt.Errorf("release browser process: %w", err) } + return nil } diff --git a/internal/app/auth_retry.go b/internal/app/auth_retry.go index 0d50c6d..ebf9712 100644 --- a/internal/app/auth_retry.go +++ b/internal/app/auth_retry.go @@ -15,7 +15,7 @@ import ( type chromeCookieRefreshFunc func(context.Context, aistudio.ChromeOAuthMaterial, string) ([]aistudio.StateCookie, error) -// authRuntimeRefresher 使用账户保存的 Chrome OAuth 材料原地续签 +// authRuntimeRefresher renews credentials in-place using saved Chrome OAuth material. type authRuntimeRefresher struct { refresh chromeCookieRefreshFunc reset func(string) error @@ -24,13 +24,13 @@ type authRuntimeRefresher struct { requests *requestRegistry } -// authRetryTransport 为普通 RPC 执行一次认证续签重试 +// authRetryTransport performs an auth renewal retry for standard RPC requests. type authRetryTransport struct { transport aistudio.RPCTransport refresher *authRuntimeRefresher } -// authRetryProtectedTransport 为受保护 RPC 执行一次认证续签重试 +// authRetryProtectedTransport performs an auth renewal retry for protected RPC requests. type authRetryProtectedTransport struct { transport aistudio.ProtectedTransport refresher *authRuntimeRefresher @@ -62,6 +62,7 @@ func (gate *bidiReleaseGate) Release() error { return nil } gate.mu.Unlock() + return gate.releaseNow() } @@ -70,9 +71,11 @@ func (gate *bidiReleaseGate) Commit() error { gate.committed = true requested := gate.requested gate.mu.Unlock() + if !requested { return nil } + return gate.releaseNow() } @@ -88,10 +91,11 @@ func (gate *bidiReleaseGate) releaseNow() error { gate.err = gate.release() } }) + return gate.err } -// UploadDrive 将 Drive 上传委托给同一认证传输 +// UploadDrive delegates Drive uploads to the underlying authenticated transport. func (transport *authRetryTransport) UploadDrive( ctx context.Context, accountID string, @@ -100,12 +104,13 @@ func (transport *authRetryTransport) UploadDrive( ) (aistudio.FileRef, error) { drive, ok := transport.transport.(aistudio.DriveTransport) if !ok { - return aistudio.FileRef{}, fmt.Errorf("transport 不支持 Drive 上传") + return aistudio.FileRef{}, fmt.Errorf("transport does not support Drive uploads") } + return drive.UploadDrive(ctx, accountID, token, request) } -// DownloadDrive 将 Drive 下载委托给同一认证传输 +// DownloadDrive delegates Drive downloads to the underlying authenticated transport. func (transport *authRetryTransport) DownloadDrive( ctx context.Context, accountID string, @@ -114,12 +119,13 @@ func (transport *authRetryTransport) DownloadDrive( ) (aistudio.MediaStream, error) { drive, ok := transport.transport.(aistudio.DriveTransport) if !ok { - return aistudio.MediaStream{}, fmt.Errorf("transport 不支持 Drive 下载") + return aistudio.MediaStream{}, fmt.Errorf("transport does not support Drive downloads") } + return drive.DownloadDrive(ctx, accountID, token, fileID) } -// DeleteDrive 将 Drive 删除委托给同一认证传输 +// DeleteDrive delegates Drive file deletions to the underlying authenticated transport. func (transport *authRetryTransport) DeleteDrive( ctx context.Context, accountID string, @@ -128,12 +134,13 @@ func (transport *authRetryTransport) DeleteDrive( ) error { drive, ok := transport.transport.(aistudio.DriveTransport) if !ok { - return fmt.Errorf("transport 不支持 Drive 删除") + return fmt.Errorf("transport does not support Drive deletion") } + return drive.DeleteDrive(ctx, accountID, token, fileID) } -// newAuthRuntimeRefresher 创建生产环境认证续签器 +// newAuthRuntimeRefresher creates a production authentication refresher. func newAuthRuntimeRefresher( workers *accountWorkerManager, headers *accountHeaderProvider, @@ -141,9 +148,11 @@ func newAuthRuntimeRefresher( globalProxy string, ) *authRuntimeRefresher { return &authRuntimeRefresher{ - refresh: chromeauth.Refresh, reset: workers.Reset, prepareHeaders: headers.prepareInvalidate, - globalProxy: globalProxy, - requests: requests, + refresh: chromeauth.Refresh, + reset: workers.Reset, + prepareHeaders: headers.prepareInvalidate, + globalProxy: globalProxy, + requests: requests, } } @@ -151,12 +160,15 @@ func (provider *accountHeaderProvider) prepareInvalidate(accountID string) (func provider.mu.RLock() account := provider.accounts[accountID] provider.mu.RUnlock() + if account == nil { - return nil, fmt.Errorf("账户固定出口不存在: %s", accountID) + return nil, fmt.Errorf("fixed egress for account does not exist: %s", accountID) } + account.mu.Lock() previous := account.headers.Clone() account.headers = nil + return func(committed bool) { if !committed { account.headers = previous @@ -165,26 +177,30 @@ func (provider *accountHeaderProvider) prepareInvalidate(accountID string) (func }, nil } -// Do 在 401 后续签同一账户并重放一次请求 +// Do refreshes credentials for the same account upon receiving a 401 status and replays the request. func (transport *authRetryTransport) Do(ctx context.Context, request aistudio.RPCRequest) (*aistudio.RPCResponse, error) { response, err := transport.transport.Do(ctx, request) if err != nil || !authenticationFailed(response) { return response, err } + if !transport.refresher.Available(ctx) { return response, nil } + originalErr, err := readAuthenticationFailure(request.Method, response) if err != nil { return nil, err } + if err := transport.refresher.Refresh(ctx); err != nil { return nil, errors.Join(originalErr, err) } + return transport.transport.Do(ctx, request) } -// DoProtected 在 401 后续签同一账户并重放一次受保护请求 +// DoProtected refreshes credentials for the same account upon receiving a 401 status and replays the protected request. func (transport *authRetryProtectedTransport) DoProtected( ctx context.Context, request aistudio.GenerateRequest, @@ -194,20 +210,24 @@ func (transport *authRetryProtectedTransport) DoProtected( if err != nil || !authenticationFailed(response) { return response, err } + if !transport.refresher.Available(ctx) { return response, nil } + originalErr, err := readAuthenticationFailure(rpc.Method, response) if err != nil { return nil, err } + if err := transport.refresher.Refresh(ctx); err != nil { return nil, errors.Join(originalErr, err) } + return transport.transport.DoProtected(ctx, request, rpc) } -// OpenBidiProtected 在 401 后续签同一账户并重新建立 WebChannel +// OpenBidiProtected refreshes credentials for the same account upon receiving a 401 status and re-establishes the WebChannel session. func (transport *authRetryProtectedTransport) OpenBidiProtected( ctx context.Context, request aistudio.BidiRequest, @@ -217,8 +237,9 @@ func (transport *authRetryProtectedTransport) OpenBidiProtected( ) (*aistudio.BidiSession, error) { bidiTransport, ok := transport.transport.(aistudio.BidiProtectedTransport) if !ok { - return nil, fmt.Errorf("protected transport 不支持 BidiGenerateContent") + return nil, fmt.Errorf("protected transport does not support BidiGenerateContent") } + gate := newBidiReleaseGate(release) session, err := bidiTransport.OpenBidiProtected(ctx, request, runtime, lease, gate.Release) if err == nil { @@ -227,17 +248,21 @@ func (transport *authRetryProtectedTransport) OpenBidiProtected( } return session, nil } + if !aistudio.DefinitiveAuthenticationFailure(err) || transport.refresher == nil || !transport.refresher.Available(ctx) { return nil, errors.Join(err, gate.Commit()) } + gate.Abandon() + if refreshErr := transport.refresher.Refresh(ctx); refreshErr != nil { return nil, errors.Join(err, refreshErr) } + return bidiTransport.OpenBidiProtected(ctx, request, runtime, lease, release) } -// DoProtectedVideo 在认证失败后续签同一账户并重放 Veo 请求 +// DoProtectedVideo refreshes credentials for the same account upon authentication failure and replays the Veo request. func (transport *authRetryProtectedTransport) DoProtectedVideo( ctx context.Context, request aistudio.VideoRequest, @@ -245,89 +270,108 @@ func (transport *authRetryProtectedTransport) DoProtectedVideo( ) (*aistudio.RPCResponse, error) { videoTransport, ok := transport.transport.(aistudio.VideoProtectedTransport) if !ok { - return nil, fmt.Errorf("protected transport 不支持 GenerateVideo") + return nil, fmt.Errorf("protected transport does not support GenerateVideo") } + response, err := videoTransport.DoProtectedVideo(ctx, request, rpc) if err != nil || !authenticationFailed(response) { return response, err } + if !transport.refresher.Available(ctx) { return response, nil } + originalErr, err := readAuthenticationFailure(rpc.Method, response) if err != nil { return nil, err } + if err := transport.refresher.Refresh(ctx); err != nil { return nil, errors.Join(originalErr, err) } + return videoTransport.DoProtectedVideo(ctx, request, rpc) } -// Refresh 续签当前租约账户并保存新的 storage state +// Refresh renews credentials for the currently leased account and saves the new storage state. func (refresher *authRuntimeRefresher) Refresh(ctx context.Context) error { lease, ok := aistudio.AccountLeaseFromContext(ctx) if !ok { - return fmt.Errorf("认证续签缺少账户租约") + return fmt.Errorf("auth renewal missing account lease") } + endRefresh, ok := lease.BeginAuthRefresh() if !ok { - return fmt.Errorf("%w: 账户存在活动生成", aistudio.ErrAccountLeased) + return fmt.Errorf("%w: account has active generation", aistudio.ErrAccountLeased) } defer endRefresh() + account := lease.Account() startedAt := time.Now() - refresher.requests.log(account.Config.Label, "INFO", "账户认证续签 | 1/2 | 刷新 Cookie") + + refresher.requests.log(account.Config.Label, "INFO", "Account auth renewal | 1/2 | Refreshing cookies") + err := lease.RefreshStorageState(func(state *aistudio.StorageState) error { extension, exists, err := state.AuthExtension() if err != nil { return err } if !exists || extension.OAuth == nil { - return fmt.Errorf("账户 %s 缺少 Chrome OAuth 续签材料", account.ID) + return fmt.Errorf("account %s missing Chrome OAuth renewal material", account.ID) } + cookies, err := refresher.refresh(ctx, *extension.OAuth, account.EffectiveProxy(refresher.globalProxy)) if err != nil { - return fmt.Errorf("续签账户 %s: %w", account.ID, err) + return fmt.Errorf("renew account %s: %w", account.ID, err) } + state.Cookies = cookies return nil }, func() (func(bool), error) { - refresher.requests.log(account.Config.Label, "INFO", "账户认证续签 | 2/2 | 重置协议运行时") + refresher.requests.log(account.Config.Label, "INFO", "Account auth renewal | 2/2 | Resetting protocol runtime") + if err := refresher.reset(account.ID); err != nil { - return nil, fmt.Errorf("重置账户 %s runtime: %w", account.ID, err) + return nil, fmt.Errorf("reset runtime for account %s: %w", account.ID, err) } + finish, err := refresher.prepareHeaders(account.ID) if err != nil { - return nil, fmt.Errorf("刷新账户 %s 公共头: %w", account.ID, err) + return nil, fmt.Errorf("refresh common headers for account %s: %w", account.ID, err) } + return finish, nil }) + if err != nil { - wrapped := fmt.Errorf("保存账户 %s 认证状态: %w", account.ID, err) + wrapped := fmt.Errorf("save storage state for account %s: %w", account.ID, err) refresher.requests.log(account.Config.Label, "ERROR", fmt.Sprintf( - "账户认证续签失败 | 耗时=%s | 错误=%s", + "Account auth renewal failed | duration=%s | error=%s", time.Since(startedAt).Round(time.Millisecond), wrapped.Error(), )) return wrapped } + refresher.requests.log(account.Config.Label, "INFO", fmt.Sprintf( - "账户认证续签完成 | 耗时=%s", + "Account auth renewal completed | duration=%s", time.Since(startedAt).Round(time.Millisecond), )) + return nil } -// Available 返回当前租约账户是否保存了 Chrome OAuth 续签材料 +// Available returns whether the currently leased account has saved Chrome OAuth refresh material. func (refresher *authRuntimeRefresher) Available(ctx context.Context) bool { lease, ok := aistudio.AccountLeaseFromContext(ctx) if !ok { return false } + state, err := lease.ReloadStorageState() if err != nil { return false } + extension, exists, err := state.AuthExtension() return err == nil && exists && extension.OAuth != nil } @@ -336,12 +380,13 @@ func authenticationFailed(response *aistudio.RPCResponse) bool { return response != nil && response.Body != nil && response.StatusCode == http.StatusUnauthorized } -// readAuthenticationFailure 读取并关闭认证失败响应以保留原始原因 +// readAuthenticationFailure reads and closes an authentication failure response to preserve the original error reason. func readAuthenticationFailure(method string, response *aistudio.RPCResponse) (*aistudio.RPCError, error) { body, readErr := io.ReadAll(response.Body) if err := errors.Join(readErr, response.Body.Close()); err != nil { - return nil, fmt.Errorf("读取认证失败响应: %w", err) + return nil, fmt.Errorf("read authentication failure response: %w", err) } + return aistudio.DecodeRPCError(method, response.StatusCode, body), nil } diff --git a/internal/app/bidi.go b/internal/app/bidi.go index 6145060..1bc4a04 100644 --- a/internal/app/bidi.go +++ b/internal/app/bidi.go @@ -10,14 +10,16 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/api" ) -// OpenBidi 将实时会话绑定到当前生成服务生命周期 +// OpenBidi binds a bidirectional streaming session to the current generation service lifecycle. func (service *trackedService) OpenBidi(ctx context.Context, request aistudio.BidiRequest) (*aistudio.BidiSession, error) { api.SetAccessLogTarget(ctx, request.Model, "") + requestCtx, cancel, err := service.bidiRequestContext(ctx) if err != nil { api.SetAccessLogError(ctx, err) return nil, err } + if request.AccountID == "" && len(request.AllowedAccountIDs) == 0 { request.AllowedAccountIDs, err = service.bidiCandidates(requestCtx, request.Model, request.ModelAccessScope) if err != nil { @@ -26,12 +28,15 @@ func (service *trackedService) OpenBidi(ctx context.Context, request aistudio.Bi return nil, err } } + workerGenerations := make(map[string]uint64) recoveredWorkers := make(map[string]struct{}) + request.ObserveWAARuntime = func(accountID string, generation uint64) { workerGenerations[accountID] = generation } request.ObserveModelAccessChange = service.publishModelAccess + request.ObserveAccountFailure = func(accountID string, cause error) { label := accountID for _, status := range service.pool.Status() { @@ -40,55 +45,67 @@ func (service *trackedService) OpenBidi(ctx context.Context, request aistudio.Bi break } } + service.requests.log(label, "WARN", fmt.Sprintf( - "账号切换 | 模型=%s\n原因: %s", + "Account switch | model=%s\nReason: %s", strings.TrimPrefix(request.Model, "models/"), strings.TrimSpace(cause.Error()), )) } + request.RecoverWAARuntime = func(recoveryCtx context.Context, accountID string, cause error) (bool, error) { workerFailed := service.workers.WorkerFailed(accountID) waaRuntimeFailed := aistudio.DefinitiveWAARuntimeFailure(cause) workerReplaced := errors.Is(cause, errAccountWorkerReplaced) recoverCurrentGeneration := needsWAARuntimeRecovery(cause, false, workerFailed, workerReplaced) + if recoveryCtx.Err() != nil || !recoverCurrentGeneration { return false, nil } + expectedGeneration := workerGenerations[accountID] recovered, _, recoveryErr := service.recoverWorkerOnce( accountID, expectedGeneration, recoveredWorkers, recoverCurrentGeneration, workerFailed || waaRuntimeFailed, ) + return recovered, recoveryErr } + requestCtx = aistudio.ContextWithAccountSelectionObserver(requestCtx, func(account *aistudio.Account) { workerGenerations[account.ID] = service.workers.WorkerGeneration(account.ID) api.SetAccessLogTarget(ctx, request.Model, account.Config.Label) }) + bidi, ok := service.service.(aistudio.BidiService) if !ok { cancel() - return nil, fmt.Errorf("bidi service 不可用") + return nil, fmt.Errorf("bidi service is unavailable") } + session, err := bidi.OpenBidi(requestCtx, request) if err != nil { api.SetAccessLogError(ctx, err) cancel() return nil, err } + return session, nil } -// WorkerGeneration 返回当前 preparer 对应的 Worker 版本号 +// WorkerGeneration returns the worker generation number corresponding to the current preparer. func (preparer *accountWorkerPreparer) WorkerGeneration() uint64 { return preparer.account.generation.Load() } func (service *trackedService) bidiCandidates(ctx context.Context, model string, modelAccessScope string) ([]string, error) { modelID := strings.TrimPrefix(strings.TrimSpace(model), "models/") + groups, err := service.pool.ClassifyCandidates( ctx, aistudio.AccountSelection{ - ModelID: modelID, ModelAccessScope: modelAccessScope, Method: "bidiGenerateContent", + ModelID: modelID, + ModelAccessScope: modelAccessScope, + Method: "bidiGenerateContent", }, service.workers.WarmAccountIDs(), ) @@ -98,30 +115,36 @@ func (service *trackedService) bidiCandidates(ctx context.Context, model string, if err := ctx.Err(); err != nil { return nil, err } + warmAvailable := append(append([]string(nil), groups.WarmReady...), groups.WarmAvailable...) candidates := service.preferBidiAccounts(warmAvailable, modelID, modelAccessScope) candidates = append(candidates, service.preferBidiAccounts(groups.StandbyReady, modelID, modelAccessScope)...) candidates = append(candidates, service.preferBidiAccounts(groups.WarmBusy, modelID, modelAccessScope)...) candidates = append(candidates, service.preferBidiAccounts(groups.StandbyBusy, modelID, modelAccessScope)...) + if len(candidates) == 0 { return nil, aistudio.ErrNoEligibleAccount } + return candidates, nil } -// preferBidiAccounts 在实测资格分组内按账户策略排列候选 +// preferBidiAccounts orders candidate accounts within verified qualification groups according to account policy. func (service *trackedService) preferBidiAccounts(accountIDs []string, modelID string, modelAccessScope string) []string { scope := strings.TrimSpace(modelAccessScope) if scope == "" { scope = strings.TrimPrefix(strings.TrimSpace(modelID), "models/") } + candidates := service.pool.OrderCandidates(accountIDs, scope) if strings.TrimSpace(modelAccessScope) == "" { return candidates } + states := service.pool.CandidateStatesForScope(candidates, modelID, modelAccessScope) verified := make([]string, 0, len(candidates)) unverified := make([]string, 0, len(candidates)) + for _, accountID := range candidates { if states[accountID].ModelAccess == aistudio.ModelAccessVerified { verified = append(verified, accountID) @@ -129,21 +152,27 @@ func (service *trackedService) preferBidiAccounts(accountIDs []string, modelID s } unverified = append(unverified, accountID) } + return append(verified, unverified...) } func (service *trackedService) bidiRequestContext(ctx context.Context) (context.Context, context.CancelFunc, error) { service.lifecycleMu.Lock() + if service.state.Load() != serviceRunning || service.dataContext == nil { service.lifecycleMu.Unlock() return nil, nil, &serviceStoppedError{} } + requestCtx, cancel := context.WithCancel(ctx) stopData := context.AfterFunc(service.dataContext, cancel) + context.AfterFunc(requestCtx, func() { stopData() }) + service.lifecycleMu.Unlock() + return requestCtx, func() { stopData() cancel() diff --git a/internal/app/files.go b/internal/app/files.go index dfcbd06..821c1f5 100644 --- a/internal/app/files.go +++ b/internal/app/files.go @@ -8,50 +8,59 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/api" ) -// UploadFile 上传文件并记录实际账户 +// UploadFile uploads a file and tracks the actual account used. func (service *trackedService) UploadFile(ctx context.Context, request aistudio.UploadRequest) (aistudio.FileRef, error) { requestCtx, cancel, err := service.observedDataRequestContext(ctx, "") if err != nil { return aistudio.FileRef{}, err } defer cancel() + files, ok := service.service.(aistudio.FileService) if !ok { - return aistudio.FileRef{}, fmt.Errorf("file service 不可用") + return aistudio.FileRef{}, fmt.Errorf("file service is unavailable") } + file, requestErr := files.UploadFile(requestCtx, request) api.SetAccessLogError(requestCtx, requestErr) + return file, requestErr } -// FileMetadata 返回上传文件的持久元数据 +// FileMetadata returns persistent metadata for an uploaded file. func (service *trackedService) FileMetadata(ctx context.Context, fileID string) (aistudio.FileMetadata, error) { requestCtx, cancel, err := service.observedDataRequestContext(ctx, "") if err != nil { return aistudio.FileMetadata{}, err } defer cancel() + files, ok := service.service.(aistudio.FileService) if !ok { - return aistudio.FileMetadata{}, fmt.Errorf("file service 不可用") + return aistudio.FileMetadata{}, fmt.Errorf("file service is unavailable") } + metadata, requestErr := files.FileMetadata(requestCtx, fileID) api.SetAccessLogError(requestCtx, requestErr) + return metadata, requestErr } -// DeleteFile 删除上传文件并记录结果 +// DeleteFile deletes an uploaded file and logs the outcome. func (service *trackedService) DeleteFile(ctx context.Context, fileID string) error { requestCtx, cancel, err := service.observedDataRequestContext(ctx, "") if err != nil { return err } defer cancel() + files, ok := service.service.(aistudio.FileService) if !ok { - return fmt.Errorf("file service 不可用") + return fmt.Errorf("file service is unavailable") } + requestErr := files.DeleteFile(requestCtx, fileID) api.SetAccessLogError(requestCtx, requestErr) + return requestErr } diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index a11071a..2c1bdd1 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -11,13 +11,13 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/config" ) -// managedService 表示可整体替换的生成服务 +// managedService represents a hot-swappable generation service. type managedService interface { aistudio.Service State() string } -// runtimeGeneration 保存同一份配置装配的完整运行时 +// runtimeGeneration holds a complete runtime assembled from a single configuration snapshot. type runtimeGeneration struct { service managedService admin api.AdminService @@ -26,27 +26,27 @@ type runtimeGeneration struct { closeRuntime func() error } -// runtimeFactory 创建一个完整生成服务实例 +// runtimeFactory creates a complete generation service instance. type runtimeFactory func(context.Context, context.Context, config.Config, *requestRegistry) (*runtimeGeneration, error) -// cancelLifecycle 取消当前生成服务实例的全部后台操作 +// cancelLifecycle cancels all background operations of the current generation instance. func (generation *runtimeGeneration) cancelLifecycle() { generation.lifecycleCancel() } -// Close 取消当前生成服务实例并释放运行时 +// Close cancels the current generation instance and releases runtime resources. func (generation *runtimeGeneration) Close() error { generation.cancelLifecycle() return generation.closeRuntime() } -// dataConfigOverrides 保存当前进程的命令行生成服务配置覆盖 +// dataConfigOverrides stores command-line overrides for generation service configuration. type dataConfigOverrides struct { authStates *string proxy *string } -// Apply 将命令行覆盖应用到新实例配置 +// Apply applies command-line overrides to the target configuration. func (overrides dataConfigOverrides) Apply(cfg *config.Config) { if overrides.authStates != nil { cfg.AuthStates = *overrides.authStates @@ -56,7 +56,7 @@ func (overrides dataConfigOverrides) Apply(cfg *config.Config) { } } -// runtimeManager 在固定管理监听器内切换完整生成服务 +// runtimeManager hot-swaps generation services within a persistent admin listener. type runtimeManager struct { lifecycle context.Context configPath string @@ -70,7 +70,7 @@ type runtimeManager struct { startCancel context.CancelFunc } -// newRuntimeManager 创建进程级管理器与初始生成服务 +// newRuntimeManager creates a process-level manager and initializes the first generation service. func newRuntimeManager( ctx context.Context, configPath string, @@ -78,19 +78,26 @@ func newRuntimeManager( overrides dataConfigOverrides, ) (*runtimeManager, error) { requests := newRequestRegistry(ctx) + manager := &runtimeManager{ - lifecycle: ctx, configPath: configPath, activeManagement: cfg, - overrides: overrides, requests: requests, factory: buildRuntimeGeneration, + lifecycle: ctx, + configPath: configPath, + activeManagement: cfg, + overrides: overrides, + requests: requests, + factory: buildRuntimeGeneration, } + generation, err := manager.factory(ctx, ctx, cfg, requests) if err != nil { return nil, err } + manager.current = generation return manager, nil } -// buildRuntimeGeneration 从配置快照创建账户池、Worker 与协议运行时 +// buildRuntimeGeneration creates an account pool, workers, and protocol runtimes from a configuration snapshot. func buildRuntimeGeneration( launchCtx context.Context, parentLifecycle context.Context, @@ -98,33 +105,41 @@ func buildRuntimeGeneration( requests *requestRegistry, ) (*runtimeGeneration, error) { lifecycle, lifecycleCancel := context.WithCancel(parentLifecycle) + service, admin, closeRuntime, err := newRuntime(launchCtx, lifecycle, cfg, requests) if err != nil { lifecycleCancel() return nil, err } + return &runtimeGeneration{ - service: service, admin: admin, config: cfg, - lifecycleCancel: lifecycleCancel, closeRuntime: closeRuntime, + service: service, + admin: admin, + config: cfg, + lifecycleCancel: lifecycleCancel, + closeRuntime: closeRuntime, }, nil } -// StartService 从最新配置创建并启动新生成服务 +// StartService creates and starts a new generation service from the latest configuration. func (manager *runtimeManager) StartService(ctx context.Context) (api.AdminStatus, error) { manager.startMu.Lock() defer manager.startMu.Unlock() manager.mu.Lock() current := manager.current + if current.service.State() != "STOPPED" { status, err := current.admin.StartService(ctx) manager.mu.Unlock() return status, err } + if _, err := current.admin.StopService(ctx); err != nil { manager.mu.Unlock() return api.AdminStatus{}, err } + launchCtx, launchCancel := context.WithCancel(manager.lifecycle) manager.startCancel = launchCancel manager.mu.Unlock() @@ -134,6 +149,7 @@ func (manager *runtimeManager) StartService(ctx context.Context) (api.AdminStatu manager.finishStart(launchCancel) return api.AdminStatus{}, err } + manager.overrides.Apply(&cfg) if err := cfg.Validate(); err != nil { manager.finishStart(launchCancel) @@ -145,6 +161,7 @@ func (manager *runtimeManager) StartService(ctx context.Context) (api.AdminStatu manager.finishStart(launchCancel) return api.AdminStatus{}, err } + if launchCtx.Err() != nil { manager.finishStart(launchCancel) _ = next.Close() @@ -159,218 +176,252 @@ func (manager *runtimeManager) StartService(ctx context.Context) (api.AdminStatu current.cancelLifecycle() status, startErr := next.admin.StartService(launchCtx) manager.finishStart(launchCancel) + if err := current.Close(); err != nil { - manager.requests.log("service", "WARN", "旧生成服务关闭失败 | 错误="+err.Error()) + manager.requests.log("service", "WARN", "Failed to close previous generation service | error="+err.Error()) } + return status, startErr } -// finishStart 清理本轮生成服务启动取消句柄 +// finishStart cleans up the start cancellation handle for the current cycle. func (manager *runtimeManager) finishStart(cancel context.CancelFunc) { manager.mu.Lock() manager.startCancel = nil manager.mu.Unlock() + cancel() } -// StopService 停止当前生成服务并保持管理监听器运行 +// StopService stops the current generation service while keeping the admin listener active. func (manager *runtimeManager) StopService(ctx context.Context) (api.AdminStatus, error) { manager.mu.RLock() cancel := manager.startCancel current := manager.current manager.mu.RUnlock() + if cancel != nil { cancel() } + return current.admin.StopService(ctx) } -// Close 释放当前生成服务 +// Close releases the current generation service. func (manager *runtimeManager) Close() error { manager.mu.Lock() defer manager.mu.Unlock() + return manager.current.Close() } -// Models 返回当前生成服务模型 +// Models returns the current generation service models. func (manager *runtimeManager) Models(ctx context.Context) ([]aistudio.Model, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.service.Models(ctx) } -// CountTokens 由当前生成服务计数 +// CountTokens counts tokens using the current generation service. func (manager *runtimeManager) CountTokens(ctx context.Context, request aistudio.TokenCountRequest) (aistudio.TokenCount, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.service.CountTokens(ctx, request) } -// Generate 由当前生成服务生成事件流 +// Generate generates an event stream using the current generation service. func (manager *runtimeManager) Generate(ctx context.Context, request aistudio.GenerateRequest) (<-chan aistudio.Event, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.service.Generate(ctx, request) } -// GenerateVideo 由当前生成服务创建视频任务 +// GenerateVideo creates a video generation task using the current generation service. func (manager *runtimeManager) GenerateVideo(ctx context.Context, request aistudio.VideoRequest) (aistudio.VideoOperation, error) { manager.mu.RLock() defer manager.mu.RUnlock() + service, ok := manager.current.service.(aistudio.VideoService) if !ok { - return aistudio.VideoOperation{}, fmt.Errorf("video service 不可用") + return aistudio.VideoOperation{}, fmt.Errorf("video service is unavailable") } + return service.GenerateVideo(ctx, request) } -// GetGenerateVideoOperation 由当前生成服务读取视频任务 +// GetGenerateVideoOperation retrieves a video task using the current generation service. func (manager *runtimeManager) GetGenerateVideoOperation(ctx context.Context, id string) (aistudio.VideoOperation, error) { manager.mu.RLock() defer manager.mu.RUnlock() + service, ok := manager.current.service.(aistudio.VideoService) if !ok { - return aistudio.VideoOperation{}, fmt.Errorf("video service 不可用") + return aistudio.VideoOperation{}, fmt.Errorf("video service is unavailable") } + return service.GetGenerateVideoOperation(ctx, id) } -// DownloadFile 由当前生成服务下载文件 +// DownloadFile downloads a file using the current generation service. func (manager *runtimeManager) DownloadFile(ctx context.Context, id string) (aistudio.MediaStream, error) { manager.mu.RLock() defer manager.mu.RUnlock() + service, ok := manager.current.service.(aistudio.VideoService) if !ok { - return aistudio.MediaStream{}, fmt.Errorf("video service 不可用") + return aistudio.MediaStream{}, fmt.Errorf("video service is unavailable") } + return service.DownloadFile(ctx, id) } -// UploadFile 由当前生成服务上传文件 +// UploadFile uploads a file using the current generation service. func (manager *runtimeManager) UploadFile(ctx context.Context, request aistudio.UploadRequest) (aistudio.FileRef, error) { manager.mu.RLock() defer manager.mu.RUnlock() + service, ok := manager.current.service.(aistudio.FileService) if !ok { - return aistudio.FileRef{}, fmt.Errorf("file service 不可用") + return aistudio.FileRef{}, fmt.Errorf("file service is unavailable") } + return service.UploadFile(ctx, request) } -// FileMetadata 由当前生成服务读取文件元数据 +// FileMetadata reads file metadata using the current generation service. func (manager *runtimeManager) FileMetadata(ctx context.Context, id string) (aistudio.FileMetadata, error) { manager.mu.RLock() defer manager.mu.RUnlock() + service, ok := manager.current.service.(aistudio.FileService) if !ok { - return aistudio.FileMetadata{}, fmt.Errorf("file service 不可用") + return aistudio.FileMetadata{}, fmt.Errorf("file service is unavailable") } + return service.FileMetadata(ctx, id) } -// DeleteFile 由当前生成服务删除文件 +// DeleteFile deletes a file using the current generation service. func (manager *runtimeManager) DeleteFile(ctx context.Context, id string) error { manager.mu.RLock() defer manager.mu.RUnlock() + service, ok := manager.current.service.(aistudio.FileService) if !ok { - return fmt.Errorf("file service 不可用") + return fmt.Errorf("file service is unavailable") } + return service.DeleteFile(ctx, id) } -// OpenBidi 由当前生成服务创建实时会话 +// OpenBidi establishes a bidirectional streaming session using the current generation service. func (manager *runtimeManager) OpenBidi(ctx context.Context, request aistudio.BidiRequest) (*aistudio.BidiSession, error) { manager.mu.RLock() defer manager.mu.RUnlock() + service, ok := manager.current.service.(aistudio.BidiService) if !ok { - return nil, fmt.Errorf("bidi service 不可用") + return nil, fmt.Errorf("bidi service is unavailable") } + return service.OpenBidi(ctx, request) } -// Transcribe 由当前生成服务执行音频转录 +// Transcribe performs audio transcription using the current generation service. func (manager *runtimeManager) Transcribe(ctx context.Context, request aistudio.TranscriptionRequest) (aistudio.TranscriptionResult, error) { manager.mu.RLock() defer manager.mu.RUnlock() + service, ok := manager.current.service.(aistudio.TranscriptionService) if !ok { - return aistudio.TranscriptionResult{}, fmt.Errorf("transcription service 不可用") + return aistudio.TranscriptionResult{}, fmt.Errorf("transcription service is unavailable") } + return service.Transcribe(ctx, request) } -// Status 返回当前生成服务状态 +// Status returns the status of the current generation service. func (manager *runtimeManager) Status(ctx context.Context) (api.AdminStatus, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.Status(ctx) } -// Accounts 返回当前生成服务账户 +// Accounts returns accounts managed by the current generation service. func (manager *runtimeManager) Accounts(ctx context.Context) ([]api.AdminAccount, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.Accounts(ctx) } -// CreateAccount 在当前生成服务创建账户 +// CreateAccount creates an account in the current generation service. func (manager *runtimeManager) CreateAccount(ctx context.Context, input api.AccountCreateInput) (api.AdminAccount, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.CreateAccount(ctx, input) } -// ChromeImportProfiles 返回当前生成服务可导入的 Chrome 账号 +// ChromeImportProfiles returns importable Chrome profiles for the current generation service. func (manager *runtimeManager) ChromeImportProfiles(ctx context.Context) ([]api.ChromeImportProfile, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.ChromeImportProfiles(ctx) } -// ImportChromeAccounts 在当前生成服务批量导入 Chrome 账号 +// ImportChromeAccounts imports Chrome accounts in bulk into the current generation service. func (manager *runtimeManager) ImportChromeAccounts(ctx context.Context, input api.ChromeImportInput) ([]api.AdminAccount, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.ImportChromeAccounts(ctx, input) } -// UpdateAccount 在当前生成服务更新账户 +// UpdateAccount updates an account in the current generation service. func (manager *runtimeManager) UpdateAccount(ctx context.Context, id string, input api.AccountInput) (api.AdminAccount, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.UpdateAccount(ctx, id, input) } -// DeleteAccount 在当前生成服务删除账户 +// DeleteAccount deletes an account from the current generation service. func (manager *runtimeManager) DeleteAccount(ctx context.Context, id string) error { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.DeleteAccount(ctx, id) } -// LoginAccount 在当前生成服务登录账户 +// LoginAccount logs in an account in the current generation service. func (manager *runtimeManager) LoginAccount(ctx context.Context, id string) (api.AdminAccount, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.LoginAccount(ctx, id) } -// VerifyAccount 在当前生成服务验证账户 +// VerifyAccount verifies an account in the current generation service. func (manager *runtimeManager) VerifyAccount(ctx context.Context, id string) (api.AdminAccount, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.VerifyAccount(ctx, id) } -// ClearLogs 清空进程级管理日志 +// ClearLogs clears process-level admin logs. func (manager *runtimeManager) ClearLogs(context.Context) error { manager.requests.clearLogs() return nil } -// RuntimeConfig 返回已保存配置与进程级生效状态 +// RuntimeConfig returns the saved configuration along with process-level active states. func (manager *runtimeManager) RuntimeConfig(ctx context.Context) (api.RuntimeConfig, error) { manager.mu.RLock() value, err := manager.current.admin.RuntimeConfig(ctx) @@ -378,10 +429,11 @@ func (manager *runtimeManager) RuntimeConfig(ctx context.Context) (api.RuntimeCo value = manager.decorateRuntimeConfig(value, manager.current.config) } manager.mu.RUnlock() + return value, err } -// UpdateRuntimeConfig 保存下一次启动生成服务时使用的配置 +// UpdateRuntimeConfig saves configuration to be used on the next generation service launch. func (manager *runtimeManager) UpdateRuntimeConfig(ctx context.Context, value api.RuntimeConfig) (api.RuntimeConfig, error) { manager.mu.RLock() updated, err := manager.current.admin.UpdateRuntimeConfig(ctx, value) @@ -389,79 +441,95 @@ func (manager *runtimeManager) UpdateRuntimeConfig(ctx context.Context, value ap updated = manager.decorateRuntimeConfig(updated, manager.current.config) } manager.mu.RUnlock() + return updated, err } -// Cooldowns 返回当前生成服务冷却状态 +// Cooldowns returns the cooldown states for the current generation service. func (manager *runtimeManager) Cooldowns(ctx context.Context) ([]api.AdminCooldown, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.Cooldowns(ctx) } -// Requests 返回进程级活动请求 +// Requests returns active requests across the process. func (manager *runtimeManager) Requests(ctx context.Context) ([]api.AdminRequest, error) { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.Requests(ctx) } -// CancelRequest 取消进程级活动请求 +// CancelRequest cancels an active request by ID. func (manager *runtimeManager) CancelRequest(ctx context.Context, id string) error { manager.mu.RLock() defer manager.mu.RUnlock() + return manager.current.admin.CancelRequest(ctx, id) } -// Events 创建生成服务实例切换期间持续可用的管理事件流 +// Events creates an admin event stream that persists across generation service restarts. func (manager *runtimeManager) Events(ctx context.Context) (<-chan api.AdminEvent, error) { return openAdminEvents(ctx, manager.lifecycle, manager.requests, manager) } -// RecordAccessStart 记录公开 API 请求开始 +// RecordAccessStart records the start of a public API request. func (manager *runtimeManager) RecordAccessStart(entry api.AccessLog) { manager.mu.RLock() manager.current.admin.RecordAccessStart(entry) manager.mu.RUnlock() } -// RecordAccessLog 记录公开 API 请求结果 +// RecordAccessLog records the result of a public API request. func (manager *runtimeManager) RecordAccessLog(entry api.AccessLog) { manager.mu.RLock() manager.current.admin.RecordAccessLog(entry) manager.mu.RUnlock() } -// decorateRuntimeConfig 标记配置所属的进程级与生成服务生效时机 +// decorateRuntimeConfig marks whether changes require a management or service restart. func (manager *runtimeManager) decorateRuntimeConfig(value api.RuntimeConfig, active config.Config) api.RuntimeConfig { value.ActiveListenAddr = manager.activeManagement.ListenAddr value.ActiveAPIKey = manager.activeManagement.ProxyAPIKey value.ManagementRestartRequired = value.ListenAddr != value.ActiveListenAddr || value.APIKey != value.ActiveAPIKey value.ServiceRestartRequired = !sameDataConfig(value, active, manager.overrides) + return value } -// sameDataConfig 比较已保存配置与当前生成服务配置 +// sameDataConfig compares saved configuration with active generation service configuration. func sameDataConfig(value api.RuntimeConfig, active config.Config, overrides dataConfigOverrides) bool { initTimeout, initErr := time.ParseDuration(value.InitTimeout) requestTimeout, requestErr := time.ParseDuration(value.RequestTimeout) if initErr != nil || requestErr != nil { return false } + saved := config.Config{ - AuthStates: value.AuthStates, Proxy: value.Proxy, - InitTimeout: initTimeout, RequestTimeout: requestTimeout, - WarmWorkerLimit: value.WarmWorkerLimit, MaxActiveWorkers: value.MaxActiveWorkers, + AuthStates: value.AuthStates, + Proxy: value.Proxy, + InitTimeout: initTimeout, + RequestTimeout: requestTimeout, + WarmWorkerLimit: value.WarmWorkerLimit, + MaxActiveWorkers: value.MaxActiveWorkers, WarmStartupConcurrency: value.WarmStartupConcurrency, - PerAccountConcurrency: value.PerAccountConcurrency, TemporaryChat: value.TemporaryChat, - RoutingStrategy: value.RoutingStrategy, + PerAccountConcurrency: value.PerAccountConcurrency, + TemporaryChat: value.TemporaryChat, + RoutingStrategy: value.RoutingStrategy, } + overrides.Apply(&saved) - return saved.AuthStates == active.AuthStates && saved.Proxy == active.Proxy && - saved.InitTimeout == active.InitTimeout && saved.RequestTimeout == active.RequestTimeout && - saved.WarmWorkerLimit == active.WarmWorkerLimit && saved.MaxActiveWorkers == active.MaxActiveWorkers && + + return saved.AuthStates == active.AuthStates && + saved.Proxy == active.Proxy && + saved.InitTimeout == active.InitTimeout && + saved.RequestTimeout == active.RequestTimeout && + saved.WarmWorkerLimit == active.WarmWorkerLimit && + saved.MaxActiveWorkers == active.MaxActiveWorkers && saved.WarmStartupConcurrency == active.WarmStartupConcurrency && - saved.PerAccountConcurrency == active.PerAccountConcurrency && saved.TemporaryChat == active.TemporaryChat && + saved.PerAccountConcurrency == active.PerAccountConcurrency && + saved.TemporaryChat == active.TemporaryChat && saved.RoutingStrategy == active.RoutingStrategy } diff --git a/internal/app/request_logging.go b/internal/app/request_logging.go index 8302b99..5b2c7e4 100644 --- a/internal/app/request_logging.go +++ b/internal/app/request_logging.go @@ -9,75 +9,117 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/api" ) -// requestLogData 投影请求身份、参数与端到端用量 +// requestLogData projects request identity, parameters, and end-to-end usage metrics. func requestLogData(entry api.AccessLog) *api.RequestLog { data := &api.RequestLog{ - ID: entry.RequestID, Model: entry.Model, Method: entry.Method, Path: entry.Path, - Status: entry.Status, DurationMS: float64(entry.Latency) / float64(time.Millisecond), - ToolCalls: entry.ToolCalls, FinishReason: entry.FinishReason, Error: entry.Error, - InputMessages: entry.InputMessages, InputTextChars: entry.InputTextChars, - InputMedia: entry.InputMedia, InputMediaBytes: entry.InputMediaBytes, InputFiles: entry.InputFiles, - FirstEventMS: float64(entry.FirstEvent) / float64(time.Millisecond), UpstreamBytes: entry.UpstreamBytes, + ID: entry.RequestID, + Model: entry.Model, + Method: entry.Method, + Path: entry.Path, + Status: entry.Status, + DurationMS: float64(entry.Latency) / float64(time.Millisecond), + ToolCalls: entry.ToolCalls, + FinishReason: entry.FinishReason, + Error: entry.Error, + InputMessages: entry.InputMessages, + InputTextChars: entry.InputTextChars, + InputMedia: entry.InputMedia, + InputMediaBytes: entry.InputMediaBytes, + InputFiles: entry.InputFiles, + FirstEventMS: float64(entry.FirstEvent) / float64(time.Millisecond), + UpstreamBytes: entry.UpstreamBytes, } + if entry.Generation { data.Parameters = map[string]string{ - "temperature": entry.Temperature, "top_p": entry.TopP, - "thinking": strings.ToLower(entry.Thinking), "max_output_tokens": entry.MaxOutputTokens, + "temperature": entry.Temperature, + "top_p": entry.TopP, + "thinking": strings.ToLower(entry.Thinking), + "max_output_tokens": entry.MaxOutputTokens, } } + if usage := entry.Usage; usage != nil { output := usage.OutputTokens + usage.ReasoningTokens data.Usage = &api.RequestLogUsage{ - InputTokens: usage.InputTokens + usage.ToolTokens, ReasoningTokens: usage.ReasoningTokens, - ReplyTokens: usage.OutputTokens, OutputTokens: output, TotalTokens: usage.TotalTokens, + InputTokens: usage.InputTokens + usage.ToolTokens, + ReasoningTokens: usage.ReasoningTokens, + ReplyTokens: usage.OutputTokens, + OutputTokens: output, + TotalTokens: usage.TotalTokens, } + if entry.Latency > 0 { data.Usage.AverageTokensPerSecond = float64(output) / entry.Latency.Seconds() } } + return data } -// RecordAccessStart 保存可与后续事件关联的请求开始记录 +// RecordAccessStart records the start of a request that can be correlated with subsequent events. func (admin *runtimeAdmin) RecordAccessStart(entry api.AccessLog) { data := requestLogData(entry) data.State = "running" - admin.recordRequestLog(entry.Account, "INFO", "request.started", "请求开始", data) + + admin.recordRequestLog(entry.Account, "INFO", "request.started", "Request started", data) } -// RecordAccessLog 保存请求结果并区分工具调用、限制与失败 +// RecordAccessLog records the outcome of a request, categorizing tool calls, limits, and failures. func (admin *runtimeAdmin) RecordAccessLog(entry api.AccessLog) { data := requestLogData(entry) data.State = "completed" - level, message := "INFO", "请求完成" + + level, message := "INFO", "Request completed" + switch { case entry.Canceled || entry.Status == 499: - data.State, level, message = "cancelled", "WARN", "请求已取消" + data.State, level, message = "cancelled", "WARN", "Request canceled" + case entry.Status >= http.StatusBadRequest || entry.Error != "": - data.State, level, message = "failed", "ERROR", "请求失败" + data.State, level, message = "failed", "ERROR", "Request failed" if data.Error == "" { data.Error = fmt.Sprintf("HTTP %d", entry.Status) } + case entry.FinishReason == "max_tokens" || entry.FinishReason == "max_output_tokens" || entry.FinishReason == "length": - data.State, level, message = "limited", "WARN", "达到输出上限" + data.State, level, message = "limited", "WARN", "Output token limit reached" + case entry.FinishReason != "" && entry.FinishReason != "stop" && entry.FinishReason != "stop_sequence": - data.State, level, message = "blocked", "WARN", "上游终止生成" + data.State, level, message = "blocked", "WARN", "Upstream terminated generation" + case entry.ToolCalls > 0: - data.State, message = "tool_calls", "工具调用完成" + data.State, message = "tool_calls", "Tool calls completed" } + admin.recordRequestLog(entry.Account, level, "request.finished", message, data) } -// recordRequestLog 统一请求日志的账户来源与事件载荷 +// recordRequestLog standardizes the account source and event payload for request logs. func (admin *runtimeAdmin) recordRequestLog(source, level, event, message string, data *api.RequestLog) { if source == "" { source = "request" } - admin.requests.recordLog(api.AdminLog{Source: source, Level: level, Event: event, Message: message, Request: data}) + + admin.requests.recordLog(api.AdminLog{ + Source: source, + Level: level, + Event: event, + Message: message, + Request: data, + }) } -// logRequestProgress 将等待与恢复事件关联到所属请求 +// logRequestProgress associates waiting and recovery events with the corresponding request. func (registry *requestRegistry) logRequestProgress(id, source, level, message string) { - registry.recordLog(api.AdminLog{Source: source, Level: level, Event: "request.progress", Message: message, - Request: &api.RequestLog{ID: id, State: "running"}}) + registry.recordLog(api.AdminLog{ + Source: source, + Level: level, + Event: "request.progress", + Message: message, + Request: &api.RequestLog{ + ID: id, + State: "running", + }, + }) } diff --git a/internal/app/runtime.go b/internal/app/runtime.go index ccb6274..57530c3 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -19,7 +19,7 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/config" ) -// newRuntime 装配单个生成服务实例 +// newRuntime assembles a single generation service instance. func newRuntime( launchCtx context.Context, lifecycle context.Context, @@ -29,33 +29,42 @@ func newRuntime( if err := launchCtx.Err(); err != nil { return nil, nil, nil, err } + startedAt := time.Now() - requests.log("service", "INFO", "运行时装配 | 1/3 | 载入账户") + requests.log("service", "INFO", "Runtime assembly | 1/3 | Loading accounts") + store := aistudio.NewAccountStore(strings.Split(cfg.AuthStates, ",")...) accounts, err := store.Load() if err != nil { return nil, nil, nil, err } + if err := launchCtx.Err(); err != nil { return nil, nil, nil, err } - requests.log("service", "INFO", fmt.Sprintf("运行时装配 | 2/3 | 校验 Camoufox | 账户=%d", len(accounts))) + + requests.log("service", "INFO", fmt.Sprintf("Runtime assembly | 2/3 | Verifying Camoufox | accounts=%d", len(accounts))) + camoufoxPath, err := camoufoxnative.FindExecutable(launchCtx) if err != nil { return nil, nil, nil, err } + login, err := aistudio.NewNativeLoginDriver(camoufoxPath, cfg.RequestTimeout) if err != nil { return nil, nil, nil, err } - requests.log("service", "INFO", "运行时装配 | 3/3 | 创建协议客户端") + requests.log("service", "INFO", "Runtime assembly | 3/3 | Creating protocol clients") + pool := aistudio.NewAccountPool(accounts, cfg.PerAccountConcurrency) pool.SetRoutingStrategy(cfg.RoutingStrategy) + headers, err := newAccountHeaderProvider(accounts, cfg.Proxy) if err != nil { return nil, nil, nil, err } + transport, err := aistudio.NewMakerSuiteHTTPTransport(aistudio.HTTPTransportOptions{ Pool: pool, Signer: aistudio.NewSigner(), Headers: headers, GlobalProxy: cfg.Proxy, }) @@ -63,10 +72,12 @@ func newRuntime( headers.Close() return nil, nil, nil, err } + workers := newAccountWorkerManager( pool, accounts, requests, camoufoxPath, cfg.Proxy, cfg.InitTimeout, cfg.WarmWorkerLimit, cfg.MaxActiveWorkers, cfg.WarmStartupConcurrency, cfg.TemporaryChat, ) + protected, err := aistudio.NewWorkerProtectedTransport(aistudio.WorkerProtectedTransportOptions{ Transport: transport, Workers: workers, SetupTimeout: cfg.InitTimeout, }) @@ -75,13 +86,16 @@ func newRuntime( headers.Close() return nil, nil, nil, errors.Join(err, workers.Close()) } + requestContext, err := aistudio.NewPoolRequestContextProvider(pool) if err != nil { transport.CloseIdleConnections() headers.Close() return nil, nil, nil, errors.Join(err, workers.Close()) } + refresher := newAuthRuntimeRefresher(workers, headers, requests, cfg.Proxy) + client, err := aistudio.NewClient(aistudio.ClientOptions{ Transport: &authRetryTransport{transport: transport, refresher: refresher}, Protected: &authRetryProtectedTransport{transport: protected, refresher: refresher}, @@ -92,28 +106,33 @@ func newRuntime( headers.Close() return nil, nil, nil, errors.Join(err, workers.Close()) } + pooled, err := aistudio.NewPooledService(pool, client) if err != nil { transport.CloseIdleConnections() headers.Close() return nil, nil, nil, errors.Join(err, workers.Close()) } + service := newTrackedService(lifecycle, pooled, pool, requests, workers, cfg.RequestTimeout) admin := newRuntimeAdmin(lifecycle, pool, store, service, requests, login, workers, headers, cfg) + requests.log("service", "INFO", fmt.Sprintf( - "协议运行时就绪 | 账户=%d | 耗时=%s", + "Protocol runtime ready | accounts=%d | duration=%s", len(accounts), time.Since(startedAt).Round(time.Millisecond), )) + closeRuntime := func() error { err := workers.Close() transport.CloseIdleConnections() headers.Close() return err } + return service, admin, closeRuntime, nil } -// accountWorkerManager 管理每账户的长驻 WAA worker +// accountWorkerManager manages resident WAA workers per account. type accountWorkerManager struct { mu sync.RWMutex fillMu sync.Mutex @@ -167,49 +186,55 @@ type accountWorkerUpdate struct { pending bool } -var errAccountWorkerReplaced = errors.New("WAA worker 已更新") -var errAccountWorkerOpening = errors.New("WAA worker 正在启动") -var errAccountWorkerCleanupPending = errors.New("WAA worker 清理未完成") +var errAccountWorkerReplaced = errors.New("WAA worker has been updated") +var errAccountWorkerOpening = errors.New("WAA worker is starting") +var errAccountWorkerCleanupPending = errors.New("WAA worker cleanup pending") const ( workerEvictionTimeout = 100 * time.Millisecond ) -// Prepare 在账户 Worker 有效期间生成 proof +// Prepare generates proof while the account worker is valid. func (preparer *accountWorkerPreparer) Prepare(ctx context.Context, request aistudio.ProtectedRequest) (aistudio.PreparedProtectedRequest, error) { preparer.account.mu.Lock() defer preparer.account.mu.Unlock() + if preparer.account.worker != preparer.worker { return aistudio.PreparedProtectedRequest{}, errAccountWorkerReplaced } if preparer.account.bootstrapModel != preparer.bootstrapModel { return aistudio.PreparedProtectedRequest{}, errAccountWorkerReplaced } + return preparer.worker.Prepare(ctx, request) } -// SendProtected 校验当前账户 Worker 后发送浏览器请求 +// SendProtected validates the current account worker and sends a browser request. func (preparer *accountWorkerPreparer) SendProtected(ctx context.Context, request aistudio.ProtectedRequest) (*aistudio.RPCResponse, error) { preparer.account.mu.Lock() current := preparer.account.worker == preparer.worker && preparer.account.bootstrapModel == preparer.bootstrapModel preparer.account.mu.Unlock() + if !current { return nil, errAccountWorkerReplaced } + return preparer.worker.SendProtected(ctx, request) } -// BrowserStorageState 返回同一有效账户 Worker 的浏览器 Cookie 状态 +// BrowserStorageState returns browser cookie state for the same valid account worker. func (preparer *accountWorkerPreparer) BrowserStorageState(ctx context.Context) (aistudio.StorageState, error) { preparer.account.mu.Lock() defer preparer.account.mu.Unlock() + if preparer.account.worker != preparer.worker || preparer.account.bootstrapModel != preparer.bootstrapModel { return aistudio.StorageState{}, errAccountWorkerReplaced } + return preparer.worker.BrowserStorageState(ctx) } -// accountWorkerInitError 表示单个账户的 WAA worker 初始化失败 +// accountWorkerInitError indicates initialization failure of an account WAA worker. type accountWorkerInitError struct { err error } @@ -222,7 +247,7 @@ func (err *accountWorkerInitError) Unwrap() error { return err.err } -// newAccountWorkerManager 创建账户 worker 配置 +// newAccountWorkerManager creates account worker manager. func newAccountWorkerManager( pool *aistudio.AccountPool, accounts []*aistudio.Account, @@ -236,54 +261,73 @@ func newAccountWorkerManager( temporaryChat bool, ) *accountWorkerManager { lifecycle, cancel := context.WithCancel(context.Background()) + manager := &accountWorkerManager{ - pool: pool, accounts: make(map[string]*accountWorker, len(accounts)), requests: requests, camoufox: camoufoxPath, - globalProxy: globalProxy, initTimeout: initTimeout, - warmTarget: warmTarget, maxActive: maxActive, warmConcurrency: warmConcurrency, temporaryChat: temporaryChat, - openings: make(map[string]chan struct{}), - lifecycle: lifecycle, cancel: cancel, + pool: pool, + accounts: make(map[string]*accountWorker, len(accounts)), + requests: requests, + camoufox: camoufoxPath, + globalProxy: globalProxy, + initTimeout: initTimeout, + warmTarget: warmTarget, + maxActive: maxActive, + warmConcurrency: warmConcurrency, + temporaryChat: temporaryChat, + openings: make(map[string]chan struct{}), + lifecycle: lifecycle, + cancel: cancel, } + for _, account := range accounts { if account == nil { continue } manager.accounts[account.ID] = manager.newAccountWorker(account) } + return manager } -// Add 注册新账户的 WAA worker 配置 +// Add registers WAA worker configuration for a new account. func (manager *accountWorkerManager) Add(account *aistudio.Account) error { if account == nil { - return fmt.Errorf("账户未初始化") + return fmt.Errorf("account not initialized") } + manager.mu.Lock() defer manager.mu.Unlock() + if manager.closed { - return fmt.Errorf("WAA worker manager 已关闭") + return fmt.Errorf("WAA worker manager is closed") } if _, exists := manager.accounts[account.ID]; exists { - return fmt.Errorf("WAA worker 账户已存在: %s", account.ID) + return fmt.Errorf("WAA worker account already exists: %s", account.ID) } + manager.accounts[account.ID] = manager.newAccountWorker(account) return nil } -// Reset 关闭账户当前 WAA worker 并保留重建配置 +// Reset shuts down an account's current WAA worker while retaining config for reconstruction. func (manager *accountWorkerManager) Reset(accountID string) error { manager.mu.RLock() account := manager.accounts[accountID] manager.mu.RUnlock() + if account == nil { - return fmt.Errorf("WAA worker 账户不存在: %s", accountID) + return fmt.Errorf("WAA worker account not found: %s", accountID) } + account.startupMu.Lock() defer account.startupMu.Unlock() + account.mu.Lock() defer account.mu.Unlock() + if account.worker == nil && account.cleanupWorker == nil && account.runtimeLease == nil && account.cleanupLease == nil { return nil } + startedAt := time.Now() pid := 0 if account.worker != nil { @@ -291,51 +335,62 @@ func (manager *accountWorkerManager) Reset(accountID string) error { } else if account.cleanupWorker != nil { pid = account.cleanupWorker.State().PID } - manager.requests.log(account.label, "INFO", fmt.Sprintf("WAA Worker 停止 | PID=%d", pid)) + + manager.requests.log(account.label, "INFO", fmt.Sprintf("Stopping WAA worker | PID=%d", pid)) + err := closeAccountWorker(account) if err != nil { manager.requests.log(account.label, "ERROR", fmt.Sprintf( - "WAA Worker 停止失败 | PID=%d | 耗时=%s | 错误=%s", + "WAA worker stop failed | PID=%d | duration=%s | error=%s", pid, time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(err.Error()), )) return err } + manager.requests.log(account.label, "INFO", fmt.Sprintf( - "WAA Worker 已停止 | PID=%d | 耗时=%s", + "WAA worker stopped | PID=%d | duration=%s", pid, time.Since(startedAt).Round(time.Millisecond), )) + return err } -// WorkerGeneration 返回账户当前 Worker 版本号 +// WorkerGeneration returns the current worker generation for an account. func (manager *accountWorkerManager) WorkerGeneration(accountID string) uint64 { manager.mu.RLock() account := manager.accounts[accountID] manager.mu.RUnlock() + if account == nil { return 0 } + return account.generation.Load() } -// ResetIfGeneration 仅关闭产生当前失败的 Worker +// ResetIfGeneration only shuts down the worker if it matches the failed generation. func (manager *accountWorkerManager) ResetIfGeneration(accountID string, generation uint64) (bool, error) { manager.mu.RLock() account := manager.accounts[accountID] manager.mu.RUnlock() + if account == nil { - return false, fmt.Errorf("WAA worker 账户不存在: %s", accountID) + return false, fmt.Errorf("WAA worker account not found: %s", accountID) } + account.startupMu.Lock() defer account.startupMu.Unlock() + account.mu.Lock() defer account.mu.Unlock() + if account.generation.Load() != generation { return false, nil } if account.worker == nil && account.cleanupWorker == nil && account.runtimeLease == nil && account.cleanupLease == nil { return false, nil } + startedAt := time.Now() pid := 0 if account.worker != nil { @@ -343,40 +398,52 @@ func (manager *accountWorkerManager) ResetIfGeneration(accountID string, generat } else if account.cleanupWorker != nil { pid = account.cleanupWorker.State().PID } - manager.requests.log(account.label, "INFO", fmt.Sprintf("WAA Worker 停止 | PID=%d", pid)) + + manager.requests.log(account.label, "INFO", fmt.Sprintf("Stopping WAA worker | PID=%d", pid)) + if err := closeAccountWorker(account); err != nil { manager.requests.log(account.label, "ERROR", fmt.Sprintf( - "WAA Worker 停止失败 | PID=%d | 耗时=%s | 错误=%s", + "WAA worker stop failed | PID=%d | duration=%s | error=%s", pid, time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(err.Error()), )) return false, err } + manager.requests.log(account.label, "INFO", fmt.Sprintf( - "WAA Worker 已停止 | PID=%d | 耗时=%s", + "WAA worker stopped | PID=%d | duration=%s", pid, time.Since(startedAt).Round(time.Millisecond), )) + return true, nil } -// prepareUpdate 关闭旧 Worker 并保持新配置待发布 +// prepareUpdate shuts down the old worker and keeps the new config pending publication. func (manager *accountWorkerManager) prepareUpdate( account *aistudio.Account, config aistudio.AccountConfig, ) (*accountWorkerUpdate, error) { if account == nil { - return nil, fmt.Errorf("账户未初始化") + return nil, fmt.Errorf("account not initialized") } + manager.mu.RLock() worker := manager.accounts[account.ID] manager.mu.RUnlock() + if worker == nil { - return nil, fmt.Errorf("WAA worker 账户不存在: %s", account.ID) + return nil, fmt.Errorf("WAA worker account not found: %s", account.ID) } + update := &accountWorkerUpdate{ - account: worker, config: manager.workerConfigFor(account, config), label: config.Label, pending: true, + account: worker, + config: manager.workerConfigFor(account, config), + label: config.Label, + pending: true, } + worker.startupMu.Lock() worker.mu.Lock() + if worker.worker != nil || worker.cleanupWorker != nil || worker.runtimeLease != nil || worker.cleanupLease != nil { if err := closeAccountWorker(worker); err != nil { worker.mu.Unlock() @@ -384,33 +451,37 @@ func (manager *accountWorkerManager) prepareUpdate( return nil, err } } + worker.mu.Unlock() return update, nil } -// Commit 发布已准备的 Worker 配置 +// Commit publishes the prepared worker configuration. func (update *accountWorkerUpdate) Commit() { if update == nil || !update.pending { return } + update.account.mu.Lock() update.account.config = update.config update.account.label = update.label update.account.mu.Unlock() + update.pending = false update.account.startupMu.Unlock() } -// Discard 放弃已准备的 Worker 配置 +// Discard drops the prepared worker configuration. func (update *accountWorkerUpdate) Discard() { if update == nil || !update.pending { return } + update.pending = false update.account.startupMu.Unlock() } -// ResetAll 关闭全部账户当前 worker 并保留后续按需重建能力 +// ResetAll shuts down all current account workers while retaining rebuild capability. func (manager *accountWorkerManager) ResetAll() error { manager.mu.RLock() accountIDs := make([]string, 0, len(manager.accounts)) @@ -418,8 +489,10 @@ func (manager *accountWorkerManager) ResetAll() error { accountIDs = append(accountIDs, accountID) } manager.mu.RUnlock() + resetResults := make(chan error, len(accountIDs)) var resets sync.WaitGroup + for _, accountID := range accountIDs { resets.Add(1) go func(accountID string) { @@ -427,38 +500,49 @@ func (manager *accountWorkerManager) ResetAll() error { resetResults <- manager.Reset(accountID) }(accountID) } + resets.Wait() close(resetResults) + var resetErrors []error for resetErr := range resetResults { resetErrors = append(resetErrors, resetErr) } + return errors.Join(resetErrors...) } -// Remove 删除账户的 WAA worker 配置 +// Remove deletes the WAA worker configuration for an account. func (manager *accountWorkerManager) Remove(accountID string) error { manager.rebalanceMu.Lock() manager.mu.Lock() + account := manager.accounts[accountID] if account == nil { manager.mu.Unlock() manager.rebalanceMu.Unlock() - return fmt.Errorf("WAA worker 账户不存在: %s", accountID) + return fmt.Errorf("WAA worker account not found: %s", accountID) } + delete(manager.accounts, accountID) manager.mu.Unlock() manager.rebalanceMu.Unlock() account.startupMu.Lock() defer account.startupMu.Unlock() + account.mu.Lock() defer account.mu.Unlock() + return closeAccountWorker(account) } func (manager *accountWorkerManager) newAccountWorker(account *aistudio.Account) *accountWorker { - return &accountWorker{id: account.ID, label: account.Config.Label, config: manager.workerConfig(account)} + return &accountWorker{ + id: account.ID, + label: account.Config.Label, + config: manager.workerConfig(account), + } } func (manager *accountWorkerManager) workerConfig(account *aistudio.Account) camoufoxnative.Options { @@ -473,18 +557,19 @@ func (manager *accountWorkerManager) workerConfigFor( if proxy == "" { proxy = strings.TrimSpace(manager.globalProxy) } + return camoufoxnative.Options{ ExecutablePath: manager.camoufox, StorageStatePath: account.StoragePath, Locale: config.Locale, Timezone: config.Timezone, Proxy: proxy, - Headless: true, + Headless: false, TemporaryChat: manager.temporaryChat, } } -// WarmAccountIDs 返回当前驻留的健康 WAA worker +// WarmAccountIDs returns currently resident healthy WAA workers. func (manager *accountWorkerManager) WarmAccountIDs() []string { manager.mu.RLock() accounts := make([]*accountWorker, 0, len(manager.accounts)) @@ -492,12 +577,14 @@ func (manager *accountWorkerManager) WarmAccountIDs() []string { accounts = append(accounts, account) } manager.mu.RUnlock() + warm := make([]string, 0, len(accounts)) for _, account := range accounts { if account.warm.Load() { warm = append(warm, account.id) } } + return warm } @@ -506,7 +593,7 @@ type workerOccupancy struct { slots int } -// occupiedWorkers 返回仍持有进程或运行锁的账户与容量槽位 +// occupiedWorkers returns accounts and capacity slots still holding processes or runtime leases. func (manager *accountWorkerManager) occupiedWorkers() workerOccupancy { manager.mu.RLock() accounts := make([]*accountWorker, 0, len(manager.accounts)) @@ -514,9 +601,12 @@ func (manager *accountWorkerManager) occupiedWorkers() workerOccupancy { accounts = append(accounts, account) } manager.mu.RUnlock() + occupied := workerOccupancy{accountIDs: make([]string, 0, len(accounts))} + for _, account := range accounts { account.mu.Lock() + if account.worker != nil || account.cleanupWorker != nil || account.runtimeLease != nil || account.cleanupLease != nil { occupied.accountIDs = append(occupied.accountIDs, account.id) } @@ -532,12 +622,14 @@ func (manager *accountWorkerManager) occupiedWorkers() workerOccupancy { if account.cleanupWorker == nil && account.cleanupLease != nil { occupied.slots++ } + account.mu.Unlock() } + return occupied } -// ReadyWarmAccountIDs 返回可生成 proof 的预热账户 +// ReadyWarmAccountIDs returns prewarmed accounts that can generate proof. func (manager *accountWorkerManager) ReadyWarmAccountIDs() []string { manager.mu.RLock() accounts := make([]*accountWorker, 0, len(manager.accounts)) @@ -545,11 +637,14 @@ func (manager *accountWorkerManager) ReadyWarmAccountIDs() []string { accounts = append(accounts, account) } manager.mu.RUnlock() + warm := make([]string, 0, len(accounts)) + for _, account := range accounts { if !account.warm.Load() { continue } + account.mu.Lock() worker := account.worker matches := worker != nil @@ -558,16 +653,19 @@ func (manager *accountWorkerManager) ReadyWarmAccountIDs() []string { matches = phase == aistudio.WorkerReady || phase == aistudio.WorkerBusy } account.mu.Unlock() + if matches { warm = append(warm, account.id) } } + return warm } func (manager *accountWorkerManager) coldAccounts(accountIDs []string) []string { manager.mu.RLock() defer manager.mu.RUnlock() + cold := make([]string, 0, len(accountIDs)) for _, accountID := range accountIDs { account := manager.accounts[accountID] @@ -575,12 +673,14 @@ func (manager *accountWorkerManager) coldAccounts(accountIDs []string) []string cold = append(cold, accountID) } } + return cold } -// PrewarmTarget 返回当前配置需要预热的账户数 +// PrewarmTarget returns the number of accounts to prewarm based on configuration. func (manager *accountWorkerManager) PrewarmTarget() int { available := 0 + for _, status := range manager.pool.Status() { if !status.Enabled || (status.State != aistudio.AccountReady && status.State != aistudio.AccountBusy) { continue @@ -589,6 +689,7 @@ func (manager *accountWorkerManager) PrewarmTarget() int { available++ } } + return min(manager.warmTarget, available) } @@ -602,6 +703,7 @@ func (manager *accountWorkerManager) classifyBootstrapCandidates( seenWarmBusy := make(map[string]struct{}) seenStandbyReady := make(map[string]struct{}) seenStandbyBusy := make(map[string]struct{}) + appendUnique := func(target *[]string, seen map[string]struct{}, values []string) { for _, value := range values { if _, exists := seen[value]; exists { @@ -611,8 +713,10 @@ func (manager *accountWorkerManager) classifyBootstrapCandidates( *target = append(*target, value) } } + modelIDs := make([]string, 0) seenModels := make(map[string]struct{}) + for _, status := range manager.pool.Status() { models, err := manager.pool.BootstrapModels(status.ID) if err != nil { @@ -626,6 +730,7 @@ func (manager *accountWorkerManager) classifyBootstrapCandidates( modelIDs = append(modelIDs, modelID) } } + var matched bool for _, modelID := range modelIDs { groups, err := manager.pool.ClassifyCandidates(ctx, aistudio.AccountSelection{ @@ -637,39 +742,47 @@ func (manager *accountWorkerManager) classifyBootstrapCandidates( if err != nil { return aistudio.AccountCandidateGroups{}, err } + matched = true combined.Eligible = combined.Eligible || groups.Eligible if combined.EarliestCooldown.IsZero() || !groups.EarliestCooldown.IsZero() && groups.EarliestCooldown.Before(combined.EarliestCooldown) { combined.EarliestCooldown = groups.EarliestCooldown } + appendUnique(&combined.WarmReady, seenWarmReady, groups.WarmReady) appendUnique(&combined.WarmAvailable, seenWarmAvailable, groups.WarmAvailable) appendUnique(&combined.WarmBusy, seenWarmBusy, groups.WarmBusy) appendUnique(&combined.StandbyReady, seenStandbyReady, groups.StandbyReady) appendUnique(&combined.StandbyBusy, seenStandbyBusy, groups.StandbyBusy) } + if !matched { return aistudio.AccountCandidateGroups{}, aistudio.ErrNoEligibleAccount } + combined.StandbyReady = manager.pool.PreferWarmPool(combined.StandbyReady) combined.StandbyBusy = manager.pool.PreferWarmPool(combined.StandbyBusy) + return combined, nil } -// WorkerFailed 返回账户驻留 worker 是否已经失败 +// WorkerFailed returns whether an account's resident worker has failed. func (manager *accountWorkerManager) WorkerFailed(accountID string) bool { manager.mu.RLock() account := manager.accounts[accountID] manager.mu.RUnlock() + if account == nil { return false } + account.mu.Lock() defer account.mu.Unlock() + return account.worker != nil && account.worker.State().Phase == aistudio.WorkerFailed } -// Worker 在活动上限内返回账户的通用 WAA preparer +// Worker returns a general WAA preparer for an account within active limits. func (manager *accountWorkerManager) Worker(ctx context.Context, accountID string, _ string) (aistudio.ProtectedPreparer, error) { return manager.ensureWorker(ctx, accountID, true) } @@ -678,24 +791,29 @@ func (manager *accountWorkerManager) readyWorker(accountID string, bootstrapMode manager.mu.RLock() if manager.closed { manager.mu.RUnlock() - return nil, false, fmt.Errorf("WAA worker manager 已关闭") + return nil, false, fmt.Errorf("WAA worker manager is closed") } account := manager.accounts[accountID] manager.mu.RUnlock() + if account == nil { - return nil, false, fmt.Errorf("账户不存在: %s", accountID) + return nil, false, fmt.Errorf("account not found: %s", accountID) } + account.mu.Lock() defer account.mu.Unlock() + manager.mu.RLock() active := !manager.closed && manager.accounts[accountID] == account manager.mu.RUnlock() + if !active { - return nil, false, fmt.Errorf("账户不存在: %s", accountID) + return nil, false, fmt.Errorf("account not found: %s", accountID) } if accountWorkerCleanupPending(account) { return nil, false, fmt.Errorf("%w: %s", errAccountWorkerCleanupPending, accountID) } + if account.worker != nil { phase := account.worker.State().Phase if (phase == aistudio.WorkerReady || phase == aistudio.WorkerBusy) && account.bootstrapModel == bootstrapModel { @@ -704,6 +822,7 @@ func (manager *accountWorkerManager) readyWorker(accountID string, bootstrapMode }, true, nil } } + return nil, false, nil } @@ -715,28 +834,33 @@ func (manager *accountWorkerManager) startReservedWorker( manager.mu.RLock() if manager.closed { manager.mu.RUnlock() - return nil, fmt.Errorf("WAA worker manager 已关闭") + return nil, fmt.Errorf("WAA worker manager is closed") } account := manager.accounts[accountID] manager.mu.RUnlock() + if account == nil { - return nil, fmt.Errorf("账户不存在: %s", accountID) + return nil, fmt.Errorf("account not found: %s", accountID) } + account.startupMu.Lock() account.mu.Lock() + manager.mu.RLock() active := !manager.closed && manager.accounts[accountID] == account manager.mu.RUnlock() + if !active { account.mu.Unlock() account.startupMu.Unlock() - return nil, fmt.Errorf("账户不存在: %s", accountID) + return nil, fmt.Errorf("account not found: %s", accountID) } if accountWorkerCleanupPending(account) { account.mu.Unlock() account.startupMu.Unlock() return nil, fmt.Errorf("%w: %s", errAccountWorkerCleanupPending, accountID) } + if account.worker != nil { phase := account.worker.State().Phase if (phase == aistudio.WorkerReady || phase == aistudio.WorkerBusy) && account.bootstrapModel == bootstrapModel { @@ -748,34 +872,40 @@ func (manager *accountWorkerManager) startReservedWorker( return preparer, nil } } + startedAt := time.Now() label := account.label options := account.config runtimeLease := account.runtimeLease ownsLease := false account.mu.Unlock() + if runtimeLease == nil { var err error runtimeLease, err = aistudio.AcquireAccountRuntimeLease(account.id) if err != nil { account.startupMu.Unlock() manager.requests.log(label, "ERROR", fmt.Sprintf( - "WAA Worker 启动失败 | 耗时=%s | 错误=%s", + "WAA worker startup failed | duration=%s | error=%s", time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(err.Error()), )) return nil, &accountWorkerInitError{err: err} } ownsLease = true } - manager.requests.log(label, "INFO", "WAA Worker 启动 | 1/7 | 初始化页面 | 页面模型="+bootstrapModel) + + manager.requests.log(label, "INFO", "WAA worker startup | 1/7 | Initializing page | page_model="+bootstrapModel) + initCtx, cancel := context.WithTimeout(ctx, manager.initTimeout) options.Model = bootstrapModel options.StartupProgress = func(stage camoufoxnative.StartupStage) { step, message := workerStartupProgress(stage) - manager.requests.log(label, "INFO", fmt.Sprintf("WAA Worker 启动 | %d/7 | %s", step, message)) + manager.requests.log(label, "INFO", fmt.Sprintf("WAA worker startup | %d/7 | %s", step, message)) } + worker, initErr := aistudio.NewNativeWorker(initCtx, account.id, options) cancel() + if initErr != nil { if ownsLease { _ = runtimeLease.Release() @@ -785,30 +915,40 @@ func (manager *accountWorkerManager) startReservedWorker( return nil, err } manager.requests.log(label, "ERROR", fmt.Sprintf( - "WAA Worker 启动失败 | 页面模型=%s | 耗时=%s | 错误=%s", + "WAA worker startup failed | page_model=%s | duration=%s | error=%s", bootstrapModel, time.Since(startedAt).Round(time.Millisecond), strings.TrimSpace(initErr.Error()), )) return nil, &accountWorkerInitError{err: initErr} } + return &accountWorkerPreparer{ - account: account, worker: worker, bootstrapModel: bootstrapModel, manager: manager, - runtimeLease: runtimeLease, ownsLease: ownsLease, startedAt: startedAt, pending: true, + account: account, + worker: worker, + bootstrapModel: bootstrapModel, + manager: manager, + runtimeLease: runtimeLease, + ownsLease: ownsLease, + startedAt: startedAt, + pending: true, }, nil } -// activateAccountWorker 将已启动 Worker 发布到热池 +// activateAccountWorker publishes a started worker to the warm pool. func activateAccountWorker(preparer *accountWorkerPreparer) error { if !preparer.pending { return nil } + preparer.account.mu.Lock() preparer.manager.mu.RLock() active := !preparer.manager.closed && preparer.manager.accounts[preparer.account.id] == preparer.account preparer.manager.mu.RUnlock() + if !active { preparer.account.mu.Unlock() - return errors.Join(fmt.Errorf("账户不存在: %s", preparer.account.id), discardAccountWorker(preparer)) + return errors.Join(fmt.Errorf("account not found: %s", preparer.account.id), discardAccountWorker(preparer)) } + oldWorker := preparer.account.worker if oldWorker != nil { if closeErr := oldWorker.Close(); closeErr != nil { @@ -816,11 +956,12 @@ func activateAccountWorker(preparer *accountWorkerPreparer) error { preparer.account.mu.Unlock() cleanupErr := discardAccountWorker(preparer) preparer.manager.requests.log(label, "ERROR", fmt.Sprintf( - "WAA Worker 旧实例停止失败 | 错误=%s", strings.TrimSpace(closeErr.Error()), + "Failed to stop previous WAA worker instance | error=%s", strings.TrimSpace(closeErr.Error()), )) return errors.Join(closeErr, cleanupErr) } } + preparer.account.worker = preparer.worker preparer.account.bootstrapModel = preparer.bootstrapModel if preparer.ownsLease { @@ -830,27 +971,32 @@ func activateAccountWorker(preparer *accountWorkerPreparer) error { preparer.account.generation.Add(1) } preparer.account.warm.Store(true) + label := preparer.account.label preparer.account.mu.Unlock() + preparer.manager.requests.log(label, "INFO", fmt.Sprintf( - "WAA Worker 就绪 | 页面模型=%s | PID=%d | 耗时=%s", + "WAA worker ready | page_model=%s | PID=%d | duration=%s", preparer.bootstrapModel, preparer.worker.State().PID, time.Since(preparer.startedAt).Round(time.Millisecond), )) + preparer.pending = false preparer.account.startupMu.Unlock() return nil } -// discardAccountWorker 关闭尚未发布的 Worker +// discardAccountWorker shuts down an uncommitted worker. func discardAccountWorker(preparer *accountWorkerPreparer) error { if !preparer.pending { return nil } + workerErr := preparer.worker.Close() var leaseErr error if workerErr == nil && preparer.ownsLease { leaseErr = preparer.runtimeLease.Release() } + cleanupErr := errors.Join(workerErr, leaseErr) if cleanupErr != nil { preparer.account.mu.Lock() @@ -862,6 +1008,7 @@ func discardAccountWorker(preparer *accountWorkerPreparer) error { } preparer.account.mu.Unlock() } + preparer.pending = false preparer.account.startupMu.Unlock() return cleanupErr @@ -877,19 +1024,19 @@ func accountWorkerCleanupPending(account *accountWorker) bool { func workerStartupProgress(stage camoufoxnative.StartupStage) (int, string) { switch stage { case camoufoxnative.StartupPreparingBrowser: - return 2, "准备浏览器配置" + return 2, "Preparing browser configuration" case camoufoxnative.StartupLaunchingBrowser: - return 3, "启动 Camoufox" + return 3, "Launching Camoufox" case camoufoxnative.StartupConnectingBiDi: - return 4, "连接 WebDriver BiDi" + return 4, "Connecting to WebDriver BiDi" case camoufoxnative.StartupLoadingAIStudio: - return 5, "载入 AI Studio" + return 5, "Loading AI Studio" case camoufoxnative.StartupLocatingWAA: - return 6, "定位 WAA 服务" + return 6, "Locating WAA service" case camoufoxnative.StartupBootstrappingWAA: - return 7, "执行 WAA Bootstrap" + return 7, "Executing WAA bootstrap" } - panic(fmt.Sprintf("未知 WAA Worker 启动阶段: %s", stage)) + panic(fmt.Sprintf("unknown WAA worker startup stage: %s", stage)) } func (manager *accountWorkerManager) idleWarmVictim(excludeID string) string { @@ -897,44 +1044,55 @@ func (manager *accountWorkerManager) idleWarmVictim(excludeID string) string { for _, status := range manager.pool.Status() { statusByID[status.ID] = status } + warm := manager.WarmAccountIDs() var selected string var selectedUsed time.Time + for _, accountID := range warm { if accountID == excludeID { continue } + manager.mu.RLock() account := manager.accounts[accountID] manager.mu.RUnlock() + if account == nil { continue } + account.mu.Lock() cleanupPending := accountWorkerCleanupPending(account) account.mu.Unlock() + if cleanupPending { continue } + status := statusByID[accountID] if status.State == aistudio.AccountBusy { continue } + lastUsed := time.Time{} if status.LastUsed != nil { lastUsed = *status.LastUsed } + if selected == "" || lastUsed.Before(selectedUsed) { selected = accountID selectedUsed = lastUsed } } + return selected } func (manager *accountWorkerManager) evictIdleWorker(ctx context.Context, accountID string) (bool, error) { evictionCtx, cancel := context.WithTimeout(ctx, workerEvictionTimeout) defer cancel() + lease, err := manager.pool.AcquireAccount(evictionCtx, accountID) if err != nil { if ctx.Err() == nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { @@ -942,11 +1100,14 @@ func (manager *accountWorkerManager) evictIdleWorker(ctx context.Context, accoun } return false, err } + if err := ctx.Err(); err != nil { return false, errors.Join(err, lease.Release()) } + resetErr := manager.Reset(accountID) releaseErr := lease.Release() + return true, errors.Join(resetErr, releaseErr) } @@ -959,6 +1120,7 @@ func (manager *accountWorkerManager) ensureWorker( if err != nil { return nil, err } + workerCtx, cancel := context.WithCancel(ctx) stopLifecycle := context.AfterFunc(manager.lifecycle, cancel) defer func() { @@ -966,10 +1128,12 @@ func (manager *accountWorkerManager) ensureWorker( cancel() }() ctx = workerCtx + for { if err := ctx.Err(); err != nil { return nil, err } + manager.rebalanceMu.Lock() if opening := manager.openings[accountID]; opening != nil { manager.rebalanceMu.Unlock() @@ -983,6 +1147,7 @@ func (manager *accountWorkerManager) ensureWorker( continue } } + preparer, ready, err := manager.readyWorker(accountID, bootstrapModel) if err != nil { manager.rebalanceMu.Unlock() @@ -992,6 +1157,7 @@ func (manager *accountWorkerManager) ensureWorker( manager.rebalanceMu.Unlock() return preparer, nil } + occupancy := manager.occupiedWorkers() occupied := make(map[string]struct{}, len(occupancy.accountIDs)+len(manager.openings)) for _, occupiedAccountID := range occupancy.accountIDs { @@ -1001,12 +1167,15 @@ func (manager *accountWorkerManager) ensureWorker( occupied[openingAccountID] = struct{}{} occupancy.slots++ } + _, replacing := occupied[accountID] if replacing || occupancy.slots < manager.maxActive { opening := make(chan struct{}) manager.openings[accountID] = opening manager.rebalanceMu.Unlock() + preparer, err := manager.startReservedWorker(ctx, accountID, bootstrapModel) + manager.rebalanceMu.Lock() if err == nil { if ctxErr := ctx.Err(); ctxErr != nil { @@ -1017,15 +1186,18 @@ func (manager *accountWorkerManager) ensureWorker( } delete(manager.openings, accountID) close(opening) + warmCount := len(manager.WarmAccountIDs()) manager.rebalanceMu.Unlock() + if err == nil && warmCount > manager.warmTarget { manager.requests.log("service", "INFO", fmt.Sprintf( - "WAA Worker 按需扩容 | Worker=%d/%d", warmCount, manager.maxActive, + "WAA worker scaled up on demand | workers=%d/%d", warmCount, manager.maxActive, )) } return preparer, err } + if len(manager.openings) > 0 { manager.rebalanceMu.Unlock() if err := waitWarmCandidate(ctx, 100*time.Millisecond); err != nil { @@ -1033,6 +1205,7 @@ func (manager *accountWorkerManager) ensureWorker( } continue } + victim := manager.idleWarmVictim(accountID) if victim == "" { manager.rebalanceMu.Unlock() @@ -1041,9 +1214,11 @@ func (manager *accountWorkerManager) ensureWorker( } continue } + opening := make(chan struct{}) manager.openings[accountID] = opening manager.rebalanceMu.Unlock() + pending, startErr := manager.startReservedWorker(ctx, accountID, bootstrapModel) if startErr != nil { manager.rebalanceMu.Lock() @@ -1052,6 +1227,7 @@ func (manager *accountWorkerManager) ensureWorker( manager.rebalanceMu.Unlock() return nil, startErr } + for { manager.rebalanceMu.Lock() if ctxErr := ctx.Err(); ctxErr != nil { @@ -1061,6 +1237,7 @@ func (manager *accountWorkerManager) ensureWorker( manager.rebalanceMu.Unlock() return nil, errors.Join(ctxErr, discardErr) } + evicted, evictionErr := manager.evictIdleWorker(ctx, victim) if evictionErr == nil && evicted { activationErr := activateAccountWorker(pending) @@ -1068,16 +1245,18 @@ func (manager *accountWorkerManager) ensureWorker( close(opening) warmCount := len(manager.WarmAccountIDs()) manager.rebalanceMu.Unlock() + if activationErr != nil { return nil, activationErr } if warmCount > manager.warmTarget { manager.requests.log("service", "INFO", fmt.Sprintf( - "WAA Worker 按需替换 | Worker=%d/%d", warmCount, manager.maxActive, + "WAA worker replaced on demand | workers=%d/%d", warmCount, manager.maxActive, )) } return pending, nil } + if evictionErr != nil || ctx.Err() != nil { discardErr := discardAccountWorker(pending) delete(manager.openings, accountID) @@ -1085,8 +1264,10 @@ func (manager *accountWorkerManager) ensureWorker( manager.rebalanceMu.Unlock() return nil, errors.Join(evictionErr, ctx.Err(), discardErr) } + victim = manager.idleWarmVictim(accountID) manager.rebalanceMu.Unlock() + if victim == "" { if err := waitWarmCandidate(ctx, 100*time.Millisecond); err != nil { manager.rebalanceMu.Lock() @@ -1109,8 +1290,10 @@ func (manager *accountWorkerManager) promote(ctx context.Context, accountID stri func (manager *accountWorkerManager) withoutOpening(accountIDs []string) ([]string, bool) { manager.rebalanceMu.Lock() defer manager.rebalanceMu.Unlock() + result := make([]string, 0, len(accountIDs)) pending := false + for _, accountID := range accountIDs { if manager.openings[accountID] != nil { pending = true @@ -1118,35 +1301,42 @@ func (manager *accountWorkerManager) withoutOpening(accountIDs []string) ([]stri } result = append(result, accountID) } + return result, pending } -// StartPrewarm 启动有界预热并在当前可用账户完成后返回 +// StartPrewarm launches bounded prewarming and returns once ready accounts are prepared. func (manager *accountWorkerManager) StartPrewarm(ctx context.Context) <-chan error { first := make(chan error, 1) + manager.mu.RLock() closed := manager.closed manager.mu.RUnlock() + if closed { - first <- fmt.Errorf("WAA worker manager 已关闭") + first <- fmt.Errorf("WAA worker manager is closed") close(first) return first } + if !manager.fillMu.TryLock() { if len(manager.WarmAccountIDs()) > 0 { first <- nil } else { - first <- fmt.Errorf("WAA 预热已在进行") + first <- fmt.Errorf("WAA prewarming already in progress") } close(first) return first } + fillContext, cancel := context.WithCancel(ctx) stop := context.AfterFunc(manager.lifecycle, cancel) + go manager.fillWarm(fillContext, first, func() { stop() cancel() }) + return first } @@ -1155,6 +1345,7 @@ func (manager *accountWorkerManager) fillWarm(ctx context.Context, first chan<- defer cleanup() defer manager.fillMu.Unlock() defer close(first) + notified := false notify := func(err error) { if notified { @@ -1163,8 +1354,10 @@ func (manager *accountWorkerManager) fillWarm(ctx context.Context, first chan<- notified = true first <- err } + var failures []error failedAccounts := make(map[string]struct{}) + for { if err := ctx.Err(); err != nil { if !notified { @@ -1172,17 +1365,20 @@ func (manager *accountWorkerManager) fillWarm(ctx context.Context, first chan<- } return } + warm := manager.WarmAccountIDs() if len(warm) >= manager.warmTarget { manager.requests.log("service", "INFO", fmt.Sprintf( - "WAA Worker 预热完成 | Worker=%d/%d | 耗时=%s", + "WAA worker prewarming completed | workers=%d/%d | duration=%s", len(warm), manager.PrewarmTarget(), time.Since(startedAt).Round(time.Millisecond), )) notify(nil) return } + remaining := manager.warmTarget - len(warm) batchSize := min(manager.warmConcurrency, remaining) + groups, err := manager.classifyBootstrapCandidates(ctx, warm) if err != nil { failures = append(failures, err) @@ -1190,14 +1386,17 @@ func (manager *accountWorkerManager) fillWarm(ctx context.Context, first chan<- groups.StandbyReady = excludeAccountIDs(groups.StandbyReady, failedAccounts) groups.StandbyBusy = excludeAccountIDs(groups.StandbyBusy, failedAccounts) } + pendingBusy := false tasks := make([]warmTask, 0, batchSize) + if err == nil { pendingBusy = len(groups.StandbyBusy) > 0 for _, accountID := range groups.StandbyReady[:min(batchSize, len(groups.StandbyReady))] { tasks = append(tasks, warmTask{accountID: accountID}) } } + if len(tasks) == 0 { if pendingBusy { if err := waitWarmCandidate(ctx, 100*time.Millisecond); err != nil && !notified { @@ -1205,21 +1404,24 @@ func (manager *accountWorkerManager) fillWarm(ctx context.Context, first chan<- } continue } + warm = manager.WarmAccountIDs() if len(warm) > 0 { manager.requests.log("service", "INFO", fmt.Sprintf( - "WAA Worker 预热完成 | Worker=%d/%d | 失败=%d | 耗时=%s", + "WAA worker prewarming completed | workers=%d/%d | failures=%d | duration=%s", len(warm), manager.PrewarmTarget(), len(failures), time.Since(startedAt).Round(time.Millisecond), )) notify(nil) return } + if len(failures) == 0 { failures = append(failures, aistudio.ErrNoEligibleAccount) } notify(errors.Join(failures...)) return } + results := make(chan warmResult, len(tasks)) for _, task := range tasks { go func(task warmTask) { @@ -1227,6 +1429,7 @@ func (manager *accountWorkerManager) fillWarm(ctx context.Context, first chan<- results <- warmResult{accountID: task.accountID, err: err} }(task) } + for range len(tasks) { result := <-results if result.err == nil { @@ -1236,7 +1439,7 @@ func (manager *accountWorkerManager) fillWarm(ctx context.Context, first chan<- if ctx.Err() != nil { continue } - failure := fmt.Errorf("预热账户 %s: %w", result.accountID, result.err) + failure := fmt.Errorf("prewarm account %s: %w", result.accountID, result.err) failures = append(failures, failure) failedAccounts[result.accountID] = struct{}{} } @@ -1267,7 +1470,7 @@ func (manager *accountWorkerManager) waitPrewarm() { manager.fillMu.Unlock() } -// Close 关闭全部账户 worker +// Close shuts down all account workers. func (manager *accountWorkerManager) Close() error { manager.mu.Lock() if manager.closed { @@ -1276,38 +1479,48 @@ func (manager *accountWorkerManager) Close() error { } manager.closed = true manager.cancel() + accounts := make([]*accountWorker, 0, len(manager.accounts)) for _, account := range manager.accounts { accounts = append(accounts, account) } manager.mu.Unlock() + manager.waitPrewarm() + closeResults := make(chan error, len(accounts)) var closes sync.WaitGroup + for _, account := range accounts { closes.Add(1) go func(account *accountWorker) { defer closes.Done() account.startupMu.Lock() defer account.startupMu.Unlock() + account.mu.Lock() defer account.mu.Unlock() + if account.worker != nil || account.cleanupWorker != nil || account.runtimeLease != nil || account.cleanupLease != nil { closeResults <- closeAccountWorker(account) } }(account) } + closes.Wait() close(closeResults) + var closeErrors []error for closeErr := range closeResults { closeErrors = append(closeErrors, closeErr) } + return errors.Join(closeErrors...) } func closeAccountWorker(account *accountWorker) error { var closeErrors []error + if account.worker != nil { if closeErr := account.worker.Close(); closeErr != nil { closeErrors = append(closeErrors, closeErr) @@ -1316,6 +1529,7 @@ func closeAccountWorker(account *accountWorker) error { account.generation.Add(1) } } + if account.cleanupWorker != nil { if closeErr := account.cleanupWorker.Close(); closeErr != nil { closeErrors = append(closeErrors, closeErr) @@ -1323,6 +1537,7 @@ func closeAccountWorker(account *accountWorker) error { account.cleanupWorker = nil } } + if account.cleanupWorker == nil && account.cleanupLease != nil { if releaseErr := account.cleanupLease.Release(); releaseErr != nil { closeErrors = append(closeErrors, releaseErr) @@ -1330,6 +1545,7 @@ func closeAccountWorker(account *accountWorker) error { account.cleanupLease = nil } } + if account.worker == nil && account.cleanupWorker == nil && account.cleanupLease == nil && account.runtimeLease != nil { if releaseErr := account.runtimeLease.Release(); releaseErr != nil { closeErrors = append(closeErrors, releaseErr) @@ -1337,9 +1553,11 @@ func closeAccountWorker(account *accountWorker) error { account.runtimeLease = nil } } + if account.worker == nil && account.cleanupWorker == nil && account.runtimeLease == nil && account.cleanupLease == nil { account.warm.Store(false) } + return errors.Join(closeErrors...) } @@ -1362,11 +1580,13 @@ type accountHeaderUpdate struct { pending bool } -// newAccountHeaderProvider 创建每账户固定出口的公开头提供器 +// newAccountHeaderProvider creates a public header provider with fixed egress per account. func newAccountHeaderProvider(accounts []*aistudio.Account, globalProxy string) (*accountHeaderProvider, error) { provider := &accountHeaderProvider{ - accounts: make(map[string]*accountHeaderState, len(accounts)), globalProxy: globalProxy, + accounts: make(map[string]*accountHeaderState, len(accounts)), + globalProxy: globalProxy, } + for _, account := range accounts { if account == nil { continue @@ -1376,78 +1596,94 @@ func newAccountHeaderProvider(accounts []*aistudio.Account, globalProxy string) return nil, err } } + return provider, nil } -// Add 注册新账户的固定出口 +// Add registers a fixed egress client for a new account. func (provider *accountHeaderProvider) Add(account *aistudio.Account) error { if account == nil { - return fmt.Errorf("账户未初始化") + return fmt.Errorf("account not initialized") } + client, err := aistudio.NewProxyHTTPClient(account.EffectiveProxy(provider.globalProxy)) if err != nil { - return fmt.Errorf("创建账户 %s 的固定出口: %w", account.ID, err) + return fmt.Errorf("create fixed egress for account %s: %w", account.ID, err) } + provider.mu.Lock() defer provider.mu.Unlock() + if _, exists := provider.accounts[account.ID]; exists { client.CloseIdleConnections() - return fmt.Errorf("账户固定出口已存在: %s", account.ID) + return fmt.Errorf("fixed egress already exists for account: %s", account.ID) } + provider.accounts[account.ID] = &accountHeaderState{client: client} return nil } -// prepareUpdate 创建待发布的账户固定出口 +// prepareUpdate creates a pending fixed egress update for an account. func (provider *accountHeaderProvider) prepareUpdate( account *aistudio.Account, config aistudio.AccountConfig, ) (*accountHeaderUpdate, error) { if account == nil { - return nil, fmt.Errorf("账户未初始化") + return nil, fmt.Errorf("account not initialized") } + provider.mu.RLock() current := provider.accounts[account.ID] provider.mu.RUnlock() + if current == nil { - return nil, fmt.Errorf("账户固定出口不存在: %s", account.ID) + return nil, fmt.Errorf("fixed egress does not exist for account: %s", account.ID) } + proxy := strings.TrimSpace(config.Proxy) if proxy == "" { proxy = strings.TrimSpace(provider.globalProxy) } + client, err := aistudio.NewProxyHTTPClient(proxy) if err != nil { - return nil, fmt.Errorf("创建账户 %s 的固定出口: %w", account.ID, err) + return nil, fmt.Errorf("create fixed egress for account %s: %w", account.ID, err) } + return &accountHeaderUpdate{ - provider: provider, accountID: account.ID, state: &accountHeaderState{client: client}, pending: true, + provider: provider, + accountID: account.ID, + state: &accountHeaderState{client: client}, + pending: true, }, nil } -// Commit 发布已准备的账户固定出口 +// Commit publishes the prepared fixed egress update. func (update *accountHeaderUpdate) Commit() { if update == nil || !update.pending { return } + update.provider.mu.Lock() current := update.provider.accounts[update.accountID] update.provider.accounts[update.accountID] = update.state update.provider.mu.Unlock() + update.pending = false current.client.CloseIdleConnections() } -// Discard 关闭未发布的账户固定出口 +// Discard closes an uncommitted fixed egress client. func (update *accountHeaderUpdate) Discard() { if update == nil || !update.pending { return } + update.pending = false update.state.client.CloseIdleConnections() } -// Remove 删除账户的固定出口 +// Remove deletes an account's fixed egress client. func (provider *accountHeaderProvider) Remove(accountID string) error { provider.mu.Lock() account := provider.accounts[accountID] @@ -1455,48 +1691,57 @@ func (provider *accountHeaderProvider) Remove(accountID string) error { delete(provider.accounts, accountID) } provider.mu.Unlock() + if account == nil { - return fmt.Errorf("账户固定出口不存在: %s", accountID) + return fmt.Errorf("fixed egress does not exist for account: %s", accountID) } + account.client.CloseIdleConnections() return nil } -// Close 关闭全部账户固定出口 +// Close closes all account fixed egress clients. func (provider *accountHeaderProvider) Close() { provider.mu.Lock() accounts := provider.accounts provider.accounts = nil provider.mu.Unlock() + for _, account := range accounts { account.client.CloseIdleConnections() } } -// Invalidate 清除账户公共头并让下一次请求重新发现 +// Invalidate clears an account's common headers, forcing rediscover on the next request. func (provider *accountHeaderProvider) Invalidate(accountID string) error { provider.mu.RLock() account := provider.accounts[accountID] provider.mu.RUnlock() + if account == nil { - return fmt.Errorf("账户固定出口不存在: %s", accountID) + return fmt.Errorf("fixed egress does not exist for account: %s", accountID) } + account.mu.Lock() account.headers = nil account.mu.Unlock() + return nil } -// ProtocolHeaders 返回账户当前使用的公开协议头 +// ProtocolHeaders returns public protocol headers currently used by the account. func (provider *accountHeaderProvider) ProtocolHeaders(ctx context.Context, accountID string) (http.Header, error) { provider.mu.RLock() account := provider.accounts[accountID] provider.mu.RUnlock() + if account == nil { - return nil, fmt.Errorf("账户不存在: %s", accountID) + return nil, fmt.Errorf("account not found: %s", accountID) } + account.mu.Lock() defer account.mu.Unlock() + if len(account.headers) == 0 { headers, err := aistudio.DiscoverPublicHeaders(ctx, account.client) if err != nil { @@ -1504,10 +1749,11 @@ func (provider *accountHeaderProvider) ProtocolHeaders(ctx context.Context, acco } account.headers = headers.Clone() } + return account.headers.Clone(), nil } -// trackedService 跟踪生成请求及其唯一账户租约 +// trackedService tracks generation requests and their exclusive account leases. type trackedService struct { lifecycle context.Context service aistudio.Service @@ -1539,7 +1785,7 @@ type modelCatalogService interface { CachedModels() []aistudio.Model } -// newTrackedService 创建带超时和生命周期的协议服务 +// newTrackedService creates a protocol service with timeout and lifecycle tracking. func newTrackedService( lifecycle context.Context, service aistudio.Service, @@ -1550,14 +1796,20 @@ func newTrackedService( ) *trackedService { catalog := service.(modelCatalogService) return &trackedService{ - lifecycle: lifecycle, service: service, catalog: catalog, pool: pool, requests: requests, workers: workers, - timeout: timeout, modelRetries: make(map[string]struct{}), + lifecycle: lifecycle, + service: service, + catalog: catalog, + pool: pool, + requests: requests, + workers: workers, + timeout: timeout, + modelRetries: make(map[string]struct{}), } } type serviceStoppedError struct{} -var errServiceTransitioning = errors.New("生成服务正在切换状态") +var errServiceTransitioning = errors.New("generation service is transitioning states") const ( serviceStopped int32 = iota @@ -1569,7 +1821,7 @@ const ( ) func (*serviceStoppedError) Error() string { - return "AIStudio2API 服务已停止" + return "AIStudio2API service is stopped" } func (*serviceStoppedError) HTTPStatus() int { @@ -1580,12 +1832,12 @@ func (*serviceStoppedError) ErrorCode() string { return "service_stopped" } -// Running 返回公开生成服务是否接受请求 +// Running returns whether the public generation service accepts requests. func (service *trackedService) Running() bool { return service.state.Load() == serviceRunning } -// State 返回生成服务生命周期状态 +// State returns the lifecycle state of the generation service. func (service *trackedService) State() string { switch service.state.Load() { case serviceLaunching: @@ -1597,17 +1849,19 @@ func (service *trackedService) State() string { } } -// Start 刷新模型并创建本次公开生成服务 +// Start refreshes models and launches the public generation service. func (service *trackedService) Start(ctx context.Context, launching func()) ([]aistudio.Model, bool, error) { service.lifecycleMu.Lock() if service.state.Load() == serviceRunning { service.lifecycleMu.Unlock() return service.modelSnapshot(), false, nil } + if service.state.Load() == serviceLaunching || service.transitionDone != nil { service.lifecycleMu.Unlock() return service.modelSnapshot(), false, errServiceTransitioning } + dataContext, dataCancel := context.WithCancel(service.lifecycle) transitionDone := make(chan struct{}) service.dataContext = dataContext @@ -1617,21 +1871,25 @@ func (service *trackedService) Start(ctx context.Context, launching func()) ([]a service.transitionTimedOut = false service.state.Store(serviceLaunching) service.lifecycleMu.Unlock() + stopCaller := context.AfterFunc(ctx, dataCancel) service.replaceModelSnapshot(service.catalog.CachedModels()) if launching != nil { launching() } + models := service.modelSnapshot() service.requests.log("service", "INFO", fmt.Sprintf( - "生成服务启动 | 1/2 | 准备模型目录 | 缓存=%d", len(models), + "Generation service startup | 1/2 | Preparing model catalog | cached=%d", len(models), )) + catalogReady, catalogDone := service.startModelCatalogRefresh(dataContext) service.lifecycleMu.Lock() if service.dataContext == dataContext { service.modelRefreshDone = catalogDone } service.lifecycleMu.Unlock() + if len(models) == 0 { select { case <-dataContext.Done(): @@ -1643,16 +1901,19 @@ func (service *trackedService) Start(ctx context.Context, launching func()) ([]a return nil, false, service.finishLaunch(transitionDone, dataCancel, catalogDone, err) } } + models = service.modelSnapshot() if len(models) == 0 { stopCaller() return nil, false, service.finishLaunch(transitionDone, dataCancel, catalogDone, aistudio.ErrNoEligibleAccount) } } + service.requests.log("service", "INFO", fmt.Sprintf( - "生成服务启动 | 2/2 | 预热 WAA Worker | 模型=%d | 目标=%d", + "Generation service startup | 2/2 | Prewarming WAA workers | models=%d | target=%d", len(models), service.workers.PrewarmTarget(), )) + firstWarm := service.workers.StartPrewarm(dataContext) select { case <-dataContext.Done(): @@ -1660,42 +1921,49 @@ func (service *trackedService) Start(ctx context.Context, launching func()) ([]a return nil, false, service.finishLaunch(transitionDone, dataCancel, catalogDone, dataContext.Err()) case warmErr, ok := <-firstWarm: if !ok { - warmErr = fmt.Errorf("WAA 预热未返回就绪账户") + warmErr = fmt.Errorf("WAA prewarming returned no ready accounts") } if warmErr != nil { stopCaller() return nil, false, service.finishLaunch(transitionDone, dataCancel, catalogDone, warmErr) } } + if !stopCaller() { return nil, false, service.finishLaunch(transitionDone, dataCancel, catalogDone, ctx.Err()) } + models, err := service.finishModelLaunch(dataContext, transitionDone) if err != nil { return models, false, service.finishLaunch(transitionDone, dataCancel, catalogDone, err) } + return models, true, nil } -// finishModelLaunch 刷新启动期变化并原子启用生成服务 +// finishModelLaunch applies launch-time revisions and atomically enables the generation service. func (service *trackedService) finishModelLaunch( dataContext context.Context, transitionDone chan struct{}, ) ([]aistudio.Model, error) { service.modelSyncMu.Lock() defer service.modelSyncMu.Unlock() + for { modelRevision := service.currentModelRevision() models := service.catalog.CachedModels() service.replaceModelSnapshot(models) + if len(models) == 0 { return models, aistudio.ErrNoEligibleAccount } + service.modelChangeMu.Lock() if service.modelRevision != modelRevision { service.modelChangeMu.Unlock() continue } + service.lifecycleMu.Lock() if service.transitionDone != transitionDone || service.state.Load() != serviceLaunching || dataContext.Err() != nil { launchErr := dataContext.Err() @@ -1706,12 +1974,15 @@ func (service *trackedService) finishModelLaunch( service.modelChangeMu.Unlock() return service.modelSnapshot(), launchErr } + service.modelApplied = modelRevision service.state.Store(serviceRunning) service.transitionDone = nil close(transitionDone) + service.lifecycleMu.Unlock() service.modelChangeMu.Unlock() + return service.modelSnapshot(), nil } } @@ -1723,9 +1994,10 @@ func (service *trackedService) finishLaunch( launchErr error, ) error { dataCancel() - refreshErr := waitServiceTransition(modelRefreshDone, modelCatalogShutdownTimeout, "模型目录刷新停止") + refreshErr := waitServiceTransition(modelRefreshDone, modelCatalogShutdownTimeout, "model catalog refresh shutdown") service.workers.waitPrewarm() resetErr := service.workers.ResetAll() + service.lifecycleMu.Lock() if service.transitionDone == transitionDone { service.state.Store(serviceStopped) @@ -1737,6 +2009,7 @@ func (service *trackedService) finishLaunch( close(transitionDone) } service.lifecycleMu.Unlock() + return errors.Join(launchErr, refreshErr, resetErr) } @@ -1744,13 +2017,15 @@ func waitServiceTransition(done <-chan struct{}, timeout time.Duration, operatio if done == nil { return nil } + timer := time.NewTimer(timeout) defer timer.Stop() + select { case <-done: return nil case <-timer.C: - return fmt.Errorf("%s超时", operation) + return fmt.Errorf("%s timed out", operation) } } @@ -1767,20 +2042,22 @@ func (service *trackedService) markModelAccessVerifiedAsync( ) if err != nil { service.requests.log(accountLabel, "ERROR", fmt.Sprintf( - "模型资格保存失败 | 模型=%s | 错误=%s", modelID, strings.TrimSpace(err.Error()), + "Failed to save model qualification | model=%s | error=%s", modelID, strings.TrimSpace(err.Error()), )) return } + if changed { service.publishModelAccess() } }() } -// Stop 停止公开生成服务并释放活动 worker +// Stop halts the public generation service and releases active workers. func (service *trackedService) Stop() (bool, error) { service.lifecycleMu.Lock() state := service.state.Load() + if state == serviceStopped { done := service.transitionDone if done == nil && service.transitionTimedOut { @@ -1791,7 +2068,8 @@ func (service *trackedService) Stop() (bool, error) { return false, cleanupErr } service.lifecycleMu.Unlock() - transitionErr := waitServiceTransition(done, serviceTransitionShutdownTimeout, "生成服务切换停止") + + transitionErr := waitServiceTransition(done, serviceTransitionShutdownTimeout, "generation service transition shutdown") if done != nil { service.lifecycleMu.Lock() cleanupErr := service.transitionErr @@ -1804,6 +2082,7 @@ func (service *trackedService) Stop() (bool, error) { service.lifecycleMu.Unlock() return false, errors.Join(transitionErr, cleanupErr) } + resetErr := service.workers.ResetAll() service.lifecycleMu.Lock() service.transitionErr = resetErr @@ -1811,14 +2090,17 @@ func (service *trackedService) Stop() (bool, error) { service.lifecycleMu.Unlock() return false, resetErr } + dataCancel := service.dataCancel if state == serviceLaunching { done := service.transitionDone service.state.Store(serviceStopped) service.lifecycleMu.Unlock() + dataCancel() - transitionErr := waitServiceTransition(done, serviceTransitionShutdownTimeout, "生成服务启动停止") + transitionErr := waitServiceTransition(done, serviceTransitionShutdownTimeout, "generation service startup shutdown") service.requests.cancelAll() + service.lifecycleMu.Lock() cleanupErr := service.transitionErr if transitionErr == nil { @@ -1828,16 +2110,21 @@ func (service *trackedService) Stop() (bool, error) { service.transitionTimedOut = true } service.lifecycleMu.Unlock() + return true, errors.Join(transitionErr, cleanupErr) } + transitionDone := make(chan struct{}) service.transitionDone = transitionDone service.state.Store(serviceStopped) modelRefreshDone := service.modelRefreshDone service.lifecycleMu.Unlock() + dataCancel() go service.finishStop(transitionDone, modelRefreshDone) - transitionErr := waitServiceTransition(transitionDone, serviceTransitionShutdownTimeout, "生成服务停止") + + transitionErr := waitServiceTransition(transitionDone, serviceTransitionShutdownTimeout, "generation service shutdown") + service.lifecycleMu.Lock() cleanupErr := service.transitionErr if transitionErr == nil { @@ -1847,20 +2134,24 @@ func (service *trackedService) Stop() (bool, error) { service.transitionTimedOut = true } service.lifecycleMu.Unlock() + return true, errors.Join(transitionErr, cleanupErr) } -// finishStop 完成运行态服务的后台清理 +// finishStop completes background cleanup of a running service. func (service *trackedService) finishStop(transitionDone chan struct{}, modelRefreshDone <-chan struct{}) { service.requests.cancelAll() - refreshErr := waitServiceTransition(modelRefreshDone, modelCatalogShutdownTimeout, "模型目录刷新停止") + + refreshErr := waitServiceTransition(modelRefreshDone, modelCatalogShutdownTimeout, "model catalog refresh shutdown") service.lifecycleMu.Lock() if service.transitionDone == transitionDone { service.transitionErr = refreshErr } service.lifecycleMu.Unlock() + service.workers.waitPrewarm() resetErr := service.workers.ResetAll() + service.lifecycleMu.Lock() if service.transitionDone == transitionDone { service.dataContext = nil @@ -1873,7 +2164,7 @@ func (service *trackedService) finishStop(transitionDone chan struct{}, modelRef service.lifecycleMu.Unlock() } -// Models 返回最近一次成功同步的模型目录 +// Models returns the most recently synchronized model catalog. func (service *trackedService) Models(context.Context) ([]aistudio.Model, error) { return service.modelSnapshot(), nil } @@ -1882,6 +2173,7 @@ func (service *trackedService) modelSnapshot() []aistudio.Model { service.modelsMu.RLock() models := append([]aistudio.Model(nil), service.models...) service.modelsMu.RUnlock() + return models } @@ -1889,11 +2181,13 @@ func (service *trackedService) publishModelAccess() { if service.lifecycle.Err() != nil { return } + statuses := service.pool.Status() accounts := make([]api.AdminAccount, 0, len(statuses)) for _, status := range statuses { accounts = append(accounts, adminAccountDTO(status)) } + service.requests.publish(api.AdminEvent{Type: "accounts", Data: map[string]any{"accounts": accounts}}) service.requests.publish(api.AdminEvent{Type: "models", Data: map[string]any{"models": service.modelSnapshot()}}) } @@ -1905,38 +2199,45 @@ type accountModelRefreshResult struct { skipped bool } -// startModelCatalogRefresh 并发刷新全部账户并持续处理失败目录 +// startModelCatalogRefresh concurrently refreshes all account model catalogs and handles failures. func (service *trackedService) startModelCatalogRefresh(ctx context.Context) (<-chan error, <-chan struct{}) { ready := make(chan error, 1) done := make(chan struct{}) accountIDs := service.modelCatalogAccountIDs() + go func() { defer close(done) + startedAt := time.Now() firstReady := false synchronized := 0 refreshed := 0 failures := make([]error, 0) authRequiredBefore := service.authRequiredAccountIDs() + for result := range service.refreshAccountModelCatalogs(ctx, accountIDs, false) { if result.err != nil { - failures = append(failures, fmt.Errorf("账户 %s: %w", result.accountID, result.err)) + failures = append(failures, fmt.Errorf("account %s: %w", result.accountID, result.err)) continue } + synchronized++ if len(result.models) == 0 { continue } + refreshed++ if ctx.Err() == nil { service.applyCachedModelCatalog() service.publishModelAccess() } + if !firstReady && ctx.Err() == nil { ready <- nil firstReady = true } } + if !firstReady { launchErr := ctx.Err() if launchErr == nil && len(failures) > 0 { @@ -1948,29 +2249,36 @@ func (service *trackedService) startModelCatalogRefresh(ctx context.Context) (<- ready <- launchErr } close(ready) + if ctx.Err() != nil { return } + if !maps.Equal(service.authRequiredAccountIDs(), authRequiredBefore) { service.publishModelAccess() } + service.requests.log("service", "INFO", fmt.Sprintf( - "模型目录后台同步完成 | 同步=%d | 非空=%d | 模型=%d | 待重试账户=%d | 耗时=%s", + "Model catalog background sync completed | synced=%d | non_empty=%d | models=%d | pending_retry=%d | duration=%s", synchronized, refreshed, len(service.modelSnapshot()), service.pendingModelRetryCount(), time.Since(startedAt).Round(time.Millisecond), )) + service.retryModelCatalogs(ctx) }() + return ready, done } func (service *trackedService) modelCatalogAccountIDs() []string { statuses := service.pool.Status() accountIDs := make([]string, 0, len(statuses)) + for _, status := range statuses { if status.Enabled && (status.State == aistudio.AccountReady || status.State == aistudio.AccountBusy) { accountIDs = append(accountIDs, status.ID) } } + sort.Strings(accountIDs) return accountIDs } @@ -1982,6 +2290,7 @@ func (service *trackedService) refreshAccountModelCatalogs( ) <-chan accountModelRefreshResult { results := make(chan accountModelRefreshResult, len(accountIDs)) var refreshes sync.WaitGroup + for _, accountID := range accountIDs { refreshes.Add(1) go func(accountID string) { @@ -1994,47 +2303,56 @@ func (service *trackedService) refreshAccountModelCatalogs( results <- accountModelRefreshResult{accountID: accountID, models: models, err: err} }(accountID) } + go func() { refreshes.Wait() close(results) }() + return results } func (service *trackedService) refreshAccountModelCatalog(ctx context.Context, accountID string) ([]aistudio.Model, error) { requestCtx, cancel := service.lifecycleRequestContext(ctx) defer cancel() + models, err := service.catalog.RefreshAccountModels(requestCtx, accountID) service.modelRetriesMu.Lock() + if err != nil { if ctx.Err() == nil { service.modelRetries[accountID] = struct{}{} } service.modelRetriesMu.Unlock() if ctx.Err() == nil { - service.requests.log(accountID, "WARN", "模型目录同步失败 | 错误="+err.Error()) + service.requests.log(accountID, "WARN", "Model catalog sync failed | error="+err.Error()) } return nil, err } + if len(models) == 0 { service.modelRetries[accountID] = struct{}{} } else { delete(service.modelRetries, accountID) } service.modelRetriesMu.Unlock() + if len(models) == 0 && ctx.Err() == nil { - service.requests.log(accountID, "WARN", "模型目录为空,等待重新同步") + service.requests.log(accountID, "WARN", "Model catalog is empty, awaiting resync") } + return models, nil } func (service *trackedService) applyCachedModelCatalog() { service.replaceModelSnapshot(service.catalog.CachedModels()) + service.lifecycleMu.Lock() state := service.state.Load() dataContext := service.dataContext running := state == serviceRunning && dataContext != nil && dataContext.Err() == nil service.lifecycleMu.Unlock() + if running { service.workers.StartPrewarm(dataContext) } @@ -2042,21 +2360,25 @@ func (service *trackedService) applyCachedModelCatalog() { func (service *trackedService) syncAccountModels(ctx context.Context, accountID string) ([]aistudio.Model, error) { models, err := service.refreshAccountModelCatalog(ctx, accountID) + service.modelSyncMu.Lock() service.applyCachedModelCatalog() service.modelSyncMu.Unlock() + return models, err } func (service *trackedService) retryModelCatalogs(ctx context.Context) { ticker := time.NewTicker(modelCatalogRetryInterval) defer ticker.Stop() + for { select { case <-ctx.Done(): return case <-ticker.C: } + if !service.retryAccountModelCatalogs(ctx) { return } @@ -2067,8 +2389,10 @@ func (service *trackedService) retryAccountModelCatalogs(ctx context.Context) bo if ctx.Err() != nil { return false } + accountIDs := service.pendingModelRetryIDs() authRequiredBefore := service.authRequiredAccountIDs() + for result := range service.refreshAccountModelCatalogs(ctx, accountIDs, true) { if ctx.Err() != nil { return false @@ -2076,13 +2400,15 @@ func (service *trackedService) retryAccountModelCatalogs(ctx context.Context) bo if !result.skipped && result.err == nil && len(result.models) > 0 { service.applyCachedModelCatalog() service.publishModelAccess() - service.requests.log(result.accountID, "INFO", fmt.Sprintf("模型目录已更新 | 模型=%d", len(result.models))) + service.requests.log(result.accountID, "INFO", fmt.Sprintf("Model catalog updated | models=%d", len(result.models))) } } + authChanged := !maps.Equal(service.authRequiredAccountIDs(), authRequiredBefore) if authChanged { service.publishModelAccess() } + return true } @@ -2092,7 +2418,7 @@ func (service *trackedService) removeAccountModelRetry(accountID string) { service.modelRetriesMu.Unlock() } -// pendingModelRetryIDs 清理失效账户并返回当前可同步的重试任务 +// pendingModelRetryIDs purges invalid accounts and returns retry tasks available for sync. func (service *trackedService) pendingModelRetryIDs() []string { states := make(map[string]aistudio.AccountState) for _, status := range service.pool.Status() { @@ -2100,6 +2426,7 @@ func (service *trackedService) pendingModelRetryIDs() []string { states[status.ID] = status.State } } + service.modelRetriesMu.Lock() accountIDs := make([]string, 0, len(service.modelRetries)) for accountID := range service.modelRetries { @@ -2112,15 +2439,18 @@ func (service *trackedService) pendingModelRetryIDs() []string { } } service.modelRetriesMu.Unlock() + sort.Strings(accountIDs) return accountIDs } func (service *trackedService) pendingModelRetryCount() int { service.pendingModelRetryIDs() + service.modelRetriesMu.Lock() pending := len(service.modelRetries) service.modelRetriesMu.Unlock() + return pending } @@ -2128,6 +2458,7 @@ func (service *trackedService) modelRetryPending(accountID string) bool { service.modelRetriesMu.Lock() _, pending := service.modelRetries[accountID] service.modelRetriesMu.Unlock() + return pending } @@ -2138,6 +2469,7 @@ func (service *trackedService) authRequiredAccountIDs() map[string]struct{} { accountIDs[status.ID] = struct{}{} } } + return accountIDs } @@ -2147,76 +2479,90 @@ func (service *trackedService) replaceModelSnapshot(models []aistudio.Model) { service.modelsMu.Unlock() } -// changeModels 原子登记影响模型目录的账户变化 +// changeModels atomically records account changes affecting the model catalog. func (service *trackedService) changeModels(update func() error) error { service.modelChangeMu.Lock() defer service.modelChangeMu.Unlock() + err := update() service.modelRevision++ + return err } -// SyncModels 合并刷新已登记的模型目录变化 +// SyncModels consolidates and refreshes recorded model catalog changes. func (service *trackedService) SyncModels(ctx context.Context) error { service.modelSyncMu.Lock() defer service.modelSyncMu.Unlock() + return service.syncPendingModels(ctx) } -// syncPendingModels 刷新当前生命周期内尚未提交的模型变化 +// syncPendingModels refreshes uncommitted model changes within the current lifecycle. func (service *trackedService) syncPendingModels(ctx context.Context) error { service.lifecycleMu.Lock() state := service.state.Load() + if state == serviceLaunching { service.lifecycleMu.Unlock() return nil } + if state != serviceRunning { service.lifecycleMu.Unlock() service.replaceModelSnapshot(service.catalog.CachedModels()) service.applyModelRevision(service.currentModelRevision()) return nil } + dataContext := service.dataContext service.lifecycleMu.Unlock() + for { modelRevision, modelApplied := service.modelRevisions() if modelApplied >= modelRevision { break } + if err := ctx.Err(); err != nil { return err } if err := dataContext.Err(); err != nil { return err } + service.replaceModelSnapshot(service.catalog.CachedModels()) service.applyModelRevision(modelRevision) } + service.lifecycleMu.Lock() running := service.state.Load() == serviceRunning && service.dataContext == dataContext service.lifecycleMu.Unlock() + if running { service.workers.StartPrewarm(dataContext) } + return nil } -// currentModelRevision 返回最新模型变化代际 +// currentModelRevision returns the latest model revision. func (service *trackedService) currentModelRevision() uint64 { service.modelChangeMu.Lock() defer service.modelChangeMu.Unlock() + return service.modelRevision } -// modelRevisions 返回模型变化与已提交代际 +// modelRevisions returns the current and applied model revisions. func (service *trackedService) modelRevisions() (uint64, uint64) { service.modelChangeMu.Lock() defer service.modelChangeMu.Unlock() + return service.modelRevision, service.modelApplied } -// applyModelRevision 提交成功刷新的模型代际 +// applyModelRevision applies a successfully refreshed model revision. func (service *trackedService) applyModelRevision(revision uint64) { service.modelChangeMu.Lock() if service.modelApplied < revision { @@ -2230,43 +2576,51 @@ func (service *trackedService) observedDataRequestContext( model string, ) (context.Context, context.CancelFunc, error) { api.SetAccessLogTarget(ctx, model, "") + requestCtx, cancel, err := service.dataRequestContext(ctx) if err != nil { api.SetAccessLogError(ctx, err) return nil, nil, err } + observed := aistudio.ContextWithAccountSelectionObserver(requestCtx, func(account *aistudio.Account) { api.SetAccessLogTarget(requestCtx, model, account.Config.Label) }) + return observed, cancel, nil } -// CountTokens 返回上游权威输入 token 数 +// CountTokens returns authoritative input token counts from upstream. func (service *trackedService) CountTokens(ctx context.Context, request aistudio.TokenCountRequest) (aistudio.TokenCount, error) { requestCtx, cancel, err := service.observedDataRequestContext(ctx, request.Model) if err != nil { return aistudio.TokenCount{}, err } defer cancel() + count, requestErr := service.service.CountTokens(requestCtx, request) api.SetAccessLogError(requestCtx, requestErr) + return count, requestErr } -// GenerateVideo 创建一个 Veo 长任务 +// GenerateVideo creates a long-running Veo task. func (service *trackedService) GenerateVideo(ctx context.Context, request aistudio.VideoRequest) (aistudio.VideoOperation, error) { api.SetAccessLogTarget(ctx, request.Model, "") + requestCtx, cancel, err := service.dataRequestContext(ctx) if err != nil { api.SetAccessLogError(ctx, err) return aistudio.VideoOperation{}, err } defer cancel() + workerGenerations := make(map[string]uint64) recoveredWorkers := make(map[string]struct{}) selectedAccountID := "" selectedAccountLabel := "" selectedAccessGeneration := uint64(0) + requestCtx = aistudio.ContextWithAccountSelectionObserver(requestCtx, func(account *aistudio.Account) { workerGenerations[account.ID] = service.workers.WorkerGeneration(account.ID) selectedAccountID = account.ID @@ -2274,25 +2628,32 @@ func (service *trackedService) GenerateVideo(ctx context.Context, request aistud selectedAccessGeneration = service.pool.ModelAccessGeneration(account.ID) api.SetAccessLogTarget(requestCtx, request.Model, account.Config.Label) }) + request.RecoverWAARuntime = func(recoveryCtx context.Context, accountID string, cause error) (bool, error) { workerFailed := service.workers.WorkerFailed(accountID) workerReplaced := errors.Is(cause, errAccountWorkerReplaced) recoverCurrentGeneration := needsWAARuntimeRecovery(cause, false, workerFailed, workerReplaced) + if recoveryCtx.Err() != nil || !recoverCurrentGeneration { return false, nil } + waaRuntimeFailed := aistudio.DefinitiveWAARuntimeFailure(cause) expectedGeneration := workerGenerations[accountID] + recovered, _, recoveryErr := service.recoverWorkerOnce( accountID, expectedGeneration, recoveredWorkers, recoverCurrentGeneration, workerFailed || waaRuntimeFailed, ) + return recovered, recoveryErr } + video, ok := service.service.(aistudio.VideoService) if !ok { - return aistudio.VideoOperation{}, fmt.Errorf("video service 不可用") + return aistudio.VideoOperation{}, fmt.Errorf("video service is unavailable") } + operation, requestErr := video.GenerateVideo(requestCtx, request) if requestErr == nil && selectedAccountID != "" { service.markModelAccessVerifiedAsync( @@ -2301,6 +2662,7 @@ func (service *trackedService) GenerateVideo(ctx context.Context, request aistud selectedAccessGeneration, operation.ModelAccessCheckedAt(), ) } + api.SetAccessLogError(requestCtx, requestErr) return operation, requestErr } @@ -2310,7 +2672,7 @@ func needsWAARuntimeRecovery(cause error, generationChanged bool, workerFailed b aistudio.DefinitiveWAARuntimeFailure(cause) } -// recoverWorkerOnce 对指定账户的失败 Worker 版本执行至多一次恢复 +// recoverWorkerOnce performs at most one recovery attempt for a failed worker generation on an account. func (service *trackedService) recoverWorkerOnce( accountID string, expectedGeneration uint64, @@ -2321,13 +2683,16 @@ func (service *trackedService) recoverWorkerOnce( if _, recovered := recoveredWorkers[accountID]; recovered { return false, false, nil } + if service.workers.WorkerGeneration(accountID) != expectedGeneration { recoveredWorkers[accountID] = struct{}{} return true, false, nil } + if !currentGenerationEligible { return false, false, nil } + if resetCurrentGeneration { reset, err := service.workers.ResetIfGeneration(accountID, expectedGeneration) if err != nil { @@ -2338,43 +2703,51 @@ func (service *trackedService) recoverWorkerOnce( return true, true, nil } } + recoveredWorkers[accountID] = struct{}{} return true, true, nil } -// GetGenerateVideoOperation 读取 Veo 长任务状态 +// GetGenerateVideoOperation reads the state of a Veo long-running operation. func (service *trackedService) GetGenerateVideoOperation(ctx context.Context, operationID string) (aistudio.VideoOperation, error) { requestCtx, cancel, err := service.observedDataRequestContext(ctx, "") if err != nil { return aistudio.VideoOperation{}, err } defer cancel() + video, ok := service.service.(aistudio.VideoService) if !ok { - return aistudio.VideoOperation{}, fmt.Errorf("video service 不可用") + return aistudio.VideoOperation{}, fmt.Errorf("video service is unavailable") } + operation, requestErr := video.GetGenerateVideoOperation(requestCtx, operationID) api.SetAccessLogError(requestCtx, requestErr) + return operation, requestErr } -// DownloadFile 下载生成任务绑定的 Drive 文件 +// DownloadFile downloads a Drive file associated with a generation task. func (service *trackedService) DownloadFile(ctx context.Context, fileID string) (aistudio.MediaStream, error) { requestCtx, cancel, err := service.observedDataRequestContext(ctx, "") if err != nil { return aistudio.MediaStream{}, err } + video, ok := service.service.(aistudio.VideoService) if !ok { cancel() - return aistudio.MediaStream{}, fmt.Errorf("video service 不可用") + return aistudio.MediaStream{}, fmt.Errorf("video service is unavailable") } + media, requestErr := video.DownloadFile(requestCtx, fileID) api.SetAccessLogError(requestCtx, requestErr) + if requestErr != nil { cancel() return aistudio.MediaStream{}, requestErr } + media.Body = &trackedMediaReadCloser{body: media.Body, cancel: cancel} return media, requestErr } @@ -2401,27 +2774,34 @@ func (closer *trackedMediaReadCloser) Close() error { func (service *trackedService) acquireWarmLease(ctx context.Context, selection aistudio.AccountSelection) (*aistudio.AccountLease, error) { fixedAccount := strings.TrimSpace(selection.AccountID) != "" || strings.TrimSpace(selection.ResourceID) != "" failedWorkers := make(map[string]struct{}) + for { if err := ctx.Err(); err != nil { return nil, err } + warm := service.workers.ReadyWarmAccountIDs() active := service.workers.WarmAccountIDs() + groups, err := service.pool.ClassifyCandidates(ctx, selection, warm) if err != nil { return nil, err } + groups.WarmReady = excludeAccountIDs(groups.WarmReady, failedWorkers) groups.WarmAvailable = excludeAccountIDs(groups.WarmAvailable, failedWorkers) groups.WarmBusy = excludeAccountIDs(groups.WarmBusy, failedWorkers) groups.StandbyReady = excludeAccountIDs(groups.StandbyReady, failedWorkers) groups.StandbyBusy = excludeAccountIDs(groups.StandbyBusy, failedWorkers) + standbyReady, opening := service.workers.withoutOpening(groups.StandbyReady) groups.StandbyReady = standbyReady + warmAvailable := append(append([]string(nil), groups.WarmReady...), groups.WarmAvailable...) if len(warmAvailable) > 0 { candidate := selection candidate.AllowedAccountIDs = warmAvailable + lease, _, acquireErr := service.pool.TryAcquireFor(ctx, candidate) if errors.Is(acquireErr, aistudio.ErrAccountNotFound) && !fixedAccount { continue @@ -2430,12 +2810,14 @@ func (service *trackedService) acquireWarmLease(ctx context.Context, selection a return lease, acquireErr } } + if len(groups.StandbyReady) == 0 && opening { if err := waitWarmCandidate(ctx, 100*time.Millisecond); err != nil { return nil, err } continue } + if len(groups.StandbyReady) > 0 && (len(active) < service.workers.maxActive || service.workers.idleWarmVictim("") != "") { standby := groups.StandbyReady if len(active) < service.workers.maxActive { @@ -2443,9 +2825,11 @@ func (service *trackedService) acquireWarmLease(ctx context.Context, selection a standby = cold } } + accountID := standby[0] candidate := selection candidate.AccountID = accountID + lease, _, acquireErr := service.pool.TryAcquireFor(ctx, candidate) if acquireErr != nil { if errors.Is(acquireErr, aistudio.ErrAccountNotFound) && !fixedAccount { @@ -2459,10 +2843,12 @@ func (service *trackedService) acquireWarmLease(ctx context.Context, selection a } continue } + _, promoteErr := service.workers.promote(ctx, accountID, selection.ModelID) if promoteErr == nil { return lease, nil } + if releaseErr := lease.Release(); releaseErr != nil { return nil, errors.Join(promoteErr, releaseErr) } @@ -2472,21 +2858,25 @@ func (service *trackedService) acquireWarmLease(ctx context.Context, selection a if fixedAccount { return nil, promoteErr } + failedWorkers[accountID] = struct{}{} continue } + if len(groups.WarmBusy) > 0 || len(groups.StandbyBusy) > 0 || opening { if err := waitWarmCandidate(ctx, 100*time.Millisecond); err != nil { return nil, err } continue } + if !groups.EarliestCooldown.IsZero() { if err := waitWarmCandidate(ctx, min(time.Until(groups.EarliestCooldown), 100*time.Millisecond)); err != nil { return nil, err } continue } + return nil, aistudio.ErrNoEligibleAccount } } @@ -2494,6 +2884,7 @@ func (service *trackedService) acquireWarmLease(ctx context.Context, selection a func waitWarmCandidate(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() + select { case <-ctx.Done(): return ctx.Err() @@ -2524,9 +2915,11 @@ func newRequestPreparationTiming(startedAt time.Time) *requestPreparationTiming func (timing *requestPreparationTiming) observe(phase aistudio.RequestPhase) { timing.mu.Lock() defer timing.mu.Unlock() + if phase == timing.phase { return } + now := time.Now() timing.finishPhaseLocked(now) timing.phase = phase @@ -2536,27 +2929,32 @@ func (timing *requestPreparationTiming) observe(phase aistudio.RequestPhase) { func (timing *requestPreparationTiming) snapshot(now time.Time) (string, time.Duration, time.Duration) { timing.mu.Lock() defer timing.mu.Unlock() + waa := timing.waa responseHeader := timing.responseHeader elapsed := now.Sub(timing.phaseStarted) + switch timing.phase { case aistudio.RequestPhasePreparingWAA: waa += elapsed case aistudio.RequestPhaseSendingUpstream: responseHeader += elapsed } - current := "流已建立" + + current := "stream established" switch timing.phase { case aistudio.RequestPhasePreparingWAA: current = "WAA proof" case aistudio.RequestPhaseSendingUpstream: - current = "等待上游响应头" + current = "waiting for upstream response headers" } + return current, waa, responseHeader } func (timing *requestPreparationTiming) finishPhaseLocked(now time.Time) { elapsed := now.Sub(timing.phaseStarted) + switch timing.phase { case aistudio.RequestPhasePreparingWAA: timing.waa += elapsed @@ -2569,6 +2967,7 @@ func (activity *upstreamActivity) observe(count int) { if count <= 0 { return } + now := time.Now().UnixNano() activity.lastNano.Store(now) activity.bytes.Add(int64(count)) @@ -2576,22 +2975,25 @@ func (activity *upstreamActivity) observe(count int) { func (activity *upstreamActivity) logFields(now time.Time) string { if activity == nil { - return "网络字节=0" + return "network_bytes=0" } + lastNano := activity.lastNano.Load() if lastNano == 0 { - return "网络字节=0" + return "network_bytes=0" } + return fmt.Sprintf( - "网络字节=%d | 最近网络=%s", + "network_bytes=%d | last_activity=%s", activity.bytes.Load(), now.Sub(time.Unix(0, lastNano)).Round(time.Millisecond), ) } -// inlineMediaInput 统计规范消息中的内联附件数量和原始大小 +// inlineMediaInput counts inline parts and calculates total raw size in canonical messages. func inlineMediaInput(contents []aistudio.Content) (int, int64) { count := 0 var bytes int64 + for _, content := range contents { for _, part := range content.Parts { if part.InlineData == nil { @@ -2601,16 +3003,19 @@ func inlineMediaInput(contents []aistudio.Content) (int, int64) { bytes += int64(len(part.InlineData.Data)) } } + return count, bytes } -// Generate 获取唯一账户并转发规范事件流 +// Generate acquires an exclusive account and streams canonical events. func (service *trackedService) Generate(ctx context.Context, request aistudio.GenerateRequest) (<-chan aistudio.Event, error) { generationStartedAt := time.Now() + api.SetAccessLogTarget(ctx, request.Model, "") api.SetAccessLogGenerationConfig(ctx, request.Config) api.SetAccessLogGenerationInput(ctx, request) api.StartAccessLog(ctx) + requestCtx, cancel, err := service.dataRequestContext(ctx) if err != nil { api.SetAccessLogError(ctx, err) @@ -2619,6 +3024,7 @@ func (service *trackedService) Generate(ctx context.Context, request aistudio.Ge return nil, err } service.requests.start(request, cancel) + resourceID, err := service.pool.ResourceIDForContents(requestCtx, request.Contents) if err != nil { api.SetAccessLogError(requestCtx, err) @@ -2626,8 +3032,10 @@ func (service *trackedService) Generate(ctx context.Context, request aistudio.Ge service.requests.finish(request.ID, finalRequestState(err), err) return nil, err } + events := make(chan aistudio.Event, 8) go service.generateWithRetry(ctx, requestCtx, cancel, generationStartedAt, request, resourceID, events) + return events, nil } @@ -2645,6 +3053,7 @@ func (service *trackedService) generateWithRetry( modelID := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") unbound := requestedAccountID == "" && resourceID == "" fileBound := requestedAccountID == "" && resourceID != "" + if unbound || fileBound { eligible := 0 for _, status := range service.pool.Status() { @@ -2656,33 +3065,42 @@ func (service *trackedService) generateWithRetry( maxAttempts = eligible } } + var lease *aistudio.AccountLease var source <-chan aistudio.Event var first aistudio.Event var activity *upstreamActivity var temporaryCopies *aistudio.TemporaryFileCopies var err error + originalContents := request.Contents attempted := make(map[string]struct{}, maxAttempts) recoveredWorker := make(map[string]struct{}) recoveryAccountID := "" copyFiles := false + for attempt := 0; attempt < maxAttempts; attempt++ { request.Contents = originalContents selectionAccountID := requestedAccountID recoveringAccount := recoveryAccountID != "" + if recoveryAccountID != "" { selectionAccountID = recoveryAccountID recoveryAccountID = "" } + selectionResourceID := resourceID if copyFiles { selectionResourceID = "" } + selection := aistudio.AccountSelection{ - ModelID: modelID, Method: "generateContent", - AccountID: selectionAccountID, ResourceID: selectionResourceID, + ModelID: modelID, + Method: "generateContent", + AccountID: selectionAccountID, + ResourceID: selectionResourceID, } + if (unbound || fileBound) && len(attempted) > 0 { for _, status := range service.pool.Status() { if _, exists := attempted[status.ID]; status.Enabled && !exists { @@ -2690,6 +3108,7 @@ func (service *trackedService) generateWithRetry( } } } + nextLease, acquireErr := service.acquireWarmLease(requestCtx, selection) if acquireErr != nil { if fileBound && !copyFiles && errors.Is(acquireErr, aistudio.ErrNoEligibleAccount) && requestCtx.Err() == nil { @@ -2705,25 +3124,29 @@ func (service *trackedService) generateWithRetry( } break } + lease = nextLease err = nil source = nil request.AccountID = lease.Account().ID workerGeneration := service.workers.WorkerGeneration(request.AccountID) attempted[request.AccountID] = struct{}{} + accountLabel := lease.Account().Config.Label api.SetAccessLogTarget(requestCtx, modelID, accountLabel) service.requests.markRunning(request.ID, request.AccountID, accountLabel) - service.requests.logRequestProgress(request.ID, accountLabel, "INFO", "等待上游响应") + service.requests.logRequestProgress(request.ID, accountLabel, "INFO", "Waiting for upstream response") + attemptCtx := aistudio.ContextWithAccountLease(requestCtx, lease) var attemptCopies *aistudio.TemporaryFileCopies copiedFileCount := 0 + if resourceID != "" { fileCopies, ok := service.service.(interface { CopyFileReferencesToLease(context.Context, *aistudio.AccountLease, []aistudio.Content) ([]aistudio.Content, *aistudio.TemporaryFileCopies, error) }) if !ok { - err = fmt.Errorf("文件引用跨账户服务不可用") + err = fmt.Errorf("cross-account file reference copy service is unavailable") } else { request.Contents, attemptCopies, err = fileCopies.CopyFileReferencesToLease( requestCtx, lease, originalContents, @@ -2733,13 +3156,14 @@ func (service *trackedService) generateWithRetry( } } } + mediaCount, mediaBytes := inlineMediaInput(request.Contents) if err == nil && mediaCount > 0 { uploader, ok := service.service.(interface { UploadInlineMediaToLease(context.Context, *aistudio.AccountLease, []aistudio.Content, *aistudio.TemporaryFileCopies) ([]aistudio.Content, *aistudio.TemporaryFileCopies, error) }) if !ok { - err = fmt.Errorf("内联附件上传服务不可用") + err = fmt.Errorf("inline media upload service is unavailable") } else { uploadStartedAt := time.Now() request.Contents, attemptCopies, err = uploader.UploadInlineMediaToLease( @@ -2747,29 +3171,34 @@ func (service *trackedService) generateWithRetry( ) if err == nil { service.requests.log(accountLabel, "INFO", fmt.Sprintf( - "内联附件处理完成 | 附件=%d | 原始=%dB | 耗时=%s", + "Inline media processed | parts=%d | raw_bytes=%dB | duration=%s", mediaCount, mediaBytes, time.Since(uploadStartedAt).Round(time.Millisecond), )) } } } + prepareStartedAt := time.Now() prepareTiming := newRequestPreparationTiming(prepareStartedAt) prepareWarningDone := make(chan struct{}) + prepareWarning := time.AfterFunc(streamStallThreshold, func() { current, _, _ := prepareTiming.snapshot(time.Now()) service.requests.logRequestProgress(request.ID, accountLabel, "WARN", fmt.Sprintf( - "请求准备等待 | 已等待=%s | 当前=%s | 模型=%s", + "Request preparation waiting | waited=%s | current=%s | model=%s", streamStallThreshold, current, modelID, )) close(prepareWarningDone) }) + activity = &upstreamActivity{} attemptCtx = aistudio.ContextWithStreamActivityObserver(attemptCtx, activity.observe) attemptCtx = aistudio.ContextWithRequestPhaseObserver(attemptCtx, prepareTiming.observe) + if err == nil { source, err = service.service.Generate(attemptCtx, request) } + if err == nil && copiedFileCount > 0 { sourceLabels := make([]string, 0, len(attemptCopies.SourceAccountIDs())) statuses := service.pool.Status() @@ -2784,36 +3213,40 @@ func (service *trackedService) generateWithRetry( sourceLabels = append(sourceLabels, sourceLabel) } service.requests.log(accountLabel, "INFO", fmt.Sprintf( - "文件引用复制 | 来源=%s | 目标=%s | 文件=%d", + "File references copied | source=%s | target=%s | files=%d", strings.Join(sourceLabels, ","), accountLabel, copiedFileCount, )) } + prepareElapsed := time.Since(prepareStartedAt) if !prepareWarning.Stop() { <-prepareWarningDone _, waa, responseHeader := prepareTiming.snapshot(time.Now()) service.requests.logRequestProgress(request.ID, accountLabel, "INFO", fmt.Sprintf( - "请求准备结束 | 等待=%s | WAA=%s | 响应头=%s | 模型=%s", + "Request preparation finished | wait=%s | waa=%s | response_headers=%s | model=%s", prepareElapsed.Round(time.Millisecond), waa.Round(time.Millisecond), responseHeader.Round(time.Millisecond), modelID, )) } + if err == nil { upstreamStartedAt := time.Now() firstEventDelayed := false first, err = firstGenerateEvent(requestCtx, source, func() { firstEventDelayed = true service.requests.logRequestProgress(request.ID, accountLabel, "WARN", fmt.Sprintf( - "上游首事件等待 | 已等待=%s | 模型=%s | %s", + "Waiting for upstream first event | waited=%s | model=%s | %s", streamStallThreshold, modelID, activity.logFields(time.Now()), )) }) + if firstEventDelayed && err == nil { service.requests.logRequestProgress(request.ID, accountLabel, "INFO", fmt.Sprintf( - "上游首事件到达 | 等待=%s | 事件=%s | 模型=%s", + "Upstream first event received | wait=%s | event=%s | model=%s", time.Since(upstreamStartedAt).Round(time.Millisecond), first.Kind, modelID, )) } + if err == nil { api.SetAccessLogFirstEvent(requestCtx, time.Since(generationStartedAt)) api.SetAccessLogTarget(requestCtx, first.ProviderModel, accountLabel) @@ -2821,15 +3254,18 @@ func (service *trackedService) generateWithRetry( break } } + if attemptCopies != nil { err = errors.Join(err, attemptCopies.Cleanup()) } + workerFailed := service.workers.WorkerFailed(request.AccountID) waaRuntimeFailed := aistudio.DefinitiveWAARuntimeFailure(err) workerReplaced := errors.Is(err, errAccountWorkerReplaced) localWorkerFailure := (workerFailed || workerReplaced) && requestCtx.Err() == nil retryable := retryableGenerateAccountError(requestCtx, err) || localWorkerFailure recoverWorker := false + if requestCtx.Err() == nil { var resetErr error recoverWorker, _, resetErr = service.recoverWorkerOnce( @@ -2842,18 +3278,20 @@ func (service *trackedService) generateWithRetry( retryable = false } } + if aistudio.DefinitiveAuthenticationFailure(err) { if stateErr := lease.MarkAuthenticationRequired(err.Error()); stateErr != nil { err = errors.Join(err, stateErr) retryable = false } } + if cooldown, ok := aistudio.QuotaCooldownForError(err, time.Now()); ok { modelAccessScope := modelID scopeLabel := modelID if cooldown.Global { modelAccessScope = "" - scopeLabel = "全局" + scopeLabel = "global" } stateErr := service.pool.MarkCooldownIfGeneration( request.AccountID, modelAccessScope, lease.ModelAccessGeneration(), lease.CheckedAt(), @@ -2864,54 +3302,64 @@ func (service *trackedService) generateWithRetry( retryable = false } else { service.requests.log(accountLabel, "WARN", fmt.Sprintf( - "账号冷却 | 类型=%s | 范围=%s | 恢复=%s", + "Account cooldown | type=%s | scope=%s | reset=%s", cooldown.Kind, scopeLabel, cooldown.Until.Format(time.RFC3339), )) } } + releaseErr := lease.Release() lease = nil if releaseErr != nil { err = errors.Join(err, releaseErr) break } + if !retryable { break } + if recoverWorker { recoveryAccountID = request.AccountID delete(attempted, request.AccountID) maxAttempts++ } + if attempt+1 == maxAttempts { break } + if recoverWorker { service.requests.log(accountLabel, "WARN", fmt.Sprintf( - "WAA Worker 重建 | 模型=%s | 重放当前请求", modelID, + "Rebuilding WAA worker | model=%s | replaying current request", modelID, )) continue } + switchMessage := fmt.Sprintf( - "账号切换 | 模型=%s\n原因: %s", + "Account switch | model=%s\nReason: %s", modelID, strings.TrimSpace(err.Error()), ) service.requests.log(accountLabel, "WARN", switchMessage) } + if err != nil { if activity != nil { api.SetAccessLogUpstreamBytes(requestCtx, activity.bytes.Load()) } api.SetAccessLogError(requestCtx, err) service.requests.finish(request.ID, finalRequestState(err), err) + select { case destination <- aistudio.Event{Kind: aistudio.EventError, Err: err}: case <-clientCtx.Done(): } + cancel() close(destination) return } + service.forwardEvents( clientCtx, requestCtx, cancel, request.ID, first, source, destination, lease, temporaryCopies, activity, modelID, @@ -2921,11 +3369,12 @@ func (service *trackedService) generateWithRetry( var errStreamClosedBeforeFirstEvent = errors.New("AI Studio stream closed before first event") var errStreamClosedBeforeFinish = errors.New("AI Studio stream closed before finish") -// firstGenerateEvent 等待首事件并在请求期限内完成错误流清理 +// firstGenerateEvent waits for the first event and cleans up error streams within context. func firstGenerateEvent(ctx context.Context, source <-chan aistudio.Event, onWait func()) (aistudio.Event, error) { timer := time.NewTimer(streamStallThreshold) defer timer.Stop() wait := timer.C + for { select { case event, ok := <-source: @@ -2948,11 +3397,13 @@ func firstGenerateEvent(ctx context.Context, source <-chan aistudio.Event, onWai return aistudio.Event{}, ctx.Err() } } + case <-wait: if onWait != nil { onWait() } wait = nil + case <-ctx.Done(): return aistudio.Event{}, ctx.Err() } @@ -2963,24 +3414,32 @@ func retryableGenerateAccountError(ctx context.Context, err error) bool { if errors.Is(err, errStreamClosedBeforeFirstEvent) { return true } + var workerInitError *accountWorkerInitError if errors.As(err, &workerInitError) { return ctx.Err() == nil } + if ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) { return true } + var rpcError *aistudio.RPCError if !errors.As(err, &rpcError) { return false } - return rpcError.StatusCode == http.StatusUnauthorized || rpcError.StatusCode == http.StatusForbidden || rpcError.StatusCode == http.StatusNotFound || - rpcError.StatusCode == http.StatusTooManyRequests || rpcError.StatusCode >= http.StatusInternalServerError + + return rpcError.StatusCode == http.StatusUnauthorized || + rpcError.StatusCode == http.StatusForbidden || + rpcError.StatusCode == http.StatusNotFound || + rpcError.StatusCode == http.StatusTooManyRequests || + rpcError.StatusCode >= http.StatusInternalServerError } func (service *trackedService) lifecycleRequestContext(ctx context.Context) (context.Context, context.CancelFunc) { requestCtx, cancel := context.WithTimeout(ctx, service.timeout) stopLifecycle := context.AfterFunc(service.lifecycle, cancel) + return requestCtx, func() { stopLifecycle() cancel() @@ -2993,9 +3452,11 @@ func (service *trackedService) dataRequestContext(ctx context.Context) (context. service.lifecycleMu.Unlock() return nil, nil, &serviceStoppedError{} } + requestCtx, cancel := context.WithTimeout(ctx, service.timeout) stopData := context.AfterFunc(service.dataContext, cancel) service.lifecycleMu.Unlock() + return requestCtx, func() { stopData() cancel() @@ -3021,18 +3482,21 @@ func (service *trackedService) forwardEvents( accountLabel := lease.Account().Config.Label accessGeneration := lease.ModelAccessGeneration() modelID := strings.TrimPrefix(strings.TrimSpace(first.ProviderModel), "models/") + var lastEventAt time.Time lastEventKind := "-" reasoningEvents := 0 contentEvents := 0 var usage *aistudio.Usage toolCalls := 0 + stalled := false stallTimer := time.NewTimer(streamStallThreshold) if !stallTimer.Stop() { <-stallTimer.C } defer stallTimer.Stop() + var stall <-chan time.Time resetStallTimer := func() { if !stallTimer.Stop() { @@ -3044,6 +3508,7 @@ func (service *trackedService) forwardEvents( stallTimer.Reset(streamStallThreshold) stall = stallTimer.C } + finishFromContext := func() { requestErr = requestCtx.Err() state = finalRequestState(requestErr) @@ -3055,15 +3520,18 @@ func (service *trackedService) forwardEvents( case <-clientCtx.Done(): } } + defer cancel() defer func() { api.SetAccessLogUpstreamBytes(requestCtx, activity.bytes.Load()) api.SetAccessLogGenerationResult(requestCtx, usage, toolCalls) + if temporaryCopies != nil { if err := temporaryCopies.Cleanup(); err != nil { - service.requests.log(accountLabel, "WARN", "临时文件清理失败 | 错误="+err.Error()) + service.requests.log(accountLabel, "WARN", "Failed to cleanup temporary files | error="+err.Error()) } } + if err := lease.Release(); err != nil { state = "failed" requestErr = errors.Join(requestErr, err) @@ -3074,15 +3542,19 @@ func (service *trackedService) forwardEvents( } } } + api.SetAccessLogError(requestCtx, requestErr) service.requests.finish(requestID, state, requestErr) close(destination) }() + pendingFirst := true verified := false + for { var event aistudio.Event var ok bool + if pendingFirst { event = first ok = true @@ -3094,7 +3566,7 @@ func (service *trackedService) forwardEvents( stalled = true stall = nil service.requests.logRequestProgress(requestID, accountLabel, "WARN", fmt.Sprintf( - "事件流停顿 | 模型=%s | 已等待=%s | 最近事件=%s | 推理=%d | 正文=%d | %s", + "Stream stalled | model=%s | waited=%s | last_event=%s | reasoning=%d | content=%d | %s", modelID, streamStallThreshold, lastEventKind, reasoningEvents, contentEvents, activity.logFields(time.Now()), )) @@ -3104,6 +3576,7 @@ func (service *trackedService) forwardEvents( return } } + if !ok { if err := requestCtx.Err(); err != nil { finishFromContext() @@ -3117,16 +3590,19 @@ func (service *trackedService) forwardEvents( } return } + now := time.Now() if stalled { service.requests.logRequestProgress(requestID, accountLabel, "INFO", fmt.Sprintf( - "事件流恢复 | 模型=%s | 停顿=%s | 当前事件=%s", + "Stream resumed | model=%s | stall_duration=%s | current_event=%s", modelID, now.Sub(lastEventAt).Round(time.Millisecond), event.Kind, )) stalled = false } + lastEventAt = now lastEventKind = string(event.Kind) + switch event.Kind { case aistudio.EventReasoning: reasoningEvents++ @@ -3141,10 +3617,12 @@ func (service *trackedService) forwardEvents( toolCalls++ } } + api.SetAccessLogTarget(requestCtx, event.ProviderModel, lease.Account().Config.Label) if terminal { continue } + if event.Kind == aistudio.EventError { requestErr = event.Err if aistudio.DefinitiveAuthenticationFailure(event.Err) { @@ -3156,22 +3634,26 @@ func (service *trackedService) forwardEvents( state = finalRequestState(event.Err) terminal = true } + if event.Kind == aistudio.EventFinish { api.SetAccessLogFinishReason(requestCtx, event.FinishReason) state = "completed" terminal = true } + if terminal { stall = nil } else { resetStallTimer() } + select { case destination <- event: case <-requestCtx.Done(): finishFromContext() return } + if !verified && event.Kind == aistudio.EventFinish { verified = true service.markModelAccessVerifiedAsync( diff --git a/internal/app/transcriptions.go b/internal/app/transcriptions.go index 425029b..24c4f24 100644 --- a/internal/app/transcriptions.go +++ b/internal/app/transcriptions.go @@ -10,7 +10,7 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/api" ) -// Transcribe 跟踪音频转录使用的账户与生成结果 +// Transcribe tracks the account used for audio transcription and the generation result. func (service *trackedService) Transcribe( ctx context.Context, request aistudio.TranscriptionRequest, @@ -18,6 +18,7 @@ func (service *trackedService) Transcribe( api.SetAccessLogTarget(ctx, request.Model, "") api.SetAccessLogGenerationConfig(ctx, request.Config) api.StartAccessLog(ctx) + requestCtx, cancel, err := service.dataRequestContext(ctx) if err != nil { api.SetAccessLogError(ctx, err) @@ -25,9 +26,11 @@ func (service *trackedService) Transcribe( service.requests.finish(request.ID, "failed", err) return aistudio.TranscriptionResult{}, err } + service.requests.start(aistudio.GenerateRequest{ ID: request.ID, Model: request.Model, Config: request.Config, }, cancel) + request.CandidateAccountIDs, err = service.transcriptionCandidates(requestCtx, request.Model) if err != nil { api.SetAccessLogError(requestCtx, err) @@ -35,8 +38,10 @@ func (service *trackedService) Transcribe( cancel() return aistudio.TranscriptionResult{}, err } + workerGenerations := make(map[string]uint64) recoveredWorkers := make(map[string]struct{}) + request.ObserveAccountFailure = func(accountID string, cause error) { label := accountID for _, status := range service.pool.Status() { @@ -45,22 +50,27 @@ func (service *trackedService) Transcribe( break } } + service.requests.log(label, "WARN", fmt.Sprintf( - "账号切换 | 模型=%s\n原因: %s", + "Account switch | model=%s\nReason: %s", strings.TrimPrefix(request.Model, "models/"), strings.TrimSpace(cause.Error()), )) } + request.RecoverWAARuntime = func(recoveryCtx context.Context, accountID string, cause error) (bool, error) { if !aistudio.TranscriptionGenerationFailure(cause) { return false, nil } + workerFailed := service.workers.WorkerFailed(accountID) workerReplaced := errors.Is(cause, errAccountWorkerReplaced) if recoveryCtx.Err() != nil || !needsWAARuntimeRecovery(cause, false, workerFailed, workerReplaced) { return false, nil } + waaRuntimeFailed := aistudio.DefinitiveWAARuntimeFailure(cause) expectedGeneration := workerGenerations[accountID] + recovered, currentGeneration, recoveryErr := service.recoverWorkerOnce( accountID, expectedGeneration, recoveredWorkers, true, workerFailed || waaRuntimeFailed, ) @@ -73,6 +83,7 @@ func (service *trackedService) Transcribe( if !currentGeneration { return true, nil } + label := accountID for _, status := range service.pool.Status() { if status.ID == accountID { @@ -80,24 +91,29 @@ func (service *trackedService) Transcribe( break } } + modelID := strings.TrimPrefix(request.Model, "models/") if workerFailed || waaRuntimeFailed { - service.requests.log(label, "WARN", "WAA Worker 重建 | 模型="+modelID) + service.requests.log(label, "WARN", "Rebuilding WAA worker | model="+modelID) } - service.requests.log(label, "WARN", "WAA Worker 已更新 | 模型="+modelID+" | 重放当前请求") + service.requests.log(label, "WARN", "WAA worker updated | model="+modelID+" | replaying current request") + return true, nil } + observed := aistudio.ContextWithAccountSelectionObserver(requestCtx, func(account *aistudio.Account) { workerGenerations[account.ID] = service.workers.WorkerGeneration(account.ID) api.SetAccessLogTarget(requestCtx, request.Model, account.Config.Label) service.requests.markRunning(request.ID, account.ID, account.Config.Label) }) + transcriptions, ok := service.service.(aistudio.TranscriptionService) if !ok { - err = fmt.Errorf("transcription service 不可用") + err = fmt.Errorf("transcription service is unavailable") } else { var result aistudio.TranscriptionResult result, err = transcriptions.Transcribe(observed, request) + api.SetAccessLogFirstEvent(requestCtx, result.FirstEvent) api.SetAccessLogGenerationResult( requestCtx, &result.Usage, 0, @@ -105,23 +121,29 @@ func (service *trackedService) Transcribe( api.SetAccessLogTarget(requestCtx, result.ProviderModel, "") api.SetAccessLogFinishReason(requestCtx, result.FinishReason) api.SetAccessLogError(requestCtx, err) + service.requests.finish(request.ID, finalRequestState(err), err) cancel() return result, err } + api.SetAccessLogError(requestCtx, err) service.requests.finish(request.ID, finalRequestState(err), err) cancel() + return aistudio.TranscriptionResult{}, err } func (service *trackedService) transcriptionCandidates(ctx context.Context, model string) ([]string, error) { modelID := strings.TrimPrefix(strings.TrimSpace(model), "models/") + groups, err := service.pool.ClassifyCandidates( ctx, aistudio.AccountSelection{ - ModelID: modelID, ModelAccessScope: modelID, - Method: "generateContent", Capability: "transcription_output", + ModelID: modelID, + ModelAccessScope: modelID, + Method: "generateContent", + Capability: "transcription_output", }, service.workers.WarmAccountIDs(), ) @@ -131,14 +153,17 @@ func (service *trackedService) transcriptionCandidates(ctx context.Context, mode if err := ctx.Err(); err != nil { return nil, err } + warmAvailable := append(append([]string(nil), groups.WarmReady...), groups.WarmAvailable...) candidates := service.pool.OrderCandidates(warmAvailable, modelID) candidates = append(candidates, service.pool.OrderCandidates(groups.StandbyReady, modelID)...) candidates = append(candidates, service.pool.OrderCandidates(groups.WarmBusy, modelID)...) candidates = append(candidates, service.pool.OrderCandidates(groups.StandbyBusy, modelID)...) + if len(candidates) == 0 { return nil, aistudio.ErrNoEligibleAccount } + return candidates, nil } diff --git a/internal/camoufoxnative/bidi.go b/internal/camoufoxnative/bidi.go index c19f56e..1e8a0fd 100644 --- a/internal/camoufoxnative/bidi.go +++ b/internal/camoufoxnative/bidi.go @@ -30,10 +30,10 @@ type bidiCommandError struct { } func (err *bidiCommandError) Error() string { - return fmt.Sprintf("BiDi %s 失败: %s", err.method, err.payload) + return fmt.Sprintf("bidi %s failed: %s", err.method, err.payload) } -// newBiDiClient 创建串行 WebDriver BiDi 客户端 +// newBiDiClient creates a serialized WebDriver BiDi client. func newBiDiClient(connection *websocket.Conn) *bidiClient { return &bidiClient{ connection: connection, @@ -42,7 +42,7 @@ func newBiDiClient(connection *websocket.Conn) *bidiClient { } } -// command 发送一条 BiDi 命令并消费穿插的网络事件 +// command sends a BiDi command and consumes interleaved network events. func (client *bidiClient) command(ctx context.Context, method string, params map[string]any) (map[string]any, error) { select { case client.commandLock <- struct{}{}: @@ -96,7 +96,7 @@ func (client *bidiClient) command(ctx context.Context, method string, params map } } -// observe 捕获官网 GenerateContent 的公共头和响应状态 +// observe captures common headers and response status from official GenerateContent. func (client *bidiClient) observe(message map[string]any) { method, _ := message["method"].(string) if !strings.HasPrefix(method, "network.") { @@ -127,7 +127,7 @@ func (client *bidiClient) observe(message map[string]any) { } } -// installCookies 按 storage state 的分区恢复 Cookie +// installCookies restores cookies partitioned by storage state. func (client *bidiClient) installCookies(ctx context.Context, cookies []storageCookie) error { for _, item := range cookies { cookie := map[string]any{ @@ -150,13 +150,13 @@ func (client *bidiClient) installCookies(ctx context.Context, cookies []storageC params["partition"] = map[string]any{"type": "storageKey", "sourceOrigin": item.PartitionKey} } if _, err := client.command(ctx, "storage.setCookie", params); err != nil { - return fmt.Errorf("写入 Cookie %s: %w", item.Name, err) + return fmt.Errorf("writing cookie %s: %w", item.Name, err) } } return nil } -// installLocalStorage 在站点脚本前恢复各 origin 的 localStorage +// installLocalStorage restores localStorage for each origin before site scripts run. func (client *bidiClient) installLocalStorage(ctx context.Context, contextID string, origins []storageOrigin) error { values := make(map[string]map[string]string, len(origins)) for _, origin := range origins { @@ -181,12 +181,12 @@ func (client *bidiClient) installLocalStorage(ctx context.Context, contextID str "contexts": []string{contextID}, }) if err != nil { - return fmt.Errorf("安装 localStorage preload: %w", err) + return fmt.Errorf("installing localStorage preload: %w", err) } return nil } -// evaluate 在页面默认主世界执行表达式 +// evaluate executes an expression in the page default main realm. func (client *bidiClient) evaluate(ctx context.Context, contextID, expression string) (map[string]any, error) { result, err := client.command(ctx, "script.evaluate", map[string]any{ "expression": expression, @@ -198,7 +198,7 @@ func (client *bidiClient) evaluate(ctx context.Context, contextID, expression st } if result["type"] == "exception" { encoded, _ := json.Marshal(result) - return nil, fmt.Errorf("页面表达式异常: %s", encoded) + return nil, fmt.Errorf("page expression error: %s", encoded) } remote, _ := result["result"].(map[string]any) return remote, nil @@ -222,7 +222,7 @@ func (client *bidiClient) evaluateBool(ctx context.Context, contextID, expressio return value, nil } -// waitFor 将页面条件的等待期限传递给 BiDi 命令 +// waitFor passes the page condition timeout to BiDi commands. func (client *bidiClient) waitFor(ctx context.Context, contextID, expression string, timeout time.Duration) error { deadline := time.Now().Add(timeout) ctx, cancel := context.WithDeadline(ctx, deadline) @@ -242,10 +242,10 @@ func (client *bidiClient) waitFor(ctx context.Context, contextID, expression str return err } } - return fmt.Errorf("等待页面条件超时: %s", expression) + return fmt.Errorf("timed out waiting for page condition: %s", expression) } -// waitSnapshotFunction 在阶段期限内定位页面函数 +// waitSnapshotFunction locates the page function within the stage deadline. func (client *bidiClient) waitSnapshotFunction(ctx context.Context, contextID string, timeout time.Duration) (string, error) { deadline := time.Now().Add(timeout) ctx, cancel := context.WithDeadline(ctx, deadline) @@ -265,10 +265,10 @@ func (client *bidiClient) waitSnapshotFunction(ctx context.Context, contextID st return "", err } } - return "", errors.New("官网高层 snapshot 函数定位超时") + return "", errors.New("timed out locating high-level snapshot function") } -// waitBlockedGenerateRequest 在阶段期限内接收网络拦截事件 +// waitBlockedGenerateRequest receives network interception events within the stage deadline. func (client *bidiClient) waitBlockedGenerateRequest(ctx context.Context, contextID string, timeout time.Duration) (string, error) { deadline := time.Now().Add(timeout) ctx, cancel := context.WithDeadline(ctx, deadline) @@ -284,7 +284,7 @@ func (client *bidiClient) waitBlockedGenerateRequest(ctx context.Context, contex return "", err } } - return "", errors.New("官网 GenerateContent 拦截事件超时") + return "", errors.New("timed out waiting for GenerateContent interception event") } func retryablePageEvaluation(err error) bool { @@ -353,7 +353,7 @@ func takeProofExpression(digest string) string { const service = window.__aistudioWaaService; const snapshotKey = window.__aistudioWaaSnapshotKey; if (!makerSuite || !service || !snapshotKey || typeof makerSuite[snapshotKey] !== 'function') { - throw new Error('官方 WAA service 尚未就绪'); + throw new Error('official WAA service is not ready yet'); } return await makerSuite[snapshotKey](service, %s); })()`, encoded) diff --git a/internal/camoufoxnative/download.go b/internal/camoufoxnative/download.go index d7a7ec7..8093622 100644 --- a/internal/camoufoxnative/download.go +++ b/internal/camoufoxnative/download.go @@ -16,7 +16,7 @@ import ( const camoufoxRelease = "152.0.4-beta.29" -// installCamoufox 下载当前协议传输已对齐的 Camoufox 版本 +// installCamoufox downloads the Camoufox version aligned with the current protocol transport. func installCamoufox(ctx context.Context, executableName string) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -30,16 +30,16 @@ func installCamoufox(ctx context.Context, executableName string) (string, error) return "", err } if err := os.MkdirAll(filepath.Dir(root), 0o755); err != nil { - return "", fmt.Errorf("创建 Camoufox 目录: %w", err) + return "", fmt.Errorf("creating Camoufox directory: %w", err) } archive, err := os.CreateTemp(filepath.Dir(root), "camoufox-*.zip") if err != nil { - return "", fmt.Errorf("创建 Camoufox 下载文件: %w", err) + return "", fmt.Errorf("creating Camoufox download file: %w", err) } archivePath := archive.Name() defer os.Remove(archivePath) url := fmt.Sprintf("https://github.com/daijro/camoufox/releases/download/v%s/%s", camoufoxRelease, asset) - slog.Info("正在下载 Camoufox", "version", camoufoxRelease, "platform", runtime.GOOS+"/"+runtime.GOARCH) + slog.Info("downloading Camoufox", "version", camoufoxRelease, "platform", runtime.GOOS+"/"+runtime.GOARCH) client := &http.Client{Timeout: 30 * time.Minute} request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -50,25 +50,25 @@ func installCamoufox(ctx context.Context, executableName string) (string, error) response, err := client.Do(request) if err != nil { archive.Close() - return "", fmt.Errorf("下载 Camoufox: %w", err) + return "", fmt.Errorf("downloading Camoufox: %w", err) } if response.StatusCode != http.StatusOK { response.Body.Close() archive.Close() - return "", fmt.Errorf("下载 Camoufox: HTTP %d", response.StatusCode) + return "", fmt.Errorf("downloading Camoufox: HTTP %d", response.StatusCode) } if response.ContentLength > 0 { - slog.Info("Camoufox 下载已开始", "size_mib", response.ContentLength/(1024*1024)) + slog.Info("Camoufox download started", "size_mib", response.ContentLength/(1024*1024)) } _, copyErr := io.Copy(archive, contextReader{ctx: ctx, reader: response.Body}) closeErr := response.Body.Close() archiveCloseErr := archive.Close() if copyErr != nil || closeErr != nil || archiveCloseErr != nil { - return "", fmt.Errorf("保存 Camoufox: %w", firstError(copyErr, closeErr, archiveCloseErr)) + return "", fmt.Errorf("saving Camoufox: %w", firstError(copyErr, closeErr, archiveCloseErr)) } staging, err := os.MkdirTemp(filepath.Dir(root), ".camoufox-stage-*") if err != nil { - return "", fmt.Errorf("创建 Camoufox 临时目录: %w", err) + return "", fmt.Errorf("creating Camoufox staging directory: %w", err) } defer os.RemoveAll(staging) if err := extractCamoufoxArchive(ctx, archivePath, staging); err != nil { @@ -77,30 +77,30 @@ func installCamoufox(ctx context.Context, executableName string) (string, error) stagedExecutable := filepath.Join(staging, executableName) if runtime.GOOS != "windows" { if err := os.Chmod(stagedExecutable, 0o755); err != nil { - return "", fmt.Errorf("设置 Camoufox 执行权限: %w", err) + return "", fmt.Errorf("setting Camoufox executable permissions: %w", err) } } if _, err := validateCamoufoxExecutable(stagedExecutable); err != nil { - return "", fmt.Errorf("校验 Camoufox 临时目录: %w", err) + return "", fmt.Errorf("validating Camoufox staging directory: %w", err) } if err := ctx.Err(); err != nil { return "", err } if err := os.RemoveAll(root); err != nil { - return "", fmt.Errorf("清理旧 Camoufox 目录: %w", err) + return "", fmt.Errorf("cleaning up old Camoufox directory: %w", err) } if err := os.Rename(staging, root); err != nil { - return "", fmt.Errorf("发布 Camoufox 目录: %w", err) + return "", fmt.Errorf("publishing Camoufox directory: %w", err) } executable := filepath.Join(root, executableName) - slog.Info("Camoufox 已就绪", "path", executable) + slog.Info("Camoufox is ready", "path", executable) return executable, nil } func camoufoxInstallRoot() (string, error) { root, err := filepath.Abs(filepath.Join("runtime", "camoufox")) if err != nil { - return "", fmt.Errorf("定位 Camoufox 目录: %w", err) + return "", fmt.Errorf("locating Camoufox directory: %w", err) } return root, nil } @@ -109,7 +109,7 @@ func camoufoxAssetName() (string, error) { platform := map[string]string{"windows": "win", "linux": "lin", "darwin": "mac"}[runtime.GOOS] architecture := map[string]string{"amd64": "x86_64", "386": "i686", "arm64": "arm64"}[runtime.GOARCH] if platform == "" || architecture == "" || runtime.GOOS == "darwin" && runtime.GOARCH == "386" { - return "", fmt.Errorf("Camoufox 没有 %s/%s 发行包", runtime.GOOS, runtime.GOARCH) + return "", fmt.Errorf("Camoufox release not available for %s/%s", runtime.GOOS, runtime.GOARCH) } return fmt.Sprintf("camoufox-%s-%s.%s.zip", camoufoxRelease, platform, architecture), nil } @@ -117,7 +117,7 @@ func camoufoxAssetName() (string, error) { func extractCamoufoxArchive(ctx context.Context, archivePath string, destination string) error { archive, err := zip.OpenReader(archivePath) if err != nil { - return fmt.Errorf("打开 Camoufox 压缩包: %w", err) + return fmt.Errorf("opening Camoufox archive: %w", err) } defer archive.Close() for _, entry := range archive.File { @@ -127,7 +127,7 @@ func extractCamoufoxArchive(ctx context.Context, archivePath string, destination target := filepath.Join(destination, filepath.FromSlash(entry.Name)) relative, err := filepath.Rel(destination, target) if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return fmt.Errorf("Camoufox 压缩包包含无效路径 %q", entry.Name) + return fmt.Errorf("Camoufox archive contains invalid path %q", entry.Name) } if entry.FileInfo().IsDir() { if err := os.MkdirAll(target, entry.Mode()); err != nil { @@ -151,19 +151,19 @@ func extractCamoufoxArchive(ctx context.Context, archivePath string, destination closeTargetErr := targetFile.Close() closeSourceErr := source.Close() if copyErr != nil || closeTargetErr != nil || closeSourceErr != nil { - return fmt.Errorf("解压 Camoufox %s: %w", entry.Name, firstError(copyErr, closeTargetErr, closeSourceErr)) + return fmt.Errorf("extracting Camoufox %s: %w", entry.Name, firstError(copyErr, closeTargetErr, closeSourceErr)) } } return nil } -// contextReader 在复制过程中传播装配取消 +// contextReader propagates cancellation during copy operations. type contextReader struct { ctx context.Context reader io.Reader } -// Read 在每个数据块前检查装配取消 +// Read checks for cancellation before each read operation. func (reader contextReader) Read(buffer []byte) (int, error) { if err := reader.ctx.Err(); err != nil { return 0, err diff --git a/internal/camoufoxnative/executable.go b/internal/camoufoxnative/executable.go index 62f47bb..257fba6 100644 --- a/internal/camoufoxnative/executable.go +++ b/internal/camoufoxnative/executable.go @@ -9,7 +9,7 @@ import ( "strings" ) -// FindExecutable 定位源码环境或 Release 自带的 Camoufox +// FindExecutable locates Camoufox from the source environment or bundled release. func FindExecutable(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -41,7 +41,7 @@ func FindExecutable(ctx context.Context) (string, error) { } path, err := installCamoufox(ctx, name) if err != nil { - return "", fmt.Errorf("自动准备 Camoufox: %w", err) + return "", fmt.Errorf("automatically preparing Camoufox: %w", err) } return path, nil } @@ -55,21 +55,21 @@ func camoufoxExecutablePath() (string, error) { case "darwin": return filepath.Join("Camoufox.app", "Contents", "MacOS", "camoufox"), nil default: - return "", fmt.Errorf("Camoufox 不支持 %s", runtime.GOOS) + return "", fmt.Errorf("Camoufox does not support %s", runtime.GOOS) } } func validateCamoufoxExecutable(path string) (string, error) { absolute, err := filepath.Abs(path) if err != nil { - return "", fmt.Errorf("解析 Camoufox 路径: %w", err) + return "", fmt.Errorf("resolving Camoufox path: %w", err) } info, err := os.Stat(absolute) if err != nil { return "", err } if info.IsDir() { - return "", fmt.Errorf("Camoufox 路径是目录") + return "", fmt.Errorf("Camoufox path is a directory") } return absolute, nil } diff --git a/internal/camoufoxnative/fingerprint.go b/internal/camoufoxnative/fingerprint.go index f209bcb..c2dce4d 100644 --- a/internal/camoufoxnative/fingerprint.go +++ b/internal/camoufoxnative/fingerprint.go @@ -25,7 +25,7 @@ type savedFingerprint struct { Config map[string]any `json:"config"` } -// PersistAccountFingerprint 将隔离登录指纹保存到账户目录 +// PersistAccountFingerprint persists the isolated login fingerprint to the account directory. func PersistAccountFingerprint(sourceDirectory string, targetDirectory string) error { source := filepath.Join(sourceDirectory, "camoufox-fingerprint.json") data, err := os.ReadFile(source) @@ -33,19 +33,19 @@ func PersistAccountFingerprint(sourceDirectory string, targetDirectory string) e return nil } if err != nil { - return fmt.Errorf("读取隔离登录 Camoufox 指纹: %w", err) + return fmt.Errorf("reading isolated login Camoufox fingerprint: %w", err) } var saved savedFingerprint if err := json.Unmarshal(data, &saved); err != nil { - return fmt.Errorf("解析隔离登录 Camoufox 指纹: %w", err) + return fmt.Errorf("parsing isolated login Camoufox fingerprint: %w", err) } if len(saved.Config) == 0 { - return fmt.Errorf("隔离登录 Camoufox 指纹为空") + return fmt.Errorf("isolated login Camoufox fingerprint is empty") } return writeAccountCamoufoxConfig(filepath.Join(targetDirectory, "camoufox-fingerprint.json"), saved) } -// buildCamoufoxConfig 生成与实际 Camoufox 版本一致的 Windows Firefox 指纹 +// buildCamoufoxConfig generates a Windows Firefox fingerprint consistent with the actual Camoufox version. func buildCamoufoxConfig(ffVersion int, locale string, timezone string) (map[string]any, error) { locale = normalizeLocale(locale) locales := localeValues(locale) @@ -65,7 +65,7 @@ func buildCamoufoxConfig(ffVersion int, locale string, timezone string) (map[str }, }) if err != nil { - return nil, fmt.Errorf("生成 BrowserForge 指纹: %w", err) + return nil, fmt.Errorf("generating BrowserForge fingerprint: %w", err) } version := fmt.Sprintf("%d.0", ffVersion) userAgent := replaceFirefoxVersion(fingerprint.Navigator.UserAgent, version) @@ -131,7 +131,7 @@ func buildCamoufoxConfig(ffVersion int, locale string, timezone string) (map[str return config, nil } -// loadAccountCamoufoxConfig 按账户复用非敏感 Camoufox 指纹 +// loadAccountCamoufoxConfig reuses non-sensitive Camoufox fingerprints per account. func loadAccountCamoufoxConfig(storageStatePath string, ffVersion int, locale string, timezone string) (map[string]any, error) { locale = normalizeLocale(locale) timezone = strings.TrimSpace(timezone) @@ -151,14 +151,14 @@ func loadAccountCamoufoxConfig(storageStatePath string, ffVersion int, locale st return config, nil } if err != nil { - return nil, fmt.Errorf("读取账户 Camoufox 指纹: %w", err) + return nil, fmt.Errorf("reading account Camoufox fingerprint: %w", err) } var saved savedFingerprint if err := json.Unmarshal(data, &saved); err != nil { - return nil, fmt.Errorf("解析账户 Camoufox 指纹: %w", err) + return nil, fmt.Errorf("parsing account Camoufox fingerprint: %w", err) } if len(saved.Config) == 0 { - return nil, fmt.Errorf("账户 Camoufox 指纹为空") + return nil, fmt.Errorf("account Camoufox fingerprint is empty") } changed := false if saved.FirefoxVersion != ffVersion { @@ -222,19 +222,19 @@ func applyLocaleTimezone(config map[string]any, locale string, timezone string) func writeAccountCamoufoxConfig(path string, saved savedFingerprint) error { encoded, err := json.Marshal(saved) if err != nil { - return fmt.Errorf("编码账户 Camoufox 指纹: %w", err) + return fmt.Errorf("encoding account Camoufox fingerprint: %w", err) } if err := os.WriteFile(path, encoded, 0o600); err != nil { - return fmt.Errorf("写入账户 Camoufox 指纹: %w", err) + return fmt.Errorf("writing account Camoufox fingerprint: %w", err) } return nil } -// camoufoxEnvironment 将指纹 JSON 分片写入 Camoufox 环境变量 +// camoufoxEnvironment writes the fingerprint JSON chunks into Camoufox environment variables. func camoufoxEnvironment(config map[string]any) ([]string, error) { encoded, err := json.Marshal(config) if err != nil { - return nil, fmt.Errorf("编码 Camoufox 指纹: %w", err) + return nil, fmt.Errorf("encoding Camoufox fingerprint: %w", err) } values := make(map[string]string) for _, item := range os.Environ() { diff --git a/internal/camoufoxnative/launcher.go b/internal/camoufoxnative/launcher.go index 48a363c..77dd024 100644 --- a/internal/camoufoxnative/launcher.go +++ b/internal/camoufoxnative/launcher.go @@ -33,10 +33,10 @@ type browserProcess struct { closed bool } -// launchBrowser 启动 Camoufox 并返回原生 WebDriver BiDi 端点 +// launchBrowser starts Camoufox and returns the native WebDriver BiDi endpoint. func launchBrowser(ctx context.Context, options Options, config map[string]any) (*browserProcess, string, error) { if _, err := os.Stat(options.ExecutablePath); err != nil { - return nil, "", fmt.Errorf("Camoufox 不可用: %w", err) + return nil, "", fmt.Errorf("camoufox is unavailable: %w", err) } environment, err := camoufoxEnvironment(config) if err != nil { @@ -44,7 +44,7 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) } profile, err := os.MkdirTemp("", "aistudio-camoufox-*") if err != nil { - return nil, "", fmt.Errorf("创建 Camoufox profile: %w", err) + return nil, "", fmt.Errorf("creating Camoufox profile: %w", err) } prefs, err := firefoxPreferences(options.Proxy, options.ProxyBypass) if err != nil { @@ -78,7 +78,7 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) } if err := command.Start(); err != nil { _ = os.RemoveAll(profile) - return nil, "", fmt.Errorf("启动 Camoufox: %w", err) + return nil, "", fmt.Errorf("starting Camoufox: %w", err) } process := &browserProcess{ command: command, @@ -111,19 +111,19 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) process.mu.Unlock() _ = os.RemoveAll(profile) if err == nil { - err = errors.New("Camoufox 在报告 BiDi 端点前退出") + err = errors.New("camoufox exited before reporting BiDi endpoint") } return nil, "", err case <-timer.C: _ = process.Close() - return nil, "", fmt.Errorf("等待 Camoufox BiDi 端点超时: %s", timeout) + return nil, "", fmt.Errorf("timed out waiting for Camoufox BiDi endpoint: %s", timeout) case <-ctx.Done(): _ = process.Close() return nil, "", ctx.Err() } } -// Close 关闭 Camoufox 并删除隔离 profile +// Close closes Camoufox and removes the isolated profile. func (process *browserProcess) Close() error { return process.close(browserProcessCloseTimeout, terminateBrowserProcess) } @@ -151,7 +151,7 @@ func (process *browserProcess) close(timeout time.Duration, terminate browserPro select { case <-process.done: case <-closeCtx.Done(): - closeErr = errors.Join(closeErr, fmt.Errorf("等待 Camoufox 进程退出: %w", closeCtx.Err())) + closeErr = errors.Join(closeErr, fmt.Errorf("waiting for Camoufox process exit: %w", closeCtx.Err())) } } closeErr = errors.Join(closeErr, removeProfile(process.profile)) @@ -217,14 +217,14 @@ func firefoxPreferences(proxyValue, bypass string) (map[string]any, error) { } parsed, err := url.Parse(proxyValue) if err != nil || parsed.Hostname() == "" { - return nil, fmt.Errorf("Camoufox 代理 URL 无效") + return nil, fmt.Errorf("invalid Camoufox proxy URL") } if parsed.User != nil { - return nil, fmt.Errorf("Camoufox 原生代理暂不接受账号密码") + return nil, fmt.Errorf("native Camoufox proxy does not yet accept username and password") } port, err := strconv.Atoi(parsed.Port()) if err != nil || port <= 0 { - return nil, fmt.Errorf("Camoufox 代理缺少有效端口") + return nil, fmt.Errorf("camoufox proxy missing valid port") } prefs["network.proxy.type"] = 1 prefs["network.proxy.no_proxies_on"] = bypass @@ -245,7 +245,7 @@ func firefoxPreferences(proxyValue, bypass string) (map[string]any, error) { prefs["network.proxy.socks_version"] = 4 } default: - return nil, fmt.Errorf("Camoufox 代理协议必须是 http、https、socks4 或 socks5") + return nil, fmt.Errorf("camoufox proxy scheme must be http, https, socks4, or socks5") } return prefs, nil } @@ -269,7 +269,7 @@ func writeUserJS(profile string, prefs map[string]any) error { builder.WriteString(");\n") } if err := os.WriteFile(filepath.Join(profile, "user.js"), []byte(builder.String()), 0o600); err != nil { - return fmt.Errorf("写入 Camoufox profile: %w", err) + return fmt.Errorf("writing Camoufox profile: %w", err) } return nil } @@ -283,6 +283,6 @@ func firefoxPrefLiteral(value any) (string, error) { case int: return strconv.Itoa(typed), nil default: - return "", fmt.Errorf("不支持 %T", value) + return "", fmt.Errorf("unsupported %T", value) } } diff --git a/internal/camoufoxnative/login.go b/internal/camoufoxnative/login.go index 51c9d9d..ca35fa2 100644 --- a/internal/camoufoxnative/login.go +++ b/internal/camoufoxnative/login.go @@ -24,7 +24,7 @@ const loginEmailExpression = `(() => { return values.join('\n').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0] || ''; })()` -// LoginOptions 定义纯 Go 隔离登录环境 +// LoginOptions defines the isolated login environment. type LoginOptions struct { ExecutablePath string Directory string @@ -36,7 +36,7 @@ type LoginOptions struct { Log io.Writer } -// LoginResult 返回隔离浏览器导出的 Playwright storage state +// LoginResult returns the Playwright storage state exported from the isolated browser. type LoginResult struct { StorageStateJSON []byte Email string @@ -44,7 +44,7 @@ type LoginResult struct { VerifiedAt time.Time } -// LoginVerification 返回已有登录态的页面验证结果 +// LoginVerification returns the page verification result for an existing login state. type LoginVerification struct { Authenticated bool PageURL string @@ -59,7 +59,7 @@ type loginSession struct { contextID string } -// Login 启动可见隔离 Camoufox 并在 AI Studio 可用后导出认证状态 +// Login launches a visible isolated Camoufox instance and exports authentication state once AI Studio is ready. func Login(ctx context.Context, options LoginOptions) (result LoginResult, err error) { options, err = validateLoginOptions(options) if err != nil { @@ -81,11 +81,11 @@ func Login(ctx context.Context, options LoginOptions) (result LoginResult, err e } email, err := session.client.evaluateString(loginCtx, session.contextID, loginEmailExpression) if err != nil { - return LoginResult{}, fmt.Errorf("读取 AI Studio 登录邮箱: %w", err) + return LoginResult{}, fmt.Errorf("reading AI Studio login email: %w", err) } email = strings.ToLower(strings.TrimSpace(email)) if email == "" { - return LoginResult{}, errors.New("AI Studio 页面没有登录邮箱") + return LoginResult{}, errors.New("no login email found on AI Studio page") } state, err := session.exportStorageState(loginCtx, origins) if err != nil { @@ -93,12 +93,12 @@ func Login(ctx context.Context, options LoginOptions) (result LoginResult, err e } encoded, err := json.Marshal(state) if err != nil { - return LoginResult{}, fmt.Errorf("编码 storage state: %w", err) + return LoginResult{}, fmt.Errorf("encoding storage state: %w", err) } return LoginResult{StorageStateJSON: encoded, Email: email, PageURL: pageURL, VerifiedAt: time.Now().UTC()}, nil } -// Verify 使用无头隔离 Camoufox 验证已有 Playwright storage state +// Verify validates an existing Playwright storage state using a headless isolated Camoufox instance. func Verify(ctx context.Context, options LoginOptions, storageStateJSON []byte) (verification LoginVerification, err error) { options, err = validateLoginOptions(options) if err != nil { @@ -106,10 +106,10 @@ func Verify(ctx context.Context, options LoginOptions, storageStateJSON []byte) } var state storageState if err := json.Unmarshal(storageStateJSON, &state); err != nil { - return LoginVerification{}, fmt.Errorf("解析 storage state: %w", err) + return LoginVerification{}, fmt.Errorf("parsing storage state: %w", err) } if len(state.Cookies) == 0 { - return LoginVerification{}, errors.New("storage state 没有 Cookie") + return LoginVerification{}, errors.New("storage state contains no cookies") } verifyCtx, cancel := context.WithTimeout(ctx, options.Timeout) defer cancel() @@ -136,20 +136,20 @@ func validateLoginOptions(options LoginOptions) (LoginOptions, error) { options.ExecutablePath = strings.TrimSpace(options.ExecutablePath) options.Directory = strings.TrimSpace(options.Directory) if options.ExecutablePath == "" { - return LoginOptions{}, errors.New("缺少 Camoufox 路径") + return LoginOptions{}, errors.New("missing Camoufox path") } if options.Directory == "" { - return LoginOptions{}, errors.New("缺少隔离登录目录") + return LoginOptions{}, errors.New("missing isolated login directory") } directory, err := filepath.Abs(options.Directory) if err != nil { - return LoginOptions{}, fmt.Errorf("解析隔离登录目录: %w", err) + return LoginOptions{}, fmt.Errorf("resolving isolated login directory: %w", err) } if err := os.MkdirAll(directory, 0o700); err != nil { - return LoginOptions{}, fmt.Errorf("创建隔离登录目录: %w", err) + return LoginOptions{}, fmt.Errorf("creating isolated login directory: %w", err) } if options.Timeout <= 0 { - return LoginOptions{}, errors.New("隔离登录超时必须为正数") + return LoginOptions{}, errors.New("isolated login timeout must be positive") } options.Directory = directory return options, nil @@ -185,7 +185,7 @@ func startLoginSession(ctx context.Context, options LoginOptions, headless bool, dialer := websocket.Dialer{HandshakeTimeout: 30 * time.Second} connection, _, err := dialer.DialContext(ctx, endpoint, nil) if err != nil { - return nil, fmt.Errorf("连接 Camoufox BiDi: %w", err) + return nil, fmt.Errorf("connecting to Camoufox BiDi: %w", err) } session.connection = connection session.client = newBiDiClient(connection) @@ -198,12 +198,12 @@ func startLoginSession(ctx context.Context, options LoginOptions, headless bool, } contexts, _ := tree["contexts"].([]any) if len(contexts) == 0 { - return nil, errors.New("Camoufox BiDi 未返回初始 tab") + return nil, errors.New("Camoufox BiDi did not return an initial tab") } root, _ := contexts[0].(map[string]any) session.contextID, _ = root["context"].(string) if session.contextID == "" { - return nil, errors.New("Camoufox BiDi 初始 tab 无效") + return nil, errors.New("Camoufox BiDi initial tab is invalid") } if len(state.Origins) != 0 { if err := session.client.installLocalStorage(ctx, session.contextID, state.Origins); err != nil { @@ -220,7 +220,7 @@ func startLoginSession(ctx context.Context, options LoginOptions, headless bool, "url": aiStudioOrigin + "/prompts/new_chat", "wait": "interactive", }); err != nil && !strings.Contains(err.Error(), "NS_ERROR_ABORT") { - return nil, fmt.Errorf("导航 AI Studio: %w", err) + return nil, fmt.Errorf("navigating to AI Studio: %w", err) } failed = false return session, nil @@ -234,7 +234,7 @@ func (session *loginSession) waitLogin(ctx context.Context, origins map[string]s pageURL, err := session.client.evaluateString(ctx, session.contextID, "location.href") if err != nil { if !retryablePageEvaluation(err) { - return "", fmt.Errorf("读取隔离登录页面: %w", err) + return "", fmt.Errorf("reading isolated login page: %w", err) } if err := waitContext(ctx, 300*time.Millisecond); err != nil { return "", err @@ -244,7 +244,7 @@ func (session *loginSession) waitLogin(ctx context.Context, origins map[string]s session.captureCurrentOrigin(ctx, pageURL, origins) ready, readyErr := session.client.evaluateBool(ctx, session.contextID, promptReadyExpression) if readyErr != nil && !retryablePageEvaluation(readyErr) { - return "", fmt.Errorf("检查隔离登录页面: %w", readyErr) + return "", fmt.Errorf("checking isolated login page: %w", readyErr) } if readyErr == nil && ready && strings.HasPrefix(pageURL, aiStudioOrigin+"/") { return pageURL, nil @@ -263,7 +263,7 @@ func (session *loginSession) waitVerification(ctx context.Context) (string, bool pageURL, err := session.client.evaluateString(ctx, session.contextID, "location.href") if err != nil { if !retryablePageEvaluation(err) { - return "", false, "", fmt.Errorf("读取隔离验证页面: %w", err) + return "", false, "", fmt.Errorf("reading isolated verification page: %w", err) } if err := waitContext(ctx, 200*time.Millisecond); err != nil { return "", false, "", err @@ -271,11 +271,11 @@ func (session *loginSession) waitVerification(ctx context.Context) (string, bool continue } if isGoogleLoginURL(pageURL) { - return pageURL, false, "AI Studio 登录已失效", nil + return pageURL, false, "AI Studio login expired", nil } ready, err := session.client.evaluateBool(ctx, session.contextID, promptReadyExpression) if err != nil && !retryablePageEvaluation(err) { - return "", false, "", fmt.Errorf("检查隔离验证页面: %w", err) + return "", false, "", fmt.Errorf("checking isolated verification page: %w", err) } if err == nil && ready && strings.HasPrefix(pageURL, aiStudioOrigin+"/") { return pageURL, true, "", nil @@ -307,7 +307,7 @@ func (session *loginSession) captureCurrentOrigin(ctx context.Context, pageURL s func (session *loginSession) exportStorageState(ctx context.Context, origins map[string]storageOrigin) (storageState, error) { result, err := session.client.command(ctx, "storage.getCookies", map[string]any{}) if err != nil { - return storageState{}, fmt.Errorf("导出 Cookie: %w", err) + return storageState{}, fmt.Errorf("exporting cookies: %w", err) } items, _ := result["cookies"].([]any) cookies := make([]storageCookie, 0, len(items)) @@ -319,7 +319,7 @@ func (session *loginSession) exportStorageState(ctx context.Context, origins map } } if len(cookies) == 0 { - return storageState{}, errors.New("隔离浏览器没有可导出的 Cookie") + return storageState{}, errors.New("isolated browser has no exportable cookies") } sort.SliceStable(cookies, func(left, right int) bool { if cookies[left].Domain != cookies[right].Domain { @@ -376,7 +376,7 @@ func decodeStorageCookie(value map[string]any) (storageCookie, bool) { }, true } -// Close 结束登录 session 并清理隔离 profile +// Close terminates the login session and cleans up the isolated profile. func (session *loginSession) Close() error { if session == nil { return nil diff --git a/internal/camoufoxnative/page.go b/internal/camoufoxnative/page.go index 37f2411..0045692 100644 --- a/internal/camoufoxnative/page.go +++ b/internal/camoufoxnative/page.go @@ -6,31 +6,31 @@ import ( "fmt" ) -// pageDOMHelpers 定义官网页面共用的可见目标与按钮状态判断 +// pageDOMHelpers defines shared visibility and button state checks for official web pages. const pageDOMHelpers = ` const visible = element => element.checkVisibility({visibilityProperty: true}); const uniqueVisible = (selector, label) => { const items = [...document.querySelectorAll(selector)].filter(visible); - if (items.length > 1) throw new Error(label + ' 匹配多个可见目标'); + if (items.length > 1) throw new Error(label + ' matched multiple visible elements'); return items[0]; }; const buttonEnabled = button => !button.matches(':disabled') && !button.closest('[aria-disabled="true"]'); ` -// promptReadyExpression 检查登录与生成流程使用的同一个可见输入框 +// promptReadyExpression checks the visible input box used across login and generation flows. const promptReadyExpression = `(() => {` + pageDOMHelpers + ` - return Boolean(uniqueVisible('ms-prompt-box textarea', '提示词输入框')); + return Boolean(uniqueVisible('ms-prompt-box textarea', 'prompt textarea')); })()` -// workerPageReadyExpression 等待输入框出现或页面跳转到登录入口 +// workerPageReadyExpression waits for the prompt textarea to appear or page redirects to login. const workerPageReadyExpression = `(location.hostname === 'accounts.google.com' || ` + promptReadyExpression + `)` -// fillPromptExpression 向当前可见提示框写入文本并通知页面表单 +// fillPromptExpression writes text to the currently visible prompt box and dispatches input events. func fillPromptExpression(prompt string) string { encoded, _ := json.Marshal(prompt) return fmt.Sprintf(`(() => {`+pageDOMHelpers+` - const textarea = uniqueVisible('ms-prompt-box textarea', '提示词输入框'); - if (!textarea) throw new Error('提示词输入框不存在'); + const textarea = uniqueVisible('ms-prompt-box textarea', 'prompt textarea'); + if (!textarea) throw new Error('prompt textarea does not exist'); const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; setter.call(textarea, %s); textarea.dispatchEvent(new InputEvent('input', {bubbles: true, inputType: 'insertText', data: %s})); @@ -39,16 +39,16 @@ func fillPromptExpression(prompt string) string { })()`, encoded, encoded) } -// submitPromptExpression 点击官网当前可见且启用的提交按钮 +// submitPromptExpression clicks the currently visible and enabled run button. const submitPromptExpression = `(() => {` + pageDOMHelpers + ` - const button = uniqueVisible('ms-run-button button', '官网 Run 按钮'); - if (!button) throw new Error('官网 Run 按钮不存在'); - if (!buttonEnabled(button)) throw new Error('官网 Run 按钮已禁用'); + const button = uniqueVisible('ms-run-button button', 'official Run button'); + if (!button) throw new Error('official Run button does not exist'); + if (!buttonEnabled(button)) throw new Error('official Run button is disabled'); button.click(); return true; })()` -// dismissOverlaysExpression 关闭官网已知且可交互的启动弹层 +// dismissOverlaysExpression dismisses known and interactive startup overlays. const dismissOverlaysExpression = `(() => {` + pageDOMHelpers + ` const selectors = [ 'ms-g1-welcome-dialog button[aria-label="Close dialog"]', @@ -57,7 +57,7 @@ const dismissOverlaysExpression = `(() => {` + pageDOMHelpers + ` ]; let clicked = 0; for (const selector of selectors) { - const button = uniqueVisible(selector, '启动弹层按钮'); + const button = uniqueVisible(selector, 'startup overlay button'); if (button && buttonEnabled(button)) { button.click(); clicked++; @@ -66,10 +66,10 @@ const dismissOverlaysExpression = `(() => {` + pageDOMHelpers + ` return clicked; })()` -// dismissKnownOverlays 关闭官网已知启动弹层 +// dismissKnownOverlays dismisses known startup overlays. func dismissKnownOverlays(ctx context.Context, client *bidiClient, contextID string) error { if _, err := client.evaluate(ctx, contextID, dismissOverlaysExpression); err != nil { - return fmt.Errorf("处理 AI Studio 启动覆盖层: %w", err) + return fmt.Errorf("handling AI Studio startup overlays: %w", err) } return nil } diff --git a/internal/camoufoxnative/process_other.go b/internal/camoufoxnative/process_other.go index b6edab3..0ef9ea2 100644 --- a/internal/camoufoxnative/process_other.go +++ b/internal/camoufoxnative/process_other.go @@ -10,12 +10,12 @@ import ( "syscall" ) -// configureBrowserProcess 将 Camoufox 隔离到独立进程组 +// configureBrowserProcess isolates Camoufox into an independent process group. func configureBrowserProcess(command *exec.Cmd, _ bool) { command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } -// terminateBrowserProcess 结束 Camoufox 进程组 +// terminateBrowserProcess terminates the Camoufox process group. func terminateBrowserProcess(ctx context.Context, command *exec.Cmd) error { if command == nil || command.Process == nil { return nil diff --git a/internal/camoufoxnative/process_windows.go b/internal/camoufoxnative/process_windows.go index dbbf804..0ce95bb 100644 --- a/internal/camoufoxnative/process_windows.go +++ b/internal/camoufoxnative/process_windows.go @@ -16,7 +16,7 @@ import ( const windowsStillActive = 259 -// configureBrowserProcess 将 Camoufox 隔离到独立 Windows 进程组 +// configureBrowserProcess isolates Camoufox into an independent Windows process group. func configureBrowserProcess(command *exec.Cmd, headless bool) { attributes := &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP} if headless { @@ -26,7 +26,7 @@ func configureBrowserProcess(command *exec.Cmd, headless bool) { command.SysProcAttr = attributes } -// terminateBrowserProcess 结束 Camoufox 及其全部子进程 +// terminateBrowserProcess terminates Camoufox and all of its child processes. func terminateBrowserProcess(ctx context.Context, command *exec.Cmd) error { if command == nil || command.Process == nil { return nil @@ -44,12 +44,12 @@ func terminateBrowserProcess(ctx context.Context, command *exec.Cmd) error { if !browserProcessActive(pid) { return nil } - taskkillErr := fmt.Errorf("taskkill 结束 Camoufox 进程树 PID=%d: %w: %s", pid, err, output) + taskkillErr := fmt.Errorf("taskkill terminating Camoufox process tree PID=%d: %w: %s", pid, err, output) directKillErr := command.Process.Kill() if directKillErr == nil || errors.Is(directKillErr, os.ErrProcessDone) || !browserProcessActive(pid) { return nil } - terminateErr := errors.Join(taskkillErr, fmt.Errorf("Process.Kill 结束 Camoufox PID=%d: %w", pid, directKillErr)) + terminateErr := errors.Join(taskkillErr, fmt.Errorf("Process.Kill terminating Camoufox PID=%d: %w", pid, directKillErr)) if ctxErr := ctx.Err(); ctxErr != nil { return errors.Join(terminateErr, ctxErr) } diff --git a/internal/camoufoxnative/protected.go b/internal/camoufoxnative/protected.go index a0ac34a..d42b205 100644 --- a/internal/camoufoxnative/protected.go +++ b/internal/camoufoxnative/protected.go @@ -15,7 +15,7 @@ import ( "time" ) -// ProtectedResponse 表示由固定指纹浏览器流式返回的 MakerSuite 响应 +// ProtectedResponse represents the MakerSuite response streamed from a fixed-fingerprint browser. type ProtectedResponse struct { StatusCode int Header http.Header @@ -34,7 +34,7 @@ type protectedChunk struct { Error string `json:"error"` } -// SendProtected 通过固定指纹 Camoufox 页面发送请求,保留原生 TLS、HTTP/2、请求头、Cookie 和页面指纹 +// SendProtected sends a request via the fixed-fingerprint Camoufox page, preserving native TLS, HTTP/2, headers, cookies, and page fingerprint. func (worker *Worker) SendProtected(ctx context.Context, rawURL string, headers http.Header, body []byte) (*ProtectedResponse, error) { if err := ctx.Err(); err != nil { return nil, err @@ -42,7 +42,7 @@ func (worker *Worker) SendProtected(ctx context.Context, rawURL string, headers worker.mu.Lock() if worker.closed { worker.mu.Unlock() - return nil, errors.New("Camoufox runtime 已关闭") + return nil, errors.New("Camoufox runtime is closed") } client := worker.client contextID := worker.contextID @@ -51,7 +51,7 @@ func (worker *Worker) SendProtected(ctx context.Context, rawURL string, headers encodedURL, _ := json.Marshal(rawURL) encodedHeaders, err := json.Marshal(browserRequestHeaders(headers)) if err != nil { - return nil, fmt.Errorf("编码浏览器请求头: %w", err) + return nil, fmt.Errorf("encoding browser request headers: %w", err) } encodedBody, _ := json.Marshal(string(body)) requestID := rand.Text() @@ -99,7 +99,7 @@ func (worker *Worker) SendProtected(ctx context.Context, rawURL string, headers _, err = client.evaluateBool(startCtx, contextID, expression) cancelStart() if err != nil { - return nil, errors.Join(fmt.Errorf("浏览器发送受保护请求: %w", err), worker.cancelProtectedRequest(requestID)) + return nil, errors.Join(fmt.Errorf("sending protected request via browser: %w", err), worker.cancelProtectedRequest(requestID)) } metadata, err := worker.waitProtectedHeaders(ctx, requestID) if err != nil { @@ -122,7 +122,7 @@ func (worker *Worker) SendProtected(ctx context.Context, rawURL string, headers }, nil } -// waitProtectedHeaders 通过短命令读取异步请求的响应头 +// waitProtectedHeaders reads the response headers of an asynchronous request via short-polling commands. func (worker *Worker) waitProtectedHeaders(ctx context.Context, requestID string) (protectedResponseMetadata, error) { encodedID, _ := json.Marshal(requestID) expression := fmt.Sprintf(`(() => { @@ -141,7 +141,7 @@ func (worker *Worker) waitProtectedHeaders(ctx context.Context, requestID string } var metadata protectedResponseMetadata if err := json.Unmarshal([]byte(value), &metadata); err != nil { - return metadata, fmt.Errorf("解析浏览器响应元数据: %w", err) + return metadata, fmt.Errorf("parsing browser response metadata: %w", err) } if metadata.Status > 0 { return metadata, nil @@ -175,18 +175,18 @@ func browserRequestHeaders(headers http.Header) [][2]string { return result } -// StorageCookies 导出当前固定指纹浏览器 Cookie,供账户状态签名和持久化 +// StorageCookies exports the current fixed-fingerprint browser cookies for account state signing and persistence. func (worker *Worker) StorageCookies(ctx context.Context) ([]byte, error) { worker.mu.Lock() if worker.closed { worker.mu.Unlock() - return nil, errors.New("Camoufox runtime 已关闭") + return nil, errors.New("Camoufox runtime is closed") } client := worker.client worker.mu.Unlock() result, err := client.command(ctx, "storage.getCookies", map[string]any{}) if err != nil { - return nil, fmt.Errorf("导出浏览器 Cookie: %w", err) + return nil, fmt.Errorf("exporting browser cookies: %w", err) } items, _ := result["cookies"].([]any) cookies := make([]storageCookie, 0, len(items)) @@ -242,7 +242,7 @@ func (body *protectedResponseBody) Read(target []byte) (int, error) { for _, data := range chunk.Data { body.buffer, err = base64.StdEncoding.AppendDecode(body.buffer, []byte(data)) if err != nil { - return 0, fmt.Errorf("解码浏览器响应块: %w", err) + return 0, fmt.Errorf("decoding browser response chunk: %w", err) } } body.done = chunk.Done @@ -299,7 +299,7 @@ func (worker *Worker) readProtectedChunk(requestID string) (protectedChunk, erro } var chunk protectedChunk if err := json.Unmarshal([]byte(value), &chunk); err != nil { - return protectedChunk{}, fmt.Errorf("解析浏览器响应块: %w", err) + return protectedChunk{}, fmt.Errorf("parsing browser response chunk: %w", err) } return chunk, nil } diff --git a/internal/camoufoxnative/types.go b/internal/camoufoxnative/types.go index ab8bdd9..9989b66 100644 --- a/internal/camoufoxnative/types.go +++ b/internal/camoufoxnative/types.go @@ -6,25 +6,25 @@ import ( "time" ) -// StartupStage 表示 Camoufox runtime 的启动阶段 +// StartupStage represents the startup stage of the Camoufox runtime. type StartupStage string const ( - // StartupPreparingBrowser 表示正在准备浏览器配置 + // StartupPreparingBrowser indicates that the browser configuration is being prepared. StartupPreparingBrowser StartupStage = "preparing_browser" - // StartupLaunchingBrowser 表示正在启动浏览器进程 + // StartupLaunchingBrowser indicates that the browser process is being launched. StartupLaunchingBrowser StartupStage = "launching_browser" - // StartupConnectingBiDi 表示正在连接 WebDriver BiDi + // StartupConnectingBiDi indicates that WebDriver BiDi is connecting. StartupConnectingBiDi StartupStage = "connecting_bidi" - // StartupLoadingAIStudio 表示正在载入 AI Studio 页面 + // StartupLoadingAIStudio indicates that the AI Studio page is loading. StartupLoadingAIStudio StartupStage = "loading_ai_studio" - // StartupLocatingWAA 表示正在定位 WAA 服务 + // StartupLocatingWAA indicates that the WAA service is being located. StartupLocatingWAA StartupStage = "locating_waa" - // StartupBootstrappingWAA 表示正在执行 WAA Bootstrap + // StartupBootstrappingWAA indicates that WAA bootstrap is in progress. StartupBootstrappingWAA StartupStage = "bootstrapping_waa" ) -// Options 定义单个 AI Studio 账户的原生 Camoufox runtime +// Options defines the native Camoufox runtime for a single AI Studio account. type Options struct { ExecutablePath string StorageStatePath string @@ -47,7 +47,7 @@ func (options Options) reportStartup(stage StartupStage) { } } -// State 返回原生 runtime 的当前页面与 bootstrap 结果 +// State returns the current page and bootstrap results of the native runtime. type State struct { PID int PageURL string diff --git a/internal/camoufoxnative/worker.go b/internal/camoufoxnative/worker.go index 769cb79..57bd85b 100644 --- a/internal/camoufoxnative/worker.go +++ b/internal/camoufoxnative/worker.go @@ -29,7 +29,7 @@ var publicHeaderNames = []string{ "user-agent", } -// Worker 保存单个账户的长驻 Camoufox 与 WAA service +// Worker maintains a long-running Camoufox instance and WAA service for a single account. type Worker struct { mu sync.Mutex process *browserProcess @@ -40,14 +40,14 @@ type Worker struct { closed bool } -// Start 启动隔离 Camoufox 并完成一次官网 WAA bootstrap +// Start launches an isolated Camoufox instance and performs a bootstrap with the official WAA service. func Start(ctx context.Context, options Options) (*Worker, error) { state, err := loadStorageState(options.StorageStatePath) if err != nil { return nil, err } if options.Model == "" { - return nil, errors.New("WAA bootstrap 缺少实时目录聊天模型") + return nil, errors.New("WAA bootstrap requires a chat model from live catalog") } if options.BootstrapPrompt == "" { options.BootstrapPrompt = fmt.Sprintf("AIStudio2API bootstrap %d", time.Now().UnixNano()) @@ -74,7 +74,7 @@ func Start(ctx context.Context, options Options) (*Worker, error) { dialer := websocket.Dialer{HandshakeTimeout: 30 * time.Second} connection, _, err := dialer.DialContext(ctx, endpoint, nil) if err != nil { - return nil, fmt.Errorf("连接 Camoufox BiDi: %w", err) + return nil, fmt.Errorf("connecting to Camoufox BiDi: %w", err) } worker.connection = connection worker.client = newBiDiClient(connection) @@ -104,7 +104,7 @@ func (worker *Worker) abort() error { return process.Close() } -// ProtocolHeaders 返回官网为 GenerateContent 构造的七个公共头 +// ProtocolHeaders returns the seven common headers constructed by the official site for GenerateContent. func (worker *Worker) ProtocolHeaders(ctx context.Context) (http.Header, error) { worker.mu.Lock() defer worker.mu.Unlock() @@ -112,29 +112,29 @@ func (worker *Worker) ProtocolHeaders(ctx context.Context) (http.Header, error) return nil, err } if worker.closed { - return nil, errors.New("Camoufox runtime 已关闭") + return nil, errors.New("Camoufox runtime is closed") } return worker.state.Headers.Clone(), nil } -// Proof 同步官网 prompt 状态后为 SHA-256 digest 生成 fresh WAA proof +// Proof synchronizes the prompt state on the official page and generates a fresh WAA proof for the SHA-256 digest. func (worker *Worker) Proof(ctx context.Context, digest string, prompt string) (string, error) { worker.mu.Lock() defer worker.mu.Unlock() if worker.closed { - return "", errors.New("Camoufox runtime 已关闭") + return "", errors.New("Camoufox runtime is closed") } deadline := time.Now().Add(5 * time.Second) for { value, err := worker.client.evaluateString(ctx, worker.contextID, fillPromptExpression(prompt)) if err != nil { - return "", fmt.Errorf("同步官网 prompt: %w", err) + return "", fmt.Errorf("synchronizing official page prompt: %w", err) } if value == prompt { break } if time.Now().After(deadline) { - return "", errors.New("官网 prompt 状态未同步") + return "", errors.New("official page prompt state not synchronized") } if err := waitContext(ctx, 100*time.Millisecond); err != nil { return "", err @@ -142,15 +142,15 @@ func (worker *Worker) Proof(ctx context.Context, digest string, prompt string) ( } proof, err := worker.client.evaluateString(ctx, worker.contextID, takeProofExpression(digest)) if err != nil { - return "", fmt.Errorf("生成 fresh WAA proof: %w", err) + return "", fmt.Errorf("generating fresh WAA proof: %w", err) } if !strings.HasPrefix(proof, "!") { - return "", errors.New("fresh WAA proof 前缀无效") + return "", errors.New("invalid fresh WAA proof prefix") } return proof, nil } -// State 返回 runtime 的不可变状态副本 +// State returns an immutable copy of the runtime state. func (worker *Worker) State() State { worker.mu.Lock() defer worker.mu.Unlock() @@ -159,7 +159,7 @@ func (worker *Worker) State() State { return state } -// Close 结束 BiDi session 并清理 Camoufox profile +// Close terminates the BiDi session and cleans up the Camoufox profile. func (worker *Worker) Close() error { if worker == nil { return nil @@ -199,12 +199,12 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st } contexts, _ := tree["contexts"].([]any) if len(contexts) == 0 { - return errors.New("Camoufox BiDi 未返回初始 tab") + return errors.New("Camoufox BiDi did not return an initial tab") } root, _ := contexts[0].(map[string]any) contextID, _ := root["context"].(string) if contextID == "" { - return errors.New("Camoufox BiDi 初始 tab 无效") + return errors.New("Camoufox BiDi initial tab is invalid") } worker.contextID = contextID if err := client.installLocalStorage(ctx, contextID, storage.Origins); err != nil { @@ -223,18 +223,18 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st "url": target, "wait": "interactive", }); err != nil && !strings.Contains(err.Error(), "NS_ERROR_ABORT") { - return fmt.Errorf("导航 AI Studio: %w", err) + return fmt.Errorf("navigating to AI Studio: %w", err) } if err := client.waitFor(ctx, contextID, workerPageReadyExpression, 120*time.Second); err != nil { pageURL, _ := client.evaluateString(ctx, contextID, "location.href") - return fmt.Errorf("AI Studio 输入框未就绪 url=%s: %w", pageURL, err) + return fmt.Errorf("AI Studio prompt textarea not ready url=%s: %w", pageURL, err) } pageURL, err := client.evaluateString(ctx, contextID, "location.href") if err != nil { return err } if strings.Contains(pageURL, "accounts.google.com") { - return fmt.Errorf("隔离登录态失效 url=%s", pageURL) + return fmt.Errorf("isolated login state expired url=%s", pageURL) } if err := dismissKnownOverlays(ctx, client, contextID); err != nil { return err @@ -250,7 +250,7 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st options.reportStartup(StartupBootstrappingWAA) filled, err := client.evaluateString(ctx, contextID, fillPromptExpression(options.BootstrapPrompt)) if err != nil || filled != options.BootstrapPrompt { - return fmt.Errorf("填写 bootstrap 提示词失败 value=%q err=%v", filled, err) + return fmt.Errorf("failed to fill bootstrap prompt value=%q err=%v", filled, err) } if _, err := client.command(ctx, "session.subscribe", map[string]any{ "events": []string{"network.beforeRequestSent"}, @@ -269,34 +269,34 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st }}, }) if err != nil { - return fmt.Errorf("安装 GenerateContent 拦截: %w", err) + return fmt.Errorf("installing GenerateContent intercept: %w", err) } interceptID, _ := intercept["intercept"].(string) if interceptID == "" { - return errors.New("GenerateContent 拦截 ID 无效") + return errors.New("invalid GenerateContent intercept ID") } if _, err := client.evaluate(ctx, contextID, submitPromptExpression); err != nil { - return fmt.Errorf("提交官网提示词: %w", err) + return fmt.Errorf("submitting prompt on official page: %w", err) } if err := client.waitFor(ctx, contextID, "Boolean(window.__aistudioWaaService)", 60*time.Second); err != nil { - return fmt.Errorf("官网 WAA service 未暴露: %w", err) + return fmt.Errorf("official WAA service not exposed: %w", err) } requestID, err := client.waitBlockedGenerateRequest(ctx, contextID, 60*time.Second) if err != nil { return err } if _, err := client.command(ctx, "network.failRequest", map[string]any{"request": requestID}); err != nil { - return fmt.Errorf("终止 bootstrap GenerateContent: %w", err) + return fmt.Errorf("aborting bootstrap GenerateContent: %w", err) } if _, err := client.command(ctx, "network.removeIntercept", map[string]any{"intercept": interceptID}); err != nil { - return fmt.Errorf("移除 GenerateContent 拦截: %w", err) + return fmt.Errorf("removing GenerateContent intercept: %w", err) } actualModel, err := capturedBootstrapModel(ctx, client, contextID) if err != nil { return err } if actualModel != strings.TrimPrefix(options.Model, "models/") { - return fmt.Errorf("官网初始化页面模型不匹配 expected=%s actual=%s", options.Model, actualModel) + return fmt.Errorf("official page initialization model mismatch expected=%s actual=%s", options.Model, actualModel) } restored, err := client.evaluateBool(ctx, contextID, `(() => { if (typeof window.__aistudioRestoreBootstrapCapture !== 'function') return false; @@ -305,10 +305,10 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st return true; })()`) if err != nil { - return fmt.Errorf("移除 bootstrap 请求捕获: %w", err) + return fmt.Errorf("removing bootstrap request capture: %w", err) } if !restored { - return errors.New("bootstrap 请求捕获未安装") + return errors.New("bootstrap request capture was not installed") } headers := make(http.Header, len(publicHeaderNames)) for _, name := range publicHeaderNames { @@ -319,14 +319,14 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st } for _, name := range []string{"user-agent", "x-goog-api-key", "x-goog-authuser", "x-user-agent"} { if headers.Get(name) == "" { - return fmt.Errorf("官网 GenerateContent 缺少必要公共头 %s", name) + return fmt.Errorf("official GenerateContent missing required header %s", name) } } if _, err := client.command(ctx, "session.unsubscribe", map[string]any{ "events": []string{"network.beforeRequestSent"}, "contexts": []string{contextID}, }); err != nil { - return fmt.Errorf("停止 GenerateContent 网络事件订阅: %w", err) + return fmt.Errorf("stopping GenerateContent network event subscription: %w", err) } userAgent, _ := client.evaluateString(ctx, contextID, "navigator.userAgent") platform, _ := client.evaluateString(ctx, contextID, "navigator.platform") @@ -402,17 +402,17 @@ func installBootstrapRequestCapture(ctx context.Context, client *bidiClient, con })()`, encodedPath) installed, err := client.evaluateBool(ctx, contextID, expression) if err != nil { - return fmt.Errorf("安装 bootstrap 请求捕获: %w", err) + return fmt.Errorf("installing bootstrap request capture: %w", err) } if !installed { - return errors.New("安装 bootstrap 请求捕获失败") + return errors.New("failed to install bootstrap request capture") } return nil } func capturedBootstrapModel(ctx context.Context, client *bidiClient, contextID string) (string, error) { if err := client.waitFor(ctx, contextID, "typeof window.__aistudioBootstrapRequestBody === 'string'", 5*time.Second); err != nil { - return "", fmt.Errorf("官网 bootstrap 请求正文未捕获: %w", err) + return "", fmt.Errorf("official bootstrap request body not captured: %w", err) } body, err := client.evaluateString(ctx, contextID, `(() => { window.__aistudioRestoreBootstrapCapture?.(); @@ -423,15 +423,15 @@ func capturedBootstrapModel(ctx context.Context, client *bidiClient, contextID s } var wire []any if err := json.Unmarshal([]byte(body), &wire); err != nil { - return "", fmt.Errorf("解析官网 bootstrap 请求正文: %w", err) + return "", fmt.Errorf("parsing official bootstrap request body: %w", err) } if len(wire) == 0 { - return "", errors.New("官网 bootstrap 请求缺少模型") + return "", errors.New("official bootstrap request missing model") } model, _ := wire[0].(string) model = strings.TrimPrefix(strings.TrimSpace(model), "models/") if model == "" { - return "", errors.New("官网 bootstrap 请求模型无效") + return "", errors.New("official bootstrap request model is invalid") } return model, nil } @@ -439,14 +439,14 @@ func capturedBootstrapModel(ctx context.Context, client *bidiClient, contextID s func loadStorageState(path string) (storageState, error) { data, err := os.ReadFile(path) if err != nil { - return storageState{}, fmt.Errorf("读取 storage state: %w", err) + return storageState{}, fmt.Errorf("reading storage state: %w", err) } var state storageState if err := json.Unmarshal(data, &state); err != nil { - return storageState{}, fmt.Errorf("解析 storage state: %w", err) + return storageState{}, fmt.Errorf("parsing storage state: %w", err) } if len(state.Cookies) == 0 { - return storageState{}, errors.New("storage state 没有 Cookie") + return storageState{}, errors.New("storage state contains no cookies") } return state, nil } diff --git a/internal/chromeauth/abe_unsupported.go b/internal/chromeauth/abe_unsupported.go index 8798967..8d5b1f0 100644 --- a/internal/chromeauth/abe_unsupported.go +++ b/internal/chromeauth/abe_unsupported.go @@ -5,5 +5,5 @@ package chromeauth import "fmt" func retrieveV20Key(string) ([]byte, error) { - return nil, fmt.Errorf("自动读取 Chrome App-Bound 主密钥仅支持 Windows amd64") + return nil, fmt.Errorf("automatically retrieving Chrome App-Bound master key is only supported on Windows amd64") } diff --git a/internal/chromeauth/abe_windows_amd64.go b/internal/chromeauth/abe_windows_amd64.go index 1f153ed..eb07141 100644 --- a/internal/chromeauth/abe_windows_amd64.go +++ b/internal/chromeauth/abe_windows_amd64.go @@ -47,7 +47,7 @@ var ( //go:embed native/abe_helper_amd64.bin var abeHelperDLL []byte -// retrieveV20Key 读取 Chrome App-Bound v20 主密钥 +// retrieveV20Key reads the Chrome App-Bound v20 master key. func retrieveV20Key(chromeRoot string) ([]byte, error) { encrypted, err := readAppBoundCiphertext(chromeRoot) if err != nil { @@ -60,11 +60,11 @@ func retrieveV20Key(chromeRoot string) ([]byte, error) { return decryptAppBoundCiphertext(chromePath, encrypted) } -// readAppBoundCiphertext 从 Local State 提取 APPB 密文 +// readAppBoundCiphertext extracts the APPB ciphertext from Local State. func readAppBoundCiphertext(chromeRoot string) ([]byte, error) { data, err := os.ReadFile(filepath.Join(chromeRoot, "Local State")) if err != nil { - return nil, fmt.Errorf("读取 Chrome Local State: %w", err) + return nil, fmt.Errorf("read Chrome Local State: %w", err) } var state struct { OSCrypt struct { @@ -72,23 +72,23 @@ func readAppBoundCiphertext(chromeRoot string) ([]byte, error) { } `json:"os_crypt"` } if err := json.Unmarshal(data, &state); err != nil { - return nil, fmt.Errorf("解析 Chrome Local State: %w", err) + return nil, fmt.Errorf("parse Chrome Local State: %w", err) } raw := strings.TrimSpace(state.OSCrypt.AppBoundEncryptedKey) if raw == "" { - return nil, fmt.Errorf("Chrome Local State 缺少 app_bound_encrypted_key") + return nil, fmt.Errorf("Chrome Local State missing app_bound_encrypted_key") } decoded, err := base64.StdEncoding.DecodeString(raw) if err != nil { - return nil, fmt.Errorf("解析 app_bound_encrypted_key: %w", err) + return nil, fmt.Errorf("decode app_bound_encrypted_key: %w", err) } if len(decoded) <= len(appBoundPrefix) || string(decoded[:len(appBoundPrefix)]) != appBoundPrefix { - return nil, fmt.Errorf("app_bound_encrypted_key 缺少 APPB 前缀") + return nil, fmt.Errorf("app_bound_encrypted_key missing APPB prefix") } return decoded[len(appBoundPrefix):], nil } -// findChromeExecutable 定位稳定版 Chrome 可执行文件 +// findChromeExecutable locates the stable Chrome executable. func findChromeExecutable() (string, error) { for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} { path, err := chromeExecutableFromRegistry(root) @@ -101,10 +101,10 @@ func findChromeExecutable() (string, error) { return path, nil } } - return "", fmt.Errorf("找不到 chrome.exe") + return "", fmt.Errorf("chrome.exe not found") } -// chromeExecutableFromRegistry 读取 Windows App Paths +// chromeExecutableFromRegistry reads the Windows App Paths. func chromeExecutableFromRegistry(root registry.Key) (string, error) { key, err := registry.OpenKey(root, `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe`, registry.QUERY_VALUE) if err != nil { @@ -117,15 +117,15 @@ func chromeExecutableFromRegistry(root registry.Key) (string, error) { } value = strings.TrimSpace(value) if value == "" { - return "", fmt.Errorf("Chrome App Paths 为空") + return "", fmt.Errorf("Chrome App Paths is empty") } if fileInfo, err := os.Stat(value); err != nil || fileInfo.IsDir() { - return "", fmt.Errorf("Chrome App Paths 不可用") + return "", fmt.Errorf("Chrome App Paths is unavailable") } return value, nil } -// chromeExecutableFallbacks 返回常见安装位置 +// chromeExecutableFallbacks returns common installation locations. func chromeExecutableFallbacks() []string { paths := make([]string, 0, 3) if programFiles := strings.TrimSpace(os.Getenv("ProgramFiles")); programFiles != "" { @@ -140,18 +140,18 @@ func chromeExecutableFallbacks() []string { return paths } -// decryptAppBoundCiphertext 在独立 Chrome 进程内解密主密钥 +// decryptAppBoundCiphertext decrypts the master key inside an isolated Chrome process. func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, error) { tempDir, err := os.MkdirTemp("", "aistudio2api-abe-*") if err != nil { - return nil, fmt.Errorf("创建 ABE 临时目录: %w", err) + return nil, fmt.Errorf("create ABE temporary directory: %w", err) } defer os.RemoveAll(tempDir) helperPath := filepath.Join(tempDir, abeHelperDLLName) outputPath := filepath.Join(tempDir, abeKeyFileName) if err := os.WriteFile(helperPath, abeHelperDLL, 0o600); err != nil { - return nil, fmt.Errorf("写入 ABE helper: %w", err) + return nil, fmt.Errorf("write ABE helper: %w", err) } restoreEnv := setTemporaryEnv(map[string]string{ @@ -173,7 +173,7 @@ func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, err defer closeChromeProcess(job, process, thread) if _, err := windows.ResumeThread(thread); err != nil { - return nil, fmt.Errorf("启动临时 Chrome: %w", err) + return nil, fmt.Errorf("resume temporary Chrome: %w", err) } time.Sleep(750 * time.Millisecond) if err := injectDLL(process, helperPath); err != nil { @@ -184,20 +184,20 @@ func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, err data, err := os.ReadFile(outputPath) if err == nil { if len(data) != 32 { - return nil, fmt.Errorf("Chrome ABE helper 返回异常: %s", strings.TrimSpace(string(data))) + return nil, fmt.Errorf("Chrome ABE helper returned abnormal output: %s", strings.TrimSpace(string(data))) } return data, nil } time.Sleep(abePollInterval) } - return nil, fmt.Errorf("Chrome ABE helper 超时") + return nil, fmt.Errorf("Chrome ABE helper timed out") } -// createKillOnCloseJob 将临时 Chrome 进程树绑定到独立 Job +// createKillOnCloseJob binds the temporary Chrome process tree to an isolated Job. func createKillOnCloseJob(process windows.Handle) (windows.Handle, error) { job, err := windows.CreateJobObject(nil, nil) if err != nil { - return 0, fmt.Errorf("创建 Chrome Job: %w", err) + return 0, fmt.Errorf("create Chrome Job: %w", err) } info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE @@ -208,16 +208,16 @@ func createKillOnCloseJob(process windows.Handle) (windows.Handle, error) { uint32(unsafe.Sizeof(info)), ); err != nil { windows.CloseHandle(job) - return 0, fmt.Errorf("配置 Chrome Job: %w", err) + return 0, fmt.Errorf("configure Chrome Job: %w", err) } if err := windows.AssignProcessToJobObject(job, process); err != nil { windows.CloseHandle(job) - return 0, fmt.Errorf("加入 Chrome Job: %w", err) + return 0, fmt.Errorf("assign to Chrome Job: %w", err) } return job, nil } -// closeChromeProcess 终止临时 Chrome 进程树并释放句柄 +// closeChromeProcess terminates the temporary Chrome process tree and releases handles. func closeChromeProcess(job windows.Handle, process windows.Handle, thread windows.Handle) { _ = windows.TerminateJobObject(job, 0) _, _ = windows.WaitForSingleObject(process, uint32((5 * time.Second).Milliseconds())) @@ -226,13 +226,13 @@ func closeChromeProcess(job windows.Handle, process windows.Handle, thread windo windows.CloseHandle(process) } -// temporaryEnvValue 保存父进程环境变量原值 +// temporaryEnvValue saves the original environment variable value of the parent process. type temporaryEnvValue struct { value string set bool } -// setTemporaryEnv 设置子进程继承用环境变量 +// setTemporaryEnv sets environment variables to be inherited by child processes. func setTemporaryEnv(values map[string]string) func() { oldValues := make(map[string]temporaryEnvValue, len(values)) for key, value := range values { @@ -251,7 +251,7 @@ func setTemporaryEnv(values map[string]string) func() { } } -// startHiddenChrome 创建隐藏的独立临时 Chrome +// startHiddenChrome creates an isolated, hidden temporary Chrome instance. func startHiddenChrome(chromePath string, profileDir string) (windows.Handle, windows.Handle, error) { commandLine := strings.Join([]string{ quoteWindowsArg(chromePath), @@ -269,11 +269,11 @@ func startHiddenChrome(chromePath string, profileDir string) (windows.Handle, wi }, " ") commandLineUTF16, err := windows.UTF16PtrFromString(commandLine) if err != nil { - return 0, 0, fmt.Errorf("编码 Chrome 命令行: %w", err) + return 0, 0, fmt.Errorf("encode Chrome command line: %w", err) } chromePathUTF16, err := windows.UTF16PtrFromString(chromePath) if err != nil { - return 0, 0, fmt.Errorf("编码 Chrome 路径: %w", err) + return 0, 0, fmt.Errorf("encode Chrome path: %w", err) } startupInfo := windows.StartupInfo{ Flags: startfUseShowWindow, @@ -293,23 +293,23 @@ func startHiddenChrome(chromePath string, profileDir string) (windows.Handle, wi &processInfo, ) if err != nil { - return 0, 0, fmt.Errorf("启动独立临时 Chrome: %w", err) + return 0, 0, fmt.Errorf("start isolated temporary Chrome: %w", err) } return processInfo.Process, processInfo.Thread, nil } -// injectDLL 通过 LoadLibraryW 载入 helper +// injectDLL loads the helper via LoadLibraryW. func injectDLL(process windows.Handle, dllPath string) error { encodedPath, err := windows.UTF16FromString(dllPath) if err != nil { - return fmt.Errorf("编码 ABE helper 路径: %w", err) + return fmt.Errorf("encode ABE helper path: %w", err) } size := uintptr(len(encodedPath) * 2) remotePath, _, callErr := procVirtualAllocEx.Call( uintptr(process), 0, size, memCommit|memReserve, pageReadwrite, ) if remotePath == 0 { - return fmt.Errorf("VirtualAllocEx 失败: %w", callErr) + return fmt.Errorf("VirtualAllocEx failed: %w", callErr) } var written uintptr ok, _, callErr := procWriteProcessMemory.Call( @@ -320,7 +320,7 @@ func injectDLL(process windows.Handle, dllPath string) error { uintptr(unsafe.Pointer(&written)), ) if ok == 0 || written != size { - return fmt.Errorf("WriteProcessMemory 失败: %w", callErr) + return fmt.Errorf("WriteProcessMemory failed: %w", callErr) } thread, _, callErr := procCreateRemoteThread.Call( uintptr(process), @@ -332,17 +332,17 @@ func injectDLL(process windows.Handle, dllPath string) error { 0, ) if thread == 0 { - return fmt.Errorf("CreateRemoteThread 失败: %w", callErr) + return fmt.Errorf("CreateRemoteThread failed: %w", callErr) } threadHandle := windows.Handle(thread) defer windows.CloseHandle(threadHandle) if _, err := windows.WaitForSingleObject(threadHandle, uint32((10 * time.Second).Milliseconds())); err != nil { - return fmt.Errorf("等待 ABE helper 注入: %w", err) + return fmt.Errorf("wait for ABE helper injection: %w", err) } return nil } -// quoteWindowsArg 包裹 Windows 命令行参数 +// quoteWindowsArg wraps a Windows command line argument. func quoteWindowsArg(value string) string { return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` } diff --git a/internal/chromeauth/auth.go b/internal/chromeauth/auth.go index 24f3cba..2b3776e 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -18,7 +18,7 @@ const ( userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" ) -// Account 描述本机 Chrome 中可发现的 Google 账号 +// Account describes a discoverable Google account in local Chrome. type Account struct { Profile string `json:"profile"` DisplayName string `json:"display_name"` @@ -27,7 +27,7 @@ type Account struct { Importable bool `json:"importable"` } -// ImportOptions 保存 Chrome 批量导入参数 +// ImportOptions holds parameters for batch importing Chrome accounts. type ImportOptions struct { ChromeRoot string Proxy string @@ -35,7 +35,7 @@ type ImportOptions struct { Emails []string } -// ImportResult 返回一个已验证前的账号状态 +// ImportResult represents an account state before verification. type ImportResult struct { Profile string DisplayName string @@ -45,17 +45,17 @@ type ImportResult struct { State aistudio.StorageState } -// DefaultChromeRoot 返回稳定版 Chrome User Data 目录 +// DefaultChromeRoot returns the default Chrome User Data directory for the stable channel. func DefaultChromeRoot() (string, error) { return defaultChromeRoot() } -// Discover 只读列出本机 Chrome Google 账号 +// Discover lists local Chrome Google accounts in read-only mode. func Discover(chromeRoot string) ([]Account, error) { return discoverPlatform(chromeRoot) } -// Import 通过设备绑定 OAuth 材料生成 Playwright storage state +// Import generates Playwright storage state using device-bound OAuth credentials. func Import(ctx context.Context, options ImportOptions) ([]ImportResult, error) { if err := ensurePlatformImport(); err != nil { return nil, err @@ -77,20 +77,20 @@ func Import(ctx context.Context, options ImportOptions) ([]ImportResult, error) return nil, err } if len(masterKey) != 32 { - return nil, fmt.Errorf("Chrome v20 主密钥长度异常") + return nil, fmt.Errorf("invalid Chrome v20 master key length") } results := make([]ImportResult, 0, len(selected)) for _, account := range selected { result, err := importAccount(ctx, options.ChromeRoot, proxyURL, account, masterKey) if err != nil { - return nil, fmt.Errorf("导入 %s: %w", account.Profile, err) + return nil, fmt.Errorf("import %s: %w", account.Profile, err) } results = append(results, result) } return results, nil } -// Refresh 使用保存的设备绑定材料重新签发 Google Cookie +// Refresh reissues Google cookies using stored device-bound credentials. func Refresh(ctx context.Context, material aistudio.ChromeOAuthMaterial, proxy string) ([]aistudio.StateCookie, error) { if err := ensurePlatformImport(); err != nil { return nil, err @@ -140,7 +140,7 @@ func selectAccounts(accounts []Account, profiles []string, emails []string) ([]A requestedProfiles := normalizedSet(profiles) requestedEmails := normalizedSet(emails) if len(requestedProfiles) == 0 && len(requestedEmails) == 0 { - return nil, fmt.Errorf("未选择 Chrome 账号") + return nil, fmt.Errorf("no Chrome accounts selected") } selected := make([]Account, 0, len(accounts)) foundProfiles := make(map[string]struct{}) @@ -154,20 +154,20 @@ func selectAccounts(accounts []Account, profiles []string, emails []string) ([]A continue } if !account.Importable { - return nil, fmt.Errorf("%s 缺少可导入的 OAuth 认证材料", account.Profile) + return nil, fmt.Errorf("%s missing importable OAuth credentials", account.Profile) } if !strings.Contains(email, "@") { - return nil, fmt.Errorf("%s 缺少账号邮箱", account.Profile) + return nil, fmt.Errorf("%s missing account email", account.Profile) } selected = append(selected, account) foundProfiles[profile] = struct{}{} foundEmails[email] = struct{}{} } if missing := missingValues(requestedProfiles, foundProfiles); len(missing) != 0 { - return nil, fmt.Errorf("找不到 Chrome Profile: %s", strings.Join(missing, ", ")) + return nil, fmt.Errorf("Chrome profile not found: %s", strings.Join(missing, ", ")) } if missing := missingValues(requestedEmails, foundEmails); len(missing) != 0 { - return nil, fmt.Errorf("找不到 Chrome 账号: %s", strings.Join(missing, ", ")) + return nil, fmt.Errorf("Chrome account not found: %s", strings.Join(missing, ", ")) } return selected, nil } @@ -201,35 +201,35 @@ func validateProxy(value string) (string, error) { } parsed, err := url.Parse(value) if err != nil || parsed.Hostname() == "" { - return "", fmt.Errorf("proxy 必须是 http、https 或 socks5 URL") + return "", fmt.Errorf("proxy must be an http, https, or socks5 URL") } switch strings.ToLower(parsed.Scheme) { case "http", "https", "socks5": return value, nil default: - return "", fmt.Errorf("proxy 必须是 http、https 或 socks5 URL") + return "", fmt.Errorf("proxy must be an http, https, or socks5 URL") } } func decryptV20Token(masterKey []byte, encrypted []byte) (string, error) { if len(encrypted) < 3+12+16 || string(encrypted[:3]) != "v20" { - return "", fmt.Errorf("refresh token 密文版本不是 v20") + return "", fmt.Errorf("refresh token ciphertext version is not v20") } block, err := aes.NewCipher(masterKey) if err != nil { - return "", fmt.Errorf("创建 AES 解密器: %w", err) + return "", fmt.Errorf("create AES cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { - return "", fmt.Errorf("创建 GCM 解密器: %w", err) + return "", fmt.Errorf("create GCM cipher: %w", err) } plaintext, err := gcm.Open(nil, encrypted[3:15], encrypted[15:], nil) if err != nil { - return "", fmt.Errorf("refresh token 解密失败") + return "", fmt.Errorf("failed to decrypt refresh token") } token := string(plaintext) if len(token) != 103 || !strings.HasPrefix(token, "1//0") { - return "", fmt.Errorf("refresh token 解密结果格式异常") + return "", fmt.Errorf("invalid decrypted refresh token format") } return token, nil } diff --git a/internal/chromeauth/native/abe_helper.c b/internal/chromeauth/native/abe_helper.c index 744ef3a..da1bb00 100644 --- a/internal/chromeauth/native/abe_helper.c +++ b/internal/chromeauth/native/abe_helper.c @@ -11,7 +11,7 @@ typedef HRESULT(STDMETHODCALLTYPE *DecryptDataFn)(IUnknown *, BSTR, BSTR *, DWOR static HMODULE g_module; -// read_env 读取宽字符环境变量 +// read_env reads a wide-character environment variable. static wchar_t *read_env(const wchar_t *name) { DWORD size = GetEnvironmentVariableW(name, NULL, 0); if (size == 0) { @@ -28,7 +28,7 @@ static wchar_t *read_env(const wchar_t *name) { return value; } -// decode_base64 解码环境变量中的密文 +// decode_base64 decodes base64 ciphertext from an environment variable. static BOOL decode_base64(const wchar_t *input, BYTE **output, DWORD *output_size) { DWORD size = 0; if (!CryptStringToBinaryW(input, 0, CRYPT_STRING_BASE64, NULL, &size, NULL, NULL) || size == 0) { @@ -46,7 +46,7 @@ static BOOL decode_base64(const wchar_t *input, BYTE **output, DWORD *output_siz *output_size = size; return TRUE; } -// write_output 写出成功结果或错误信息 +// write_output writes success results or error messages. static void write_output(const wchar_t *path, const BYTE *data, DWORD size) { HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (file == INVALID_HANDLE_VALUE) { @@ -57,12 +57,12 @@ static void write_output(const wchar_t *path, const BYTE *data, DWORD size) { CloseHandle(file); } -// write_error 写出短错误码 +// write_error writes a short error code. static void write_error(const wchar_t *path, const char *message) { write_output(path, (const BYTE *)message, (DWORD)lstrlenA(message)); } -// call_decrypt 调用 Chrome IElevator 解开 App-Bound 主密钥 +// call_decrypt invokes Chrome IElevator to decrypt the App-Bound master key. static HRESULT call_decrypt(const BYTE *ciphertext, DWORD ciphertext_size, BYTE *plaintext, DWORD plaintext_size) { const IID clsid_chrome = {0x708860E0, 0xF641, 0x4611, {0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B}}; const IID iid_chrome_v2 = {0x1BF5208B, 0x295F, 0x4992, {0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}}; @@ -110,7 +110,7 @@ static HRESULT call_decrypt(const BYTE *ciphertext, DWORD ciphertext_size, BYTE return S_OK; } -// worker 解密后通知 Go 父进程 +// worker decrypts the key and notifies the Go parent process. static DWORD WINAPI worker(LPVOID parameter) { (void)parameter; wchar_t *input_env = read_env(ABE_INPUT_ENV); @@ -154,7 +154,7 @@ static DWORD WINAPI worker(LPVOID parameter) { return 0; } -// DllMain 启动独立工作线程 +// DllMain starts an independent worker thread. BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) { (void)reserved; if (reason == DLL_PROCESS_ATTACH) { diff --git a/internal/chromeauth/ncrypt_other.go b/internal/chromeauth/ncrypt_other.go index 6f9e160..2ce9637 100644 --- a/internal/chromeauth/ncrypt_other.go +++ b/internal/chromeauth/ncrypt_other.go @@ -5,5 +5,5 @@ package chromeauth import "fmt" func openDeviceBindingKey([]byte) (deviceBindingKey, error) { - return nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return nil, fmt.Errorf("Chrome OAuth import is only supported on Windows") } diff --git a/internal/chromeauth/ncrypt_windows.go b/internal/chromeauth/ncrypt_windows.go index e310fd7..d30d495 100644 --- a/internal/chromeauth/ncrypt_windows.go +++ b/internal/chromeauth/ncrypt_windows.go @@ -31,20 +31,20 @@ type ncryptDeviceKey struct { func openDeviceBindingKey(wrappedKey []byte) (deviceBindingKey, error) { providerName, err := windows.UTF16PtrFromString("Microsoft Platform Crypto Provider") if err != nil { - return nil, fmt.Errorf("编码 NCrypt Provider 名称: %w", err) + return nil, fmt.Errorf("encode NCrypt provider name: %w", err) } var provider uintptr status, _, _ := ncryptOpenStorageProvider.Call( uintptr(unsafe.Pointer(&provider)), uintptr(unsafe.Pointer(providerName)), 0, ) if uint32(status) != 0 { - return nil, fmt.Errorf("NCryptOpenStorageProvider 返回 %d", int32(status)) + return nil, fmt.Errorf("NCryptOpenStorageProvider returned %d", int32(status)) } key := &ncryptDeviceKey{provider: provider} blobType, err := windows.UTF16PtrFromString("OpaqueKeyBlob") if err != nil { key.Close() - return nil, fmt.Errorf("编码 NCrypt Blob 类型: %w", err) + return nil, fmt.Errorf("encode NCrypt blob type: %w", err) } status, _, _ = ncryptImportKey.Call( key.provider, @@ -59,7 +59,7 @@ func openDeviceBindingKey(wrappedKey []byte) (deviceBindingKey, error) { runtime.KeepAlive(wrappedKey) if uint32(status) != 0 { key.Close() - return nil, fmt.Errorf("NCryptImportKey 返回 %d", int32(status)) + return nil, fmt.Errorf("NCryptImportKey returned %d", int32(status)) } return key, nil } @@ -67,7 +67,7 @@ func openDeviceBindingKey(wrappedKey []byte) (deviceBindingKey, error) { func (key *ncryptDeviceKey) PublicKey() (*ecdsa.PublicKey, []byte, error) { blobType, err := windows.UTF16PtrFromString("ECCPUBLICBLOB") if err != nil { - return nil, nil, fmt.Errorf("编码 NCrypt 公钥类型: %w", err) + return nil, nil, fmt.Errorf("encode NCrypt public key type: %w", err) } var size uint32 status, _, _ := ncryptExportKey.Call( @@ -75,7 +75,7 @@ func (key *ncryptDeviceKey) PublicKey() (*ecdsa.PublicKey, []byte, error) { uintptr(unsafe.Pointer(&size)), 0, ) if uint32(status) != 0 { - return nil, nil, fmt.Errorf("NCryptExportKey 查询返回 %d", int32(status)) + return nil, nil, fmt.Errorf("NCryptExportKey query returned %d", int32(status)) } output := make([]byte, size) status, _, _ = ncryptExportKey.Call( @@ -84,7 +84,7 @@ func (key *ncryptDeviceKey) PublicKey() (*ecdsa.PublicKey, []byte, error) { ) runtime.KeepAlive(output) if uint32(status) != 0 { - return nil, nil, fmt.Errorf("NCryptExportKey 返回 %d", int32(status)) + return nil, nil, fmt.Errorf("NCryptExportKey returned %d", int32(status)) } return parsePublicKeyBlob(output[:size]) } @@ -97,7 +97,7 @@ func (key *ncryptDeviceKey) SignSHA256(value []byte) ([]byte, error) { 0, 0, uintptr(unsafe.Pointer(&size)), ncryptSilentFlag, ) if uint32(status) != 0 { - return nil, fmt.Errorf("NCryptSignHash 查询返回 %d", int32(status)) + return nil, fmt.Errorf("NCryptSignHash query returned %d", int32(status)) } output := make([]byte, size) status, _, _ = ncryptSignHash.Call( @@ -106,7 +106,7 @@ func (key *ncryptDeviceKey) SignSHA256(value []byte) ([]byte, error) { ) runtime.KeepAlive(output) if uint32(status) != 0 { - return nil, fmt.Errorf("NCryptSignHash 返回 %d", int32(status)) + return nil, fmt.Errorf("NCryptSignHash returned %d", int32(status)) } return output[:size], nil } diff --git a/internal/chromeauth/platform_other.go b/internal/chromeauth/platform_other.go index 99a51a6..302dcb2 100644 --- a/internal/chromeauth/platform_other.go +++ b/internal/chromeauth/platform_other.go @@ -5,17 +5,17 @@ package chromeauth import "fmt" func defaultChromeRoot() (string, error) { - return "", fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return "", fmt.Errorf("Chrome OAuth import is only supported on Windows") } func ensurePlatformImport() error { - return fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return fmt.Errorf("Chrome OAuth import is only supported on Windows") } func discoverPlatform(string) ([]Account, error) { - return nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return nil, fmt.Errorf("Chrome OAuth import is only supported on Windows") } func readTokenService(string, string) (string, []byte, []byte, error) { - return "", nil, nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return "", nil, nil, fmt.Errorf("Chrome OAuth import is only supported on Windows") } diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index ea89cfe..c1277a9 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -19,7 +19,7 @@ import ( func defaultChromeRoot() (string, error) { localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) if localAppData == "" { - return "", fmt.Errorf("环境变量 LOCALAPPDATA 为空") + return "", fmt.Errorf("environment variable LOCALAPPDATA is empty") } return filepath.Join(localAppData, "Google", "Chrome", "User Data"), nil } @@ -31,7 +31,7 @@ func ensurePlatformImport() error { func discoverPlatform(chromeRoot string) ([]Account, error) { data, err := os.ReadFile(filepath.Join(chromeRoot, "Local State")) if err != nil { - return nil, fmt.Errorf("读取 Chrome Local State: %w", err) + return nil, fmt.Errorf("read Chrome Local State: %w", err) } var state struct { Variations struct { @@ -45,10 +45,10 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { } `json:"profile"` } if err := json.Unmarshal(data, &state); err != nil { - return nil, fmt.Errorf("解析 Chrome Local State: %w", err) + return nil, fmt.Errorf("parse Chrome Local State: %w", err) } if state.Profile.InfoCache == nil { - return nil, fmt.Errorf("Chrome Local State 缺少 profile.info_cache") + return nil, fmt.Errorf("Chrome Local State missing profile.info_cache") } profiles := make([]string, 0, len(state.Profile.InfoCache)) @@ -101,39 +101,39 @@ func readTokenService(chromeRoot string, profile string) (string, []byte, []byte uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro&immutable=1" database, err := sql.Open("sqlite", uri) if err != nil { - return "", nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) + return "", nil, nil, fmt.Errorf("open %s Web Data: %w", profile, err) } defer database.Close() database.SetMaxOpenConns(1) rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service") if err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + return "", nil, nil, fmt.Errorf("read %s token_service: %w", profile, err) } defer rows.Close() if !rows.Next() { if err := rows.Err(); err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + return "", nil, nil, fmt.Errorf("read %s token_service: %w", profile, err) } - return "", nil, nil, fmt.Errorf("%s 的 token_service 记录数为 0", profile) + return "", nil, nil, fmt.Errorf("%s token_service record count is 0", profile) } var service string var encryptedToken []byte var bindingKey []byte if err := rows.Scan(&service, &encryptedToken, &bindingKey); err != nil { - return "", nil, nil, fmt.Errorf("解析 %s token_service: %w", profile, err) + return "", nil, nil, fmt.Errorf("parse %s token_service: %w", profile, err) } if rows.Next() { - return "", nil, nil, fmt.Errorf("%s 的 token_service 记录数大于 1", profile) + return "", nil, nil, fmt.Errorf("%s token_service record count greater than 1", profile) } if err := rows.Err(); err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + return "", nil, nil, fmt.Errorf("read %s token_service: %w", profile, err) } if !strings.HasPrefix(service, "AccountId-") { - return "", nil, nil, fmt.Errorf("%s 的 token_service service 格式异常", profile) + return "", nil, nil, fmt.Errorf("%s token_service service format is invalid", profile) } if len(encryptedToken) == 0 || len(bindingKey) == 0 { - return "", nil, nil, fmt.Errorf("%s 的 token_service 缺少认证材料", profile) + return "", nil, nil, fmt.Errorf("%s token_service missing authentication credentials", profile) } return strings.TrimPrefix(service, "AccountId-"), encryptedToken, bindingKey, nil } diff --git a/internal/chromeauth/protocol.go b/internal/chromeauth/protocol.go index fa068df..7a9afff 100644 --- a/internal/chromeauth/protocol.go +++ b/internal/chromeauth/protocol.go @@ -83,12 +83,12 @@ func fetchGoogleCookies(ctx context.Context, gaiaID string, token string, wrappe } challenge := findChallenge(first) if first.Status != "RETRY" || challenge == "" { - return nil, fmt.Errorf("OAuthMultilogin challenge 阶段失败 HTTP %d status %s", firstStatus, first.Status) + return nil, fmt.Errorf("OAuthMultilogin challenge phase failed: HTTP %d status %s", firstStatus, first.Status) } ephemeralPrivateKey, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { - return nil, fmt.Errorf("生成 HPKE 临时密钥: %w", err) + return nil, fmt.Errorf("generate ephemeral HPKE key: %w", err) } assertion, err := createAssertion(bindingKey, publicKey, spki, challenge, ephemeralPrivateKey.PublicKey().Bytes()) if err != nil { @@ -99,33 +99,33 @@ func fetchGoogleCookies(ctx context.Context, gaiaID string, token string, wrappe return nil, err } if secondStatus != http.StatusOK || second.Status != "OK" { - return nil, fmt.Errorf("OAuthMultilogin assertion 阶段失败 HTTP %d status %s", secondStatus, second.Status) + return nil, fmt.Errorf("OAuthMultilogin assertion phase failed: HTTP %d status %s", secondStatus, second.Status) } if len(second.Directed) == 0 || bytes.Equal(second.Directed, []byte("null")) { - return nil, fmt.Errorf("OAuthMultilogin 响应缺少 token_binding_directed_response") + return nil, fmt.Errorf("OAuthMultilogin response missing token_binding_directed_response") } if len(second.Cookies) == 0 { - return nil, fmt.Errorf("OAuthMultilogin 响应缺少 Cookie") + return nil, fmt.Errorf("OAuthMultilogin response missing cookies") } names := make(map[string]struct{}, len(second.Cookies)) for index := range second.Cookies { cookie := &second.Cookies[index] if cookie.Name == "" || cookie.Value == "" { - return nil, fmt.Errorf("OAuthMultilogin Cookie 格式异常") + return nil, fmt.Errorf("invalid OAuthMultilogin cookie format") } cookie.Value, err = hpkeOpen(ephemeralPrivateKey, cookie.Value) if err != nil { return nil, err } if cookie.Domain == "" && (cookie.Host == "" || strings.HasPrefix(cookie.Host, ".")) { - return nil, fmt.Errorf("OAuthMultilogin Cookie 域格式异常") + return nil, fmt.Errorf("invalid OAuthMultilogin cookie domain format") } names[cookie.Name] = struct{}{} } for _, required := range []string{"SAPISID", "__Secure-1PSID"} { if _, ok := names[required]; !ok { - return nil, fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) + return nil, fmt.Errorf("OAuthMultilogin missing required cookie: %s", required) } } return second.Cookies, nil @@ -143,7 +143,7 @@ func newOAuthClient(proxyURL string) (tls_client.HttpClient, error) { } client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...) if err != nil { - return nil, fmt.Errorf("创建 OAuth HTTP 客户端: %w", err) + return nil, fmt.Errorf("create OAuth HTTP client: %w", err) } return client, nil } @@ -152,19 +152,19 @@ func requestMultilogin(ctx context.Context, client tls_client.HttpClient, gaiaID authorization := encodeMultiOAuthHeader(gaiaID, token, assertion) request, err := http.NewRequestWithContext(ctx, http.MethodPost, multiloginURL, strings.NewReader(" ")) if err != nil { - return 0, multiloginResponse{}, fmt.Errorf("创建 OAuthMultilogin 请求: %w", err) + return 0, multiloginResponse{}, fmt.Errorf("create OAuthMultilogin request: %w", err) } request.Header.Set("Authorization", "MultiOAuth "+authorization) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("User-Agent", userAgent) response, err := client.Do(request) if err != nil { - return 0, multiloginResponse{}, fmt.Errorf("OAuthMultilogin 请求失败: %w", err) + return 0, multiloginResponse{}, fmt.Errorf("OAuthMultilogin request failed: %w", err) } defer response.Body.Close() body, err := io.ReadAll(io.LimitReader(response.Body, 8<<20)) if err != nil { - return response.StatusCode, multiloginResponse{}, fmt.Errorf("读取 OAuthMultilogin 响应: %w", err) + return response.StatusCode, multiloginResponse{}, fmt.Errorf("read OAuthMultilogin response: %w", err) } body = bytes.TrimSpace(body) if bytes.HasPrefix(body, []byte(")]}'")) { @@ -172,7 +172,7 @@ func requestMultilogin(ctx context.Context, client tls_client.HttpClient, gaiaID } var result multiloginResponse if err := json.Unmarshal(body, &result); err != nil { - return response.StatusCode, multiloginResponse{}, fmt.Errorf("OAuthMultilogin 返回无法解析的响应 HTTP %d", response.StatusCode) + return response.StatusCode, multiloginResponse{}, fmt.Errorf("OAuthMultilogin returned unparseable response HTTP %d", response.StatusCode) } return response.StatusCode, result, nil } @@ -234,11 +234,11 @@ func createAssertion(bindingKey deviceBindingKey, publicKey *ecdsa.PublicKey, sp payload.EphemeralKey.KeyInfo = base64.RawURLEncoding.EncodeToString(createTinkHPKEKeyset(ephemeralPublicKey)) encodedHeader, err := json.Marshal(header) if err != nil { - return "", fmt.Errorf("编码 token binding header: %w", err) + return "", fmt.Errorf("marshal token binding header: %w", err) } encodedPayload, err := json.Marshal(payload) if err != nil { - return "", fmt.Errorf("编码 token binding payload: %w", err) + return "", fmt.Errorf("marshal token binding payload: %w", err) } signingInput := base64.RawURLEncoding.EncodeToString(encodedHeader) + "." + base64.RawURLEncoding.EncodeToString(encodedPayload) signature, err := bindingKey.SignSHA256([]byte(signingInput)) @@ -246,13 +246,13 @@ func createAssertion(bindingKey deviceBindingKey, publicKey *ecdsa.PublicKey, sp return "", err } if len(signature) != 64 { - return "", fmt.Errorf("NCrypt ECDSA 签名长度异常") + return "", fmt.Errorf("invalid NCrypt ECDSA signature length") } digest := sha256.Sum256([]byte(signingInput)) r := new(big.Int).SetBytes(signature[:32]) s := new(big.Int).SetBytes(signature[32:]) if !ecdsa.Verify(publicKey, digest[:], r, s) { - return "", fmt.Errorf("NCrypt ECDSA 签名校验失败") + return "", fmt.Errorf("NCrypt ECDSA signature verification failed") } return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil } @@ -260,16 +260,16 @@ func createAssertion(bindingKey deviceBindingKey, publicKey *ecdsa.PublicKey, sp func hpkeOpen(privateKey *ecdh.PrivateKey, encodedValue string) (string, error) { encrypted, err := base64.RawURLEncoding.DecodeString(encodedValue) if err != nil || len(encrypted) <= 48 { - return "", fmt.Errorf("OAuthMultilogin Cookie 密文格式异常") + return "", fmt.Errorf("invalid OAuthMultilogin cookie ciphertext format") } encapsulatedKey := encrypted[:32] senderPublicKey, err := ecdh.X25519().NewPublicKey(encapsulatedKey) if err != nil { - return "", fmt.Errorf("解析 HPKE 封装密钥: %w", err) + return "", fmt.Errorf("parse HPKE encapsulated key: %w", err) } sharedDH, err := privateKey.ECDH(senderPublicKey) if err != nil { - return "", fmt.Errorf("计算 HPKE 共享密钥: %w", err) + return "", fmt.Errorf("compute HPKE shared secret: %w", err) } kemSuite := append([]byte("KEM"), 0, 0x20) hpkeSuite := append([]byte("HPKE"), 0, 0x20, 0, 1, 0, 1) @@ -284,15 +284,15 @@ func hpkeOpen(privateKey *ecdh.PrivateKey, encodedValue string) (string, error) nonce := labeledExpand(secret, hpkeSuite, []byte("base_nonce"), keyScheduleContext, 12) block, err := aes.NewCipher(key) if err != nil { - return "", fmt.Errorf("创建 HPKE AES 解密器: %w", err) + return "", fmt.Errorf("create HPKE AES cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { - return "", fmt.Errorf("创建 HPKE GCM 解密器: %w", err) + return "", fmt.Errorf("create HPKE GCM cipher: %w", err) } plaintext, err := gcm.Open(nil, nonce, encrypted[32:], []byte{}) if err != nil { - return "", fmt.Errorf("OAuthMultilogin Cookie 解密失败") + return "", fmt.Errorf("failed to decrypt OAuthMultilogin cookie") } return string(plaintext), nil } @@ -358,17 +358,17 @@ func appendVarint(output []byte, value uint64) []byte { func parsePublicKeyBlob(blob []byte) (*ecdsa.PublicKey, []byte, error) { if len(blob) != 72 || binary.LittleEndian.Uint32(blob[4:8]) != 32 { - return nil, nil, fmt.Errorf("NCrypt ECDSA 公钥格式异常") + return nil, nil, fmt.Errorf("invalid NCrypt ECDSA public key format") } x := new(big.Int).SetBytes(blob[8:40]) y := new(big.Int).SetBytes(blob[40:72]) if !elliptic.P256().IsOnCurve(x, y) { - return nil, nil, fmt.Errorf("NCrypt ECDSA 公钥曲线异常") + return nil, nil, fmt.Errorf("invalid NCrypt ECDSA public key curve") } publicKey := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} spki, err := x509.MarshalPKIXPublicKey(publicKey) if err != nil { - return nil, nil, fmt.Errorf("编码 NCrypt ECDSA 公钥: %w", err) + return nil, nil, fmt.Errorf("marshal NCrypt ECDSA public key: %w", err) } return publicKey, spki, nil } diff --git a/internal/chromeauth/verify.go b/internal/chromeauth/verify.go index 4e07718..1579cc1 100644 --- a/internal/chromeauth/verify.go +++ b/internal/chromeauth/verify.go @@ -14,15 +14,15 @@ import ( const aiStudioChatURL = "https://aistudio.google.com/prompts/new_chat" -// Verification 保存 AI Studio 登录页与模型目录验收结果 +// Verification holds acceptance results for the AI Studio login page and model catalog. type Verification struct { ModelCount int } -// Verify 验证账号可访问 WAA 页面并读取实时模型目录 +// Verify checks whether an account can access the WAA page and retrieve the real-time model catalog. func Verify(ctx context.Context, state *aistudio.StorageState, proxy string) (Verification, error) { if state == nil { - return Verification{}, fmt.Errorf("storage state 为空") + return Verification{}, fmt.Errorf("storage state is nil") } if _, err := aistudio.NewSigner().Sign(*state); err != nil { return Verification{}, err @@ -63,12 +63,12 @@ func verifyChatPage(ctx context.Context, client *http.Client, state *aistudio.St request.Header.Set("User-Agent", userAgent) response, err := client.Do(request) if err != nil { - return fmt.Errorf("访问 AI Studio 登录页: %w", err) + return fmt.Errorf("access AI Studio login page: %w", err) } defer response.Body.Close() _, _ = io.Copy(io.Discard, response.Body) if response.StatusCode != http.StatusOK { - return fmt.Errorf("AI Studio 登录页返回 HTTP %d", response.StatusCode) + return fmt.Errorf("AI Studio login page returned HTTP %d", response.StatusCode) } return mergeResponseCookies(state, response, aiStudioChatURL) } @@ -99,22 +99,22 @@ func verifyModels(ctx context.Context, client *http.Client, state *aistudio.Stor request.Header.Set("Sec-Fetch-Site", "same-site") response, err := client.Do(request) if err != nil { - return 0, fmt.Errorf("读取 AI Studio 模型目录: %w", err) + return 0, fmt.Errorf("read AI Studio model catalog: %w", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, response.Body) - return 0, fmt.Errorf("AI Studio ListModels 返回 HTTP %d", response.StatusCode) + return 0, fmt.Errorf("AI Studio ListModels returned HTTP %d", response.StatusCode) } if !strings.HasPrefix(strings.ToLower(response.Header.Get("Content-Type")), aistudio.JSONProtobufContentType) { - return 0, fmt.Errorf("AI Studio ListModels 返回未识别的 Content-Type %q", response.Header.Get("Content-Type")) + return 0, fmt.Errorf("AI Studio ListModels returned unrecognized Content-Type %q", response.Header.Get("Content-Type")) } models, err := aistudio.ParseModels(response.Body) if err != nil { return 0, err } if len(models) == 0 { - return 0, fmt.Errorf("AI Studio ListModels 返回空目录") + return 0, fmt.Errorf("AI Studio ListModels returned empty catalog") } if err := mergeResponseCookies(state, response, url); err != nil { return 0, err diff --git a/internal/config/config.go b/internal/config/config.go index b165b89..0debd7e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,7 +40,7 @@ var configKeys = [...]string{ "TEMPORARY_CHAT", } -// Config 保存服务的全局配置 +// Config holds the global configuration for the service. type Config struct { AuthStates string `json:"auth_states"` ListenAddr string `json:"listen_addr"` @@ -56,7 +56,7 @@ type Config struct { TemporaryChat bool `json:"temporary_chat"` } -// Default 返回可直接启动的默认配置 +// Default returns a default configuration ready for startup. func Default() Config { return Config{ AuthStates: defaultAuthStates, @@ -71,12 +71,13 @@ func Default() Config { } } -// Load 从指定 env 文件和进程环境加载配置 +// Load reads configuration from the specified env file and environment variables. func Load(path string) (Config, error) { values, err := readEnvFile(path) if err != nil { return Config{}, err } + for _, key := range configKeys { if value, ok := os.LookupEnv(key); ok { values[key] = value @@ -84,6 +85,7 @@ func Load(path string) (Config, error) { } cfg := Default() + if value, ok := values["AISTUDIO_AUTH_STATES"]; ok { cfg.AuthStates = strings.TrimSpace(value) } @@ -138,20 +140,23 @@ func Load(path string) (Config, error) { if value, ok := values["TEMPORARY_CHAT"]; ok { cfg.TemporaryChat, err = strconv.ParseBool(strings.TrimSpace(value)) if err != nil { - return Config{}, fmt.Errorf("TEMPORARY_CHAT 必须是 true 或 false") + return Config{}, fmt.Errorf("TEMPORARY_CHAT must be true or false") } } + if err := cfg.Validate(); err != nil { return Config{}, err } + return cfg, nil } -// Save 将配置原子写入指定 env 文件 +// Save atomically writes the configuration to the specified env file. func (c Config) Save(path string) error { if err := c.Validate(); err != nil { return err } + values := map[string]string{ "AISTUDIO_AUTH_STATES": c.AuthStates, "LISTEN_ADDR": c.ListenAddr, @@ -174,45 +179,56 @@ func (c Config) Save(path string) error { output.WriteString(formatEnvValue(values[key])) output.WriteByte('\n') } + return atomicWrite(path, []byte(output.String()), 0o600) } -// Validate 校验配置值是否能用于服务启动 +// Validate checks if the configuration values are valid for service startup. func (c Config) Validate() error { if strings.TrimSpace(c.AuthStates) == "" { - return fmt.Errorf("AISTUDIO_AUTH_STATES 不能为空") + return fmt.Errorf("AISTUDIO_AUTH_STATES cannot be empty") } + if err := validateListenAddr(c.ListenAddr); err != nil { return err } + if err := ValidateProxy(c.Proxy); err != nil { return err } + if c.InitTimeout <= 0 { - return fmt.Errorf("INIT_TIMEOUT 必须是正数时长") + return fmt.Errorf("INIT_TIMEOUT must be a positive duration") } + if c.RequestTimeout <= 0 { - return fmt.Errorf("REQUEST_TIMEOUT 必须是正数时长") + return fmt.Errorf("REQUEST_TIMEOUT must be a positive duration") } + if c.WarmWorkerLimit <= 0 { - return fmt.Errorf("WARM_WORKER_LIMIT 必须是正整数") + return fmt.Errorf("WARM_WORKER_LIMIT must be a positive integer") } + if c.MaxActiveWorkers < c.WarmWorkerLimit { - return fmt.Errorf("MAX_ACTIVE_WORKERS 必须大于或等于 WARM_WORKER_LIMIT") + return fmt.Errorf("MAX_ACTIVE_WORKERS must be greater than or equal to WARM_WORKER_LIMIT") } + if c.WarmStartupConcurrency <= 0 || c.WarmStartupConcurrency > c.WarmWorkerLimit { - return fmt.Errorf("WARM_STARTUP_CONCURRENCY 必须是 1 到 WARM_WORKER_LIMIT") + return fmt.Errorf("WARM_STARTUP_CONCURRENCY must be between 1 and WARM_WORKER_LIMIT") } + if c.PerAccountConcurrency <= 0 { - return fmt.Errorf("PER_ACCOUNT_CONCURRENCY 必须是正整数") + return fmt.Errorf("PER_ACCOUNT_CONCURRENCY must be a positive integer") } + if c.RoutingStrategy != "round-robin" && c.RoutingStrategy != "fill-first" { - return fmt.Errorf("ROUTING_STRATEGY 必须是 round-robin 或 fill-first") + return fmt.Errorf("ROUTING_STRATEGY must be round-robin or fill-first") } + return nil } -// MarshalJSON 将时长输出为 env 使用的文本格式 +// MarshalJSON outputs durations in string format matching env conventions. func (c Config) MarshalJSON() ([]byte, error) { type payload struct { AuthStates string `json:"auth_states"` @@ -228,6 +244,7 @@ func (c Config) MarshalJSON() ([]byte, error) { RoutingStrategy string `json:"routing_strategy"` TemporaryChat bool `json:"temporary_chat"` } + return json.Marshal(payload{ AuthStates: c.AuthStates, ListenAddr: c.ListenAddr, @@ -244,7 +261,7 @@ func (c Config) MarshalJSON() ([]byte, error) { }) } -// UnmarshalJSON 从管理接口使用的文本时长解析配置 +// UnmarshalJSON parses configuration from textual durations used in admin endpoints. func (c *Config) UnmarshalJSON(data []byte) error { type payload struct { AuthStates string `json:"auth_states"` @@ -260,18 +277,22 @@ func (c *Config) UnmarshalJSON(data []byte) error { RoutingStrategy string `json:"routing_strategy"` TemporaryChat bool `json:"temporary_chat"` } + var value payload if err := json.Unmarshal(data, &value); err != nil { return err } + initTimeout, err := parsePositiveDuration("INIT_TIMEOUT", value.InitTimeout) if err != nil { return err } + requestTimeout, err := parsePositiveDuration("REQUEST_TIMEOUT", value.RequestTimeout) if err != nil { return err } + parsed := Config{ AuthStates: strings.TrimSpace(value.AuthStates), ListenAddr: strings.TrimSpace(value.ListenAddr), @@ -286,34 +307,41 @@ func (c *Config) UnmarshalJSON(data []byte) error { RoutingStrategy: value.RoutingStrategy, TemporaryChat: value.TemporaryChat, } + if err := parsed.Validate(); err != nil { return err } + *c = parsed return nil } -// ValidateProxy 校验账户或全局代理 URL +// ValidateProxy validates account or global proxy URLs. func ValidateProxy(value string) error { value = strings.TrimSpace(value) if value == "" { return nil } + parsed, err := url.Parse(value) if err != nil || parsed.Hostname() == "" { - return fmt.Errorf("PROXY 必须是 http、https 或 socks5 URL") + return fmt.Errorf("PROXY must be a valid http, https, or socks5 URL") } + switch parsed.Scheme { case "http", "https", "socks5": default: - return fmt.Errorf("PROXY 必须是 http、https 或 socks5 URL") + return fmt.Errorf("PROXY must be a valid http, https, or socks5 URL") } + if parsed.User != nil { - return fmt.Errorf("PROXY 不能包含认证信息") + return fmt.Errorf("PROXY cannot contain user authentication") } + if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" { - return fmt.Errorf("PROXY 不能包含路径、查询参数或片段") + return fmt.Errorf("PROXY cannot contain path, query parameters, or fragments") } + return nil } @@ -322,12 +350,13 @@ func readEnvFile(path string) (map[string]string, error) { if strings.TrimSpace(path) == "" { return values, nil } + file, err := os.Open(path) if os.IsNotExist(err) { return values, nil } if err != nil { - return nil, fmt.Errorf("读取配置文件: %w", err) + return nil, fmt.Errorf("read config file: %w", err) } defer file.Close() @@ -337,23 +366,29 @@ func readEnvFile(path string) (map[string]string, error) { if line == "" || strings.HasPrefix(line, "#") { continue } + key, raw, ok := strings.Cut(line, "=") if !ok { - return nil, fmt.Errorf("%s:%d 缺少等号", path, lineNumber) + return nil, fmt.Errorf("%s:%d missing '=' separator", path, lineNumber) } + key = strings.TrimSpace(key) if !isConfigKey(key) { continue } + value, err := parseEnvValue(strings.TrimSpace(raw)) if err != nil { return nil, fmt.Errorf("%s:%d: %w", path, lineNumber, err) } + values[key] = value } + if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("读取配置文件: %w", err) + return nil, fmt.Errorf("read config file: %w", err) } + return values, nil } @@ -363,6 +398,7 @@ func isConfigKey(value string) bool { return true } } + return false } @@ -370,22 +406,26 @@ func parseEnvValue(value string) (string, error) { if value == "" { return "", nil } + if value[0] == '\'' { if len(value) < 2 || value[len(value)-1] != '\'' { - return "", fmt.Errorf("单引号未闭合") + return "", fmt.Errorf("unclosed single quote") } return value[1 : len(value)-1], nil } + if value[0] == '"' { parsed, err := strconv.Unquote(value) if err != nil { - return "", fmt.Errorf("双引号值无效") + return "", fmt.Errorf("invalid double-quoted string") } return parsed, nil } + if index := strings.Index(value, " #"); index >= 0 { value = strings.TrimSpace(value[:index]) } + return value, nil } @@ -393,74 +433,88 @@ func formatEnvValue(value string) string { if value == "" { return "" } + if strings.ContainsAny(value, " \t\r\n#\"'") { return strconv.Quote(value) } + return value } func parsePositiveDuration(key string, value string) (time.Duration, error) { duration, err := time.ParseDuration(strings.TrimSpace(value)) if err != nil || duration <= 0 { - return 0, fmt.Errorf("%s 必须是正数时长,例如 30s 或 5m", key) + return 0, fmt.Errorf("%s must be a positive duration, e.g., 30s or 5m", key) } + return duration, nil } func parsePositiveInt(key string, value string) (int, error) { parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || parsed <= 0 { - return 0, fmt.Errorf("%s 必须是正整数", key) + return 0, fmt.Errorf("%s must be a positive integer", key) } + return parsed, nil } func validateListenAddr(value string) error { host, port, err := net.SplitHostPort(strings.TrimSpace(value)) if err != nil || port == "" { - return fmt.Errorf("LISTEN_ADDR 必须是 host:port") + return fmt.Errorf("LISTEN_ADDR must be host:port") } + if host == "" { host = "0.0.0.0" } + if parsed, err := strconv.ParseUint(port, 10, 16); err != nil || parsed == 0 { - return fmt.Errorf("LISTEN_ADDR 端口必须是 1 到 65535") + return fmt.Errorf("LISTEN_ADDR port must be between 1 and 65535") } + return nil } func atomicWrite(path string, data []byte, mode os.FileMode) error { target, err := filepath.Abs(path) if err != nil { - return fmt.Errorf("解析配置路径: %w", err) + return fmt.Errorf("resolve config path: %w", err) } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("创建配置目录: %w", err) + return fmt.Errorf("create config directory: %w", err) } + temporary, err := os.CreateTemp(filepath.Dir(target), ".env-*.tmp") if err != nil { - return fmt.Errorf("创建临时配置: %w", err) + return fmt.Errorf("create temp config file: %w", err) } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(mode); err != nil { temporary.Close() - return fmt.Errorf("设置配置权限: %w", err) + return fmt.Errorf("chmod config file: %w", err) } + if _, err := bytes.NewReader(data).WriteTo(temporary); err != nil { temporary.Close() - return fmt.Errorf("写入配置: %w", err) + return fmt.Errorf("write config file: %w", err) } + if err := temporary.Sync(); err != nil { temporary.Close() - return fmt.Errorf("同步配置: %w", err) + return fmt.Errorf("sync config file: %w", err) } + if err := temporary.Close(); err != nil { - return fmt.Errorf("关闭配置: %w", err) + return fmt.Errorf("close config file: %w", err) } + if err := os.Rename(temporaryPath, target); err != nil { - return fmt.Errorf("替换配置: %w", err) + return fmt.Errorf("replace config file: %w", err) } + return nil } diff --git a/internal/setup/setup.go b/internal/setup/setup.go index f5858f1..e780e0f 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -28,8 +28,9 @@ func (values *setupStrings) String() string { func (values *setupStrings) Set(value string) error { value = strings.TrimSpace(value) if value == "" { - return fmt.Errorf("参数值不能为空") + return fmt.Errorf("value cannot be empty") } + *values = append(*values, value) return nil } @@ -46,19 +47,23 @@ type setupOptions struct { timezone string } -// Run 执行本机 Chrome 导入、文件导入或隔离登录 +// Run executes local Chrome import, storage state file import, or isolated login. func Run(ctx context.Context, cfg config.Config, args []string) error { options, err := parseSetupFlags(args, cfg) if err != nil { return err } + if err := validateSetupRoot(cfg.AuthStates); err != nil { return err } + store := aistudio.NewAccountStore(cfg.AuthStates) + if options.storageState != "" { return importStorageState(store, options) } + if options.login { driver, err := defaultSetupLoginDriver(ctx, cfg) if err != nil { @@ -66,6 +71,7 @@ func Run(ctx context.Context, cfg config.Config, args []string) error { } return importIsolatedLogin(ctx, store, options, driver) } + return importChromeAccounts(ctx, cfg, store, options, os.Stdin, os.Stdout) } @@ -74,23 +80,28 @@ func importStorageState(store *aistudio.AccountStore, options setupOptions) erro if err != nil { return err } + if _, err := aistudio.NewSigner().Sign(state); err != nil { - return fmt.Errorf("认证状态无法用于 AI Studio: %w", err) + return fmt.Errorf("storage state cannot be used with AI Studio: %w", err) } + label := defaultSetupLabel(options.storageState) if extension, exists, err := state.AuthExtension(); err != nil { return err } else if exists && strings.TrimSpace(extension.Source.Email) != "" { label = extension.Source.Email } + account, publishLease, err := store.Create(setupAccountConfig(label, options), state) if err != nil { return err } + if err := publishLease.Release(); err != nil { return err } - fmt.Fprintf(os.Stdout, "账户已保存: %s\n", account.Config.Label) + + fmt.Fprintf(os.Stdout, "Account saved: %s\n", account.Config.Label) return nil } @@ -102,19 +113,25 @@ func importIsolatedLogin( ) (resultErr error) { loginDirectory, err := os.MkdirTemp("", "aistudio2api-login-*") if err != nil { - return fmt.Errorf("创建隔离登录目录: %w", err) + return fmt.Errorf("create isolated login directory: %w", err) } defer os.RemoveAll(loginDirectory) + result, err := driver.Login(ctx, aistudio.IsolatedLoginRequest{ - AccountID: "setup", Directory: loginDirectory, Proxy: options.proxy, - Locale: options.locale, Timezone: options.timezone, + AccountID: "setup", + Directory: loginDirectory, + Proxy: options.proxy, + Locale: options.locale, + Timezone: options.timezone, }) if err != nil { return err } + if _, err := aistudio.NewSigner().Sign(result.StorageState); err != nil { - return fmt.Errorf("认证状态无法用于 AI Studio: %w", err) + return fmt.Errorf("storage state cannot be used with AI Studio: %w", err) } + account, publishLease, err := store.Create(setupAccountConfig(result.Email, options), result.StorageState) if err != nil { return err @@ -122,14 +139,23 @@ func importIsolatedLogin( defer func() { resultErr = errors.Join(resultErr, publishLease.Release()) }() + if err := camoufoxnative.PersistAccountFingerprint(loginDirectory, account.Directory); err != nil { return errors.Join(err, store.Delete(account)) } - fmt.Fprintf(os.Stdout, "账户已保存: %s\n", account.Config.Label) + + fmt.Fprintf(os.Stdout, "Account saved: %s\n", account.Config.Label) return nil } -func importChromeAccounts(ctx context.Context, cfg config.Config, store *aistudio.AccountStore, options setupOptions, input io.Reader, output io.Writer) error { +func importChromeAccounts( + ctx context.Context, + cfg config.Config, + store *aistudio.AccountStore, + options setupOptions, + input io.Reader, + output io.Writer, +) error { root := options.chromeRoot if root == "" { var err error @@ -138,142 +164,181 @@ func importChromeAccounts(ctx context.Context, cfg config.Config, store *aistudi return err } } + if len(options.profiles) == 0 && len(options.emails) == 0 { accounts, err := chromeauth.Discover(root) if err != nil { return err } + options.profiles, err = promptChromeProfiles(accounts, input, output) if err != nil { return err } } + results, err := chromeauth.Import(ctx, chromeauth.ImportOptions{ - ChromeRoot: root, Proxy: options.proxy, Profiles: options.profiles, Emails: options.emails, + ChromeRoot: root, + Proxy: options.proxy, + Profiles: options.profiles, + Emails: options.emails, }) if err != nil { return err } + modelCounts := make([]int, len(results)) for index := range results { verifyContext, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) verification, verifyErr := chromeauth.Verify(verifyContext, &results[index].State, options.proxy) cancel() + if verifyErr != nil { - return fmt.Errorf("验证 %s: %w", results[index].Email, verifyErr) + return fmt.Errorf("verify %s: %w", results[index].Email, verifyErr) } + modelCounts[index] = verification.ModelCount } + for index, result := range results { accountOptions := options if !options.localeSet && result.Locale != "" { accountOptions.locale = result.Locale } + _, publishLease, err := store.Create(setupAccountConfig(result.Email, accountOptions), result.State) if err != nil { return err } + if err := publishLease.Release(); err != nil { return err } - fmt.Fprintf(output, "已导入: %s (%s),%d 个模型\n", result.Email, result.Profile, modelCounts[index]) + + fmt.Fprintf(output, "Imported: %s (%s), %d models\n", result.Email, result.Profile, modelCounts[index]) } + return nil } func promptChromeProfiles(accounts []chromeauth.Account, input io.Reader, output io.Writer) (setupStrings, error) { available := make([]chromeauth.Account, 0, len(accounts)) table := tabwriter.NewWriter(output, 0, 4, 2, ' ', 0) - fmt.Fprintln(table, "编号\t状态\tProfile\t显示名\t邮箱") + + fmt.Fprintln(table, "No.\tStatus\tProfile\tDisplay Name\tEmail") for _, account := range accounts { index := "-" - status := "不可导入" + status := "Not Importable" if account.Importable { available = append(available, account) index = strconv.Itoa(len(available)) - status = "可导入" + status = "Importable" } fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\n", index, status, account.Profile, account.DisplayName, account.Email) } + if err := table.Flush(); err != nil { - return nil, fmt.Errorf("输出 Chrome 账号列表: %w", err) + return nil, fmt.Errorf("write Chrome account list: %w", err) } + if len(available) == 0 { - return nil, fmt.Errorf("本机 Chrome 没有可导入账号") + return nil, fmt.Errorf("no importable accounts found in local Chrome") } - fmt.Fprint(output, "请输入逗号分隔的编号: ") + + fmt.Fprint(output, "Enter comma-separated numbers: ") line, err := bufio.NewReader(input).ReadString('\n') if err != nil && err != io.EOF { - return nil, fmt.Errorf("读取账号选择: %w", err) + return nil, fmt.Errorf("read account selection: %w", err) } + line = strings.TrimSpace(line) if line == "" { - return nil, fmt.Errorf("未选择账号") + return nil, fmt.Errorf("no accounts selected") } + selected := make(setupStrings, 0) seen := make(map[int]struct{}) + for _, raw := range strings.Split(line, ",") { index, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || index < 1 || index > len(available) { - return nil, fmt.Errorf("账号编号 %q 无效", strings.TrimSpace(raw)) + return nil, fmt.Errorf("invalid account number %q", strings.TrimSpace(raw)) } + if _, exists := seen[index]; exists { continue } + seen[index] = struct{}{} selected = append(selected, available[index-1].Profile) } + return selected, nil } func parseSetupFlags(args []string, cfg config.Config) (setupOptions, error) { flags := flag.NewFlagSet("aistudio2api setup", flag.ContinueOnError) flags.Usage = func() { - fmt.Fprintln(flags.Output(), "Chrome 导入: aistudio2api setup") - fmt.Fprintln(flags.Output(), "文件导入: aistudio2api setup --storage-state ") - fmt.Fprintln(flags.Output(), "隔离登录: aistudio2api setup --login") + fmt.Fprintln(flags.Output(), "Chrome import: aistudio2api setup") + fmt.Fprintln(flags.Output(), "File import: aistudio2api setup --storage-state ") + fmt.Fprintln(flags.Output(), "Isolated login: aistudio2api setup --login") flags.PrintDefaults() } + var profiles setupStrings var emails setupStrings - flags.Var(&profiles, "profile", "要导入的 Chrome Profile,可重复") - flags.Var(&emails, "email", "要导入的 Google 邮箱,可重复") - storageState := flags.String("storage-state", "", "Playwright storage state 文件") - login := flags.Bool("login", false, "使用隔离 Camoufox 登录") - chromeRoot := flags.String("chrome-root", "", "Chrome User Data 目录") - proxy := flags.String("proxy", cfg.Proxy, "账户固定 HTTP、HTTPS 或 SOCKS5 代理") - locale := flags.String("locale", aistudio.DefaultAccountLocale(), "账户语言") - timezone := flags.String("timezone", aistudio.DefaultAccountTimezone(), "账户时区") + + flags.Var(&profiles, "profile", "Chrome profile to import, can be repeated") + flags.Var(&emails, "email", "Google email to import, can be repeated") + storageState := flags.String("storage-state", "", "Playwright storage state file") + login := flags.Bool("login", false, "Log in using isolated Camoufox browser") + chromeRoot := flags.String("chrome-root", "", "Chrome User Data directory") + proxy := flags.String("proxy", cfg.Proxy, "Fixed HTTP, HTTPS, or SOCKS5 proxy for accounts") + locale := flags.String("locale", aistudio.DefaultAccountLocale(), "Account locale") + timezone := flags.String("timezone", aistudio.DefaultAccountTimezone(), "Account timezone") + if err := flags.Parse(args); err != nil { return setupOptions{}, err } + if flags.NArg() != 0 { - return setupOptions{}, fmt.Errorf("未知参数 %q", flags.Arg(0)) + return setupOptions{}, fmt.Errorf("unknown argument %q", flags.Arg(0)) } + options := setupOptions{ - storageState: strings.TrimSpace(*storageState), login: *login, - chromeRoot: strings.TrimSpace(*chromeRoot), profiles: profiles, emails: emails, - proxy: strings.TrimSpace(*proxy), - locale: strings.TrimSpace(*locale), timezone: strings.TrimSpace(*timezone), + storageState: strings.TrimSpace(*storageState), + login: *login, + chromeRoot: strings.TrimSpace(*chromeRoot), + profiles: profiles, + emails: emails, + proxy: strings.TrimSpace(*proxy), + locale: strings.TrimSpace(*locale), + timezone: strings.TrimSpace(*timezone), } + flags.Visit(func(value *flag.Flag) { if value.Name == "locale" { options.localeSet = true } }) + chromeSelection := options.chromeRoot != "" || len(options.profiles) != 0 || len(options.emails) != 0 if options.storageState != "" && (options.login || chromeSelection) { - return setupOptions{}, fmt.Errorf("--storage-state 不能与 Chrome 导入或 --login 同时使用") + return setupOptions{}, fmt.Errorf("--storage-state cannot be used together with Chrome import or --login") } + if options.login && chromeSelection { - return setupOptions{}, fmt.Errorf("--login 不能与 Chrome 导入参数同时使用") + return setupOptions{}, fmt.Errorf("--login cannot be used together with Chrome import flags") } + if options.locale == "" || options.timezone == "" { - return setupOptions{}, fmt.Errorf("locale 和 timezone 不能为空") + return setupOptions{}, fmt.Errorf("locale and timezone cannot be empty") } + if err := config.ValidateProxy(options.proxy); err != nil { return setupOptions{}, err } + return options, nil } @@ -282,6 +347,7 @@ func setupAccountConfig(label string, options setupOptions) aistudio.AccountConf accountConfig.Proxy = options.proxy accountConfig.Locale = options.locale accountConfig.Timezone = options.timezone + return accountConfig } @@ -290,6 +356,7 @@ func defaultSetupLabel(storageState string) string { if parent == "" || parent == "." || parent == string(filepath.Separator) { return "Default" } + return parent } @@ -298,26 +365,31 @@ func defaultSetupLoginDriver(ctx context.Context, cfg config.Config) (aistudio.I if err != nil { return nil, err } + return aistudio.NewNativeLoginDriver(camoufoxPath, cfg.RequestTimeout) } func validateSetupRoot(root string) error { root = strings.TrimSpace(root) if root == "" { - return fmt.Errorf("AISTUDIO_AUTH_STATES 不能为空") + return fmt.Errorf("AISTUDIO_AUTH_STATES cannot be empty") } + if strings.Contains(root, ",") { - return fmt.Errorf("setup 需要 AISTUDIO_AUTH_STATES 指向单个账户目录") + return fmt.Errorf("setup requires AISTUDIO_AUTH_STATES to point to a single account directory") } + info, err := os.Stat(root) if os.IsNotExist(err) { return nil } if err != nil { - return fmt.Errorf("读取账户目录: %w", err) + return fmt.Errorf("read account directory: %w", err) } + if !info.IsDir() { - return fmt.Errorf("setup 需要 AISTUDIO_AUTH_STATES 指向账户目录") + return fmt.Errorf("setup requires AISTUDIO_AUTH_STATES to point to an account directory") } + return nil } diff --git a/internal/webui/embed.go b/internal/webui/embed.go index ba189ae..84bfcd2 100644 --- a/internal/webui/embed.go +++ b/internal/webui/embed.go @@ -1,4 +1,4 @@ -// Package webui 提供内嵌管理端静态资源 +// Package webui provides embedded static assets for the admin UI. package webui import ( @@ -12,22 +12,25 @@ import ( //go:embed dist var embedded embed.FS -// Files 返回管理端构建产物文件系统 +// Files returns the filesystem containing the admin UI build artifacts. func Files() fs.FS { dist, err := fs.Sub(embedded, "dist") if err != nil { panic(err) } + return dist } -// Handler 返回管理端静态文件处理器 +// Handler returns the HTTP handler for admin UI static assets. func Handler() http.Handler { files := Files() + index, err := fs.ReadFile(files, "index.html") if err != nil { panic(err) } + return &spaHandler{ files: files, static: http.FileServer(http.FS(files)), @@ -41,13 +44,15 @@ type spaHandler struct { index []byte } -// ServeHTTP 服务静态资源并为普通页面路径返回 SPA 入口 +// ServeHTTP serves static assets and returns the SPA entry point for page routes. func (handler *spaHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { requestPath := path.Clean(request.URL.Path) + if reservedPath(requestPath) { http.NotFound(writer, request) return } + if request.Method != http.MethodGet && request.Method != http.MethodHead { http.NotFound(writer, request) return @@ -57,10 +62,12 @@ func (handler *spaHandler) ServeHTTP(writer http.ResponseWriter, request *http.R if name == "." { name = "index.html" } + if _, err := fs.Stat(handler.files, name); err == nil { handler.static.ServeHTTP(writer, request) return } + if request.Method == http.MethodHead { http.NotFound(writer, request) return @@ -71,12 +78,13 @@ func (handler *spaHandler) ServeHTTP(writer http.ResponseWriter, request *http.R _, _ = writer.Write(handler.index) } -// reservedPath 判断路径是否属于服务 API +// reservedPath checks if a path belongs to reserved service API routes. func reservedPath(requestPath string) bool { for _, prefix := range []string{"/api", "/v1", "/v1beta", "/health"} { if requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") { return true } } + return false }