From 42fa60338ee7ec302c01b1a3f2be4cb35f09f393 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Tue, 10 Nov 2026 17:57:39 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20Responses=20=E6=8E=A5=E5=8F=97=20Cod?= =?UTF-8?q?ex=20=E5=A4=9A=E6=99=BA=E8=83=BD=E4=BD=93=E7=9A=84=20agent=5Fme?= =?UTF-8?q?ssage=20=E8=BE=93=E5=85=A5=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/protocol.md | 1 + internal/api/responses.go | 32 ++++++++++++++++++++++++++++ internal/api/responses_compaction.go | 4 ++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 5fd58b80..ccdde545 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1832,6 +1832,7 @@ Responses 的图片、视频与文件 Part 复用上述 data URL、file ID 与 Y | item | 映射 | | --- | --- | | system/developer message | 文本进入系统指令,图片与文件移到首个 user 轮 | +| `agent_message` | user 轮,`content` 中 `input_text` 的 `text` 与 `encrypted_content` 的 `encrypted_content` 依次作为文本 | | `custom_tool_call` | 函数调用,参数为 `{"input":}` | | `local_shell_call` | `local_shell` 函数调用,参数为去掉 `type` 的 `action` | | `shell_call` | `shell` 函数调用,参数为 `action` | diff --git a/internal/api/responses.go b/internal/api/responses.go index ae30710e..9e37ede3 100644 --- a/internal/api/responses.go +++ b/internal/api/responses.go @@ -952,6 +952,14 @@ func responsesContents(raw json.RawMessage) ([]aistudio.Content, []string, error } contents = append(contents, aistudio.Content{Role: role, Parts: parts}) continue + case "agent_message": + pendingSignature = "" + parts, err := agentMessageParts(item.Content) + if err != nil { + return nil, nil, fmt.Errorf("agent_message: %w", err) + } + contents = append(contents, aistudio.Content{Role: aistudio.RoleUser, Parts: parts}) + continue case "function_call": call.Arguments = functionCallArguments(item.Arguments) case "custom_tool_call": @@ -1005,6 +1013,30 @@ func responsesContents(raw json.RawMessage) ([]aistudio.Content, []string, error return prependSystemMedia(contents, systemMedia), instructions, nil } +// agentMessageParts 把 Codex 智能体间消息的内容转为文本片段,encrypted_content 中是本服务模型写出的消息原文 +func agentMessageParts(raw json.RawMessage) ([]aistudio.Part, error) { + var content []struct { + Type string `json:"type"` + Text string `json:"text"` + EncryptedContent string `json:"encrypted_content"` + } + if err := json.Unmarshal(raw, &content); err != nil { + return nil, err + } + parts := make([]aistudio.Part, 0, len(content)) + for _, part := range content { + switch part.Type { + case "input_text": + parts = append(parts, aistudio.Part{Text: part.Text}) + case "encrypted_content": + parts = append(parts, aistudio.Part{Text: part.EncryptedContent}) + default: + return nil, fmt.Errorf("unsupported content type %q", part.Type) + } + } + return parts, nil +} + // responsesInputConfig 返回输入项中 additional_tools 提供的工具与最后一个 configuration_update 的推理强度 func responsesInputConfig(raw json.RawMessage) ([]responsesTool, string) { var items []struct { diff --git a/internal/api/responses_compaction.go b/internal/api/responses_compaction.go index 2427a7a7..e15436d3 100644 --- a/internal/api/responses_compaction.go +++ b/internal/api/responses_compaction.go @@ -246,7 +246,7 @@ func compactionItemTokens(raw json.RawMessage) int { return (len(encoded) + 3) / 4 } -// compactionItemSide 把条目分为 user 消息、工具输出、指令与模型侧条目 +// compactionItemSide 把条目分为 user 轮(user 消息与 agent_message)、工具输出、指令与模型侧条目 func compactionItemSide(raw json.RawMessage) string { var head struct { Type string `json:"type"` @@ -254,7 +254,7 @@ func compactionItemSide(raw json.RawMessage) string { } _ = json.Unmarshal(raw, &head) switch { - case (head.Type == "" || head.Type == "message") && head.Role == "user": + case (head.Type == "" || head.Type == "message") && head.Role == "user", head.Type == "agent_message": return "user" case (head.Type == "" || head.Type == "message") && (head.Role == "system" || head.Role == "developer"): return "instruction" From 430046d6c682953fbc2c0eb790510ab2f9fc0e65 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Tue, 10 Nov 2026 18:09:20 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E5=85=B3=E9=97=AD=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=9A=84=E5=AE=98=E7=BD=91=E6=AC=A2=E8=BF=8E?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E3=80=81=E5=BC=95=E5=AF=BC=E6=B5=AE=E5=B1=82?= =?UTF-8?q?=E4=B8=8E=20Cookie=20=E5=91=8A=E7=9F=A5=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/camoufoxnative/generate.go | 2 +- internal/camoufoxnative/page.go | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/camoufoxnative/generate.go b/internal/camoufoxnative/generate.go index 0c95b422..61ca726c 100644 --- a/internal/camoufoxnative/generate.go +++ b/internal/camoufoxnative/generate.go @@ -339,7 +339,7 @@ func (worker *Worker) waitRunnable(ctx context.Context, tab *chatTab) error { if state.Enabled { return nil } - if len(state.Dialogs) > 0 { + if len(state.Dialogs) > 0 || state.Cover != "" { clicked, err := dismissKnownOverlays(ctx, worker.client, tab.contextID) if err != nil { return err diff --git a/internal/camoufoxnative/page.go b/internal/camoufoxnative/page.go index ffa105f9..cee1d9bf 100644 --- a/internal/camoufoxnative/page.go +++ b/internal/camoufoxnative/page.go @@ -222,12 +222,13 @@ func (client *bidiClient) pointer(ctx context.Context, contextID, selector, patt return true, err } -// dismissOverlaysExpression 关闭官网已知且可交互的启动弹层 +// dismissOverlaysExpression 关闭官网已知且可交互的启动弹层:G1 欢迎对话框、引导浏览浮层与 Cookie 提示条,提示条有拒绝按钮时拒绝,只有确认按钮时确认 const dismissOverlaysExpression = `(() => {` + pageDOMHelpers + ` const selectors = [ - 'ms-g1-welcome-dialog button[aria-label="Close dialog"]', - 'button[aria-label="Close guided tour"]', - '#glue-cookie-notification-bar-1 .glue-cookie-notification-bar__reject' + 'ms-g1-welcome-dialog button', + 'button.xap-tour-exit-button', + '#glue-cookie-notification-bar-1 .glue-cookie-notification-bar__reject', + '#glue-cookie-notification-bar-1:not(:has(.glue-cookie-notification-bar__reject)) .glue-cookie-notification-bar__accept' ]; let clicked = 0; for (const selector of selectors) { From 44272920c872ace9dd1b490ecd5958509bf7a058 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Tue, 10 Nov 2026 09:01:20 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20WAA=20=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E7=AB=9E=E6=80=81=E4=B8=8E=E8=B4=A6=E5=8F=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=BB=AD=E7=AD=BE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留后台 Worker 启动的账户租约,在锁外关闭 Worker,关闭后不覆盖状态 - 修复 SpiderMonkey VM 重复刷新、图片静置、计时器登记与 TextDecoder fatal - 让 SpiderMonkey 受保护请求网络错误同账户重放,不重建 Worker - 减少流解码的内存分配,自动续写时及时关闭上一轮响应 - 修复浏览器登录已存在账户的错误、浏览器登录账户续签与重新登录保留材料 - 远程续签在续签凭据失效或账户已删除时停止并删除记录,重试沿用未送达的会话 - 指纹浏览器扫描使用对话框代理,登录等待时限固定为 30 分钟 - 更新续签与扫描的用户文档,补齐六种语言的日志文案 Co-Authored-By: Claude Fable 5.2 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- README_en.md | 2 +- docs/development.md | 2 +- docs/protocol.md | 6 +- internal/aistudio/accounts.go | 74 ++++++--- internal/aistudio/decoder.go | 26 ++- internal/aistudio/generate.go | 9 +- internal/aistudio/login_native.go | 10 +- internal/aistudio/runtime_go.go | 100 +++++++++--- internal/aistudio/runtime_native.go | 13 +- internal/api/admin.go | 4 +- internal/app/admin.go | 28 ++-- internal/app/auth_retry.go | 13 +- internal/app/auth_retry_test.go | 1 + internal/app/lifecycle.go | 8 +- internal/app/runtime.go | 9 +- internal/app/waa_backend.go | 14 +- internal/app/worker_manager.go | 232 +++++++++++++++------------ internal/i18n/messages/de.json | 8 + internal/i18n/messages/en.json | 8 + internal/i18n/messages/fr.json | 8 + internal/i18n/messages/ja.json | 8 + internal/i18n/messages/ko.json | 8 + internal/i18n/messages/zh-TW.json | 8 + internal/setup/renewal.go | 99 +++++++++--- internal/setup/setup.go | 6 +- internal/waa/host.go | 31 +++- internal/waa/loop.go | 12 +- internal/waa/realm.go | 12 +- internal/waa/runtime.go | 20 ++- web/src/api.ts | 8 +- web/src/components/AccountsPanel.vue | 4 +- 32 files changed, 524 insertions(+), 269 deletions(-) diff --git a/README.md b/README.md index d65ea5bc..e34ee429 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ Windows 桌面版在命令行中运行以下命令时写作 `cmd /c start /wait - 文件导入:`aistudio2api setup --storage-state ` - Cookie 导入:`aistudio2api setup --cookie-file `、`aistudio2api setup --cookie ""` 或 `aistudio2api setup --cdp <调试地址>`,账户邮箱按 Cookie 识别,`--cdp` 导入的账户沿用该浏览器的语言与时区;Cookie 来自同时登录多个 Google 账户的浏览器时,以 `--authuser N` 选择官网地址 `/u/N/` 中第 N 个账户 -`aistudio2api setup --remote https://<远程实例>`:向远程实例添加账户。在远程实例的账户页点击“远程添加”,对话框给出完整命令(Windows 为 `cmd /c start /wait aistudio2api.exe setup --remote <地址> --token <令牌>`,其他系统为 `./aistudio2api setup --remote <地址> --token <令牌>`),账户来源可选“该电脑 Chrome 中的账户”或“在新窗口登录”(命令带 `--login`),一键复制后在能登录 Google 的电脑上运行;省略 `--token` 时运行后从标准输入读取令牌。对话框打开期间沿用仍有效的令牌,令牌到期时自动换新,点击“换一个令牌”立即换新,旧令牌随即失效。从该电脑 Chrome 导入的账户只能由这台电脑续签:命令上传这类账户后留在前台,立即并每 100 分钟向远程实例发送它们的新会话,失败时 5 分钟后重试;保持该窗口打开,重启电脑后运行 `aistudio2api setup --remote <地址> --renew` 恢复续签。 +`aistudio2api setup --remote https://<远程实例>`:向远程实例添加账户。在远程实例的账户页点击“远程添加”,对话框给出完整命令(Windows 为 `cmd /c start /wait aistudio2api.exe setup --remote <地址> --token <令牌>`,其他系统为 `./aistudio2api setup --remote <地址> --token <令牌>`),账户来源可选“该电脑 Chrome 中的账户”或“在新窗口登录”(命令带 `--login`),一键复制后在能登录 Google 的电脑上运行;省略 `--token` 时运行后从标准输入读取令牌。对话框打开期间沿用仍有效的令牌,令牌到期时自动换新,点击“换一个令牌”立即换新,旧令牌随即失效。从该电脑 Chrome 导入的账户只能由这台电脑续签:命令上传这类账户后留在前台,立即并每 100 分钟向远程实例发送它们的新会话,失败时 5 分钟后重试,远程实例已删除账户或续签凭据失效时停止为该账户续签;保持该窗口打开,重启电脑后运行 `aistudio2api setup --remote <地址> --renew` 恢复续签。 ## API 使用 diff --git a/README_en.md b/README_en.md index 96086f19..49a40100 100644 --- a/README_en.md +++ b/README_en.md @@ -316,7 +316,7 @@ On Windows, run these commands for the desktop app as `cmd /c start /wait aistud - 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; 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; the dialog shows the full command (`cmd /c start /wait aistudio2api.exe setup --remote
--token ` on Windows, `./aistudio2api setup --remote
--token ` on other systems) with the account source set to "That computer's Chrome accounts" or "Sign in in a new window" (which adds `--login`). Copy it with one click and run it on a computer that can sign in to Google; without `--token`, the command reads the token from standard input. While the dialog is open it keeps a token that is still valid and replaces it when it expires; "New token" replaces it at once and the old token stops working. Only the computer that imported an account from its Chrome can renew that account: after uploading such accounts the command stays in the foreground, sends their new sessions to the remote instance right away and every 100 minutes, and retries 5 minutes after a failed renewal. Keep that window open, and after restarting the computer run `aistudio2api setup --remote
--renew` to resume renewal. +`aistudio2api setup --remote https://`: Adds accounts to a remote instance. Click "Add remotely" on the remote instance's Accounts page; the dialog shows the full command (`cmd /c start /wait aistudio2api.exe setup --remote
--token ` on Windows, `./aistudio2api setup --remote
--token ` on other systems) with the account source set to "That computer's Chrome accounts" or "Sign in in a new window" (which adds `--login`). Copy it with one click and run it on a computer that can sign in to Google; without `--token`, the command reads the token from standard input. While the dialog is open it keeps a token that is still valid and replaces it when it expires; "New token" replaces it at once and the old token stops working. Only the computer that imported an account from its Chrome can renew that account: after uploading such accounts the command stays in the foreground, sends their new sessions to the remote instance right away and every 100 minutes, retries 5 minutes after a failed renewal, and stops renewing an account once the remote instance has deleted it or its renewal credential is invalid. Keep that window open, and after restarting the computer run `aistudio2api setup --remote
--renew` to resume renewal. ## API Usage diff --git a/docs/development.md b/docs/development.md index 8e3ef048..c18af4ec 100644 --- a/docs/development.md +++ b/docs/development.md @@ -80,7 +80,7 @@ go run ./cmd/aistudio2api `setup` 写入本机时要求 `AISTUDIO_AUTH_STATES` 指向一个账户目录。 -加 `--remote` 时,Chrome 导入、隔离登录、文件导入与 Cookie 导入照常在本机完成,结果凭远程实例账户页“远程添加”生成的配对令牌逐个上传,隔离登录同时上传 Camoufox 指纹。远程账户使用远程实例的默认代理,`--proxy` 只用于本机导入与登录。上传的账户带 Chrome OAuth 材料时(本机 Chrome 导入,或带该材料的认证状态文件),上传完成后命令留在前台:立即并每 100 分钟以本机 Chrome 来源与 OAuth 材料签发新会话,经 `PUT /api/pairing/accounts/{account}/cookies` 凭上传响应的 `renewal_token` 替换远程账户的 Cookie,失败时 5 分钟后重试,每次一行日志;续签材料与凭据保存在本机 `runtime/remote-renewals.json`(权限 0600),`--remote --renew` 读取该 origin 的记录恢复续签。只含隔离登录、文件或 Cookie 导入的账户时上传后退出。 +加 `--remote` 时,Chrome 导入、隔离登录、文件导入与 Cookie 导入照常在本机完成,结果凭远程实例账户页“远程添加”生成的配对令牌逐个上传,隔离登录同时上传 Camoufox 指纹。远程账户使用远程实例的默认代理,`--proxy` 只用于本机导入与登录。上传的账户带 Chrome OAuth 材料时(本机 Chrome 导入,或带该材料的认证状态文件),上传完成后命令留在前台:立即并每 100 分钟以本机 Chrome 来源与 OAuth 材料签发新会话,经 `PUT /api/pairing/accounts/{account}/cookies` 凭上传响应的 `renewal_token` 替换远程账户的 Cookie,失败时 5 分钟后重试,重试时沿用尚未送达、签发不到 100 分钟的会话,远程返回 401 `pairing_renewal_invalid` 或 404 `account_not_found` 时停止该账户并从记录中删除,其他失败照常重试,每次一行日志;续签材料与凭据保存在本机 `runtime/remote-renewals.json`(权限 0600),`--remote --renew` 读取该 origin 的记录恢复续签。只含隔离登录、文件或 Cookie 导入的账户时上传后退出。 - `--proxy` 会同时固定到新账户的初始化、WAA 与业务请求,接受 HTTP、HTTPS 或 SOCKS5 URL,代理认证写在 URL 的 `user:password@` 部分;未指定时导入与登录使用 `PROXY` 中的第一个代理,新账户不设账户代理 - `--locale` 和 `--timezone` 设置账户环境;Chrome 导入未显式指定语言时读取 Profile 的首选语言 diff --git a/docs/protocol.md b/docs/protocol.md index 5fd58b80..5621d002 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -118,7 +118,7 @@ Google 对部分账户强制设备绑定会话:OAuthMultilogin 响应的 `devi OAuthMultilogin 以该 Profile 的 Chrome 身份发出,请求头与 Chrome `GaiaAuthFetcher` 相同:HTTP/2 下依次为 `content-length`、`authorization`、`content-type`、`sec-fetch-site: none`、`sec-fetch-mode: no-cors`、`sec-fetch-dest: empty`、`user-agent`、`accept-encoding: gzip, deflate, br, zstd`、`accept-language` 与 `priority: u=4, i`。`user-agent` 的主版本取 User Data 的 `Last Version`;`accept-language` 取 Profile `Preferences` 的 `intl.accept_languages`,没有该项时取 `intl.selected_languages`,按 Chromium 的规则在带地区的语言后补主语言并依次加 `q=0.9` 到 `q=0.1`。导入与续签都读取账户导入来源的 User Data 目录中当前的 Chrome 版本与该 Profile 的语言设置,来源没有记录目录时读取稳定版 Chrome 的默认目录。 -Chrome 导入状态在 `storage-state.json` 的 `aistudio2api` 扩展中保存来源(浏览器、User Data 目录、Profile 与邮箱)、Gaia ID、refresh token、wrapped binding key 与 DBSC 会话登记。登录页跳转、签名 Cookie 失效、HTTP `401` 与协议 Code 16 进入同一认证恢复流程,覆盖 Worker 预热、按需启动、普通 RPC、受保护 RPC 与 Live 建连。服务优先在同一账户出口续签 Cookie;保存的 OAuth 材料被拒绝时,从当前 Chrome 匹配原账户邮箱与 Gaia ID,更新来源材料。没有 `aistudio2api` 扩展的账户在本机 Chrome 登录着同一邮箱时,从 Chrome 导入该邮箱的材料并写入扩展,此后按 Chrome 导入账户续签。有效 Cookie 提交后使动态头失效、关闭该账户 WAA runtime,并重放一次。恢复失败后,服务以账户的指纹、出口与保存的 Cookie 打开 AI Studio 新对话页:被转到 Google 登录页时账户标为 `auth_required`,管理事件同步发布账户状态,后续调度使用其他合格账户;页面正常打开时账户状态不变,只有该次请求失败或换号。 +Chrome 导入状态在 `storage-state.json` 的 `aistudio2api` 扩展中保存来源(浏览器、User Data 目录、Profile 与邮箱)、Gaia ID、refresh token、wrapped binding key 与 DBSC 会话登记。登录页跳转、签名 Cookie 失效、HTTP `401` 与协议 Code 16 进入同一认证恢复流程,覆盖 Worker 预热、按需启动、普通 RPC、受保护 RPC 与 Live 建连。服务优先在同一账户出口续签 Cookie;保存的 OAuth 材料被拒绝时,从当前 Chrome 匹配原账户邮箱与 Gaia ID,更新来源材料。没有 OAuth 材料的账户(Cookie 导入、外部 storage state 与隔离 Camoufox 登录的账户)在本机 Chrome 登录着同一邮箱时,从 Chrome 导入该邮箱的材料并写入扩展,此后按 Chrome 导入账户续签;在账户页重新登录时沿用账户已保存的 OAuth 材料。有效 Cookie 提交后使动态头失效、关闭该账户 WAA runtime,并重放一次。恢复失败后,服务以账户的指纹、出口与保存的 Cookie 打开 AI Studio 新对话页:被转到 Google 登录页时账户标为 `auth_required`,管理事件同步发布账户状态,后续调度使用其他合格账户;页面正常打开时账户状态不变,只有该次请求失败或换号。 认证恢复等待同账户的正常请求结束;并发失效复用一次提交结果。替换登录材料推进认证代际,旧请求的认证结果按代际和请求顺序写回;生成正常结束后确认认证有效。HTTP `403`、协议 Code 7 与 Drive `unauthorized_client` 保留模型权限或 Drive 授权含义。隔离 Camoufox 登录和外部 storage state 在本机 Chrome 没有同一邮箱时只使用各自的登录材料。 @@ -1306,8 +1306,8 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete - `POST /api/pairing/accounts` 的令牌无效或过期时返回 401 `pairing_token_invalid`,账户已存在时返回 409 `account_exists` - `PUT /api/pairing/accounts/{account}/cookies` 的续签凭据与该账户不符时返回 401 `pairing_renewal_invalid`,账户不存在时返回 404 `account_not_found`,Cookie 无法生成 SAPISID 授权时返回 400 `invalid_account` -- 一份 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 -- 添加、导入与重新登录账户在账户任务中运行:请求解析 body 后返回 202 与任务,任务不随请求或管理页面关闭而结束;任务中的账户依次处理,单个账户失败时在该项记下错误码与原因后继续下一个,Cookie 无法解析或缺少登录 Cookie 为 `invalid_cookies`,调试地址连接或读取失败为 `cdp_unavailable`,账户已存在为 `account_exists`;有账户失败时任务以 `failed` 结束,否则为 `done`。结束的任务保留到 `DELETE /api/accounts/tasks/{id}` 关闭,对进行中的任务调用它会取消任务,任务不存在时返回 404 `task_not_found`;任务列表变化时 `GET /api/events` 推送 `account_tasks` 事件,连接建立时先推送当前列表 +- 一份 Cookie 可包含同一浏览器中多个 Google 账户的会话,第 N 个会话对应官网 `/u/N/` 与请求头 `X-Goog-AuthUser: N`。`POST /api/accounts/import/cookies/discover` 从 u/0 起依次打开各会话的对话页,列出每个会话登录的账户;`GET /api/accounts/import/browsers` 为每个浏览器的每个会话各列一项,查询参数 `proxy` 为识别账户的请求出口,省略时用默认出口。`POST /api/accounts/import/cookies` 中每项的 `auth_user` 选择导入的会话,省略时为 0;账户的 `auth_user` 写入 `account.json`,运行时以 `/u/N/` 页面与 `X-Goog-AuthUser: N` 访问官网,浏览器重新登录后为 0 +- 添加、导入与重新登录账户在账户任务中运行:请求解析 body 后返回 202 与任务,任务不随请求或管理页面关闭而结束;任务中的账户依次处理,单个账户失败时在该项记下错误码与原因后继续下一个,Cookie 无法解析或缺少登录 Cookie 为 `invalid_cookies`,调试地址连接或读取失败为 `cdp_unavailable`;导入或浏览器登录得到账户列表中已有的账户时就地替换它的认证状态,保留文件资源绑定,账户目录已存在而账户不在列表中时为 `account_exists`;有账户失败时任务以 `failed` 结束,否则为 `done`。结束的任务保留到 `DELETE /api/accounts/tasks/{id}` 关闭,对进行中的任务调用它会取消任务,任务不存在时返回 404 `task_not_found`;任务列表变化时 `GET /api/events` 推送 `account_tasks` 事件,连接建立时先推送当前列表 - `GET /api/pairing` 返回仍在有效期内的配对令牌,没有时返回 404 `pairing_token_missing`;`POST /api/pairing` 签发新令牌,此前的令牌随之失效 - `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}` 传递,任务的 `view` 为该 ID;登录窗口断开后以同一 ID 重新连接,继续显示当前画面 diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index 35b3bdf0..b2dbe3c5 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -72,6 +72,8 @@ var ( errNoAccounts = fmt.Errorf("%w:在管理页面账户页添加账户", ErrNoEligibleAccount) // ErrAccountNotFound 表示稳定账户 ID 不存在 ErrAccountNotFound = errors.New("账户不存在") + // ErrAccountExists 表示同一邮箱的账户目录已存在 + ErrAccountExists = errors.New("账户已存在") // ErrAccountLeased 表示账户当前存在进程内或跨进程租约 ErrAccountLeased = errors.New("账户正在使用") // ErrAccountCoolingDown 表示租约账户在请求发出前进入冷却 @@ -436,8 +438,11 @@ type AccountLease struct { ignoreQuotaCooldown bool operation sync.Mutex released bool - once sync.Once - err error + // holds 是 Retain 尚未结束的次数,大于 0 时 Release 推迟到最后一次结束 + holds int + // releaseDeferred 表示 Release 已被推迟 + releaseDeferred bool + err error // sessionOnce 使同一租约内的认证失败只检查一次会话 sessionOnce sync.Once // sessionErr 是本租约会话检查的结果 @@ -576,6 +581,10 @@ func (s *AccountStore) Create(accountConfig AccountConfig, state StorageState) ( return nil, nil, err } accountConfig.Label = id + directory := filepath.Join(root, id) + if _, err := os.Stat(directory); err == nil { + return nil, nil, fmt.Errorf("%w: %s", ErrAccountExists, id) + } temporary, err := os.MkdirTemp(root, ".account-*.tmp") if err != nil { return nil, nil, fmt.Errorf("创建临时账户目录: %w", err) @@ -587,7 +596,6 @@ func (s *AccountStore) Create(accountConfig AccountConfig, state StorageState) ( if err := WriteStorageState(filepath.Join(temporary, StorageStateName), state); err != nil { return nil, nil, err } - directory := filepath.Join(root, id) account, err := loadAccount(temporary) if err != nil { return nil, nil, err @@ -1885,26 +1893,52 @@ func (l *AccountLease) MergeSetCookieHeaders(headers []string, requestURL string // Release 释放账户文件和进程内租约 func (l *AccountLease) Release() error { - l.once.Do(func() { + l.operation.Lock() + defer l.operation.Unlock() + if l.holds > 0 { + l.releaseDeferred = true + return nil + } + l.releaseLocked() + return l.err +} + +// Retain 让租约在返回的函数调用前保持占用,期间的 Release 推迟到该函数调用时生效 +func (l *AccountLease) Retain() func() error { + l.operation.Lock() + l.holds++ + l.operation.Unlock() + return func() error { l.operation.Lock() defer l.operation.Unlock() - l.released = true - l.pool.mu.Lock() - if l.exclusive { - l.account.exclusive = false - } else if l.account.active > 0 { - l.account.active-- - } - if l.account.active == 0 && !l.account.exclusive && l.account.authRefreshers == 0 && l.account.leaseLock != nil { - if err := l.account.leaseLock.Unlock(); err != nil { - l.err = err - } - l.account.leaseLock = nil + l.holds-- + if l.holds == 0 && l.releaseDeferred { + l.releaseLocked() } - l.pool.notifyLocked() - l.pool.mu.Unlock() - }) - return l.err + return l.err + } +} + +// releaseLocked 归还租约占用的账户名额,调用方持有 operation,已归还时不重复归还 +func (l *AccountLease) releaseLocked() { + if l.released { + return + } + l.released = true + l.pool.mu.Lock() + if l.exclusive { + l.account.exclusive = false + } else if l.account.active > 0 { + l.account.active-- + } + if l.account.active == 0 && !l.account.exclusive && l.account.authRefreshers == 0 && l.account.leaseLock != nil { + if err := l.account.leaseLock.Unlock(); err != nil { + l.err = err + } + l.account.leaseLock = nil + } + l.pool.notifyLocked() + l.pool.mu.Unlock() } // SetCatalog 替换账户的权益等级与实时模型目录 diff --git a/internal/aistudio/decoder.go b/internal/aistudio/decoder.go index d1e50ba1..5df9690e 100644 --- a/internal/aistudio/decoder.go +++ b/internal/aistudio/decoder.go @@ -109,6 +109,7 @@ func (reader *streamReadRecorder) failure(method string, frames int, decodeErr e type sparseJSONReader struct { source *bufio.Reader pending []byte + hole [5]byte inString bool escaped bool previousToken byte @@ -137,12 +138,18 @@ func (r *sparseJSONReader) Read(destination []byte) (int, error) { } return 0, err } - r.pending = r.normalize(value) + if r.emptySlot(value) { + r.pending = append(append(r.hole[:0], "null"...), value) + continue + } + destination[written] = value + written++ } return written, nil } -func (r *sparseJSONReader) normalize(value byte) []byte { +// emptySlot 按 value 更新扫描状态,返回 value 之前是否为数组空位 +func (r *sparseJSONReader) emptySlot(value byte) bool { if r.inString { if r.escaped { r.escaped = false @@ -151,25 +158,16 @@ func (r *sparseJSONReader) normalize(value byte) []byte { } else if value == '"' { r.inString = false } - return []byte{value} + return false } if value == '"' { r.inString = true - r.previousToken = value - return []byte{value} - } - if value == ',' && (r.previousToken == '[' || r.previousToken == ',') { - r.previousToken = value - return []byte("null,") - } - if value == ']' && r.previousToken == ',' { - r.previousToken = value - return []byte("null]") } + empty := value == ',' && (r.previousToken == '[' || r.previousToken == ',') || value == ']' && r.previousToken == ',' if !unicode.IsSpace(rune(value)) { r.previousToken = value } - return []byte{value} + return empty } func decodeJSONValue(raw []byte) (json.RawMessage, error) { diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index 2bd5f007..294d851f 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -1184,6 +1184,8 @@ func (c *Client) decodePlaygroundRounds(ctx context.Context, request GenerateReq total, _ = config[3].(int64) } var previous *Usage + closeRound := func() {} + defer func() { closeRound() }() for { decoder := NewFrameDecoder() decoder.continuable = defaults.ContinuedOutputTokens > 0 @@ -1229,6 +1231,7 @@ func (c *Client) decodePlaygroundRounds(ctx context.Context, request GenerateReq if err != nil { return err } + closeRound() response, err := c.doProtected(ctx, request, body) if err != nil { return err @@ -1236,8 +1239,10 @@ func (c *Client) decodePlaygroundRounds(ctx context.Context, request GenerateReq stopClose := context.AfterFunc(ctx, func() { _ = response.Body.Close() }) - defer stopClose() - defer response.Body.Close() + closeRound = func() { + stopClose() + _ = response.Body.Close() + } source = observeStreamActivity(ctx, response.Body) } } diff --git a/internal/aistudio/login_native.go b/internal/aistudio/login_native.go index 75aa487a..845ff6e3 100644 --- a/internal/aistudio/login_native.go +++ b/internal/aistudio/login_native.go @@ -10,21 +10,23 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/camoufoxnative" ) +// loginTimeout 是隔离登录从启动浏览器到导出认证状态的期限 +const loginTimeout = 30 * time.Minute + // NativeLoginDriver 通过纯 Go WebDriver BiDi 完成隔离登录 type NativeLoginDriver struct { camoufox string - timeout time.Duration } var _ IsolatedLoginDriver = (*NativeLoginDriver)(nil) // NewNativeLoginDriver 创建纯 Go Camoufox 登录驱动 -func NewNativeLoginDriver(camoufoxPath string, timeout time.Duration) (*NativeLoginDriver, error) { +func NewNativeLoginDriver(camoufoxPath string) (*NativeLoginDriver, error) { absolute, err := filepath.Abs(camoufoxPath) if err != nil { return nil, fmt.Errorf("解析 Camoufox 路径: %w", err) } - return &NativeLoginDriver{camoufox: absolute, timeout: timeout}, nil + return &NativeLoginDriver{camoufox: absolute}, nil } // Login 启动隔离 Camoufox 供用户登录并导出认证状态 @@ -35,7 +37,7 @@ func (driver *NativeLoginDriver) Login(ctx context.Context, request IsolatedLogi Locale: request.Locale, Timezone: request.Timezone, Proxy: request.Proxy, - Timeout: driver.timeout, + Timeout: loginTimeout, View: request.View, }) if err != nil { diff --git a/internal/aistudio/runtime_go.go b/internal/aistudio/runtime_go.go index 52073f65..b428a499 100644 --- a/internal/aistudio/runtime_go.go +++ b/internal/aistudio/runtime_go.go @@ -75,6 +75,7 @@ type goWAARuntime struct { vmMu sync.Mutex vm *waa.Runtime vmExpires time.Time + refreshing *vmRefresh interpreterHash string interpreter string closed bool @@ -82,6 +83,16 @@ type goWAARuntime struct { incidentsTimer *time.Timer } +// vmRefreshTimeout 是后台重建 VM 的期限 +const vmRefreshTimeout = 2 * time.Minute + +// vmRefresh 是一次后台 VM 重建,done 关闭后 err 为重建结果 +type vmRefresh struct { + done chan struct{} + cancel context.CancelFunc + err error +} + // pageRPC 是官网页面自身发出的 MakerSuite 请求方法与正文 type pageRPC struct { method string @@ -365,10 +376,8 @@ func (runtime *goWAARuntime) scheduleRefresh() { runtime.refreshTimer = time.AfterFunc(waaLifetime, func() { runtime.vmMu.Lock() defer runtime.vmMu.Unlock() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - if _, err := runtime.currentVM(ctx, true); err != nil { - slog.Warn("WAA SpiderMonkey VM 刷新失败", "error", err) + if !runtime.closed && !time.Now().Before(runtime.vmExpires) { + runtime.refreshVM() } }) } @@ -876,21 +885,49 @@ func (runtime *goWAARuntime) loadImage(address string) bool { return response.StatusCode == http.StatusOK && strings.HasPrefix(response.Header.Get("Content-Type"), "image/") && len(body) > 0 } -// currentVM 返回未过期的 VM,到期时按官网刷新流程重建 -func (runtime *goWAARuntime) currentVM(ctx context.Context, force bool) (*waa.Runtime, error) { - if runtime.closed { - return nil, errors.New("SpiderMonkey WAA runtime 已关闭") - } - if !force && runtime.vm != nil && time.Now().Before(runtime.vmExpires) { - return runtime.vm, nil - } +// refreshVM 在后台按官网刷新流程重建 VM 并返回这次重建,已有重建进行中时返回它;调用方持有 vmMu +func (runtime *goWAARuntime) refreshVM() *vmRefresh { + if runtime.refreshing != nil { + return runtime.refreshing + } + ctx, cancel := context.WithTimeout(context.Background(), vmRefreshTimeout) + refresh := &vmRefresh{done: make(chan struct{}), cancel: cancel} + runtime.refreshing = refresh + previous := runtime.vm + go func() { + defer cancel() + vm, err := runtime.nextVM(ctx, previous) + runtime.vmMu.Lock() + defer runtime.vmMu.Unlock() + switch { + case err != nil: + slog.Warn("WAA SpiderMonkey VM 刷新失败", "error", err) + case runtime.closed: + vm.Close() + err = errors.New("SpiderMonkey WAA runtime 已关闭") + default: + if previous != nil { + previous.Close() + } + runtime.vm = vm + runtime.scheduleRefresh() + } + refresh.err = err + runtime.refreshing = nil + close(refresh.done) + }() + return refresh +} + +// nextVM 以旧 VM 的无绑定 snapshot 调用 Waa/Create 并返回静置后的新 VM,没有旧 VM 时按首次载入创建 +func (runtime *goWAARuntime) nextVM(ctx context.Context, previous *waa.Runtime) (*waa.Runtime, error) { var refresh []string - if runtime.vm != nil { - previous, err := runtime.vm.RefreshSnapshot(ctx) + if previous != nil { + snapshot, err := previous.RefreshSnapshot(ctx) if err != nil { - previous = "E:UCE" + snapshot = "E:UCE" } - refresh = []string{runtime.interpreterHash, previous} + refresh = []string{runtime.interpreterHash, snapshot} } challenge, err := runtime.createChallenge(ctx, refresh) if err != nil { @@ -904,11 +941,6 @@ func (runtime *goWAARuntime) currentVM(ctx context.Context, force bool) (*waa.Ru vm.Close() return nil, err } - if runtime.vm != nil { - runtime.vm.Close() - } - runtime.vm = vm - runtime.scheduleRefresh() return vm, nil } @@ -922,14 +954,29 @@ func (runtime *goWAARuntime) RunProof(ctx context.Context, digest string, prompt return runtime.proof(ctx, digest, prompt, true) } -// proof 以当前 VM 生成 fresh WAA proof,run 时写入提示词后点击 Run +// proof 以当前 VM 生成 fresh WAA proof,run 时写入提示词后点击 Run;VM 到期时等待后台重建完成,等待随 ctx 结束 func (runtime *goWAARuntime) proof(ctx context.Context, digest string, prompt string, run bool) (string, error) { runtime.vmMu.Lock() defer runtime.vmMu.Unlock() - vm, err := runtime.currentVM(ctx, false) - if err != nil { - return "", err + for !runtime.closed && (runtime.vm == nil || !time.Now().Before(runtime.vmExpires)) { + refresh := runtime.refreshVM() + runtime.vmMu.Unlock() + select { + case <-refresh.done: + case <-ctx.Done(): + } + runtime.vmMu.Lock() + if err := ctx.Err(); err != nil { + return "", err + } + if refresh.err != nil { + return "", refresh.err + } + } + if runtime.closed { + return "", errors.New("SpiderMonkey WAA runtime 已关闭") } + vm := runtime.vm snapshot := vm.Proof if run { snapshot = vm.RunProof @@ -1014,6 +1061,9 @@ func (runtime *goWAARuntime) Close() error { if runtime.refreshTimer != nil { runtime.refreshTimer.Stop() } + if runtime.refreshing != nil { + runtime.refreshing.cancel() + } if runtime.incidentsTimer != nil { runtime.incidentsTimer.Stop() } diff --git a/internal/aistudio/runtime_native.go b/internal/aistudio/runtime_native.go index 1493388b..9240bc4e 100644 --- a/internal/aistudio/runtime_native.go +++ b/internal/aistudio/runtime_native.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "sync" "time" @@ -27,6 +28,9 @@ type NativeWorker struct { state WorkerState } +// ErrProtectedTransport 表示 SpiderMonkey 后端受保护请求在出口连接上失败,VM 与页面状态不受影响 +var ErrProtectedTransport = errors.New("受保护请求网络错误") + var _ ProtectedPreparer = (*NativeWorker)(nil) var _ ProtocolHeaderProvider = (*NativeWorker)(nil) @@ -227,6 +231,10 @@ func (worker *NativeWorker) SendProtected(ctx context.Context, request Protected } response, err := worker.current().SendProtected(ctx, request.URL, headers, request.Body) if err != nil { + var transport *url.Error + if errors.As(err, &transport) { + return nil, fmt.Errorf("%w: %w", ErrProtectedTransport, err) + } if ctx.Err() == nil { worker.setPhase(WorkerFailed) } @@ -353,9 +361,12 @@ func (worker *NativeWorker) Close() error { return err } -// setPhase 更新账户 WAA runtime 阶段 +// setPhase 更新账户 WAA runtime 阶段,进入关闭后只接受 WorkerClosed func (worker *NativeWorker) setPhase(phase WorkerPhase) { worker.stateMu.Lock() defer worker.stateMu.Unlock() + if current := worker.state.Phase; (current == WorkerClosing || current == WorkerClosed) && phase != WorkerClosed { + return + } worker.state.Phase = phase } diff --git a/internal/api/admin.go b/internal/api/admin.go index b46e8d58..fe8b55ae 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -17,7 +17,7 @@ type AdminService interface { Accounts(context.Context) ([]AdminAccount, error) CreateAccount(context.Context, AccountCreateInput, *camoufoxnative.LiveView) (AdminAccount, error) ChromeImportProfiles(context.Context) ([]ChromeImportProfile, error) - CookieBrowsers(context.Context) ([]CookieBrowser, error) + CookieBrowsers(ctx context.Context, proxy string) ([]CookieBrowser, error) DiscoverCookieAccounts(context.Context, CookieImportInput) ([]CookieBrowser, error) ImportChromeAccounts(context.Context, ChromeImportInput) ([]AdminAccount, error) ImportAccountState(context.Context, AccountStateInput) (AdminAccount, error) @@ -409,7 +409,7 @@ func (s *server) handleChromeImportProfiles(w http.ResponseWriter, r *http.Reque } func (s *server) handleCookieBrowsers(w http.ResponseWriter, r *http.Request) { - browsers, err := s.config.Admin.CookieBrowsers(r.Context()) + browsers, err := s.config.Admin.CookieBrowsers(r.Context(), r.URL.Query().Get("proxy")) if err != nil { writeAdminUpstreamError(w, err) return diff --git a/internal/app/admin.go b/internal/app/admin.go index daa910f6..acbbe33c 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -177,7 +177,7 @@ func (admin *runtimeAdmin) CreateAccount(ctx context.Context, input api.AccountC if err := accountConfig.Validate(); err != nil { return api.AdminAccount{}, invalidAccount(err) } - created, err := admin.addAccount(ctx, accountConfig, result.StorageState, directory) + created, err := admin.upsertAccount(ctx, accountConfig, result.StorageState, directory) if err != nil { return api.AdminAccount{}, err } @@ -215,8 +215,8 @@ func (admin *runtimeAdmin) ChromeImportProfiles(context.Context) ([]api.ChromeIm return profiles, nil } -// CookieBrowsers 列出本机开启远程调试的浏览器,读取其 Cookie 并识别其中各会话登录 AI Studio 的账户,每个账户一项 -func (admin *runtimeAdmin) CookieBrowsers(ctx context.Context) ([]api.CookieBrowser, error) { +// CookieBrowsers 列出本机开启远程调试的浏览器,读取其 Cookie 并经 proxy 识别其中各会话登录 AI Studio 的账户,每个账户一项,proxy 为空时用默认出口 +func (admin *runtimeAdmin) CookieBrowsers(ctx context.Context, proxy string) ([]api.CookieBrowser, error) { addresses, err := chromeauth.DebugAddresses(ctx) if err != nil { return nil, err @@ -234,7 +234,7 @@ func (admin *runtimeAdmin) CookieBrowsers(ctx context.Context) ([]api.CookieBrow return } browser.Browser = name - accounts, err := admin.readCookieBrowser(ctx, &browser) + accounts, err := admin.readCookieBrowser(ctx, &browser, proxy) browser.SignedOut = errors.Is(err, chromeauth.ErrSignedOut) || errors.Is(err, aistudio.ErrLoginCookieMissing) if err != nil && !browser.SignedOut { browser.Error = err.Error() @@ -246,8 +246,8 @@ func (admin *runtimeAdmin) CookieBrowsers(ctx context.Context) ([]api.CookieBrow return append([]api.CookieBrowser{}, slices.Concat(browsers...)...), nil } -// readCookieBrowser 读取调试浏览器的语言、时区与 Cookie,返回其中各会话登录 AI Studio 的账户 -func (admin *runtimeAdmin) readCookieBrowser(ctx context.Context, browser *api.CookieBrowser) ([]chromeauth.CookieAccount, error) { +// readCookieBrowser 读取调试浏览器的语言、时区与 Cookie,经 proxy 识别其中各会话登录 AI Studio 的账户 +func (admin *runtimeAdmin) readCookieBrowser(ctx context.Context, browser *api.CookieBrowser, proxy string) ([]chromeauth.CookieAccount, error) { source, err := chromeauth.ReadCDPBrowser(ctx, browser.Address) if err != nil { return nil, err @@ -258,7 +258,7 @@ func (admin *runtimeAdmin) readCookieBrowser(ctx context.Context, browser *api.C return nil, err } browser.DeviceBound = deviceBoundSession(state) - return chromeauth.DiscoverCookieAccounts(ctx, &state, admin.effectiveProxy("")) + return chromeauth.DiscoverCookieAccounts(ctx, &state, admin.effectiveProxy(proxy)) } // DiscoverCookieAccounts 解析粘贴的 Cookie,返回其中各会话登录 AI Studio 的账户 @@ -267,9 +267,6 @@ func (admin *runtimeAdmin) DiscoverCookieAccounts(ctx context.Context, input api 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()} @@ -509,6 +506,9 @@ func (admin *runtimeAdmin) addAccount( fingerprintDirectory string, ) (created api.AdminAccount, resultErr error) { account, publishLease, err := admin.store.Create(accountConfig, state) + if errors.Is(err, aistudio.ErrAccountExists) { + return api.AdminAccount{}, &adminOperationError{status: http.StatusConflict, code: "account_exists", message: err.Error()} + } if err != nil { return api.AdminAccount{}, err } @@ -825,13 +825,13 @@ func setCookieAuthUser(lease *aistudio.AccountLease, authUser int, prepare func( return nil } -// keepAuthExtension 在新登录状态缺少续签材料时沿用账户已有的 Chrome 来源与 OAuth 材料,DBSC 会话登记属于旧会话不沿用 +// keepAuthExtension 在新登录状态没有 OAuth 续签材料时沿用账户已有的 Chrome 来源与 OAuth 材料,DBSC 会话登记属于旧会话不沿用 func keepAuthExtension(next *aistudio.StorageState, current aistudio.StorageState) error { - if _, exists, err := next.AuthExtension(); err != nil || exists { + if extension, _, err := next.AuthExtension(); err != nil || extension.OAuth != nil { return err } - extension, exists, err := current.AuthExtension() - if err != nil || !exists { + extension, _, err := current.AuthExtension() + if err != nil || extension.OAuth == nil { return err } extension.Bound = nil diff --git a/internal/app/auth_retry.go b/internal/app/auth_retry.go index 03427580..1dd8efde 100644 --- a/internal/app/auth_retry.go +++ b/internal/app/auth_retry.go @@ -384,11 +384,11 @@ func (refresher *authRuntimeRefresher) Refresh(ctx context.Context, generation u err := lease.RefreshStorageState(generation, func(state *aistudio.StorageState) error { refreshed = true logRenewal("INFO", "账户认证续签 | 1/2 | 刷新 Cookie") - extension, exists, err := state.AuthExtension() + extension, _, err := state.AuthExtension() if err != nil { return err } - if !exists { + if extension.OAuth == nil { extension = aistudio.AuthExtension{Source: aistudio.AuthSource{Browser: "chrome", Email: account.ID}} } proxy := account.EffectiveProxy(refresher.globalProxy) @@ -490,7 +490,7 @@ func (refresher *authRuntimeRefresher) ensureBound(ctx context.Context) error { return refresher.mergeWorker(ctx, account.ID, headers, extension.Bound.RefreshURL) } -// Available 返回当前租约账户是否保存了 Chrome OAuth 续签材料 +// Available 返回当前租约账户能否续签:保存了 Chrome OAuth 续签材料,或本机 Chrome 登录着同一邮箱 func (refresher *authRuntimeRefresher) Available(ctx context.Context) bool { lease, ok := aistudio.AccountLeaseFromContext(ctx) if !ok { @@ -500,14 +500,11 @@ func (refresher *authRuntimeRefresher) Available(ctx context.Context) bool { if err != nil { return false } - extension, exists, err := state.AuthExtension() + extension, _, err := state.AuthExtension() if err != nil { return false } - if exists { - return extension.OAuth != nil || extension.Source.Browser == "chrome" - } - return refresher.chromeHas != nil && refresher.chromeHas(lease.Account().ID) + return extension.OAuth != nil || refresher.chromeHas != nil && refresher.chromeHas(lease.Account().ID) } func authenticationFailed(response *aistudio.RPCResponse) bool { diff --git a/internal/app/auth_retry_test.go b/internal/app/auth_retry_test.go index 35fb4425..610ada1e 100644 --- a/internal/app/auth_retry_test.go +++ b/internal/app/auth_retry_test.go @@ -65,6 +65,7 @@ func TestAuthRefreshWithoutExtension(t *testing.T) { } login := aistudio.StorageState{Cookies: cookies} + login.SetAuthExtension(aistudio.AuthExtension{Source: aistudio.AuthSource{Browser: "camoufox", Email: account.ID}}) if err := keepAuthExtension(&login, stored); err != nil { t.Fatal(err) } diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index 927cac07..cde51995 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -30,7 +30,7 @@ type generationAdmin interface { Accounts(context.Context) ([]api.AdminAccount, error) CreateAccount(context.Context, api.AccountCreateInput, *camoufoxnative.LiveView) (api.AdminAccount, error) ChromeImportProfiles(context.Context) ([]api.ChromeImportProfile, error) - CookieBrowsers(context.Context) ([]api.CookieBrowser, error) + CookieBrowsers(ctx context.Context, proxy string) ([]api.CookieBrowser, error) DiscoverCookieAccounts(context.Context, api.CookieImportInput) ([]api.CookieBrowser, error) ImportChromeAccounts(context.Context, api.ChromeImportInput) ([]api.AdminAccount, error) ImportAccountState(context.Context, api.AccountStateInput) (api.AdminAccount, error) @@ -463,11 +463,11 @@ func (manager *runtimeManager) ChromeImportProfiles(ctx context.Context) ([]api. return manager.current.admin.ChromeImportProfiles(ctx) } -// CookieBrowsers 返回当前生成服务发现的本机调试浏览器 -func (manager *runtimeManager) CookieBrowsers(ctx context.Context) ([]api.CookieBrowser, error) { +// CookieBrowsers 返回当前生成服务发现的本机调试浏览器,proxy 为识别账户的请求出口 +func (manager *runtimeManager) CookieBrowsers(ctx context.Context, proxy string) ([]api.CookieBrowser, error) { manager.mu.RLock() defer manager.mu.RUnlock() - return manager.current.admin.CookieBrowsers(ctx) + return manager.current.admin.CookieBrowsers(ctx, proxy) } // DiscoverCookieAccounts 返回粘贴 Cookie 中各会话序号登录的账户并标记已导入的账户 diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 4df37099..0ac9479c 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -1973,6 +1973,12 @@ func (service *trackedService) generateWithRetry( if attempt+1 == retry.maxAttempts { break } + if last.recoverWorker && errors.Is(err, aistudio.ErrProtectedTransport) { + service.requests.logRequestProgress(request.ID, last.accountLabel, "WARN", fmt.Sprintf( + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求\n原因: %s", modelID, strings.TrimSpace(err.Error()), + )) + continue + } if last.recoverWorker { service.requests.logRequestProgress(request.ID, last.accountLabel, "WARN", fmt.Sprintf( "WAA Worker 重建 | 模型=%s | 重放当前请求\n原因: %s", modelID, strings.TrimSpace(err.Error()), @@ -2211,6 +2217,7 @@ func (service *trackedService) settleAttempt( workerFailed := service.workers.WorkerFailed(accountID) waaRuntimeFailed := aistudio.DefinitiveWAARuntimeFailure(err) workerReplaced := errors.Is(err, errAccountWorkerReplaced) + protectedTransport := errors.Is(err, aistudio.ErrProtectedTransport) && requestCtx.Err() == nil localWorkerFailure := (workerFailed || workerReplaced) && requestCtx.Err() == nil retryable := retryableGenerateAccountError(requestCtx, err) || localWorkerFailure if aistudio.DefinitiveAuthenticationFailure(err) { @@ -2283,7 +2290,7 @@ func (service *trackedService) settleAttempt( var resetErr error recoverWorker, _, resetErr = service.recoverWorkerOnce( requestCtx, accountID, attempt.workerGeneration, retry.recoveredWorker, - needsWAARuntimeRecovery(err, workerFailed, workerReplaced), + needsWAARuntimeRecovery(err, workerFailed, workerReplaced) || protectedTransport, workerFailed || waaRuntimeFailed, ) if resetErr != nil { diff --git a/internal/app/waa_backend.go b/internal/app/waa_backend.go index 430d1973..99fd4a41 100644 --- a/internal/app/waa_backend.go +++ b/internal/app/waa_backend.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" "sync" - "time" "github.com/Mag1cFall/AIStudio2API/internal/aistudio" "github.com/Mag1cFall/AIStudio2API/internal/camoufoxnative" @@ -17,14 +16,14 @@ func prepareWAABackend(ctx context.Context, cfg config.Config, requests *request removeStaleCamoufoxProfiles(requests) if cfg.WAABackend == config.WAABackendSpiderMonkey { requests.log("service", "INFO", fmt.Sprintf("运行时装配 | 2/3 | WAA 后端=spidermonkey | 账户=%d", accounts)) - return "", &lazyLoginDriver{timeout: cfg.RequestTimeout, proxy: proxy}, nil + return "", &lazyLoginDriver{proxy: proxy}, nil } requests.log("service", "INFO", fmt.Sprintf("运行时装配 | 2/3 | 校验 Camoufox | 账户=%d", accounts)) camoufoxPath, err := camoufoxnative.FindExecutable(ctx, proxy) if err != nil { return "", nil, err } - login, err := aistudio.NewNativeLoginDriver(camoufoxPath, cfg.RequestTimeout) + login, err := aistudio.NewNativeLoginDriver(camoufoxPath) if err != nil { return "", nil, err } @@ -52,10 +51,9 @@ func newWAAWorker(ctx context.Context, accountID string, options camoufoxnative. // lazyLoginDriver 在第一次登录或校验账户时定位 Camoufox 并创建登录驱动 type lazyLoginDriver struct { - timeout time.Duration - proxy string - mu sync.Mutex - driver *aistudio.NativeLoginDriver + proxy string + mu sync.Mutex + driver *aistudio.NativeLoginDriver } func (lazy *lazyLoginDriver) resolve(ctx context.Context) (*aistudio.NativeLoginDriver, error) { @@ -68,7 +66,7 @@ func (lazy *lazyLoginDriver) resolve(ctx context.Context) (*aistudio.NativeLogin if err != nil { return nil, err } - driver, err := aistudio.NewNativeLoginDriver(camoufoxPath, lazy.timeout) + driver, err := aistudio.NewNativeLoginDriver(camoufoxPath) if err != nil { return nil, err } diff --git a/internal/app/worker_manager.go b/internal/app/worker_manager.go index 501978dc..d83e8ff4 100644 --- a/internal/app/worker_manager.go +++ b/internal/app/worker_manager.go @@ -66,8 +66,11 @@ type accountWorker struct { busyUntil time.Time busyDelay time.Duration busyErr error + // closing 表示持有启动锁的调用方正在释放状态锁关闭 Worker + closing bool } +// accountWorkerPreparer 是账户 Worker 的受保护请求 preparer,pending 时 Worker 尚未发布,preparer 持有账户启动锁,由 activateAccountWorker、discardAccountWorker 或 closePendingWorker 释放 type accountWorkerPreparer struct { account *accountWorker worker *aistudio.NativeWorker @@ -339,13 +342,14 @@ func (manager *accountWorkerManager) reapIdleWorkers() { // reapIdleWorker 关闭一个多余的空闲 Worker 并返回是否关闭:超出峰值上限时立即关闭,否则只关闭最近使用早于 before 的 func (manager *accountWorkerManager) reapIdleWorker(before time.Time) bool { manager.rebalanceMu.Lock() - defer manager.rebalanceMu.Unlock() warm := len(manager.WarmAccountIDs()) if len(manager.openings) > 0 || warm <= manager.warmLimit() { + manager.rebalanceMu.Unlock() return false } victim := manager.idleWarmVictim("") if victim == "" { + manager.rebalanceMu.Unlock() return false } manager.mu.RLock() @@ -354,9 +358,13 @@ func (manager *accountWorkerManager) reapIdleWorker(before time.Time) bool { _, lastUsed := manager.pool.Activity(victim) if warm <= manager.activeLimit() && (lastUsed.After(before) || account != nil && time.Unix(0, account.readyAt.Load()).After(before)) { + manager.rebalanceMu.Unlock() return false } + manager.reserveVictim(victim) + manager.rebalanceMu.Unlock() evicted, err := manager.evictIdleWorker(manager.lifecycle, victim) + manager.releaseVictim(victim) if err != nil { manager.requests.log(victim, "WARN", fmt.Sprintf("WAA Worker 空闲回收失败 | 错误=%v", err)) return false @@ -1010,6 +1018,9 @@ func (manager *accountWorkerManager) readyWorker(accountID string, bootstrapMode if !active { return nil, false, fmt.Errorf("账户不存在: %s", accountID) } + if account.closing { + return nil, false, nil + } if accountWorkerCleanupPending(account) { return nil, false, fmt.Errorf("%w: %s", errAccountWorkerCleanupPending, accountID) } @@ -1173,7 +1184,9 @@ func activateAccountWorker(preparer *accountWorkerPreparer) error { } oldWorker := preparer.account.worker if oldWorker != nil { - if closeErr := oldWorker.Close(); closeErr != nil { + preparer.account.worker = nil + if closeErr := preparer.account.closeUnlocked(oldWorker); closeErr != nil { + preparer.account.worker = oldWorker label := preparer.account.label preparer.account.mu.Unlock() cleanupErr := discardAccountWorker(preparer) @@ -1244,6 +1257,16 @@ func closePendingWorker(preparer *accountWorkerPreparer) error { return cleanupErr } +// closeUnlocked 在持有账户启动锁与状态锁时关闭 worker,关闭期间释放状态锁,其他账户的调度与本账户的状态读取不等这次关闭 +func (account *accountWorker) closeUnlocked(worker *aistudio.NativeWorker) error { + account.closing = true + account.mu.Unlock() + err := worker.Close() + account.mu.Lock() + account.closing = false + return err +} + func accountWorkerCleanupPending(account *accountWorker) bool { if account.cleanupWorker != nil || account.cleanupLease != nil || account.worker == nil && account.runtimeLease != nil { return true @@ -1421,126 +1444,116 @@ func (manager *accountWorkerManager) ensureWorker( manager.rebalanceMu.Unlock() return nil, errAccountWorkerCapacity } - if replacing || occupancy.slots < manager.activeLimit() { - opening := make(chan struct{}) - manager.openings[accountID] = opening - reservation := manager.startup.reserve(accountID) - manager.rebalanceMu.Unlock() - return manager.detachStartup(context.WithValue(ctx, startupReservationKey{}, reservation), func(ctx context.Context) (*accountWorkerPreparer, error) { - defer reservation.release() - preparer, err := manager.startAuthenticatedWorker(ctx, accountID, bootstrapModel) - manager.rebalanceMu.Lock() - if err == nil { - if ctxErr := ctx.Err(); ctxErr != nil { - err = errors.Join(ctxErr, discardAccountWorker(preparer)) - } else { - err = activateAccountWorker(preparer) - } - } - manager.finishOpening(accountID, opening) - warmCount := len(manager.WarmAccountIDs()) + victim := "" + if !replacing && occupancy.slots >= manager.activeLimit() { + victim = manager.idleWarmVictimFor(accountID, modelID, len(manager.openings) > 0) + if victim == "" { manager.rebalanceMu.Unlock() - if err == nil && warmCount > manager.warmLimit() { - manager.requests.log("service", "INFO", fmt.Sprintf( - "WAA Worker 按需扩容 | Worker=%d/%d", warmCount, manager.activeLimit(), - )) + if !waitForOpening { + return nil, errAccountWorkerCapacity } - return preparer, err - }) - } - victim := "" - if len(manager.openings) == 0 { - victim = manager.idleWarmVictimFor(accountID, modelID, false) - } else { - victim = manager.idleWarmVictimFor(accountID, modelID, true) - } - if victim == "" { - manager.rebalanceMu.Unlock() - if !waitForOpening { - return nil, errAccountWorkerCapacity - } - if err := waitScheduling(ctx, nil, 100*time.Millisecond); err != nil { - return nil, err + if err := waitScheduling(ctx, nil, 100*time.Millisecond); err != nil { + return nil, err + } + continue } - continue + manager.reserveVictim(victim) } - manager.reserveVictim(victim) opening := make(chan struct{}) manager.openings[accountID] = opening reservation := manager.startup.reserve(accountID) manager.rebalanceMu.Unlock() return manager.detachStartup(context.WithValue(ctx, startupReservationKey{}, reservation), func(ctx context.Context) (*accountWorkerPreparer, error) { - defer func() { manager.releaseVictim(victim) }() - defer reservation.release() - pending, startErr := manager.startAuthenticatedWorker(ctx, accountID, bootstrapModel) - if startErr != nil { - manager.rebalanceMu.Lock() - manager.finishOpening(accountID, opening) - manager.rebalanceMu.Unlock() - return nil, startErr + return manager.openWorker(ctx, accountID, modelID, bootstrapModel, opening, reservation, victim) + }) + } +} + +// openWorker 在已登记的启动占位上启动账户 Worker 并发布到热池;victim 不为空时峰值已满,先回收一个空闲 Worker 再发布,victim 在回收前变忙时改选其他空闲 Worker;返回前结束占位 +func (manager *accountWorkerManager) openWorker( + ctx context.Context, + accountID string, + modelID string, + bootstrapModel string, + opening chan struct{}, + reservation *startupReservation, + victim string, +) (*accountWorkerPreparer, error) { + defer reservation.release() + replacing := victim != "" + preparer, err := manager.startAuthenticatedWorker(ctx, accountID, bootstrapModel) + if err == nil && replacing { + victim, err = manager.evictForOpening(ctx, accountID, modelID, victim) + } + manager.releaseVictim(victim) + if err == nil { + err = ctx.Err() + if err != nil { + err = errors.Join(err, discardAccountWorker(preparer)) + } else { + err = activateAccountWorker(preparer) + } + } else if preparer != nil { + err = errors.Join(err, discardAccountWorker(preparer)) + } + manager.rebalanceMu.Lock() + manager.finishOpening(accountID, opening) + warmCount := len(manager.WarmAccountIDs()) + manager.rebalanceMu.Unlock() + if err != nil { + return nil, err + } + switch { + case warmCount <= manager.warmLimit(): + case replacing: + manager.requests.log("service", "INFO", fmt.Sprintf("WAA Worker 按需替换 | Worker=%d/%d", warmCount, manager.activeLimit())) + default: + manager.requests.log("service", "INFO", fmt.Sprintf("WAA Worker 按需扩容 | Worker=%d/%d", warmCount, manager.activeLimit())) + } + return preparer, nil +} + +// evictForOpening 回收一个空闲 Worker 为启动中的账户腾出名额,victim 为已预留的候选,回收前候选变忙时改选其他空闲 Worker,返回最后预留的候选 +func (manager *accountWorkerManager) evictForOpening(ctx context.Context, accountID string, modelID string, victim string) (string, error) { + for { + if err := ctx.Err(); err != nil { + return victim, err + } + if victim == "" { + manager.rebalanceMu.Lock() + victim = manager.idleWarmVictimFor(accountID, modelID, false) + if victim != "" { + manager.reserveVictim(victim) } - for { - manager.rebalanceMu.Lock() - if ctxErr := ctx.Err(); ctxErr != nil { - discardErr := discardAccountWorker(pending) - manager.finishOpening(accountID, opening) - manager.rebalanceMu.Unlock() - return nil, errors.Join(ctxErr, discardErr) - } - if victim == "" { - victim = manager.idleWarmVictimFor(accountID, modelID, false) - if victim != "" { - manager.reserveVictim(victim) - } - } - if victim == "" { - manager.rebalanceMu.Unlock() - if err := waitScheduling(ctx, nil, 100*time.Millisecond); err != nil { - manager.rebalanceMu.Lock() - discardErr := discardAccountWorker(pending) - manager.finishOpening(accountID, opening) - manager.rebalanceMu.Unlock() - return nil, errors.Join(err, discardErr) - } - continue - } - evicted, evictionErr := manager.evictIdleWorker(ctx, victim) - if evictionErr == nil && evicted { - activationErr := activateAccountWorker(pending) - manager.finishOpening(accountID, opening) - warmCount := len(manager.WarmAccountIDs()) - manager.rebalanceMu.Unlock() - if activationErr != nil { - return nil, activationErr - } - if warmCount > manager.warmLimit() { - manager.requests.log("service", "INFO", fmt.Sprintf( - "WAA Worker 按需替换 | Worker=%d/%d", warmCount, manager.activeLimit(), - )) - } - return pending, nil - } - if evictionErr != nil || ctx.Err() != nil { - discardErr := discardAccountWorker(pending) - manager.finishOpening(accountID, opening) - manager.rebalanceMu.Unlock() - return nil, errors.Join(evictionErr, ctx.Err(), discardErr) - } - manager.releaseVictim(victim) - victim = "" - manager.rebalanceMu.Unlock() + manager.rebalanceMu.Unlock() + } + if victim == "" { + if err := waitScheduling(ctx, nil, 100*time.Millisecond); err != nil { + return "", err } - }) + continue + } + evicted, err := manager.evictIdleWorker(ctx, victim) + if err != nil || evicted { + return victim, err + } + manager.releaseVictim(victim) + victim = "" } } -// detachStartup 运行已占用启动名额的 Worker 启动,调用方离开后启动继续完成并发布到热池,ResetAll 或关闭时取消 +// detachStartup 运行已占用启动名额的 Worker 启动,调用方离开后启动继续完成并发布到热池,启动结束前保持 ctx 中的账户租约,ResetAll 或关闭时取消 func (manager *accountWorkerManager) detachStartup( ctx context.Context, start func(context.Context) (*accountWorkerPreparer, error), ) (aistudio.ProtectedPreparer, error) { startupCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) stop := context.AfterFunc(manager.backgroundContext(), cancel) + release := func() error { return nil } + lease, leased := aistudio.AccountLeaseFromContext(ctx) + if leased { + release = lease.Retain() + } type startResult struct { preparer *accountWorkerPreparer err error @@ -1550,6 +1563,9 @@ func (manager *accountWorkerManager) detachStartup( defer func() { stop() cancel() + if err := release(); err != nil { + manager.requests.log(lease.Account().Config.Label, "WARN", "账户租约释放失败 | 错误="+err.Error()) + } }() preparer, err := start(startupCtx) done <- startResult{preparer: preparer, err: err} @@ -1907,18 +1923,20 @@ func (manager *accountWorkerManager) Close() error { return errors.Join(closeErrors...) } +// closeAccountWorker 在持有账户启动锁与状态锁时关闭账户的 Worker 并释放 runtime 租约,关闭期间释放状态锁 func (manager *accountWorkerManager) closeAccountWorker(account *accountWorker) error { var closeErrors []error - if account.worker != nil { - if closeErr := account.worker.Close(); closeErr != nil { + if worker := account.worker; worker != nil { + account.worker = nil + if closeErr := account.closeUnlocked(worker); closeErr != nil { + account.worker = worker closeErrors = append(closeErrors, closeErr) } else { - account.worker = nil account.generation.Add(1) } } - if account.cleanupWorker != nil { - if closeErr := account.cleanupWorker.Close(); closeErr != nil { + if worker := account.cleanupWorker; worker != nil { + if closeErr := account.closeUnlocked(worker); closeErr != nil { closeErrors = append(closeErrors, closeErr) } else { account.cleanupWorker = nil diff --git a/internal/i18n/messages/de.json b/internal/i18n/messages/de.json index eab25808..a1bb66bb 100644 --- a/internal/i18n/messages/de.json +++ b/internal/i18n/messages/de.json @@ -20,6 +20,8 @@ "%s 账户 %s 已续签,下次续签 %s\n": "{0} Konto {1} erneuert, nächste Erneuerung {2}\n", "%s 账户 %s 续签失败,5 分钟后重试: %v": "{0} Erneuerung von Konto {1} fehlgeschlagen, erneuter Versuch in 5 Minuten: {2}", "%s 账户 %s 续签失败,5 分钟后重试: %v\n": "{0} Erneuerung von Konto {1} fehlgeschlagen, erneuter Versuch in 5 Minuten: {2}\n", + "%s 账户 %s 停止续签: %v": "{0} Erneuerung von Konto {1} beendet: {2}", + "%s 账户 %s 停止续签: %v\n": "{0} Erneuerung von Konto {1} beendet: {2}\n", "%s 需要重新登录": "{0} erfordert erneute Anmeldung", "%s:%d 缺少等号": "{0}:{1} Gleichheitszeichen fehlt", "%s超时": "{0} Zeitüberschreitung", @@ -668,6 +670,10 @@ "取消备用请求": "Backup-Anfrage abbrechen", "受保护请求缺少 WAA field %d: %w": "Geschützter Anfrage fehlt das WAA-Feld {0}: {1}", "受保护请求缺少账户租约": "Geschützter Anfrage fehlt das Konto-Lease", + "受保护请求网络错误": "Netzwerkfehler bei geschützter Anfrage", + "同账户重放当前请求": "Aktuelle Anfrage mit demselben Konto wiederholen", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求": "Netzwerkfehler bei geschützter Anfrage | Modell={0} | Aktuelle Anfrage mit demselben Konto wiederholen", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求\n原因: %s": "Netzwerkfehler bei geschützter Anfrage | Modell={0} | Aktuelle Anfrage mit demselben Konto wiederholen\nGrund: {1}", "只有本机地址可以使用 HTTP": "Nur lokale Adressen können HTTP verwenden", "可导入": "Importierbar", "合并 MakerSuite %s 响应 Cookie: %w": "MakerSuite-{0}-Antwort-Cookies zusammenführen: {1}", @@ -1260,6 +1266,7 @@ "账户已删除": "Konto gelöscht", "账户已删除 | 账户=%v": "Konto gelöscht | Konto={0}", "账户已存在: %s": "Das Konto existiert bereits: {0}", + "账户已存在": "Das Konto existiert bereits", "账户已存在: %v": "Das Konto existiert bereits: {0}", "账户已进入冷却": "Konto im Cooldown", "账户必须填写 Google 邮箱": "Für das Konto muss eine Google-E-Mail angegeben werden", @@ -1335,6 +1342,7 @@ "远程实例导入失败: HTTP %d: %s": "Import von Remote-Instanz fehlgeschlagen: HTTP {0}: {1}", "远程实例续签失败: HTTP %d": "Erneuerung auf Remote-Instanz fehlgeschlagen: HTTP {0}", "远程实例续签失败: HTTP %d: %s": "Erneuerung auf Remote-Instanz fehlgeschlagen: HTTP {0}: {1}", + "远程账户已删除或续签凭据失效": "Das Remote-Konto wurde gelöscht oder die Erneuerungsberechtigung ist ungültig", "远程实例账户页生成的配对令牌,省略时从标准输入读取": "Auf der Kontoseite der Remote-Instanz generiertes Kopplungstoken; wenn weggelassen, wird von stdin gelesen", "连接 Camoufox BiDi: %w": "Verbindung zu Camoufox BiDi: {0}", "连接 WebDriver BiDi": "Verbindung zu WebDriver BiDi", diff --git a/internal/i18n/messages/en.json b/internal/i18n/messages/en.json index 6e0a4a9b..976ae4ce 100644 --- a/internal/i18n/messages/en.json +++ b/internal/i18n/messages/en.json @@ -20,6 +20,8 @@ "%s 账户 %s 已续签,下次续签 %s\n": "{0} account {1} renewed, next renewal {2}\n", "%s 账户 %s 续签失败,5 分钟后重试: %v": "{0} account {1} renewal failed, retrying in 5 minutes: {2}", "%s 账户 %s 续签失败,5 分钟后重试: %v\n": "{0} account {1} renewal failed, retrying in 5 minutes: {2}\n", + "%s 账户 %s 停止续签: %v": "{0} account {1} renewal stopped: {2}", + "%s 账户 %s 停止续签: %v\n": "{0} account {1} renewal stopped: {2}\n", "%s 需要重新登录": "{0} needs to sign in again", "%s:%d 缺少等号": "{0}:{1} is missing an equals sign", "%s超时": "{0} timed out", @@ -668,6 +670,10 @@ "取消备用请求": "Hedge request canceled", "受保护请求缺少 WAA field %d: %w": "Protected request missing WAA field {0}: {1}", "受保护请求缺少账户租约": "Protected request missing account lease", + "受保护请求网络错误": "Protected request network error", + "同账户重放当前请求": "Replay current request on the same account", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求": "Protected request network error | Model={0} | Replay current request on the same account", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求\n原因: %s": "Protected request network error | Model={0} | Replay current request on the same account\nReason: {1}", "只有本机地址可以使用 HTTP": "HTTP is only allowed on loopback addresses", "可导入": "Ready to import", "合并 MakerSuite %s 响应 Cookie: %w": "Merge MakerSuite {0} response Cookies: {1}", @@ -1260,6 +1266,7 @@ "账户已删除": "Account deleted", "账户已删除 | 账户=%v": "Account deleted | Account={0}", "账户已存在: %s": "The account already exists: {0}", + "账户已存在": "The account already exists", "账户已存在: %v": "The account already exists: {0}", "账户已进入冷却": "Account entered cooldown", "账户必须填写 Google 邮箱": "Account must have a Google email", @@ -1335,6 +1342,7 @@ "远程实例导入失败: HTTP %d: %s": "Remote instance import failed: HTTP {0}: {1}", "远程实例续签失败: HTTP %d": "Remote instance renewal failed: HTTP {0}", "远程实例续签失败: HTTP %d: %s": "Remote instance renewal failed: HTTP {0}: {1}", + "远程账户已删除或续签凭据失效": "The remote account was deleted or the renewal credential is invalid", "远程实例账户页生成的配对令牌,省略时从标准输入读取": "Pairing token generated on remote instance account page, read from standard input if omitted", "连接 Camoufox BiDi: %w": "Connecting to Camoufox BiDi: {0}", "连接 WebDriver BiDi": "Connecting to WebDriver BiDi", diff --git a/internal/i18n/messages/fr.json b/internal/i18n/messages/fr.json index 1dbafaa1..b55df330 100644 --- a/internal/i18n/messages/fr.json +++ b/internal/i18n/messages/fr.json @@ -20,6 +20,8 @@ "%s 账户 %s 已续签,下次续签 %s\n": "{0} | Compte {1} renouvelé, prochain renouvellement : {2}\n", "%s 账户 %s 续签失败,5 分钟后重试: %v": "{0} | Échec du renouvellement du compte {1}, nouvel essai dans 5 minutes : {2}", "%s 账户 %s 续签失败,5 分钟后重试: %v\n": "{0} | Échec du renouvellement du compte {1}, nouvel essai dans 5 minutes : {2}\n", + "%s 账户 %s 停止续签: %v": "{0} | Renouvellement du compte {1} arrêté : {2}", + "%s 账户 %s 停止续签: %v\n": "{0} | Renouvellement du compte {1} arrêté : {2}\n", "%s 需要重新登录": "{0} doit se reconnecter", "%s:%d 缺少等号": "{0}:{1} manque d’un signe égal", "%s超时": "{0} a expiré", @@ -668,6 +670,10 @@ "取消备用请求": "Annulation de la requête de secours", "受保护请求缺少 WAA field %d: %w": "Champ WAA {0} manquant dans la requête protégée : {1}", "受保护请求缺少账户租约": "Bail de compte manquant dans la requête protégée", + "受保护请求网络错误": "Erreur réseau de la requête protégée", + "同账户重放当前请求": "Rejouer la requête actuelle sur le même compte", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求": "Erreur réseau de la requête protégée | modèle={0} | Rejouer la requête actuelle sur le même compte", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求\n原因: %s": "Erreur réseau de la requête protégée | modèle={0} | Rejouer la requête actuelle sur le même compte\nRaison : {1}", "只有本机地址可以使用 HTTP": "Seules les adresses locales peuvent utiliser HTTP", "可导入": "Importable", "合并 MakerSuite %s 响应 Cookie: %w": "Fusion des Cookies de réponse MakerSuite {0} : {1}", @@ -1260,6 +1266,7 @@ "账户已删除": "Compte supprimé", "账户已删除 | 账户=%v": "Compte supprimé | Compte={0}", "账户已存在: %s": "Ce compte existe déjà: {0}", + "账户已存在": "Ce compte existe déjà", "账户已存在: %v": "Ce compte existe déjà: {0}", "账户已进入冷却": "Compte entré en période de refroidissement", "账户必须填写 Google 邮箱": "Le compte doit spécifier une adresse e-mail Google", @@ -1335,6 +1342,7 @@ "远程实例导入失败: HTTP %d: %s": "Échec de l’importation de l’instance distante : HTTP {0} : {1}", "远程实例续签失败: HTTP %d": "Échec du renouvellement sur l'instance distante : HTTP {0}", "远程实例续签失败: HTTP %d: %s": "Échec du renouvellement sur l'instance distante : HTTP {0} : {1}", + "远程账户已删除或续签凭据失效": "Le compte distant a été supprimé ou les identifiants de renouvellement ne sont plus valides", "远程实例账户页生成的配对令牌,省略时从标准输入读取": "Jeton d’association généré sur la page des comptes de l’instance distante, lu depuis stdin si omis", "连接 Camoufox BiDi: %w": "Connexion à Camoufox BiDi : {0}", "连接 WebDriver BiDi": "Connexion à WebDriver BiDi", diff --git a/internal/i18n/messages/ja.json b/internal/i18n/messages/ja.json index cb0656a7..8b2be86d 100644 --- a/internal/i18n/messages/ja.json +++ b/internal/i18n/messages/ja.json @@ -20,6 +20,8 @@ "%s 账户 %s 已续签,下次续签 %s\n": "{0} アカウント {1} を更新しました。次回の更新は {2}\n", "%s 账户 %s 续签失败,5 分钟后重试: %v": "{0} アカウント {1} の更新に失敗しました。5 分後に再試行します: {2}", "%s 账户 %s 续签失败,5 分钟后重试: %v\n": "{0} アカウント {1} の更新に失敗しました。5 分後に再試行します: {2}\n", + "%s 账户 %s 停止续签: %v": "{0} アカウント {1} の更新を停止しました: {2}", + "%s 账户 %s 停止续签: %v\n": "{0} アカウント {1} の更新を停止しました: {2}\n", "%s 需要重新登录": "{0} は再ログインが必要です", "%s:%d 缺少等号": "{0}:{1} に等号がありません", "%s超时": "{0} タイムアウト", @@ -668,6 +670,10 @@ "取消备用请求": "バックアップ リクエストのキャンセル", "受保护请求缺少 WAA field %d: %w": "保護されたリクエストに WAA field {0} がありません: {1}", "受保护请求缺少账户租约": "保護されたリクエストにアカウント リースがありません", + "受保护请求网络错误": "保護されたリクエストのネットワーク エラー", + "同账户重放当前请求": "同じアカウントで現在のリクエストを再試行", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求": "保護されたリクエストのネットワーク エラー | モデル={0} | 同じアカウントで現在のリクエストを再試行", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求\n原因: %s": "保護されたリクエストのネットワーク エラー | モデル={0} | 同じアカウントで現在のリクエストを再試行\n原因: {1}", "只有本机地址可以使用 HTTP": "ローカル アドレスのみ HTTP を使用できます", "可导入": "インポート可能", "合并 MakerSuite %s 响应 Cookie: %w": "MakerSuite {0} レスポンス Cookie のマージ: {1}", @@ -1260,6 +1266,7 @@ "账户已删除": "アカウントを削除しました", "账户已删除 | 账户=%v": "アカウントを削除しました | アカウント={0}", "账户已存在: %s": "アカウントはすでに存在します: {0}", + "账户已存在": "アカウントはすでに存在します", "账户已存在: %v": "アカウントはすでに存在します: {0}", "账户已进入冷却": "アカウントがクールダウンに入りました", "账户必须填写 Google 邮箱": "アカウントには Google のメールアドレスを指定する必要があります", @@ -1335,6 +1342,7 @@ "远程实例导入失败: HTTP %d: %s": "リモート インスタンスのインポートに失敗しました: HTTP {0}: {1}", "远程实例续签失败: HTTP %d": "リモート インスタンスの更新失敗: HTTP {0}", "远程实例续签失败: HTTP %d: %s": "リモート インスタンスの更新失敗: HTTP {0}: {1}", + "远程账户已删除或续签凭据失效": "リモートのアカウントが削除されたか、更新用の認証情報が無効です", "远程实例账户页生成的配对令牌,省略时从标准输入读取": "リモート インスタンスのアカウント画面で生成されたペアリング トークン。省略時は標準入力から読み取ります", "连接 Camoufox BiDi: %w": "Camoufox BiDi への接続: {0}", "连接 WebDriver BiDi": "WebDriver BiDi への接続", diff --git a/internal/i18n/messages/ko.json b/internal/i18n/messages/ko.json index 7f214cfd..b63f15ab 100644 --- a/internal/i18n/messages/ko.json +++ b/internal/i18n/messages/ko.json @@ -20,6 +20,8 @@ "%s 账户 %s 已续签,下次续签 %s\n": "{0} 계정 {1} 갱신됨, 다음 갱신 {2}\n", "%s 账户 %s 续签失败,5 分钟后重试: %v": "{0} 계정 {1} 갱신 실패, 5분 후 다시 시도: {2}", "%s 账户 %s 续签失败,5 分钟后重试: %v\n": "{0} 계정 {1} 갱신 실패, 5분 후 다시 시도: {2}\n", + "%s 账户 %s 停止续签: %v": "{0} 계정 {1} 갱신 중지: {2}", + "%s 账户 %s 停止续签: %v\n": "{0} 계정 {1} 갱신 중지: {2}\n", "%s 需要重新登录": "{0} 다시 로그인 필요", "%s:%d 缺少等号": "{0}:{1}에 등호가 없습니다", "%s超时": "{0} 시간 초과", @@ -668,6 +670,10 @@ "取消备用请求": "예비 요청 취소", "受保护请求缺少 WAA field %d: %w": "보호된 요청에 WAA field {0} 누락: {1}", "受保护请求缺少账户租约": "보호된 요청에 계정 임대 누락", + "受保护请求网络错误": "보호된 요청 네트워크 오류", + "同账户重放当前请求": "같은 계정으로 현재 요청 재생", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求": "보호된 요청 네트워크 오류 | 모델={0} | 같은 계정으로 현재 요청 재생", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求\n原因: %s": "보호된 요청 네트워크 오류 | 모델={0} | 같은 계정으로 현재 요청 재생\n원인: {1}", "只有本机地址可以使用 HTTP": "로컬 주소에서만 HTTP를 사용할 수 있습니다", "可导入": "가져오기 가능", "合并 MakerSuite %s 响应 Cookie: %w": "MakerSuite {0} 응답 쿠키 병합: {1}", @@ -1260,6 +1266,7 @@ "账户已删除": "계정이 삭제되었습니다", "账户已删除 | 账户=%v": "계정이 삭제되었습니다 | 계정={0}", "账户已存在: %s": "이미 있는 계정입니다: {0}", + "账户已存在": "이미 있는 계정입니다", "账户已存在: %v": "이미 있는 계정입니다: {0}", "账户已进入冷却": "계정 쿨다운 진입", "账户必须填写 Google 邮箱": "계정에 Google 이메일을 입력해야 합니다", @@ -1335,6 +1342,7 @@ "远程实例导入失败: HTTP %d: %s": "원격 인스턴스 가져오기 실패: HTTP {0}: {1}", "远程实例续签失败: HTTP %d": "원격 인스턴스 갱신 실패: HTTP {0}", "远程实例续签失败: HTTP %d: %s": "원격 인스턴스 갱신 실패: HTTP {0}: {1}", + "远程账户已删除或续签凭据失效": "원격 계정이 삭제되었거나 갱신 자격 증명이 유효하지 않습니다", "远程实例账户页生成的配对令牌,省略时从标准输入读取": "원격 인스턴스 계정 페이지에서 생성된 페어링 토큰, 생략 시 표준 입력에서 읽음", "连接 Camoufox BiDi: %w": "Camoufox BiDi 연결: {0}", "连接 WebDriver BiDi": "WebDriver BiDi 연결", diff --git a/internal/i18n/messages/zh-TW.json b/internal/i18n/messages/zh-TW.json index 71f518fe..639bae18 100644 --- a/internal/i18n/messages/zh-TW.json +++ b/internal/i18n/messages/zh-TW.json @@ -20,6 +20,8 @@ "%s 账户 %s 已续签,下次续签 %s\n": "{0} 帳號 {1} 已續約,下次續約 {2}\n", "%s 账户 %s 续签失败,5 分钟后重试: %v": "{0} 帳號 {1} 續簽失敗,5 分鐘後重試: {2}", "%s 账户 %s 续签失败,5 分钟后重试: %v\n": "{0} 帳號 {1} 續約失敗,5 分鐘後重試: {2}\n", + "%s 账户 %s 停止续签: %v": "{0} 帳號 {1} 停止續簽: {2}", + "%s 账户 %s 停止续签: %v\n": "{0} 帳號 {1} 停止續簽: {2}\n", "%s 需要重新登录": "{0} 需要重新登入", "%s:%d 缺少等号": "{0}:{1} 缺少等號", "%s超时": "{0}逾時", @@ -668,6 +670,10 @@ "取消备用请求": "取消備用請求", "受保护请求缺少 WAA field %d: %w": "受保護請求缺少 WAA field {0}: {1}", "受保护请求缺少账户租约": "受保護請求缺少帳號租約", + "受保护请求网络错误": "受保護請求網路錯誤", + "同账户重放当前请求": "同帳號重放目前請求", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求": "受保護請求網路錯誤 | 模型={0} | 同帳號重放目前請求", + "受保护请求网络错误 | 模型=%s | 同账户重放当前请求\n原因: %s": "受保護請求網路錯誤 | 模型={0} | 同帳號重放目前請求\n原因: {1}", "只有本机地址可以使用 HTTP": "只有本機位址可以使用 HTTP", "可导入": "可匯入", "合并 MakerSuite %s 响应 Cookie: %w": "合併 MakerSuite {0} 回應 Cookie: {1}", @@ -1260,6 +1266,7 @@ "账户已删除": "帳號已刪除", "账户已删除 | 账户=%v": "帳號已刪除 | 帳號={0}", "账户已存在: %s": "帳號已存在: {0}", + "账户已存在": "帳號已存在", "账户已存在: %v": "帳號已存在: {0}", "账户已进入冷却": "帳號已進入冷卻", "账户必须填写 Google 邮箱": "帳號必須填寫 Google 電子郵件", @@ -1335,6 +1342,7 @@ "远程实例导入失败: HTTP %d: %s": "遠端執行個體匯入失敗: HTTP {0}: {1}", "远程实例续签失败: HTTP %d": "遠端執行個體續簽失敗: HTTP {0}", "远程实例续签失败: HTTP %d: %s": "遠端執行個體續簽失敗: HTTP {0}: {1}", + "远程账户已删除或续签凭据失效": "遠端帳號已刪除或續簽憑證失效", "远程实例账户页生成的配对令牌,省略时从标准输入读取": "遠端執行個體帳號頁產生的配對權杖,省略時從標準輸入讀取", "连接 Camoufox BiDi: %w": "連線 Camoufox BiDi: {0}", "连接 WebDriver BiDi": "連線 WebDriver BiDi", diff --git a/internal/setup/renewal.go b/internal/setup/renewal.go index baae76c9..5da3efe9 100644 --- a/internal/setup/renewal.go +++ b/internal/setup/renewal.go @@ -25,6 +25,9 @@ const renewalInterval = 100 * time.Minute // renewalRetry 是推送失败后的重试间隔 const renewalRetry = 5 * time.Minute +// errRenewalRevoked 表示远程实例已删除该账户或续签凭据失效,之后的续签不会成功 +var errRenewalRevoked = errors.New("远程账户已删除或续签凭据失效") + // renewalsPath 是本机保存远程配对账户续签材料的文件 var renewalsPath = filepath.Join("runtime", "remote-renewals.json") @@ -66,7 +69,23 @@ func saveRenewals(added []remoteRenewal) error { kept = append(kept, renewal) } } - data, err := json.MarshalIndent(append(kept, added...), "", " ") + return writeRenewals(append(kept, added...)) +} + +// removeRenewal 从续签记录中删除 removed 的实例、账户与续签凭据都相同的记录 +func removeRenewal(removed remoteRenewal) error { + renewals, err := readRenewals() + if err != nil { + return err + } + return writeRenewals(slices.DeleteFunc(renewals, func(item remoteRenewal) bool { + return item.Origin == removed.Origin && item.Account == removed.Account && item.Token == removed.Token + })) +} + +// writeRenewals 写回全部续签记录 +func writeRenewals(renewals []remoteRenewal) error { + data, err := json.MarshalIndent(renewals, "", " ") if err != nil { return err } @@ -97,45 +116,71 @@ func originRenewals(origin string) ([]remoteRenewal, error) { return selected, nil } -// renewRemote 在前台为配对账户立即签发并推送新会话,之后每 100 分钟一次,失败 5 分钟后重试,直到 ctx 结束 +// renewalState 是一个配对账户的续签材料、下次续签时刻与本机签发后尚未推送成功的会话 +type renewalState struct { + renewal remoteRenewal + due time.Time + cookies []aistudio.StateCookie + mintedAt time.Time +} + +// renewRemote 在前台为配对账户立即签发并推送新会话,之后在会话签发 100 分钟后推送下一个,失败 5 分钟后重试,远程实例已删除账户或续签凭据失效时停止该账户并删除其记录,直到 ctx 结束或没有账户 func renewRemote(ctx context.Context, client *http.Client, renewals []remoteRenewal, output io.Writer) error { fmt.Fprintln(output, "保持此窗口打开,本机按时为这些账户续签") - due := make([]time.Time, len(renewals)) - for { + states := make([]renewalState, len(renewals)) + for index, renewal := range renewals { + states[index].renewal = renewal + } + for len(states) > 0 { next := time.Time{} - for index, renewal := range renewals { + kept := states[:0] + for _, state := range states { now := time.Now() - if !now.Before(due[index]) { - if err := pushRenewal(ctx, client, renewal); err != nil { - if ctx.Err() != nil { - return nil + if !now.Before(state.due) { + err := pushRenewal(ctx, client, &state) + switch { + case ctx.Err() != nil: + return nil + case errors.Is(err, errRenewalRevoked): + fmt.Fprintf(output, "%s 账户 %s 停止续签: %v\n", now.Format(time.DateTime), state.renewal.Account, err) + if err := removeRenewal(state.renewal); err != nil { + return err } - due[index] = now.Add(renewalRetry) - fmt.Fprintf(output, "%s 账户 %s 续签失败,5 分钟后重试: %v\n", now.Format(time.DateTime), renewal.Account, err) - } else { - due[index] = now.Add(renewalInterval) - fmt.Fprintf(output, "%s 账户 %s 已续签,下次续签 %s\n", now.Format(time.DateTime), renewal.Account, due[index].Format(time.TimeOnly)) + continue + case err != nil: + state.due = now.Add(renewalRetry) + fmt.Fprintf(output, "%s 账户 %s 续签失败,5 分钟后重试: %v\n", now.Format(time.DateTime), state.renewal.Account, err) + default: + state.due = state.mintedAt.Add(renewalInterval) + fmt.Fprintf(output, "%s 账户 %s 已续签,下次续签 %s\n", now.Format(time.DateTime), state.renewal.Account, state.due.Format(time.TimeOnly)) } } - if next.IsZero() || due[index].Before(next) { - next = due[index] + kept = append(kept, state) + if next.IsZero() || state.due.Before(next) { + next = state.due } } + states = kept select { case <-ctx.Done(): return nil case <-time.After(time.Until(next)): } } + return nil } -// pushRenewal 以本机 Chrome 来源与 OAuth 材料签发新会话,凭续签凭据写入远程实例 -func pushRenewal(ctx context.Context, client *http.Client, renewal remoteRenewal) error { - cookies, _, err := chromeauth.Refresh(ctx, renewal.Source, renewal.OAuth, renewal.Proxy) - if err != nil { - return err +// pushRenewal 凭续签凭据把会话 Cookie 写入远程实例;state 中没有签发不到 renewalInterval、尚未推送成功的会话时先以本机 Chrome 来源与 OAuth 材料签发新会话 +func pushRenewal(ctx context.Context, client *http.Client, state *renewalState) error { + renewal := state.renewal + if state.cookies == nil || time.Since(state.mintedAt) >= renewalInterval { + cookies, _, err := chromeauth.Refresh(ctx, renewal.Source, renewal.OAuth, renewal.Proxy) + if err != nil { + return err + } + state.cookies, state.mintedAt = cookies, time.Now() } - payload, err := json.Marshal(api.PairingRenewalInput{Cookies: cookies}) + payload, err := json.Marshal(api.PairingRenewalInput{Cookies: state.cookies}) if err != nil { return fmt.Errorf("编码 Cookie: %w", err) } @@ -152,15 +197,21 @@ func pushRenewal(ctx context.Context, client *http.Client, renewal remoteRenewal } defer response.Body.Close() if response.StatusCode == http.StatusOK { + state.cookies = nil return nil } var result struct { Error struct { + Code string `json:"code"` Message string `json:"message"` } `json:"error"` } + failure := fmt.Errorf("远程实例续签失败: HTTP %d", response.StatusCode) if json.NewDecoder(response.Body).Decode(&result) == nil && result.Error.Message != "" { - return fmt.Errorf("远程实例续签失败: HTTP %d: %s", response.StatusCode, result.Error.Message) + failure = fmt.Errorf("远程实例续签失败: HTTP %d: %s", response.StatusCode, result.Error.Message) + } + if result.Error.Code == "pairing_renewal_invalid" || result.Error.Code == "account_not_found" { + return fmt.Errorf("%w: %w", errRenewalRevoked, failure) } - return fmt.Errorf("远程实例续签失败: HTTP %d", response.StatusCode) + return failure } diff --git a/internal/setup/setup.go b/internal/setup/setup.go index d16d7e76..9f547ad6 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -123,7 +123,7 @@ func importAccounts(ctx context.Context, cfg config.Config, target accountTarget if !camoufoxnative.HasDisplay() { return camoufoxnative.ErrNoDisplay } - driver, err := defaultSetupLoginDriver(ctx, cfg, options.egress) + driver, err := defaultSetupLoginDriver(ctx, options.egress) if err != nil { return err } @@ -583,12 +583,12 @@ func defaultSetupLabel(storageState string) string { return parent } -func defaultSetupLoginDriver(ctx context.Context, cfg config.Config, egress string) (aistudio.IsolatedLoginDriver, error) { +func defaultSetupLoginDriver(ctx context.Context, egress string) (aistudio.IsolatedLoginDriver, error) { camoufoxPath, err := camoufoxnative.FindExecutable(ctx, egress) if err != nil { return nil, err } - return aistudio.NewNativeLoginDriver(camoufoxPath, cfg.RequestTimeout) + return aistudio.NewNativeLoginDriver(camoufoxPath) } func validateSetupRoot(root string) error { diff --git a/internal/waa/host.go b/internal/waa/host.go index ef899d80..f8f56735 100644 --- a/internal/waa/host.go +++ b/internal/waa/host.go @@ -169,7 +169,8 @@ type agent struct { profile Profile loadImage func(string) bool loadFrame func(string) bool - pending *sync.WaitGroup + pending int + settled []chan struct{} timezone string navigationStart time.Time depth int @@ -234,6 +235,18 @@ func (state *agent) dispatch(realm *realmHost, id float64, argument mozjs.Value) state.cx.ReleaseValue(value) } +// loaded 结束一个页面资源请求,没有进行中的请求时唤醒等待静置的调用方 +func (state *agent) loaded() { + state.pending-- + if state.pending > 0 { + return + } + for _, done := range state.settled { + close(done) + } + state.settled = nil +} + // afterTask 在每个任务结束后为 Promise 拒绝派发 unhandledrejection 与 rejectionhandled func (state *agent) afterTask() { defer state.cx.ClearKeptObjects() @@ -474,11 +487,13 @@ func (state *agent) natives(realm *realmHost) map[string]hostNative { if state.loadImage != nil { natives["loadImage"] = hostNative{2, func(call *mozjs.Call) error { address, id := call.ArgString(0), call.ArgNumber(1) - state.pending.Add(1) + state.pending++ go func() { - defer state.pending.Done() loaded := state.loadImage(address) - state.loop.schedule(0, func() { state.dispatch(realm, id, mozjs.Bool(loaded)) }) + state.loop.post(func() { + state.dispatch(realm, id, mozjs.Bool(loaded)) + state.loaded() + }) }() return nil }} @@ -486,11 +501,13 @@ func (state *agent) natives(realm *realmHost) map[string]hostNative { if state.loadFrame != nil { natives["loadFrame"] = hostNative{2, func(call *mozjs.Call) error { address, id := call.ArgString(0), call.ArgNumber(1) - state.pending.Add(1) + state.pending++ go func() { - defer state.pending.Done() loaded := state.loadFrame(address) - state.loop.schedule(0, func() { state.dispatch(realm, id, mozjs.Bool(loaded)) }) + state.loop.post(func() { + state.dispatch(realm, id, mozjs.Bool(loaded)) + state.loaded() + }) }() return nil }} diff --git a/internal/waa/loop.go b/internal/waa/loop.go index 5f8fe168..eee028c7 100644 --- a/internal/waa/loop.go +++ b/internal/waa/loop.go @@ -82,6 +82,11 @@ func (loop *eventLoop) schedule(delay time.Duration, job func()) { loop.post(job) return } + loop.mu.Lock() + defer loop.mu.Unlock() + if loop.closed { + return + } var timer *time.Timer timer = time.AfterFunc(delay, func() { loop.mu.Lock() @@ -89,14 +94,7 @@ func (loop *eventLoop) schedule(delay time.Duration, job func()) { loop.mu.Unlock() loop.post(job) }) - loop.mu.Lock() - if loop.closed { - loop.mu.Unlock() - timer.Stop() - return - } loop.timers[timer] = struct{}{} - loop.mu.Unlock() } func (loop *eventLoop) close() { diff --git a/internal/waa/realm.go b/internal/waa/realm.go index b144e77c..3b27d0bb 100644 --- a/internal/waa/realm.go +++ b/internal/waa/realm.go @@ -1,6 +1,7 @@ package waa import ( + "bytes" "crypto/sha1" "crypto/sha256" "crypto/sha512" @@ -51,8 +52,17 @@ func decodeText(name string, data []byte, fatal bool) (string, error) { if err != nil { return "", err } - if fatal && strings.ContainsRune(string(decoded), '�') && !strings.ContainsRune(string(data), '�') { + if fatal && bytes.Count(decoded, []byte("�")) > encodedReplacements(encoder, data) { return "", encoding.ErrInvalidUTF8 } return string(decoded), nil } + +// encodedReplacements 返回 data 中按该编码合法编码的 U+FFFD 个数,编码不能表示 U+FFFD 时为 0 +func encodedReplacements(encoder encoding.Encoding, data []byte) int { + sequence, err := encoder.NewEncoder().Bytes([]byte("�")) + if err != nil { + return 0 + } + return bytes.Count(data, sequence) +} diff --git a/internal/waa/runtime.go b/internal/waa/runtime.go index cb05c599..453a1680 100644 --- a/internal/waa/runtime.go +++ b/internal/waa/runtime.go @@ -37,7 +37,6 @@ type Runtime struct { nextResult float64 operationMu sync.Mutex closeOnce sync.Once - pending sync.WaitGroup // idle 在最后一次操作 idleShrinkDelay 后执行收缩 GC idle *time.Timer } @@ -119,7 +118,7 @@ func newRuntime(ctx context.Context, options Options, prelude string) (*Runtime, complete(err) return } - state := &agent{cx: cx, loop: loop, profile: options.Profile, loadImage: options.LoadImage, loadFrame: options.LoadFrame, pending: &runtime.pending, timezone: location.String(), navigationStart: options.Profile.NavigationStart} + state := &agent{cx: cx, loop: loop, profile: options.Profile, loadImage: options.LoadImage, loadFrame: options.LoadFrame, timezone: location.String(), navigationStart: options.Profile.NavigationStart} runtime.agent = state loop.cleanup = cx.Close loop.afterTask = state.afterTask @@ -328,16 +327,23 @@ func experimentSignals(cx *mozjs.Context, global mozjs.Handle, blob string) (moz return signals, state, err } -// Settle 等待 VM 发出的页面资源请求完成 +// Settle 等待 VM 发出的页面资源请求完成,请求完成回调中发出的新请求一并等待 func (runtime *Runtime) Settle(ctx context.Context) error { done := make(chan struct{}) - go func() { - runtime.pending.Wait() - close(done) - }() + if !runtime.loop.post(func() { + if runtime.agent.pending == 0 { + close(done) + return + } + runtime.agent.settled = append(runtime.agent.settled, done) + }) { + return errors.New("WAA runtime 已关闭") + } select { case <-done: return nil + case <-runtime.loop.done: + return errors.New("WAA runtime 已关闭") case <-ctx.Done(): return ctx.Err() } diff --git a/web/src/api.ts b/web/src/api.ts index 10321078..da64d217 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -160,8 +160,12 @@ export const api = { body: jsonBody(input), }) ).task, - cookieBrowsers: async () => - (await requestJSON<{ browsers: CookieBrowser[] }>('/api/accounts/import/browsers')).browsers, + cookieBrowsers: async (proxy: string) => + ( + await requestJSON<{ browsers: CookieBrowser[] }>( + `/api/accounts/import/browsers?${new URLSearchParams({ proxy })}`, + ) + ).browsers, discoverCookieAccounts: async (input: CookieImportInput) => ( await requestJSON<{ accounts: CookieBrowser[] }>('/api/accounts/import/cookies/discover', { diff --git a/web/src/components/AccountsPanel.vue b/web/src/components/AccountsPanel.vue index d293812d..d06420a8 100644 --- a/web/src/components/AccountsPanel.vue +++ b/web/src/components/AccountsPanel.vue @@ -688,11 +688,11 @@ function browserSummary(browser: CookieBrowser): string { ) } -// scanBrowsers 查找本机调试浏览器,默认勾选可导入的账户 +// scanBrowsers 查找本机调试浏览器并经对话框里的代理识别账户,默认勾选可导入的账户 async function scanBrowsers(): Promise { scanningBrowsers.value = true try { - cookieBrowsers.value = await api.cookieBrowsers() + cookieBrowsers.value = await api.cookieBrowsers(accountEnvironment.proxy) selectedBrowsers.value = cookieBrowsers.value .filter((browser) => browser.email !== '' && !browser.imported && browser.error === '') .map((browser) => browserKey(browser)) From bab7209eef0d5a8f9baed4652d7e4158a3adad7b Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Tue, 10 Nov 2026 23:23:14 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=96=B0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=AF=86=E5=88=AB=E3=80=81=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=8F=90=E7=A4=BA=E4=B8=8E=E5=8F=91=E5=B8=83=E5=89=8D?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改写客户端系统提示中带归属声明的身份句,删除 Claude Code 计费行与含 RFC 2119 keywords 的行 - 缺少模型时的目录读取按权益从高到低进行,加入 Gemini 4 Carbon 的单价与 README 模型行 - 多个账户先后额度失败时,429 的重试时间取最早恢复的账户 - TextDecoder fatal 以重新编码逐字节比较判定无效序列,覆盖跨 UTF-16 码元的字节 - 试用页停止生成时中止发送前的视频存在检查与重新上传,附件预览地址在显示时创建 - 统一设置页开关行与输入框行的高度与对齐,合并账户导入的新增与更新分流 --- README.md | 1 + README_en.md | 1 + docs/development.md | 2 +- docs/logging.md | 2 +- docs/protocol.md | 4 ++- internal/aistudio/accounts.go | 17 +++++++++++++ internal/aistudio/bidi.go | 3 ++- internal/aistudio/client_prompt.go | 38 +++++++++++++++++++++++++++ internal/aistudio/turns.go | 3 ++- internal/app/admin.go | 41 ++++++++++++++---------------- internal/app/runtime.go | 14 +++++++--- internal/pricing/pricing.go | 1 + internal/waa/realm.go | 14 +++++----- web/src/components/SettingRow.vue | 6 ++--- web/src/components/UiSwitch.vue | 2 +- web/src/playground/attachment.ts | 26 ++++++++++--------- web/src/playground/drive.ts | 17 ++++++++++--- web/src/playground/session.ts | 14 ++++++---- 18 files changed, 142 insertions(+), 64 deletions(-) create mode 100644 internal/aistudio/client_prompt.go diff --git a/README.md b/README.md index e34ee429..45fd6679 100644 --- a/README.md +++ b/README.md @@ -516,6 +516,7 @@ curl http://127.0.0.1:2048/v1/videos \ | Model ID | 名称 | 类型 | 输入 | 输出 | | --- | --- | --- | ---: | ---: | +| `gemini-4-carbon` | Gemini 4 Carbon | 文本 | 1048576 | 32768 | | `gemini-4-argon` | Gemini 4 Argon | 文本 | 1048576 | 32768 | | `gemini-3.8-flash` | Gemini 3.8 Flash | 文本 | 1048576 | 65536 | | `gemini-3.6-flash` | Gemini 3.6 Flash | 文本 | 1048576 | 65536 | diff --git a/README_en.md b/README_en.md index 49a40100..5dbbc9e2 100644 --- a/README_en.md +++ b/README_en.md @@ -516,6 +516,7 @@ The model catalog follows AI Studio updates; clients read the current values fro | Model ID | Name | Type | Input | Output | | --- | --- | --- | ---: | ---: | +| `gemini-4-carbon` | Gemini 4 Carbon | Text | 1048576 | 32768 | | `gemini-4-argon` | Gemini 4 Argon | Text | 1048576 | 32768 | | `gemini-3.8-flash` | Gemini 3.8 Flash | Text | 1048576 | 65536 | | `gemini-3.6-flash` | Gemini 3.6 Flash | Text | 1048576 | 65536 | diff --git a/docs/development.md b/docs/development.md index c18af4ec..46875707 100644 --- a/docs/development.md +++ b/docs/development.md @@ -353,7 +353,7 @@ Worker 容量由热池目标、活动上限和单账户并发共同约束。活 `ModelAccessKey(scope, model)` 会移除 `models/` 前缀;空 scope 返回规范模型 ID,非空 scope 返回 `:`。Bidi setup 使用 lease(账户租约)时间,会话内每个 qualifying turn(一次模型资格检查)分配严格递增的 attempt 时间,`turn_complete` 消费对应 attempt。普通流式生成在规范 `EventFinish` 到达时写 `verified`;此前的 text、reasoning、tool、usage 和首事件用于输出与性能统计,终态前断流、取消或错误保持原验证状态。 -模型目录取自账户会话页面载入时的 `GetAiStudioBenefitTier` 与 `ListModels` 响应:有常驻 Worker 的账户取它最近一次页面载入,没有的账户在 Worker 容量允许时启动 Worker,否则临时载入一个页面,取得响应后关闭。启动期同步对全部 enabled ready/busy 账户执行,还没有目录的账户同时读取,已有目录的账户逐个读取,每个账户占用一个普通并发槽位。同步报错或返回空目录的账户进入 generation 内的 pending ID 集合;每个非空结果立即更新公共目录、账户状态并在 RUNNING 期间预热更多 Worker。启动期同步结束后,单个 30 秒 ticker(定时器)重新载入可用的 pending 账户的页面;同一循环从启动期同步开始时起每 24 小时逐个重新载入全部 enabled ready/busy 账户的页面,槽位已满的账户跳过本轮,权益或目录变化时写回并发布账户与模型快照。请求的模型不在任何账户的目录中时,巡检正在读取一轮的第一个账户则请求等它读完,没有进行中的一轮且距上一轮开始已超过 10 分钟时巡检立即开始一轮,请求等这一轮读完第一个账户后再查找模型,其余账户在后台继续读取。冷却中的任务保留到期再试;需要登录、停用、不可用或已删除的账户退出重试,重新登录或启用后由账户更新流程重新同步。失败日志记录账户与原因,恢复成功记录模型数量。`modelRevision` 跟踪账户和配置变化,生成服务开始接收请求前会确认已应用当前 revision。 +模型目录取自账户会话页面载入时的 `GetAiStudioBenefitTier` 与 `ListModels` 响应:有常驻 Worker 的账户取它最近一次页面载入,没有的账户在 Worker 容量允许时启动 Worker,否则临时载入一个页面,取得响应后关闭。启动期同步对全部 enabled ready/busy 账户执行,还没有目录的账户同时读取,已有目录的账户按权益从高到低、同权益按账户 ID 逐个读取,每个账户占用一个普通并发槽位。同步报错或返回空目录的账户进入 generation 内的 pending ID 集合;每个非空结果立即更新公共目录、账户状态并在 RUNNING 期间预热更多 Worker。启动期同步结束后,单个 30 秒 ticker(定时器)重新载入可用的 pending 账户的页面;同一循环从启动期同步开始时起每 24 小时按同样的顺序逐个重新载入全部 enabled ready/busy 账户的页面,槽位已满的账户跳过本轮,权益或目录变化时写回并发布账户与模型快照。请求的模型不在任何账户的目录中时,巡检正在读取一轮的第一个账户则请求等它读完,没有进行中的一轮且距上一轮开始已超过 10 分钟时巡检立即开始一轮,请求等这一轮读完第一个账户后再查找模型,其余账户在后台继续读取。冷却中的任务保留到期再试;需要登录、停用、不可用或已删除的账户退出重试,重新登录或启用后由账户更新流程重新同步。失败日志记录账户与原因,恢复成功记录模型数量。`modelRevision` 跟踪账户和配置变化,生成服务开始接收请求前会确认已应用当前 revision。 认证状态包含长期凭证和设备绑定材料,保存在本机受控目录。提交、Issue、CI 和普通日志使用脱敏材料,保留字段形状并替换 Cookie、token、proof、邮箱、账户 ID、提示词、响应正文和完整原始帧。 diff --git a/docs/logging.md b/docs/logging.md index f100e801..14ebdce3 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -65,7 +65,7 @@ INFO service WAA Worker 预热完成 | Worker=5/5 | 耗时=34.903s INFO account@example.com 模型目录已更新 | 模型=39 ``` -同一循环从启动期同步开始时起每 24 小时逐个重新载入全部 `enabled`、`ready` 或 `busy` 账户的页面读取权益与 `ListModels` 目录,并发槽位已满的账户跳过本轮:有常驻 Worker 的账户重新载入它的页面,其余账户临时载入一个页面,取得响应后关闭。请求的模型不在任何账户的目录中时,巡检正在读取一轮的第一个账户则请求等它读完,没有进行中的一轮且距上一轮开始已超过 10 分钟时立即读取一轮,请求等这一轮读完第一个账户后再查找模型,其余账户在后台继续读取。订阅模型遇到额度错误时同样刷新该账户。权益或模型数变化时更新账户调度与公开目录,读取失败时记录原因,登录失效的账户转为需要登录: +同一循环从启动期同步开始时起每 24 小时按权益从高到低、同权益按账户 ID 的顺序逐个重新载入全部 `enabled`、`ready` 或 `busy` 账户的页面读取权益与 `ListModels` 目录,并发槽位已满的账户跳过本轮:有常驻 Worker 的账户重新载入它的页面,其余账户临时载入一个页面,取得响应后关闭。请求的模型不在任何账户的目录中时,巡检正在读取一轮的第一个账户则请求等它读完,没有进行中的一轮且距上一轮开始已超过 10 分钟时立即读取一轮,请求等这一轮读完第一个账户后再查找模型,其余账户在后台继续读取。订阅模型遇到额度错误时同样刷新该账户。权益或模型数变化时更新账户调度与公开目录,读取失败时记录原因,登录失效的账户转为需要登录: ```text INFO account@example.com WAA Worker 页面已重新载入 | 耗时=4.521s diff --git a/docs/protocol.md b/docs/protocol.md index b0c84d93..2da33de1 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1518,7 +1518,7 @@ Worker 进程故障、Worker 被替换与协议 Code 5 会重建当前账户 Wor 模型目录重试的 pending 集合保存等待再次同步的账户 ID。启动期全账户同步、以及新增、登录或在管理端启用后的单账户同步,遇到任意错误或成功返回空目录时加入;返回非空目录时移除;删除账户同时移除。全账户后台同步结束后启动单个 30 秒 ticker(Go 定时器),每次按启动期同步的方式重新载入排序后的待重试账户的会话页面,并在任务开始时复核该 ID 仍在 pending 集合中。错误或空目录继续保留;每个非空成功立即更新账户缓存与公共目录快照、发布 `accounts` 和 `models`,并在 `RUNNING` 状态触发 Worker 预热。批次结束时,`auth_required` 集合发生变化会补发当前账户与模型快照;即时单账户同步无论成功或失败都立即发布当前快照。启动期同步、单账户同步与 30 秒重试中的每个账户读取占用该账户的一个普通并发槽位,与生成请求和 Worker 启动共用 `PER_ACCOUNT_CONCURRENCY`。 -同一循环从全账户同步开始时起每 24 小时巡检一轮:逐个重新载入全部 enabled 且处于 `ready` 或 `busy` 的账户的会话页面,读取页面载入时的 `GetAiStudioBenefitTier` 与 `ListModels` 响应(启用 Build 时另读 Build 目录;账户本进程的首次读取先按[条款同意](#条款同意)处理 `GetLoggingContext`): +同一循环从全账户同步开始时起每 24 小时巡检一轮:按权益从高到低(Ultra、Pro、Plus、Free)、同权益按账户 ID 的顺序,逐个重新载入全部 enabled 且处于 `ready` 或 `busy` 的账户的会话页面,读取页面载入时的 `GetAiStudioBenefitTier` 与 `ListModels` 响应(启用 Build 时另读 Build 目录;账户本进程的首次读取先按[条款同意](#条款同意)处理 `GetLoggingContext`): - 每个账户占用一个普通并发槽位,与生成请求共用 `PER_ACCOUNT_CONCURRENCY`,槽位已满的账户跳过本轮 - 请求的模型不在任何账户的目录中时,巡检正在读取一轮的第一个账户则请求等它读完;没有进行中的一轮且距上一轮开始已超过 10 分钟时,巡检立即开始一轮,请求等这一轮读完第一个账户后再查找模型,仍没有时返回模型不存在,其余账户在后台继续读取;下一轮巡检从这一轮开始时计时 @@ -1632,6 +1632,8 @@ Gemini `GET /v1beta/models` 返回 `{"models":[...]}`,单模型路由直接返 生成与计数请求需要系统提示或对话内容中的至少一项,只有系统提示时以它作为 user 轮发送。请求以 assistant 轮结尾时,`gemini-3.1-pro-preview`、`gemini-3.1-flash-lite`、`gemini-3-flash-preview`、`gemini-omni-1.1-flash`、`gemini-nano-banana-2.1` 与 `gemma-` 开头的模型原样以 model 轮结尾发送,由模型原生续写。其余模型(包括 `-latest` 别名与 `-build` 写代码助手)在 `CONTINUATION_REWRITE` 打开时改写末尾连续 assistant 轮:`CONTINUATION_PROMPT` 非空时,这些轮的正文与代码执行内容以 `` 包裹,连同该提示词写入系统指令;为空时这段正文作为最后一轮 user 发送。改写后模型从前缀之后续写,响应只返回续写部分,末尾未回传结果的函数调用由模型重新生成。`CONTINUATION_REWRITE` 关闭时所有模型原样发送,Playground 以 400 `Requests ending with a model turn are not supported` 拒绝的模型返回该错误。各生成入口与计数接口按同一规则转换,`countTokens` 接受以 model 轮结尾的请求。 +生成、计数与 Live 的系统提示按同一规则改写后发送:行首以 `You are` 或 `You're` 开头、以 agent、assistant 或 AI 结尾并带归属声明(`created by`、`built on`、`based on` 等)的身份句替换为 `You are an AI Agent.`,以 `x-anthropic-billing-header:` 开头的行与含 `RFC 2119 keywords`(不区分大小写)的行整行删除,改写后为空的系统提示分段不发送。 + 四套生成入口共享同一规范请求,输入映射如下: | 能力 | OpenAI Chat | OpenAI Responses | Anthropic | Gemini | diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index b2dbe3c5..0b647e8b 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -2132,6 +2132,23 @@ func (p *AccountPool) CandidateStatesForScope( return result } +// ByBenefitTier 把账户按权益从高到低稳定排序,同权益保持传入顺序 +func (p *AccountPool) ByBenefitTier(accountIDs []string) []string { + p.mu.Lock() + defer p.mu.Unlock() + priority := func(accountID string) int { + if account := p.byID[accountID]; account != nil { + return benefitTierPriority(account.BenefitTier) + } + return -1 + } + ordered := slices.Clone(accountIDs) + slices.SortStableFunc(ordered, func(left, right string) int { + return priority(right) - priority(left) + }) + return ordered +} + // PreferWarmPool 按权益分层轮选初始热账户,fill-first 时 ACCOUNT_ORDER 列出的账户在前 func (p *AccountPool) PreferWarmPool(accountIDs []string) []string { type warmCandidate struct { diff --git a/internal/aistudio/bidi.go b/internal/aistudio/bidi.go index a294c87d..82b4efe4 100644 --- a/internal/aistudio/bidi.go +++ b/internal/aistudio/bidi.go @@ -206,8 +206,9 @@ type BidiEvent struct { Err error `json:"-"` } -// EncodeBidiSetupRequest 编码 Live 或 Robotics 的已验证 setup 帧 +// EncodeBidiSetupRequest 编码 Live 或 Robotics 的已验证 setup 帧,系统指令按 clientSystemText 改写 func EncodeBidiSetupRequest(request BidiRequest, runtime RequestContext) ([]byte, string, error) { + request.SystemInstruction = clientSystemText(request.SystemInstruction) model := strings.TrimPrefix(strings.TrimSpace(request.Model), "models/") if model == "" { return nil, "", fmt.Errorf("%w: bidi model 不能为空", ErrInvalidArgument) diff --git a/internal/aistudio/client_prompt.go b/internal/aistudio/client_prompt.go new file mode 100644 index 00000000..c22ee98b --- /dev/null +++ b/internal/aistudio/client_prompt.go @@ -0,0 +1,38 @@ +package aistudio + +import ( + "regexp" + "slices" + "strings" +) + +// clientIdentity 匹配客户端系统提示中行首带归属声明的身份句:You are 加以 agent、assistant 或 AI 结尾的身份短语,8 个字符内接 created by、built on、based on 等归属声明,到句号为止,句号后的空格、Tab 或 CR 保留 +var clientIdentity = regexp.MustCompile(`(?im)^([ \t]*)(?:you are|you're)\s+(?:an?\s+)?(?:[\w'-]+[\s,]+){0,8}(?:agent|assistant|ai)\b[^.!?\n<>]{0,8}?\b(?:(?:created|built|made|developed|designed|trained|powered|maintained|published|released|provided)\s+(?:by|on|upon)|based\s+on)\s+[^\n<>]{1,80}?\.([ \t\r]|$)`) + +// neutralIdentity 为替换客户端身份句的中性身份句 +const neutralIdentity = "You are an AI Agent." + +// billingPrefix 为 Claude Code 计费行的开头 +const billingPrefix = "x-anthropic-billing-header:" + +// rfc2119Keywords 匹配 RFC 2119 keywords 写法,不区分大小写 +var rfc2119Keywords = regexp.MustCompile(`(?i)RFC 2119 keywords`) + +// clientSystem 逐段改写客户端系统提示,改写后变空的段落不再发送 +func clientSystem(system []string) []string { + var rewritten []string + for _, text := range system { + if text = clientSystemText(text); text != "" { + rewritten = append(rewritten, text) + } + } + return rewritten +} + +// clientSystemText 改写一段客户端系统提示:行首带归属声明的身份句换成中性身份句,Claude Code 计费行与含 RFC 2119 keywords 的行整行删除 +func clientSystemText(text string) string { + lines := slices.DeleteFunc(strings.Split(text, "\n"), func(line string) bool { + return strings.HasPrefix(line, billingPrefix) || rfc2119Keywords.MatchString(line) + }) + return clientIdentity.ReplaceAllString(strings.Join(lines, "\n"), "${1}"+neutralIdentity+"${2}") +} diff --git a/internal/aistudio/turns.go b/internal/aistudio/turns.go index bcbe134b..5f1f3fd1 100644 --- a/internal/aistudio/turns.go +++ b/internal/aistudio/turns.go @@ -24,8 +24,9 @@ func (s *PooledService) rewritesAssistantTail(modelID string) bool { return s.ContinuationRewrite && !nativePrefillModels[modelID] && !strings.HasPrefix(modelID, "gemma-") } -// normalizeTurns 在 rewrite 时改写末尾连续 assistant 轮:prompt 非空时正文作为续写前缀连同 prompt 写入系统指令,为空时正文作为最后一轮 user;没有对话内容时以系统提示作为 user 轮 +// normalizeTurns 按 clientSystem 改写客户端系统提示;在 rewrite 时改写末尾连续 assistant 轮:prompt 非空时正文作为续写前缀连同 prompt 写入系统指令,为空时正文作为最后一轮 user;没有对话内容时以系统提示作为 user 轮 func normalizeTurns(system []string, contents []Content, rewrite bool, prompt string) ([]string, []Content) { + system = clientSystem(system) if rewrite { tail := len(contents) for tail > 0 && contents[tail-1].Role == RoleAssistant { diff --git a/internal/app/admin.go b/internal/app/admin.go index acbbe33c..70427974 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -177,7 +177,7 @@ func (admin *runtimeAdmin) CreateAccount(ctx context.Context, input api.AccountC if err := accountConfig.Validate(); err != nil { return api.AdminAccount{}, invalidAccount(err) } - created, err := admin.upsertAccount(ctx, accountConfig, result.StorageState, directory) + created, err := admin.upsertAccount(ctx, accountConfig.Label, accountConfig, result.StorageState, directory) if err != nil { return api.AdminAccount{}, err } @@ -356,7 +356,7 @@ func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.C } accounts := make([]api.AdminAccount, 0, len(results)) for index, result := range results { - account, err := admin.upsertAccount(ctx, configs[index], result.State, "") + account, err := admin.upsertAccount(ctx, configs[index].Label, configs[index], result.State, "") if err != nil { return nil, err } @@ -407,16 +407,9 @@ func (admin *runtimeAdmin) ImportAccountState(ctx context.Context, input api.Acc } else if exists && strings.TrimSpace(extension.Source.Email) != "" { email = extension.Source.Email } - existingID := "" - for _, status := range admin.pool.Status() { - if strings.EqualFold(status.ID, strings.TrimSpace(email)) { - if input.RenewalToken != "" { - return api.AdminAccount{}, &adminOperationError{ - status: http.StatusConflict, code: "account_exists", message: "账户已存在: " + status.ID, - } - } - existingID = status.ID - break + if existing := admin.accountID(email); existing != "" && input.RenewalToken != "" { + return api.AdminAccount{}, &adminOperationError{ + status: http.StatusConflict, code: "account_exists", message: "账户已存在: " + existing, } } fingerprintDirectory := "" @@ -431,12 +424,7 @@ func (admin *runtimeAdmin) ImportAccountState(ctx context.Context, input api.Acc } fingerprintDirectory = directory } - var account api.AdminAccount - if existingID != "" { - account, err = admin.updateImportedAccount(ctx, existingID, accountConfig.AuthUser, input.StorageState, fingerprintDirectory) - } else { - account, err = admin.addAccount(ctx, accountConfig, input.StorageState, fingerprintDirectory) - } + account, err := admin.upsertAccount(ctx, email, accountConfig, input.StorageState, fingerprintDirectory) if err != nil { return api.AdminAccount{}, err } @@ -449,19 +437,28 @@ func (admin *runtimeAdmin) ImportAccountState(ctx context.Context, input api.Acc return account, nil } -// upsertAccount 新增账户,若同名账户已存在则就地更新其认证状态并保留运行态资源绑定 +// upsertAccount 在 id 对应的账户已存在时就地更新其认证状态并保留运行态资源绑定,否则以 accountConfig 新增账户 func (admin *runtimeAdmin) upsertAccount( ctx context.Context, + id string, accountConfig aistudio.AccountConfig, state aistudio.StorageState, fingerprintDirectory string, ) (api.AdminAccount, error) { + if existing := admin.accountID(id); existing != "" { + return admin.updateImportedAccount(ctx, existing, accountConfig.AuthUser, state, fingerprintDirectory) + } + return admin.addAccount(ctx, accountConfig, state, fingerprintDirectory) +} + +// accountID 返回与 id 不区分大小写相同的已有账户 ID,没有时为空 +func (admin *runtimeAdmin) accountID(id string) string { for _, status := range admin.pool.Status() { - if strings.EqualFold(status.ID, strings.TrimSpace(accountConfig.Label)) { - return admin.updateImportedAccount(ctx, status.ID, accountConfig.AuthUser, state, fingerprintDirectory) + if strings.EqualFold(status.ID, strings.TrimSpace(id)) { + return status.ID } } - return admin.addAccount(ctx, accountConfig, state, fingerprintDirectory) + return "" } // updateImportedAccount 就地替换已有账户的 Cookie、认证材料与会话序号,保留 runtime-state.json 中的文件资源绑定与模型资格 diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 0ac9479c..586fbb49 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -717,6 +717,7 @@ func (service *trackedService) startModelCatalogRefresh(ctx context.Context) (<- return ready, synced, done } +// modelCatalogAccountIDs 返回读取目录的启用账户,权益高的在前,同权益按 ID 排列 func (service *trackedService) modelCatalogAccountIDs() []string { statuses := service.pool.Status() accountIDs := make([]string, 0, len(statuses)) @@ -726,7 +727,7 @@ func (service *trackedService) modelCatalogAccountIDs() []string { } } sort.Strings(accountIDs) - return accountIDs + return service.pool.ByBenefitTier(accountIDs) } // refreshAccountModelCatalogs 同步账户目录并按完成顺序发出结果:还没有目录的账户并发载入页面,已有目录的账户逐个载入 @@ -1756,6 +1757,8 @@ type generateRetry struct { missingInlineMedia bool // quotaRejected 表示已有尝试收到套餐限额或每日限额拒绝,之后全部账户冷却时不再改用这两种冷却中的账户 quotaRejected bool + // quotaUntil 是本次请求中因额度失败而冷却的账户最早的恢复时刻 + quotaUntil time.Time } // attemptOutcome 是失败尝试处理后的错误与换号决定 @@ -2167,7 +2170,7 @@ func (service *trackedService) runAttempt( attempt.err = err } -// quotaCooldownError 是带账户冷却恢复时刻的额度失败 +// quotaCooldownError 是带恢复时刻的额度失败,恢复时刻取本次请求中额度失败账户最早的冷却恢复时刻 type quotaCooldownError struct { err error until time.Time @@ -2181,7 +2184,7 @@ func (err *quotaCooldownError) Unwrap() error { return err.err } -// RetryAt 返回账户冷却的恢复时刻 +// RetryAt 返回额度失败账户最早的冷却恢复时刻 func (err *quotaCooldownError) RetryAt(time.Time) time.Time { return err.until } @@ -2243,7 +2246,10 @@ func (service *trackedService) settleAttempt( retryable = false } else { if !until.IsZero() { - err = "aCooldownError{err: err, until: until} + if retry.quotaUntil.IsZero() || until.Before(retry.quotaUntil) { + retry.quotaUntil = until + } + err = "aCooldownError{err: err, until: retry.quotaUntil} service.requests.logRequestProgress(requestID, accountLabel, "WARN", fmt.Sprintf( "账号冷却 | 类型=%s | 范围=%s | 恢复=%s", cooldown.Kind, scopeLabel, until.Local().Format(time.RFC3339), diff --git a/internal/pricing/pricing.go b/internal/pricing/pricing.go index 6e5b3db3..c615029c 100644 --- a/internal/pricing/pricing.go +++ b/internal/pricing/pricing.go @@ -12,6 +12,7 @@ type price struct { // prices 是按 API 模型 ID 索引的文本生成单价,输出单价包含思考 token var prices = map[string]price{ + "gemini-4-carbon": {input: 2, cached: 0.10, output: 10}, "gemini-4-argon": {input: 2, cached: 0.10, output: 10}, "gemini-3.8-flash": {input: 0.75, cached: 0.075, output: 3.75}, "gemini-3.7-flash": {input: 0.75, cached: 0.075, output: 3.75}, diff --git a/internal/waa/realm.go b/internal/waa/realm.go index 3b27d0bb..f2bb142a 100644 --- a/internal/waa/realm.go +++ b/internal/waa/realm.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "crypto/sha512" "strings" + "unicode/utf8" "golang.org/x/text/encoding" "golang.org/x/text/encoding/htmlindex" @@ -52,17 +53,14 @@ func decodeText(name string, data []byte, fatal bool) (string, error) { if err != nil { return "", err } - if fatal && bytes.Count(decoded, []byte("�")) > encodedReplacements(encoder, data) { + if fatal && bytes.ContainsRune(decoded, utf8.RuneError) && !reencodes(encoder, decoded, data) { return "", encoding.ErrInvalidUTF8 } return string(decoded), nil } -// encodedReplacements 返回 data 中按该编码合法编码的 U+FFFD 个数,编码不能表示 U+FFFD 时为 0 -func encodedReplacements(encoder encoding.Encoding, data []byte) int { - sequence, err := encoder.NewEncoder().Bytes([]byte("�")) - if err != nil { - return 0 - } - return bytes.Count(data, sequence) +// reencodes 判断 decoded 按该编码重新编码后与 data 逐字节相同,此时其中的 U+FFFD 都来自合法编码的 U+FFFD +func reencodes(encoder encoding.Encoding, decoded, data []byte) bool { + encoded, err := encoder.NewEncoder().Bytes(decoded) + return err == nil && bytes.Equal(encoded, data) } diff --git a/web/src/components/SettingRow.vue b/web/src/components/SettingRow.vue index 08fcedff..682e821a 100644 --- a/web/src/components/SettingRow.vue +++ b/web/src/components/SettingRow.vue @@ -22,9 +22,9 @@ const { t } = useI18n() class="group/row @container border-b border-line py-3.5 last:border-b-0 [&:has([role=switch])_label]:cursor-pointer" >
-
+
{{ label @@ -50,7 +50,7 @@ const { t } = useI18n()
diff --git a/web/src/components/UiSwitch.vue b/web/src/components/UiSwitch.vue index af2430ea..3b24febc 100644 --- a/web/src/components/UiSwitch.vue +++ b/web/src/components/UiSwitch.vue @@ -10,7 +10,7 @@ defineProps<{ id: string }>() role="switch" :aria-checked="model" :data-state="model ? 'checked' : 'unchecked'" - class="group relative flex h-[18px] w-8 shrink-0 items-center rounded-full bg-line-strong transition-colors duration-150 hover:bg-gray-600 disabled:cursor-default disabled:opacity-50 data-[state=checked]:bg-blue-600 data-[state=checked]:hover:bg-blue-500 md:ms-auto md:mt-px" + class="group relative flex h-[18px] w-8 shrink-0 items-center rounded-full bg-line-strong transition-colors duration-150 hover:bg-gray-600 disabled:cursor-default disabled:opacity-50 data-[state=checked]:bg-blue-600 data-[state=checked]:hover:bg-blue-500 md:ms-auto" @click="model = !model" > () const mediaURLs = new WeakMap() const mediaRegistry = new FinalizationRegistry((url) => URL.revokeObjectURL(url)) -// cacheAttachmentBlob 绑定内联附件的原始 Blob 与 Object URL -export function cacheAttachmentBlob(inline: NonNullable, blob: Blob): string { - const raw = toRaw(inline) - const existing = mediaURLs.get(raw) - if (existing !== undefined) return existing - const url = URL.createObjectURL(blob) - mediaBlobs.set(raw, blob) - mediaURLs.set(raw, url) - mediaRegistry.register(raw, url) - return url +// cacheAttachmentBlob 绑定内联附件的原始 Blob +export function cacheAttachmentBlob(inline: NonNullable, blob: Blob): void { + mediaBlobs.set(toRaw(inline), blob) } // copyAttachmentBlob 为复制出的内联附件复用已缓存的 Blob @@ -38,10 +31,19 @@ export function attachmentBlob(part: Part): Blob { return mediaBlobs.get(raw) ?? new Blob([base64Bytes(raw.data)], { type: raw.mimeType }) } -// attachmentURL 返回内联附件的 Object URL +// attachmentURL 返回内联附件的 Object URL,首次显示时以 attachmentBlob 创建并缓存其 Blob export function attachmentURL(part: Part): string { if (!part.inlineData) return '' - return cacheAttachmentBlob(part.inlineData, attachmentBlob(part)) + const raw = toRaw(part.inlineData) + let url = mediaURLs.get(raw) + if (url === undefined) { + const blob = attachmentBlob(part) + cacheAttachmentBlob(raw, blob) + url = URL.createObjectURL(blob) + mediaURLs.set(raw, url) + mediaRegistry.register(raw, url) + } + return url } // attachmentKind 返回内联附件在预览对话框中的类型,Drive 与 YouTube 引用和无法预览的文件为空 diff --git a/web/src/playground/drive.ts b/web/src/playground/drive.ts index 855fe369..36212198 100644 --- a/web/src/playground/drive.ts +++ b/web/src/playground/drive.ts @@ -23,12 +23,16 @@ export async function listDriveFiles(): Promise { return ((await response.json()) as { files?: DriveFile[] }).files ?? [] } -// driveFileExists 检查已上传的 Files API 文件是否存在 -export async function driveFileExists(fileUri: string): Promise { +// driveFileExists 检查已上传的 Files API 文件是否存在,signal 中止时请求随之中止 +export async function driveFileExists( + fileUri: string, + signal: AbortSignal | null = null, +): Promise { const name = fileUri.split('/files/')[1] if (!name) return false const response = await fetch(`/v1beta/files/${encodeURIComponent(name)}`, { headers: filesHeaders(), + signal, }) return response.status !== 404 } @@ -41,8 +45,12 @@ function filesHeaders(): Headers { return headers } -// uploadDriveFile 与官网一致在附件加入时经本服务 Files API 把文件上传到账户的 Drive,返回上传后的文件 -export async function uploadDriveFile(file: Blob, name: string): Promise { +// uploadDriveFile 与官网一致在附件加入时经本服务 Files API 把文件上传到账户的 Drive,返回上传后的文件;signal 中止时上传随之中止 +export async function uploadDriveFile( + file: Blob, + name: string, + signal: AbortSignal | null = null, +): Promise { const form = new FormData() form.append('metadata', JSON.stringify({ file: { displayName: name } })) form.append('file', file, name) @@ -50,6 +58,7 @@ export async function uploadDriveFile(file: Blob, name: string): Promise { +// renewUploads 在发送前把 Files API 上传文件已到期或已不存在的视频以对话中保留的原视频重新上传,片段改为引用新文件;signal 中止时检查与上传随之中止 +async function renewUploads( + turns: readonly Turn[], + signal: AbortSignal | null = null, +): Promise { const videos = turns.flatMap((turn) => turn.parts).filter(uploadedVideo) for (const part of videos) { - if (Date.now() < part.fileExpires! && (await driveFileExists(part.fileData!.fileUri))) continue - const uploaded = await uploadDriveFile(attachmentBlob(part), part.fileName!) + if (Date.now() < part.fileExpires! && (await driveFileExists(part.fileData!.fileUri, signal))) + continue + const uploaded = await uploadDriveFile(attachmentBlob(part), part.fileName!, signal) part.fileData = { mimeType: uploaded.mimeType, fileUri: uploaded.uri } part.fileExpires = Date.parse(uploaded.expirationTime) } @@ -499,7 +503,7 @@ function createSession(temporary = false, links: SessionLinks = {}) { const signal = controller.signal if (session.id === null && !session.temporary) await save() try { - await renewUploads(sent) + await renewUploads(sent, signal) void refreshTokens() await generate({ protocol: session.settings.protocol, From e38957a084c8928977e74a1d4faba80e5e852950 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Wed, 11 Nov 2026 02:21:30 +0800 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20=E4=BF=AE=E8=AE=A2=20PR=20=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=9A=84=20AI=20Agent=20=E6=A0=B8=E5=AF=B9=E6=B8=85?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 72ef1980..67c32820 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,16 +5,19 @@ From 77ee1c63a3140837e829d54c01249eb21ed49787 Mon Sep 17 00:00:00 2001 From: xiaohai1314-sys <219774556+xiaohai1314-sys@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:34:19 +0800 Subject: [PATCH 6/7] Fix lease retain and renewal error parsing --- internal/aistudio/accounts.go | 19 +++++--- internal/aistudio/accounts_lease_test.go | 61 ++++++++++++++++++++++++ internal/setup/renewal.go | 3 +- internal/setup/renewal_test.go | 34 +++++++++++++ 4 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 internal/aistudio/accounts_lease_test.go create mode 100644 internal/setup/renewal_test.go diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index b0a62cbe..579d8498 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -2007,14 +2007,19 @@ func (l *AccountLease) Retain() func() error { l.operation.Lock() l.holds++ l.operation.Unlock() + var once sync.Once + var err error return func() error { - l.operation.Lock() - defer l.operation.Unlock() - l.holds-- - if l.holds == 0 && l.releaseDeferred { - l.releaseLocked() - } - return l.err + once.Do(func() { + l.operation.Lock() + defer l.operation.Unlock() + l.holds-- + if l.holds == 0 && l.releaseDeferred { + l.releaseLocked() + } + err = l.err + }) + return err } } diff --git a/internal/aistudio/accounts_lease_test.go b/internal/aistudio/accounts_lease_test.go new file mode 100644 index 00000000..99bfcef4 --- /dev/null +++ b/internal/aistudio/accounts_lease_test.go @@ -0,0 +1,61 @@ +package aistudio + +import ( + "errors" + "sync" + "testing" +) + +// TestAccountLeaseRetainEndIsIdempotent 验证同一个 Retain 结束函数重复调用不会重复减少 holds。 +func TestAccountLeaseRetainEndIsIdempotent(t *testing.T) { + lease := &AccountLease{} + end := lease.Retain() + + if got := lease.holds; got != 1 { + t.Fatalf("Retain 后 holds = %d, want 1", got) + } + if err := end(); err != nil { + t.Fatalf("第一次结束租约: %v", err) + } + if err := end(); err != nil { + t.Fatalf("重复结束租约: %v", err) + } + if got := lease.holds; got != 0 { + t.Fatalf("结束函数重复调用后 holds = %d, want 0", got) + } +} + +func TestAccountLeaseRetainEndPreservesErrorOnRepeatedCall(t *testing.T) { + wantErr := errors.New("release failed") + lease := &AccountLease{err: wantErr} + end := lease.Retain() + + for range 2 { + if err := end(); !errors.Is(err, wantErr) { + t.Fatalf("结束函数返回错误 %v, want %v", err, wantErr) + } + } +} + +// TestAccountLeaseRetainEndIsConcurrentIdempotent 验证并发重复调用同一个结束函数仍只生效一次。 +func TestAccountLeaseRetainEndIsConcurrentIdempotent(t *testing.T) { + lease := &AccountLease{} + end := lease.Retain() + + const callers = 16 + var wg sync.WaitGroup + wg.Add(callers) + for range callers { + go func() { + defer wg.Done() + if err := end(); err != nil { + t.Errorf("并发结束租约: %v", err) + } + }() + } + wg.Wait() + + if got := lease.holds; got != 0 { + t.Fatalf("并发重复调用结束函数后 holds = %d, want 0", got) + } +} diff --git a/internal/setup/renewal.go b/internal/setup/renewal.go index bda91be3..5da3efe9 100644 --- a/internal/setup/renewal.go +++ b/internal/setup/renewal.go @@ -207,8 +207,7 @@ func pushRenewal(ctx context.Context, client *http.Client, state *renewalState) } `json:"error"` } failure := fmt.Errorf("远程实例续签失败: HTTP %d", response.StatusCode) - raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20+1)) - if len(raw) <= 1<<20 && json.Unmarshal(raw, &result) == nil && result.Error.Message != "" { + if json.NewDecoder(response.Body).Decode(&result) == nil && result.Error.Message != "" { failure = fmt.Errorf("远程实例续签失败: HTTP %d: %s", response.StatusCode, result.Error.Message) } if result.Error.Code == "pairing_renewal_invalid" || result.Error.Code == "account_not_found" { diff --git a/internal/setup/renewal_test.go b/internal/setup/renewal_test.go new file mode 100644 index 00000000..b235d46b --- /dev/null +++ b/internal/setup/renewal_test.go @@ -0,0 +1,34 @@ +package setup + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Mag1cFall/AIStudio2API/internal/aistudio" +) + +func TestPushRenewalExtractsRevocationCodeFromLargeErrorResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(writer, `{"error":{"message":%q,"code":"pairing_renewal_invalid"}}`, strings.Repeat("x", 1<<20)) + })) + defer server.Close() + + state := renewalState{ + renewal: remoteRenewal{Origin: server.URL, Account: "account", Token: "token"}, + cookies: []aistudio.StateCookie{{Name: "session", Value: "cookie"}}, + mintedAt: time.Now(), + } + + err := pushRenewal(context.Background(), server.Client(), &state) + if !errors.Is(err, errRenewalRevoked) { + t.Fatalf("大错误响应中的撤销码未被识别: %v", err) + } +} From a47e73be55f8536e35c965eeaced90ec39d2de7a Mon Sep 17 00:00:00 2001 From: xiaohai1314-sys <219774556+xiaohai1314-sys@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:44:32 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E7=BB=AD=E7=AD=BE=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E8=A7=A3=E7=A0=81=E9=99=90=208=20MiB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/setup/renewal.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/setup/renewal.go b/internal/setup/renewal.go index 5da3efe9..466eb6b7 100644 --- a/internal/setup/renewal.go +++ b/internal/setup/renewal.go @@ -207,7 +207,8 @@ func pushRenewal(ctx context.Context, client *http.Client, state *renewalState) } `json:"error"` } failure := fmt.Errorf("远程实例续签失败: HTTP %d", response.StatusCode) - if json.NewDecoder(response.Body).Decode(&result) == nil && result.Error.Message != "" { + // 错误响应限 8 MiB,足够容纳带长消息的撤销码,又不会被异常响应撑爆内存 + if json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(&result) == nil && result.Error.Message != "" { failure = fmt.Errorf("远程实例续签失败: HTTP %d: %s", response.StatusCode, result.Error.Message) } if result.Error.Code == "pairing_renewal_invalid" || result.Error.Code == "account_not_found" {