@optima-chat/dev-skills 0.16.3 → 0.16.4
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/skills/gateway-admin/SKILL.md +15 -0
- package/.claude/skills/yzsgo-e2e/SKILL.md +51 -0
- package/.claude/skills/yzsgo-e2e/SYNC.md +10 -0
- package/.claude/skills/yzsgo-e2e/chat_driver.py +659 -0
- package/.claude/skills/yzsgo-e2e/judge_outcome.js +11 -0
- package/.claude/skills/yzsgo-e2e/judge_workflow.js +100 -0
- package/.claude/skills/yzsgo-e2e/preflight.py +44 -0
- package/.claude/skills/yzsgo-e2e/prep_conversation.py +21 -0
- package/.claude/skills/yzsgo-e2e/pull_wire.py +312 -0
- package/.claude/skills/yzsgo-e2e/run_e2e.py +122 -0
- package/.claude/skills/yzsgo-e2e/verify_drift.py +42 -0
- package/.codex/skills/yzsgo-e2e/SKILL.md +51 -0
- package/.codex/skills/yzsgo-e2e/SYNC.md +10 -0
- package/.codex/skills/yzsgo-e2e/chat_driver.py +659 -0
- package/.codex/skills/yzsgo-e2e/judge_outcome.js +11 -0
- package/.codex/skills/yzsgo-e2e/judge_workflow.js +100 -0
- package/.codex/skills/yzsgo-e2e/preflight.py +44 -0
- package/.codex/skills/yzsgo-e2e/prep_conversation.py +21 -0
- package/.codex/skills/yzsgo-e2e/pull_wire.py +312 -0
- package/.codex/skills/yzsgo-e2e/run_e2e.py +122 -0
- package/.codex/skills/yzsgo-e2e/verify_drift.py +42 -0
- package/AGENTS.md +1 -0
- package/README.md +1 -0
- package/docs/superpowers/plans/2026-08-31-yzsgo-e2e.md +969 -0
- package/docs/superpowers/specs/2026-08-31-yzsgo-e2e-design.md +154 -0
- package/package.json +1 -1
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""yzsgo Agentic Chat 网页操作固化库 —— 所有对 www.yzsgo.com/zh-HK/chat 的动作的**唯一入口**。
|
|
3
|
+
|
|
4
|
+
任何人任何时候操作这个网页都走这里,不再现写 Playwright。封装了 pilot(2026-08-27)实测的三个坑:
|
|
5
|
+
① 侧栏图标文字是 hover tooltip、被 svg 遮挡 → 用 DOM `.click()`(不用坐标/Playwright click);
|
|
6
|
+
② 搜索框/输入是 React 受控输入 → 用原生 value setter + dispatchEvent('input');
|
|
7
|
+
③ 流式回复 → 轮询 body 文本尾部稳定判完成。
|
|
8
|
+
|
|
9
|
+
前置:用户已用带远程调试端口的 Chrome 登录好 Agentic Chat:
|
|
10
|
+
open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir=/tmp/yzsgo-chrome https://www.yzsgo.com
|
|
11
|
+
|
|
12
|
+
用法:
|
|
13
|
+
from chat_driver import ChatDriver
|
|
14
|
+
d = ChatDriver().attach()
|
|
15
|
+
d.ensure_installed(["briefing-store-status"])
|
|
16
|
+
d.new_conversation()
|
|
17
|
+
r = d.send_and_wait("跑一下老赵店的运营简报", timeout=300)
|
|
18
|
+
print(r["text"], r["elapsed"], r["tool_trace"])
|
|
19
|
+
d.close()
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from playwright.sync_api import sync_playwright
|
|
26
|
+
|
|
27
|
+
CHAT_URL = "https://www.yzsgo.com/zh-HK/chat"
|
|
28
|
+
SEARCH_PH = "搜尋技能..."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ChatDriver:
|
|
32
|
+
def __init__(self, port: int = 9222):
|
|
33
|
+
self.port = port
|
|
34
|
+
self._pw = None
|
|
35
|
+
self.browser = None
|
|
36
|
+
self.page = None
|
|
37
|
+
self._tool_baseline = 0 # 发送前的「個工具」面板数;本轮只抓之后新增的(防超时用例污染下一轮)
|
|
38
|
+
self._console_errs = [] # 前端 console 报错缓冲——区分「傳送失敗」的真实根因(weekly_limit vs credits)
|
|
39
|
+
|
|
40
|
+
# ── 连接生命周期 ──
|
|
41
|
+
def attach(self) -> "ChatDriver":
|
|
42
|
+
self._pw = sync_playwright().start()
|
|
43
|
+
self.browser = self._pw.chromium.connect_over_cdp(f"http://localhost:{self.port}")
|
|
44
|
+
ctx = self.browser.contexts[0]
|
|
45
|
+
# 明确选 chat page(url 含 /chat),别抓到残留 tab(如 device 授权页)——它可能中途关闭致 TargetClosedError
|
|
46
|
+
chat = [p for p in ctx.pages if "/chat" in p.url]
|
|
47
|
+
self.page = chat[0] if chat else (ctx.pages[-1] if ctx.pages else ctx.new_page())
|
|
48
|
+
# 捕获前端 console:发送被拒时「傳送失敗」toast 是通用的,真实原因(weekly_limit=本周额度用完 / 积分不足 …)
|
|
49
|
+
# 只在 console 里([Chat Error] weekly_limit…)。留最近 20 条供 send() 分流,别再把 weekly_limit 误报成 credits。
|
|
50
|
+
self.page.on("console", lambda m: self._console_errs.append((m.text or "")[:200])
|
|
51
|
+
if m.type in ("error", "warning") else None)
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
# 只断开 attach,不关用户的 Chrome
|
|
56
|
+
try:
|
|
57
|
+
if self.browser:
|
|
58
|
+
self.browser.close()
|
|
59
|
+
finally:
|
|
60
|
+
if self._pw:
|
|
61
|
+
self._pw.stop()
|
|
62
|
+
|
|
63
|
+
def _eval(self, js: str, arg=None):
|
|
64
|
+
return self.page.evaluate(js, arg) if arg is not None else self.page.evaluate(js)
|
|
65
|
+
|
|
66
|
+
# ── 单对话隔离(yzsgo 同一时间只能有一个对话在进行)──
|
|
67
|
+
def is_generating(self) -> bool:
|
|
68
|
+
"""当前是否有对话正在流式生成。派生自 chat_state(单一真相)。"""
|
|
69
|
+
return self.chat_state() == "generating"
|
|
70
|
+
|
|
71
|
+
def has_pending_question(self) -> bool:
|
|
72
|
+
"""Agent 是否停在「需要你的輸入」(AskUserQuestion 弹问)。派生自 chat_state。
|
|
73
|
+
这个状态既非「生成中」也非「结束」,若不处理,下一个用例会串进这个卡住的对话。"""
|
|
74
|
+
return self.chat_state() == "waiting_input"
|
|
75
|
+
|
|
76
|
+
def dismiss_pending_question(self) -> bool:
|
|
77
|
+
"""关掉待输入问题框(点卡内 取消/關閉),让对话回到可开新对话的状态。返回是否点到。"""
|
|
78
|
+
clicked = bool(self._eval(r"""()=>{
|
|
79
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
80
|
+
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
81
|
+
if(!card) return false;
|
|
82
|
+
const btn=[...card.querySelectorAll('button')].find(e=>/^(取消|關閉|关闭)$/.test((e.textContent||'').trim()) && vis(e) && !e.disabled);
|
|
83
|
+
if(btn){btn.click(); return true;}
|
|
84
|
+
return false;
|
|
85
|
+
}"""))
|
|
86
|
+
if clicked:
|
|
87
|
+
self.page.wait_for_timeout(1200)
|
|
88
|
+
return clicked
|
|
89
|
+
|
|
90
|
+
def read_question(self) -> str:
|
|
91
|
+
"""读当前 AskUserQuestion 卡片全文(问题+选项标题+按钮)——供 _pick_answer 匹配。"""
|
|
92
|
+
return self._eval(r"""()=>{
|
|
93
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
94
|
+
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
95
|
+
return card ? (card.innerText||'').slice(0,1200) : '';
|
|
96
|
+
}""") or ""
|
|
97
|
+
|
|
98
|
+
def answer_question(self, text: str) -> bool:
|
|
99
|
+
"""给当前 AskUserQuestion **回答并提交** —— 真跟鸭嘴兽对话往下走(不是关掉)。
|
|
100
|
+
两种题型(照 agentic-chat QuestionCard 结构):
|
|
101
|
+
① 选项题:`text` 与某选项标题互含 → 点该选项行;否则点「其它」展开 textarea 填 text;
|
|
102
|
+
② 开放题(0 选项):直接有 textarea → 填 text。
|
|
103
|
+
再点「下一題」(非末题) 或「確認/補充回答」(末题) 提交。返回提交动作是否真的点了。"""
|
|
104
|
+
acted = self._eval(r"""(t)=>{
|
|
105
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
106
|
+
const norm=s=>(s||'').replace(/\s+/g,'').toLowerCase();
|
|
107
|
+
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
108
|
+
if(!card) return 'no-card';
|
|
109
|
+
const nt=norm(t);
|
|
110
|
+
// 选项行 = 卡内 cursor:pointer 的 div(OptionRow),标题在 .font-medium
|
|
111
|
+
const rows=[...card.querySelectorAll('div')].filter(e=>{
|
|
112
|
+
const st=getComputedStyle(e); const tx=(e.textContent||'').trim();
|
|
113
|
+
return st.cursor==='pointer' && tx && tx.length<200;
|
|
114
|
+
});
|
|
115
|
+
const titleOf=r=>((r.querySelector('.font-medium')||r).textContent||'').trim();
|
|
116
|
+
// ① 先试固定选项(排除「其它」):标题与 t 互含即点它
|
|
117
|
+
for(const r of rows){
|
|
118
|
+
const ti=titleOf(r); if(!ti || /^其它$|^其他$/.test(ti)) continue;
|
|
119
|
+
const nti=norm(ti);
|
|
120
|
+
if(nti && (nti.includes(nt) || nt.includes(nti))){ r.click(); return 'option'; }
|
|
121
|
+
}
|
|
122
|
+
// ② 开放题:卡内已有 textarea → 直接填
|
|
123
|
+
let ta=[...card.querySelectorAll('textarea')].find(vis);
|
|
124
|
+
if(ta){
|
|
125
|
+
const set=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
|
|
126
|
+
ta.focus(); set.call(ta,t); ta.dispatchEvent(new Event('input',{bubbles:true}));
|
|
127
|
+
return 'textarea';
|
|
128
|
+
}
|
|
129
|
+
// ③ 选项题但无匹配 → 点「其它」展开 textarea
|
|
130
|
+
const other=rows.find(r=>/^其它$|^其他$/.test(titleOf(r)));
|
|
131
|
+
if(other){ other.click(); return 'other-opened'; }
|
|
132
|
+
return 'no-input';
|
|
133
|
+
}""", text)
|
|
134
|
+
if acted == "no-card":
|
|
135
|
+
return False
|
|
136
|
+
self.page.wait_for_timeout(700)
|
|
137
|
+
# 若刚点开「其它」,textarea 才出现 → 再填一次
|
|
138
|
+
if acted == "other-opened":
|
|
139
|
+
self._eval(r"""(t)=>{
|
|
140
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
141
|
+
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
142
|
+
const ta=card && [...card.querySelectorAll('textarea')].find(vis);
|
|
143
|
+
if(ta){const set=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
|
|
144
|
+
ta.focus(); set.call(ta,t); ta.dispatchEvent(new Event('input',{bubbles:true}));}
|
|
145
|
+
}""", text)
|
|
146
|
+
self.page.wait_for_timeout(500)
|
|
147
|
+
# 提交:末题点 確認/補充回答,非末题点 下一題(都在卡内、非取消/關閉、未 disabled)
|
|
148
|
+
submitted = bool(self._eval(r"""()=>{
|
|
149
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
150
|
+
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
151
|
+
if(!card) return false;
|
|
152
|
+
const btns=[...card.querySelectorAll('button')].filter(e=>vis(e) && !e.disabled);
|
|
153
|
+
const b=btns.find(e=>/^(確認|确认|補充回答|补充回答)$/.test((e.textContent||'').replace(/[✓\s]/g,'')))
|
|
154
|
+
|| btns.find(e=>/^(下一題|下一题)/.test((e.textContent||'').trim()));
|
|
155
|
+
if(b){ b.click(); return true; }
|
|
156
|
+
return false;
|
|
157
|
+
}"""))
|
|
158
|
+
self.page.wait_for_timeout(2500)
|
|
159
|
+
return submitted
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _pick_answer(question: str, answers) -> str | None:
|
|
163
|
+
"""按问题文本匹配预设答案。answers=[{match:正则关键词, answer:文本}, ...];无匹配返回 None。"""
|
|
164
|
+
if not answers:
|
|
165
|
+
return None
|
|
166
|
+
import re as _re
|
|
167
|
+
for a in answers:
|
|
168
|
+
if _re.search(a.get("match", ""), question):
|
|
169
|
+
return a.get("answer")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def is_service_error(self) -> bool:
|
|
173
|
+
"""yzsgo 侧 LLM 服务报错(「AI 服務出錯」/llm_error toast)—— 非 skill 缺陷。"""
|
|
174
|
+
return self.chat_state() == "service_error"
|
|
175
|
+
|
|
176
|
+
def read_toasts(self) -> list:
|
|
177
|
+
"""读**全局 ToastContainer**(providers.tsx 挂载的 div[aria-live="assertive"],
|
|
178
|
+
ui/Toast.tsx 渲染)里当前可见的 toast。返回 [{type,title,description}],
|
|
179
|
+
type 由配色类反推(bg-red-50=error / bg-yellow-50=warning / bg-green-50=success / 其余 info)。
|
|
180
|
+
错误 toast 的 description 常带「錯誤代碼:XXX」(useFriendlyError 的 diagnosticCode 副行,
|
|
181
|
+
如 SESSION_NOT_FOUND)——判 blocked 根因全靠它,别只看标题。带 action 的 toast persist
|
|
182
|
+
不自动消失(关不掉就一直在),残留时 ensure_idle 靠 reload 清。"""
|
|
183
|
+
return self._eval(r"""()=>{
|
|
184
|
+
const tc=document.querySelector('div[aria-live="assertive"]'); if(!tc) return [];
|
|
185
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
186
|
+
return [...tc.querySelectorAll('div.rounded-lg')].filter(vis).map(e=>{
|
|
187
|
+
const cls=e.className||'';
|
|
188
|
+
const type=/bg-red-50/.test(cls)?'error':/bg-yellow-50/.test(cls)?'warning':/bg-green-50/.test(cls)?'success':'info';
|
|
189
|
+
const ps=e.querySelectorAll('p');
|
|
190
|
+
return {type, title:(ps[0]?.textContent||'').trim(), description:(ps[1]?.textContent||'').trim()};
|
|
191
|
+
});
|
|
192
|
+
}""") or []
|
|
193
|
+
|
|
194
|
+
def chat_state(self) -> str:
|
|
195
|
+
"""**统一感知对话 UI 状态**(单一真相),优先级:待输入 > 生成中 > 报错 > 空闲。
|
|
196
|
+
所有 send/answer/wait 都据此判断,不靠「填了字就以为发出去了」。返回:
|
|
197
|
+
'service_error' —— AI 服務出錯/llm_error toast 在
|
|
198
|
+
'waiting_input' —— AskUserQuestion「需要你的輸入」框在(要么回答、要么关掉)
|
|
199
|
+
'generating' —— 「停止生成」在,Agent 正在流式跑
|
|
200
|
+
'idle' —— 都不在,空闲可发消息
|
|
201
|
+
"""
|
|
202
|
+
# 用 agentic-chat 前端的**稳定 testid**(stop-button/question-card)+ 短文本 aiError toast。
|
|
203
|
+
# ⚠️ 优先级:待输入 > **生成中** > 报错 > 空闲。stop-button = 权威「正在生成」信号,
|
|
204
|
+
# 必须**高于**残留错误 toast——实测 llm_error toast 会残留 DOM(opacity1、不销毁),若把
|
|
205
|
+
# service_error 排前面,正在跑的对话会被残留 toast 盖成 service_error(用户实测:对话在跑我却报错)。
|
|
206
|
+
return self._eval(r"""()=>{
|
|
207
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
208
|
+
// ① 待输入:question-card 且**可答**(卡内有 確認/下一題/補充回答 按钮;readOnly 历史卡无按钮=不算)
|
|
209
|
+
const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
|
|
210
|
+
if(card && [...card.querySelectorAll('button')].some(b=>/確認|确认|下一題|下一题|補充回答|补充回答/.test((b.textContent||'').trim()))) return 'waiting_input';
|
|
211
|
+
// ② 生成中:stop-button 在(权威信号,优先于残留错误 toast)
|
|
212
|
+
if([...document.querySelectorAll('[data-testid="stop-button"]')].some(vis)) return 'generating';
|
|
213
|
+
// ③ 报错 toast:仅在**既不待输入也不生成**时才当真(自身文本短=真 toast)
|
|
214
|
+
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
|
+
// ④ 输入框 disabled = 忙(生成中/上一轮 finalizing,但没显示 stop-button)→ 不算 idle,
|
|
221
|
+
// 否则 send 会往禁用框打字+回车、无声失败(send_failed)。
|
|
222
|
+
const ta=document.querySelector('textarea');
|
|
223
|
+
if(ta && ta.disabled) return 'generating';
|
|
224
|
+
return 'idle';
|
|
225
|
+
}""") or "idle"
|
|
226
|
+
|
|
227
|
+
def ensure_idle(self, timeout: int = 90) -> bool:
|
|
228
|
+
"""让对话回到 idle 好开新的。**所有对话都是本 driver 发起、我控制它——正在生成就直接
|
|
229
|
+
stop(abort),不被动等它自然结束**(用户点醒:我发起的我停,不用干等)。
|
|
230
|
+
待输入→关问题框;报错→reload 清 toast。返回是否 idle。"""
|
|
231
|
+
start = time.time()
|
|
232
|
+
while time.time() - start < timeout:
|
|
233
|
+
st = self.chat_state()
|
|
234
|
+
if st == "idle":
|
|
235
|
+
return True
|
|
236
|
+
if st == "generating":
|
|
237
|
+
self.stop_generating() # 主动 abort,不等它自己跑完 → 释放 activeConversationId 锁
|
|
238
|
+
elif st == "waiting_input":
|
|
239
|
+
self.dismiss_pending_question()
|
|
240
|
+
elif st == "service_error":
|
|
241
|
+
self.page.goto(CHAT_URL, wait_until="domcontentloaded")
|
|
242
|
+
self.page.wait_for_timeout(5000)
|
|
243
|
+
self.goto_tab("AI 助手")
|
|
244
|
+
time.sleep(2)
|
|
245
|
+
return self.chat_state() == "idle"
|
|
246
|
+
|
|
247
|
+
def _expand_all_tools(self) -> None:
|
|
248
|
+
"""展开当轮**所有**工具面板(非 data-old),让面板内的分步叙述/工具行进入可见文本,
|
|
249
|
+
供 _main_panel_text 一并抓走。只展开(aria-expanded false→click),不碰已展开的。"""
|
|
250
|
+
self._eval(r"""()=>{for(const b of document.querySelectorAll('button[aria-expanded]')){if(/個工具|个工具/.test(b.textContent||'') && !b.hasAttribute('data-old') && b.getAttribute('aria-expanded')==='false') b.click();}}""")
|
|
251
|
+
self.page.wait_for_timeout(1200)
|
|
252
|
+
|
|
253
|
+
def _main_panel_text(self) -> str:
|
|
254
|
+
"""抓**主对话区**(排除左侧历史栏)的完整 innerText —— 含 Agent 全部推理叙述 + 最终回复。
|
|
255
|
+
new_conversation 后主区只有本轮,故这就是整轮完整输出(不再只抓 body 尾巴/截断)。"""
|
|
256
|
+
return self._eval(r"""()=>{
|
|
257
|
+
const cand=[...document.querySelectorAll('div')].filter(e=>{
|
|
258
|
+
const r=e.getBoundingClientRect();
|
|
259
|
+
return r.width>500 && r.left>250 && r.height>200;
|
|
260
|
+
});
|
|
261
|
+
// 取 innerText 最长的那个容器 = 主对话流
|
|
262
|
+
let best='', blen=0;
|
|
263
|
+
for(const e of cand){ const t=e.innerText||''; if(t.length>blen){blen=t.length; best=t;} }
|
|
264
|
+
return best;
|
|
265
|
+
}""") or ""
|
|
266
|
+
|
|
267
|
+
# ── 页面导航 ──
|
|
268
|
+
def goto_chat(self) -> None:
|
|
269
|
+
if "/chat" not in self.page.url:
|
|
270
|
+
self.page.goto(CHAT_URL, wait_until="domcontentloaded")
|
|
271
|
+
self.page.wait_for_timeout(3000)
|
|
272
|
+
|
|
273
|
+
def goto_tab(self, name: str) -> None:
|
|
274
|
+
"""点侧栏 tab(AI 助手 / 技能 / 瀏覽器 / 工作空間 等)。DOM click 绕 tooltip 遮挡。"""
|
|
275
|
+
self._eval(r"""(name)=>{
|
|
276
|
+
const tip=[...document.querySelectorAll('*')].find(e=>e.children.length===0 && e.textContent.trim()===name && /group-hover|opacity-0|tooltip/.test(e.className||''));
|
|
277
|
+
let g=tip?(tip.closest('.group')||tip.parentElement?.parentElement||tip.parentElement):null;
|
|
278
|
+
const btn=g?(g.querySelector('a[href],button,[role=button]')||g.querySelector('svg')?.closest('a,button,div')):null;
|
|
279
|
+
if(btn){btn.click();return;}
|
|
280
|
+
const alt=[...document.querySelectorAll('a,button,[role=button]')].find(e=>e.textContent.trim()===name && e.getBoundingClientRect().width>0);
|
|
281
|
+
if(alt)alt.click();
|
|
282
|
+
}""", name)
|
|
283
|
+
self.page.wait_for_timeout(2000)
|
|
284
|
+
|
|
285
|
+
def preflight(self, timeout: int = 150) -> dict:
|
|
286
|
+
"""测试前置预检(每轮必做):确认桌面客户端连着**正确账号**、能列出可操作的紫鸟店。
|
|
287
|
+
桌面默认连的可能是别的账号 → 连店类 skill 会「桌面应用未连接」。返回 {ok, stores, reply}。"""
|
|
288
|
+
self.goto_tab("AI 助手")
|
|
289
|
+
self.new_conversation()
|
|
290
|
+
r = self.send_and_wait("先别执行任何店铺操作。请确认桌面客户端连接是否正常,并列出现在能操作的 TikTok 紫鸟店铺(Profile ID + 店铺名)。", timeout)
|
|
291
|
+
txt = r["text"]
|
|
292
|
+
stores = __import__("re").findall(r"(27\d{11,12})\s*[\t| ]*([^\n\t|]{0,30}货盘)", txt)
|
|
293
|
+
ok = bool(stores) # 能列出可操作店 = 桌面连着且账号对(Agent 回复可能顺带提「未连接」作说明,不据此判)
|
|
294
|
+
return {"ok": ok, "stores": stores, "reply": txt[:600]}
|
|
295
|
+
|
|
296
|
+
def new_conversation(self) -> bool:
|
|
297
|
+
"""开新对话,隔离每个测试用例。开新对话是**图标按钮**(文本空、靠 aria-label「新建對話」),
|
|
298
|
+
不能用文本匹配。找不到则留在当前对话,返回 False。
|
|
299
|
+
⚠️ yzsgo 同一时间只能一个对话在跑 —— 上一个还在生成时开新对话会失败/串数据,先 ensure_idle。"""
|
|
300
|
+
self.ensure_idle()
|
|
301
|
+
click_new = r"""()=>{
|
|
302
|
+
const el=[...document.querySelectorAll('button,a,[role=button]')].find(e=>{
|
|
303
|
+
const t=(e.textContent||'').trim();
|
|
304
|
+
const al=(e.getAttribute('aria-label')||e.getAttribute('title')||'');
|
|
305
|
+
return /新建對話|新建对话|新對話|新对话|開始新對話|开始新对话|新會話|新会话/.test(t+al) && e.getBoundingClientRect().width>0;
|
|
306
|
+
});
|
|
307
|
+
if(!el) return false; el.click(); return true;
|
|
308
|
+
}"""
|
|
309
|
+
ok = self._eval(click_new)
|
|
310
|
+
if ok:
|
|
311
|
+
self.page.wait_for_timeout(1500)
|
|
312
|
+
# 校验真空白:新对话不该残留「已完成 N 個工具」面板;残留=没真开新对话(上轮卡住)→ reload 兜底
|
|
313
|
+
fresh = self._eval(r"""()=>![...document.querySelectorAll('button[aria-expanded]')].some(e=>/個工具|个工具/.test(e.textContent||''))""")
|
|
314
|
+
if not fresh:
|
|
315
|
+
self.page.goto(CHAT_URL, wait_until="domcontentloaded")
|
|
316
|
+
self.page.wait_for_timeout(5000)
|
|
317
|
+
self.goto_tab("AI 助手")
|
|
318
|
+
self.ensure_idle()
|
|
319
|
+
ok = self._eval(click_new)
|
|
320
|
+
self.page.wait_for_timeout(1500)
|
|
321
|
+
return bool(ok)
|
|
322
|
+
|
|
323
|
+
# ── 发消息 + 等回复 ──
|
|
324
|
+
_EMPTY_TRACE = {"tools": [], "count": 0, "names": [], "failures": 0, "failed_tools": [], "repeats": {},
|
|
325
|
+
"script_failed": 0, "script_ok": 0}
|
|
326
|
+
|
|
327
|
+
def send(self, msg: str) -> bool:
|
|
328
|
+
"""发消息,并**验证真的发出去了**(状态离开 idle → generating/waiting,或输入框清空+消息上屏)。
|
|
329
|
+
发不出去(仍 idle 且没上屏)返回 False —— 不再「填了字就以为发出去了」。
|
|
330
|
+
⚠️ **发之前强制 ensure_idle**:所有对话都是本 driver 发起的,上一个 turn 没结束就发下一个 =
|
|
331
|
+
往忙着的对话里塞消息 → concurrent/傳送中卡死。从代码上根绝——绝不往非 idle 的对话发。"""
|
|
332
|
+
if not self.ensure_idle(180):
|
|
333
|
+
self._last_send_fail = "concurrent" # 上一个 turn 迟迟不结束 → 别硬发
|
|
334
|
+
return False
|
|
335
|
+
# 隔离:旧工具面板打 data-old,本轮 tool_trace/must_call 只抓未标记的(新增的)
|
|
336
|
+
self._eval(r"""()=>{for(const e of document.querySelectorAll('button[aria-expanded]')){if(/個工具|个工具/.test(e.textContent||''))e.setAttribute('data-old','1');}}""")
|
|
337
|
+
self._eval(r"""(m)=>{
|
|
338
|
+
const ta=document.querySelector('textarea');
|
|
339
|
+
const set=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
|
|
340
|
+
ta.focus(); set.call(ta,m); ta.dispatchEvent(new Event('input',{bubbles:true}));
|
|
341
|
+
}""", msg)
|
|
342
|
+
self.page.wait_for_timeout(400)
|
|
343
|
+
self._console_errs.clear() # 只看本次发送后新产生的 console 报错
|
|
344
|
+
self.page.locator("textarea").first.press("Enter")
|
|
345
|
+
self._last_send_fail = None
|
|
346
|
+
# 验证发送:轮询状态离开 idle(开始生成/进入问答),或输入框清空且消息已上屏
|
|
347
|
+
for _ in range(16): # ~24s
|
|
348
|
+
self.page.wait_for_timeout(1500)
|
|
349
|
+
st = self.chat_state()
|
|
350
|
+
if st in ("generating", "waiting_input"):
|
|
351
|
+
return True
|
|
352
|
+
if st == "service_error":
|
|
353
|
+
self._last_send_fail = "service_error"
|
|
354
|
+
return False
|
|
355
|
+
# 消息被拒的两种根因,UI 都不进生成但含义不同,必须区分(否则 judge 报错方向全反):
|
|
356
|
+
# ① 并发锁未释放:前一 turn 的 activeConversationId 锁没释放(另一對話正在處理/會話正忙…)→ 'concurrent'
|
|
357
|
+
# ② 「傳送失敗/传送失败」:通用发送被拒,真实根因看 console(toast 本身分不出):
|
|
358
|
+
# [Chat Error] weekly_limit(本周额度用完)→ 'weekly_limit'(要升级 plan/等重置,充积分没用!)
|
|
359
|
+
# 否则按积分耗尽(余额不足发不出)→ 'credits'
|
|
360
|
+
# 收集 text='' 的失败也能被 judge 分流(升级 plan vs 充值 vs 等锁)。
|
|
361
|
+
rej = self._eval(r"""()=>{
|
|
362
|
+
const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
|
|
363
|
+
const hit=[...document.querySelectorAll('*')].find(e=>{const t=(e.textContent||'').trim();
|
|
364
|
+
return t.length<30 && /另一對話正在處理|另一对话正在处理|會話正忙|会话正忙|請稍後重試|请稍后重试|傳送失敗|传送失败/.test(t) && vis(e);});
|
|
365
|
+
if(!hit) return '';
|
|
366
|
+
const t=(hit.textContent||'').trim();
|
|
367
|
+
return /傳送失敗|传送失败/.test(t) ? 'credits' : 'concurrent';
|
|
368
|
+
}""")
|
|
369
|
+
if rej == "credits" and any(("weekly_limit" in e or "本周额度" in e or "本週額度" in e)
|
|
370
|
+
for e in self._console_errs):
|
|
371
|
+
rej = "weekly_limit" # console 坐实:不是积分,是本周额度墙(升级 plan/等重置)
|
|
372
|
+
if rej:
|
|
373
|
+
self._last_send_fail = rej # weekly_limit / credits / concurrent
|
|
374
|
+
return False
|
|
375
|
+
# 「傳送中」= 消息在途(还没被后端接受进生成),继续等,别当已发
|
|
376
|
+
if self._eval(r"""()=>{const b=document.body.innerText||'';return b.includes('傳送中')||b.includes('传送中');}"""):
|
|
377
|
+
continue
|
|
378
|
+
# 极快回复:输入框清空 + 消息上屏 + 无「傳送中」
|
|
379
|
+
cleared = self._eval(r"""()=>{const ta=document.querySelector('textarea');return !!(ta && !(ta.value||'').trim());}""")
|
|
380
|
+
if cleared and self._eval(r"""(m)=>((document.body.innerText||'').includes(m.slice(0,24)))""", msg):
|
|
381
|
+
return True
|
|
382
|
+
self._last_send_fail = "send_failed"
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
def wait_reply(self, timeout: int = 180, answers=None) -> dict:
|
|
386
|
+
"""**状态驱动**等回复:随时感知 生成中/待输入/报错/空闲,不靠猜。
|
|
387
|
+
- service_error → 立刻收尾返回(blocked,非 skill 缺陷);
|
|
388
|
+
- waiting_input → 有预设答案就回答再继续,没有就停在此(待人工/答案);
|
|
389
|
+
- generating → 继续等;idle + 文本连续稳定 → done。
|
|
390
|
+
answers=[{match,answer}](见 _pick_answer)。返回含 state: done/timeout/service_error/waiting_input。"""
|
|
391
|
+
start = time.time()
|
|
392
|
+
blen = len(self._eval("()=>document.body.innerText||''"))
|
|
393
|
+
prev = ""
|
|
394
|
+
stable = 0
|
|
395
|
+
tail = ""
|
|
396
|
+
end_state = "timeout"
|
|
397
|
+
while time.time() - start < timeout:
|
|
398
|
+
time.sleep(3)
|
|
399
|
+
st = self.chat_state()
|
|
400
|
+
if st == "service_error":
|
|
401
|
+
end_state = "service_error"
|
|
402
|
+
break
|
|
403
|
+
if st == "waiting_input":
|
|
404
|
+
q = self.read_question()
|
|
405
|
+
ans = self._pick_answer(q, answers)
|
|
406
|
+
if ans is not None and self.answer_question(ans):
|
|
407
|
+
stable = 0
|
|
408
|
+
prev = "" # 回答后 Agent 继续,重置稳定判定
|
|
409
|
+
continue
|
|
410
|
+
end_state = "waiting_input" # 没预设答案 → 停在此,交人工/补答案
|
|
411
|
+
break
|
|
412
|
+
body = self._eval("()=>document.body.innerText||''")
|
|
413
|
+
tail = body[blen:] if len(body) > blen else body[-1500:]
|
|
414
|
+
if st == "generating":
|
|
415
|
+
stable = 0
|
|
416
|
+
prev = tail
|
|
417
|
+
continue
|
|
418
|
+
# idle:文本稳定确认完成(连续 2 次 ~6s 不变)
|
|
419
|
+
if tail == prev:
|
|
420
|
+
stable += 1
|
|
421
|
+
if stable >= 2:
|
|
422
|
+
end_state = "done"
|
|
423
|
+
break
|
|
424
|
+
else:
|
|
425
|
+
stable = 0
|
|
426
|
+
prev = tail
|
|
427
|
+
# 收尾:展开当轮所有工具面板,再抓**完整主面板**(推理叙述+工具行+回复),全量不截断。
|
|
428
|
+
self._expand_all_tools()
|
|
429
|
+
full = self._main_panel_text().strip()
|
|
430
|
+
return {
|
|
431
|
+
"text": full or tail.strip(),
|
|
432
|
+
"transcript": full,
|
|
433
|
+
"tail": tail.strip(),
|
|
434
|
+
"elapsed": round(time.time() - start, 1),
|
|
435
|
+
"timed_out": end_state == "timeout",
|
|
436
|
+
"state": end_state,
|
|
437
|
+
"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
|
+
}
|
|
442
|
+
|
|
443
|
+
def send_and_wait(self, msg: str, timeout: int = 180, answers=None) -> dict:
|
|
444
|
+
if not self.send(msg):
|
|
445
|
+
# 消息没真发出去(对话忙/并发锁未释放/积分耗尽/报错)—— 别再 wait 一堆残留内容。
|
|
446
|
+
# state 区分 credits(积分不足)/ concurrent(前一 turn 未释放)/ service_error / send_failed,judge 据此报清楚。
|
|
447
|
+
return {"text": "", "transcript": "", "tail": "", "elapsed": 0,
|
|
448
|
+
"timed_out": False, "state": getattr(self, "_last_send_fail", None) or "send_failed",
|
|
449
|
+
"tool_trace": dict(self._EMPTY_TRACE), "toasts": self.read_toasts()}
|
|
450
|
+
return self.wait_reply(timeout, answers)
|
|
451
|
+
|
|
452
|
+
def stop_generating(self) -> None:
|
|
453
|
+
"""点停止按钮停掉 Agent 并**确认真停了**(turn abort → 释放 activeConversationId 锁)。
|
|
454
|
+
⚠️ 停止键是 `data-testid="stop-button"`(图标、**无文本**)——旧版用文本匹配「停止」永远点不到,
|
|
455
|
+
turn 没 abort、锁没释放 → 下一个用例撞 concurrent(测试环境全是本 driver 发起,本不该有并发)。
|
|
456
|
+
点 testid + 轮询确认 chat_state 离开 generating;停不掉最多点 6 次。"""
|
|
457
|
+
for _ in range(6):
|
|
458
|
+
if self.chat_state() != "generating":
|
|
459
|
+
return
|
|
460
|
+
self._eval(r"""()=>{
|
|
461
|
+
const el=[...document.querySelectorAll('[data-testid="stop-button"]')].find(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;});
|
|
462
|
+
if(el){ (el.closest('button')||el).click(); }
|
|
463
|
+
}""")
|
|
464
|
+
self.page.wait_for_timeout(2500)
|
|
465
|
+
|
|
466
|
+
def _scrape_tool_trace(self) -> dict:
|
|
467
|
+
"""抓 Agent tool call **完整轨迹**——展开所有「已完成 N 個工具」面板,返回每个 tool 的 {name,status}。
|
|
468
|
+
用来看『调了哪些工具 / 成没成功 / 有没有反复』:光看回复文本会漏掉「工具反复重试最后才成功」这类隐患。
|
|
469
|
+
网页结构(实测):每次 Agent 回复带一个可展开 button「已完成 N 個工具」,展开后每个 tool 一行
|
|
470
|
+
= <name> <status> [參數][結果](name 如 bash/write/read;status 已完成/失敗/進行中)。"""
|
|
471
|
+
# 只看**最后一条回复**的工具面板(当前测试用例;用例间应 new_conversation 隔离)
|
|
472
|
+
self._eval(r"""()=>{const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));const last=bs[bs.length-1];if(last&&last.getAttribute('aria-expanded')==='false')last.click();}""")
|
|
473
|
+
self.page.wait_for_timeout(800)
|
|
474
|
+
tools = self._eval(r"""()=>{
|
|
475
|
+
const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
|
|
476
|
+
const last=bs[bs.length-1]; if(!last) return [];
|
|
477
|
+
const panel=last.parentElement; // 限定到当轮面板
|
|
478
|
+
const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數/.test(e.textContent) && /結果/.test(e.textContent) && e.textContent.trim().length<12);
|
|
479
|
+
const out=[];
|
|
480
|
+
for(const op of ops){
|
|
481
|
+
const t=(op.parentElement?.innerText||'').replace(/\n/g,' ').trim();
|
|
482
|
+
if((t.match(/參數/g)||[]).length!==1) continue; // 跳过嵌套整面板行
|
|
483
|
+
const m=t.match(/^(\S+)\s+(已完成|失敗|失败|進行中|进行中|錯誤|错误|error|失敗了)/i);
|
|
484
|
+
if(m) out.push({name:m[1], status:m[2]});
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}""") or []
|
|
488
|
+
names = [t["name"] for t in tools]
|
|
489
|
+
failures = [t for t in tools if t.get("status") not in ("已完成", "进行中", "進行中")]
|
|
490
|
+
# 反复:同名工具出现次数(>1 提示可能有重试/多步)
|
|
491
|
+
repeats = {n: names.count(n) for n in set(names) if names.count(n) > 1}
|
|
492
|
+
|
|
493
|
+
# ── 任务级成败(#30 假 pass 根因)──
|
|
494
|
+
# runscript/脚本类工具的 bash **退 0 也可能是任务失败**:stdout 里是 `✗ Script failed: ...`,
|
|
495
|
+
# 但工具面板 status 仍显示「已完成」→ 上面的 status/failures 维度**漏判**(#052 就这么被判 pass)。
|
|
496
|
+
# 展开当轮所有「結果」读 stdout,只数成败标记:
|
|
497
|
+
# ✗ Script failed = 脚本任务失败; ✓ Script <...> = 脚本完成(completed 及各 skill 名变体)。
|
|
498
|
+
# ⚠️ 只在这里就地读、**只回整数计数、不回结果原文**——结果体里可能含 env 明文密钥/JWT(gw#2350),
|
|
499
|
+
# 绝不落进 transcript/日志(transcript 已在本方法调用前抓定,此处展开不会回灌它)。
|
|
500
|
+
script_failed = script_ok = 0
|
|
501
|
+
try: # 结果体扫描出任何岔子都只降为 0,绝不让整个 tool_trace 崩掉(它喂所有用例判定)
|
|
502
|
+
self._eval(r"""()=>{
|
|
503
|
+
const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
|
|
504
|
+
const last=bs[bs.length-1]; const panel=last?last.parentElement:document;
|
|
505
|
+
for(const b of panel.querySelectorAll('button')){ if(/^(結果|结果)$/.test((b.textContent||'').trim())) b.click(); }
|
|
506
|
+
}""")
|
|
507
|
+
self.page.wait_for_timeout(700)
|
|
508
|
+
res_text = self._eval(r"""()=>{
|
|
509
|
+
const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
|
|
510
|
+
const last=bs[bs.length-1]; return last? (last.parentElement.innerText||'') : (document.body.innerText||'');
|
|
511
|
+
}""") or ""
|
|
512
|
+
script_failed = res_text.count("✗ Script failed")
|
|
513
|
+
script_ok = res_text.count("✓ Script")
|
|
514
|
+
except Exception: # noqa: BLE001
|
|
515
|
+
pass
|
|
516
|
+
return {
|
|
517
|
+
"tools": tools, # [{name,status}] 完整顺序
|
|
518
|
+
"count": len(tools),
|
|
519
|
+
"names": names,
|
|
520
|
+
"failures": len(failures),
|
|
521
|
+
"failed_tools": [t["name"] for t in failures],
|
|
522
|
+
"repeats": repeats, # {工具名: 次数},看有没有反复
|
|
523
|
+
"script_failed": script_failed, # stdout 里 `✗ Script failed` 次数(任务级失败)
|
|
524
|
+
"script_ok": script_ok, # stdout 里 `✓ Script …` 次数(任务级完成)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
def read_tool_io(self, index: int) -> dict:
|
|
528
|
+
"""深挖第 index 个 tool 的參數/結果内容(点开对应「參數」「結果」button 读文本)。判定存疑时用。"""
|
|
529
|
+
return self._eval(r"""(idx)=>{
|
|
530
|
+
const ops=[...document.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數/.test(e.textContent) && /結果/.test(e.textContent) && e.textContent.trim().length<12);
|
|
531
|
+
const clean=ops.filter(op=>((op.parentElement?.innerText||'').match(/參數/g)||[]).length===1);
|
|
532
|
+
const op=clean[idx]; if(!op) return {err:'no-tool'};
|
|
533
|
+
const btns=[...op.querySelectorAll('button')];
|
|
534
|
+
btns.forEach(b=>b.click());
|
|
535
|
+
const box=op.parentElement?.parentElement;
|
|
536
|
+
return {text:(box?.innerText||'').slice(0,1200)};
|
|
537
|
+
}""", index)
|
|
538
|
+
|
|
539
|
+
def loaded_skills(self) -> list[str]:
|
|
540
|
+
"""当轮 Agent 通过 `load_skill` 加载了哪些 skill —— 用于精确验证 must_call(「到底调没调对 skill」)。
|
|
541
|
+
实测:load_skill 的參數展开是 `<pre>` JSON `{"name": "<slug>"}`。
|
|
542
|
+
**结构无关**:不管有没有「N 個工具」聚合头——**单工具轮次**(只 load_skill、无 bash)不生成聚合头,
|
|
543
|
+
load_skill 是个 `<span>` + 就近 `參數/結果` 按钮。直接找所有 `load_skill` 条目、点它的「參數」,读 pre。"""
|
|
544
|
+
# 直接定位每个 load_skill 条目(span/div,自身文本恰为 load_skill),点开它就近的「參數」
|
|
545
|
+
self._eval(r"""()=>{
|
|
546
|
+
const nodes=[...document.querySelectorAll('span,div')].filter(e=>(e.textContent||'').trim()==='load_skill' && e.getBoundingClientRect().width>0);
|
|
547
|
+
for(const n of nodes){
|
|
548
|
+
let box=n; for(let i=0;i<5 && box;i++){ box=box.parentElement;
|
|
549
|
+
if(!box) break;
|
|
550
|
+
const p=[...box.querySelectorAll('button')].find(b=>b.textContent.trim()==='參數' && b.getBoundingClientRect().width>0);
|
|
551
|
+
if(p){ p.click(); break; }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}""")
|
|
555
|
+
self.page.wait_for_timeout(900)
|
|
556
|
+
# 读全页 <pre> 里的 {"name":"<slug>"}(当轮对话已 new_conversation 隔离,只此一轮)
|
|
557
|
+
return self._eval(r"""()=>{
|
|
558
|
+
const out=[];
|
|
559
|
+
for(const pre of document.querySelectorAll('pre')){
|
|
560
|
+
const m=(pre.textContent||'').match(/"name"\s*:\s*"([^"]+)"/);
|
|
561
|
+
if(m) out.push(m[1]);
|
|
562
|
+
}
|
|
563
|
+
return [...new Set(out)];
|
|
564
|
+
}""") or []
|
|
565
|
+
|
|
566
|
+
def verify_must_call(self, expected) -> dict:
|
|
567
|
+
"""验证当轮是否加载了期望 skill。expected 可为单个 slug 或 slug 列表(**多选之一**——
|
|
568
|
+
有些任务合理地会路由到几个 skill 之一,如「只读核价」→ verifying 或 listing 都算对)。
|
|
569
|
+
返回 {expected, loaded, ok}。"""
|
|
570
|
+
loaded = self.loaded_skills()
|
|
571
|
+
exp = expected if isinstance(expected, list) else [expected]
|
|
572
|
+
return {"expected": expected, "loaded": loaded, "ok": any(e in loaded for e in exp)}
|
|
573
|
+
|
|
574
|
+
# ── 技能市场 ──
|
|
575
|
+
def open_market(self) -> None:
|
|
576
|
+
self._eval(r"""()=>{
|
|
577
|
+
const tip=[...document.querySelectorAll('*')].find(e=>e.children.length===0 && e.textContent.trim()==='技能' && /group-hover|opacity-0|tooltip/.test(e.className||''));
|
|
578
|
+
let g = tip ? (tip.closest('.group')||tip.parentElement?.parentElement||tip.parentElement) : null;
|
|
579
|
+
const btn = g ? (g.querySelector('a[href],button,[role=button]')||g.querySelector('svg')?.closest('a,button,div')) : null;
|
|
580
|
+
if(btn){ btn.click(); return; }
|
|
581
|
+
// 兜底:任意含「技能」的可点侧栏项
|
|
582
|
+
const alt=[...document.querySelectorAll('a,button,[role=button]')].find(e=>/技能/.test(e.textContent||'') && e.getBoundingClientRect().width>0);
|
|
583
|
+
if(alt) alt.click();
|
|
584
|
+
}""")
|
|
585
|
+
self.page.wait_for_timeout(2500)
|
|
586
|
+
|
|
587
|
+
def _fill_search(self, slug: str) -> None:
|
|
588
|
+
self._eval(r"""([ph,slug])=>{
|
|
589
|
+
const inp=[...document.querySelectorAll('input')].find(e=>e.placeholder===ph);
|
|
590
|
+
if(!inp) return;
|
|
591
|
+
const set=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
|
|
592
|
+
inp.focus(); set.call(inp,''); inp.dispatchEvent(new Event('input',{bubbles:true}));
|
|
593
|
+
set.call(inp,slug); inp.dispatchEvent(new Event('input',{bubbles:true}));
|
|
594
|
+
}""", [SEARCH_PH, slug])
|
|
595
|
+
self.page.wait_for_timeout(2200)
|
|
596
|
+
|
|
597
|
+
def card_status(self, slug: str) -> str:
|
|
598
|
+
"""搜到卡后读该 slug 卡的状态:已安裝 / 待安裝 / 安裝中 / notfound。"""
|
|
599
|
+
return self._eval(r"""(slug)=>{
|
|
600
|
+
const body=document.body.innerText||''; const i=body.indexOf(slug);
|
|
601
|
+
if(i<0) return 'notfound';
|
|
602
|
+
const seg=body.slice(i, i+60);
|
|
603
|
+
return /已安裝/.test(seg)?'已安裝':/安裝中/.test(seg)?'安裝中':/安裝/.test(seg)?'待安裝':'?';
|
|
604
|
+
}""", slug)
|
|
605
|
+
|
|
606
|
+
def search_skill(self, slug: str) -> str:
|
|
607
|
+
self._fill_search(slug)
|
|
608
|
+
return self.card_status(slug)
|
|
609
|
+
|
|
610
|
+
def install_skill(self, slug: str) -> bool:
|
|
611
|
+
"""确保在市场;搜 slug;若待装则点安裝并验证已安裝。返回是否已安裝。"""
|
|
612
|
+
if not self._eval(f"()=>!!document.querySelector('input[placeholder=\"{SEARCH_PH}\"]')"):
|
|
613
|
+
self.open_market()
|
|
614
|
+
st = self.search_skill(slug)
|
|
615
|
+
if st == '已安裝':
|
|
616
|
+
return True
|
|
617
|
+
if st == 'notfound':
|
|
618
|
+
return False
|
|
619
|
+
self._eval(r"""()=>{const btn=[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='安裝' && e.getBoundingClientRect().width>0 && !e.closest('nav'));if(btn)btn.click();}""")
|
|
620
|
+
self.page.wait_for_timeout(2500)
|
|
621
|
+
# 可能的确认弹窗(pilot 实测无,但兜底)
|
|
622
|
+
self._eval(r"""()=>{const b=[...document.querySelectorAll('button,[role=button]')].find(e=>/確認|确认|確定|确定|立即安/.test(e.textContent.trim()) && e.getBoundingClientRect().width>0);if(b)b.click();}""")
|
|
623
|
+
self.page.wait_for_timeout(1500)
|
|
624
|
+
return self.search_skill(slug) == '已安裝'
|
|
625
|
+
|
|
626
|
+
def ensure_installed(self, slugs: list[str]) -> dict:
|
|
627
|
+
"""批量确保已装。返回 {slug: '已安裝'|'新装'|'notfound'|'失败'}。"""
|
|
628
|
+
if not self._eval(f"()=>!!document.querySelector('input[placeholder=\"{SEARCH_PH}\"]')"):
|
|
629
|
+
self.open_market()
|
|
630
|
+
out = {}
|
|
631
|
+
for s in slugs:
|
|
632
|
+
st = self.search_skill(s)
|
|
633
|
+
if st == '已安裝':
|
|
634
|
+
out[s] = '已安裝'
|
|
635
|
+
elif st == 'notfound':
|
|
636
|
+
out[s] = 'notfound'
|
|
637
|
+
else:
|
|
638
|
+
out[s] = '新装' if self.install_skill(s) else '失败'
|
|
639
|
+
# ⚠️ 新装的技能不会进当前会话页已加载的 load_skill 索引 —— Agent 找不到就退回
|
|
640
|
+
# 通用 scout/tiktok 兜底(实测:未 reload 前 competitor-pricing 被 scout/tiktok 顶替、
|
|
641
|
+
# must_call ✗;reload 后一次 load_skill 就调对、pass)。装了新的就刷新页面重载索引。
|
|
642
|
+
if any(v == '新装' for v in out.values()):
|
|
643
|
+
self.page.reload(wait_until='domcontentloaded')
|
|
644
|
+
self.page.wait_for_timeout(6000)
|
|
645
|
+
self.goto_tab("AI 助手")
|
|
646
|
+
return out
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
if __name__ == "__main__":
|
|
650
|
+
# 冒烟:attach + 报告当前页 + 已装技能数
|
|
651
|
+
import sys
|
|
652
|
+
d = ChatDriver().attach()
|
|
653
|
+
print("URL:", d.page.url)
|
|
654
|
+
print("在 chat 页:", "/chat" in d.page.url)
|
|
655
|
+
if "--install" in sys.argv:
|
|
656
|
+
i = sys.argv.index("--install")
|
|
657
|
+
slugs = sys.argv[i + 1].split(",")
|
|
658
|
+
print("ensure_installed:", d.ensure_installed(slugs))
|
|
659
|
+
d.close()
|