diff --git a/README.md b/README.md index d08d54ba..9429ca5a 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Windows 桌面版在命令行中运行以下命令时写作 `cmd /c start /wait - 不带参数时扫描本机 Chrome 账户,`--email` 或 `--profile` 选择明确的 Chrome 账户 - 隔离登录:`aistudio2api setup --login`,需要图形界面;没有图形界面时启动服务,在管理页面的“浏览器登录”中登录 - 文件导入:`aistudio2api setup --storage-state ` -- Cookie 导入:`aistudio2api setup --cookie-file `、`aistudio2api setup --cookie ""` 或 `aistudio2api setup --cdp <调试地址>`,账户邮箱按 Cookie 识别,`--cdp` 导入的账户沿用该浏览器的语言与时区 +- Cookie 导入:`aistudio2api setup --cookie-file `、`aistudio2api setup --cookie ""` 或 `aistudio2api setup --cdp <调试地址>`,账户邮箱按 Cookie 识别,`--cdp` 导入的账户沿用该浏览器的语言与时区;Cookie 来自同时登录多个 Google 账户的浏览器时,以 `--authuser N` 选择官网地址 `/u/N/` 中第 N 个账户 `aistudio2api setup --remote https://<远程实例>`:向远程实例添加账户。先在远程实例的账户页点击“远程添加”取得配对令牌,再在能登录 Google 的电脑上运行该命令并粘贴令牌;可与 `--login` 等参数组合。 diff --git a/README_en.md b/README_en.md index 2206ed96..de5ed28a 100644 --- a/README_en.md +++ b/README_en.md @@ -262,7 +262,7 @@ On Windows, run these commands for the desktop app as `cmd /c start /wait aistud - Without options it scans local Chrome accounts; `--email` or `--profile` selects a Chrome account - Isolated login: `aistudio2api setup --login`, which needs a graphical display; without one, start the service and sign in with "Sign in with browser" in the console - File import: `aistudio2api setup --storage-state ` -- Cookie import: `aistudio2api setup --cookie-file `, `aistudio2api setup --cookie ""`, or `aistudio2api setup --cdp `; the account email is detected from the cookies, and an account imported with `--cdp` keeps the language and time zone of that browser +- Cookie import: `aistudio2api setup --cookie-file `, `aistudio2api setup --cookie ""`, or `aistudio2api setup --cdp `; the account email is detected from the cookies, and an account imported with `--cdp` keeps the language and time zone of that browser; when the cookies come from a browser signed in to several Google accounts, `--authuser N` selects the account at `/u/N/` on the official site `aistudio2api setup --remote https://`: Adds accounts to a remote instance. Click "Add remotely" on the remote instance's Accounts page to get a pairing token, then run the command on a computer that can sign in to Google and paste the token. It combines with options such as `--login`. diff --git a/docs/development.md b/docs/development.md index 91f49ff5..2722c55a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -69,7 +69,7 @@ go run ./cmd/aistudio2api `setup` 的导入模式: - 文件导入:`--storage-state` -- Cookie 导入:`--cookie`、`--cookie-file` 或 `--cdp`,账户邮箱按 Cookie 识别,也可用一个 `--email` 指定;只保留 google.com 及其子域的 Cookie,缺少 `__Secure-1PSID`、`SAPISID`、`__Secure-1PAPISID` 或 `__Secure-3PAPISID` 时报告缺少的 Cookie +- Cookie 导入:`--cookie`、`--cookie-file` 或 `--cdp`,账户邮箱按 Cookie 识别,也可用一个 `--email` 指定;`--authuser N` 选择 Cookie 中第 N 个 Google 会话(官网 `/u/N/`)的账户并验证其邮箱;只保留 google.com 及其子域的 Cookie,缺少 `__Secure-1PSID`、`SAPISID`、`__Secure-1PAPISID` 或 `__Secure-3PAPISID` 时报告缺少的 Cookie - `--cdp` 接受 `host:port`、HTTP 调试地址或浏览器级 `ws://` 地址,经 CDP `Storage.getCookies` 读取该浏览器的全部未分区 Cookie,再附加到第一个页面读取 `navigator.language` 与 Intl 时区,未给 `--locale`、`--timezone` 时作为账户的语言与时区 - 管理页面导入 Cookie 只需粘贴 Cookie:服务以 Cookie 打开 AI Studio 对话页,从页面全局数据 `oPEP7c` 读取登录账户邮箱,再读取 `ListModels` 确认可用;页面转到登录页时返回 `signed_out` - “从指纹浏览器导入”从本机进程命令行找出带 `--remote-debugging-port` 的浏览器(端口为 0 时读取用户数据目录的 `DevToolsActivePort`),逐个读取 Cookie 并识别账户,列出可导入、已导入与未登录的浏览器 @@ -279,7 +279,7 @@ POST /api/control/start | 文件 | 内容 | 生命周期 | | --- | --- | --- | -| `account.json` | label、enabled、proxy、locale、timezone | 创建或编辑账户时写入 | +| `account.json` | label、enabled、proxy、locale、timezone、auth_user | 创建或编辑账户时写入 | | `storage-state.json` | Cookie、localStorage 与可选 Chrome OAuth/DBSC 续签材料 | 合并 `Set-Cookie` 或认证续签后原子写回 | | `camoufox-fingerprint.json` | 账户固定的浏览器指纹、语言与时区 | 首次运行生成,空值、窗口几何、字体、语音、WebGL 与媒体设备按 Camoufox 官方启动器规则规范化;重新登录和 WAA runtime 继续复用 | | `runtime-state.json` | 权益等级、模型资格、冷却与资源账户绑定 | 权益同步、首次模型结果或资源变化后原子写回 | diff --git a/docs/protocol.md b/docs/protocol.md index 54efdeb7..bb69a6db 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -158,7 +158,7 @@ Cookie 的 `name`、`value`、`domain`、`path`、`expires`、`httpOnly`、`secu | 文件 | 内容 | | --- | --- | -| `auth//account.json` | 邮箱、enabled、proxy、locale、timezone | +| `auth//account.json` | 邮箱、enabled、proxy、locale、timezone、auth_user | | `auth//storage-state.json` | Cookie、localStorage 和可选 Chrome 续签材料 | | `auth//camoufox-fingerprint.json` | 账户固定的 navigator、屏幕、字体、WebGL、语言、地区和时区配置 | | `auth//runtime-state.json` | 账户权益、实测模型资格、冷却与 Drive/Veo 资源绑定 | @@ -1184,7 +1184,7 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete | 管理登录 | `GET /api/auth/session`、`POST /api/auth/login`、`POST /api/auth/logout` | | 状态与模型 | `GET /api/status`、`GET /api/models` | | 生成服务 | `POST /api/control/start`、`POST /api/control/stop` | -| 账户 | `GET /api/accounts`、`POST /api/accounts`、`GET/POST /api/accounts/import/chrome`、`GET /api/accounts/import/browsers`、`POST /api/accounts/import/cookies`、`PUT /api/accounts/{id}`、`DELETE /api/accounts/{id}` | +| 账户 | `GET /api/accounts`、`POST /api/accounts`、`GET/POST /api/accounts/import/chrome`、`GET /api/accounts/import/browsers`、`POST /api/accounts/import/cookies/discover`、`POST /api/accounts/import/cookies`、`PUT /api/accounts/{id}`、`DELETE /api/accounts/{id}` | | 账户认证与刷新 | `POST /api/accounts/{id}/login`、`POST /api/accounts/{id}/refresh`、`GET /api/login-view/{view}`(WebSocket) | | 远程配对 | `POST /api/pairing`、`POST /api/pairing/accounts` | | 配置 | `GET /api/config`、`PUT /api/config` | @@ -1203,6 +1203,7 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete | `GET /api/accounts/import/chrome` | 200 | `{"profiles":[ChromeImportProfile,...]}` | | `POST /api/accounts/import/chrome` | 201 | `{"accounts":[AdminAccount,...]}` | | `GET /api/accounts/import/browsers` | 200 | `{"browsers":[CookieBrowser,...]}` | +| `POST /api/accounts/import/cookies/discover` | 200 | `{"accounts":[CookieBrowser,...]}` | | `POST /api/accounts/import/cookies` | 201 | `{"account":AdminAccount}` | | `POST /api/pairing` | 201 | `PairingToken` | | `POST /api/pairing/accounts` | 201 | `{"account":AdminAccount}` | @@ -1222,6 +1223,7 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete | `GET /api/events` | 200 SSE | `{"type":"","data":}` | - `POST /api/pairing/accounts` 的令牌无效或过期时返回 401 `pairing_token_invalid`,账户已存在时返回 409 `account_exists` +- 一份 Cookie 可包含同一浏览器中多个 Google 账户的会话,第 N 个会话对应官网 `/u/N/` 与请求头 `X-Goog-AuthUser: N`。`POST /api/accounts/import/cookies/discover` 从 u/0 起依次打开各会话的对话页,列出每个会话登录的账户;`GET /api/accounts/import/browsers` 为每个浏览器的每个会话各列一项。`POST /api/accounts/import/cookies` 的 `auth_user` 选择导入的会话,省略时为 0;账户的 `auth_user` 写入 `account.json`,运行时以 `/u/N/` 页面与 `X-Goog-AuthUser: N` 访问官网,浏览器重新登录后为 0 - `POST /api/accounts/import/cookies` 的 Cookie 无法解析或缺少登录 Cookie 时返回 400 `invalid_cookies`,调试地址连接或读取失败时返回 502 `cdp_unavailable`,账户已存在时返回 409 `account_exists` - `GET /api/requests/{id}/body` 在没有保存正文时返回 404 `request_body_not_found` - `POST /api/accounts` 与 `POST /api/accounts/{id}/login` 的 body 可带 `view`,即管理页面为本次登录生成的登录窗口 ID;`POST /api/accounts/{id}/login` 也接受空 body。服务器没有图形界面,或请求来自非回环地址、带 `Forwarded` 或 `X-Forwarded-For` 时,登录画面经同一 ID 的 `GET /api/login-view/{view}` 传递,请求在登录结束后返回 @@ -1238,10 +1240,10 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete | `AccountInput` | `label`、`enabled`、`proxy`、`locale`、`timezone` | | `ChromeImportProfile` | `id`、`profile`、`display_name`、`email`、`locale` | | `ChromeImportInput` | `account_ids`、`proxy`、`locale`、`timezone` | -| `CookieImportInput` | `label`、`cookies` 或 `cdp`、`proxy`、`locale`、`timezone`;`cdp` 导入时 `locale`、`timezone` 为空则取该浏览器页面的语言与时区 | -| `CookieBrowser` | `address`、`browser`、`email`、`locale`、`timezone`、`imported`、`signed_out`、`error` | +| `CookieImportInput` | `label`、`cookies` 或 `cdp`、`proxy`、`locale`、`timezone`、可选 `auth_user`(Cookie 中的会话序号 0–9,默认 0);`cdp` 导入时 `locale`、`timezone` 为空则取该浏览器页面的语言与时区 | +| `CookieBrowser` | `address`、`browser`、`email`、`auth_user`、`locale`、`timezone`、`imported`、`signed_out`、`error` | | `PairingToken` | `token`、`expires_at` | -| `AccountStateInput` | `label`、`locale`、`timezone`、`storage_state`、可选 `fingerprint` | +| `AccountStateInput` | `label`、`locale`、`timezone`、`storage_state`、可选 `auth_user`(默认 0)、可选 `fingerprint` | | `AdminCooldown` | `account_id`、`account_label`、`model_id`、`until`、可选 `reason` | | `AdminRequest` | `id`、`model`、`account_id`、`account_label`、`state`、`started_at` | | `AdminLog` | `time`、`level`、`source`、`message`、`event`;请求事件携带 `request`,包含 `id`、`state`、HTTP `status`、`model`、`duration_ms`、`tool_calls`、`usage` 与诊断字段,字段口径见 [logging.md](logging.md) | diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index bec03a2d..a7236478 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -128,6 +128,7 @@ type AccountConfig struct { Proxy string `json:"proxy"` Locale string `json:"locale"` Timezone string `json:"timezone"` + AuthUser int `json:"auth_user,omitempty"` } // ResourceBinding 记录上游资源的创建账户 @@ -583,6 +584,9 @@ func (c AccountConfig) Validate() error { if strings.TrimSpace(c.Timezone) == "" { return fmt.Errorf("账户 timezone 不能为空") } + if c.AuthUser < 0 || c.AuthUser > 9 { + return fmt.Errorf("账户 auth_user 无效: %d", c.AuthUser) + } if err := appconfig.ValidateProxy(c.Proxy); err != nil { return fmt.Errorf("账户 proxy 无效: %w", err) } diff --git a/internal/aistudio/runtime_go.go b/internal/aistudio/runtime_go.go index 4461e7be..a7243853 100644 --- a/internal/aistudio/runtime_go.go +++ b/internal/aistudio/runtime_go.go @@ -174,7 +174,11 @@ func newGoWAARuntime(ctx context.Context, options camoufoxnative.Options) (*goWA } runtime := &goWAARuntime{options: options, client: client, storage: storage} runtime.profile, runtime.userAgent, runtime.acceptLanguage = profileFromFingerprint(fingerprint) - runtime.pageURL = aiStudioOrigin + "/prompts/new_chat?model=" + url.QueryEscape(options.Model) + pagePath := "/prompts/new_chat" + if options.AuthUser > 0 { + pagePath = fmt.Sprintf("/u/%d/prompts/new_chat", options.AuthUser) + } + runtime.pageURL = aiStudioOrigin + pagePath + "?model=" + url.QueryEscape(options.Model) if options.TemporaryChat { runtime.pageURL += "&temporary=true" } @@ -229,7 +233,7 @@ func newGoWAARuntime(ctx context.Context, options camoufoxnative.Options) (*goWA runtime.headers = http.Header{} runtime.headers.Set("user-agent", runtime.userAgent) runtime.headers.Set("x-goog-api-key", apiKey) - runtime.headers.Set("x-goog-authuser", "0") + runtime.headers.Set("x-goog-authuser", strconv.Itoa(options.AuthUser)) runtime.headers.Set("x-user-agent", "grpc-web-javascript/0.1") runtime.headers.Set("x-aistudio-visit-id", visitID) if extension != "" { @@ -462,7 +466,7 @@ func (runtime *goWAARuntime) rpcHeaders(apiKey string) (http.Header, error) { headers.Set("Referer", aiStudioOrigin+"/") headers.Set("Content-Type", "application/json+protobuf") headers.Set("X-Goog-Api-Key", apiKey) - headers.Set("X-Goog-AuthUser", "0") + headers.Set("X-Goog-AuthUser", strconv.Itoa(runtime.options.AuthUser)) headers.Set("X-User-Agent", "grpc-web-javascript/0.1") headers.Set("Authorization", authorization) headers.Set("Origin", aiStudioOrigin) diff --git a/internal/aistudio/transport_http.go b/internal/aistudio/transport_http.go index ecb23d77..e5bfd0a2 100644 --- a/internal/aistudio/transport_http.go +++ b/internal/aistudio/transport_http.go @@ -203,6 +203,9 @@ func (t *MakerSuiteHTTPTransport) UploadAssistantFile(ctx context.Context, accou header.Set("Sec-Fetch-Dest", "empty") header.Set("Sec-Fetch-Mode", "cors") header.Set("Sec-Fetch-Site", "same-origin") + if account.Config.AuthUser > 0 { + header.Set("X-Goog-Authuser", strconv.Itoa(account.Config.AuthUser)) + } if language := account.AcceptLanguage(); language != "" { header.Set("Accept-Language", language) } @@ -363,6 +366,9 @@ func prepareProtocolHeaders( headers.Add(name, value) } } + if account.Config.AuthUser > 0 { + headers.Set("X-Goog-Authuser", strconv.Itoa(account.Config.AuthUser)) + } 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) diff --git a/internal/api/admin.go b/internal/api/admin.go index 9f6c245c..70f43bd5 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -20,6 +20,7 @@ type AdminService interface { CreateAccount(context.Context, AccountCreateInput, *camoufoxnative.LiveView) (AdminAccount, error) ChromeImportProfiles(context.Context) ([]ChromeImportProfile, error) CookieBrowsers(context.Context) ([]CookieBrowser, error) + DiscoverCookieAccounts(context.Context, CookieImportInput) ([]CookieBrowser, error) ImportChromeAccounts(context.Context, ChromeImportInput) ([]AdminAccount, error) ImportAccountState(context.Context, AccountStateInput) (AdminAccount, error) UpdateAccount(context.Context, string, AccountInput) (AdminAccount, error) @@ -197,6 +198,7 @@ type CookieImportInput struct { Proxy string `json:"proxy"` Locale string `json:"locale"` Timezone string `json:"timezone"` + AuthUser int `json:"auth_user"` } // ChromeImportProfile 表示可从本机 Chrome 导入的账号 @@ -213,6 +215,7 @@ type CookieBrowser struct { Address string `json:"address"` Browser string `json:"browser"` Email string `json:"email"` + AuthUser int `json:"auth_user"` Locale string `json:"locale"` Timezone string `json:"timezone"` Imported bool `json:"imported"` @@ -305,6 +308,7 @@ func (s *server) registerAdmin(mux *http.ServeMux) { mux.HandleFunc("GET /api/accounts/import/chrome", s.handleChromeImportProfiles) mux.HandleFunc("POST /api/accounts/import/chrome", s.handleImportChromeAccounts) mux.HandleFunc("GET /api/accounts/import/browsers", s.handleCookieBrowsers) + mux.HandleFunc("POST /api/accounts/import/cookies/discover", s.handleDiscoverCookieAccounts) mux.HandleFunc("POST /api/accounts/import/cookies", s.handleImportCookieAccount) mux.HandleFunc("POST /api/pairing", s.handleCreatePairing) mux.HandleFunc("PUT /api/accounts/{id}", s.handleUpdateAccount) @@ -414,6 +418,20 @@ func (s *server) handleCookieBrowsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string][]CookieBrowser{"browsers": browsers}) } +func (s *server) handleDiscoverCookieAccounts(w http.ResponseWriter, r *http.Request) { + var input CookieImportInput + if err := decodeJSON(r, &input); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + accounts, err := s.config.Admin.DiscoverCookieAccounts(r.Context(), input) + if err != nil { + writeAdminUpstreamError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string][]CookieBrowser{"accounts": accounts}) +} + func (s *server) handleImportCookieAccount(w http.ResponseWriter, r *http.Request) { var input CookieImportInput if err := decodeJSON(r, &input); err != nil { @@ -440,7 +458,8 @@ func (s *server) handleImportCookieAccount(w http.ResponseWriter, r *http.Reques return } account, err := s.config.Admin.ImportAccountState(r.Context(), AccountStateInput{ - Label: input.Label, Proxy: input.Proxy, Locale: input.Locale, Timezone: input.Timezone, StorageState: state, + Label: input.Label, Proxy: input.Proxy, Locale: input.Locale, Timezone: input.Timezone, + AuthUser: input.AuthUser, StorageState: state, }) if err != nil { writeAdminUpstreamError(w, err) diff --git a/internal/api/pairing.go b/internal/api/pairing.go index 2b645dd4..b310ad93 100644 --- a/internal/api/pairing.go +++ b/internal/api/pairing.go @@ -28,6 +28,7 @@ type AccountStateInput struct { Proxy string `json:"-"` Locale string `json:"locale"` Timezone string `json:"timezone"` + AuthUser int `json:"auth_user"` StorageState aistudio.StorageState `json:"storage_state"` Fingerprint json.RawMessage `json:"fingerprint,omitempty"` } diff --git a/internal/app/admin.go b/internal/app/admin.go index 840c2896..978e3eaa 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "runtime/debug" + "slices" "sort" "strings" "sync" @@ -245,17 +246,14 @@ func (admin *runtimeAdmin) ChromeImportProfiles(context.Context) ([]api.ChromeIm return profiles, nil } -// CookieBrowsers 列出本机开启远程调试的浏览器,读取其 Cookie 并识别其中登录 AI Studio 的账户 +// CookieBrowsers 列出本机开启远程调试的浏览器,读取其 Cookie 并识别其中各会话登录 AI Studio 的账户,每个账户一项 func (admin *runtimeAdmin) CookieBrowsers(ctx context.Context) ([]api.CookieBrowser, error) { addresses, err := chromeauth.DebugAddresses(ctx) 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{}{} - } - browsers := make([]api.CookieBrowser, len(addresses)) + existing := admin.accountIDs() + browsers := make([][]api.CookieBrowser, len(addresses)) var group sync.WaitGroup for index, address := range addresses { group.Go(func() { @@ -263,40 +261,77 @@ func (admin *runtimeAdmin) CookieBrowsers(ctx context.Context) ([]api.CookieBrow name, err := chromeauth.DebugBrowserName(ctx, address) if err != nil { browser.Error = err.Error() - browsers[index] = browser + browsers[index] = []api.CookieBrowser{browser} return } browser.Browser = name - err = admin.readCookieBrowser(ctx, &browser) + accounts, err := admin.readCookieBrowser(ctx, &browser) browser.SignedOut = errors.Is(err, chromeauth.ErrSignedOut) || errors.Is(err, aistudio.ErrLoginCookieMissing) if err != nil && !browser.SignedOut { browser.Error = err.Error() } - _, browser.Imported = existing[strings.ToLower(browser.Email)] - browsers[index] = browser + browsers[index] = cookieAccountEntries(browser, accounts, existing) }) } group.Wait() - return browsers, nil + return append([]api.CookieBrowser{}, slices.Concat(browsers...)...), nil } -// readCookieBrowser 读取调试浏览器的语言、时区与 Cookie,填入其中登录 AI Studio 的账户邮箱 -func (admin *runtimeAdmin) readCookieBrowser(ctx context.Context, browser *api.CookieBrowser) error { +// readCookieBrowser 读取调试浏览器的语言、时区与 Cookie,返回其中各会话登录 AI Studio 的账户 +func (admin *runtimeAdmin) readCookieBrowser(ctx context.Context, browser *api.CookieBrowser) ([]chromeauth.CookieAccount, error) { source, err := chromeauth.ReadCDPBrowser(ctx, browser.Address) if err != nil { - return err + return nil, err } browser.Locale, browser.Timezone = source.Locale, source.Timezone state, err := aistudio.ParseCookieText(source.Cookies) if err != nil { - return err + return nil, err + } + return chromeauth.DiscoverCookieAccounts(ctx, &state, admin.effectiveProxy("")) +} + +// DiscoverCookieAccounts 解析粘贴的 Cookie,返回其中各会话登录 AI Studio 的账户 +func (admin *runtimeAdmin) DiscoverCookieAccounts(ctx context.Context, input api.CookieImportInput) ([]api.CookieBrowser, error) { + state, err := aistudio.ParseCookieText(input.Cookies) + if err != nil { + return nil, &adminOperationError{status: http.StatusBadRequest, code: "invalid_cookies", message: err.Error()} + } + if err := state.Validate(); err != nil { + return nil, invalidAccount(err) + } + accounts, err := chromeauth.DiscoverCookieAccounts(ctx, &state, admin.effectiveProxy(input.Proxy)) + if errors.Is(err, chromeauth.ErrSignedOut) { + return nil, &adminOperationError{status: http.StatusBadRequest, code: "signed_out", message: err.Error()} } - verification, err := chromeauth.Verify(ctx, &state, admin.effectiveProxy("")) if err != nil { - return err + return nil, invalidAccount(err) } - browser.Email = strings.ToLower(verification.Email) - return nil + return cookieAccountEntries(api.CookieBrowser{}, accounts, admin.accountIDs()), nil +} + +// cookieAccountEntries 以 template 为模板为每个会话账户生成一项并标记已导入的账户,没有账户时只返回 template +func cookieAccountEntries(template api.CookieBrowser, accounts []chromeauth.CookieAccount, existing map[string]struct{}) []api.CookieBrowser { + if len(accounts) == 0 { + return []api.CookieBrowser{template} + } + entries := make([]api.CookieBrowser, len(accounts)) + for index, account := range accounts { + entry := template + entry.Email, entry.AuthUser = account.Email, account.AuthUser + _, entry.Imported = existing[account.Email] + entries[index] = entry + } + return entries +} + +// accountIDs 返回已导入账户的小写 ID +func (admin *runtimeAdmin) accountIDs() map[string]struct{} { + existing := make(map[string]struct{}) + for _, status := range admin.pool.Status() { + existing[strings.ToLower(strings.TrimSpace(status.ID))] = struct{}{} + } + return existing } func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.ChromeImportInput) ([]api.AdminAccount, error) { @@ -352,11 +387,11 @@ func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.C return accounts, nil } -// ImportAccountState 导入远程配对上传或 Cookie 导入的认证状态与 Camoufox 指纹,未给标签时以 Cookie 登录的账户邮箱为标签 +// ImportAccountState 导入远程配对上传或 Cookie 导入的认证状态与 Camoufox 指纹,未给标签时以 Cookie 中第 auth_user 个会话登录的账户邮箱为标签 func (admin *runtimeAdmin) ImportAccountState(ctx context.Context, input api.AccountStateInput) (api.AdminAccount, error) { label := strings.TrimSpace(input.Label) if label == "" { - verification, err := chromeauth.Verify(ctx, &input.StorageState, admin.effectiveProxy(input.Proxy)) + verification, err := chromeauth.VerifyAuthUser(ctx, &input.StorageState, admin.effectiveProxy(input.Proxy), input.AuthUser) if errors.Is(err, chromeauth.ErrSignedOut) { return api.AdminAccount{}, &adminOperationError{status: http.StatusBadRequest, code: "signed_out", message: err.Error()} } @@ -366,6 +401,7 @@ func (admin *runtimeAdmin) ImportAccountState(ctx context.Context, input api.Acc label = strings.ToLower(verification.Email) } accountConfig := aistudio.DefaultAccountConfig(label) + accountConfig.AuthUser = input.AuthUser accountConfig.Proxy = strings.TrimSpace(input.Proxy) if locale := strings.TrimSpace(input.Locale); locale != "" { accountConfig.Locale = locale @@ -479,6 +515,7 @@ func (admin *runtimeAdmin) UpdateAccount(ctx context.Context, accountID string, return api.AdminAccount{}, accountOperationError(err) } account := lease.Account() + accountConfig.AuthUser = account.Config.AuthUser enabling := accountConfig.Enabled && !account.Config.Enabled if accountConfig.Proxy == "" && !admin.proxies.Available(account.PoolProxy("")) { account.SetPoolProxy(admin.proxies.Next()) @@ -595,6 +632,9 @@ func (admin *runtimeAdmin) LoginAccount(ctx context.Context, accountID string, v if err := lease.SaveStorageState(state); err != nil { return api.AdminAccount{}, errors.Join(err, lease.Release()) } + if err := resetCookieAuthUser(lease, admin.workers.prepareUpdate); 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), @@ -615,6 +655,26 @@ func (admin *runtimeAdmin) LoginAccount(ctx context.Context, accountID string, v return admin.account(account.ID) } +// resetCookieAuthUser 在账户认证状态换成单账号 Cookie 后把会话序号归零并同步到 Worker +func resetCookieAuthUser(lease *aistudio.AccountLease, prepare func(*aistudio.Account, aistudio.AccountConfig) (*accountWorkerUpdate, error)) error { + account := lease.Account() + if account.Config.AuthUser == 0 { + return nil + } + accountConfig := account.Config + accountConfig.AuthUser = 0 + workerUpdate, err := prepare(account, accountConfig) + if err != nil { + return err + } + if err := lease.SaveConfig(accountConfig); err != nil { + workerUpdate.Discard() + return err + } + workerUpdate.Commit() + return nil +} + // keepAuthExtension 在新登录状态缺少续签材料时沿用账户已有的 Chrome 来源与 OAuth 材料 func keepAuthExtension(next *aistudio.StorageState, current aistudio.StorageState) error { if _, exists, err := next.AuthExtension(); err != nil || exists { diff --git a/internal/app/auth_retry.go b/internal/app/auth_retry.go index 9e9896c0..80800b29 100644 --- a/internal/app/auth_retry.go +++ b/internal/app/auth_retry.go @@ -24,6 +24,7 @@ type authRuntimeRefresher struct { importCurrent func(context.Context, aistudio.AuthSource, *aistudio.ChromeOAuthMaterial, string) (aistudio.StorageState, error) chromeHas func(string) bool reset func(string) error + prepareWorker func(*aistudio.Account, aistudio.AccountConfig) (*accountWorkerUpdate, error) prepareHeaders func(string) (func(bool), error) globalProxy string requests *requestRegistry @@ -172,7 +173,7 @@ func newAuthRuntimeRefresher( return &authRuntimeRefresher{ pool: workers.pool, refresh: chromeauth.Refresh, importCurrent: importCurrentChromeState, chromeHas: chromeIdentityImportable, - reset: workers.Reset, prepareHeaders: headers.prepareInvalidate, + reset: workers.Reset, prepareWorker: workers.prepareUpdate, prepareHeaders: headers.prepareInvalidate, globalProxy: globalProxy, requests: requests, } @@ -461,6 +462,9 @@ func (refresher *authRuntimeRefresher) Refresh(ctx context.Context) error { )) return wrapped } + if err := resetCookieAuthUser(lease, refresher.prepareWorker); err != nil { + return fmt.Errorf("保存账户 %s 会话序号: %w", account.ID, err) + } refresher.requests.log(account.Config.Label, "INFO", fmt.Sprintf( "账户认证续签完成 | 耗时=%s", time.Since(startedAt).Round(time.Millisecond), diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index 3c7ef2a7..8dbe93be 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -421,6 +421,13 @@ func (manager *runtimeManager) CookieBrowsers(ctx context.Context) ([]api.Cookie return manager.current.admin.CookieBrowsers(ctx) } +// DiscoverCookieAccounts 返回粘贴 Cookie 中各会话序号登录的账户并标记已导入的账户 +func (manager *runtimeManager) DiscoverCookieAccounts(ctx context.Context, input api.CookieImportInput) ([]api.CookieBrowser, error) { + manager.mu.RLock() + defer manager.mu.RUnlock() + return manager.current.admin.DiscoverCookieAccounts(ctx, input) +} + // ImportChromeAccounts 在当前生成服务批量导入 Chrome 账号 func (manager *runtimeManager) ImportChromeAccounts(ctx context.Context, input api.ChromeImportInput) ([]api.AdminAccount, error) { manager.mu.RLock() diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 2b551abf..dc423ea2 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -779,6 +779,7 @@ func (manager *accountWorkerManager) workerConfigFor( Proxy: proxy, Headless: true, TemporaryChat: manager.temporaryChat, + AuthUser: config.AuthUser, } } diff --git a/internal/camoufoxnative/types.go b/internal/camoufoxnative/types.go index c9f784ea..70922982 100644 --- a/internal/camoufoxnative/types.go +++ b/internal/camoufoxnative/types.go @@ -49,6 +49,7 @@ type Options struct { ProxyBypass string Headless bool TemporaryChat bool + AuthUser int CacheDirectory string ReadyTimeout time.Duration Log io.Writer diff --git a/internal/camoufoxnative/worker.go b/internal/camoufoxnative/worker.go index b68f2cf8..fc90d156 100644 --- a/internal/camoufoxnative/worker.go +++ b/internal/camoufoxnative/worker.go @@ -323,7 +323,11 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st return err } options.reportStartup(StartupLoadingAIStudio) - target := aiStudioOrigin + "/prompts/new_chat?model=" + url.QueryEscape(options.Model) + pagePath := "/prompts/new_chat" + if options.AuthUser > 0 { + pagePath = fmt.Sprintf("/u/%d/prompts/new_chat", options.AuthUser) + } + target := aiStudioOrigin + pagePath + "?model=" + url.QueryEscape(options.Model) if options.TemporaryChat { target += "&temporary=true" } diff --git a/internal/chromeauth/verify.go b/internal/chromeauth/verify.go index 6d395b39..c2f0e465 100644 --- a/internal/chromeauth/verify.go +++ b/internal/chromeauth/verify.go @@ -9,6 +9,8 @@ import ( "io" "net/http" "regexp" + "slices" + "strconv" "strings" "time" @@ -17,6 +19,9 @@ import ( const aiStudioChatURL = "https://aistudio.google.com/prompts/new_chat" +// maxCookieAuthUsers 是识别同一份 Cookie 中会话的序号上限 +const maxCookieAuthUsers = 10 + // ErrSignedOut 表示 Cookie 打开 AI Studio 时被转到登录页 var ErrSignedOut = errors.New("Cookie 未登录 AI Studio 或已失效") @@ -29,44 +34,99 @@ type Verification struct { ModelCount int } -// Verify 验证账号可访问 WAA 页面并读取实时模型目录 +// CookieAccount 是同一份 Cookie 中一个 Google 会话序号上登录 AI Studio 的账户 +type CookieAccount struct { + AuthUser int + Email string +} + +// Verify 验证 Cookie 中第一个会话的账号可访问 WAA 页面并读取实时模型目录 func Verify(ctx context.Context, state *aistudio.StorageState, proxy string) (Verification, error) { - if state == nil { - return Verification{}, fmt.Errorf("storage state 为空") + return VerifyAuthUser(ctx, state, proxy, 0) +} + +// VerifyAuthUser 验证 Cookie 中第 authUser 个会话的账号可访问 WAA 页面并读取实时模型目录 +func VerifyAuthUser(ctx context.Context, state *aistudio.StorageState, proxy string, authUser int) (Verification, error) { + client, headers, err := newVerifyClient(ctx, state, proxy) + if err != nil { + return Verification{}, err } - if _, err := aistudio.NewSigner().Sign(*state); err != nil { + defer client.CloseIdleConnections() + email, err := verifyChatPage(ctx, client, state, headers.Get("User-Agent"), authUser) + if err != nil { return Verification{}, err } - client, err := aistudio.NewProxyHTTPClient(proxy) + models, err := verifyModels(ctx, client, state, headers, authUser) if err != nil { return Verification{}, err } + return Verification{Email: email, ModelCount: models}, nil +} + +// DiscoverCookieAccounts 从 u/0 起依次打开 Cookie 中各会话的对话页,返回各序号登录的账户,序号不存在或账户与前面重复时结束 +func DiscoverCookieAccounts(ctx context.Context, state *aistudio.StorageState, proxy string) ([]CookieAccount, error) { + client, headers, err := newVerifyClient(ctx, state, proxy) + if err != nil { + return nil, err + } defer client.CloseIdleConnections() + var accounts []CookieAccount + for authUser := range maxCookieAuthUsers { + email, err := verifyChatPage(ctx, client, state, headers.Get("User-Agent"), authUser) + if authUser > 0 && errors.Is(err, ErrSignedOut) { + break + } + if err != nil { + return nil, err + } + email = strings.ToLower(email) + if slices.ContainsFunc(accounts, func(account CookieAccount) bool { return account.Email == email }) { + break + } + accounts = append(accounts, CookieAccount{AuthUser: authUser, Email: email}) + } + return accounts, nil +} + +// newVerifyClient 创建验证用的 HTTP 客户端并读取官网公共请求头,客户端不跟随重定向 +func newVerifyClient(ctx context.Context, state *aistudio.StorageState, proxy string) (*http.Client, http.Header, error) { + if state == nil { + return nil, nil, fmt.Errorf("storage state 为空") + } + if _, err := aistudio.NewSigner().Sign(*state); err != nil { + return nil, nil, err + } + client, err := aistudio.NewProxyHTTPClient(proxy) + if err != nil { + return nil, nil, err + } headers, err := aistudio.DiscoverPublicHeaders(ctx, client) if err != nil { - return Verification{}, err + client.CloseIdleConnections() + return nil, nil, err } client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } - email, err := verifyChatPage(ctx, client, state, headers.Get("User-Agent")) - if err != nil { - return Verification{}, err - } - models, err := verifyModels(ctx, client, state, headers) - if err != nil { - return Verification{}, err + return client, headers, nil +} + +// chatURLForAuthUser 返回 Cookie 中第 authUser 个会话的对话页地址 +func chatURLForAuthUser(authUser int) string { + if authUser > 0 { + return fmt.Sprintf("https://aistudio.google.com/u/%d/prompts/new_chat", authUser) } - return Verification{Email: email, ModelCount: models}, nil + return aiStudioChatURL } // verifyChatPage 以账户 Cookie 打开对话页并返回页面中登录账户的邮箱 -func verifyChatPage(ctx context.Context, client *http.Client, state *aistudio.StorageState, userAgent string) (string, error) { - cookie, err := state.CookieHeader(aiStudioChatURL, time.Now()) +func verifyChatPage(ctx context.Context, client *http.Client, state *aistudio.StorageState, userAgent string, authUser int) (string, error) { + targetURL := chatURLForAuthUser(authUser) + cookie, err := state.CookieHeader(targetURL, time.Now()) if err != nil { return "", err } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, aiStudioChatURL, nil) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) if err != nil { return "", err } @@ -92,10 +152,10 @@ func verifyChatPage(ctx context.Context, client *http.Client, state *aistudio.St if match := accountEmailPattern.FindSubmatch(body); match == nil || json.Unmarshal(match[1], &email) != nil || !strings.Contains(email, "@") { return "", fmt.Errorf("AI Studio 页面缺少登录账户") } - return email, mergeResponseCookies(state, response, aiStudioChatURL) + return email, mergeResponseCookies(state, response, targetURL) } -func verifyModels(ctx context.Context, client *http.Client, state *aistudio.StorageState, headers http.Header) (int, error) { +func verifyModels(ctx context.Context, client *http.Client, state *aistudio.StorageState, headers http.Header, authUser int) (int, error) { url := aistudio.MakerSuiteRPCBase + "ListModels" cookie, err := state.CookieHeader(url, time.Now()) if err != nil { @@ -119,6 +179,7 @@ func verifyModels(ctx context.Context, client *http.Client, state *aistudio.Stor request.Header.Set("Sec-Fetch-Dest", "empty") request.Header.Set("Sec-Fetch-Mode", "cors") request.Header.Set("Sec-Fetch-Site", "same-site") + request.Header.Set("X-Goog-Authuser", strconv.Itoa(authUser)) response, err := client.Do(request) if err != nil { return 0, fmt.Errorf("读取 AI Studio 模型目录: %w", err) diff --git a/internal/setup/setup.go b/internal/setup/setup.go index b0ac8dc0..e3d1bbc8 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -54,6 +54,8 @@ type setupOptions struct { localeSet bool timezone string timezoneSet bool + authUser int + authUserSet bool remote string token string } @@ -148,7 +150,8 @@ func (target remoteTarget) Save( fingerprintDirectory string, ) (string, error) { input := api.AccountStateInput{ - Label: accountConfig.Label, Locale: accountConfig.Locale, Timezone: accountConfig.Timezone, StorageState: state, + Label: accountConfig.Label, Locale: accountConfig.Locale, Timezone: accountConfig.Timezone, + AuthUser: accountConfig.AuthUser, StorageState: state, } if fingerprintDirectory != "" { data, err := os.ReadFile(filepath.Join(fingerprintDirectory, "camoufox-fingerprint.json")) @@ -264,14 +267,18 @@ func importCookies(ctx context.Context, target accountTarget, options setupOptio if _, err := aistudio.NewSigner().Sign(state); err != nil { return fmt.Errorf("认证状态无法用于 AI Studio: %w", err) } - var email string + email := "" if len(options.emails) == 1 { email = options.emails[0] - } else { - verification, err := chromeauth.Verify(ctx, &state, options.egress) + } + if email == "" || options.authUserSet { + verification, err := chromeauth.VerifyAuthUser(ctx, &state, options.egress, options.authUser) if err != nil { return fmt.Errorf("识别 Cookie 登录的账户: %w", err) } + if email != "" && !strings.EqualFold(email, verification.Email) { + return fmt.Errorf("Cookie 中 u/%d 登录的账户是 %s,与 --email %s 不同", options.authUser, verification.Email, email) + } email = strings.ToLower(verification.Email) } saved, err := target.Save(ctx, setupAccountConfig(email, options), state, "") @@ -434,6 +441,7 @@ func parseSetupFlags(args []string, cfg config.Config) (setupOptions, error) { proxy := flags.String("proxy", "", "账户固定 HTTP、HTTPS 或 SOCKS5 代理") locale := flags.String("locale", aistudio.DefaultAccountLocale(), "账户语言") timezone := flags.String("timezone", aistudio.DefaultAccountTimezone(), "账户时区") + authUser := flags.Int("authuser", 0, "Google 多账号会话序号 (0..9,对应 /u/0、/u/1...)") remote := flags.String("remote", "", "远程实例 origin,账户上传到该实例") token := flags.String("token", "", "远程实例账户页生成的配对令牌,省略时从标准输入读取") if err := flags.Parse(args); err != nil { @@ -442,12 +450,16 @@ func parseSetupFlags(args []string, cfg config.Config) (setupOptions, error) { if flags.NArg() != 0 { return setupOptions{}, fmt.Errorf("未知参数 %q", flags.Arg(0)) } + if *authUser < 0 || *authUser > 9 { + return setupOptions{}, fmt.Errorf("--authuser 必须在 0..9 之间") + } 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), - token: strings.TrimSpace(*token), + authUser: *authUser, + token: strings.TrimSpace(*token), } options.egress = options.proxy if proxies := config.ProxyList(cfg.Proxy); options.egress == "" && len(proxies) > 0 { @@ -493,8 +505,13 @@ func parseSetupFlags(args []string, cfg config.Config) (setupOptions, error) { options.localeSet = true case "timezone": options.timezoneSet = true + case "authuser": + options.authUserSet = true } }) + if options.authUserSet && options.cookies == "" && options.cdp == "" { + return setupOptions{}, fmt.Errorf("--authuser 只能与 --cookie、--cookie-file 或 --cdp 同时使用") + } chromeSelection := options.cookies == "" && options.cdp == "" && (options.chromeRoot != "" || len(options.profiles) != 0 || len(options.emails) != 0) if options.storageState != "" && (options.login || chromeSelection) { return setupOptions{}, fmt.Errorf("--storage-state 不能与 Chrome 导入或 --login 同时使用") @@ -516,6 +533,7 @@ func setupAccountConfig(label string, options setupOptions) aistudio.AccountConf accountConfig.Proxy = options.proxy accountConfig.Locale = options.locale accountConfig.Timezone = options.timezone + accountConfig.AuthUser = options.authUser return accountConfig } diff --git a/web/src/api.ts b/web/src/api.ts index 752441d0..bb827d5d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -187,6 +187,13 @@ export const api = { }), cookieBrowsers: async () => (await requestJSON<{ browsers: CookieBrowser[] }>('/api/accounts/import/browsers')).browsers, + discoverCookieAccounts: async (input: CookieImportInput) => + ( + await requestJSON<{ accounts: CookieBrowser[] }>('/api/accounts/import/cookies/discover', { + method: 'POST', + body: JSON.stringify(input), + }) + ).accounts, importCookieAccount: (input: CookieImportInput) => requestJSON('/api/accounts/import/cookies', { method: 'POST', diff --git a/web/src/components/AccountsPanel.vue b/web/src/components/AccountsPanel.vue index fcbd5f3b..2584a5ed 100644 --- a/web/src/components/AccountsPanel.vue +++ b/web/src/components/AccountsPanel.vue @@ -44,6 +44,8 @@ const showBrowserLogin = ref(false) const showChromeImport = ref(false) const showCookieImport = ref(false) const cookieText = ref('') +const cookieCandidates = ref([]) +const selectedCookieUsers = ref([]) const showBrowserImport = ref(false) // cookieBrowsers 是本机找到的调试浏览器与其中登录 AI Studio 的账户 const cookieBrowsers = ref([]) @@ -285,9 +287,31 @@ async function importChromeAccounts(): Promise { } function closeCookieImport(): void { - if (pendingAction.value === 'cookie-import') return + if (pendingAction.value === 'cookie-import' || pendingAction.value === 'cookie-discover') return showCookieImport.value = false cookieText.value = '' + cookieCandidates.value = [] + selectedCookieUsers.value = [] +} + +function resetCookieDiscovery(): void { + cookieCandidates.value = [] + selectedCookieUsers.value = [] +} + +// importableCookieUsers 是 Cookie 中尚未导入的会话序号 +const importableCookieUsers = computed(() => + cookieCandidates.value.filter((item) => !item.imported).map((item) => item.auth_user), +) + +const allCookieCandidatesSelected = computed( + () => + importableCookieUsers.value.length > 0 && + selectedCookieUsers.value.length === importableCookieUsers.value.length, +) + +function toggleAllCookieCandidates(): void { + selectedCookieUsers.value = allCookieCandidatesSelected.value ? [] : importableCookieUsers.value } // pasteSteps 是粘贴 Cookie 的操作步骤 @@ -299,12 +323,24 @@ function openBrowserImport(): void { void scanBrowsers() } -// browserSummary 返回浏览器条目第二行:未登录提示、读取错误或浏览器名、地址、语言与时区 +function browserKey(browser: CookieBrowser): string { + return `${browser.address}#${browser.auth_user}` +} + +// browserSummary 返回浏览器条目第二行:未登录提示、读取错误或浏览器名、地址、u/N、语言与时区 function browserSummary(browser: CookieBrowser): string { if (browser.signed_out) return t('accounts.browserSignedOut') return ( browser.error || - [browser.browser, browser.address, browser.locale, browser.timezone].filter(Boolean).join(' · ') + [ + browser.browser, + browser.address, + browser.email ? `u/${browser.auth_user}` : '', + browser.locale, + browser.timezone, + ] + .filter(Boolean) + .join(' · ') ) } @@ -315,7 +351,7 @@ async function scanBrowsers(): Promise { cookieBrowsers.value = await api.cookieBrowsers() selectedBrowsers.value = cookieBrowsers.value .filter((browser) => browser.email !== '' && !browser.imported && browser.error === '') - .map((browser) => browser.address) + .map((browser) => browserKey(browser)) } catch (error) { actionError(error) } finally { @@ -334,17 +370,20 @@ function closeBrowserImport(): void { async function importBrowserAccounts(): Promise { pendingAction.value = 'browser-import' try { - for (const address of selectedBrowsers.value) { + const chosen = new Set(selectedBrowsers.value) + const targets = cookieBrowsers.value.filter((browser) => chosen.has(browserKey(browser))) + for (const target of targets) { await api.importCookieAccount({ label: '', cookies: '', - cdp: address, + cdp: target.address, + auth_user: target.auth_user, proxy: accountEnvironment.proxy, locale: '', timezone: '', }) } - const count = selectedBrowsers.value.length + const count = targets.length pendingAction.value = '' closeBrowserImport() emit('refresh') @@ -410,21 +449,64 @@ const addMethods: { }, ] -// importCookieAccount 导入粘贴的 Cookie,账户由服务按 Cookie 登录的 AI Studio 识别 +// importCookieAccount 第一步识别 Cookie 中各会话登录的账户供勾选,第二步按会话序号导入勾选的账户 async function importCookieAccount(): Promise { + if (cookieCandidates.value.length === 0) { + pendingAction.value = 'cookie-discover' + try { + const discovered = await api.discoverCookieAccounts({ + label: '', + cookies: cookieText.value, + cdp: '', + ...accountEnvironment, + }) + cookieCandidates.value = discovered + selectedCookieUsers.value = importableCookieUsers.value + } catch (error) { + actionError(error) + } finally { + pendingAction.value = '' + } + return + } + if (selectedCookieUsers.value.length === 0) return pendingAction.value = 'cookie-import' + let lastAccountID = '' try { - const { account } = await api.importCookieAccount({ - label: '', - cookies: cookieText.value, - cdp: '', - ...accountEnvironment, - }) + const chosen = new Set(selectedCookieUsers.value) + const targets = cookieCandidates.value.filter((item) => chosen.has(item.auth_user)) + for (const target of targets) { + const { account } = await api.importCookieAccount({ + label: '', + cookies: cookieText.value, + cdp: '', + auth_user: target.auth_user, + ...accountEnvironment, + }) + target.imported = true + selectedCookieUsers.value = selectedCookieUsers.value.filter( + (user) => user !== target.auth_user, + ) + lastAccountID = account.id + } + const count = targets.length pendingAction.value = '' closeCookieImport() emit('refresh') - emit('notice', t('accounts.importedAccount').replace('{email}', account.id), 'success') + if (count === 1 && lastAccountID !== '') { + emit('notice', t('accounts.importedAccount').replace('{email}', lastAccountID), 'success') + } else { + emit( + 'notice', + t(count === 1 ? 'accounts.importCompleteOne' : 'accounts.importComplete').replace( + '{count}', + String(count), + ), + 'success', + ) + } } catch (error) { + if (lastAccountID !== '') emit('refresh') actionError(error) } finally { pendingAction.value = '' @@ -1004,7 +1086,7 @@ async function refreshAllAccounts(): Promise { + +
+ +
+ +
{ @@ -1141,7 +1284,7 @@ async function refreshAllAccounts(): Promise {