@optima-chat/dev-skills 0.16.7 → 0.16.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/logs.md +1 -1
- package/.claude/commands/query-db.md +6 -1
- package/.claude/skills/discount-codes/SKILL.md +4 -4
- package/.claude/skills/generate-test-token/SKILL.md +8 -4
- package/.claude/skills/logs/SKILL.md +5 -3
- package/.claude/skills/show-env/SKILL.md +1 -1
- package/.claude/skills/yzsgo-e2e/SKILL.md +19 -0
- package/.claude/skills/yzsgo-e2e/SYNC.md +26 -2
- package/.claude/skills/yzsgo-e2e/chat_driver.py +221 -47
- package/.claude/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.claude/skills/yzsgo-e2e/run_e2e.py +17 -2
- package/.codex/skills/generate-test-token/SKILL.md +3 -2
- package/.codex/skills/logs/SKILL.md +4 -2
- package/.codex/skills/show-env/SKILL.md +4 -2
- package/.codex/skills/yzsgo-e2e/SKILL.md +19 -0
- package/.codex/skills/yzsgo-e2e/SYNC.md +26 -2
- package/.codex/skills/yzsgo-e2e/chat_driver.py +221 -47
- package/.codex/skills/yzsgo-e2e/pull_wire.py +39 -8
- package/.codex/skills/yzsgo-e2e/run_e2e.py +17 -2
- package/AGENTS.md +1 -1
- package/bin/cli.js +3 -3
- package/bin/helpers/billing-http.ts +12 -3
- package/bin/helpers/db-utils.ts +8 -5
- package/bin/helpers/generate-test-token.ts +40 -3
- package/bin/helpers/verify-health.ts +77 -29
- package/dist/bin/helpers/billing-http.js +9 -1
- package/dist/bin/helpers/db-utils.js +8 -2
- package/dist/bin/helpers/generate-test-token.js +40 -3
- package/dist/bin/helpers/verify-health.js +83 -27
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: "logs"
|
|
3
|
-
description: "Use when the user asks to inspect service logs, debug runtime failures, or compare CI, Stage, and
|
|
3
|
+
description: "Use when the user asks to inspect service logs, debug runtime failures, or compare logs across CI, Stage, Prod, cn-prod, and cn-stage for Optima services."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Inspect Service Logs
|
|
@@ -14,6 +14,7 @@ Use the local shell and follow the environment-specific workflow:
|
|
|
14
14
|
- `ci`: SSH to the shared CI host and read Docker Compose logs
|
|
15
15
|
- `stage`: read AWS CloudWatch logs from `/ecs/<service>-stage`
|
|
16
16
|
- `prod`: read AWS CloudWatch logs from `/ecs/<service>-prod` with `--region ap-southeast-1`
|
|
17
|
+
- `cn-prod` / `cn-stage`: Alibaba Cloud SAE (cn-beijing) — use `optima-logs <service> --env cn-prod|cn-stage`, which queries SLS directly (no buildbox hop)
|
|
17
18
|
|
|
18
19
|
## Common Services
|
|
19
20
|
|
|
@@ -29,7 +30,8 @@ Use the local shell and follow the environment-specific workflow:
|
|
|
29
30
|
|
|
30
31
|
## Guidance
|
|
31
32
|
|
|
32
|
-
- Default to `ci` unless the user clearly requests `
|
|
33
|
+
- Default to `ci` unless the user clearly requests another environment — note `ci` is the SSH + Docker Compose path, **not** `optima-logs`.
|
|
34
|
+
- 🔴 `optima-logs` with no `--env` defaults to **`cn-prod` (Alibaba Cloud production)**, not `ci` (see `bin/helpers/logs.ts`). Always pass `--env` explicitly, or you will be reading production logs while believing you are reading CI.
|
|
33
35
|
- For `prod`, keep the query narrow and use the exact service the user asked for.
|
|
34
36
|
- When investigating an error, read enough context around the failure instead of only grepping a single line.
|
|
35
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: "show-env"
|
|
3
|
-
description: "Use when the user asks to inspect environment variables, service configuration, or Infisical-backed settings for Stage or
|
|
3
|
+
description: "Use when the user asks to inspect environment variables, service configuration, or Infisical-backed settings for Stage, Prod, cn-prod, or cn-stage."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Inspect Environment Configuration
|
|
@@ -10,7 +10,7 @@ Use this skill to inspect current shell environment variables or service configu
|
|
|
10
10
|
## Preferred Command
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
optima-show-env <service> <stage|prod> [options]
|
|
13
|
+
optima-show-env <service> <stage|prod|cn-prod|cn-stage> [options]
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
## Common Options
|
|
@@ -18,6 +18,7 @@ optima-show-env <service> <stage|prod> [options]
|
|
|
18
18
|
```bash
|
|
19
19
|
optima-show-env commerce-backend stage --filter DATABASE
|
|
20
20
|
optima-show-env user-auth prod --keys-only
|
|
21
|
+
optima-show-env gateway-core cn-prod --filter REDIS
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
## Guidance
|
|
@@ -25,3 +26,4 @@ optima-show-env user-auth prod --keys-only
|
|
|
25
26
|
- For local shell variables, simple shell commands like `env` or `echo $VAR` are enough.
|
|
26
27
|
- For service configuration, prefer `optima-show-env` over raw Infisical API calls.
|
|
27
28
|
- If the user only needs key names, use `--keys-only` to avoid exposing values unnecessarily.
|
|
29
|
+
- `cn-prod` / `cn-stage` read the separate Alibaba Cloud cn Infisical instance and need `INFISICAL_CN_EMAIL` / `INFISICAL_CN_PASSWORD` (or a `~/.infisical_cn_creds` file).
|
|
@@ -27,6 +27,25 @@ allowed-tools: ["Bash", "Read", "Write", "Agent", "Workflow"]
|
|
|
27
27
|
- 每个 ✋ 缺项:引导用户——`chrome-9222`:`python3 $S/bootstrap.py launch-chrome` 起窗口、让用户**手动登测试账号**(登一次长期免登);`buildbox-pw`:让用户把口令放 `~/.buildbox_pw`(内部拉 wire 用,向团队要);`test-user-id`:让用户登录 Optima(run_e2e 自动从 `~/.optima/token.json` 读 userId)。
|
|
28
28
|
- 补完 `python3 $S/preflight.py <env>` 复检,直到全 ✅ 才进四段。
|
|
29
29
|
|
|
30
|
+
## 并发(2026-09-09 起):本 skill 自己开 tab
|
|
31
|
+
|
|
32
|
+
鸭嘴兽支持并发任务了 —— **一个浏览器 tab = 一个独立 gateway session**(一 tab 一 session,
|
|
33
|
+
跨 tab 可真并行,同 tab 内仍串行;上限按 plan:free 1 / starter 2 / pro 4 / enterprise 20)。
|
|
34
|
+
|
|
35
|
+
`chat_driver.attach()` **默认就自己开一个 tab**(`own_tab="auto"`)——平台既然支持多 tab,
|
|
36
|
+
独占一个 session 就是常态,共用别人的 tab 才是例外。所以 `run_e2e.py` 直接 `attach()` 即可,
|
|
37
|
+
不去抢用户已经开着的 chat tab。两个好处——① 不跟用户/别的跑测互相串台;② 拿到确定的 `sessionId`,
|
|
38
|
+
`locate_conversation(..., session_id=...)` 按它**确定性**定位 wire 对话,不再靠 (时间, 首句) 猜
|
|
39
|
+
(并发跑时多个会话时间戳交叠,启发式会挑错)。`meta.json` 里多了 `session_id` 字段。
|
|
40
|
+
|
|
41
|
+
该环境 multi-tab flag 没开(`NEXT_PUBLIC_MULTI_TAB_SESSION`,build-time、**默认关**;
|
|
42
|
+
cn-prod 已验开、cn-stage 未验)时 `"auto"` 档自己会降级复用已有 tab(`d.tab_isolated=False`)
|
|
43
|
+
并打 warn,功能不减,只是 wire 定位退回启发式。
|
|
44
|
+
|
|
45
|
+
🔴 **绝不能让两个 driver 共用一个 tab**:实证会**静默串台**——两个线程写同一个 textarea,
|
|
46
|
+
后写的覆盖先写的,只有一条消息真到服务端,两边却都抓到同一份回复,还全程无报错。
|
|
47
|
+
细节见 `chat_driver.py` 模块 docstring。
|
|
48
|
+
|
|
30
49
|
## 四段流程
|
|
31
50
|
1. **preflight + 自举**:见上「环境自举」,**全绿**才继续。
|
|
32
51
|
2. **驱动 + 拉 wire + 备料**:`$VENV/bin/python $S/run_e2e.py --env <env> --message ... [--answer k=v] [--expect ...] --out <dir>` → 出 `prepped.md` + `meta.json`。🔑 用 **venv 的 python**(playwright 在那儿);`--user` 不给则自动读 `~/.optima/token.json`。
|
|
@@ -2,9 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
| 本文件 | 上游 repo · 路径 | commit | 同步日期 |
|
|
4
4
|
|---|---|---|---|
|
|
5
|
-
| chat_driver.py | optima-store-skills · .claude/skills/operating-yzsgo-chat/chat_driver.py |
|
|
6
|
-
| pull_wire.py | optima-store-skills · .claude/skills/pulling-yzsgo-session-wire/pull_wire.py |
|
|
5
|
+
| chat_driver.py | optima-store-skills · .claude/skills/operating-yzsgo-chat/chat_driver.py | 3cfc2d6 | 2026-09-09 |
|
|
6
|
+
| pull_wire.py | optima-store-skills · .claude/skills/pulling-yzsgo-session-wire/pull_wire.py | 3cfc2d6 | 2026-09-09 |
|
|
7
7
|
| prep_conversation.py | optima-gateway · .claude/skills/conversation-iq/prep_session.py(改编:+浏览器证据合并) | b75f575c | 2026-08-31 |
|
|
8
8
|
| judge_workflow.js | optima-gateway · .claude/skills/conversation-iq/workflow.js(改编:+前后端一致性维度) | b75f575c | 2026-08-31 |
|
|
9
9
|
|
|
10
10
|
> 注:judge_workflow.js 内联的 decideOutcome 是 judge_outcome.js(有 node 单测)的副本,改逻辑需同步两处。
|
|
11
|
+
|
|
12
|
+
## 2026-09-09 这次同步带了什么
|
|
13
|
+
|
|
14
|
+
上游把 e2e 改成**并发跑**(鸭嘴兽支持并发任务:一个 tab = 一个独立 gateway session)。
|
|
15
|
+
`chat_driver.py` 逐字取自上游 `3cfc2d6`(optima-store-skills#206,已合入 main),本次变更点:
|
|
16
|
+
|
|
17
|
+
- `attach()` **默认自己开 tab** 独占 session(`own_tab="auto"`;拿不到独立 session 就降级复用
|
|
18
|
+
已有 tab 并置 `tab_isolated=False`)。`own_tab=True` 是严格档,并行必用——auto 档多 worker
|
|
19
|
+
一起降级会都落到同一个 tab 上 = 静默串台。
|
|
20
|
+
- `session_id` / `close()` 只关自己开的 tab / `concurrency_status()` 读并发名额。
|
|
21
|
+
- send 拒绝分流从三码扩到五码(新增 `concurrency_limit` / `busy_elsewhere`)。
|
|
22
|
+
|
|
23
|
+
`pull_wire.py` 是**改编**(不逐字)。这次做了两件事:
|
|
24
|
+
|
|
25
|
+
1. **补回漏掉的上游修复 #102**(`bfa103c`「末 response 缺失显式标注,不再静默截断」)——
|
|
26
|
+
vendored 副本从 2026-08-31 起就没跟上,一直在静默截断。上游的 4 条回归也一并移植到
|
|
27
|
+
`tests/yzsgo-e2e/test_pull_wire_render.py`(另加一条锁 `render_conversation` 的 6 元 arity,
|
|
28
|
+
因为 `run_e2e.py` 按 6 元解包,少一个会在出报告前一步崩)。
|
|
29
|
+
⚠️ 教训:`verify_drift.py` 对改编文件只标「⚠️(改编·预期)」,**看到这个标记要真的去读上游有什么新提交**,
|
|
30
|
+
不能当成「预期差异」放过——这次就是那么漏掉半个月的。
|
|
31
|
+
2. 自行加了 `locate_conversation(..., session_id=...)`:
|
|
32
|
+
按 gateway sessionId **确定性**定位本轮对话,取代 (时间, 首句) 启发式 —— 并发跑时多个会话
|
|
33
|
+
时间戳交叠,启发式会挑错。传了 sid 却命不中就返回 None,**不静默回退**全局启发式
|
|
34
|
+
(回退会安静地定位到别的 session 的对话)。上游没有这个函数,是本仓独有的腿。
|
|
@@ -11,21 +11,51 @@
|
|
|
11
11
|
|
|
12
12
|
用法:
|
|
13
13
|
from chat_driver import ChatDriver
|
|
14
|
-
d = ChatDriver().attach()
|
|
14
|
+
d = ChatDriver().attach() # 串行:复用页面上已有的 chat tab
|
|
15
|
+
d = ChatDriver().attach(own_tab=True) # 并行:**自己开一个 tab**,独占一个 gateway session
|
|
15
16
|
d.ensure_installed(["briefing-store-status"])
|
|
16
17
|
d.new_conversation()
|
|
17
18
|
r = d.send_and_wait("跑一下老赵店的运营简报", timeout=300)
|
|
18
19
|
print(r["text"], r["elapsed"], r["tool_trace"])
|
|
19
20
|
d.close()
|
|
21
|
+
|
|
22
|
+
🔴 并发形态(2026-09-09 真机坐实,别再按「同一时间只能一个对话」写代码):
|
|
23
|
+
鸭嘴兽已支持并发任务 —— **一个浏览器 tab = 一个独立 gateway session**(agentic-chat ac#957,
|
|
24
|
+
sessionId 存 sessionStorage `optima:gw:sid`,天然 per-tab;F5 靠 sessionAttachProvider attach 回同一个)。
|
|
25
|
+
· 跨 tab = 可并行:实测两 tab 的 LLM 调用在服务端 wire 上真重叠约 3.0s。
|
|
26
|
+
· 同 tab 内 = 仍串行:一个 session 里另一个对话在跑会被 CONCURRENT_CONVERSATION_BLOCKED 拒。
|
|
27
|
+
· 并发上限按 plan 分档(free 1 / starter 2 / pro 4 / enterprise 20),超了 CONCURRENCY_LIMIT_EXCEEDED。
|
|
28
|
+
⇒ 想并行跑 N 个用例,就开 N 个 tab、每 tab 一个 ChatDriver,**不是**在一个 tab 里开 N 个对话。
|
|
29
|
+
|
|
30
|
+
⚠️ 两个 driver 落到**同一个 tab** 会静默串台(2026-09-09 负向对照实证):两个线程往同一个
|
|
31
|
+
textarea 写字,后写的覆盖先写的,**只有一条消息真到服务端**(wire 里那个 session 只有 1 个对话),
|
|
32
|
+
但两边 send() 都返回 True、无 toast、无 console 报错,双方都抓到同一份回复 ——
|
|
33
|
+
A 用例被判在 B 的回复上,harness 任何信号都拦不住。所以并行时必须 `attach(own_tab=True)`。
|
|
20
34
|
"""
|
|
21
35
|
from __future__ import annotations
|
|
22
36
|
|
|
37
|
+
import os
|
|
23
38
|
import time
|
|
24
39
|
|
|
25
40
|
from playwright.sync_api import sync_playwright
|
|
26
41
|
|
|
27
|
-
|
|
42
|
+
# cn-stage 前端是 app.stage.optima.chat(同一套页面),用 YZSGO_CHAT_URL 覆盖;缺省 cn-prod。
|
|
43
|
+
CHAT_URL = os.environ.get("YZSGO_CHAT_URL", "https://www.yzsgo.com/zh-HK/chat")
|
|
28
44
|
SEARCH_PH = "搜尋技能..."
|
|
45
|
+
# 本 tab 认领的 gateway sessionId 存这里(agentic-chat src/lib/session/tabSessionClaim.ts)。
|
|
46
|
+
# sessionStorage 天然 per-tab —— 这是「一 tab 一 session」的物理依据,也是并行隔离的校验口。
|
|
47
|
+
TAB_SID_KEY = "optima:gw:sid"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TabSessionUnavailable(RuntimeError):
|
|
51
|
+
"""新开的 tab 没能拿到**独立**的 gateway session —— 并行不安全,调用方必须退回串行。
|
|
52
|
+
|
|
53
|
+
两种触发(都实证过):
|
|
54
|
+
① claim 超时:页面没在 timeout 内写 sessionStorage(没登录 / 连不上 gateway);
|
|
55
|
+
② sid 与已有 tab 撞车:该环境 `NEXT_PUBLIC_MULTI_TAB_SESSION` 没开(build-time flag,
|
|
56
|
+
默认关,见 agentic-chat src/lib/feature-flags.ts),新 tab 会被 gateway 的
|
|
57
|
+
createOrResolveSession 并回同一个 session。cn-prod 已开、cn-stage 未验。
|
|
58
|
+
绝不能降级成「那就共用一个 tab 吧」——那正是静默串台的成因。"""
|
|
29
59
|
|
|
30
60
|
|
|
31
61
|
class ChatDriver:
|
|
@@ -34,36 +64,134 @@ class ChatDriver:
|
|
|
34
64
|
self._pw = None
|
|
35
65
|
self.browser = None
|
|
36
66
|
self.page = None
|
|
67
|
+
self.session_id = None # 本 tab 认领的 gateway sessionId(wire 归因/并行隔离校验都靠它)
|
|
68
|
+
self._own_tab = False # 这个 tab 是我开的吗 —— close() 只关自己开的,绝不关用户的
|
|
69
|
+
self.tab_isolated = False # 是否真独占了一个 gateway session(并行的前提,降级后为 False)
|
|
37
70
|
self._tool_baseline = 0 # 发送前的「個工具」面板数;本轮只抓之后新增的(防超时用例污染下一轮)
|
|
38
71
|
self._console_errs = [] # 前端 console 报错缓冲——区分「傳送失敗」的真实根因(weekly_limit vs credits)
|
|
39
72
|
|
|
40
73
|
# ── 连接生命周期 ──
|
|
41
|
-
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _read_sid(page):
|
|
76
|
+
"""读某个 page 认领的 gateway sessionId(读不到返回 None)。"""
|
|
77
|
+
try:
|
|
78
|
+
return page.evaluate("(k)=>sessionStorage.getItem(k)", TAB_SID_KEY)
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def _await_sid(self, timeout: int = 60):
|
|
83
|
+
"""等本 tab 把 sessionId 认领上(session_ready 才写)。超时返回 None。"""
|
|
84
|
+
deadline = time.time() + timeout
|
|
85
|
+
while time.time() < deadline:
|
|
86
|
+
sid = self._read_sid(self.page)
|
|
87
|
+
if sid:
|
|
88
|
+
return sid
|
|
89
|
+
self.page.wait_for_timeout(1000)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def attach(self, own_tab="auto", claim_timeout: int = 60) -> "ChatDriver":
|
|
93
|
+
"""连上调试端口 Chrome 并选定本 driver 要驱动的 tab。
|
|
94
|
+
|
|
95
|
+
`own_tab` 三态 —— **默认自己开 tab**(平台既然支持多 tab,独占就是常态,共用才是例外):
|
|
96
|
+
|
|
97
|
+
- `"auto"`(默认):先试着自己开 tab 独占一个 gateway session;该环境不支持多 tab
|
|
98
|
+
(`NEXT_PUBLIC_MULTI_TAB_SESSION` 没开,新 tab 会被并回同一个 session)或认领超时时,
|
|
99
|
+
**降级复用已有 tab** 并把 `self.tab_isolated` 置 False、打一行 warn。
|
|
100
|
+
单 driver 场景下复用是安全的(那就是改并发之前的老行为),所以降级不是问题。
|
|
101
|
+
- `True`(**并行必用**):严格独占,拿不到独立 session 直接抛 `TabSessionUnavailable`。
|
|
102
|
+
🔴 并行时绝不能用 "auto" —— 多个 worker 各自降级到同一个已有 tab = 静默串台
|
|
103
|
+
(见模块 docstring 的实证)。要并行就必须 fail-fast 让调用方退回串行。
|
|
104
|
+
- `False`:显式复用已有 tab(旧行为;只在你确实想操作用户当前那个 tab 时用)。
|
|
105
|
+
|
|
106
|
+
`self.session_id` 记认领到的 gateway sessionId(Wire 归因锚点);
|
|
107
|
+
`self.tab_isolated` 说明这个 driver 是不是真独占了一个 session。
|
|
108
|
+
"""
|
|
109
|
+
strict = (own_tab is True)
|
|
110
|
+
want_own = (own_tab is True or own_tab == "auto")
|
|
42
111
|
self._pw = sync_playwright().start()
|
|
43
112
|
self.browser = self._pw.chromium.connect_over_cdp(f"http://localhost:{self.port}")
|
|
44
113
|
ctx = self.browser.contexts[0]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
114
|
+
if want_own:
|
|
115
|
+
# 先快照**别的 tab 已认领的 sid**,再开自己的 —— 顺序反了就分不清撞没撞车。
|
|
116
|
+
# ⚠️ 多 worker 并行 attach 时调用方要串行化这一段(各自认领完再放下一个),
|
|
117
|
+
# 否则两个新 tab 可能都在对方写 sessionStorage 之前完成快照,撞车检测漏判。
|
|
118
|
+
taken = {sid for sid in (self._read_sid(p) for p in ctx.pages if "/chat" in p.url) if sid}
|
|
119
|
+
self.page = ctx.new_page()
|
|
120
|
+
self._own_tab = True
|
|
121
|
+
self.page.goto(CHAT_URL, wait_until="domcontentloaded")
|
|
122
|
+
else:
|
|
123
|
+
# 明确选 chat page(url 含 /chat),别抓到残留 tab(如 device 授权页)——它可能中途关闭致 TargetClosedError
|
|
124
|
+
chat = [p for p in ctx.pages if "/chat" in p.url]
|
|
125
|
+
self.page = chat[0] if chat else (ctx.pages[-1] if ctx.pages else ctx.new_page())
|
|
126
|
+
taken = None
|
|
127
|
+
# 清掉可能残留的视口仿真:driver 进程被杀时 Playwright 的 1440x900 override 会孤儿化留在
|
|
128
|
+
# 标签页上(用户看到页面右侧大块空白)。别的会话 clear 不掉它——必须先 set 接管再 clear。
|
|
129
|
+
try:
|
|
130
|
+
cdp = self.page.context.new_cdp_session(self.page)
|
|
131
|
+
cdp.send("Emulation.setDeviceMetricsOverride",
|
|
132
|
+
{"width": 0, "height": 0, "deviceScaleFactor": 0, "mobile": False})
|
|
133
|
+
cdp.send("Emulation.clearDeviceMetricsOverride")
|
|
134
|
+
except Exception:
|
|
135
|
+
pass # 清不掉不影响驱动,只影响观感
|
|
48
136
|
# 捕获前端 console:发送被拒时「傳送失敗」toast 是通用的,真实原因(weekly_limit=本周额度用完 / 积分不足 …)
|
|
49
137
|
# 只在 console 里([Chat Error] weekly_limit…)。留最近 20 条供 send() 分流,别再把 weekly_limit 误报成 credits。
|
|
138
|
+
self._hook_console()
|
|
139
|
+
self.session_id = self._await_sid(claim_timeout if want_own else 10)
|
|
140
|
+
if want_own:
|
|
141
|
+
why = None
|
|
142
|
+
if not self.session_id:
|
|
143
|
+
why = f"新 tab {claim_timeout}s 内没认领到 gateway session(没登录?连不上 gateway?)"
|
|
144
|
+
elif self.session_id in taken:
|
|
145
|
+
why = (f"新 tab 与已有 tab 撞同一个 session({self.session_id})—— 该环境 multi-tab 没开"
|
|
146
|
+
f"(NEXT_PUBLIC_MULTI_TAB_SESSION 是 build-time flag、默认关)")
|
|
147
|
+
if why:
|
|
148
|
+
if strict:
|
|
149
|
+
self.close() # 失败路径也要把刚开的 tab 关掉,别留垃圾
|
|
150
|
+
raise TabSessionUnavailable(why + ",并行会静默串台。退回串行跑。")
|
|
151
|
+
# auto:降级复用已有 tab。单 driver 复用是安全的(= 改并发之前的老行为)。
|
|
152
|
+
print(f"[chat_driver] ⚠️ 独占 tab 失败({why})—— 降级复用已有 tab;"
|
|
153
|
+
f"**此 driver 不可用于并行**")
|
|
154
|
+
self._close_own_tab()
|
|
155
|
+
self._own_tab = False
|
|
156
|
+
chat = [p for p in ctx.pages if "/chat" in p.url]
|
|
157
|
+
self.page = chat[0] if chat else (ctx.pages[-1] if ctx.pages else ctx.new_page())
|
|
158
|
+
self._hook_console()
|
|
159
|
+
self.session_id = self._await_sid(10)
|
|
160
|
+
self.tab_isolated = False
|
|
161
|
+
return self
|
|
162
|
+
self.tab_isolated = bool(want_own)
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def _hook_console(self) -> None:
|
|
166
|
+
"""捕获前端 console:发送被拒时「傳送失敗」toast 是通用的,真实原因(weekly_limit / 积分…)只在 console 里。"""
|
|
50
167
|
self.page.on("console", lambda m: self._console_errs.append((m.text or "")[:200])
|
|
51
168
|
if m.type in ("error", "warning") else None)
|
|
52
|
-
|
|
169
|
+
|
|
170
|
+
def _close_own_tab(self) -> None:
|
|
171
|
+
"""只关本 driver 自己开的 tab(降级/收尾共用)。"""
|
|
172
|
+
if self._own_tab and self.page:
|
|
173
|
+
try:
|
|
174
|
+
self.page.close()
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
53
177
|
|
|
54
178
|
def close(self) -> None:
|
|
55
|
-
# 只断开 attach,不关用户的 Chrome
|
|
179
|
+
# 只断开 attach,不关用户的 Chrome。**自己开的 tab 要自己关**(关掉即释放该 gateway
|
|
180
|
+
# session 的并发名额);不是自己开的(默认 attach 复用的用户 tab)一律不动。
|
|
56
181
|
try:
|
|
57
|
-
|
|
58
|
-
self.browser.close()
|
|
182
|
+
self._close_own_tab()
|
|
59
183
|
finally:
|
|
60
|
-
|
|
61
|
-
self.
|
|
184
|
+
try:
|
|
185
|
+
if self.browser:
|
|
186
|
+
self.browser.close()
|
|
187
|
+
finally:
|
|
188
|
+
if self._pw:
|
|
189
|
+
self._pw.stop()
|
|
62
190
|
|
|
63
191
|
def _eval(self, js: str, arg=None):
|
|
64
192
|
return self.page.evaluate(js, arg) if arg is not None else self.page.evaluate(js)
|
|
65
193
|
|
|
66
|
-
# ──
|
|
194
|
+
# ── 单对话隔离(**本 tab 内**同一时间只能有一个对话在进行;跨 tab 可并行,见模块 docstring)──
|
|
67
195
|
def is_generating(self) -> bool:
|
|
68
196
|
"""当前是否有对话正在流式生成。派生自 chat_state(单一真相)。"""
|
|
69
197
|
return self.chat_state() == "generating"
|
|
@@ -169,10 +297,6 @@ class ChatDriver:
|
|
|
169
297
|
return a.get("answer")
|
|
170
298
|
return None
|
|
171
299
|
|
|
172
|
-
def is_service_error(self) -> bool:
|
|
173
|
-
"""yzsgo 侧 LLM 服务报错(「AI 服務出錯」/llm_error toast)—— 非 skill 缺陷。"""
|
|
174
|
-
return self.chat_state() == "service_error"
|
|
175
|
-
|
|
176
300
|
def read_toasts(self) -> list:
|
|
177
301
|
"""读**全局 ToastContainer**(providers.tsx 挂载的 div[aria-live="assertive"],
|
|
178
302
|
ui/Toast.tsx 渲染)里当前可见的 toast。返回 [{type,title,description}],
|
|
@@ -191,6 +315,10 @@ class ChatDriver:
|
|
|
191
315
|
});
|
|
192
316
|
}""") or []
|
|
193
317
|
|
|
318
|
+
def is_service_error(self) -> bool:
|
|
319
|
+
"""yzsgo 侧 LLM 服务报错(「AI 服務出錯」/llm_error toast)—— 非 skill 缺陷。"""
|
|
320
|
+
return self.chat_state() == "service_error"
|
|
321
|
+
|
|
194
322
|
def chat_state(self) -> str:
|
|
195
323
|
"""**统一感知对话 UI 状态**(单一真相),优先级:待输入 > 生成中 > 报错 > 空闲。
|
|
196
324
|
所有 send/answer/wait 都据此判断,不靠「填了字就以为发出去了」。返回:
|
|
@@ -212,11 +340,6 @@ class ChatDriver:
|
|
|
212
340
|
if([...document.querySelectorAll('[data-testid="stop-button"]')].some(vis)) return 'generating';
|
|
213
341
|
// ③ 报错 toast:仅在**既不待输入也不生成**时才当真(自身文本短=真 toast)
|
|
214
342
|
if([...document.querySelectorAll('*')].some(e=>{const t=(e.textContent||'').trim();return t.length<40 && /服務出錯,請重試|服务出错,请重试|AI 服務出錯|AI 服务出错/.test(t) && vis(e);})) return 'service_error';
|
|
215
|
-
// ③b 全局 ToastContainer(providers.tsx 的 div[aria-live=assertive])里的 **error 级** toast:
|
|
216
|
-
// useFriendlyError 走这条路(如 未找到/SESSION_NOT_FOUND、伺服器錯誤…),旧检查只认「AI 服務出錯」
|
|
217
|
-
// 文案,这类 toast 完全感知不到(2026-08-31 用户截图实证盲区)。warning 级(重試类)不翻状态。
|
|
218
|
-
const tc=document.querySelector('div[aria-live="assertive"]');
|
|
219
|
-
if(tc && [...tc.querySelectorAll('div.rounded-lg')].some(e=>vis(e)&&/bg-red-50/.test(e.className||''))) return 'service_error';
|
|
220
343
|
// ④ 输入框 disabled = 忙(生成中/上一轮 finalizing,但没显示 stop-button)→ 不算 idle,
|
|
221
344
|
// 否则 send 会往禁用框打字+回车、无声失败(send_failed)。
|
|
222
345
|
const ta=document.querySelector('textarea');
|
|
@@ -225,9 +348,11 @@ class ChatDriver:
|
|
|
225
348
|
}""") or "idle"
|
|
226
349
|
|
|
227
350
|
def ensure_idle(self, timeout: int = 90) -> bool:
|
|
228
|
-
"""
|
|
229
|
-
stop(abort),不被动等它自然结束**(用户点醒:我发起的我停,不用干等)。
|
|
230
|
-
待输入→关问题框;报错→reload 清 toast。返回是否 idle。
|
|
351
|
+
"""让**本 tab** 回到 idle 好开新的。**本 tab 的对话都是本 driver 发起、我控制它——正在生成
|
|
352
|
+
就直接 stop(abort),不被动等它自然结束**(用户点醒:我发起的我停,不用干等)。
|
|
353
|
+
待输入→关问题框;报错→reload 清 toast。返回是否 idle。
|
|
354
|
+
⚠️ 作用域**只有本 tab**:并行跑时别指望它能清别的 tab,也绝不会误停别的 tab 的 turn
|
|
355
|
+
(chat_state/stop_generating 都只看 self.page 的 DOM)。"""
|
|
231
356
|
start = time.time()
|
|
232
357
|
while time.time() - start < timeout:
|
|
233
358
|
st = self.chat_state()
|
|
@@ -282,6 +407,26 @@ class ChatDriver:
|
|
|
282
407
|
}""", name)
|
|
283
408
|
self.page.wait_for_timeout(2000)
|
|
284
409
|
|
|
410
|
+
def concurrency_status(self) -> dict | None:
|
|
411
|
+
"""读 gateway 的并发名额 `GET /api/sessions/concurrency` → {active, limit}。
|
|
412
|
+
并行跑测**定标并行度**用:limit 是按 plan 的会话数上限(free 1 / starter 2 / pro 4 /
|
|
413
|
+
enterprise 20 / custom 不限);`limit=None` = 不执法(无权益/billing 不可达),
|
|
414
|
+
**不等于无限**,此时别贪,按保守值走。借页面自己的 token 发请求,不额外要凭据。
|
|
415
|
+
读不到返回 None(网络/未登录/端点变更),调用方按「未知」处理、别当 0。"""
|
|
416
|
+
return self._eval(r"""async ()=>{
|
|
417
|
+
try{
|
|
418
|
+
const t=localStorage.getItem('unified_access_token'); if(!t) return null;
|
|
419
|
+
const base=[...new Set(performance.getEntriesByType('resource')
|
|
420
|
+
.map(e=>{try{return new URL(e.name).origin}catch(_){return ''}}))]
|
|
421
|
+
.find(o=>/(^|\/\/)(gw|gateway)\./.test(o));
|
|
422
|
+
if(!base) return null;
|
|
423
|
+
const r=await fetch(base+'/api/sessions/concurrency',{headers:{Authorization:'Bearer '+t}});
|
|
424
|
+
if(!r.ok) return null;
|
|
425
|
+
const j=await r.json();
|
|
426
|
+
return (typeof j.active==='number') ? {active:j.active, limit:j.limit} : null;
|
|
427
|
+
}catch(_){ return null; }
|
|
428
|
+
}""")
|
|
429
|
+
|
|
285
430
|
def preflight(self, timeout: int = 150) -> dict:
|
|
286
431
|
"""测试前置预检(每轮必做):确认桌面客户端连着**正确账号**、能列出可操作的紫鸟店。
|
|
287
432
|
桌面默认连的可能是别的账号 → 连店类 skill 会「桌面应用未连接」。返回 {ok, stores, reply}。"""
|
|
@@ -289,14 +434,17 @@ class ChatDriver:
|
|
|
289
434
|
self.new_conversation()
|
|
290
435
|
r = self.send_and_wait("先别执行任何店铺操作。请确认桌面客户端连接是否正常,并列出现在能操作的 TikTok 紫鸟店铺(Profile ID + 店铺名)。", timeout)
|
|
291
436
|
txt = r["text"]
|
|
292
|
-
|
|
437
|
+
# 店名别写死后缀(旧账号店名带「货盘」,换账号就不带了):ID 后面跟到行尾/分隔符的即店名
|
|
438
|
+
stores = __import__("re").findall(r"(27\d{11,12})\s*[\t|::、 ]*([^\n\t|]{2,40})", txt)
|
|
293
439
|
ok = bool(stores) # 能列出可操作店 = 桌面连着且账号对(Agent 回复可能顺带提「未连接」作说明,不据此判)
|
|
294
440
|
return {"ok": ok, "stores": stores, "reply": txt[:600]}
|
|
295
441
|
|
|
296
442
|
def new_conversation(self) -> bool:
|
|
297
443
|
"""开新对话,隔离每个测试用例。开新对话是**图标按钮**(文本空、靠 aria-label「新建對話」),
|
|
298
444
|
不能用文本匹配。找不到则留在当前对话,返回 False。
|
|
299
|
-
⚠️
|
|
445
|
+
⚠️ **同一个 tab(=同一个 gateway session)里**同一时间只能一个对话在跑 —— 上一个还在生成时
|
|
446
|
+
开新对话会失败/串数据,先 ensure_idle。想真并行请开多个 tab(attach(own_tab=True)),
|
|
447
|
+
不是在这一个 tab 里连开对话。"""
|
|
300
448
|
self.ensure_idle()
|
|
301
449
|
click_new = r"""()=>{
|
|
302
450
|
const el=[...document.querySelectorAll('button,a,[role=button]')].find(e=>{
|
|
@@ -307,10 +455,23 @@ class ChatDriver:
|
|
|
307
455
|
if(!el) return false; el.click(); return true;
|
|
308
456
|
}"""
|
|
309
457
|
ok = self._eval(click_new)
|
|
458
|
+
if not ok:
|
|
459
|
+
# 「新建對話」按钮找不到 ≠ 按钮没了——技能市场等视图与聊天页**共用 /chat URL**,
|
|
460
|
+
# goto_chat() 会 no-op 停在别的视图(按钮 width=0)。切回「AI 助手」tab 再试一次。
|
|
461
|
+
self.goto_tab("AI 助手")
|
|
462
|
+
self.ensure_idle()
|
|
463
|
+
ok = self._eval(click_new)
|
|
310
464
|
if ok:
|
|
311
465
|
self.page.wait_for_timeout(1500)
|
|
312
|
-
#
|
|
313
|
-
|
|
466
|
+
# 校验真空白:新对话既不该残留「已完成 N 個工具」面板,**也不该残留 AskUserQuestion 问答卡**。
|
|
467
|
+
# ⚠️ 漏了问答卡这条,就是 gmvmax 用例抓到上一条 promotions 残留问答卡(「建一场秒杀活动…」待输入)
|
|
468
|
+
# 的根因——工具面板碰巧空了就被当「真空白」放行,旧问答卡却还盖在页上。两样都空才算真新建成功。
|
|
469
|
+
# (鸭嘴兽 chat 页只有这两类残留会串下一条;这里的“框”指 question-card,不是卖家后台的风控验证码。)
|
|
470
|
+
fresh = self._eval(r"""()=>{
|
|
471
|
+
const hasTool=[...document.querySelectorAll('button[aria-expanded]')].some(e=>/個工具|个工具/.test(e.textContent||''));
|
|
472
|
+
const hasCard=[...document.querySelectorAll('[data-testid="question-card"]')].some(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;});
|
|
473
|
+
return !hasTool && !hasCard;
|
|
474
|
+
}""")
|
|
314
475
|
if not fresh:
|
|
315
476
|
self.page.goto(CHAT_URL, wait_until="domcontentloaded")
|
|
316
477
|
self.page.wait_for_timeout(5000)
|
|
@@ -327,7 +488,7 @@ class ChatDriver:
|
|
|
327
488
|
def send(self, msg: str) -> bool:
|
|
328
489
|
"""发消息,并**验证真的发出去了**(状态离开 idle → generating/waiting,或输入框清空+消息上屏)。
|
|
329
490
|
发不出去(仍 idle 且没上屏)返回 False —— 不再「填了字就以为发出去了」。
|
|
330
|
-
⚠️ **发之前强制 ensure_idle
|
|
491
|
+
⚠️ **发之前强制 ensure_idle**:本 tab 的对话都是本 driver 发起的,上一个 turn 没结束就发下一个 =
|
|
331
492
|
往忙着的对话里塞消息 → concurrent/傳送中卡死。从代码上根绝——绝不往非 idle 的对话发。"""
|
|
332
493
|
if not self.ensure_idle(180):
|
|
333
494
|
self._last_send_fail = "concurrent" # 上一个 turn 迟迟不结束 → 别硬发
|
|
@@ -352,25 +513,40 @@ class ChatDriver:
|
|
|
352
513
|
if st == "service_error":
|
|
353
514
|
self._last_send_fail = "service_error"
|
|
354
515
|
return False
|
|
355
|
-
#
|
|
356
|
-
# ①
|
|
357
|
-
#
|
|
516
|
+
# 消息被拒的**五种**根因,UI 都不进生成但含义天差地别,必须区分(否则 judge 报错方向全反):
|
|
517
|
+
# ① 'concurrency_limit':会话数撞 plan 上限(ConcurrencyLimitNotice,testid
|
|
518
|
+
# concurrency-limit-notice)。并行跑测的头号 blocked —— 降并行度或升 plan,
|
|
519
|
+
# 跟积分/额度都无关。plan 分档:free 1 / starter 2 / pro 4 / enterprise 20。
|
|
520
|
+
# ② 'busy_elsewhere':**这个对话**正被别的 session(另一个 tab)处理
|
|
521
|
+
# (ConversationBusyNotice,testid conversation-busy-notice;文案「傳送失敗(另一會話處理中)」)。
|
|
522
|
+
# 🔴 它的文案里**也含「傳送失敗」**——必须排在 ④ 前面判,否则会被误报成积分不足。
|
|
523
|
+
# ③ 'concurrent':**同一个 session 内**前一个对话还没跑完(另一對話正在處理/會話正忙)。
|
|
524
|
+
# 注意 ② 和 ③ 不是一回事:② 跨 session,③ 同 session。
|
|
525
|
+
# ④ 「傳送失敗/传送失败」:通用发送被拒,真实根因看 console(toast 本身分不出):
|
|
358
526
|
# [Chat Error] weekly_limit(本周额度用完)→ 'weekly_limit'(要升级 plan/等重置,充积分没用!)
|
|
359
527
|
# 否则按积分耗尽(余额不足发不出)→ 'credits'
|
|
360
|
-
# 收集 text='' 的失败也能被 judge
|
|
528
|
+
# 收集 text='' 的失败也能被 judge 分流(降并行 vs 换 tab vs 等锁 vs 升级 plan vs 充值)。
|
|
529
|
+
# ①② 用前端**稳定 testid**认(agentic-chat ConcurrencyLimitNotice.tsx:46 /
|
|
530
|
+
# ConversationBusyNotice.tsx:32),比文案可靠;③④ 才退回短文本匹配。
|
|
361
531
|
rej = self._eval(r"""()=>{
|
|
362
532
|
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
533
|
+
const seen=id=>[...document.querySelectorAll('[data-testid="'+id+'"]')].some(vis);
|
|
534
|
+
if(seen('concurrency-limit-notice')) return 'concurrency_limit';
|
|
535
|
+
if(seen('conversation-busy-notice')) return 'busy_elsewhere';
|
|
363
536
|
const hit=[...document.querySelectorAll('*')].find(e=>{const t=(e.textContent||'').trim();
|
|
364
537
|
return t.length<30 && /另一對話正在處理|另一对话正在处理|會話正忙|会话正忙|請稍後重試|请稍后重试|傳送失敗|传送失败/.test(t) && vis(e);});
|
|
365
538
|
if(!hit) return '';
|
|
366
539
|
const t=(hit.textContent||'').trim();
|
|
540
|
+
// 「傳送失敗(另一會話處理中)」= 跨 session 忙,别当积分不足
|
|
541
|
+
if(/另一會話處理中|另一会话处理中|另一個分頁|另一个标签页/.test(t)) return 'busy_elsewhere';
|
|
367
542
|
return /傳送失敗|传送失败/.test(t) ? 'credits' : 'concurrent';
|
|
368
543
|
}""")
|
|
369
544
|
if rej == "credits" and any(("weekly_limit" in e or "本周额度" in e or "本週額度" in e)
|
|
370
545
|
for e in self._console_errs):
|
|
371
546
|
rej = "weekly_limit" # console 坐实:不是积分,是本周额度墙(升级 plan/等重置)
|
|
372
547
|
if rej:
|
|
373
|
-
|
|
548
|
+
# concurrency_limit / busy_elsewhere / weekly_limit / credits / concurrent
|
|
549
|
+
self._last_send_fail = rej
|
|
374
550
|
return False
|
|
375
551
|
# 「傳送中」= 消息在途(还没被后端接受进生成),继续等,别当已发
|
|
376
552
|
if self._eval(r"""()=>{const b=document.body.innerText||'';return b.includes('傳送中')||b.includes('传送中');}"""):
|
|
@@ -435,9 +611,6 @@ class ChatDriver:
|
|
|
435
611
|
"timed_out": end_state == "timeout",
|
|
436
612
|
"state": end_state,
|
|
437
613
|
"tool_trace": self._scrape_tool_trace(),
|
|
438
|
-
# 全局 toast 快照(错误码根因,如「錯誤代碼:SESSION_NOT_FOUND」)——service_error/timeout
|
|
439
|
-
# 时判 blocked 根因用;正常 done 时多为空(非 persist toast 5s 自动消失)。
|
|
440
|
-
"toasts": self.read_toasts(),
|
|
441
614
|
}
|
|
442
615
|
|
|
443
616
|
def send_and_wait(self, msg: str, timeout: int = 180, answers=None) -> dict:
|
|
@@ -446,7 +619,7 @@ class ChatDriver:
|
|
|
446
619
|
# state 区分 credits(积分不足)/ concurrent(前一 turn 未释放)/ service_error / send_failed,judge 据此报清楚。
|
|
447
620
|
return {"text": "", "transcript": "", "tail": "", "elapsed": 0,
|
|
448
621
|
"timed_out": False, "state": getattr(self, "_last_send_fail", None) or "send_failed",
|
|
449
|
-
"tool_trace": dict(self._EMPTY_TRACE)
|
|
622
|
+
"tool_trace": dict(self._EMPTY_TRACE)}
|
|
450
623
|
return self.wait_reply(timeout, answers)
|
|
451
624
|
|
|
452
625
|
def stop_generating(self) -> None:
|
|
@@ -475,11 +648,11 @@ class ChatDriver:
|
|
|
475
648
|
const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
|
|
476
649
|
const last=bs[bs.length-1]; if(!last) return [];
|
|
477
650
|
const panel=last.parentElement; // 限定到当轮面板
|
|
478
|
-
const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 &&
|
|
651
|
+
const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數|参数/.test(e.textContent) && /結果|结果/.test(e.textContent) && e.textContent.trim().length<12);
|
|
479
652
|
const out=[];
|
|
480
653
|
for(const op of ops){
|
|
481
654
|
const t=(op.parentElement?.innerText||'').replace(/\n/g,' ').trim();
|
|
482
|
-
if((t.match(
|
|
655
|
+
if((t.match(/參數|参数/g)||[]).length!==1) continue; // 跳过嵌套整面板行
|
|
483
656
|
const m=t.match(/^(\S+)\s+(已完成|失敗|失败|進行中|进行中|錯誤|错误|error|失敗了)/i);
|
|
484
657
|
if(m) out.push({name:m[1], status:m[2]});
|
|
485
658
|
}
|
|
@@ -527,8 +700,8 @@ class ChatDriver:
|
|
|
527
700
|
def read_tool_io(self, index: int) -> dict:
|
|
528
701
|
"""深挖第 index 个 tool 的參數/結果内容(点开对应「參數」「結果」button 读文本)。判定存疑时用。"""
|
|
529
702
|
return self._eval(r"""(idx)=>{
|
|
530
|
-
const ops=[...document.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 &&
|
|
531
|
-
const clean=ops.filter(op=>((op.parentElement?.innerText||'').match(
|
|
703
|
+
const ops=[...document.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數|参数/.test(e.textContent) && /結果|结果/.test(e.textContent) && e.textContent.trim().length<12);
|
|
704
|
+
const clean=ops.filter(op=>((op.parentElement?.innerText||'').match(/參數|参数/g)||[]).length===1);
|
|
532
705
|
const op=clean[idx]; if(!op) return {err:'no-tool'};
|
|
533
706
|
const btns=[...op.querySelectorAll('button')];
|
|
534
707
|
btns.forEach(b=>b.click());
|
|
@@ -547,7 +720,7 @@ class ChatDriver:
|
|
|
547
720
|
for(const n of nodes){
|
|
548
721
|
let box=n; for(let i=0;i<5 && box;i++){ box=box.parentElement;
|
|
549
722
|
if(!box) break;
|
|
550
|
-
const p=[...box.querySelectorAll('button')].find(b=>b.textContent.trim()
|
|
723
|
+
const p=[...box.querySelectorAll('button')].find(b=>['參數','参数'].includes(b.textContent.trim()) && b.getBoundingClientRect().width>0);
|
|
551
724
|
if(p){ p.click(); break; }
|
|
552
725
|
}
|
|
553
726
|
}
|
|
@@ -595,12 +768,13 @@ class ChatDriver:
|
|
|
595
768
|
self.page.wait_for_timeout(2200)
|
|
596
769
|
|
|
597
770
|
def card_status(self, slug: str) -> str:
|
|
598
|
-
"""搜到卡后读该 slug 卡的状态:已安裝 / 待安裝 / 安裝中 / notfound。
|
|
771
|
+
"""搜到卡后读该 slug 卡的状态:已安裝 / 待安裝 / 安裝中 / notfound。
|
|
772
|
+
⚠️ UI 文案随账号 locale 简繁都可能出现(zh-HK「安裝」/ zh-CN「安装」),两种都要匹配。"""
|
|
599
773
|
return self._eval(r"""(slug)=>{
|
|
600
774
|
const body=document.body.innerText||''; const i=body.indexOf(slug);
|
|
601
775
|
if(i<0) return 'notfound';
|
|
602
776
|
const seg=body.slice(i, i+60);
|
|
603
|
-
return
|
|
777
|
+
return /已安[裝装]/.test(seg)?'已安裝':/安[裝装]中/.test(seg)?'安裝中':/安[裝装]/.test(seg)?'待安裝':'?';
|
|
604
778
|
}""", slug)
|
|
605
779
|
|
|
606
780
|
def search_skill(self, slug: str) -> str:
|
|
@@ -616,7 +790,7 @@ class ChatDriver:
|
|
|
616
790
|
return True
|
|
617
791
|
if st == 'notfound':
|
|
618
792
|
return False
|
|
619
|
-
self._eval(r"""()=>{const btn=[...document.querySelectorAll('button')].find(e
|
|
793
|
+
self._eval(r"""()=>{const btn=[...document.querySelectorAll('button')].find(e=>/^安[裝装]$/.test(e.textContent.trim()) && e.getBoundingClientRect().width>0 && !e.closest('nav'));if(btn)btn.click();}""")
|
|
620
794
|
self.page.wait_for_timeout(2500)
|
|
621
795
|
# 可能的确认弹窗(pilot 实测无,但兜底)
|
|
622
796
|
self._eval(r"""()=>{const b=[...document.querySelectorAll('button,[role=button]')].find(e=>/確認|确认|確定|确定|立即安/.test(e.textContent.trim()) && e.getBoundingClientRect().width>0);if(b)b.click();}""")
|