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.
@@ -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
+ }