From e53c15cbaa0818bd633caba45ac694f1ee58f358 Mon Sep 17 00:00:00 2001 From: wimi321 <34830250+wimi321@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:16:22 +0800 Subject: [PATCH] feat: integrate official Zhizi Cloud API --- docs/ZHIZI_CLOUD_ENGINE.md | 140 +++-- package.json | 4 + pnpm-lock.yaml | 466 +++++++++++++- scripts/smoke_zhizi_public_api.mjs | 66 ++ scripts/smoke_zhizi_remote.mjs | 130 ++-- src/main/index.ts | 168 ++++- src/main/lib/store.ts | 63 +- src/main/lib/types.ts | 179 +++++- .../services/analysis/runtimeIntegration.ts | 5 +- src/main/services/katago.ts | 32 +- src/main/services/zhiziApiClient.ts | 516 ++++++++++++++++ src/main/services/zhiziCloudAuth.ts | 290 ++------- src/main/services/zhiziConnectionProbe.ts | 26 +- src/main/services/zhiziGtpEngine.ts | 584 +++++------------- src/main/services/zhiziGtpProtocol.ts | 124 ++-- src/main/services/zhiziPayment.ts | 166 +++++ src/main/services/zhiziSocketSession.ts | 149 +---- src/preload/index.ts | 20 +- src/renderer/src/App.tsx | 516 +--------------- .../settings/ZhiziCloudSettingsPanel.tsx | 470 ++++++++++++++ .../src/features/settings/zhizi-cloud.css | 364 +++++++++++ src/renderer/src/global.d.ts | 20 +- tests/first-run-onboarding-contract.test.mjs | 10 +- tests/ikatago-client-engine-contract.test.mjs | 7 +- .../local-analysis-default-contract.test.mjs | 15 +- ...-teaching-engine-pool-v2-contract.test.mjs | 3 +- tests/settings-center-ui-contract.test.mjs | 13 +- tests/zhizi-cloud-engine-contract.test.mjs | 206 ++---- tests/zhizi-official-api.test.mjs | 120 ++++ tests/zhizi-persistent-session.test.mjs | 120 +++- 30 files changed, 3221 insertions(+), 1771 deletions(-) create mode 100644 scripts/smoke_zhizi_public_api.mjs create mode 100644 src/main/services/zhiziApiClient.ts create mode 100644 src/main/services/zhiziPayment.ts create mode 100644 src/renderer/src/features/settings/ZhiziCloudSettingsPanel.tsx create mode 100644 src/renderer/src/features/settings/zhizi-cloud.css create mode 100644 tests/zhizi-official-api.test.mjs diff --git a/docs/ZHIZI_CLOUD_ENGINE.md b/docs/ZHIZI_CLOUD_ENGINE.md index bed647a..7da342b 100644 --- a/docs/ZHIZI_CLOUD_ENGINE.md +++ b/docs/ZHIZI_CLOUD_ENGINE.md @@ -1,90 +1,102 @@ -# 智子云远程算力 +# 智子云官方远程算力 -GoAgent 默认使用本机 KataGo 分析。智子云是手动启用的远程算力模式:只有你在设置里明确选择 `智子云远程算力:直连`,GoAgent 才会把当前局面发送到智子云。 - -它和普通 iKataGo world 账号不是同一条登录路径:GoAgent 会调用智子云登录接口获取 token,再通过智子云 Socket.IO 通道启动远端 KataGo GTP,用 `kata-analyze` 读取候选点、胜率、目差和 PV。 +GoAgent 依据智子云公开的 OpenAPI 1.0、认证、引擎会话和支付规范接入远程 KataGo。**本机 KataGo 始终是默认引擎**。登录、购买 VIP 或充值后,GoAgent 都不会自动上传棋局;只有用户在“设置 → 智子云 → 算力”中确认启用,才会发送当前棋盘状态。 ```text -GoAgent -> 智子云登录 -> Socket.IO 远程 KataGo -> GTP kata-analyze +登录智子云 → 查看账户和商品 → 可选购买/充值 → 检测远程算力 → 用户确认启用 ``` -## 适合谁 +## 普通用户怎么用 + +1. 打开“设置 → 智子云 → 账户与充值”。 +2. 使用手机号或邮箱进行密码登录;也可以发送验证码登录。验证码登录会在账号不存在时自动注册。 +3. 查看 VIP、余额、昨日消费和当前连接数。 +4. 如有需要,选择实时返回的 VIP 商品或充值金额,用微信扫描二维码。支付窗口每两秒更新一次状态,关闭窗口后立即停止查询。 +5. 打开“算力”,选择档位并点击检测。检测成功后,再确认启用智子云。 +6. 随时点击“回到本机分析”,立即停止远程任务并恢复本机 KataGo。 + +账户或支付无法在 GoAgent 内处理时,可打开[智子官方 App 下载页](https://zhizigo.com/download)。 + +## 算力选择 + +GoAgent 只允许智子官方公开的参数,不接受任意命令行附加参数: -- 本机 KataGo 速度慢,希望借用智子云算力的用户。 -- 不想额外安装或启动智子围棋电脑版,只希望在 GoAgent 里登录后直接使用远程算力的用户。 -- 想保持 GoAgent 老师讲解、棋谱库、知识库和胜率图流程不变,只把分析引擎换成远程算力的用户。 +| 项目 | 可选值 | +| --- | --- | +| GPU | `vip-share`、`1x`、`3x`、`6x`、`12x`、`24x` | +| 引擎 | `katago-TENSORRT`、`katago-CUDA` | +| 权重 | `18bnbt`、`fdx`、`28bnbt` | -## 设置方式 +`platform=all` 和 `engine-type=go` 由程序固定设置。 -在 GoAgent 设置页打开“分析引擎”: +- 有效 VIP 默认推荐 `vip-share`,使用 VIP 共享权益。 +- 非 VIP 默认推荐 `1x`,属于按量计费,启用前会显示余额并要求确认。 +- 更高独享档位必须由用户主动选择和确认,GoAgent 不会因为失败自动升级到更贵档位。 +- 通用 iKataGo 是独立的高级兼容能力,不是智子云的自动兜底。 -1. 算力类型默认选择 `VIP 共享引擎`,GoAgent 会提交 `--gpu-type vip-share`。这是智子管理员确认的 VIP 包月引擎,不会额外按时间扣独享费用。 - - `独享 1x / 3x / 6x` 对应 `--gpu-type 1x / 3x / 6x`,属于按算力和时间计费的独享 worker。 - - 如果你只是购买了 VIP,请不要选择独享档位。 -2. 填写智子云主账号手机号或邮箱和密码,点击“登录智子云”。 - - GoAgent 会调用智子云登录接口获取 token。 - - 登录成功后,GoAgent 会把 token 保存到本地加密存储。 - - 登录本身不会中断当前本机分析,也不会立即上传局面。 - - 后续只需要启动 GoAgent,不需要先启动其它应用。 - - `zz-` 开头的是智子连接账号,不是主账号登录 token。它需要先在智子官方账号体系里绑定,不能直接填在 GoAgent 的登录框里换取远程算力 token。 -3. 点击“检测并启用”。GoAgent 会真实启动远程 KataGo,并等待候选点、胜率、目差和实时搜索速度返回。只有检测通过后才切换到智子云;检测失败时仍保持本机分析。 -4. 如果密码登录提示密码不正确,可以点击“发送验证码”,收到短信后用“验证码登录”。 - - 这和智子 Web 端的 `send-code` / `fast-login` 流程一致。 - - 验证码只用于本次登录;GoAgent 不会长期保存验证码。 -5. Token 通常不需要手动填写。只有你已经从其它方式拿到了 token,才使用“高级:Token”。 -6. `zz-ikatago 路径` 是兼容旧连接器的高级选项,普通用户可以不填。 -7. 附加参数一般留空;只有你明确知道智子云 Socket.IO 连接参数时再填写。算力类型优先用设置页下拉框选择,避免把 VIP 共享误写成独享计费参数。 +## 账户与支付 -`自动` 模式现在是本机优先的默认模式,不会因为本机分析失败就自动上传局面到智子云。需要远程算力时,请手动启用 `智子云远程算力:直连`。 +GoAgent 仅调用公开接口: -## 连接与分析机制 +- 密码登录、验证码发送、验证码登录/注册、重置密码。 +- 账户资料、余额、使用记录和入账记录。 +- 实时会员商品目录。 +- 微信 Native Pay 订单创建和订单状态查询。 -- 检测通过后,GoAgent 会复用同一个远程 Socket.IO / GTP 会话,避免每次切换手数都重新排队启动 worker。 -- Socket 断线时会自动重连;局面同步或分析过程中断线,会重新建立会话并重新同步当前局面,旧结果不会覆盖新局面。 -- 分析按真实 `visits` 达到目标后停止,不再用固定等待时间猜测分析是否完成。 -- `stdout` / `stderr` 的字符串、Buffer、ArrayBuffer 和 Socket.IO Buffer JSON 都会按 UTF-8 解码。 -- VIP 共享会话空闲 5 分钟后释放;独享 1x / 3x / 6x 空闲 90 秒后释放,减少无效占用。 -- 切回本机分析或退出登录时,会立即停止任务并释放远程会话。 +VIP 商品名和价格每次从官方商品接口获取。VIP 订单严格使用接口返回的商品名和以分为单位的整数价格,不硬编码价格。余额充值支持 ¥10、¥30、¥50、¥100 和最多两位小数的自定义金额。 -## 退出和重新登录 +主进程将官方返回的 `codeURL` 原样编码成二维码图片;页面拿不到原始支付载荷。创建订单超时不会自动重试,以免生成重复订单。支付成功后会刷新账户、余额和推荐算力,但仍不会自动启用远程分析。 -如果刚购买套餐、充值或修改了智子云账号状态,但 GoAgent 仍提示额度不足或远程算力不可用,可以在设置页点击 `退出智子云登录`。GoAgent 会: +## 远程分析协议 -1. 停止当前正在运行的 KataGo/智子云分析任务。 -2. 清除本地保存的智子云 token。 -3. 自动切回 `自动` 分析模式,并关闭“本机慢时自动使用智子云”,避免旧 token 或旧连接器路径继续重试。 +1. GoAgent 使用 Bearer Token 请求一次性的 Socket.IO 会话令牌。 +2. 使用官方返回的 URL、路径 `/socket.io.v4` 和查询参数 `zz-socketio-token` 建立连接。 +3. 只接受官方 `ready` 事件作为引擎就绪信号。 +4. 使用带数字 ID 的 GTP 命令逐条确认 `boardsize`、规则、贴目、清盘和完整手顺。 +5. 启动 `kata-analyze`,将候选点、胜率、目差、PV 和实时搜索速度转换成 GoAgent 的统一分析数据。 +6. 断线后废弃旧 Socket Token 和旧棋盘状态,获取新令牌、建立新连接并重放完整棋局。 -然后使用账号密码或短信验证码重新登录,再点击“检测并启用”。 +每个会话都有 generation。断线前迟到的输出不会写入新局面。用户取消、切回本机或退出登录时,会停止分析并释放远程会话。失败只进行有限重连,不会自动切换更贵的算力档位。 -## 和 iKataGo 的区别 +## 隐私与本地存储 -- iKataGo 路径使用 `ikatago -- analysis`,面向普通 iKataGo world / 自建远程服务。 -- 智子云路径优先使用 GoAgent 直连 Socket.IO;旧版 `zz-ikatago` 本地连接器仅作为兼容备选。 +- 默认 `auto` 模式只使用本机 KataGo。 +- 智子云密码和验证码只用于当前请求,不长期保存。 +- 登录 Token 保存在 GoAgent 的本地加密存储,不进入普通设置、renderer、日志或错误报告。 +- Socket Token 和支付原始响应只存在于主进程。 +- 只有用户明确启用智子云后,当前棋盘状态才会发送到智子云。 +- 退出登录会清除 Token、终止远程分析并切回本机 `auto` 模式。 -如果你把智子账号密码直接填到 iKataGo world,会出现用户配置 404 或无法登录,这是两套远程服务入口不同导致的。 +旧版本的 `zhiziClientBin`、`zhiziExtraArgs` 和“本机慢时自动切远程”设置会在迁移时清理,不再参与运行。 -## 隐私边界 +## 验证 -只有在以下情况,GoAgent 才会把局面发送到智子云: +匿名契约检查不会登录或创建订单: + +```bash +pnpm smoke:zhizi-public-api +``` + +真实远程 smoke 使用本机已经保存的登录状态,只读取账户/余额并执行一次 64 visits 分析,不创建支付订单: + +```bash +GOAGENT_ZHIZI_REAL=1 pnpm smoke:zhizi-remote +``` -- 引擎模式明确选择 `智子云远程算力:直连`。 -- 或者用户在高级选项中明确启用了“本机测速低于阈值时使用智子云”,并且本机测速低于阈值。 +真实支付验收只能由用户主动创建订单并扫码确认,自动化测试不得付款。 -默认安装、默认 `自动` 模式、以及旧版本升级后的首次启动,都会优先回到本机分析。 +## 故障处理 -GoAgent 不会把智子 token 写入普通设置文件,也不会在日志中打印 token。智子云密码只用于本次登录请求;登录成功后 GoAgent 保存 token,而不是长期保存密码。 +- **登录失效**:重新登录,旧 Token 会被替换。 +- **VIP 共享不可用**:确认 VIP 尚未过期,刷新账户后重试;持续失败时在智子官方 App 处理权益问题。 +- **余额不足**:独享档位按量计费,请充值或改用本机分析。 +- **暂无算力**:当前没有空闲资源,可以稍后重试;GoAgent 不会自动升级档位。 +- **网络中断**:GoAgent 会使用新会话令牌有限重连并重放棋局;失败后可重试或回到本机。 +- **支付状态不明**:不要重复点击创建订单,先保留当前二维码并刷新状态;关闭后再由用户决定是否创建新订单。 -## 故障排查 +官方依据: -- `智子云未配置完整`:还没有在 GoAgent 中登录智子云,请输入账号密码或短信验证码登录。 -- `智子云未登录`:当前选择了 `智子云远程算力:直连`,但本地没有可用 token。请在设置页用账号密码或短信验证码登录;普通用户不需要填写 `zz-ikatago` 路径。 -- `这是智子云连接账号,不是可直接登录的主账号`:请改用智子云主账号手机号或邮箱登录。`zz-` 连接账号不能直接换取 GoAgent 远程算力 token。 -- `密码不正确`:智子官方密码接口拒绝了这组密码。可以改用“发送验证码”再“验证码登录”。 -- `验证码不正确或已过期`:重新发送验证码后再登录。 -- `当前没有空闲算力`:GoAgent 会按有限次数自动重试。这通常是 worker 暂时繁忙,不等于账号余额不足。 -- VIP 共享返回 `not_enough_credit`:GoAgent 会明确提示“VIP 权益/连接账号未同步”,不会把它误报成独享余额不足。先退出并重新登录;持续失败时请让智子官方检查 VIP 与连接账号的 worker 权益。 -- 独享 1x / 3x / 6x 返回 `not_enough_credit`:这是按量档位的余额或档位问题,请在智子官方 App 检查。设置页可直接打开 [智子官方 App 下载页](https://zhizigo.com/download)。 -- 官方 Postman 中的 `connectAccount/login` 只用于验证连接账号凭据,不返回 GoAgent 直连所需的 token,也不会替代主账号登录流程。 -- `智子云 KataGo 启动超时`:检查 token 是否有效,或在 GoAgent 中重新登录。 -- `智子云 GTP 命令失败`:可能是远端引擎尚未 ready、账号状态异常,或智子云当前连接不可用。 -- 如果本机 KataGo 可用,且不是强制智子云模式,GoAgent 会在智子云失败后回退本机 KataGo。 +- [OpenAPI 1.0](https://github.com/kinfkong/zhizi-open-api/blob/main/openapi/zhizi-public-api.yaml) +- [认证规范](https://github.com/kinfkong/zhizi-open-api/blob/main/docs/zh-CN/guides/authentication.md) +- [引擎会话规范](https://github.com/kinfkong/zhizi-open-api/blob/main/docs/zh-CN/guides/engine-sessions.md) +- [支付规范](https://github.com/kinfkong/zhizi-open-api/blob/main/docs/zh-CN/guides/payments.md) diff --git a/package.json b/package.json index aab14c2..521fb29 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "check:artifacts": "node scripts/package_artifact_smoke.mjs --mode=dev", "smoke:teacher-llm": "pnpm build && node scripts/teacher_llm_smoke.mjs", "smoke:teacher-llm:real": "pnpm build && node scripts/teacher_llm_real_smoke.mjs", + "smoke:zhizi-public-api": "node scripts/smoke_zhizi_public_api.mjs", "smoke:zhizi-remote": "node scripts/smoke_zhizi_remote.mjs", "rc:check": "node scripts/p0_release_candidate_check.mjs --mode=dev", "rc:artifacts": "node scripts/verify_release_artifacts.mjs --mode=dev", @@ -84,6 +85,7 @@ "electron-store": "^10.0.1", "kokoro-js": "^1.2.1", "openai": "^6.3.0", + "qrcode": "^1.5.4", "react": "^19.1.1", "react-dom": "^19.1.1", "socket.io-client": "^4.8.3", @@ -91,6 +93,7 @@ }, "devDependencies": { "@types/node": "^24.5.2", + "@types/qrcode": "^1.5.6", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.2", @@ -100,6 +103,7 @@ "eslint": "^9.35.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "tsx": "^4.23.5", "typescript": "^5.9.2", "vite": "^7.1.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ab4942..59b9626 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: openai: specifier: ^6.3.0 version: 6.33.0(ws@8.20.1)(zod@4.3.6) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.1.1 version: 19.2.4 @@ -33,6 +36,9 @@ importers: '@types/node': specifier: ^24.5.2 version: 24.12.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.1.13 version: 19.2.14 @@ -41,7 +47,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4)) + version: 5.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4)) electron: specifier: ^37.4.0 version: 37.10.3 @@ -50,7 +56,7 @@ importers: version: 26.8.1(electron-builder-squirrel-windows@26.8.1) electron-vite: specifier: ^4.0.0 - version: 4.0.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4)) + version: 4.0.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4)) eslint: specifier: ^9.35.0 version: 9.39.4(jiti@2.6.1) @@ -60,12 +66,15 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + tsx: + specifier: ^4.23.5 + version: 4.23.5 typescript: specifier: ^5.9.2 version: 5.9.3 vite: specifier: ^7.1.5 - version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4) + version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4) website: dependencies: @@ -74,7 +83,7 @@ importers: version: 0.9.9(prettier@3.8.3)(typescript@5.9.3) astro: specifier: ^5.16.4 - version: 5.18.1(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.4) + version: 5.18.1(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.1)(tsx@4.23.5)(typescript@5.9.3)(yaml@2.8.4) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -295,6 +304,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -307,6 +322,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -319,6 +340,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -331,6 +358,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -343,6 +376,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -355,6 +394,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -367,6 +412,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -379,6 +430,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -391,6 +448,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -403,6 +466,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -415,6 +484,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -427,6 +502,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -439,6 +520,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -451,6 +538,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -463,6 +556,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -475,6 +574,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -487,6 +592,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -499,6 +610,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -511,6 +628,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -523,6 +646,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -535,6 +664,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -547,6 +682,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -559,6 +700,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -571,6 +718,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -583,6 +736,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -595,6 +754,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1122,6 +1287,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1393,6 +1561,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@8.0.0: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} @@ -1459,6 +1631,9 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1580,6 +1755,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1640,6 +1819,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -1810,6 +1992,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1930,6 +2117,10 @@ packages: filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2315,6 +2506,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2715,6 +2910,10 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2723,6 +2922,10 @@ packages: resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} engines: {node: '>=18'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -2739,6 +2942,10 @@ packages: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2805,6 +3012,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -2860,6 +3071,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -2947,6 +3163,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -3031,6 +3250,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3220,6 +3442,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.5: + resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3590,6 +3817,9 @@ packages: when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -3612,6 +3842,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3650,6 +3884,9 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3678,10 +3915,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -4075,156 +4320,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.4': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -4674,6 +4997,10 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.12.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -4698,7 +5025,7 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -4706,7 +5033,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -4874,7 +5201,7 @@ snapshots: astral-regex@2.0.0: optional: true - astro@5.18.1(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.4): + astro@5.18.1(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.1)(tsx@4.23.5)(typescript@5.9.3)(yaml@2.8.4): dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/internal-helpers': 0.7.6 @@ -4931,8 +5258,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5 vfile: 6.0.3 - vite: 6.4.2(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4) - vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4)) + vite: 6.4.2(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4) + vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -5118,6 +5445,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + camelcase@8.0.0: {} caniuse-lite@1.0.30001782: {} @@ -5167,6 +5496,12 @@ snapshots: string-width: 4.2.3 optional: true + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -5281,6 +5616,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -5333,6 +5670,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -5452,7 +5791,7 @@ snapshots: electron-to-chromium@1.5.329: {} - electron-vite@4.0.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4)): + electron-vite@4.0.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -5460,7 +5799,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4) + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -5605,6 +5944,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -5736,6 +6104,11 @@ snapshots: dependencies: minimatch: 5.1.9 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -6183,6 +6556,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -6770,6 +7147,10 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -6778,6 +7159,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -6791,6 +7176,8 @@ snapshots: p-timeout@6.1.4: {} + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -6847,6 +7234,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@5.0.0: {} + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -6908,6 +7297,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quick-lru@5.1.1: {} radix3@1.1.2: {} @@ -7021,6 +7416,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -7133,6 +7530,8 @@ snapshots: dependencies: type-fest: 0.13.1 + set-blocking@2.0.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -7372,6 +7771,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.23.5: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -7517,7 +7922,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.2(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4): + vite@6.4.2(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -7529,9 +7934,10 @@ snapshots: '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 + tsx: 4.23.5 yaml: 2.8.4 - vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4): + vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -7543,11 +7949,12 @@ snapshots: '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 + tsx: 4.23.5 yaml: 2.8.4 - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4)): + vitefu@1.1.3(vite@6.4.2(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4)): optionalDependencies: - vite: 6.4.2(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.12.0)(jiti@2.6.1)(tsx@4.23.5)(yaml@2.8.4) volar-service-css@0.0.70(@volar/language-service@2.4.28): dependencies: @@ -7654,6 +8061,8 @@ snapshots: when-exit@2.1.5: {} + which-module@2.0.1: {} + which-pm-runs@1.1.0: {} which@2.0.2: @@ -7670,6 +8079,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -7698,6 +8113,8 @@ snapshots: xxhash-wasm@1.1.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -7724,8 +8141,27 @@ snapshots: yaml@2.8.4: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/scripts/smoke_zhizi_public_api.mjs b/scripts/smoke_zhizi_public_api.mjs new file mode 100644 index 0000000..80124f3 --- /dev/null +++ b/scripts/smoke_zhizi_public_api.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' + +const API_BASE = 'https://www.zhizigo.com' +const OPENAPI_URL = 'https://raw.githubusercontent.com/kinfkong/zhizi-open-api/main/openapi/zhizi-public-api.yaml' +const timeoutMs = 20_000 + +async function fetchWithTimeout(url) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { + headers: { Accept: url.endsWith('.yaml') ? 'text/yaml' : 'application/json' }, + signal: controller.signal + }) + } finally { + clearTimeout(timer) + } +} + +try { + const [specResponse, productResponse] = await Promise.all([ + fetchWithTimeout(OPENAPI_URL), + fetchWithTimeout(`${API_BASE}/api/cluster/product?type=MEMBERSHIP`) + ]) + assert.equal(specResponse.ok, true, `OpenAPI request returned HTTP ${specResponse.status}`) + assert.equal(productResponse.ok, true, `Product request returned HTTP ${productResponse.status}`) + + const spec = await specResponse.text() + for (const path of [ + '/api/cluster/account/login', + '/api/cluster/account/fetch-socketio-token', + '/api/cluster/usage/my-usages', + '/api/pay/orders' + ]) { + assert.equal(spec.includes(path), true, `OpenAPI is missing ${path}`) + } + + const products = await productResponse.json() + assert.equal(Array.isArray(products), true, 'Membership product response must be an array.') + assert.equal(products.length > 0, true, 'Membership product response is empty.') + const allowed = new Set([ + 'MEMBERSHIP_1_MONTH', + 'MEMBERSHIP_3_MONTH', + 'MEMBERSHIP_6_MONTH', + 'MEMBERSHIP_12_MONTH' + ]) + for (const product of products) { + assert.equal(allowed.has(product?.name), true, `Unexpected membership product: ${product?.name}`) + assert.equal(product?.type, 'MEMBERSHIP') + assert.equal(Number.isInteger(product?.price) && product.price > 0, true) + } + + console.log(JSON.stringify({ + status: 'passed', + openapi: OPENAPI_URL, + apiBase: API_BASE, + products: products.map(({ name, price }) => ({ name, priceFen: price })) + }, null, 2)) +} catch (error) { + console.error(JSON.stringify({ + status: 'failed', + error: error instanceof Error ? error.message : String(error) + }, null, 2)) + process.exitCode = 1 +} diff --git a/scripts/smoke_zhizi_remote.mjs b/scripts/smoke_zhizi_remote.mjs index ab6e70b..79a751f 100644 --- a/scripts/smoke_zhizi_remote.mjs +++ b/scripts/smoke_zhizi_remote.mjs @@ -3,13 +3,22 @@ import { createDecipheriv, scryptSync } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import os from 'node:os' import { join } from 'node:path' +import { tsImport } from 'tsx/esm/api' -import { +const protocol = await tsImport('../src/main/services/zhiziGtpProtocol.ts', import.meta.url) +const api = await tsImport('../src/main/services/zhiziApiClient.ts', import.meta.url) +const { ZhiziPersistentSession } = await tsImport( + '../src/main/services/zhiziSocketSession.ts', + import.meta.url +) + +const { buildZhiziRemoteArgs, + findGtpCommandResponse, + formatGtpCommand, parseKataAnalyzeInfo, zhiziAnalysisReachedVisits -} from '../src/main/services/zhiziGtpProtocol.ts' -import { ZhiziPersistentSession } from '../src/main/services/zhiziSocketSession.ts' +} = protocol const strict = process.env.GOAGENT_ZHIZI_REAL === '1' const appHome = process.env.GOAGENT_APP_HOME || join(os.homedir(), '.goagent') @@ -34,66 +43,65 @@ function decryptLocalSecret(secret) { ]).toString('utf8') } -function responseCount(text) { - return (text.match(/\r?\n\r?\n/g) ?? []).length -} - -async function sendSetup(session, signal, commands) { +async function sendCommand(session, signal, id, command, timeoutMs = 60_000) { const start = session.output().stdout.length const disconnectVersion = session.captureDisconnectVersion() - session.send(`${commands.join('\n')}\n`) + const generation = session.captureGeneration() + session.send(formatGtpCommand(id, command)) + let response await session.waitUntil( - () => responseCount(session.output().stdout.slice(start)) >= commands.length, - 15_000, - 'Zhizi smoke setup', + () => { + if (session.captureGeneration() !== generation) return false + response = findGtpCommandResponse(session.output().stdout.slice(start), id) + return Boolean(response) + }, + timeoutMs, + `Zhizi smoke command ${id}`, signal, disconnectVersion ) - const output = session.output().stdout.slice(start) - if (/(?:^|\n)\?/.test(output.trim())) { - throw new Error(`GTP setup rejected: ${output.trim().slice(0, 300)}`) - } + if (!response.ok) throw new Error(`GTP command ${id} rejected: ${response.firstLine}`) } -async function analyze(session, signal, id, moves) { +async function analyze(session, signal, moves) { session.clearOutput() - await sendSetup(session, signal, [ + let commandId = 1 + for (const command of [ 'boardsize 19', 'kata-set-rules chinese', 'komi 7.5', 'clear_board', ...moves.map(([color, point]) => `play ${color} ${point}`) - ]) + ]) { + await sendCommand(session, signal, commandId, command) + commandId += 1 + } + + const player = moves.at(-1)?.[0] === 'B' ? 'W' : 'B' const start = session.output().stdout.length const disconnectVersion = session.captureDisconnectVersion() - const player = moves.at(-1)?.[0] === 'B' ? 'W' : 'B' - session.send(`kata-analyze ${player} 25 rootInfo true maxmoves 8\n`) + const generation = session.captureGeneration() + const analysisId = commandId + session.send(formatGtpCommand(analysisId, `kata-analyze ${player} 25 rootInfo true maxmoves 8`)) let response const startedAt = Date.now() await session.waitUntil( () => { + if (session.captureGeneration() !== generation) return false const output = session.output().stdout.slice(start) if (!/\binfo\s+/.test(output)) return false - const parsed = parseKataAnalyzeInfo(output, player, id) + const parsed = parseKataAnalyzeInfo(output, player, 'zhizi-real-smoke') if (!parsed.moveInfos?.length) return false response = parsed return zhiziAnalysisReachedVisits(parsed, 64) }, - 30_000, + 45_000, 'Zhizi smoke analysis', signal, disconnectVersion ) - session.send('stop\n') - await session.waitUntil( - () => responseCount(session.output().stdout.slice(start)) >= 1, - 10_000, - 'Zhizi smoke stop', - signal, - disconnectVersion - ) + session.send(formatGtpCommand(analysisId + 1, 'stop')) return { - id, elapsedMillis: Date.now() - startedAt, topMove: response.moveInfos[0].move, visits: response.moveInfos.reduce((sum, move) => sum + Number(move.visits ?? 0), 0), @@ -103,7 +111,7 @@ async function analyze(session, signal, id, moves) { } if (!strict) { - skipped('Set GOAGENT_ZHIZI_REAL=1 to run the real Zhizi cloud smoke test.') + skipped('Set GOAGENT_ZHIZI_REAL=1 to run the saved-account remote smoke test.') } else if (![settingsPath, secretsPath, keyPath].every(existsSync)) { skipped('GoAgent local settings or secret files are missing.') } else { @@ -111,40 +119,50 @@ if (!strict) { const secrets = JSON.parse(readFileSync(secretsPath, 'utf8')) const accountToken = decryptLocalSecret(secrets.zhiziToken) if (!accountToken) { - skipped('No locally saved Zhizi token is available.') + skipped('No locally saved Zhizi login is available. Log in from GoAgent first.') } else { - const gpuType = String(settings.zhiziGpuType || 'vip-share') - const args = buildZhiziRemoteArgs({ - zhiziGpuType: gpuType, - zhiziExtraArgs: String(settings.zhiziExtraArgs || '') - }) - const session = new ZhiziPersistentSession({ accountToken, args, gpuType }) + const profile = { + zhiziGpuType: settings.zhiziGpuType || 'vip-share', + zhiziKataName: settings.zhiziKataName || 'katago-TENSORRT', + zhiziKataWeight: settings.zhiziKataWeight || '28bnbt' + } + const args = buildZhiziRemoteArgs(profile) const controller = new AbortController() + const session = new ZhiziPersistentSession({ + accountToken, + args, + gpuType: profile.zhiziGpuType + }) try { - const results = await session.runExclusive(controller.signal, async (channel) => [ - await analyze(channel, controller.signal, 'smoke-1', [ - ['B', 'D4'], - ['W', 'Q16'], - ['B', 'Q4'] - ]), - await analyze(channel, controller.signal, 'smoke-2', [ + const account = await api.getZhiziAccountOverview(accountToken) + if (!account.tokenValid) throw new Error('Saved Zhizi login is no longer valid.') + const result = await session.runExclusive(controller.signal, (channel) => analyze( + channel, + controller.signal, + [ ['B', 'D4'], ['W', 'Q16'], ['B', 'Q4'], ['W', 'D16'] - ]) - ]) + ] + )) const telemetry = session.telemetry() console.log(JSON.stringify({ status: 'passed', - gpuType, - connectionCount: telemetry.connectionCount, - sessionReadyMillis: telemetry.lastReadyMillis, - results + account: { + identifier: account.identifierMasked, + membership: account.isMembership, + balanceAvailable: Boolean(account.balance) + }, + profile, + session: { + state: telemetry.state, + connectionCount: telemetry.connectionCount, + generation: telemetry.generation, + readyMillis: telemetry.lastReadyMillis + }, + result }, null, 2)) - if (telemetry.connectionCount !== 1) { - throw new Error(`Expected one persistent connection, received ${telemetry.connectionCount}.`) - } } catch (error) { console.error(JSON.stringify({ status: 'failed', diff --git a/src/main/index.ts b/src/main/index.ts index 16a373a..f80172a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,42 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell, type Conte import { isAbsolute, relative, resolve, join } from 'node:path' import { appHome, findGame, getGames, getIkatagoPassword, getSettings, getTtsCustomApiKey, getTtsVolcengineAccessToken, getTtsVolcengineApiKey, getZhiziToken, hasIkatagoPassword, hasLlmApiKey, hasTtsCustomApiKey, hasTtsVolcengineAccessToken, hasTtsVolcengineApiKey, hasZhiziToken, replaceSettings, setSettings, upsertGames } from './lib/store' import { BRAND_NAME } from '@shared/brand' -import type { AnalyzeGameQuickRequest, AnalyzePositionRequest, AnalyzeTrialPositionRequest, AppSettings, DashboardData, FoxSyncRequest, KataGoAssetInstallRequest, KataGoBenchmarkCancelRequest, KataGoBenchmarkRequest, KataGoBenchmarkStartRequest, KataGoCancelAnalysisRequest, LibraryDeleteRequest, LlmModelsListRequest, LlmSettingsTestRequest, ReviewRequest, TeacherBoardImageRenderImage, TeacherBoardImageRenderRequest, TeacherBoardImageRenderResponse, TeacherChatMessage, TeacherRunCancelRequest, TeacherRunRequest, ZhiziCloudConnectionTestResult, ZhiziCloudLoginCodeRequest, ZhiziCloudLoginRequest, ZhiziCloudLoginResult, ZhiziCloudSendCodeRequest, ZhiziCloudSendCodeResult } from './lib/types' +import type { + AnalyzeGameQuickRequest, + AnalyzePositionRequest, + AnalyzeTrialPositionRequest, + AppSettings, + DashboardData, + FoxSyncRequest, + KataGoAssetInstallRequest, + KataGoBenchmarkCancelRequest, + KataGoBenchmarkRequest, + KataGoBenchmarkStartRequest, + KataGoCancelAnalysisRequest, + LibraryDeleteRequest, + LlmModelsListRequest, + LlmSettingsTestRequest, + ReviewRequest, + TeacherBoardImageRenderImage, + TeacherBoardImageRenderRequest, + TeacherBoardImageRenderResponse, + TeacherChatMessage, + TeacherRunCancelRequest, + TeacherRunRequest, + ZhiziAccountData, + ZhiziCloudConnectionTestResult, + ZhiziCloudLoginCodeRequest, + ZhiziCloudLoginRequest, + ZhiziCloudLoginResult, + ZhiziCloudResetPasswordRequest, + ZhiziCloudSendCodeRequest, + ZhiziCloudSendCodeResult, + ZhiziCreditPage, + ZhiziEngineProfile, + ZhiziPaymentCreateRequest, + ZhiziPaymentSession, + ZhiziUsagePage +} from './lib/types' import { importSgfFile, readGameRecord } from './services/sgf' import { ensureFoxGameDownloaded, syncFoxGames } from './services/fox' import { runReview } from './services/review' @@ -30,8 +65,25 @@ import { } from './services/studentProfile' import { archiveTeacherSession, createTeacherSession, deleteTeacherSession, getActiveTeacherSession, listTeacherSessions, updateTeacherSessionMessages } from './services/teacherSession' import { clearTtsCache, inspectTtsAssets, listTtsVoices, synthesizeTts, testTtsSettings } from './services/tts' -import { loginZhiziCloudByCode, loginZhiziCloudByPassword, sendZhiziCloudLoginCode, ZHIZI_OFFICIAL_APP_DOWNLOAD_URL } from './services/zhiziCloudAuth' +import { + loginZhiziCloudByCode, + loginZhiziCloudByPassword, + resetZhiziCloudPassword, + sendZhiziCloudLoginCode, + ZHIZI_OFFICIAL_APP_DOWNLOAD_URL +} from './services/zhiziCloudAuth' +import { + getZhiziAccountOverview, + listZhiziCredits, + listZhiziMembershipProducts, + listZhiziUsages +} from './services/zhiziApiClient' import { probeZhiziCloudConnection } from './services/zhiziConnectionProbe' +import { + cancelZhiziPaymentSession, + createZhiziPaymentSession, + refreshZhiziPaymentSession +} from './services/zhiziPayment' import { resetZhiziPersistentSession } from './services/zhiziSocketSession' let mainWindow: BrowserWindow | null = null @@ -122,7 +174,8 @@ function zhiziSessionConfigurationChanged(before: AppSettings, after: AppSetting return ( before.zhiziToken !== after.zhiziToken || before.zhiziGpuType !== after.zhiziGpuType || - before.zhiziExtraArgs !== after.zhiziExtraArgs || + before.zhiziKataName !== after.zhiziKataName || + before.zhiziKataWeight !== after.zhiziKataWeight || (before.katagoEngineMode === 'zhizi' && after.katagoEngineMode !== 'zhizi') ) } @@ -546,20 +599,17 @@ app.whenReady().then(() => { hasPassword: hasIkatagoPassword(), password: getIkatagoPassword() })) - ipcMain.handle('zhizi:get-saved-token', async () => ({ - hasToken: hasZhiziToken(), - token: getZhiziToken() - })) ipcMain.handle('zhizi:login-password', async (_event, payload: ZhiziCloudLoginRequest): Promise => { const result = await loginZhiziCloudByPassword(payload) resetZhiziPersistentSession() setSettings({ - zhiziUsername: payload.phone.trim(), - zhiziToken: result.token + zhiziUsername: payload.identifier.value.trim(), + zhiziToken: result.token, + katagoEngineMode: 'auto' }) return { ok: true, - message: `${result.message} 请点击“检测并启用”,确认远程引擎真正可用。`, + message: `${result.message} 远程算力尚未启用,你可以先查看账户或检测连接。`, hasToken: true, dashboard: await dashboard() } @@ -575,27 +625,85 @@ app.whenReady().then(() => { const result = await loginZhiziCloudByCode(payload) resetZhiziPersistentSession() setSettings({ - zhiziUsername: payload.phone.trim(), - zhiziToken: result.token + zhiziUsername: payload.identifier.value.trim(), + zhiziToken: result.token, + katagoEngineMode: 'auto' }) return { ok: true, - message: `${result.message} 请点击“检测并启用”,确认远程引擎真正可用。`, + message: `${result.message} 远程算力尚未启用,你可以先查看账户或检测连接。`, hasToken: true, dashboard: await dashboard() } }) + ipcMain.handle('zhizi:reset-password', async (_event, payload: ZhiziCloudResetPasswordRequest): Promise => { + const result = await resetZhiziCloudPassword(payload) + resetZhiziPersistentSession() + setSettings({ + zhiziUsername: payload.identifier.value.trim(), + zhiziToken: result.token, + katagoEngineMode: 'auto' + }) + return { + ok: true, + message: `${result.message} 当前仍使用本机分析。`, + hasToken: true, + dashboard: await dashboard() + } + }) + ipcMain.handle('zhizi:account-data', async (): Promise => { + const token = getZhiziToken().trim() + const productsPromise = listZhiziMembershipProducts() + if (!token) { + return { + overview: { + tokenValid: false, + isMembership: false, + recommendedGpuType: '1x' + }, + products: await productsPromise + } + } + const [overview, products] = await Promise.all([ + getZhiziAccountOverview(token), + productsPromise + ]) + return { overview, products } + }) + ipcMain.handle('zhizi:usages', async (_event, page = 0, pageSize = 20): Promise => { + const token = getZhiziToken().trim() + if (!token) throw new Error('请先登录智子云。') + return listZhiziUsages(token, page, pageSize) + }) + ipcMain.handle('zhizi:credits', async (_event, page = 0, pageSize = 20): Promise => { + const token = getZhiziToken().trim() + if (!token) throw new Error('请先登录智子云。') + return listZhiziCredits(token, page, pageSize) + }) + ipcMain.handle('zhizi:payment-create', async (_event, payload: ZhiziPaymentCreateRequest): Promise => { + const token = getZhiziToken().trim() + if (!token) throw new Error('请先登录智子云。') + return createZhiziPaymentSession(token, payload) + }) + ipcMain.handle('zhizi:payment-refresh', async (_event, orderId: string): Promise => { + const token = getZhiziToken().trim() + if (!token) throw new Error('请先登录智子云。') + return refreshZhiziPaymentSession(token, orderId) + }) + ipcMain.handle('zhizi:payment-cancel', async (_event, orderId: string): Promise => + cancelZhiziPaymentSession(orderId) + ) ipcMain.handle('zhizi:logout', async (): Promise => { cancelKataGoAnalysis({}) resetZhiziPersistentSession() setSettings({ zhiziToken: '', - katagoEngineMode: 'auto', - zhiziUseWhenLocalSlow: false + zhiziUsername: '', + katagoEngineMode: 'auto' }) return { ok: true, - message: '已退出智子云登录,已清除本地 token,并切回自动分析模式。重新登录后会自动连接智子云。', + message: '已退出智子云并切回本机分析。', hasToken: false, dashboard: await dashboard() } @@ -603,16 +711,34 @@ app.whenReady().then(() => { ipcMain.handle('zhizi:test-connection', async (): Promise => { return probeZhiziCloudConnection(getSettings()) }) - ipcMain.handle('zhizi:enable', async (): Promise => { - const result = await probeZhiziCloudConnection(getSettings()) + ipcMain.handle('zhizi:enable', async (_event, profile: ZhiziEngineProfile): Promise => { + cancelKataGoAnalysis({}) + resetZhiziPersistentSession() + const settings = setSettings({ + katagoEngineMode: 'auto', + zhiziGpuType: profile.gpuType, + zhiziKataName: profile.kataName, + zhiziKataWeight: profile.kataWeight + }) + const result = await probeZhiziCloudConnection(settings) if (!result.ok) return result setSettings({ - katagoEngineMode: 'zhizi', - zhiziUseWhenLocalSlow: false + katagoEngineMode: 'zhizi' }) return { ...result, - message: `${result.message} 已切换为智子云分析。`, + message: `${result.message} 已由你确认切换为智子云分析。`, + dashboard: await dashboard() + } + }) + ipcMain.handle('zhizi:disable', async (): Promise => { + cancelKataGoAnalysis({}) + resetZhiziPersistentSession() + setSettings({ katagoEngineMode: 'auto' }) + return { + ok: true, + message: '已停用智子云,当前使用本机分析。', + hasToken: hasZhiziToken(), dashboard: await dashboard() } }) diff --git a/src/main/lib/store.ts b/src/main/lib/store.ts index de2e91c..1e83b79 100644 --- a/src/main/lib/store.ts +++ b/src/main/lib/store.ts @@ -70,12 +70,11 @@ const defaults: AppSettings = { ikatagoExtraArgs: '', ikatagoUseWhenLocalSlow: false, ikatagoSlowThresholdVisitsPerSecond: 120, - zhiziClientBin: '', zhiziUsername: '', zhiziToken: '', zhiziGpuType: 'vip-share', - zhiziExtraArgs: '', - zhiziUseWhenLocalSlow: false, + zhiziKataName: 'katago-TENSORRT', + zhiziKataWeight: '28bnbt', pythonBin: defaultPythonBin(), llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', @@ -315,13 +314,11 @@ function migrateLocalAnalysisDefault(settings: AppSettings): AppSettings { ...settings, katagoEngineMode: settings.katagoEngineMode === 'zhizi' || settings.katagoEngineMode === 'ikatago' ? 'auto' : settings.katagoEngineMode, ikatagoUseWhenLocalSlow: false, - zhiziUseWhenLocalSlow: false, localAnalysisDefaultApplied: true } settingsStore.set({ katagoEngineMode: migrated.katagoEngineMode, ikatagoUseWhenLocalSlow: false, - zhiziUseWhenLocalSlow: false, localAnalysisDefaultApplied: true }) return migrated @@ -335,9 +332,47 @@ function migrateZhiziLoginIdentifier(settings: AppSettings): AppSettings { return { ...settings, zhiziUsername: '' } } +function migrateZhiziOfficialSettings(settings: AppSettings): AppSettings { + const gpuTypes = new Set(['vip-share', '1x', '3x', '6x', '12x', '24x']) + const kataNames = new Set(['katago-TENSORRT', 'katago-CUDA']) + const kataWeights = new Set(['18bnbt', 'fdx', '28bnbt']) + const { + zhiziClientBin: legacyClientBin, + zhiziExtraArgs: legacyExtraArgs, + zhiziUseWhenLocalSlow: legacyAutoRemote, + ...currentSettings + } = settings + const migrated: AppSettings = { + ...currentSettings, + zhiziGpuType: gpuTypes.has(settings.zhiziGpuType) ? settings.zhiziGpuType : 'vip-share', + zhiziKataName: kataNames.has(settings.zhiziKataName) ? settings.zhiziKataName : 'katago-TENSORRT', + zhiziKataWeight: kataWeights.has(settings.zhiziKataWeight) ? settings.zhiziKataWeight : '28bnbt' + } + if ( + legacyClientBin !== undefined || + legacyExtraArgs !== undefined || + legacyAutoRemote !== undefined || + migrated.zhiziGpuType !== settings.zhiziGpuType || + migrated.zhiziKataName !== settings.zhiziKataName || + migrated.zhiziKataWeight !== settings.zhiziKataWeight + ) { + settingsStore.delete('zhiziClientBin') + settingsStore.delete('zhiziExtraArgs') + settingsStore.delete('zhiziUseWhenLocalSlow') + settingsStore.set({ + zhiziGpuType: migrated.zhiziGpuType, + zhiziKataName: migrated.zhiziKataName, + zhiziKataWeight: migrated.zhiziKataWeight + }) + } + return migrated +} + export function getSettings(): AppSettings { - const persisted = migrateZhiziLoginIdentifier( - migrateLocalAnalysisDefault(migratePlaintextSecrets({ ...defaults, ...settingsStore.store })) + const persisted = migrateZhiziOfficialSettings( + migrateZhiziLoginIdentifier( + migrateLocalAnalysisDefault(migratePlaintextSecrets({ ...defaults, ...settingsStore.store })) + ) ) return { ...persisted, @@ -378,10 +413,12 @@ export function setSettings(next: Partial): AppSettings { zhiziToken: _zhiziToken, ...safeNext } = next + delete safeNext.zhiziClientBin + delete safeNext.zhiziExtraArgs + delete safeNext.zhiziUseWhenLocalSlow const shouldMarkLocalDefaultApplied = Object.prototype.hasOwnProperty.call(safeNext, 'katagoEngineMode') || - Object.prototype.hasOwnProperty.call(safeNext, 'ikatagoUseWhenLocalSlow') || - Object.prototype.hasOwnProperty.call(safeNext, 'zhiziUseWhenLocalSlow') + Object.prototype.hasOwnProperty.call(safeNext, 'ikatagoUseWhenLocalSlow') settingsStore.set(shouldMarkLocalDefaultApplied ? { ...safeNext, localAnalysisDefaultApplied: true } : safeNext) const llmConfigChanged = Object.prototype.hasOwnProperty.call(next, 'llmBaseUrl') || @@ -417,7 +454,13 @@ export function replaceSettings(next: AppSettings): AppSettings { if (next.zhiziToken.trim()) { saveZhiziToken(next.zhiziToken) } - settingsStore.store = { ...next, localAnalysisDefaultApplied: true, llmApiKey: '', ttsCustomApiKey: '', ttsVolcengineApiKey: '', ttsVolcengineAccessToken: '', ikatagoPassword: '', zhiziToken: '' } + const { + zhiziClientBin: _zhiziClientBin, + zhiziExtraArgs: _zhiziExtraArgs, + zhiziUseWhenLocalSlow: _zhiziUseWhenLocalSlow, + ...currentSettings + } = next + settingsStore.store = { ...currentSettings, localAnalysisDefaultApplied: true, llmApiKey: '', ttsCustomApiKey: '', ttsVolcengineApiKey: '', ttsVolcengineAccessToken: '', ikatagoPassword: '', zhiziToken: '' } return getSettings() } diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index 85b5459..9296ab8 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -109,12 +109,17 @@ export interface AppSettings { ikatagoExtraArgs: string ikatagoUseWhenLocalSlow: boolean ikatagoSlowThresholdVisitsPerSecond: number - zhiziClientBin: string + /** @deprecated Migration-only; official Zhizi integration never launches a local connector. */ + zhiziClientBin?: string zhiziUsername: string zhiziToken: string - zhiziGpuType: string - zhiziExtraArgs: string - zhiziUseWhenLocalSlow: boolean + zhiziGpuType: ZhiziGpuType + zhiziKataName: ZhiziKataName + zhiziKataWeight: ZhiziKataWeight + /** @deprecated Migration-only; official Zhizi options are allowlisted fields. */ + zhiziExtraArgs?: string + /** @deprecated Migration-only; remote analysis is only enabled explicitly. */ + zhiziUseWhenLocalSlow?: boolean pythonBin: string llmBaseUrl: string llmApiKey: string @@ -167,20 +172,162 @@ export type KataGoEngineMode = 'auto' | 'persistent' | 'spawn' | 'ikatago' | 'zh export type KataGoAnalysisSpeedMode = 'auto' | 'fast' | 'balanced' | 'deep' export type KataGoModelPresetId = string +export type ZhiziGpuType = 'vip-share' | '1x' | '3x' | '6x' | '12x' | '24x' +export type ZhiziKataName = 'katago-TENSORRT' | 'katago-CUDA' +export type ZhiziKataWeight = '18bnbt' | 'fdx' | '28bnbt' + +export interface ZhiziIdentifier { + kind: 'phone' | 'email' + value: string +} + +export interface ZhiziEngineProfile { + gpuType: ZhiziGpuType + kataName: ZhiziKataName + kataWeight: ZhiziKataWeight +} + +export type ZhiziApiErrorCode = + | 'invalid-data' + | 'invalid-credentials' + | 'invalid-verification-code' + | 'too-frequent' + | 'not-found' + | 'unauthorized' + | 'insufficient-credit' + | 'capacity-unavailable' + | 'payment-failed' + | 'network-error' + | 'timeout' + | 'server-error' + | 'protocol-error' + | 'unknown' + +export interface ZhiziApiError { + code: ZhiziApiErrorCode + status?: number + key?: string + retryable: boolean + message: string +} + +export interface ZhiziAccountOverview { + tokenValid: boolean + identifierMasked?: string + isMembership: boolean + membershipExpiresAt?: string + membershipAutoRenew?: boolean + recommendedGpuType: ZhiziGpuType + balance?: ZhiziBalanceInfo + warning?: ZhiziApiError +} + +export interface ZhiziBalanceInfo { + totalCashAmount: number + totalCouponAmount: number + totalProductAmount: number + totalAmount: number + totalConsumption: number + totalCouponConsumption: number + remainingBalance: number + yesterdayConsumption: number + totalDuration: number + last24HrsShareDuration: number + last24HrsVIPShareDuration: number + currentNumOfMyConnections: number + currentNumOfNodes: number +} + +export interface ZhiziMembershipProduct { + name: 'MEMBERSHIP_1_MONTH' | 'MEMBERSHIP_3_MONTH' | 'MEMBERSHIP_6_MONTH' | 'MEMBERSHIP_12_MONTH' + type: 'MEMBERSHIP' + priceFen: number +} + +export interface ZhiziUsageRecord { + id: string + startedAt?: string + endedAt?: string + finished: boolean + ready: boolean + durationSeconds: number + totalCostYuan: number + gpuType?: string + pricePerHourYuan?: number + vip: boolean + share: boolean +} + +export interface ZhiziUsagePage { + total: number + page: number + pageSize: number + items: ZhiziUsageRecord[] +} + +export interface ZhiziCreditRecord { + id: string + creditType: 'CASH' | 'COUPON' | 'PURCHASE_PRODUCT' | string + amountYuan: number + source?: string + productName?: string + createdAt?: string +} + +export interface ZhiziCreditPage { + total: number + page: number + pageSize: number + items: ZhiziCreditRecord[] +} + +export type ZhiziPaymentKind = 'top-up' | 'membership' +export type ZhiziPaymentStatus = 'PENDING' | 'SUCCESS' | 'FAIL' | 'CANCELLED' + +export interface ZhiziPaymentCreateRequest { + kind: ZhiziPaymentKind + amountFen?: number + productName?: ZhiziMembershipProduct['name'] +} + +export interface ZhiziPaymentSession { + orderId: string + kind: ZhiziPaymentKind + amountFen: number + productName?: ZhiziMembershipProduct['name'] + status: ZhiziPaymentStatus + qrImageDataUrl?: string + createdAt?: string + paidAt?: string + error?: ZhiziApiError +} + +export interface ZhiziAccountData { + overview: ZhiziAccountOverview + products: ZhiziMembershipProduct[] +} + export interface ZhiziCloudLoginRequest { - phone: string + identifier: ZhiziIdentifier password: string } export interface ZhiziCloudSendCodeRequest { - phone: string + identifier: ZhiziIdentifier + purpose?: 'fast_login' | 'reset_password' } export interface ZhiziCloudLoginCodeRequest { - phone: string + identifier: ZhiziIdentifier verificationCode: string } +export interface ZhiziCloudResetPasswordRequest { + identifier: ZhiziIdentifier + verificationCode: string + password: string +} + export interface ZhiziCloudSendCodeResult { ok: boolean message: string @@ -206,15 +353,27 @@ export interface ZhiziCloudConnectionTestResult { readyMillis?: number analysisMillis?: number sessionReused?: boolean - gpuType?: string + gpuType?: ZhiziGpuType tokenValid?: boolean isMembership?: boolean membershipExpiresAt?: string - hasConnectAccount?: boolean - connectUsernameMasked?: string + accountOverview?: ZhiziAccountOverview + sessionState?: ZhiziEngineSessionState dashboard?: DashboardData } +export interface ZhiziEngineSessionState { + state: 'idle' | 'connecting' | 'ready' | 'reconnecting' | 'error' | 'closed' + connected: boolean + ready: boolean + generation: number + reusedConnections: number + connectionCount: number + lastReadyMillis: number + lastErrorCode?: string + lastError?: string +} + export interface KataGoModelPreset { id: KataGoModelPresetId label: string diff --git a/src/main/services/analysis/runtimeIntegration.ts b/src/main/services/analysis/runtimeIntegration.ts index f3e03b4..273e201 100644 --- a/src/main/services/analysis/runtimeIntegration.ts +++ b/src/main/services/analysis/runtimeIntegration.ts @@ -113,9 +113,9 @@ function runtimeFingerprints(): { modelFingerprint: string; configFingerprint: s settings.ikatagoPlatform, settings.ikatagoUsername, settings.ikatagoExtraArgs, - settings.zhiziClientBin, settings.zhiziGpuType, - settings.zhiziExtraArgs, + settings.zhiziKataName, + settings.zhiziKataWeight, settings.zhiziToken ? 'zhizi-token-configured' : 'zhizi-token-empty' ].join('\n')), configFingerprint: sha256([ @@ -125,7 +125,6 @@ function runtimeFingerprints(): { modelFingerprint: string; configFingerprint: s settings.katagoCacheSizePowerOfTwo, settings.ikatagoUseWhenLocalSlow, settings.ikatagoSlowThresholdVisitsPerSecond, - settings.zhiziUseWhenLocalSlow, fileFingerprint(settings.katagoConfig, { readSmallFiles: true }) ].join('\n')) } diff --git a/src/main/services/katago.ts b/src/main/services/katago.ts index ae2a967..ca689c4 100644 --- a/src/main/services/katago.ts +++ b/src/main/services/katago.ts @@ -20,8 +20,7 @@ import { import { cancelZhiziGtpAnalysis, queryZhiziGtpAnalysisBatch, - shouldPreferZhiziGtpEngine, - zhiziGtpConfigured + shouldPreferZhiziGtpEngine } from './zhiziGtpEngine' import { normalizeSgfKomiForAnalysis } from './sgfScoring' import { buildKataGoTracePacket } from './teacher/katagoTraceTranslator' @@ -572,22 +571,6 @@ async function queryKataGoBatch( }) return results as Map } - function canUseZhiziAutoFallback(error: unknown): boolean { - const text = String(error) - return Boolean( - !/cancel|取消/i.test(text) && - !zhiziPreferred && - settings.katagoEngineMode === 'auto' && - settings.zhiziUseWhenLocalSlow && - zhiziGtpConfigured(settings) - ) - } - async function queryZhiziAfterLocalFailure(error: unknown): Promise> { - console.warn('Local KataGo analysis failed in opt-in slow-machine mode; falling back to Zhizi cloud.', error) - const results = await queryZhiziRemote() - engineLease.finish('done') - return results - } if (zhiziPreferred) { try { const results = await queryZhiziRemote() @@ -651,9 +634,6 @@ async function queryKataGoBatch( return results as Map } catch (error) { const status = String(error).includes('已取消') || String(error).includes('cancel') ? 'cancelled' : 'error' - if (canUseZhiziAutoFallback(error)) { - return queryZhiziAfterLocalFailure(error) - } if (status === 'cancelled' || !persistentKataGoFallbackEnabled()) { engineLease.finish(status) throw error @@ -669,9 +649,6 @@ async function queryKataGoBatch( }) } catch (error) { const normalizedError = normalizeLocalKataGoProcessError(error, command[0]) - if (canUseZhiziAutoFallback(error)) { - return queryZhiziAfterLocalFailure(error) - } throw normalizedError } @@ -860,12 +837,7 @@ async function queryKataGoBatch( } child.stdin.end() }) - return localSpawnPromise.catch(async (error) => { - if (!canUseZhiziAutoFallback(error)) { - throw error - } - return queryZhiziAfterLocalFailure(error) - }) + return localSpawnPromise } async function queryKataGo( diff --git a/src/main/services/zhiziApiClient.ts b/src/main/services/zhiziApiClient.ts new file mode 100644 index 0000000..fae607a --- /dev/null +++ b/src/main/services/zhiziApiClient.ts @@ -0,0 +1,516 @@ +import { z } from 'zod' +import type { + ZhiziAccountOverview, + ZhiziApiError, + ZhiziApiErrorCode, + ZhiziBalanceInfo, + ZhiziCloudLoginCodeRequest, + ZhiziCloudLoginRequest, + ZhiziCloudResetPasswordRequest, + ZhiziCloudSendCodeRequest, + ZhiziCreditPage, + ZhiziIdentifier, + ZhiziMembershipProduct, + ZhiziUsagePage +} from '@main/lib/types' + +export const ZHIZI_API_BASE_URL = 'https://www.zhizigo.com' +export const ZHIZI_OFFICIAL_APP_DOWNLOAD_URL = 'https://zhizigo.com/download' + +const membershipProductNames = [ + 'MEMBERSHIP_1_MONTH', + 'MEMBERSHIP_3_MONTH', + 'MEMBERSHIP_6_MONTH', + 'MEMBERSHIP_12_MONTH' +] as const + +const accountSchema = z.object({ + id: z.string(), + phone: z.string().nullable().optional(), + email: z.string().nullable().optional(), + token: z.string().optional(), + membershipExpiresAt: z.string().nullable().optional(), + membershipAutoRenew: z.boolean().nullable().optional(), + isMembership: z.boolean().optional() +}).passthrough() + +const balanceSchema = z.object({ + totalCashAmount: z.number().optional(), + totalCouponAmount: z.number().optional(), + totalProductAmount: z.number().optional(), + totalAmount: z.number().optional(), + totalConsumption: z.number().optional(), + totalCouponConsumption: z.number().optional(), + remainingBalance: z.number().optional(), + yesterdayConsumption: z.number().optional(), + totalDuration: z.number().optional(), + last24HrsShareDuration: z.number().optional(), + last24HrsVIPShareDuration: z.number().optional(), + currentNumOfMyConnections: z.number().optional(), + currentNumOfNodes: z.number().optional() +}).passthrough() + +const productSchema = z.object({ + name: z.enum(membershipProductNames), + type: z.literal('MEMBERSHIP'), + price: z.number().int().positive() +}).passthrough() + +const usageSchema = z.object({ + id: z.string(), + startedAt: z.string().optional(), + endedAt: z.string().nullable().optional(), + finished: z.boolean().optional(), + ready: z.boolean().optional(), + duration: z.number().optional(), + totalCost: z.number().optional(), + gpuType: z.string().optional(), + gpusPricePerHour: z.number().optional(), + occupationPricePerHour: z.number().optional(), + vip: z.boolean().optional(), + share: z.boolean().optional() +}).passthrough() + +const usagePageSchema = z.object({ + total: z.number().int().nonnegative(), + page: z.number().int().nullable().optional(), + pageSize: z.number().int().nullable().optional(), + items: z.array(usageSchema) +}).passthrough() + +const creditSchema = z.object({ + id: z.string(), + creditType: z.string(), + amount: z.number(), + source: z.string().nullable().optional(), + productName: z.string().nullable().optional(), + createdAt: z.string().optional() +}).passthrough() + +const creditPageSchema = z.object({ + total: z.number().int().nonnegative(), + page: z.number().int().nullable().optional(), + pageSize: z.number().int().nullable().optional(), + items: z.array(creditSchema) +}).passthrough() + +export const zhiziOrderSchema = z.object({ + id: z.string(), + amount: z.number().int().positive(), + paidStatus: z.enum(['PENDING', 'SUCCESS', 'FAIL']), + productName: z.enum(membershipProductNames).nullable().optional(), + paidAt: z.string().nullable().optional(), + createdAt: z.string().optional(), + errorMessage: z.string().nullable().optional(), + nativePayRequest: z.object({ + codeURL: z.string().min(1) + }).passthrough().nullable().optional() +}).passthrough() + +export const zhiziSocketTokenSchema = z.object({ + token: z.string().min(1), + socketIOURL: z.string().url(), + createdAt: z.string().optional(), + expiredAt: z.string().optional(), + args: z.string().optional() +}).passthrough() + +type ZhiziOrder = z.infer + +interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' + token?: string + body?: unknown + schema: z.ZodType + timeoutMs?: number + retrySafe?: boolean +} + +interface ZhiziApiDependencies { + fetch: typeof fetch + sleep: (milliseconds: number) => Promise +} + +const defaultDependencies: ZhiziApiDependencies = { + fetch: (...args) => globalThis.fetch(...args), + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +function normalizedIdentifier(identifier: ZhiziIdentifier): ZhiziIdentifier { + const value = identifier.value.trim() + if (identifier.kind === 'phone') { + if (!/^\+?\d{6,20}$/.test(value)) { + throw new ZhiziApiClientError({ + code: 'invalid-data', + key: 'invalid_phone', + retryable: false, + message: '手机号格式不正确。' + }) + } + return { kind: 'phone', value } + } + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) { + throw new ZhiziApiClientError({ + code: 'invalid-data', + key: 'invalid_email', + retryable: false, + message: '邮箱格式不正确。' + }) + } + return { kind: 'email', value: value.toLowerCase() } +} + +function identifierBody(identifier: ZhiziIdentifier): Record { + const normalized = normalizedIdentifier(identifier) + return normalized.kind === 'phone' + ? { phone: normalized.value } + : { email: normalized.value } +} + +function apiErrorCode(status: number | undefined, key: string): ZhiziApiErrorCode { + if (status === 401 || status === 403) return 'unauthorized' + if (status && status >= 500) return 'server-error' + if (/invalid_verification_code/.test(key)) return 'invalid-verification-code' + if (/fast_login_too_frequent/.test(key)) return 'too-frequent' + if (/invalid_credentials|invalid_password/.test(key)) return 'invalid-credentials' + if (/invalid_data|invalid_phone|invalid_email|invalid_amount|missing_product_name|invalid_product_name/.test(key)) return 'invalid-data' + if (/not_found|order_not_found/.test(key)) return 'not-found' + if (/not_enough_credit/.test(key)) return 'insufficient-credit' + if (/no.worker|worker.unavailable|capacity/.test(key)) return 'capacity-unavailable' + if (/failed_create_prepay_id|failed_insert_order|payment/.test(key)) return 'payment-failed' + return 'unknown' +} + +function apiErrorMessage(code: ZhiziApiErrorCode, status?: number): string { + switch (code) { + case 'invalid-credentials': return '账号或密码不正确。' + case 'invalid-verification-code': return '验证码不正确或已过期。' + case 'too-frequent': return '尝试次数过多,请稍后再试。' + case 'not-found': return '没有找到对应的智子云数据。' + case 'unauthorized': return '智子云登录已失效,请重新登录。' + case 'insufficient-credit': return '当前余额或权益不足。' + case 'capacity-unavailable': return '当前没有可用的远程算力,请稍后再试。' + case 'payment-failed': return '支付订单创建失败,请稍后再试。' + case 'network-error': return '无法连接智子云,请检查网络后重试。' + case 'timeout': return '智子云请求超时,请稍后再试。' + case 'server-error': return '智子云服务暂时不可用,请稍后再试。' + case 'protocol-error': return '智子云返回了无法识别的数据。' + case 'invalid-data': return '提交的信息不正确,请检查后重试。' + default: return status ? `智子云请求失败(HTTP ${status})。` : '智子云请求失败。' + } +} + +export class ZhiziApiClientError extends Error { + readonly info: ZhiziApiError + + constructor(info: ZhiziApiError) { + super(info.message) + this.name = 'ZhiziApiClientError' + this.info = info + } +} + +export function asZhiziApiError(cause: unknown): ZhiziApiError { + if (cause instanceof ZhiziApiClientError) return cause.info + const message = cause instanceof Error ? cause.message : String(cause) + return { + code: /abort|timeout|超时/i.test(message) ? 'timeout' : 'network-error', + retryable: true, + message: /abort|timeout|超时/i.test(message) + ? apiErrorMessage('timeout') + : apiErrorMessage('network-error') + } +} + +export async function requestZhiziApi( + path: string, + options: RequestOptions, + dependencies: Partial = {} +): Promise { + const deps = { ...defaultDependencies, ...dependencies } + const attempts = options.retrySafe ? 2 : 1 + let lastError: unknown + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000) + try { + const response = await deps.fetch(`${ZHIZI_API_BASE_URL}${path}`, { + method: options.method ?? 'GET', + headers: { + Accept: 'application/json', + ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal + }) + const rawText = await response.text() + let parsed: unknown = undefined + if (rawText.trim()) { + try { + parsed = JSON.parse(rawText) + } catch { + parsed = rawText + } + } + if (!response.ok) { + const key = typeof parsed === 'object' && parsed + ? String((parsed as Record).key ?? '') + : '' + const code = apiErrorCode(response.status, key) + throw new ZhiziApiClientError({ + code, + status: response.status, + key: key || undefined, + retryable: response.status >= 500, + message: apiErrorMessage(code, response.status) + }) + } + const result = options.schema.safeParse(parsed) + if (!result.success) { + throw new ZhiziApiClientError({ + code: 'protocol-error', + status: response.status, + retryable: false, + message: apiErrorMessage('protocol-error') + }) + } + return result.data + } catch (cause) { + lastError = cause + const info = asZhiziApiError(cause) + if (!info.retryable || attempt >= attempts) { + if (cause instanceof ZhiziApiClientError) throw cause + throw new ZhiziApiClientError(info) + } + await deps.sleep(500 * attempt) + } finally { + clearTimeout(timer) + } + } + throw lastError +} + +function requireToken(account: z.infer): string { + const token = account.token?.trim() + if (!token) { + throw new ZhiziApiClientError({ + code: 'protocol-error', + retryable: false, + message: '智子云登录成功,但没有返回登录凭据。' + }) + } + return token +} + +export async function loginZhiziPassword(request: ZhiziCloudLoginRequest): Promise<{ token: string }> { + const password = request.password.trim() + if (!password) { + throw new ZhiziApiClientError({ code: 'invalid-data', retryable: false, message: '请输入密码。' }) + } + const account = await requestZhiziApi('/api/cluster/account/login', { + method: 'POST', + body: { ...identifierBody(request.identifier), password }, + schema: accountSchema + }) + return { token: requireToken(account) } +} + +export async function sendZhiziVerificationCode(request: ZhiziCloudSendCodeRequest): Promise { + await requestZhiziApi('/api/cluster/account/send-code', { + method: 'POST', + body: { + ...identifierBody(request.identifier), + type: request.purpose ?? 'fast_login' + }, + schema: z.undefined() + }) +} + +export async function loginZhiziCode(request: ZhiziCloudLoginCodeRequest): Promise<{ token: string }> { + const verificationCode = request.verificationCode.trim() + if (!verificationCode) { + throw new ZhiziApiClientError({ code: 'invalid-data', retryable: false, message: '请输入验证码。' }) + } + const account = await requestZhiziApi('/api/cluster/account/fast-login', { + method: 'POST', + body: { ...identifierBody(request.identifier), verificationCode }, + schema: accountSchema + }) + return { token: requireToken(account) } +} + +export async function resetZhiziPassword(request: ZhiziCloudResetPasswordRequest): Promise<{ token: string }> { + const password = request.password.trim() + if (password.length < 8) { + throw new ZhiziApiClientError({ code: 'invalid-data', retryable: false, message: '新密码至少需要 8 个字符。' }) + } + const account = await requestZhiziApi('/api/cluster/account/reset-password', { + method: 'POST', + body: { + ...identifierBody(request.identifier), + verificationCode: request.verificationCode.trim(), + password + }, + schema: accountSchema + }) + return { token: requireToken(account) } +} + +function maskAccountIdentifier(phone?: string | null, email?: string | null): string | undefined { + if (phone) return phone.replace(/^(\+?\d{3})\d+(\d{2})$/, '$1****$2') + if (email) { + const [name, domain] = email.split('@') + if (!domain) return email + return `${name.slice(0, 2)}***@${domain}` + } + return undefined +} + +function balanceInfo(value: z.infer): ZhiziBalanceInfo { + return { + totalCashAmount: value.totalCashAmount ?? 0, + totalCouponAmount: value.totalCouponAmount ?? 0, + totalProductAmount: value.totalProductAmount ?? 0, + totalAmount: value.totalAmount ?? 0, + totalConsumption: value.totalConsumption ?? 0, + totalCouponConsumption: value.totalCouponConsumption ?? 0, + remainingBalance: value.remainingBalance ?? 0, + yesterdayConsumption: value.yesterdayConsumption ?? 0, + totalDuration: value.totalDuration ?? 0, + last24HrsShareDuration: value.last24HrsShareDuration ?? 0, + last24HrsVIPShareDuration: value.last24HrsVIPShareDuration ?? 0, + currentNumOfMyConnections: value.currentNumOfMyConnections ?? 0, + currentNumOfNodes: value.currentNumOfNodes ?? 0 + } +} + +export async function getZhiziAccountOverview(token: string): Promise { + try { + const account = await requestZhiziApi('/api/cluster/account/me', { + token, + schema: accountSchema, + retrySafe: true + }) + let balance: ZhiziBalanceInfo | undefined + let warning: ZhiziApiError | undefined + try { + balance = balanceInfo(await requestZhiziApi('/api/cluster/balance', { + token, + schema: balanceSchema, + retrySafe: true + })) + } catch (cause) { + warning = asZhiziApiError(cause) + } + const isMembership = account.isMembership === true + return { + tokenValid: true, + identifierMasked: maskAccountIdentifier(account.phone, account.email), + isMembership, + membershipExpiresAt: account.membershipExpiresAt ?? undefined, + membershipAutoRenew: account.membershipAutoRenew ?? undefined, + recommendedGpuType: isMembership ? 'vip-share' : '1x', + balance, + warning + } + } catch (cause) { + const error = asZhiziApiError(cause) + if (error.code === 'unauthorized') { + return { + tokenValid: false, + isMembership: false, + recommendedGpuType: '1x' + } + } + throw cause + } +} + +export async function listZhiziMembershipProducts(): Promise { + const products = await requestZhiziApi('/api/cluster/product?type=MEMBERSHIP', { + schema: z.array(productSchema), + retrySafe: true + }) + return products.map((product) => ({ + name: product.name, + type: 'MEMBERSHIP', + priceFen: product.price + })) +} + +export async function listZhiziUsages(token: string, page = 0, pageSize = 20): Promise { + const result = await requestZhiziApi(`/api/cluster/usage/my-usages?page=${Math.max(0, page)}&pageSize=${Math.max(1, Math.min(100, pageSize))}`, { + token, + schema: usagePageSchema, + retrySafe: true + }) + return { + total: result.total, + page: result.page ?? page, + pageSize: result.pageSize ?? pageSize, + items: result.items.map((item) => ({ + id: item.id, + startedAt: item.startedAt, + endedAt: item.endedAt ?? undefined, + finished: item.finished ?? false, + ready: item.ready ?? false, + durationSeconds: item.duration ?? 0, + totalCostYuan: item.totalCost ?? 0, + gpuType: item.gpuType, + pricePerHourYuan: item.gpusPricePerHour ?? item.occupationPricePerHour, + vip: item.vip ?? false, + share: item.share ?? false + })) + } +} + +export async function listZhiziCredits(token: string, page = 0, pageSize = 20): Promise { + const result = await requestZhiziApi(`/api/cluster/credit/my-credits?page=${Math.max(0, page)}&pageSize=${Math.max(1, Math.min(100, pageSize))}`, { + token, + schema: creditPageSchema, + retrySafe: true + }) + return { + total: result.total, + page: result.page ?? page, + pageSize: result.pageSize ?? pageSize, + items: result.items.map((item) => ({ + id: item.id, + creditType: item.creditType, + amountYuan: item.amount, + source: item.source ?? undefined, + productName: item.productName ?? undefined, + createdAt: item.createdAt + })) + } +} + +export async function createZhiziOrder(token: string, body: Record): Promise { + return requestZhiziApi('/api/pay/orders', { + method: 'POST', + token, + body, + schema: zhiziOrderSchema, + timeoutMs: 20_000 + }) +} + +export async function getZhiziOrder(token: string, orderId: string): Promise { + return requestZhiziApi(`/api/pay/orders/${encodeURIComponent(orderId)}`, { + token, + schema: zhiziOrderSchema, + retrySafe: true + }) +} + +export async function createZhiziSocketToken(token: string, args: string): Promise> { + return requestZhiziApi('/api/cluster/account/fetch-socketio-token', { + method: 'POST', + token, + body: { args }, + schema: zhiziSocketTokenSchema, + timeoutMs: 20_000, + retrySafe: true + }) +} diff --git a/src/main/services/zhiziCloudAuth.ts b/src/main/services/zhiziCloudAuth.ts index 22372b6..b0ebcc2 100644 --- a/src/main/services/zhiziCloudAuth.ts +++ b/src/main/services/zhiziCloudAuth.ts @@ -1,254 +1,62 @@ -import type { ZhiziCloudLoginCodeRequest, ZhiziCloudLoginRequest, ZhiziCloudSendCodeRequest } from '@main/lib/types' - -const ZHIZI_LOGIN_URL = 'https://www.zhizigo.com/api/cluster/account/login' -const ZHIZI_SEND_CODE_URL = 'https://www.zhizigo.com/api/cluster/account/send-code' -const ZHIZI_FAST_LOGIN_URL = 'https://www.zhizigo.com/api/cluster/account/fast-login' -const ZHIZI_ME_URL = 'https://www.zhizigo.com/api/cluster/account/me' -const ZHIZI_CONNECT_ACCOUNT_FETCH_URL = 'https://www.zhizigo.com/api/cluster/account/connectAccount/fetch' - -export const ZHIZI_OFFICIAL_APP_DOWNLOAD_URL = 'https://zhizigo.com/download' - -export interface ZhiziCloudAccountStatus { - tokenValid: boolean - isMembership: boolean - membershipExpiresAt?: string - hasConnectAccount: boolean - connectAccountId?: string - connectUsernameMasked?: string -} - -function findToken(value: unknown, depth = 0): string { - if (depth > 6) return '' - if (!value || typeof value !== 'object') return '' - const object = value as Record - for (const key of ['token', 'accessToken', 'access_token', 'jwt', 'accessJwt', 'connectToken', 'connectAccountToken']) { - const candidate = object[key] - if (typeof candidate === 'string' && candidate.trim()) { - return candidate.trim() - } - } - for (const [key, candidate] of Object.entries(object)) { - if (/(token|jwt)/i.test(key) && typeof candidate === 'string' && candidate.trim()) { - return candidate.trim() - } - } - for (const candidate of Object.values(object)) { - const token = findToken(candidate, depth + 1) - if (token) return token - } - return '' -} - -function humanizeZhiziLoginError(status: number, body: unknown, rawText: string): string { - const object = body && typeof body === 'object' ? body as Record : {} - const key = String(object.key ?? object.error ?? object.message ?? '').trim() - if (/invalid_verification_code|verification/i.test(key)) { - return '智子云验证码不正确或已过期,请重新获取验证码。' - } - if (/invalid_phone|phone/i.test(key)) { - return '智子云账号无效:手机号请直接填写数字;zz- 开头的连接账号请确认账号名完整。' - } - if (/invalid_password|password/i.test(key)) { - return '智子云登录失败:密码不正确。你也可以改用短信验证码登录。' - } - if (/invalid|credential/i.test(key)) { - return '智子云登录失败:账号凭据无效。' - } - if (status === 429) { - return '智子云登录请求过于频繁,请稍后再试。' - } - if (status >= 500) { - return '智子云登录服务暂时不可用,请稍后再试。' - } - return `智子云登录失败:HTTP ${status}${key ? ` · ${key}` : rawText ? ` · ${rawText.slice(0, 120)}` : ''}` -} - -function isPhoneAccount(account: string): boolean { - return /^\+?\d{6,20}$/.test(account) -} - -function isEmailAccount(account: string): boolean { - return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(account) -} - -function isConnectAccount(account: string): boolean { - return /^zz[-_]/i.test(account) -} - -async function postZhiziJson(url: string, body: Record, timeoutMs = 20_000): Promise<{ status: number; ok: boolean; json: unknown; rawText: string }> { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body), - signal: controller.signal - }) - const rawText = await response.text() - let json: unknown = undefined - try { - json = rawText ? JSON.parse(rawText) : undefined - } catch { - json = undefined - } - return { status: response.status, ok: response.ok, json, rawText } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('智子云登录超时,请检查网络后重试。') - } - throw error - } finally { - clearTimeout(timer) - } -} - -function maskZhiziUsername(value: unknown): string { - const text = typeof value === 'string' ? value.trim() : '' - if (!text) return '' - if (/^zz[-_]/i.test(text)) return text.replace(/^(.{3}).+(.{2})$/, '$1***$2') - return text.replace(/(\d{3})\d+(\d{2})/, '$1***$2') -} - -async function getZhiziJsonOnce(url: string, token: string, timeoutMs = 15_000): Promise<{ status: number; ok: boolean; json: unknown; rawText: string }> { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - try { - const response = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${token}` - }, - signal: controller.signal - }) - const rawText = await response.text() - let json: unknown = undefined - try { - json = rawText ? JSON.parse(rawText) : undefined - } catch { - json = undefined - } - return { status: response.status, ok: response.ok, json, rawText } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('智子云账号状态检查超时,请检查网络后重试。') - } - throw error - } finally { - clearTimeout(timer) - } -} - -async function getZhiziJson(url: string, token: string, timeoutMs = 15_000): Promise<{ status: number; ok: boolean; json: unknown; rawText: string }> { - let lastError: unknown - for (let attempt = 1; attempt <= 2; attempt += 1) { - try { - return await getZhiziJsonOnce(url, token, timeoutMs) - } catch (cause) { - lastError = cause - if (attempt >= 2) throw cause - await new Promise((resolve) => setTimeout(resolve, 650)) - } - } - throw lastError -} - -function objectValue(value: unknown): Record { - if (!value || typeof value !== 'object') return {} - return value as Record -} - -function responsePayload(value: unknown): Record { - const root = objectValue(value) - for (const key of ['data', 'result', 'account', 'user']) { - const nested = objectValue(root[key]) - if (Object.keys(nested).length) return nested - } - return root -} - -export async function getZhiziCloudAccountStatus(token: string): Promise { +import type { + ZhiziAccountOverview, + ZhiziCloudLoginCodeRequest, + ZhiziCloudLoginRequest, + ZhiziCloudResetPasswordRequest, + ZhiziCloudSendCodeRequest +} from '@main/lib/types' +import { + getZhiziAccountOverview, + loginZhiziCode, + loginZhiziPassword, + resetZhiziPassword, + sendZhiziVerificationCode, + ZHIZI_OFFICIAL_APP_DOWNLOAD_URL +} from './zhiziApiClient' + +export { ZHIZI_OFFICIAL_APP_DOWNLOAD_URL } +export type ZhiziCloudAccountStatus = ZhiziAccountOverview + +export async function getZhiziCloudAccountStatus(token: string): Promise { const trimmed = token.trim() if (!trimmed) { - return { tokenValid: false, isMembership: false, hasConnectAccount: false } - } - const meResponse = await getZhiziJson(ZHIZI_ME_URL, trimmed) - if (!meResponse.ok) { - if (meResponse.status === 401 || meResponse.status === 403) { - return { tokenValid: false, isMembership: false, hasConnectAccount: false } + return { + tokenValid: false, + isMembership: false, + recommendedGpuType: '1x' } - throw new Error(`智子云账号资料暂时不可用(HTTP ${meResponse.status})。`) - } - const me = responsePayload(meResponse.json) - const connectResponse = await getZhiziJson(ZHIZI_CONNECT_ACCOUNT_FETCH_URL, trimmed) - const connect = connectResponse.ok ? responsePayload(connectResponse.json) : {} - const rawConnectAccountId = connect.id ?? connect.connectAccountId - const connectAccountId = typeof rawConnectAccountId === 'string' || typeof rawConnectAccountId === 'number' - ? String(rawConnectAccountId) - : undefined - const membershipExpiresAt = me.membershipExpiresAt ?? me.membershipExpiredAt ?? me.expiredAt - return { - tokenValid: true, - isMembership: me.isMembership === true || me.membership === true, - membershipExpiresAt: typeof membershipExpiresAt === 'string' ? membershipExpiresAt : undefined, - hasConnectAccount: Boolean(connectAccountId || connect.connectUsername), - connectAccountId, - connectUsernameMasked: maskZhiziUsername(connect.connectUsername) } + return getZhiziAccountOverview(trimmed) } -export async function loginZhiziCloudByPassword(request: ZhiziCloudLoginRequest): Promise<{ token: string; message: string }> { - const account = request.phone.trim() - const password = request.password.trim() - if (!account || !password) { - throw new Error('请输入智子云账号和密码。') - } - if (isConnectAccount(account)) { - throw new Error('这是智子云连接账号,不是可直接登录的主账号。请用智子云手机号或邮箱登录 GoAgent;连接账号需要先在智子官方账号体系里绑定后,由主账号 token 分配远程算力。') - } - if (!isPhoneAccount(account) && !isEmailAccount(account)) { - throw new Error('智子云账号格式不正确:请填写手机号或邮箱。zz- 开头的是连接账号,不能直接换取 GoAgent 所需的远程算力 token。') - } +export async function loginZhiziCloudByPassword( + request: ZhiziCloudLoginRequest +): Promise<{ token: string; message: string }> { + const result = await loginZhiziPassword(request) + return { token: result.token, message: '智子云登录成功。' } +} - const response = await postZhiziJson(ZHIZI_LOGIN_URL, isEmailAccount(account) ? { email: account, password } : { phone: account, password }) - if (!response.ok) { - throw new Error(humanizeZhiziLoginError(response.status, response.json, response.rawText)) - } - const token = findToken(response.json) - if (!token) { - throw new Error('智子云登录成功但没有返回 token。请稍后重试,或确认当前账号是智子云主账号而不是连接账号。') +export async function sendZhiziCloudLoginCode( + request: ZhiziCloudSendCodeRequest +): Promise<{ message: string }> { + await sendZhiziVerificationCode(request) + return { + message: request.purpose === 'reset_password' + ? '重置密码验证码已发送。' + : '验证码已发送。' } - return { token, message: '智子云登录成功,已保存 token。' } } -export async function sendZhiziCloudLoginCode(request: ZhiziCloudSendCodeRequest): Promise<{ message: string }> { - const phone = request.phone.trim() - if (!phone) { - throw new Error('请输入智子云手机号。') - } - const response = await postZhiziJson(ZHIZI_SEND_CODE_URL, { phone, type: 'fast_login' }, 15_000) - if (!response.ok) { - throw new Error(humanizeZhiziLoginError(response.status, response.json, response.rawText)) - } - return { message: '验证码已发送,请查看手机短信。' } +export async function loginZhiziCloudByCode( + request: ZhiziCloudLoginCodeRequest +): Promise<{ token: string; message: string }> { + const result = await loginZhiziCode(request) + return { token: result.token, message: '智子云验证码登录成功。' } } -export async function loginZhiziCloudByCode(request: ZhiziCloudLoginCodeRequest): Promise<{ token: string; message: string }> { - const phone = request.phone.trim() - const verificationCode = request.verificationCode.trim() - if (!phone || !verificationCode) { - throw new Error('请输入智子云手机号和短信验证码。') - } - const response = await postZhiziJson(ZHIZI_FAST_LOGIN_URL, { phone, verificationCode }) - if (!response.ok) { - throw new Error(humanizeZhiziLoginError(response.status, response.json, response.rawText)) - } - const token = findToken(response.json) - if (!token) { - throw new Error('智子云验证码登录成功但没有返回 token,请稍后重试。') - } - return { token, message: '智子云验证码登录成功,已保存 token。' } +export async function resetZhiziCloudPassword( + request: ZhiziCloudResetPasswordRequest +): Promise<{ token: string; message: string }> { + const result = await resetZhiziPassword(request) + return { token: result.token, message: '密码已重置,并已重新登录。' } } diff --git a/src/main/services/zhiziConnectionProbe.ts b/src/main/services/zhiziConnectionProbe.ts index d512219..2d51c65 100644 --- a/src/main/services/zhiziConnectionProbe.ts +++ b/src/main/services/zhiziConnectionProbe.ts @@ -36,7 +36,7 @@ export async function probeZhiziCloudConnection( accountStatus = { tokenValid: true, isMembership: false, - hasConnectAccount: false + recommendedGpuType: '1x' } accountStatusWarning = '账号资料暂时无法读取,已直接检测远程引擎。' } @@ -45,7 +45,8 @@ export async function probeZhiziCloudConnection( ok: false, state: 'token-expired', message: '智子云登录已失效,请重新登录。', - ...accountStatus + tokenValid: false, + accountOverview: accountStatus } } @@ -57,8 +58,7 @@ export async function probeZhiziCloudConnection( const results = await queryZhiziGtpAnalysisBatch({ settings: { ...settings, - katagoEngineMode: 'zhizi', - zhiziClientBin: '' + katagoEngineMode: 'zhizi' }, runId: `zhizi-smoke-${Date.now()}`, group: 'quick', @@ -90,7 +90,11 @@ export async function probeZhiziCloudConnection( ok: false, state: 'error', message: '智子云已连接,但没有返回候选点。请稍后重试。', - ...accountStatus + tokenValid: accountStatus.tokenValid, + isMembership: accountStatus.isMembership, + membershipExpiresAt: accountStatus.membershipExpiresAt, + accountOverview: accountStatus, + sessionState: getZhiziPersistentSessionTelemetry() } } const after = getZhiziPersistentSessionTelemetry() @@ -117,7 +121,11 @@ export async function probeZhiziCloudConnection( analysisMillis: Date.now() - startedAt, sessionReused: before.ready, gpuType: settings.zhiziGpuType || 'vip-share', - ...accountStatus + tokenValid: accountStatus.tokenValid, + isMembership: accountStatus.isMembership, + membershipExpiresAt: accountStatus.membershipExpiresAt, + accountOverview: accountStatus, + sessionState: after } } catch (cause) { const error = classifyZhiziRemoteError(cause, settings.zhiziGpuType || 'vip-share') @@ -126,7 +134,11 @@ export async function probeZhiziCloudConnection( state: resultState(error.code), message: error.message, gpuType: settings.zhiziGpuType || 'vip-share', - ...accountStatus + tokenValid: accountStatus.tokenValid, + isMembership: accountStatus.isMembership, + membershipExpiresAt: accountStatus.membershipExpiresAt, + accountOverview: accountStatus, + sessionState: getZhiziPersistentSessionTelemetry() } } } diff --git a/src/main/services/zhiziGtpEngine.ts b/src/main/services/zhiziGtpEngine.ts index f5666fe..d404ad4 100644 --- a/src/main/services/zhiziGtpEngine.ts +++ b/src/main/services/zhiziGtpEngine.ts @@ -1,4 +1,3 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import type { AppSettings, GameMove, KataGoAnalysisGroup } from '@main/lib/types' import { classifyZhiziRemoteError, @@ -8,6 +7,8 @@ import { } from './zhiziSocketSession' import { buildZhiziRemoteArgs, + findGtpCommandResponse, + formatGtpCommand, parseKataAnalyzeInfo, zhiziAnalysisReachedVisits, zhiziResponseVisitTotal, @@ -37,7 +38,7 @@ export interface ZhiziGtpAnalysisBatchRequest { }) => void } -interface ActiveZhiziProcess { +interface ActiveZhiziAnalysis { group?: KataGoAnalysisGroup cancelled: boolean stop: () => void @@ -46,12 +47,12 @@ interface ActiveZhiziProcess { type GtpQuery = { id: string moves: Array<[GameMove['color'], string]> - initialStones?: Array<[GameMove['color'], string]> - initialPlayer?: GameMove['color'] - boardXSize?: number - boardYSize?: number - komi?: number - maxVisits?: number + initialStones: Array<[GameMove['color'], string]> + initialPlayer: GameMove['color'] + boardXSize: number + boardYSize: number + komi: number + maxVisits: number allowMoves?: Array<{ player: GameMove['color'] moves: string[] @@ -59,53 +60,15 @@ type GtpQuery = { }> } -const activeZhiziProcesses = new Map() -const ZHIZI_COLD_START_GTP_TIMEOUT_MS = 60_000 -function splitCommandLine(input: string): string[] { - const args: string[] = [] - let current = '' - let quote: '"' | "'" | '' = '' - let escaping = false - for (const char of input) { - if (escaping) { - current += char - escaping = false - continue - } - if (char === '\\') { - escaping = true - continue - } - if (quote) { - if (char === quote) quote = '' - else current += char - continue - } - if (char === '"' || char === "'") { - quote = char - continue - } - if (/\s/.test(char)) { - if (current) { - args.push(current) - current = '' - } - continue - } - current += char - } - if (escaping) current += '\\' - if (current) args.push(current) - return args -} +const activeZhiziAnalyses = new Map() +const GTP_SETUP_TIMEOUT_MS = 60_000 -function redactZhiziLog(text: string): string { - return redactZhiziText(text) +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function cleanVertex(move: string): string { - const value = String(move || '').trim() - return value || 'pass' + return String(move || '').trim() || 'pass' } function opposite(color: GameMove['color']): GameMove['color'] { @@ -113,22 +76,22 @@ function opposite(color: GameMove['color']): GameMove['color'] { } function sideToMove(query: GtpQuery): GameMove['color'] { - const lastMove = query.moves[query.moves.length - 1] - if (lastMove?.[0] === 'B' || lastMove?.[0] === 'W') { - return opposite(lastMove[0]) - } - return query.initialPlayer === 'W' ? 'W' : 'B' + const lastMove = query.moves.at(-1) + return lastMove ? opposite(lastMove[0]) : query.initialPlayer } -function analysisMillis(maxVisits: number | undefined): number { - const visits = Math.max(1, Number(maxVisits ?? 120) || 120) - if (visits <= 32) return 350 - if (visits <= 80) return 550 - if (visits <= 180) return 900 - if (visits <= 360) return 1400 - if (visits <= 800) return 2200 - if (visits <= 1600) return 3600 - return 5600 +function asGtpQuery(query: Record & { id?: string }): GtpQuery { + return { + id: query.id || `zhizi-${Date.now()}-${Math.random().toString(36).slice(2)}`, + moves: Array.isArray(query.moves) ? query.moves as Array<[GameMove['color'], string]> : [], + initialStones: Array.isArray(query.initialStones) ? query.initialStones as Array<[GameMove['color'], string]> : [], + initialPlayer: query.initialPlayer === 'W' ? 'W' : 'B', + boardXSize: Math.max(2, Math.round(Number(query.boardXSize ?? 19))), + boardYSize: Math.max(2, Math.round(Number(query.boardYSize ?? query.boardXSize ?? 19))), + komi: Number.isFinite(Number(query.komi)) ? Number(query.komi) : 7.5, + maxVisits: Math.max(1, Math.round(Number(query.maxVisits ?? 120))), + allowMoves: Array.isArray(query.allowMoves) ? query.allowMoves as GtpQuery['allowMoves'] : undefined + } } function emitAnalyzeProgress( @@ -138,236 +101,188 @@ function emitAnalyzeProgress( text: string, state: { lastVisits: number; lastSampleAt: number }, isDuringSearch: boolean -): void { - if (!/\binfo\s+/.test(text)) return +): ZhiziGtpAnalysisResponse | null { + if (!/\binfo\s+/.test(text)) return null const response = parseKataAnalyzeInfo(text, player, queryId) - if (response.error || !response.moveInfos?.length) return + if (response.error || !response.moveInfos?.length) return null const visits = zhiziResponseVisitTotal(response) - if (visits <= state.lastVisits) return - const now = Date.now() - const elapsedSeconds = Math.max(0.1, (now - state.lastSampleAt) / 1000) - const visitsDelta = visits - state.lastVisits - state.lastVisits = visits - state.lastSampleAt = now - const visitsPerSecond = visitsDelta / elapsedSeconds - request.onResponse?.({ ...response, isDuringSearch }) - request.onSearchProgress?.({ - id: queryId, - visits, - visitsPerSecond, - isDuringSearch - }) -} - -function hasGtpTerminator(text: string): boolean { - return /\r?\n\r?\n/.test(text) -} - -function countGtpResponses(text: string): number { - return (text.match(/\r?\n\r?\n/g) ?? []).length -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -export function zhiziGtpConfigured(settings: AppSettings): boolean { - if (settings.zhiziToken.trim()) { - return true + if (visits > state.lastVisits) { + const now = Date.now() + const elapsedSeconds = Math.max(0.1, (now - state.lastSampleAt) / 1000) + const visitsPerSecond = (visits - state.lastVisits) / elapsedSeconds + state.lastVisits = visits + state.lastSampleAt = now + request.onResponse?.({ ...response, isDuringSearch }) + request.onSearchProgress?.({ + id: queryId, + visits, + visitsPerSecond, + isDuringSearch + }) } - return settings.katagoEngineMode === 'zhizi' && Boolean(settings.zhiziClientBin.trim()) + return response } -export function shouldPreferZhiziGtpEngine(settings: AppSettings, localReady: boolean): boolean { - const mode = settings.katagoEngineMode ?? 'auto' - if (mode === 'zhizi') return true - if (mode !== 'auto' || !zhiziGtpConfigured(settings)) return false - if (!settings.zhiziUseWhenLocalSlow) return false - if (!localReady) return false - const speed = Number(settings.katagoBenchmarkVisitsPerSecond || 0) - const threshold = Number(settings.ikatagoSlowThresholdVisitsPerSecond || 0) - return Boolean(speed > 0 && threshold > 0 && speed < threshold) +export function zhiziGtpConfigured(settings: AppSettings): boolean { + return Boolean(settings.zhiziToken.trim()) } -export function buildZhiziGtpCommand(settings: AppSettings): string[] { - if (!settings.zhiziClientBin.trim()) { - throw new Error('智子云旧连接器未配置完整:需要填写 zz-ikatago 客户端路径。普通用户请用账号密码或短信验证码登录智子云直连。') - } - const args: string[] = [] - if (settings.zhiziToken.trim()) { - args.push('--token', settings.zhiziToken.trim()) - } - args.push(...splitCommandLine(settings.zhiziExtraArgs)) - return [settings.zhiziClientBin.trim(), ...args] +export function shouldPreferZhiziGtpEngine(settings: AppSettings, _localReady: boolean): boolean { + return settings.katagoEngineMode === 'zhizi' && zhiziGtpConfigured(settings) } -export function cancelZhiziGtpAnalysis(filter: { runId?: string; group?: KataGoAnalysisGroup }): { cancelled: number } { +export function cancelZhiziGtpAnalysis( + filter: { runId?: string; group?: KataGoAnalysisGroup } +): { cancelled: number } { let cancelled = 0 - for (const [id, entry] of activeZhiziProcesses.entries()) { + for (const [id, entry] of activeZhiziAnalyses.entries()) { const matchesRun = filter.runId ? id === filter.runId : true const matchesGroup = filter.group ? entry.group === filter.group : true if (!matchesRun || !matchesGroup) continue entry.cancelled = true - cancelled += 1 entry.stop() + cancelled += 1 } return { cancelled } } -function asGtpQuery(query: Record & { id?: string }): GtpQuery { - return { - id: query.id || `zhizi-${Date.now()}-${Math.random().toString(36).slice(2)}`, - moves: Array.isArray(query.moves) ? query.moves as Array<[GameMove['color'], string]> : [], - initialStones: Array.isArray(query.initialStones) ? query.initialStones as Array<[GameMove['color'], string]> : [], - initialPlayer: query.initialPlayer === 'W' ? 'W' : 'B', - boardXSize: Number(query.boardXSize ?? 19), - boardYSize: Number(query.boardYSize ?? query.boardXSize ?? 19), - komi: Number(query.komi ?? 7.5), - maxVisits: Number(query.maxVisits ?? 120), - allowMoves: Array.isArray(query.allowMoves) ? query.allowMoves as GtpQuery['allowMoves'] : undefined - } -} - -async function queryZhiziSocketGtpAnalysisBatch(request: ZhiziGtpAnalysisBatchRequest): Promise> { +async function queryZhiziSocketGtpAnalysisBatch( + request: ZhiziGtpAnalysisBatchRequest +): Promise> { if (!request.queries.length) return new Map() - const token = request.settings.zhiziToken.trim() - if (!token) { - throw new Error('智子云未登录:请先在 GoAgent 中输入账号密码登录。') - } + const accountToken = request.settings.zhiziToken.trim() + if (!accountToken) throw new Error('智子云未登录,请先登录后再启用远程分析。') const runId = request.runId || `zhizi-direct-${Date.now()}-${Math.random().toString(36).slice(2)}` const controller = new AbortController() - const activeEntry: ActiveZhiziProcess = { + const activeEntry: ActiveZhiziAnalysis = { group: request.group, cancelled: false, stop: () => controller.abort() } - activeZhiziProcesses.set(runId, activeEntry) - - function cleanup(): void { - const current = activeZhiziProcesses.get(runId) - if (current === activeEntry) activeZhiziProcesses.delete(runId) - } + activeZhiziAnalyses.set(runId, activeEntry) - const args = buildZhiziRemoteArgs(request.settings) const session = getZhiziPersistentSession({ - accountToken: token, - args, - gpuType: request.settings.zhiziGpuType?.trim() || 'vip-share' + accountToken, + args: buildZhiziRemoteArgs(request.settings), + gpuType: request.settings.zhiziGpuType }) - const overallDeadline = Date.now() + ( - request.timeoutMs ?? Math.max(240_000, request.queries.length * 9_000) - ) + const deadline = Date.now() + (request.timeoutMs ?? Math.max(240_000, request.queries.length * 9_000)) + let nextCommandId = 1 - async function sendCommands( + async function sendCommand( channel: ZhiziPersistentSession, - commandLines: string[], - description: string - ): Promise { - if (!commandLines.length) return '' + command: string, + description: string, + timeoutMs = GTP_SETUP_TIMEOUT_MS + ): Promise { + const id = nextCommandId++ const start = channel.output().stdout.length const disconnectVersion = channel.captureDisconnectVersion() - channel.send(`${commandLines.join('\n')}\n`) - // The Socket.IO runner can emit "ready" before a cold 28B KataGo model has - // finished loading. Keep commands buffered and allow the same startup - // window used by the mature LizzieYZY remote flow. - const timeoutMs = Math.max( - ZHIZI_COLD_START_GTP_TIMEOUT_MS, - commandLines.length * 1_200 - ) + const generation = channel.captureGeneration() + channel.send(formatGtpCommand(id, command)) await channel.waitUntil( - () => countGtpResponses(channel.output().stdout.slice(start)) >= commandLines.length, + () => Boolean(findGtpCommandResponse(channel.output().stdout.slice(start), id)), timeoutMs, description, controller.signal, disconnectVersion ) - const text = channel.output().stdout.slice(start) - if (/(?:^|\n)\?/.test(text.trim())) { - throw new Error(`${description}失败。\n${redactZhiziLog(text.trim().slice(0, 1200))}`) + if (channel.captureGeneration() !== generation) { + throw new Error('智子云会话已经更新,已丢弃旧会话响应。') + } + const response = findGtpCommandResponse(channel.output().stdout.slice(start), id) + if (!response?.ok) { + throw new Error(`${description}失败。${response?.firstLine ? ` ${redactZhiziText(response.firstLine)}` : ''}`) + } + } + + async function synchronizeBoard(channel: ZhiziPersistentSession, query: GtpQuery): Promise { + const boardCommand = query.boardXSize === query.boardYSize + ? `boardsize ${query.boardXSize}` + : `rectangular_boardsize ${query.boardXSize} ${query.boardYSize}` + const commands = [ + boardCommand, + 'kata-set-rules chinese', + 'time_settings 0 5 1', + `komi ${query.komi}`, + 'clear_board', + ...query.initialStones.map(([color, point]) => `play ${color} ${cleanVertex(point)}`), + ...query.moves.map(([color, point]) => `play ${color} ${cleanVertex(point)}`) + ] + for (const command of commands) { + await sendCommand(channel, command, '同步远程棋盘') } - return text } async function analyzeQuery( channel: ZhiziPersistentSession, - query: GtpQuery, - boardState: { key: string } + query: GtpQuery ): Promise { channel.clearOutput() - const boardXSize = Math.max(2, Math.round(query.boardXSize || 19)) - const boardYSize = Math.max(2, Math.round(query.boardYSize || boardXSize)) - const boardKey = `${boardXSize}x${boardYSize}` - const setupCommands: string[] = [] - if (boardState.key !== boardKey) { - setupCommands.push( - boardXSize === boardYSize ? `boardsize ${boardXSize}` : `rectangular_boardsize ${boardXSize} ${boardYSize}`, - 'kata-set-rules chinese', - 'time_settings 0 5 1' - ) - boardState.key = boardKey - } - setupCommands.push( - `komi ${Number.isFinite(query.komi) ? query.komi : 7.5}`, - 'clear_board' - ) - for (const [color, point] of query.initialStones ?? []) { - setupCommands.push(`play ${color} ${cleanVertex(point)}`) - } - for (const [color, point] of query.moves) { - setupCommands.push(`play ${color} ${cleanVertex(point)}`) - } - await sendCommands(channel, setupCommands, '智子云 GTP 初始化局面') + await synchronizeBoard(channel, query) const player = sideToMove(query) const allow = (query.allowMoves ?? []) - .filter((entry) => entry.player && entry.moves?.length) + .filter((entry) => entry.player && entry.moves.length) .map((entry) => `allow ${entry.player} ${entry.moves.map(cleanVertex).join(',')} ${Math.max(1, Math.round(entry.untilDepth || 1))}`) .join(' ') + const analyzeId = nextCommandId++ const start = channel.output().stdout.length const disconnectVersion = channel.captureDisconnectVersion() - const commandLine = `kata-analyze ${player} 25 rootInfo true maxmoves 20${allow ? ` ${allow}` : ''}` - channel.send(`${commandLine}\n`) - const searchStartedAt = Date.now() - const progressState = { lastVisits: 0, lastSampleAt: searchStartedAt } - const targetVisits = Math.max(1, Math.round(query.maxVisits ?? 120)) - const queryTimeoutMs = Math.max(15_000, Math.min(120_000, targetVisits * 220)) - let latestResponse: ZhiziGtpAnalysisResponse | null = null - - while (Date.now() - searchStartedAt < queryTimeoutMs && Date.now() < overallDeadline) { - await delay(100) - if (activeEntry.cancelled) throw new Error('智子云分析已取消') + const generation = channel.captureGeneration() + channel.send(formatGtpCommand( + analyzeId, + `kata-analyze ${player} 25 rootInfo true maxmoves 20${allow ? ` ${allow}` : ''}` + )) + const startedAt = Date.now() + const progress = { lastVisits: 0, lastSampleAt: startedAt } + const timeoutMs = Math.max(15_000, Math.min(120_000, query.maxVisits * 220)) + let latest: ZhiziGtpAnalysisResponse | null = null + + while (Date.now() - startedAt < timeoutMs && Date.now() < deadline) { + if (controller.signal.aborted || activeEntry.cancelled) { + throw new Error('智子云分析已取消。') + } + if (channel.captureDisconnectVersion() !== disconnectVersion || channel.captureGeneration() !== generation) { + throw new Error('智子云连接在分析过程中断开。') + } + await delay(80) const output = channel.output().stdout.slice(start) - if (!/\binfo\s+/.test(output)) continue - const response = parseKataAnalyzeInfo(output, player, query.id) - if (response.error || !response.moveInfos?.length) continue - latestResponse = response - emitAnalyzeProgress(request, query.id, player, output, progressState, true) - if (zhiziAnalysisReachedVisits(response, targetVisits)) break + const parsed = emitAnalyzeProgress(request, query.id, player, output, progress, true) + if (!parsed) continue + latest = parsed + if (zhiziAnalysisReachedVisits(parsed, query.maxVisits)) break } - - if (!latestResponse?.moveInfos?.length) { - throw new Error('智子云已连接,但在限定时间内没有返回候选点。') + if (!latest?.moveInfos?.length) { + throw new Error('智子云已连接,但没有返回候选点。') } - channel.send('stop\n') + + const stopId = nextCommandId++ + channel.send(formatGtpCommand(stopId, 'stop')) await channel.waitUntil( - () => hasGtpTerminator(channel.output().stdout.slice(start)), + () => { + const output = channel.output().stdout.slice(start) + return Boolean(findGtpCommandResponse(output, analyzeId) || findGtpCommandResponse(output, stopId)) + }, 12_000, - '智子云 kata-analyze', + '停止远程分析', controller.signal, disconnectVersion ) + if (channel.captureGeneration() !== generation) { + throw new Error('智子云会话已经更新,已丢弃旧会话结果。') + } const finalOutput = channel.output().stdout.slice(start) - const parsedFinal = parseKataAnalyzeInfo(finalOutput, player, query.id) - const response = parsedFinal.moveInfos?.length ? parsedFinal : latestResponse - emitAnalyzeProgress(request, query.id, player, finalOutput, progressState, false) - request.onResponse?.(response) - return response + const final = parseKataAnalyzeInfo(finalOutput, player, query.id) + const result = final.moveInfos?.length ? final : latest + emitAnalyzeProgress(request, query.id, player, finalOutput, progress, false) + request.onResponse?.(result) + return result } try { return await session.runExclusive(controller.signal, async (channel) => { const results = new Map() - const boardState = { key: '' } let firstCompletedAt = 0 for (const rawQuery of request.queries) { const query = asGtpQuery(rawQuery) @@ -375,25 +290,17 @@ async function queryZhiziSocketGtpAnalysisBatch(request: ZhiziGtpAnalysisBatchRe for (let attempt = 1; attempt <= 3; attempt += 1) { try { if (attempt > 1) { + nextCommandId = 1 await channel.restart(controller.signal) - boardState.key = '' } - const response = await analyzeQuery(channel, query, boardState) - if (response.id) results.set(response.id, response) - if (!firstCompletedAt) firstCompletedAt = Date.now() + const response = await analyzeQuery(channel, query) + results.set(query.id, response) + firstCompletedAt ||= Date.now() lastError = null break } catch (cause) { - lastError = classifyZhiziRemoteError( - cause, - request.settings.zhiziGpuType?.trim() || 'vip-share' - ) - if ( - !lastError.retryable || - lastError.code === 'cancelled' || - activeEntry.cancelled || - attempt >= 3 - ) { + lastError = classifyZhiziRemoteError(cause, request.settings.zhiziGpuType) + if (!lastError.retryable || lastError.code === 'cancelled' || activeEntry.cancelled || attempt >= 3) { throw lastError } await delay(attempt * 500) @@ -403,197 +310,24 @@ async function queryZhiziSocketGtpAnalysisBatch(request: ZhiziGtpAnalysisBatchRe if ( request.resolvePartialAfterMs && results.size < request.queries.length && - firstCompletedAt > 0 && + firstCompletedAt && Date.now() - firstCompletedAt >= request.resolvePartialAfterMs - ) { - break - } + ) break } return results }) } catch (cause) { - throw classifyZhiziRemoteError( - cause, - request.settings.zhiziGpuType?.trim() || 'vip-share' - ) + throw classifyZhiziRemoteError(cause, request.settings.zhiziGpuType) } finally { - cleanup() + if (activeZhiziAnalyses.get(runId) === activeEntry) activeZhiziAnalyses.delete(runId) } } -async function queryZhiziSpawnGtpAnalysisBatch(request: ZhiziGtpAnalysisBatchRequest): Promise> { - if (!request.queries.length) return new Map() - const command = buildZhiziGtpCommand(request.settings) - const child = spawn(command[0], command.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] }) - const runId = request.runId || `zhizi-${Date.now()}-${Math.random().toString(36).slice(2)}` - const activeEntry: ActiveZhiziProcess = { group: request.group, cancelled: false, stop: () => child.kill() } - activeZhiziProcesses.set(runId, activeEntry) - - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk) => { - stdout += String(chunk) - }) - child.stderr.on('data', (chunk) => { - stderr = (stderr + String(chunk)).slice(-40_000) - }) - let childBoardKey = '' - - function cleanup(): void { - const current = activeZhiziProcesses.get(runId) - if (current === activeEntry) activeZhiziProcesses.delete(runId) - } - - function waitUntil(predicate: () => boolean, timeoutMs: number, description: string): Promise { - const started = Date.now() - return new Promise((resolve, reject) => { - const timer = setInterval(() => { - if (activeEntry.cancelled) { - clearInterval(timer) - reject(new Error('智子云分析已取消')) - return - } - if (predicate()) { - clearInterval(timer) - resolve() - return - } - if (Date.now() - started > timeoutMs) { - clearInterval(timer) - reject(new Error(`${description}超时。\n${redactZhiziLog(stderr.trim().slice(-1200))}`)) - } - }, 50) - }) - } - - async function sendCommands(commandLines: string[], description: string): Promise { - if (!commandLines.length) return '' - const start = stdout.length - child.stdin.write(`${commandLines.join('\n')}\n`) - const timeoutMs = Math.max(10_000, commandLines.length * 600) - await waitUntil(() => countGtpResponses(stdout.slice(start)) >= commandLines.length, timeoutMs, description) - const text = stdout.slice(start) - if (/(?:^|\n)\?/.test(text.trim())) { - throw new Error(`${description}失败。\n${redactZhiziLog(text.trim().slice(0, 1200))}`) - } - return text - } - - async function analyzeQuery(query: GtpQuery): Promise { - const boardXSize = Math.max(2, Math.round(query.boardXSize || 19)) - const boardYSize = Math.max(2, Math.round(query.boardYSize || boardXSize)) - const boardKey = `${boardXSize}x${boardYSize}` - const setupCommands: string[] = [] - if (childBoardKey !== boardKey) { - setupCommands.push( - boardXSize === boardYSize ? `boardsize ${boardXSize}` : `rectangular_boardsize ${boardXSize} ${boardYSize}`, - 'kata-set-rules chinese', - 'time_settings 0 5 1' - ) - childBoardKey = boardKey - } - setupCommands.push( - `komi ${Number.isFinite(query.komi) ? query.komi : 7.5}`, - 'clear_board' - ) - for (const [color, point] of query.initialStones ?? []) { - setupCommands.push(`play ${color} ${cleanVertex(point)}`) - } - for (const [color, point] of query.moves) { - setupCommands.push(`play ${color} ${cleanVertex(point)}`) - } - await sendCommands(setupCommands, '智子云 GTP 初始化局面') - const player = sideToMove(query) - const allow = (query.allowMoves ?? []) - .filter((entry) => entry.player && entry.moves?.length) - .map((entry) => `allow ${entry.player} ${entry.moves.map(cleanVertex).join(',')} ${Math.max(1, Math.round(entry.untilDepth || 1))}`) - .join(' ') - const start = stdout.length - const commandLine = `kata-analyze ${player} 8 rootInfo true maxmoves 20${allow ? ` ${allow}` : ''}` - child.stdin.write(`${commandLine}\n`) - const searchStartedAt = Date.now() - const progressState = { lastVisits: 0, lastSampleAt: searchStartedAt } - const durationMs = analysisMillis(query.maxVisits) - while (Date.now() - searchStartedAt < durationMs) { - await delay(300) - if (activeEntry.cancelled) throw new Error('智子云分析已取消') - emitAnalyzeProgress(request, query.id, player, stdout.slice(start), progressState, true) - } - child.stdin.write('\n') - await waitUntil(() => hasGtpTerminator(stdout.slice(start)), 15_000, '智子云 kata-analyze') - const response = parseKataAnalyzeInfo(stdout.slice(start), player, query.id) - emitAnalyzeProgress(request, query.id, player, stdout.slice(start), progressState, false) - request.onResponse?.(response) - return response - } - - return new Promise((resolve, reject) => { - let settled = false - const results = new Map() - const timeout = setTimeout(() => { - if (settled) return - settled = true - activeEntry.cancelled = true - child.kill() - cleanup() - reject(new Error(`智子云分析超时。\n${redactZhiziLog(stderr.trim().slice(-1200))}`)) - }, request.timeoutMs ?? Math.max(240_000, request.queries.length * 9000)) - - child.once('error', (error) => { - if (settled) return - settled = true - clearTimeout(timeout) - cleanup() - reject(error) - }) - child.once('close', (code) => { - if (settled) return - settled = true - clearTimeout(timeout) - cleanup() - if (activeEntry.cancelled) reject(new Error('智子云分析已取消')) - else reject(new Error(redactZhiziLog(stderr.trim()) || `智子云客户端退出: ${code}`)) - }) - - ;(async () => { - try { - await waitUntil(() => /GTP ready|beginning main protocol loop/i.test(stderr + stdout), 90_000, '智子云 KataGo 启动') - for (const rawQuery of request.queries) { - const response = await analyzeQuery(asGtpQuery(rawQuery)) - if (response.id) { - results.set(response.id, response) - } - } - settled = true - clearTimeout(timeout) - child.stdin.write('quit\n') - child.kill() - cleanup() - resolve(results) - } catch (error) { - settled = true - clearTimeout(timeout) - child.kill() - cleanup() - reject(error) - } - })().catch((error) => reject(error)) - }) -} - -export async function queryZhiziGtpAnalysisBatch(request: ZhiziGtpAnalysisBatchRequest): Promise> { - if (request.settings.zhiziToken.trim()) { - try { - return await queryZhiziSocketGtpAnalysisBatch(request) - } catch (error) { - if (!request.settings.zhiziClientBin.trim()) { - throw error - } - console.warn('Zhizi direct Socket.IO engine failed; falling back to zz-ikatago connector.', error) - } - } - if (request.settings.katagoEngineMode === 'zhizi' && !request.settings.zhiziClientBin.trim()) { - throw new Error('智子云未登录:请先在设置里用账号密码或短信验证码登录智子云。登录成功后 GoAgent 会自动直连远程算力,不需要填写 zz-ikatago 路径。') +export async function queryZhiziGtpAnalysisBatch( + request: ZhiziGtpAnalysisBatchRequest +): Promise> { + if (!request.settings.zhiziToken.trim()) { + throw new Error('智子云未登录,请先在设置中登录并检测连接。') } - return queryZhiziSpawnGtpAnalysisBatch(request) + return queryZhiziSocketGtpAnalysisBatch(request) } diff --git a/src/main/services/zhiziGtpProtocol.ts b/src/main/services/zhiziGtpProtocol.ts index 0e53187..ba73ce3 100644 --- a/src/main/services/zhiziGtpProtocol.ts +++ b/src/main/services/zhiziGtpProtocol.ts @@ -1,4 +1,10 @@ -import type { AppSettings, GameMove } from '@main/lib/types' +import type { + AppSettings, + GameMove, + ZhiziGpuType, + ZhiziKataName, + ZhiziKataWeight +} from '@main/lib/types' export type ZhiziGtpAnalysisResponse = Record & { id?: string @@ -25,6 +31,9 @@ const GTP_VALUE_KEYS = new Set([ 'scoreSelfplay', 'prior', 'lcb', + 'utilityLcb', + 'weight', + 'noResultValue', 'order', 'pv', 'pvVisits', @@ -34,86 +43,55 @@ const GTP_VALUE_KEYS = new Set([ 'rootInfo' ]) -const MANAGED_REMOTE_OPTIONS = new Set([ - '--token', - '--platform', - '--engine-type', - '--gpu-type', - '--kata-name', - '--kata-weight' -]) - -function splitCommandLine(input: string): string[] { - const args: string[] = [] - let current = '' - let quote: '"' | "'" | '' = '' - let escaping = false - for (const char of input) { - if (escaping) { - current += char - escaping = false - continue - } - if (char === '\\') { - escaping = true - continue - } - if (quote) { - if (char === quote) quote = '' - else current += char - continue - } - if (char === '"' || char === "'") { - quote = char - continue - } - if (/\s/.test(char)) { - if (current) { - args.push(current) - current = '' - } - continue - } - current += char - } - if (escaping) current += '\\' - if (current) args.push(current) - return args -} +export const ZHIZI_GPU_TYPES = ['vip-share', '1x', '3x', '6x', '12x', '24x'] as const +export const ZHIZI_KATA_NAMES = ['katago-TENSORRT', 'katago-CUDA'] as const +export const ZHIZI_KATA_WEIGHTS = ['18bnbt', 'fdx', '28bnbt'] as const -function shellQuote(value: string): string { - if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) return value - return `'${value.replace(/'/g, `'\\''`)}'` -} - -function sanitizedRemoteExtraArgs(input: string): string[] { - const source = splitCommandLine(input) - const sanitized: string[] = [] - for (let index = 0; index < source.length; index += 1) { - const argument = source[index] - const option = argument.split('=', 1)[0] - if (MANAGED_REMOTE_OPTIONS.has(option)) { - if (!argument.includes('=') && index + 1 < source.length) index += 1 - continue - } - sanitized.push(argument) - } - return sanitized +function allowedValue(value: string, allowed: readonly T[], fallback: T): T { + return allowed.includes(value as T) ? value as T : fallback } export function buildZhiziRemoteArgs( - settings: Pick + settings: Pick ): string { - const gpuType = settings.zhiziGpuType?.trim() || 'vip-share' - const args = [ + const gpuType = allowedValue(settings.zhiziGpuType, ZHIZI_GPU_TYPES, 'vip-share') + const kataName = allowedValue(settings.zhiziKataName, ZHIZI_KATA_NAMES, 'katago-TENSORRT') + const kataWeight = allowedValue(settings.zhiziKataWeight, ZHIZI_KATA_WEIGHTS, '28bnbt') + return [ '--platform', 'all', '--engine-type', 'go', '--gpu-type', gpuType, - '--kata-name', 'katago-TENSORRT', - '--kata-weight', '28bnbt' - ] - args.push(...sanitizedRemoteExtraArgs(settings.zhiziExtraArgs)) - return args.map(shellQuote).join(' ') + '--kata-name', kataName, + '--kata-weight', kataWeight + ].join(' ') +} + +export interface GtpCommandResponse { + id: number + ok: boolean + firstLine: string +} + +export function formatGtpCommand(id: number, command: string): string { + return `${Math.max(1, Math.trunc(id))} ${command.trim()}\n` +} + +export function parseGtpCommandResponses(text: string): GtpCommandResponse[] { + const responses: GtpCommandResponse[] = [] + for (const rawLine of text.replace(/\r/g, '').split('\n')) { + const match = rawLine.match(/^([=?])(\d+)(?:\s+(.*))?$/) + if (!match) continue + responses.push({ + id: Number(match[2]), + ok: match[1] === '=', + firstLine: match[3] ?? '' + }) + } + return responses +} + +export function findGtpCommandResponse(text: string, id: number): GtpCommandResponse | undefined { + return parseGtpCommandResponses(text).find((response) => response.id === id) } function normalizeRate(value: number): number { diff --git a/src/main/services/zhiziPayment.ts b/src/main/services/zhiziPayment.ts new file mode 100644 index 0000000..a9749a8 --- /dev/null +++ b/src/main/services/zhiziPayment.ts @@ -0,0 +1,166 @@ +import QRCode from 'qrcode' +import type { + ZhiziMembershipProduct, + ZhiziPaymentCreateRequest, + ZhiziPaymentSession +} from '@main/lib/types' +import { + asZhiziApiError, + createZhiziOrder, + getZhiziOrder, + listZhiziMembershipProducts +} from './zhiziApiClient' + +const activeOrders = new Map>() +let createOrderPromise: Promise | null = null + +export function yuanTextToFen(input: string): number { + const normalized = input.trim() + if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) { + throw new Error('请输入正确的充值金额,最多保留两位小数。') + } + const [yuan, decimals = ''] = normalized.split('.') + const fen = Number(yuan) * 100 + Number(decimals.padEnd(2, '0')) + if (!Number.isSafeInteger(fen) || fen <= 0) { + throw new Error('充值金额必须大于 0。') + } + return fen +} + +function productForName( + products: ZhiziMembershipProduct[], + productName: ZhiziMembershipProduct['name'] | undefined +): ZhiziMembershipProduct { + const product = products.find((candidate) => candidate.name === productName) + if (!product) { + throw new Error('所选 VIP 套餐已经变化,请刷新套餐后重新选择。') + } + return product +} + +async function buildPaymentSession( + token: string, + request: ZhiziPaymentCreateRequest +): Promise { + let amountFen: number + let productName: ZhiziMembershipProduct['name'] | undefined + let body: Record + + if (request.kind === 'membership') { + const product = productForName(await listZhiziMembershipProducts(), request.productName) + amountFen = product.priceFen + productName = product.name + body = { + payType: 'WECHAT', + amount: amountFen, + tradeType: 'NATIVE', + body: 'GoAgent Zhizi VIP membership', + orderType: 'PURCHASE_PRODUCT', + productName, + extraInfo: { autoRenew: false } + } + } else { + amountFen = Number(request.amountFen) + if (!Number.isSafeInteger(amountFen) || amountFen <= 0) { + throw new Error('充值金额无效。') + } + body = { + payType: 'WECHAT', + amount: amountFen, + tradeType: 'NATIVE', + body: 'GoAgent Zhizi account top-up' + } + } + + const order = await createZhiziOrder(token, body) + const codeURL = order.nativePayRequest?.codeURL + if (order.paidStatus === 'PENDING' && !codeURL) { + throw new Error('智子云没有返回微信支付二维码,请创建新订单。') + } + const qrImageDataUrl = codeURL + ? await QRCode.toDataURL(codeURL, { + errorCorrectionLevel: 'M', + margin: 1, + width: 360, + color: { dark: '#16211f', light: '#ffffff' } + }) + : undefined + const session: ZhiziPaymentSession = { + orderId: order.id, + kind: request.kind, + amountFen: order.amount, + productName, + status: order.paidStatus, + qrImageDataUrl, + createdAt: order.createdAt, + paidAt: order.paidAt ?? undefined + } + activeOrders.set(order.id, { kind: request.kind, productName, amountFen: order.amount }) + return session +} + +export async function createZhiziPaymentSession( + token: string, + request: ZhiziPaymentCreateRequest +): Promise { + if (createOrderPromise || activeOrders.size > 0) { + throw new Error('已有支付订单正在处理,请先完成或关闭当前订单。') + } + createOrderPromise = buildPaymentSession(token, request).finally(() => { + createOrderPromise = null + }) + return createOrderPromise +} + +export async function refreshZhiziPaymentSession( + token: string, + orderId: string +): Promise { + const active = activeOrders.get(orderId) + if (!active) { + throw new Error('支付会话已结束,请创建新订单。') + } + try { + const order = await getZhiziOrder(token, orderId) + const terminal = order.paidStatus === 'SUCCESS' || order.paidStatus === 'FAIL' + if (terminal) activeOrders.delete(orderId) + return { + orderId: order.id, + kind: active.kind, + amountFen: order.amount, + productName: active.productName, + status: order.paidStatus, + createdAt: order.createdAt, + paidAt: order.paidAt ?? undefined, + error: order.paidStatus === 'FAIL' + ? { + code: 'payment-failed', + retryable: false, + message: '支付没有完成,请创建新订单后重试。' + } + : undefined + } + } catch (cause) { + return { + orderId, + kind: active.kind, + amountFen: active.amountFen, + productName: active.productName, + status: 'PENDING', + error: asZhiziApiError(cause) + } + } +} + +export function cancelZhiziPaymentSession(orderId: string): ZhiziPaymentSession | null { + const active = activeOrders.get(orderId) + if (!active) return null + activeOrders.delete(orderId) + return { + orderId, + kind: active.kind, + amountFen: active.amountFen, + productName: active.productName, + status: 'CANCELLED' + } +} diff --git a/src/main/services/zhiziSocketSession.ts b/src/main/services/zhiziSocketSession.ts index 14a194f..64b2186 100644 --- a/src/main/services/zhiziSocketSession.ts +++ b/src/main/services/zhiziSocketSession.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { io } from 'socket.io-client' +import { asZhiziApiError, createZhiziSocketToken } from './zhiziApiClient' export type ZhiziRemoteErrorCode = | 'cancelled' @@ -47,6 +48,7 @@ export interface ZhiziSessionTelemetry { reusedConnections: number connectionCount: number lastReadyMillis: number + generation: number lastErrorCode?: ZhiziRemoteErrorCode lastError?: string } @@ -59,8 +61,6 @@ interface ZhiziSessionDependencies { } const READY_TIMEOUT_MS = 60_000 -const RECONNECT_WAIT_MS = 8_000 -const READY_FALLBACK_MS = 4_000 const MAX_START_ATTEMPTS = 3 const OUTPUT_LIMIT = 8 * 1024 * 1024 const VIP_IDLE_TIMEOUT_MS = 5 * 60_000 @@ -179,81 +179,25 @@ export function zhiziStartupRetryDelayMs(completedAttempt: number): number { return 10_000 } -async function fetchSocketTokenOnce( +export async function fetchZhiziSocketToken( accountToken: string, args: string ): Promise { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), 20_000) try { - const response = await fetch( - 'https://www.zhizigo.com/api/cluster/account/fetch-socketio-token', - { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: `Bearer ${accountToken}` - }, - body: JSON.stringify({ args }), - signal: controller.signal - } - ) - const rawText = await response.text() - let body: Record = {} - try { - body = rawText ? JSON.parse(rawText) as Record : {} - } catch { - if (!response.ok) { - throw classifyZhiziRemoteError(`HTTP ${response.status} ${rawText.slice(0, 240)}`) - } - const contentType = response.headers.get('content-type') ?? 'unknown' - const preview = redactZhiziText(rawText.replace(/\s+/g, ' ').trim().slice(0, 180)) - throw new ZhiziRemoteError( - 'protocol', - `智子云连接接口返回了无法识别的内容(HTTP ${response.status} · ${contentType})。${preview ? ` ${preview}` : ''}`, - true - ) - } - if (!response.ok) { - const detail = String(body.key ?? body.error ?? body.message ?? rawText).slice(0, 240) - throw classifyZhiziRemoteError(`HTTP ${response.status} ${detail}`) - } - const socketIOURL = typeof body.socketIOURL === 'string' ? body.socketIOURL.trim() : '' - const token = typeof body.token === 'string' ? body.token.trim() : '' - if (!socketIOURL || !token) { - throw new ZhiziRemoteError( - 'protocol', - '智子云连接信息不完整,请稍后重试。', - true - ) - } - return { socketIOURL, token } + const token = await createZhiziSocketToken(accountToken, args) + return { socketIOURL: token.socketIOURL, token: token.token } } catch (cause) { - if (cause instanceof Error && cause.name === 'AbortError') { - throw new ZhiziRemoteError('timeout', '智子云连接令牌获取超时。', true) - } - throw classifyZhiziRemoteError(cause) - } finally { - clearTimeout(timer) - } -} - -export async function fetchZhiziSocketToken( - accountToken: string, - args: string -): Promise { - let lastError: ZhiziRemoteError | null = null - for (let attempt = 1; attempt <= 2; attempt += 1) { - try { - return await fetchSocketTokenOnce(accountToken, args) - } catch (cause) { - lastError = classifyZhiziRemoteError(cause) - if (!lastError.retryable || attempt >= 2) throw lastError - await sleep(650) + const error = asZhiziApiError(cause) + if (error.code === 'insufficient-credit' || error.code === 'capacity-unavailable') { + const gpuType = args.includes('--gpu-type vip-share') ? 'vip-share' : '1x' + throw classifyZhiziRemoteError(error.key ?? error.message, gpuType) } + throw new ZhiziRemoteError( + error.code === 'unauthorized' ? 'auth' : error.code === 'timeout' ? 'timeout' : 'network', + error.message, + error.retryable + ) } - throw lastError ?? new ZhiziRemoteError('unknown', '智子云连接令牌获取失败。', true) } const defaultDependencies: ZhiziSessionDependencies = { @@ -280,8 +224,8 @@ export class ZhiziPersistentSession { private reusedConnections = 0 private lastReadyMillis = 0 private disconnectVersion = 0 + private generation = 0 private connectErrorCount = 0 - private readyFallbackTimer: NodeJS.Timeout | null = null private idleTimer: NodeJS.Timeout | null = null constructor( @@ -304,6 +248,7 @@ export class ZhiziPersistentSession { reusedConnections: this.reusedConnections, connectionCount: this.connectionCount, lastReadyMillis: this.lastReadyMillis, + generation: this.generation, lastErrorCode: this.lastError?.code, lastError: this.lastError?.message } @@ -322,6 +267,10 @@ export class ZhiziPersistentSession { return this.disconnectVersion } + captureGeneration(): number { + return this.generation + } + send(command: string): void { if (this.closed) { throw new ZhiziRemoteError('network', '智子云连接已经关闭。', true) @@ -418,14 +367,8 @@ export class ZhiziPersistentSession { if (this.closed) throw new ZhiziRemoteError('network', '智子云连接已经关闭。', true) if (this.ready && this.socket?.connected) return - if (this.socket && this.everReady && !this.ready) { - try { - await this.waitForExistingReconnect(signal) - if (this.ready && this.socket?.connected) return - } catch (cause) { - if (classifyZhiziRemoteError(cause, this.config.gpuType).code === 'cancelled') throw cause - this.disposeSocket(false) - } + if (this.socket && (!this.socket.connected || !this.ready)) { + this.disposeSocket(false) } if (!this.connectionPromise) { @@ -436,16 +379,6 @@ export class ZhiziPersistentSession { await this.connectionPromise } - private async waitForExistingReconnect(signal: AbortSignal): Promise { - const started = this.dependencies.now() - while (this.dependencies.now() - started < RECONNECT_WAIT_MS) { - if (signal.aborted) throw new ZhiziRemoteError('cancelled', '智子云分析已取消。', false) - if (this.ready && this.socket?.connected) return - await this.dependencies.sleep(50) - } - throw new ZhiziRemoteError('network', '智子云自动重连未及时恢复。', true) - } - private async startWithRetries(signal: AbortSignal): Promise { let lastError: ZhiziRemoteError | null = null for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt += 1) { @@ -470,6 +403,7 @@ export class ZhiziPersistentSession { this.ready = false this.lastError = null this.connectErrorCount = 0 + this.generation += 1 this.clearOutput() const socketToken = await this.dependencies.fetchSocketToken( @@ -483,10 +417,7 @@ export class ZhiziPersistentSession { query: { 'zz-socketio-token': socketToken.token }, transports: ['websocket'], timeout: 30_000, - reconnection: true, - reconnectionAttempts: Number.POSITIVE_INFINITY, - reconnectionDelay: 1_200, - reconnectionDelayMax: 8_000, + reconnection: false, forceNew: true, autoConnect: false }) @@ -517,8 +448,7 @@ export class ZhiziPersistentSession { if (!isCurrent()) return this.connectErrorCount = 0 this.lastError = null - this.state = this.everReady ? 'reconnecting' : 'connecting' - if (this.everReady) this.scheduleReadyFallback(socket) + this.state = 'connecting' }) socket.on('ready', () => { if (!isCurrent()) return @@ -527,9 +457,6 @@ export class ZhiziPersistentSession { socket.on('stdout', (payload) => { if (!isCurrent()) return this.stdout = this.appendLimited(this.stdout, decodeZhiziSocketPayload(payload)) - if (!this.ready && socket.connected && /GTP ready|beginning main protocol loop/i.test(this.stdout)) { - this.markReady() - } }) socket.on('stderr', (payload) => { if (!isCurrent()) return @@ -538,9 +465,6 @@ export class ZhiziPersistentSession { if (!this.ready && /not_enough_credit|unauthorized|forbidden|no worker|worker unavailable/i.test(text)) { this.lastError = classifyZhiziRemoteError(text, this.config.gpuType) } - if (!this.ready && socket.connected && /GTP ready|beginning main protocol loop/i.test(text)) { - this.markReady() - } }) socket.on('connect_error', (cause) => { if (!isCurrent()) return @@ -552,7 +476,7 @@ export class ZhiziPersistentSession { if (!isCurrent()) return this.ready = false this.disconnectVersion += 1 - this.clearReadyFallback() + this.generation += 1 this.state = 'reconnecting' const detail = typeof reason === 'string' ? reason : String(reason ?? '') const stderrTail = this.stderr.trim().slice(-800) @@ -564,36 +488,18 @@ export class ZhiziPersistentSession { } private markReady(): void { - this.clearReadyFallback() this.ready = true this.everReady = true this.lastError = null this.state = 'ready' } - private scheduleReadyFallback(socket: ZhiziSocketLike): void { - this.clearReadyFallback() - this.readyFallbackTimer = setTimeout(() => { - if (!this.closed && this.socket === socket && socket.connected && !this.ready && this.everReady) { - this.markReady() - } - }, READY_FALLBACK_MS) - } - - private clearReadyFallback(): void { - if (this.readyFallbackTimer) { - clearTimeout(this.readyFallbackTimer) - this.readyFallbackTimer = null - } - } - private appendLimited(current: string, next: string): string { const combined = current + next return combined.length <= OUTPUT_LIMIT ? combined : combined.slice(-OUTPUT_LIMIT) } private disposeSocket(sendQuit: boolean): void { - this.clearReadyFallback() const socket = this.socket this.socket = null this.ready = false @@ -652,7 +558,8 @@ export function getZhiziPersistentSessionTelemetry(): ZhiziSessionTelemetry { ready: false, reusedConnections: 0, connectionCount: 0, - lastReadyMillis: 0 + lastReadyMillis: 0, + generation: 0 } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7716005..37ac23f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -50,12 +50,19 @@ import type { TtsSynthesisRequest, TtsSynthesisResult, TtsVoice, + ZhiziAccountData, ZhiziCloudConnectionTestResult, ZhiziCloudLoginCodeRequest, ZhiziCloudLoginRequest, ZhiziCloudLoginResult, + ZhiziCloudResetPasswordRequest, ZhiziCloudSendCodeRequest, - ZhiziCloudSendCodeResult + ZhiziCloudSendCodeResult, + ZhiziCreditPage, + ZhiziEngineProfile, + ZhiziPaymentCreateRequest, + ZhiziPaymentSession, + ZhiziUsagePage } from '@main/lib/types' import type { DiagnosticsReport } from '@main/services/diagnostics/types' import type { AnalysisSchedulerStats } from '@main/services/analysis/scheduler' @@ -160,13 +167,20 @@ const api = { listLlmModels: (payload: LlmModelsListRequest): Promise => ipcRenderer.invoke('llm:list-models', payload), getSavedLlmApiKey: (): Promise<{ hasKey: boolean; apiKey: string }> => ipcRenderer.invoke('llm:get-saved-api-key'), getSavedIkatagoPassword: (): Promise<{ hasPassword: boolean; password: string }> => ipcRenderer.invoke('ikatago:get-saved-password'), - getSavedZhiziToken: (): Promise<{ hasToken: boolean; token: string }> => ipcRenderer.invoke('zhizi:get-saved-token'), loginZhiziCloudPassword: (payload: ZhiziCloudLoginRequest): Promise => ipcRenderer.invoke('zhizi:login-password', payload), sendZhiziCloudLoginCode: (payload: ZhiziCloudSendCodeRequest): Promise => ipcRenderer.invoke('zhizi:send-code', payload), loginZhiziCloudCode: (payload: ZhiziCloudLoginCodeRequest): Promise => ipcRenderer.invoke('zhizi:login-code', payload), + resetZhiziCloudPassword: (payload: ZhiziCloudResetPasswordRequest): Promise => ipcRenderer.invoke('zhizi:reset-password', payload), + getZhiziAccountData: (): Promise => ipcRenderer.invoke('zhizi:account-data'), + getZhiziUsages: (page = 0, pageSize = 20): Promise => ipcRenderer.invoke('zhizi:usages', page, pageSize), + getZhiziCredits: (page = 0, pageSize = 20): Promise => ipcRenderer.invoke('zhizi:credits', page, pageSize), + createZhiziPayment: (payload: ZhiziPaymentCreateRequest): Promise => ipcRenderer.invoke('zhizi:payment-create', payload), + refreshZhiziPayment: (orderId: string): Promise => ipcRenderer.invoke('zhizi:payment-refresh', orderId), + cancelZhiziPayment: (orderId: string): Promise => ipcRenderer.invoke('zhizi:payment-cancel', orderId), logoutZhiziCloud: (): Promise => ipcRenderer.invoke('zhizi:logout'), testZhiziCloudConnection: (): Promise => ipcRenderer.invoke('zhizi:test-connection'), - enableZhiziCloud: (): Promise => ipcRenderer.invoke('zhizi:enable'), + enableZhiziCloud: (profile: ZhiziEngineProfile): Promise => ipcRenderer.invoke('zhizi:enable', profile), + disableZhiziCloud: (): Promise => ipcRenderer.invoke('zhizi:disable'), openZhiziOfficialApp: (): Promise<{ ok: boolean; url: string }> => ipcRenderer.invoke('zhizi:open-official-app'), inspectTtsAssets: (): Promise => ipcRenderer.invoke('tts:inspect-assets'), listTtsVoices: (): Promise => ipcRenderer.invoke('tts:list-voices'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4897b3b..8eeb99a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -63,6 +63,7 @@ import { UiGallery } from './features/gallery/UiGallery' import { StudentBindingDialog } from './features/student/StudentBindingDialog' import { StudentRailCard } from './features/student/StudentRailCard' import { KataGoAssetsPanel } from './features/settings/KataGoAssetsPanel' +import { ZhiziCloudSettingsPanel, zhiziSettingsNavCopy } from './features/settings/ZhiziCloudSettingsPanel' import { TeacherSpeechControls } from './features/tts/TeacherSpeechControls' import { TtsSettingsPanel } from './features/tts/TtsSettingsPanel' import { TeacherComposerPro } from './features/teacher/TeacherComposerPro' @@ -80,6 +81,7 @@ import { import './features/diagnostics/diagnostics.css' import './features/student/student.css' import './features/teacher/teacher-run-card.css' +import './features/settings/zhizi-cloud.css' const emptyDashboard: DashboardData = { settings: { @@ -108,12 +110,11 @@ const emptyDashboard: DashboardData = { ikatagoExtraArgs: '', ikatagoUseWhenLocalSlow: false, ikatagoSlowThresholdVisitsPerSecond: 120, - zhiziClientBin: '', zhiziUsername: '', zhiziToken: '', zhiziGpuType: 'vip-share', - zhiziExtraArgs: '', - zhiziUseWhenLocalSlow: false, + zhiziKataName: 'katago-TENSORRT', + zhiziKataWeight: '28bnbt', pythonBin: 'python', llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', @@ -1611,12 +1612,6 @@ export function App(): ReactElement { ikatagoExtraArgs: String(formData.get('ikatagoExtraArgs') ?? dashboard.settings.ikatagoExtraArgs), ikatagoUseWhenLocalSlow: formData.get('ikatagoUseWhenLocalSlow') === 'on', ikatagoSlowThresholdVisitsPerSecond: Number(formData.get('ikatagoSlowThresholdVisitsPerSecond') ?? dashboard.settings.ikatagoSlowThresholdVisitsPerSecond), - zhiziClientBin: String(formData.get('zhiziClientBin') ?? dashboard.settings.zhiziClientBin), - zhiziUsername: String(formData.get('zhiziUsername') ?? dashboard.settings.zhiziUsername), - zhiziToken: String(formData.get('zhiziToken') ?? ''), - zhiziGpuType: String(formData.get('zhiziGpuType') ?? dashboard.settings.zhiziGpuType ?? 'vip-share'), - zhiziExtraArgs: String(formData.get('zhiziExtraArgs') ?? dashboard.settings.zhiziExtraArgs), - zhiziUseWhenLocalSlow: formData.get('zhiziUseWhenLocalSlow') === 'on', reviewLanguage: normalizeUiLocale(String(formData.get('reviewLanguage') ?? dashboard.settings.reviewLanguage)), llmBaseUrl: String(formData.get('llmBaseUrl') ?? ''), llmApiKey: String(formData.get('llmApiKey') ?? ''), @@ -4621,7 +4616,7 @@ function UserFacingError({ message, t }: { message: string; t: UiTranslator }): ) } -type SettingsPageId = 'general' | 'ai' | 'katago' | 'voice' | 'about' +type SettingsPageId = 'general' | 'ai' | 'katago' | 'zhizi' | 'voice' | 'about' function SettingsDrawer({ dashboard, @@ -4672,18 +4667,6 @@ function SettingsDrawer({ const [savedLlmApiKey, setSavedLlmApiKey] = useState('') const [showLlmApiKey, setShowLlmApiKey] = useState(false) const [llmKeyMessage, setLlmKeyMessage] = useState('') - const [savedZhiziToken, setSavedZhiziToken] = useState('') - const [showZhiziToken, setShowZhiziToken] = useState(false) - const [zhiziTokenMessage, setZhiziTokenMessage] = useState('') - const [zhiziUsernameInput, setZhiziUsernameInput] = useState(dashboard.settings.zhiziUsername) - const [zhiziLoginPassword, setZhiziLoginPassword] = useState('') - const [zhiziLoginCode, setZhiziLoginCode] = useState('') - const [zhiziLoginBusy, setZhiziLoginBusy] = useState(false) - const [zhiziCodeBusy, setZhiziCodeBusy] = useState(false) - const [zhiziCodeCooldown, setZhiziCodeCooldown] = useState(0) - const [zhiziLoginMessage, setZhiziLoginMessage] = useState('') - const [zhiziTestBusy, setZhiziTestBusy] = useState(false) - const [zhiziTestMessage, setZhiziTestMessage] = useState('') const [activeSettingsPage, setActiveSettingsPage] = useState('ai') const [autoSaveBusy, setAutoSaveBusy] = useState(false) const [autoSaveTick, setAutoSaveTick] = useState(0) @@ -4784,16 +4767,6 @@ function SettingsDrawer({ setSelectedPresetId(dashboard.settings.katagoModelPreset) }, [dashboard.settings.katagoModelPreset]) - useEffect(() => { - setZhiziUsernameInput(dashboard.settings.zhiziUsername) - }, [dashboard.settings.zhiziUsername]) - - useEffect(() => { - if (zhiziCodeCooldown <= 0) return - const timer = window.setTimeout(() => setZhiziCodeCooldown((value) => Math.max(0, value - 1)), 1000) - return () => window.clearTimeout(timer) - }, [zhiziCodeCooldown]) - useEffect(() => { setSelectedLlmModel(dashboard.settings.llmModel) }, [dashboard.settings.llmModel]) @@ -4831,219 +4804,14 @@ function SettingsDrawer({ } } - async function revealSavedZhiziToken(): Promise { - setZhiziTokenMessage('') - try { - const result = await window.goagent.getSavedZhiziToken() - if (!result.hasToken || !result.token) { - setSavedZhiziToken('') - setShowZhiziToken(false) - setZhiziTokenMessage('还没有保存智子云 token。') - return - } - setSavedZhiziToken(result.token) - setShowZhiziToken(true) - } catch (cause) { - setZhiziTokenMessage(`读取智子云 token 失败:${String(cause)}`) - } - } - - async function loginZhiziCloud(): Promise { - setZhiziLoginMessage('') - setZhiziTestMessage('') - const phone = zhiziUsernameInput.trim() - const password = zhiziLoginPassword.trim() - if (!phone || !password) { - setZhiziLoginMessage('请输入智子云账号和密码。') - return - } - if (/^zz[-_]/i.test(phone)) { - setZhiziLoginMessage('zz- 开头的是智子云连接账号,不能直接登录 GoAgent。请填写智子云主账号手机号或邮箱;连接账号需先在智子官方账号体系里绑定。') - return - } - setZhiziLoginBusy(true) - try { - const result = await window.goagent.loginZhiziCloudPassword({ phone, password }) - if (result.dashboard) { - onDashboardUpdated(result.dashboard) - } - setZhiziLoginPassword('') - setSavedZhiziToken('') - setShowZhiziToken(false) - setZhiziLoginMessage(result.message) - } catch (cause) { - setZhiziLoginMessage(`智子云登录失败:${String(cause).replace(/^Error:\\s*/, '')}`) - } finally { - setZhiziLoginBusy(false) - } - } - - async function sendZhiziLoginCode(): Promise { - setZhiziLoginMessage('') - const phone = zhiziUsernameInput.trim() - if (!phone) { - setZhiziLoginMessage('请输入智子云手机号。短信验证码只支持手机号登录。') - return - } - if (!/^\+?\d{6,20}$/.test(phone)) { - setZhiziLoginMessage('短信验证码只支持手机号登录。邮箱请使用密码登录,zz- 连接账号不能直接登录 GoAgent。') - return - } - setZhiziCodeBusy(true) - try { - const result = await window.goagent.sendZhiziCloudLoginCode({ phone }) - setZhiziCodeCooldown(60) - setZhiziLoginMessage(result.message) - } catch (cause) { - setZhiziLoginMessage(`智子云验证码发送失败:${String(cause).replace(/^Error:\s*/, '')}`) - } finally { - setZhiziCodeBusy(false) - } - } - - async function loginZhiziCloudWithCode(): Promise { - setZhiziLoginMessage('') - setZhiziTestMessage('') - const phone = zhiziUsernameInput.trim() - const verificationCode = zhiziLoginCode.trim() - if (!phone || !verificationCode) { - setZhiziLoginMessage('请输入智子云手机号和短信验证码。短信验证码只支持手机号登录。') - return - } - if (!/^\+?\d{6,20}$/.test(phone)) { - setZhiziLoginMessage('短信验证码只支持手机号登录。邮箱请使用密码登录,zz- 连接账号不能直接登录 GoAgent。') - return - } - setZhiziLoginBusy(true) - try { - const result = await window.goagent.loginZhiziCloudCode({ phone, verificationCode }) - if (result.dashboard) { - onDashboardUpdated(result.dashboard) - } - setZhiziLoginPassword('') - setZhiziLoginCode('') - setSavedZhiziToken('') - setShowZhiziToken(false) - setZhiziLoginMessage(result.message) - } catch (cause) { - setZhiziLoginMessage(`智子云验证码登录失败:${String(cause).replace(/^Error:\s*/, '')}`) - } finally { - setZhiziLoginBusy(false) - } - } - - async function logoutZhiziCloud(): Promise { - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - autoSaveTimerRef.current = null - } - setZhiziLoginMessage('') - setZhiziTokenMessage('') - setZhiziTestMessage('') - setZhiziLoginBusy(true) - try { - const result = await window.goagent.logoutZhiziCloud() - if (result.dashboard) { - onDashboardUpdated(result.dashboard) - } - setSavedZhiziToken('') - setShowZhiziToken(false) - setZhiziLoginPassword('') - setZhiziLoginCode('') - setZhiziLoginMessage(result.message) - setZhiziTokenMessage('本地智子云 token 已清除。') - } catch (cause) { - setZhiziLoginMessage(`智子云退出失败:${String(cause).replace(/^Error:\s*/, '')}`) - } finally { - setZhiziLoginBusy(false) - } - } - - async function testZhiziCloudConnection(): Promise { - setZhiziTestMessage('') - setZhiziTestBusy(true) - try { - const result = await window.goagent.testZhiziCloudConnection() - if (result.dashboard) { - onDashboardUpdated(result.dashboard) - } - const detail = result.ok && result.topMove - ? [ - `首选 ${result.topMove}`, - typeof result.visits === 'number' ? `${result.visits} visits` : '', - typeof result.visitsPerSecond === 'number' ? formatSearchSpeed(result.visitsPerSecond) : '' - ].filter(Boolean).join(' · ') - : '' - const timingDetail = result.ok - ? [ - result.sessionReused ? '已复用远程引擎' : '', - typeof result.readyMillis === 'number' && result.readyMillis > 0 ? `启动 ${(result.readyMillis / 1000).toFixed(1)} 秒` : '', - typeof result.analysisMillis === 'number' ? `检测 ${(result.analysisMillis / 1000).toFixed(1)} 秒` : '' - ].filter(Boolean).join(' · ') - : '' - const accountDetail = result.tokenValid - ? [ - '主账号 token 有效', - result.isMembership ? `会员有效${result.membershipExpiresAt ? `至 ${new Date(result.membershipExpiresAt).toLocaleDateString()}` : ''}` : '未检测到会员状态', - result.hasConnectAccount ? `连接账号 ${result.connectUsernameMasked || '已绑定'}` : '未绑定连接账号' - ].join(' · ') - : '' - setZhiziTestMessage( - `${result.message}${detail ? `(${detail})` : ''}${timingDetail ? `\n${timingDetail}` : ''}${accountDetail ? `\n${accountDetail}` : ''}` - ) - } catch (cause) { - setZhiziTestMessage(`智子云连接检测失败:${String(cause).replace(/^Error:\s*/, '')}`) - } finally { - setZhiziTestBusy(false) - } - } - - async function enableZhiziCloudConnection(): Promise { - setZhiziTestMessage('') - setZhiziTestBusy(true) - try { - const result = await window.goagent.enableZhiziCloud() - if (result.dashboard) onDashboardUpdated(result.dashboard) - const detail = result.ok && result.topMove - ? [ - `首选 ${result.topMove}`, - typeof result.visits === 'number' ? `${result.visits} visits` : '', - typeof result.visitsPerSecond === 'number' ? formatSearchSpeed(result.visitsPerSecond) : '' - ].filter(Boolean).join(' · ') - : '' - setZhiziTestMessage(`${result.message}${detail ? `(${detail})` : ''}`) - } catch (cause) { - setZhiziTestMessage(`智子云启用失败:${String(cause).replace(/^Error:\s*/, '')}`) - } finally { - setZhiziTestBusy(false) - } - } - async function saveTtsSettings(next: Partial): Promise { const updated = await window.goagent.updateSettings(next) onDashboardUpdated(updated) } - const katagoEngineModeForSettings: KataGoEngineMode = - dashboard.settings.katagoEngineMode === 'ikatago' || dashboard.settings.katagoEngineMode === 'zhizi' - ? 'auto' - : dashboard.settings.katagoEngineMode - const zhiziRawStatus = dashboard.systemProfile.katagoStatus ?? '' const zhiziEnabled = dashboard.settings.katagoEngineMode === 'zhizi' const zhiziLoggedIn = dashboard.systemProfile.hasZhiziToken - const zhiziReady = /Zhizi Cloud Direct Ready|智子云直连/i.test(zhiziRawStatus) - || (zhiziEnabled && dashboard.systemProfile.katagoReady && /智子|Zhizi/i.test(zhiziRawStatus)) - const zhiziNeedsLogin = /Zhizi Cloud Login Required|智子云需登录|智子云未登录/i.test(zhiziRawStatus) - const zhiziStatusLabel = zhiziReady - ? '已启用' - : zhiziEnabled && zhiziNeedsLogin - ? '需登录' - : zhiziEnabled - ? '待检测' - : zhiziLoggedIn - ? '已登录' - : '未登录' - const zhiziStatusClassName = zhiziReady ? 'settings-status-chip is-ready' : zhiziNeedsLogin ? 'settings-status-chip is-warning' : 'settings-status-chip' + const zhiziNav = zhiziSettingsNavCopy(dashboard.settings.reviewLanguage) const llmReady = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' const katagoReady = Boolean(katagoAssets?.ready || dashboard.systemProfile.katagoReady) const voiceReady = dashboard.settings.ttsEnabled @@ -5083,6 +4851,15 @@ function SettingsDrawer({ status: katagoReady ? t('ready') : t('pendingConfig'), statusClassName: katagoReady ? 'settings-status-chip is-ready' : 'settings-status-chip' }, + { + id: 'zhizi', + icon: '云', + title: zhiziNav.navTitle, + subtitle: zhiziNav.navSubtitle, + summary: zhiziNav.navSummary, + status: zhiziEnabled ? t('ready') : zhiziLoggedIn ? zhiziNav.navSubtitle : t('pendingConfig'), + statusClassName: zhiziEnabled ? 'settings-status-chip is-ready' : 'settings-status-chip' + }, { id: 'voice', icon: '声', @@ -5300,259 +5077,16 @@ function SettingsDrawer({ /> - +