wtagent 0.1.0 → 0.2.0
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/README.md +50 -36
- package/package.json +2 -2
- package/src/browser/base-web-adapter.js +1178 -0
- package/src/browser/cdp-browser.js +62 -0
- package/src/browser/cdp-state.js +52 -4
- package/src/browser/chatgpt-web-adapter.js +301 -992
- package/src/browser/claude-web-adapter.js +205 -0
- package/src/browser/deepseek-web-adapter.js +337 -0
- package/src/browser/gemini-web-adapter.js +465 -0
- package/src/browser/glm-web-adapter.js +246 -0
- package/src/browser/kimi-web-adapter.js +285 -0
- package/src/browser/provider-registry.js +199 -0
- package/src/cli/main.js +276 -59
- package/src/cli/notice-store.js +125 -0
- package/src/cli/prompt-input.js +504 -170
- package/src/cli/render-events.js +14 -8
- package/src/cli/self-update.js +145 -0
- package/src/cli/startup-notices.js +161 -0
- package/src/platform/paths.js +7 -0
- package/src/protocol/markers.js +3 -2
- package/src/protocol/prompt-builder.js +9 -5
- package/src/protocol/xml-protocol.js +193 -11
- package/src/runtime/agent-runtime.js +131 -18
- package/src/session/agent-session.js +4 -1
- package/src/session/canonical-transcript.js +2 -2
- package/src/session/session-export.js +2 -2
- package/src/shared/fetch-json.js +24 -0
- package/src/shared/limits.js +11 -1
- package/src/shared/package-info.js +23 -0
- package/src/shared/usage-limit.js +2 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { BaseWebAdapter, firstVisible } from "./base-web-adapter.js";
|
|
2
|
+
import { isUsageLimitNotice } from "../shared/usage-limit.js";
|
|
3
|
+
|
|
4
|
+
export { isConnectionLostError } from "./base-web-adapter.js";
|
|
5
|
+
|
|
6
|
+
const CLAUDE_URL = "https://claude.ai/";
|
|
7
|
+
|
|
8
|
+
// Claude.ai adapter. All turn orchestration stays in BaseWebAdapter; this class
|
|
9
|
+
// only describes Claude's URL and DOM surface. The selectors prefer stable
|
|
10
|
+
// data-testid / ARIA attributes. Tailwind class names are used only where the
|
|
11
|
+
// live app currently exposes no semantic message-role attribute.
|
|
12
|
+
export class ClaudeWebAdapter extends BaseWebAdapter {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
super({
|
|
15
|
+
...options,
|
|
16
|
+
baseUrl: options.baseUrl ?? CLAUDE_URL,
|
|
17
|
+
providerName: "Claude",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
conversationUrlPattern() {
|
|
22
|
+
return /^\/chat\//;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
composerLocators() {
|
|
26
|
+
return [
|
|
27
|
+
this.page.locator('[data-testid="chat-input"]'),
|
|
28
|
+
this.page.locator('.ProseMirror[contenteditable="true"]'),
|
|
29
|
+
this.page.locator('main div[contenteditable="true"]'),
|
|
30
|
+
this.page.locator('main textarea'),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
sendButtonLocators() {
|
|
35
|
+
return [
|
|
36
|
+
this.page.locator('[data-testid="chat-input-send"]'),
|
|
37
|
+
this.page.locator('main button[aria-label*="send" i]'),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stopButtonLocators() {
|
|
42
|
+
return [
|
|
43
|
+
this.page.locator('[data-testid="chat-input-stop"]'),
|
|
44
|
+
this.page.locator('main button[aria-label*="stop" i]'),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
newConversationControls() {
|
|
49
|
+
return [
|
|
50
|
+
this.page.locator('a[href="/new"]'),
|
|
51
|
+
this.page.getByRole("button", { name: /^(new chat|new conversation|新对话|新聊天)$/i }),
|
|
52
|
+
this.page.getByRole("link", { name: /^(new chat|new conversation|新对话|新聊天)$/i }),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
loginControlLocators() {
|
|
57
|
+
return [
|
|
58
|
+
this.page.locator('[data-testid="email"]'),
|
|
59
|
+
this.page.locator('[data-testid="login-with-google"]'),
|
|
60
|
+
this.page.locator('[data-testid="continue"]'),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Signed-out visits currently redirect to /login?from=logout. Keep OAuth and
|
|
65
|
+
// SSO paths in the structural check because they also have no chat composer.
|
|
66
|
+
authUrlPattern() {
|
|
67
|
+
return /^\/(?:login|oauth|sso)(?:\/|$)/;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
authTextPattern() {
|
|
71
|
+
return /continue with google|continue with email|enter your email|sign in to claude|log in to claude|登录 Claude/i;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Claude marks each user bubble with data-testid="user-message". Each
|
|
75
|
+
// assistant transcript row contains exactly one node with data-is-streaming
|
|
76
|
+
// ("true" while generating, "false" after completion), making that node a
|
|
77
|
+
// language-independent assistant-row selector as well as a completion signal.
|
|
78
|
+
assistantMessages() {
|
|
79
|
+
return this.page.locator(
|
|
80
|
+
'[data-testid="transcript-row"] [data-is-streaming]',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
userMessages() {
|
|
85
|
+
return this.page.locator('[data-testid="user-message"]');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
conversationMessages() {
|
|
89
|
+
return this.page.locator(
|
|
90
|
+
'[data-testid="user-message"], '
|
|
91
|
+
+ '[data-testid="transcript-row"] [data-is-streaming]',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Claude's role markers are stable but do not expose a unique per-message id.
|
|
96
|
+
// Use the role-scoped row count as a monotonic boundary, matching the
|
|
97
|
+
// BaseWebAdapter-supported count-baseline strategy.
|
|
98
|
+
async messageIdentity(message) {
|
|
99
|
+
const testId = await message.getAttribute("data-testid").catch(() => null);
|
|
100
|
+
const scope = testId === "user-message"
|
|
101
|
+
? this.userMessages()
|
|
102
|
+
: this.assistantMessages();
|
|
103
|
+
return { id: null, turn: await scope.count().catch(() => 0) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isNewAssistantIdentity({ turn, text }) {
|
|
107
|
+
if (turn != null && turn > (this.assistantCountBeforeSend ?? 0)) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
// Claude may continue rendering into the current response node. Growth of
|
|
111
|
+
// the send-time baseline is therefore also a new response boundary.
|
|
112
|
+
const previous = String(this.lastAssistantTextBeforeSend ?? "");
|
|
113
|
+
return Boolean(previous && String(text ?? "") !== previous);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async assistantText(message) {
|
|
117
|
+
// The streaming wrapper also contains response controls after completion;
|
|
118
|
+
// read only the rendered answer body so Copy/Retry labels never leak into a
|
|
119
|
+
// plain final answer. The class is Claude's semantic typography hook and is
|
|
120
|
+
// kept as a content-only selector, not a turn/completion boundary.
|
|
121
|
+
const response = message.locator(".font-claude-response");
|
|
122
|
+
if (await response.count().catch(() => 0) > 0) {
|
|
123
|
+
return await response.last().innerText().catch(() => "");
|
|
124
|
+
}
|
|
125
|
+
return await message.innerText().catch(() => "");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
hasReliableCompletionSignal() {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async isAssistantGenerating(message) {
|
|
133
|
+
const streaming = await message
|
|
134
|
+
.getAttribute("data-is-streaming")
|
|
135
|
+
.catch(() => null);
|
|
136
|
+
if (streaming === "true") {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (streaming === "false") {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
// Fail closed if the structural attribute disappears: do not accept a
|
|
143
|
+
// potentially mid-stream response based on a short text-stability window.
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async attachFiles(files) {
|
|
148
|
+
this.requirePage();
|
|
149
|
+
const paths = (files ?? [])
|
|
150
|
+
.map((file) => (typeof file === "string" ? file : file?.path))
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
if (paths.length === 0) {
|
|
153
|
+
return { attached: [], failed: [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const input = this.page.locator('[data-testid="file-upload"]').first();
|
|
157
|
+
try {
|
|
158
|
+
await input.setInputFiles(paths, { timeout: 15_000 });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
await this.writeDiagnostics("claude-attach-files-failed");
|
|
161
|
+
return {
|
|
162
|
+
attached: [],
|
|
163
|
+
failed: paths.map((filePath) => ({ path: filePath, message: error.message })),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Wait for Claude to mount an attachment chip before sending the text.
|
|
168
|
+
await this.page.locator(
|
|
169
|
+
'[data-testid="file-thumbnail"], [data-testid*="attachment" i], '
|
|
170
|
+
+ 'button[aria-label*="remove" i]',
|
|
171
|
+
).first().waitFor({ state: "visible", timeout: 20_000 }).catch(() => null);
|
|
172
|
+
return { attached: [...paths], failed: [] };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async findUsageLimitMarker(message) {
|
|
176
|
+
const text = await message.innerText().catch(() => "");
|
|
177
|
+
if (!isUsageLimitNotice(text)) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const control = await firstVisible([
|
|
181
|
+
message.getByRole("button", { name: /retry|try again|upgrade|重试|升级/i }),
|
|
182
|
+
message.locator('[data-testid*="limit" i], [data-testid*="error" i]'),
|
|
183
|
+
]);
|
|
184
|
+
return control ? text.trim().slice(0, 120) : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async findGenerationErrorMarker(message) {
|
|
188
|
+
const text = await message.innerText().catch(() => "");
|
|
189
|
+
if (!/internal server error|something went wrong|failed to generate|生成失败|出错了/i.test(text)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const control = await firstVisible([
|
|
193
|
+
message.getByRole("button", { name: /retry|try again|重试|重新生成/i }),
|
|
194
|
+
message.locator('[data-testid*="retry" i], [data-testid*="error" i]'),
|
|
195
|
+
]);
|
|
196
|
+
return control ? text.trim().slice(0, 120) : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A long-thinking Claude turn may be quiet before the first response node
|
|
200
|
+
// mounts. The visible stop control normally proves liveness; this wider grace
|
|
201
|
+
// prevents a transiently absent control from triggering a false dead request.
|
|
202
|
+
deadRequestGraceMultiplier() {
|
|
203
|
+
return 5;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { BaseWebAdapter, firstVisible } from "./base-web-adapter.js";
|
|
2
|
+
import { isUsageLimitNotice } from "../shared/usage-limit.js";
|
|
3
|
+
|
|
4
|
+
export { isConnectionLostError } from "./base-web-adapter.js";
|
|
5
|
+
|
|
6
|
+
const DEEPSEEK_URL = "https://chat.deepseek.com/";
|
|
7
|
+
|
|
8
|
+
// DeepSeek (chat.deepseek.com) adapter.
|
|
9
|
+
//
|
|
10
|
+
// DeepSeek's DOM has no data-testid / data-message-id / role attributes and
|
|
11
|
+
// uses hashed, volatile class names. The stable anchors it DOES expose, all
|
|
12
|
+
// verified against the live app, are:
|
|
13
|
+
// - a single composer: textarea[name="search"] (placeholder "给 DeepSeek 发送消息")
|
|
14
|
+
// - design-system classes prefixed `ds-`: `.ds-message` wraps each turn, and
|
|
15
|
+
// assistant turns additionally contain `.ds-assistant-message-main-content`
|
|
16
|
+
// - a virtualized message list whose row wrappers carry a monotonic
|
|
17
|
+
// `data-virtual-list-item-key` — used as the per-message turn ordinal
|
|
18
|
+
// - a live conversation URL of the form /a/chat/s/<uuid>
|
|
19
|
+
// There is no labeled send button (an unlabeled icon control), so sending
|
|
20
|
+
// relies on the base adapter's Enter fallback; likewise there is no labeled
|
|
21
|
+
// stop button, so generation is not detected via a stop control (the base
|
|
22
|
+
// treats "no stop button" as simply not-generating, and the reply's stable
|
|
23
|
+
// window + turn boundary still complete the turn correctly).
|
|
24
|
+
export class DeepSeekWebAdapter extends BaseWebAdapter {
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
super({
|
|
27
|
+
...options,
|
|
28
|
+
baseUrl: options.baseUrl ?? DEEPSEEK_URL,
|
|
29
|
+
providerName: "DeepSeek",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
conversationUrlPattern() {
|
|
34
|
+
return /^\/a\/chat\/s\//;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// DeepSeek has no ChatGPT-style model dropdown; instead a new conversation
|
|
38
|
+
// exposes mode chips (快速/专家/识图) and toggles (深度思考/智能搜索). The
|
|
39
|
+
// registry's defaultMode ("expert-thinking") asks for 专家模式 (Expert) +
|
|
40
|
+
// 深度思考 (Deep Thinking) — WTAgent's preferred DeepSeek setup, applied
|
|
41
|
+
// silently on every fresh conversation (no interactive picker). Any other
|
|
42
|
+
// requested value keeps the site's current setting.
|
|
43
|
+
//
|
|
44
|
+
// Selection is LANGUAGE-INDEPENDENT and idempotent:
|
|
45
|
+
// - the chips are role="radio" in a fixed order (fast, expert, image), so
|
|
46
|
+
// the expert chip is the second radio; visible labels are only used to
|
|
47
|
+
// double-check the position when they match a locale we know
|
|
48
|
+
// - after expert is active DeepSeek shows exactly ONE .ds-toggle-button
|
|
49
|
+
// (deep thinking); fast mode shows two (deep thinking + web search), so
|
|
50
|
+
// the toggle count itself proves the chip switch landed. Clicking the
|
|
51
|
+
// single remaining toggle needs no label at all.
|
|
52
|
+
// Selection is best-effort — a UI change never aborts the run; it reports
|
|
53
|
+
// "unresolved" and keeps the current mode, like runModeSelection.
|
|
54
|
+
async selectMode(mode) {
|
|
55
|
+
this.requirePage();
|
|
56
|
+
if (mode !== "expert-thinking") {
|
|
57
|
+
return { status: "skipped", requested: mode, attempts: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const steps = [];
|
|
61
|
+
const chip = await this.#findExpertChip();
|
|
62
|
+
if (!chip) {
|
|
63
|
+
steps.push({ ok: false, label: "expert-chip" });
|
|
64
|
+
} else {
|
|
65
|
+
steps.push(await this.#ensureChipChecked(chip, "expert-chip"));
|
|
66
|
+
if (steps[0].ok) {
|
|
67
|
+
steps.push(await this.#ensureSingleThinkingToggle());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const failed = steps.filter((s) => !s.ok);
|
|
72
|
+
if (failed.length > 0) {
|
|
73
|
+
await this.writeDiagnostics("deepseek-mode-partial");
|
|
74
|
+
return {
|
|
75
|
+
status: "unresolved",
|
|
76
|
+
requested: mode,
|
|
77
|
+
selectedLabel: "expert + deep-thinking",
|
|
78
|
+
attempts: 1,
|
|
79
|
+
reason: `Could not confirm: ${failed.map((s) => s.label).join(", ")}.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
status: "select",
|
|
84
|
+
requested: mode,
|
|
85
|
+
selectedLabel: "expert + deep-thinking",
|
|
86
|
+
attempts: 1,
|
|
87
|
+
reason: "Selected expert mode with deep thinking.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Locates the expert chip without relying on its locale: try known labels
|
|
92
|
+
// first, then fall back to the fixed chip order (fast, expert, image).
|
|
93
|
+
async #findExpertChip() {
|
|
94
|
+
const radios = this.page.locator('[role="radio"]');
|
|
95
|
+
const count = await radios.count().catch(() => 0);
|
|
96
|
+
if (count === 0) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const labelPattern = /专家|expert/i;
|
|
100
|
+
for (let index = 0; index < count; index += 1) {
|
|
101
|
+
const text = (await radios.nth(index).innerText().catch(() => "")).trim();
|
|
102
|
+
if (labelPattern.test(text)) {
|
|
103
|
+
return radios.nth(index);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Positional fallback: 快速/专家/识图 — the expert chip is the 2nd radio.
|
|
107
|
+
return count >= 2 ? radios.nth(1) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Clicks a chip unless it is already aria-checked. Returns { ok, label }.
|
|
111
|
+
async #ensureChipChecked(chip, label) {
|
|
112
|
+
if (await chip.getAttribute("aria-checked").catch(() => null) === "true") {
|
|
113
|
+
return { ok: true, label };
|
|
114
|
+
}
|
|
115
|
+
await chip.click({ timeout: 5_000 }).catch(() => null);
|
|
116
|
+
await this.page.waitForTimeout(400);
|
|
117
|
+
const checked = await chip.getAttribute("aria-checked").catch(() => null);
|
|
118
|
+
return { ok: checked === "true", label };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// In expert mode exactly ONE toggle (deep thinking) exists; in fast mode
|
|
122
|
+
// there are two. Clicking the single remaining toggle therefore never needs
|
|
123
|
+
// a label. The toggle count also verifies the chip switch actually landed.
|
|
124
|
+
async #ensureSingleThinkingToggle() {
|
|
125
|
+
const toggles = this.page.locator(".ds-toggle-button");
|
|
126
|
+
const count = await toggles.count().catch(() => 0);
|
|
127
|
+
if (count !== 1) {
|
|
128
|
+
return { ok: false, label: "deep-thinking-toggle" };
|
|
129
|
+
}
|
|
130
|
+
const toggle = toggles.nth(0);
|
|
131
|
+
const isSelected = async () => (
|
|
132
|
+
(await toggle.getAttribute("class").catch(() => "") ?? "")
|
|
133
|
+
.includes("ds-toggle-button--selected")
|
|
134
|
+
);
|
|
135
|
+
if (await isSelected()) {
|
|
136
|
+
return { ok: true, label: "deep-thinking-toggle" };
|
|
137
|
+
}
|
|
138
|
+
await toggle.click({ timeout: 5_000 }).catch(() => null);
|
|
139
|
+
await this.page.waitForTimeout(400);
|
|
140
|
+
return { ok: await isSelected(), label: "deep-thinking-toggle" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
composerLocators() {
|
|
144
|
+
return [
|
|
145
|
+
this.page.locator('textarea[name="search"]'),
|
|
146
|
+
this.page.locator('textarea[placeholder*="发送消息"]'),
|
|
147
|
+
this.page.locator('textarea[placeholder*="Message" i]'),
|
|
148
|
+
this.page.locator('main textarea'),
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// DeepSeek's send control is an unlabeled icon button, so the base adapter's
|
|
153
|
+
// Enter fallback does the sending. Still offer best-effort locators so a
|
|
154
|
+
// future labeled control is used if present.
|
|
155
|
+
sendButtonLocators() {
|
|
156
|
+
return [
|
|
157
|
+
this.page.getByRole("button", { name: /send|发送/i }),
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// No labeled stop-generating control is exposed, and the composer's send
|
|
162
|
+
// button looks identical when idle-empty and when generating, so there is no
|
|
163
|
+
// reliable DOM liveness signal. Returning nothing means the base loop never
|
|
164
|
+
// sees a "generating" stop signal; the turn still completes on the
|
|
165
|
+
// stable-window + new-turn boundary. Kept overridable for the day DeepSeek
|
|
166
|
+
// ships a labeled control.
|
|
167
|
+
stopButtonLocators() {
|
|
168
|
+
return [
|
|
169
|
+
this.page.getByRole("button", { name: /stop|停止/i }),
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// DeepSeek defaults to 深度思考 (Deep Thinking), which can reason for minutes
|
|
174
|
+
// before rendering the first assistant token — and it exposes no stop button
|
|
175
|
+
// to prove it is alive during that phase. Without a wider grace window the
|
|
176
|
+
// base dead-request detector would misread that silent thinking as a dead
|
|
177
|
+
// request and fire a spurious "continue" nudge. 5 × the 60s base grace gives
|
|
178
|
+
// a ~5 min pre-token window (and ~15 min after any signal), matching how long
|
|
179
|
+
// Deep Thinking can legitimately stay quiet.
|
|
180
|
+
deadRequestGraceMultiplier() {
|
|
181
|
+
return 5;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
newConversationControls() {
|
|
185
|
+
return [
|
|
186
|
+
this.page.getByRole("button", { name: /新对话|开启新对话|new chat/i }),
|
|
187
|
+
this.page.getByRole("link", { name: /新对话|开启新对话|new chat/i }),
|
|
188
|
+
this.page.locator('[data-testid="new-chat"]'),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
loginControlLocators() {
|
|
193
|
+
return [
|
|
194
|
+
this.page.getByRole("button", { name: /^(登录|log in|sign in)$/i }),
|
|
195
|
+
this.page.getByRole("button", { name: /密码登录|验证码登录|password|verification code/i }),
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Logged-out visitors are redirected to /sign_in — locale-independent.
|
|
200
|
+
authUrlPattern() {
|
|
201
|
+
return /^\/sign_in/;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
authTextPattern() {
|
|
205
|
+
return /发送验证码|密码登录|微信扫码登录|使用 Apple 账号登录|未注册的手机号|log in|sign in|send code|password login|sign in with apple/i;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The message rows are the virtual-list wrappers that carry the stable
|
|
209
|
+
// per-message key. Filtering by presence of the assistant content node
|
|
210
|
+
// separates assistant turns from user turns without any role attribute.
|
|
211
|
+
assistantMessages() {
|
|
212
|
+
// Deep Thinking first mounts a think-only row
|
|
213
|
+
// (`.ds-think-content`) with no answer node. Count that as an assistant
|
|
214
|
+
// turn or the 5-minute dead-request window fires mid-thought.
|
|
215
|
+
return this.page.locator(
|
|
216
|
+
'[data-virtual-list-item-key]:has(.ds-assistant-message-main-content), '
|
|
217
|
+
+ '[data-virtual-list-item-key]:has(.ds-think-content)',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
userMessages() {
|
|
222
|
+
return this.page.locator(
|
|
223
|
+
'[data-virtual-list-item-key]:not(:has(.ds-assistant-message-main-content))'
|
|
224
|
+
+ ':not(:has(.ds-think-content))',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
conversationMessages() {
|
|
229
|
+
return this.page.locator("[data-virtual-list-item-key]");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// DeepSeek's `data-virtual-list-item-key` is a virtual-list rendering index,
|
|
233
|
+
// not a stable per-message id: during optimistic send/scroll it briefly takes
|
|
234
|
+
// transient (even negative) values before settling. So it cannot be used as
|
|
235
|
+
// the turn ordinal the base identity ladder expects. Instead DeepSeek uses a
|
|
236
|
+
// COUNT baseline (see isNewAssistantIdentity): identity carries a count-based
|
|
237
|
+
// ordinal, and a reply is "new" once the assistant-row count grows beyond the
|
|
238
|
+
// baseline captured at send time.
|
|
239
|
+
//
|
|
240
|
+
// messageIdentity is called for both assistant rows (turn-completion loop) and
|
|
241
|
+
// user rows (#waitForSentUserMessage). It returns a role-scoped ordinal: the
|
|
242
|
+
// number of rows of the SAME role at or before this one. For the loop's
|
|
243
|
+
// `.last()` assistant row that is the total assistant count; for a freshly
|
|
244
|
+
// appended user row it exceeds the user baseline, so the send is detected.
|
|
245
|
+
async messageIdentity(message) {
|
|
246
|
+
const isAssistant = await message
|
|
247
|
+
.locator(".ds-assistant-message-main-content, .ds-think-content")
|
|
248
|
+
.count()
|
|
249
|
+
.catch(() => 0) > 0;
|
|
250
|
+
const scope = isAssistant ? this.assistantMessages() : this.userMessages();
|
|
251
|
+
const turn = await scope.count().catch(() => 0);
|
|
252
|
+
return { id: null, turn };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// A reply is genuinely new once the assistant-row count exceeds what existed
|
|
256
|
+
// at send time. This is the count-based strategy the base explicitly allows
|
|
257
|
+
// for providers whose DOM lacks a stable per-message id or turn ordinal, and
|
|
258
|
+
// it is immune to the volatile virtual-list key. It never accepts a
|
|
259
|
+
// pre-existing reply (guarding against stale answers) because the count only
|
|
260
|
+
// rises when DeepSeek appends a new assistant turn.
|
|
261
|
+
isNewAssistantIdentity({ turn, text }) {
|
|
262
|
+
if (turn != null && turn > (this.assistantCountBeforeSend ?? 0)) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
// DeepSeek's virtual list often keeps the same row count and reuses the
|
|
266
|
+
// last assistant slot. A changed answer (or a new protocol envelope) is
|
|
267
|
+
// still a new turn even when the count does not grow.
|
|
268
|
+
const previous = String(this.lastAssistantTextBeforeSend ?? "");
|
|
269
|
+
const current = String(text ?? "");
|
|
270
|
+
if (!previous || !current.trim() || current === previous) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
return /<agent_response|<tool_call|<tool_calls|<invoke[\s>]/i.test(current);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async assistantText(message) {
|
|
277
|
+
// Prefer the answer node; fall back to the think block so a still-thinking
|
|
278
|
+
// turn is visible to the completion loop instead of looking empty.
|
|
279
|
+
const content = message.locator(".ds-assistant-message-main-content");
|
|
280
|
+
if (await content.count().catch(() => 0)) {
|
|
281
|
+
const text = await content.last().innerText().catch(() => "");
|
|
282
|
+
if (text.includes("<agent_response") || text.includes("<tool_call") || text.trim()) {
|
|
283
|
+
return text;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const think = message.locator(".ds-think-content");
|
|
287
|
+
if (await think.count().catch(() => 0)) {
|
|
288
|
+
const text = await think.last().innerText().catch(() => "");
|
|
289
|
+
if (text.includes("<agent_response") || text.includes("<tool_call") || text.trim()) {
|
|
290
|
+
return text;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return await message.innerText().catch(() => "");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
hasReliableCompletionSignal() {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async isAssistantGenerating(message) {
|
|
301
|
+
// Structural completion signal (same idea as Kimi's action bar, fully
|
|
302
|
+
// locale-independent): DeepSeek renders the reply action bar — a row of
|
|
303
|
+
// copy / regenerate / thumbs / share icon buttons carrying the stable
|
|
304
|
+
// ds-button--iconLabelTertiary class — under an assistant message only
|
|
305
|
+
// after it has FULLY finished streaming. While generating, including the
|
|
306
|
+
// think-only (.ds-think-content) phase, no such buttons exist.
|
|
307
|
+
const buttons = message.locator(
|
|
308
|
+
'[role="button"].ds-button--iconLabelTertiary',
|
|
309
|
+
);
|
|
310
|
+
return await buttons.count().catch(() => 0) === 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async findUsageLimitMarker(message) {
|
|
314
|
+
const text = await message.innerText().catch(() => "");
|
|
315
|
+
if (!isUsageLimitNotice(text)) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
// Confirm with a retry/limit affordance so an ordinary reply that merely
|
|
319
|
+
// mentions a limit is not misread as a notice.
|
|
320
|
+
const control = await firstVisible([
|
|
321
|
+
message.getByRole("button", { name: /重试|重新生成|retry|try again/i }),
|
|
322
|
+
message.locator('[class*="error" i]'),
|
|
323
|
+
]);
|
|
324
|
+
return control ? text.trim().slice(0, 120) : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// DeepSeek's thread is virtualized; scroll the list container so the latest
|
|
328
|
+
// reply mounts when resuming an existing conversation.
|
|
329
|
+
async scrollConversationToBottom() {
|
|
330
|
+
await this.page.evaluate?.(() => {
|
|
331
|
+
const list = document.querySelector(".ds-virtual-list");
|
|
332
|
+
if (list) {
|
|
333
|
+
list.scrollTop = list.scrollHeight;
|
|
334
|
+
}
|
|
335
|
+
}).catch(() => null);
|
|
336
|
+
}
|
|
337
|
+
}
|