wtagent 0.2.2 → 0.4.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 +4 -4
- package/docs/technical-design.md +18 -11
- package/package.json +6 -6
- package/src/browser/base-web-adapter.js +2085 -202
- package/src/browser/cdp-browser.js +223 -25
- package/src/browser/cdp-state.js +8 -5
- package/src/browser/chatgpt-web-adapter.js +803 -218
- package/src/browser/claude-web-adapter.js +13 -0
- package/src/browser/deepseek-web-adapter.js +0 -106
- package/src/browser/fake-web-model-adapter.js +137 -3
- package/src/browser/gemini-web-adapter.js +10 -0
- package/src/browser/glm-web-adapter.js +4 -91
- package/src/browser/grok-web-adapter.js +145 -0
- package/src/browser/kimi-web-adapter.js +2 -74
- package/src/browser/provider-registry.js +8 -50
- package/src/cli/i18n.js +949 -0
- package/src/cli/main.js +217 -223
- package/src/cli/prompt-input.js +349 -151
- package/src/cli/render-events.js +45 -35
- package/src/cli/self-update.js +10 -3
- package/src/platform/command-launcher.js +1 -1
- package/src/platform/paths.js +19 -0
- package/src/platform/windows-diagnostics.js +9 -16
- package/src/policy/path-guard.js +5 -1
- package/src/runtime/agent-runtime.js +1065 -237
- package/src/session/agent-session.js +975 -120
- package/src/shared/limits.js +2 -9
- package/src/tools/default-tools.js +1 -1
- package/src/browser/mode-selection.js +0 -214
- package/src/cli/mode-choice.js +0 -25
|
@@ -22,6 +22,19 @@ export class ClaudeWebAdapter extends BaseWebAdapter {
|
|
|
22
22
|
return /^\/chat\//;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
freshConversationUrlPattern() {
|
|
26
|
+
return /^\/new\/?$/;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pendingAttachmentLocators() {
|
|
30
|
+
return [
|
|
31
|
+
this.page.locator(
|
|
32
|
+
'form [data-testid="file-thumbnail"], '
|
|
33
|
+
+ 'form button[aria-label*="remove file" i]',
|
|
34
|
+
),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
composerLocators() {
|
|
26
39
|
return [
|
|
27
40
|
this.page.locator('[data-testid="chat-input"]'),
|
|
@@ -34,112 +34,6 @@ export class DeepSeekWebAdapter extends BaseWebAdapter {
|
|
|
34
34
|
return /^\/a\/chat\/s\//;
|
|
35
35
|
}
|
|
36
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
37
|
composerLocators() {
|
|
144
38
|
return [
|
|
145
39
|
this.page.locator('textarea[name="search"]'),
|
|
@@ -7,17 +7,110 @@ export class FakeWebModelAdapter {
|
|
|
7
7
|
this.mode = null;
|
|
8
8
|
this.conversationUrl = "https://chatgpt.com/";
|
|
9
9
|
this.lastAssistantMessageId = null;
|
|
10
|
+
this.lastAssistantTurn = null;
|
|
10
11
|
this.responseNumber = 0;
|
|
11
12
|
this.startConversationCalls = [];
|
|
12
13
|
this.startConversationOptions = [];
|
|
14
|
+
this.startConversationOutcome = null;
|
|
13
15
|
this.sentAttachments = [];
|
|
16
|
+
this.sentOutboundIds = [];
|
|
17
|
+
this.targetId = "fake-target";
|
|
18
|
+
this.lastUserMessageId = null;
|
|
19
|
+
this.lastSendStatus = "not-submitted";
|
|
20
|
+
this.conversationIdentityListener = null;
|
|
21
|
+
this.pendingOutboundRecoverySupported = true;
|
|
22
|
+
this.recoveryTargetCalls = [];
|
|
23
|
+
this.reconciliationCalls = [];
|
|
24
|
+
this.reconciliationOutcome = null;
|
|
14
25
|
// Records window-state calls so tests can assert restore/minimize ordering.
|
|
15
26
|
this.windowStateCalls = [];
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
async launch(preferredUrl = null) {
|
|
29
|
+
async launch(preferredUrl = null, options = {}) {
|
|
19
30
|
this.launched = true;
|
|
20
31
|
this.lastLaunchUrl = preferredUrl;
|
|
32
|
+
this.lastLaunchOptions = options;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
supportsPendingOutboundRecovery() {
|
|
36
|
+
return this.pendingOutboundRecoverySupported;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async launchRecoveryTarget(targetId) {
|
|
40
|
+
this.launched = true;
|
|
41
|
+
this.recoveryTargetCalls.push(targetId);
|
|
42
|
+
if (!targetId || targetId !== this.targetId) {
|
|
43
|
+
const error = new Error("The fake recovery target is unavailable.");
|
|
44
|
+
error.code = "RECOVERY_TARGET_UNAVAILABLE";
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
conversationUrl: this.conversationUrl,
|
|
49
|
+
targetId,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async reconcilePendingOutbound(options = {}) {
|
|
54
|
+
this.reconciliationCalls.push(options);
|
|
55
|
+
const outcome = typeof this.reconciliationOutcome === "function"
|
|
56
|
+
? await this.reconciliationOutcome(options)
|
|
57
|
+
: this.reconciliationOutcome;
|
|
58
|
+
if (!outcome) {
|
|
59
|
+
const error = new Error("The fake pending outbound remains uncertain.");
|
|
60
|
+
error.code = "OUTBOUND_COMMIT_UNCERTAIN";
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (outcome.conversationUrl) {
|
|
64
|
+
this.conversationUrl = outcome.conversationUrl;
|
|
65
|
+
}
|
|
66
|
+
if (outcome.conversationTargetId) {
|
|
67
|
+
this.targetId = outcome.conversationTargetId;
|
|
68
|
+
}
|
|
69
|
+
this.lastUserMessageId = outcome.userMessageId ?? this.lastUserMessageId;
|
|
70
|
+
if (outcome.status === "complete") {
|
|
71
|
+
this.lastAssistantMessageId = outcome.assistantMessageId
|
|
72
|
+
?? this.lastAssistantMessageId;
|
|
73
|
+
this.lastAssistantTurn = outcome.assistantTurn ?? this.lastAssistantTurn;
|
|
74
|
+
}
|
|
75
|
+
return outcome;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
classifyConversationUrl(value) {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(value);
|
|
81
|
+
if (url.protocol !== "https:" || url.origin !== "https://chatgpt.com") {
|
|
82
|
+
return "invalid";
|
|
83
|
+
}
|
|
84
|
+
if (/^\/c\/WEB:/.test(url.pathname)) {
|
|
85
|
+
return "provisional";
|
|
86
|
+
}
|
|
87
|
+
if (/^\/c\//.test(url.pathname)) {
|
|
88
|
+
return "restorable";
|
|
89
|
+
}
|
|
90
|
+
return url.pathname === "/" ? "fresh" : "unknown";
|
|
91
|
+
} catch {
|
|
92
|
+
return "invalid";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setConversationIdentityListener(listener) {
|
|
97
|
+
this.conversationIdentityListener = listener;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getConversationIdentity() {
|
|
101
|
+
return {
|
|
102
|
+
conversationUrl: this.conversationUrl,
|
|
103
|
+
targetId: this.targetId,
|
|
104
|
+
kind: this.classifyConversationUrl(this.conversationUrl),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getLastSendStatus() {
|
|
109
|
+
return this.lastSendStatus;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getLastUserMessageId() {
|
|
113
|
+
return this.lastUserMessageId;
|
|
21
114
|
}
|
|
22
115
|
|
|
23
116
|
async close() {
|
|
@@ -46,6 +139,19 @@ export class FakeWebModelAdapter {
|
|
|
46
139
|
} else {
|
|
47
140
|
this.conversationUrl = "https://chatgpt.com/";
|
|
48
141
|
}
|
|
142
|
+
const configuredOutcome = typeof this.startConversationOutcome === "function"
|
|
143
|
+
? await this.startConversationOutcome(conversationUrl, options)
|
|
144
|
+
: this.startConversationOutcome;
|
|
145
|
+
const isConversation = /^\/c\//.test(new URL(this.conversationUrl).pathname);
|
|
146
|
+
const outcome = configuredOutcome ?? {
|
|
147
|
+
status: isConversation ? "restored-existing" : "verified-fresh",
|
|
148
|
+
conversationUrl: this.conversationUrl,
|
|
149
|
+
targetId: this.targetId,
|
|
150
|
+
};
|
|
151
|
+
if (outcome.conversationUrl) {
|
|
152
|
+
this.conversationUrl = outcome.conversationUrl;
|
|
153
|
+
}
|
|
154
|
+
return outcome;
|
|
49
155
|
}
|
|
50
156
|
|
|
51
157
|
async selectMode(mode) {
|
|
@@ -63,13 +169,36 @@ export class FakeWebModelAdapter {
|
|
|
63
169
|
return this.conversationUrl;
|
|
64
170
|
}
|
|
65
171
|
|
|
66
|
-
async sendMessage(text, { files = [] } = {}) {
|
|
172
|
+
async sendMessage(text, { files = [], outboundId = null } = {}) {
|
|
173
|
+
const priorUserMessageId = this.lastUserMessageId;
|
|
174
|
+
const priorAssistantMessageId = this.lastAssistantMessageId;
|
|
175
|
+
this.lastSendStatus = "commit-unknown";
|
|
67
176
|
this.sentMessages.push(text);
|
|
68
177
|
this.sentAttachments.push(files);
|
|
178
|
+
this.sentOutboundIds.push(outboundId);
|
|
69
179
|
if (this.conversationUrl === "https://chatgpt.com/") {
|
|
70
180
|
this.conversationUrl = "https://chatgpt.com/c/fake";
|
|
71
181
|
}
|
|
72
|
-
|
|
182
|
+
this.lastSendStatus = "confirmed";
|
|
183
|
+
this.lastUserMessageId = `user-${this.sentMessages.length}`;
|
|
184
|
+
await this.conversationIdentityListener?.(this.getConversationIdentity());
|
|
185
|
+
return {
|
|
186
|
+
attachment: files.length ? { attached: files, failed: [] } : null,
|
|
187
|
+
userMessageId: this.lastUserMessageId,
|
|
188
|
+
userTurn: (this.sentMessages.length * 2) - 1,
|
|
189
|
+
conversationUrl: this.conversationUrl,
|
|
190
|
+
conversationTargetId: this.targetId,
|
|
191
|
+
assistantBaseline: {
|
|
192
|
+
ids: priorAssistantMessageId ? [priorAssistantMessageId] : [],
|
|
193
|
+
count: priorAssistantMessageId ? 1 : 0,
|
|
194
|
+
maxTurn: priorAssistantMessageId ? (this.responseNumber * 2) : null,
|
|
195
|
+
lastText: "",
|
|
196
|
+
},
|
|
197
|
+
preOutboundMarkerIds: [
|
|
198
|
+
priorUserMessageId,
|
|
199
|
+
priorAssistantMessageId,
|
|
200
|
+
].filter(Boolean),
|
|
201
|
+
};
|
|
73
202
|
}
|
|
74
203
|
|
|
75
204
|
async waitForTurnComplete({ onDelta } = {}) {
|
|
@@ -79,6 +208,7 @@ export class FakeWebModelAdapter {
|
|
|
79
208
|
const response = this.responses.shift();
|
|
80
209
|
this.responseNumber += 1;
|
|
81
210
|
this.lastAssistantMessageId = `assistant-${this.responseNumber}`;
|
|
211
|
+
this.lastAssistantTurn = this.responseNumber * 2;
|
|
82
212
|
if (response instanceof Error) {
|
|
83
213
|
throw response;
|
|
84
214
|
}
|
|
@@ -89,4 +219,8 @@ export class FakeWebModelAdapter {
|
|
|
89
219
|
async getLastAssistantMessageId() {
|
|
90
220
|
return this.lastAssistantMessageId;
|
|
91
221
|
}
|
|
222
|
+
|
|
223
|
+
async getLastAssistantTurn() {
|
|
224
|
+
return this.lastAssistantTurn;
|
|
225
|
+
}
|
|
92
226
|
}
|
|
@@ -25,6 +25,16 @@ export class GeminiWebAdapter extends BaseWebAdapter {
|
|
|
25
25
|
return /^\/app\/[^/]+/;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
pendingAttachmentLocators() {
|
|
29
|
+
return [
|
|
30
|
+
this.page.locator(
|
|
31
|
+
'[data-test-id="textarea-wrapper"] .attachment-container, '
|
|
32
|
+
+ '[data-test-id="textarea-wrapper"] '
|
|
33
|
+
+ 'button[aria-label*="remove" i]',
|
|
34
|
+
),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
composerLocators() {
|
|
29
39
|
return [
|
|
30
40
|
this.page.locator(
|
|
@@ -5,11 +5,6 @@ export { isConnectionLostError } from "./base-web-adapter.js";
|
|
|
5
5
|
|
|
6
6
|
const GLM_URL = "https://chat.z.ai/";
|
|
7
7
|
|
|
8
|
-
// Preferred models, newest first: try GLM-5.3 when present, else GLM-5.2. The
|
|
9
|
-
// site sometimes exposes 5.3 and sometimes only 5.2, so selection walks this
|
|
10
|
-
// list and clicks the first one present in the menu.
|
|
11
|
-
const PREFERRED_MODELS = ["GLM-5.3", "GLM-5.2"];
|
|
12
|
-
|
|
13
8
|
// GLM / Z.ai (chat.z.ai) adapter.
|
|
14
9
|
//
|
|
15
10
|
// chat.z.ai is an Open WebUI (Svelte) frontend, verified against the live app:
|
|
@@ -21,8 +16,8 @@ const PREFERRED_MODELS = ["GLM-5.3", "GLM-5.2"];
|
|
|
21
16
|
// - assistant answer markdown is `.markdown-prose` / `.prose`; a "思考过程"
|
|
22
17
|
// (deep-thinking) block may precede it and is excluded when reading the reply
|
|
23
18
|
// - a live conversation URL is /c/<uuid>
|
|
24
|
-
// - model
|
|
25
|
-
//
|
|
19
|
+
// - model choice is intentionally left to the user on chat.z.ai; WTAgent does
|
|
20
|
+
// not inspect or override the site's current model selection
|
|
26
21
|
// - Cloudflare guards the site; the base throwIfBlockedPage surfaces the
|
|
27
22
|
// window so the user can pass the check (wtagent's own CDP launch is not
|
|
28
23
|
// fingerprinted the way headless automation is)
|
|
@@ -157,90 +152,8 @@ export class GLMWebAdapter extends BaseWebAdapter {
|
|
|
157
152
|
return 5;
|
|
158
153
|
}
|
|
159
154
|
|
|
160
|
-
|
|
161
|
-
return
|
|
155
|
+
sendConfirmationTimeoutMs() {
|
|
156
|
+
return 30_000;
|
|
162
157
|
}
|
|
163
158
|
|
|
164
|
-
// Selects the newest available model. The registry's defaultMode "latest" maps
|
|
165
|
-
// to PREFERRED_MODELS (GLM-5.3, else GLM-5.2). Best-effort and non-throwing.
|
|
166
|
-
//
|
|
167
|
-
// The switcher is `button.modelSelectorButton`; opening it lists options whose
|
|
168
|
-
// visible text is the exact model name. After clicking, the switcher label
|
|
169
|
-
// becomes the selected model name.
|
|
170
|
-
async selectMode(mode) {
|
|
171
|
-
this.requirePage();
|
|
172
|
-
if (mode !== "latest") {
|
|
173
|
-
return { status: "skipped", requested: mode, attempts: 0 };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const switcher = this.page.locator("button.modelSelectorButton").first();
|
|
177
|
-
await switcher.waitFor({ state: "visible", timeout: 10_000 }).catch(() => null);
|
|
178
|
-
if (await switcher.count().catch(() => 0) === 0) {
|
|
179
|
-
await this.writeDiagnostics("glm-model-switcher-not-found");
|
|
180
|
-
return {
|
|
181
|
-
status: "switcher_not_found",
|
|
182
|
-
requested: mode,
|
|
183
|
-
attempts: 0,
|
|
184
|
-
reason: "Model switcher was not found.",
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const current = (await switcher.innerText().catch(() => "")).trim();
|
|
189
|
-
// Already on the most-preferred model that exists? If the current label is
|
|
190
|
-
// the first preferred model, nothing to do.
|
|
191
|
-
if (current.startsWith(PREFERRED_MODELS[0])) {
|
|
192
|
-
return {
|
|
193
|
-
status: "already",
|
|
194
|
-
requested: mode,
|
|
195
|
-
selectedLabel: PREFERRED_MODELS[0],
|
|
196
|
-
attempts: 0,
|
|
197
|
-
reason: `Already using ${PREFERRED_MODELS[0]}.`,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
for (const model of PREFERRED_MODELS) {
|
|
202
|
-
await switcher.click({ timeout: 5_000 }).catch(() => null);
|
|
203
|
-
await this.page.waitForTimeout(600);
|
|
204
|
-
const option = this.page.getByText(model, { exact: true }).first();
|
|
205
|
-
if (await option.count().catch(() => 0) === 0) {
|
|
206
|
-
// Not in the menu; close and try the next preferred model.
|
|
207
|
-
await this.page.keyboard.press("Escape").catch(() => null);
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
await option.click({ timeout: 5_000 }).catch(() => null);
|
|
211
|
-
await this.page.waitForTimeout(600);
|
|
212
|
-
const after = (await switcher.innerText().catch(() => "")).trim();
|
|
213
|
-
if (after.startsWith(model)) {
|
|
214
|
-
// The model menu stays open after a selection; a click in the page
|
|
215
|
-
// center dismisses it so it does not cover the composer.
|
|
216
|
-
await this.#dismissModelMenu();
|
|
217
|
-
return {
|
|
218
|
-
status: current.startsWith(model) ? "already" : "select",
|
|
219
|
-
requested: mode,
|
|
220
|
-
selectedLabel: model,
|
|
221
|
-
attempts: 1,
|
|
222
|
-
reason: `Selected ${model}.`,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
await this.#dismissModelMenu();
|
|
228
|
-
await this.writeDiagnostics("glm-mode-latest-unresolved");
|
|
229
|
-
return {
|
|
230
|
-
status: "unresolved",
|
|
231
|
-
requested: mode,
|
|
232
|
-
attempts: 1,
|
|
233
|
-
reason: `Could not select any of: ${PREFERRED_MODELS.join(", ")}.`,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
async #dismissModelMenu() {
|
|
238
|
-
const viewport = this.page.viewportSize?.() ?? { width: 1280, height: 800 };
|
|
239
|
-
await this.page.mouse.click(
|
|
240
|
-
Math.floor(viewport.width / 2),
|
|
241
|
-
Math.floor(viewport.height / 2),
|
|
242
|
-
).catch(() => null);
|
|
243
|
-
await this.page.keyboard.press("Escape").catch(() => null);
|
|
244
|
-
await this.page.waitForTimeout(200);
|
|
245
|
-
}
|
|
246
159
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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 GROK_URL = "https://grok.com/";
|
|
7
|
+
|
|
8
|
+
export class GrokWebAdapter extends BaseWebAdapter {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
super({
|
|
11
|
+
...options,
|
|
12
|
+
baseUrl: options.baseUrl ?? GROK_URL,
|
|
13
|
+
providerName: "Grok",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
conversationUrlPattern() {
|
|
18
|
+
return /^\/c\//;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
composerLocators() {
|
|
22
|
+
return [
|
|
23
|
+
this.page.locator("main textarea").first(),
|
|
24
|
+
this.page.locator("textarea[aria-label]").first(),
|
|
25
|
+
this.page.locator('[role="textbox"]').first(),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
sendButtonLocators() {
|
|
30
|
+
return [
|
|
31
|
+
this.page.locator('button[aria-label*="send" i]'),
|
|
32
|
+
this.page.locator('button[aria-label*="发送"]'),
|
|
33
|
+
this.page.locator('button[type="submit"]'),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
stopButtonLocators() {
|
|
38
|
+
return [
|
|
39
|
+
this.page.locator('button[aria-label*="停止模型响应"]'),
|
|
40
|
+
this.page.locator('button[aria-label*="stop" i]'),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
newConversationControls() {
|
|
45
|
+
return [
|
|
46
|
+
this.page.locator('[data-testid="new-chat"]'),
|
|
47
|
+
this.page.locator('a[href="/"]'),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
loginControlLocators() {
|
|
52
|
+
return [
|
|
53
|
+
this.page.getByRole("button", {
|
|
54
|
+
name: /^(log in|sign in|登录|登入|ログイン|로그인)$/i,
|
|
55
|
+
}),
|
|
56
|
+
this.page.getByRole("link", {
|
|
57
|
+
name: /^(log in|sign in|登录|登入|ログイン|로그인)$/i,
|
|
58
|
+
}),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
authUrlPattern() {
|
|
63
|
+
return /^\/(?:sign-in|signin|login|auth)(?:\/|$)/i;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
authTextPattern() {
|
|
67
|
+
return /sign in to grok|log in to grok|continue with x|continue with google|登录 Grok|登入 Grok/i;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
assistantMessages() {
|
|
71
|
+
return this.page.locator('[data-testid="assistant-message"]');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
userMessages() {
|
|
75
|
+
return this.page.locator(
|
|
76
|
+
'[data-testid="user-message"], [role="article"][aria-label="You"]',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
conversationMessages() {
|
|
81
|
+
return this.page.locator(
|
|
82
|
+
'[data-testid="user-message"], [data-testid="assistant-message"], '
|
|
83
|
+
+ '[role="article"][aria-label="You"]',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async messageIdentity(message) {
|
|
88
|
+
const testId = await message.getAttribute("data-testid").catch(() => null);
|
|
89
|
+
const aria = await message.getAttribute("aria-label").catch(() => null);
|
|
90
|
+
const scope = testId === "user-message" || aria === "You"
|
|
91
|
+
? this.userMessages()
|
|
92
|
+
: this.assistantMessages();
|
|
93
|
+
return { id: null, turn: await scope.count().catch(() => 0) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
isNewAssistantIdentity({ turn, text }) {
|
|
97
|
+
if (turn != null && turn > (this.assistantCountBeforeSend ?? 0)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
const previous = String(this.lastAssistantTextBeforeSend ?? "");
|
|
101
|
+
return Boolean(previous && String(text ?? "") !== previous);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async assistantText(message) {
|
|
105
|
+
const response = message.locator(".response-content-markdown");
|
|
106
|
+
if (await response.count().catch(() => 0) > 0) {
|
|
107
|
+
return await response.last().innerText().catch(() => "");
|
|
108
|
+
}
|
|
109
|
+
return await message.innerText().catch(() => "");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
hasReliableCompletionSignal() {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async isAssistantGenerating() {
|
|
117
|
+
return Boolean(await firstVisible(this.stopButtonLocators()));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async findUsageLimitMarker(message) {
|
|
121
|
+
const text = await message.innerText().catch(() => "");
|
|
122
|
+
if (!isUsageLimitNotice(text)) return null;
|
|
123
|
+
const control = await firstVisible([
|
|
124
|
+
message.getByRole("button", { name: /retry|try again|upgrade|重试|升级/i }),
|
|
125
|
+
this.page.getByRole("button", { name: /retry|try again|upgrade|重试|升级/i }),
|
|
126
|
+
]);
|
|
127
|
+
return control ? text.trim().slice(0, 120) : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async findGenerationErrorMarker(message) {
|
|
131
|
+
const text = await message.innerText().catch(() => "");
|
|
132
|
+
if (!/something went wrong|failed to generate|generation failed|try again|生成失败|出错了/i.test(text)) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const control = await firstVisible([
|
|
136
|
+
message.getByRole("button", { name: /retry|try again|重试|重新生成/i }),
|
|
137
|
+
this.page.getByRole("button", { name: /retry|try again|重试|重新生成/i }),
|
|
138
|
+
]);
|
|
139
|
+
return control ? text.trim().slice(0, 120) : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
deadRequestGraceMultiplier() {
|
|
143
|
+
return 3;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -19,9 +19,8 @@ const KIMI_URL = "https://www.kimi.com/";
|
|
|
19
19
|
// - assistant replies may include a `.thinking-container` reasoning block
|
|
20
20
|
// before the answer; assistantText reads the answer markdown outside it
|
|
21
21
|
// - a live conversation URL is /chat/<uuid>
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// selectMode.
|
|
22
|
+
// Model choice is intentionally left to the user on kimi.com; the adapter does
|
|
23
|
+
// not inspect or change the site's current model selection.
|
|
25
24
|
export class KimiWebAdapter extends BaseWebAdapter {
|
|
26
25
|
constructor(options = {}) {
|
|
27
26
|
super({
|
|
@@ -211,75 +210,4 @@ export class KimiWebAdapter extends BaseWebAdapter {
|
|
|
211
210
|
return 5;
|
|
212
211
|
}
|
|
213
212
|
|
|
214
|
-
// Kimi has a model switcher (快速 / K3 / K3 集群). The registry's defaultMode
|
|
215
|
-
// "k3" asks to switch to K3 ("擅长对话与 Agent 任务,全能旗舰") on every fresh
|
|
216
|
-
// conversation, silently. Any other value keeps the current model.
|
|
217
|
-
//
|
|
218
|
-
// The switcher is `.current-model`; it opens a Naive-UI popover whose rows are
|
|
219
|
-
// `.models-container .model-item`, the selected one carrying `checked`. After
|
|
220
|
-
// selecting, the switcher label starts with the model name (e.g. "K3 进阶").
|
|
221
|
-
// Best-effort and non-throwing, mirroring runModeSelection's contract.
|
|
222
|
-
async selectMode(mode) {
|
|
223
|
-
this.requirePage();
|
|
224
|
-
if (mode !== "k3") {
|
|
225
|
-
return { status: "skipped", requested: mode, attempts: 0 };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const switcher = this.page.locator(".current-model").first();
|
|
229
|
-
// The switcher can mount a beat after the composer on a fresh conversation;
|
|
230
|
-
// wait briefly before concluding it is absent.
|
|
231
|
-
await switcher.waitFor({ state: "visible", timeout: 10_000 }).catch(() => null);
|
|
232
|
-
if (await switcher.count().catch(() => 0) === 0) {
|
|
233
|
-
await this.writeDiagnostics("kimi-model-switcher-not-found");
|
|
234
|
-
return {
|
|
235
|
-
status: "switcher_not_found",
|
|
236
|
-
requested: mode,
|
|
237
|
-
attempts: 0,
|
|
238
|
-
reason: "Model switcher was not found.",
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Already on K3? The switcher label starts with "K3" (but not "K3 集群").
|
|
243
|
-
const label = (await switcher.innerText().catch(() => "")).trim();
|
|
244
|
-
if (/^K3(?!\s*集群)/.test(label)) {
|
|
245
|
-
return {
|
|
246
|
-
status: "already",
|
|
247
|
-
requested: mode,
|
|
248
|
-
selectedLabel: "K3",
|
|
249
|
-
attempts: 0,
|
|
250
|
-
reason: "Already using K3.",
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
await switcher.click({ timeout: 5_000 }).catch(() => null);
|
|
255
|
-
await this.page.locator(".models-container .model-item")
|
|
256
|
-
.first().waitFor({ state: "visible", timeout: 5_000 }).catch(() => null);
|
|
257
|
-
|
|
258
|
-
// Click the row whose title line is exactly "K3" (not "快速" / "K3 集群").
|
|
259
|
-
const k3 = this.page.locator(".models-container .model-item").filter({
|
|
260
|
-
hasText: /^K3(?!\s*集群)/,
|
|
261
|
-
}).first();
|
|
262
|
-
const clicked = await k3.count().catch(() => 0) > 0
|
|
263
|
-
&& await k3.click({ timeout: 5_000 }).then(() => true).catch(() => false);
|
|
264
|
-
await this.page.waitForTimeout(500);
|
|
265
|
-
|
|
266
|
-
const after = (await switcher.innerText().catch(() => "")).trim();
|
|
267
|
-
if (clicked && /^K3(?!\s*集群)/.test(after)) {
|
|
268
|
-
return {
|
|
269
|
-
status: "select",
|
|
270
|
-
requested: mode,
|
|
271
|
-
selectedLabel: "K3",
|
|
272
|
-
attempts: 1,
|
|
273
|
-
reason: "Selected K3.",
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
await this.page.keyboard.press("Escape").catch(() => null);
|
|
277
|
-
await this.writeDiagnostics("kimi-mode-k3-unresolved");
|
|
278
|
-
return {
|
|
279
|
-
status: "unresolved",
|
|
280
|
-
requested: mode,
|
|
281
|
-
attempts: 1,
|
|
282
|
-
reason: "Could not confirm K3 was selected.",
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
213
|
}
|