surf-cli 2.12.0 → 2.13.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 +6 -0
- package/native/cli.cjs +33 -1
- package/native/host-helpers.cjs +33 -2
- package/native/host-sessions.cjs +1 -0
- package/native/host.cjs +109 -6
- package/native/kimi-client.cjs +912 -0
- package/native/mcp-server.cjs +10 -0
- package/native/workflow-definition.cjs +1 -0
- package/package.json +1 -1
- package/pi-extension/surf.ts +71 -9
- package/skills/README.md +6 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kimi Web Client for surf-cli
|
|
3
|
+
*
|
|
4
|
+
* Browser-session client for kimi.com (Moonshot AI - Kimi K-series models).
|
|
5
|
+
* Drives the real kimi.com web UI through generic surf browser primitives
|
|
6
|
+
* (NEW_TAB + EXECUTE_JAVASCRIPT + CLOSE_TAB), so no extension changes or
|
|
7
|
+
* provider-specific CDP types are needed.
|
|
8
|
+
*
|
|
9
|
+
* Kimi's composer is a Lexical editor (`div[contenteditable][role="textbox"]`),
|
|
10
|
+
* which rejects plain CDP Input.insertText events. We type via
|
|
11
|
+
* document.execCommand("insertText") instead (proven to work with Lexical).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { abortableDelay, raceAbort, throwIfAborted } = require("./abort.cjs");
|
|
15
|
+
|
|
16
|
+
const KIMI_URL = "https://www.kimi.com/";
|
|
17
|
+
const DEFAULT_MODEL = "instant";
|
|
18
|
+
|
|
19
|
+
// Default models (best-effort labels; kimi.com UI may differ by plan/region)
|
|
20
|
+
const DEFAULT_KIMI_MODELS = {
|
|
21
|
+
"instant": { id: "instant", name: "Instant", desc: "Fast responses" },
|
|
22
|
+
"thinking": { id: "thinking", name: "Thinking", desc: "Deep reasoning (may need paid plan)" },
|
|
23
|
+
"high": { id: "high", name: "High", desc: "Higher reasoning effort" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function normalizeLabel(text) {
|
|
27
|
+
return String(text || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getMatchLabels(desiredModel) {
|
|
31
|
+
const labels = new Set([desiredModel]);
|
|
32
|
+
for (const m of Object.values(DEFAULT_KIMI_MODELS)) {
|
|
33
|
+
if (m.id === desiredModel) labels.add(m.name);
|
|
34
|
+
}
|
|
35
|
+
return Array.from(new Set(Array.from(labels).filter(Boolean).map(normalizeLabel)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Helpers
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
function delay(ms, signal) {
|
|
43
|
+
return abortableDelay(ms, signal);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// In-page click dispatcher (pointerdown/mousedown/pointerup/click) - needed
|
|
47
|
+
// because plain el.click() is ignored by React/Lexical synthetic event systems.
|
|
48
|
+
function buildClickDispatcher() {
|
|
49
|
+
return `function dispatchClickSequence(target) {
|
|
50
|
+
if (!target || !(target instanceof EventTarget)) return false;
|
|
51
|
+
const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
|
|
52
|
+
for (const type of types) {
|
|
53
|
+
const common = { bubbles: true, cancelable: true, view: window };
|
|
54
|
+
let event;
|
|
55
|
+
if (type.startsWith('pointer') && 'PointerEvent' in window) {
|
|
56
|
+
event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
|
|
57
|
+
} else {
|
|
58
|
+
event = new MouseEvent(type, common);
|
|
59
|
+
}
|
|
60
|
+
target.dispatchEvent(event);
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The chat composer input (Lexical editor) - prefer the specific class first to
|
|
67
|
+
// avoid matching sidebar search boxes
|
|
68
|
+
const INPUT_SELECTOR =
|
|
69
|
+
"div.chat-input-editor, div[contenteditable=\"true\"][role=\"textbox\"], div[contenteditable=\"true\"]";
|
|
70
|
+
|
|
71
|
+
// Find the chat composer specifically: chat-input-editor class, or a textbox
|
|
72
|
+
// whose ancestors include the composer area
|
|
73
|
+
const FIND_INPUT_JS = `(() => {
|
|
74
|
+
const direct = document.querySelector('div.chat-input-editor');
|
|
75
|
+
if (direct) return direct;
|
|
76
|
+
const boxes = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], [contenteditable="true"]'));
|
|
77
|
+
for (const el of boxes) {
|
|
78
|
+
const p = el.parentElement;
|
|
79
|
+
if (!p) continue;
|
|
80
|
+
const cls = ((p.className || '') + ' ' + ((p.parentElement && p.parentElement.className) || '')).toString();
|
|
81
|
+
if (/chat-input|chat-editor|composer|publisher/.test(cls)) return el;
|
|
82
|
+
}
|
|
83
|
+
return boxes[0] || null;
|
|
84
|
+
})()`;
|
|
85
|
+
|
|
86
|
+
async function evaluate(jsEval, expression, signal) {
|
|
87
|
+
throwIfAborted(signal);
|
|
88
|
+
// IMPORTANT: always emit `return (expr);` so the eval works on BOTH the
|
|
89
|
+
// current installed extension (wraps code as an async arrow body directly,
|
|
90
|
+
// so a bare expression's value is discarded -> undefined) and a rebuilt one
|
|
91
|
+
// (wraps with `return (code);` -> falls back to the raw code on syntax error).
|
|
92
|
+
const code = /^\s*return\b/.test(expression) ? expression : `return (${expression});`;
|
|
93
|
+
const result = await jsEval(code);
|
|
94
|
+
throwIfAborted(signal);
|
|
95
|
+
if (result && result.error) throw new Error(result.error);
|
|
96
|
+
if (result && result.output !== undefined) {
|
|
97
|
+
const output = result.output;
|
|
98
|
+
if (output === "undefined") return undefined;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(output);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return output;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// Page State
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
async function waitForPageLoad(jsEval, signal, timeoutMs = 30000) {
|
|
113
|
+
const deadline = Date.now() + timeoutMs;
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
try {
|
|
116
|
+
const ready = await evaluate(jsEval, "document.readyState", signal);
|
|
117
|
+
if (ready === "complete" || ready === "interactive") {
|
|
118
|
+
await delay(1200, signal);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// Page may not be reachable yet (mid-navigation); keep waiting
|
|
123
|
+
}
|
|
124
|
+
await delay(150, signal);
|
|
125
|
+
}
|
|
126
|
+
throw new Error("Page did not load in time");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function checkLoginStatus(jsEval, signal) {
|
|
130
|
+
const result = await evaluate(
|
|
131
|
+
jsEval,
|
|
132
|
+
`(() => {
|
|
133
|
+
const body = (document.body.innerText || '').toLowerCase();
|
|
134
|
+
const hasLogin = !!document.querySelector(
|
|
135
|
+
'a[href*="/login"], a[href*="sign-in"], button[data-testid*="login"], a[href*="signup"]'
|
|
136
|
+
);
|
|
137
|
+
const loginWallWords = ['log in', 'sign in', 'sign up', '登录', '注册'];
|
|
138
|
+
const looksLoggedOut = loginWallWords.some((w) => body.includes(w)) && !body.includes('ask anything');
|
|
139
|
+
return {
|
|
140
|
+
loggedIn: !hasLogin && !looksLoggedOut,
|
|
141
|
+
url: location.href,
|
|
142
|
+
bodyLen: body.length
|
|
143
|
+
};
|
|
144
|
+
})()`,
|
|
145
|
+
signal
|
|
146
|
+
);
|
|
147
|
+
return result || { loggedIn: false, url: "", bodyLen: 0 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function waitForKimiReady(jsEval, signal, timeoutMs = 25000) {
|
|
151
|
+
const deadline = Date.now() + timeoutMs;
|
|
152
|
+
let lastState = null;
|
|
153
|
+
while (Date.now() < deadline) {
|
|
154
|
+
let state = null;
|
|
155
|
+
try {
|
|
156
|
+
state = await evaluate(
|
|
157
|
+
jsEval,
|
|
158
|
+
`(() => {
|
|
159
|
+
const input = ${FIND_INPUT_JS};
|
|
160
|
+
const visible = !!input && input.offsetParent !== null;
|
|
161
|
+
const body = (document.body.innerText || '');
|
|
162
|
+
return { ready: visible, hasInput: visible, bodyLen: body.length, url: location.href };
|
|
163
|
+
})()`,
|
|
164
|
+
signal
|
|
165
|
+
);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (signal?.aborted) throw e;
|
|
168
|
+
// Transient jsEval failure mid-SPA-load - keep waiting
|
|
169
|
+
}
|
|
170
|
+
lastState = state;
|
|
171
|
+
if (state && state.ready) return state;
|
|
172
|
+
await delay(250, signal);
|
|
173
|
+
}
|
|
174
|
+
if (lastState) {
|
|
175
|
+
throw new Error(`Kimi chat UI not detected (current: ${lastState.url}) - may need to log in to kimi.com`);
|
|
176
|
+
}
|
|
177
|
+
throw new Error("Timed out waiting for Kimi chat UI");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Model Selection (best-effort)
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
async function selectModel(jsEval, signal, desiredModel, timeoutMs = 8000) {
|
|
185
|
+
const requestedLabels = getMatchLabels(desiredModel);
|
|
186
|
+
if (requestedLabels.length === 0) return desiredModel;
|
|
187
|
+
|
|
188
|
+
// The model picker (.current-model) renders late - wait for it (up to 6s)
|
|
189
|
+
let modelClicked = null;
|
|
190
|
+
const clickDeadline = Date.now() + 6000;
|
|
191
|
+
while (Date.now() < clickDeadline && !(modelClicked && modelClicked.success)) {
|
|
192
|
+
modelClicked = await evaluate(
|
|
193
|
+
jsEval,
|
|
194
|
+
`(() => {
|
|
195
|
+
${buildClickDispatcher()}
|
|
196
|
+
// Kimi renders the model picker as div.current-model / div.model-name
|
|
197
|
+
const modelDiv = document.querySelector('.current-model, .model-name, [class*="model-picker"], [class*="model-select"]');
|
|
198
|
+
if (modelDiv && modelDiv.offsetParent !== null) {
|
|
199
|
+
dispatchClickSequence(modelDiv);
|
|
200
|
+
return { success: true };
|
|
201
|
+
}
|
|
202
|
+
const input = ${FIND_INPUT_JS};
|
|
203
|
+
const scope = input
|
|
204
|
+
? (input.closest('[class*="composer"], [class*="input"], [role="form"], form') || input.parentElement)
|
|
205
|
+
: document;
|
|
206
|
+
const buttons = Array.from((scope || document).querySelectorAll('button'));
|
|
207
|
+
const modelBtn = buttons.find((b) => {
|
|
208
|
+
const text = (b.textContent || '').toLowerCase();
|
|
209
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
210
|
+
return /k2|k3|instant|thinking|deep|model/i.test(text) || label.includes('model');
|
|
211
|
+
});
|
|
212
|
+
if (modelBtn) { dispatchClickSequence(modelBtn); return { success: true }; }
|
|
213
|
+
return { success: false };
|
|
214
|
+
})()`,
|
|
215
|
+
signal
|
|
216
|
+
);
|
|
217
|
+
if (!(modelClicked && modelClicked.success)) await delay(500, signal);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!modelClicked || !modelClicked.success) return desiredModel;
|
|
221
|
+
await delay(900, signal);
|
|
222
|
+
|
|
223
|
+
const deadline = Date.now() + timeoutMs;
|
|
224
|
+
while (Date.now() < deadline) {
|
|
225
|
+
const result = await evaluate(
|
|
226
|
+
jsEval,
|
|
227
|
+
`(() => {
|
|
228
|
+
${buildClickDispatcher()}
|
|
229
|
+
const requestedLabels = ${JSON.stringify(requestedLabels)};
|
|
230
|
+
const norm = (t) => (t || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
231
|
+
// Kimi model popover: .models-popover with plain div rows (no role attrs)
|
|
232
|
+
const popover = document.querySelector('.models-popover') ||
|
|
233
|
+
Array.from(document.querySelectorAll('.n-popover__content, .n-popover')).find((el) => el.offsetParent !== null && (el.textContent || '').length > 0);
|
|
234
|
+
const candidates = popover
|
|
235
|
+
? Array.from(popover.querySelectorAll('[role="menuitemradio"], [role="menuitem"], [role="option"], [class*="item"], div'))
|
|
236
|
+
: Array.from(document.querySelectorAll('[role="menuitemradio"], [role="menuitem"], [role="option"]'));
|
|
237
|
+
if (candidates.length === 0) return { waiting: true };
|
|
238
|
+
let best = null, bestScore = 0;
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
for (const item of candidates) {
|
|
241
|
+
if (seen.has(item)) continue;
|
|
242
|
+
seen.add(item);
|
|
243
|
+
if (item.offsetParent === null) continue;
|
|
244
|
+
const text = norm(item.textContent || '');
|
|
245
|
+
if (text.length < 2) continue;
|
|
246
|
+
let score = 0;
|
|
247
|
+
for (const label of requestedLabels) {
|
|
248
|
+
if (!label) continue;
|
|
249
|
+
if (text === label || text.startsWith(label)) score = Math.max(score, 100);
|
|
250
|
+
else if (text.includes(label)) score = Math.max(score, 90);
|
|
251
|
+
}
|
|
252
|
+
if (score > bestScore) { bestScore = score; best = item; }
|
|
253
|
+
}
|
|
254
|
+
if (!best) return { found: true, success: false };
|
|
255
|
+
dispatchClickSequence(best);
|
|
256
|
+
return { found: true, success: true, model: (best.textContent || '').trim().split('\\n')[0] };
|
|
257
|
+
})()`,
|
|
258
|
+
signal
|
|
259
|
+
);
|
|
260
|
+
if (result && result.found) {
|
|
261
|
+
if (result.success) { await delay(200, signal); return result.model; }
|
|
262
|
+
await evaluate(jsEval, "document.body.click()", signal);
|
|
263
|
+
throw new Error(`No matching model in menu for "${desiredModel}"`);
|
|
264
|
+
}
|
|
265
|
+
await delay(120, signal);
|
|
266
|
+
}
|
|
267
|
+
await evaluate(jsEval, "document.body.click()", signal);
|
|
268
|
+
throw new Error(`Timed out waiting for model menu to show "${desiredModel}"`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Start a fresh chat - kimi.com restores the last session on home
|
|
272
|
+
async function startNewChat(jsEval, signal) {
|
|
273
|
+
const clicked = await evaluate(
|
|
274
|
+
jsEval,
|
|
275
|
+
`(() => {
|
|
276
|
+
${buildClickDispatcher()}
|
|
277
|
+
const btn = document.querySelector('a[aria-label="New Chat"], a.new-chat-btn, .sidebar-new-chat, a.logo');
|
|
278
|
+
if (!btn || btn.offsetParent === null) return { success: false };
|
|
279
|
+
dispatchClickSequence(btn);
|
|
280
|
+
return { success: true };
|
|
281
|
+
})()`,
|
|
282
|
+
signal
|
|
283
|
+
);
|
|
284
|
+
if (clicked && clicked.success) {
|
|
285
|
+
await delay(1800, signal);
|
|
286
|
+
}
|
|
287
|
+
return (clicked && clicked.success) || false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ============================================================================
|
|
291
|
+
// Input + Submission (Lexical-safe)
|
|
292
|
+
// ============================================================================
|
|
293
|
+
|
|
294
|
+
async function typePrompt(jsEval, signal, prompt) {
|
|
295
|
+
const promptTail = normalizeLabel(prompt).slice(-30);
|
|
296
|
+
const insertCode = (mode) => `(() => {
|
|
297
|
+
const input = ${FIND_INPUT_JS};
|
|
298
|
+
if (!input) return { success: false, error: 'input not found' };
|
|
299
|
+
input.focus();
|
|
300
|
+
const prompt = ${JSON.stringify(prompt)};
|
|
301
|
+
let ok = false;
|
|
302
|
+
if (${mode === 'fallback' ? 'true' : 'false'}) {
|
|
303
|
+
const sel = window.getSelection();
|
|
304
|
+
const range = document.createRange();
|
|
305
|
+
range.selectNodeContents(input);
|
|
306
|
+
range.collapse(false);
|
|
307
|
+
sel.removeAllRanges();
|
|
308
|
+
sel.addRange(range);
|
|
309
|
+
input.dispatchEvent(new InputEvent('beforeinput', {
|
|
310
|
+
bubbles: true, cancelable: true, inputType: 'insertText', data: prompt
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
ok = document.execCommand('insertText', false, prompt);
|
|
315
|
+
} catch (e) {
|
|
316
|
+
ok = false;
|
|
317
|
+
}
|
|
318
|
+
return { ok, len: (input.textContent || '').length, cls: (input.className || '').toString().slice(0, 60) };
|
|
319
|
+
})()`;
|
|
320
|
+
const verifyCode = `(() => {
|
|
321
|
+
const input = ${FIND_INPUT_JS};
|
|
322
|
+
const text = (input ? input.textContent : '') || '';
|
|
323
|
+
const tail = ${JSON.stringify(promptTail)};
|
|
324
|
+
const plen = ${JSON.stringify(prompt.length)};
|
|
325
|
+
const norm = text.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
326
|
+
return { len: text.length, matched: tail ? norm.includes(tail) : text.length > 0, full: text.length >= plen };
|
|
327
|
+
})()`;
|
|
328
|
+
|
|
329
|
+
// 1. Focus the composer
|
|
330
|
+
const focused = await evaluate(
|
|
331
|
+
jsEval,
|
|
332
|
+
`(() => {
|
|
333
|
+
${buildClickDispatcher()}
|
|
334
|
+
const input = ${FIND_INPUT_JS};
|
|
335
|
+
if (!input || input.offsetParent === null) return { success: false, error: 'input not visible' };
|
|
336
|
+
input.scrollIntoView({ block: 'center' });
|
|
337
|
+
dispatchClickSequence(input);
|
|
338
|
+
input.focus();
|
|
339
|
+
return { success: true };
|
|
340
|
+
})()`,
|
|
341
|
+
signal
|
|
342
|
+
);
|
|
343
|
+
if (!focused || !focused.success) {
|
|
344
|
+
throw new Error(`Could not focus Kimi input: ${focused?.error || 'unknown'}`);
|
|
345
|
+
}
|
|
346
|
+
await delay(400, signal);
|
|
347
|
+
|
|
348
|
+
// 2. Insert + poll for the async Lexical commit (up to 10s per attempt)
|
|
349
|
+
const waitForText = async (attempt) => {
|
|
350
|
+
const insert = await evaluate(jsEval, insertCode(attempt), signal);
|
|
351
|
+
const deadline = Date.now() + 10000;
|
|
352
|
+
let last = null;
|
|
353
|
+
while (Date.now() < deadline) {
|
|
354
|
+
last = await evaluate(jsEval, verifyCode, signal);
|
|
355
|
+
if (last && last.matched && last.full && last.len > 0) return last;
|
|
356
|
+
await delay(400, signal);
|
|
357
|
+
}
|
|
358
|
+
return last;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const result = await waitForText('primary');
|
|
362
|
+
if (result && result.matched && result.full && result.len > 0) return;
|
|
363
|
+
|
|
364
|
+
// 3. Fallback: clear composer, then selection + beforeinput + execCommand.
|
|
365
|
+
// Clearing first prevents duplicated text when the primary insert partially
|
|
366
|
+
// committed into the Lexical editor.
|
|
367
|
+
const cleared = await evaluate(
|
|
368
|
+
jsEval,
|
|
369
|
+
`(() => {
|
|
370
|
+
const input = ${FIND_INPUT_JS};
|
|
371
|
+
if (!input) return { ok: false };
|
|
372
|
+
input.focus();
|
|
373
|
+
try {
|
|
374
|
+
document.execCommand('selectAll', false, null);
|
|
375
|
+
document.execCommand('delete', false, null);
|
|
376
|
+
} catch (e) { /* some engines ignore */ }
|
|
377
|
+
return { ok: true };
|
|
378
|
+
})()`,
|
|
379
|
+
signal
|
|
380
|
+
);
|
|
381
|
+
void cleared;
|
|
382
|
+
await delay(300, signal);
|
|
383
|
+
const result2 = await waitForText('fallback');
|
|
384
|
+
if (!result2 || !result2.matched || !result2.full || !result2.len) {
|
|
385
|
+
const detail = result2 ? `(len=${result2.len}, full=${result2.full})` : '';
|
|
386
|
+
throw new Error(`Kimi composer did not accept typed text ${detail}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function submitPrompt(jsEval, signal) {
|
|
391
|
+
const clicked = await evaluate(
|
|
392
|
+
jsEval,
|
|
393
|
+
`(() => {
|
|
394
|
+
${buildClickDispatcher()}
|
|
395
|
+
const input = ${FIND_INPUT_JS};
|
|
396
|
+
const isVisible = (el) => el && el.offsetParent !== null;
|
|
397
|
+
const isEnabled = (el) => el && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
|
|
398
|
+
// 1. Kimi's send control: a div.send-button-container (not a <button>)
|
|
399
|
+
const container = document.querySelector('.send-button-container');
|
|
400
|
+
if (container && isVisible(container) && isEnabled(container)) {
|
|
401
|
+
dispatchClickSequence(container);
|
|
402
|
+
return { success: true, method: 'send-button-container' };
|
|
403
|
+
}
|
|
404
|
+
const scope = input
|
|
405
|
+
? (input.closest('[class*="composer"], [class*="input"], [role="form"], form') || input.parentElement)
|
|
406
|
+
: document;
|
|
407
|
+
const buttons = Array.from((scope || document).querySelectorAll('button'));
|
|
408
|
+
// 2. aria-label / text match
|
|
409
|
+
const labeled = buttons.find((b) => {
|
|
410
|
+
if (!isVisible(b) || !isEnabled(b)) return false;
|
|
411
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
412
|
+
const txt = (b.textContent || '').trim().toLowerCase();
|
|
413
|
+
return label === 'send' || label === 'submit' || label.startsWith('send ') ||
|
|
414
|
+
label.includes('发送') || txt === 'send' || txt.includes('发送');
|
|
415
|
+
});
|
|
416
|
+
if (labeled) { dispatchClickSequence(labeled); return { success: true, method: 'label' }; }
|
|
417
|
+
// 3. Last enabled visible button in the composer
|
|
418
|
+
const candidates = buttons.filter((b) => isVisible(b) && isEnabled(b));
|
|
419
|
+
if (candidates.length > 0) {
|
|
420
|
+
dispatchClickSequence(candidates[candidates.length - 1]);
|
|
421
|
+
return { success: true, method: 'last' };
|
|
422
|
+
}
|
|
423
|
+
return { success: false };
|
|
424
|
+
})()`,
|
|
425
|
+
signal
|
|
426
|
+
);
|
|
427
|
+
if (!clicked || !clicked.success) {
|
|
428
|
+
throw new Error("Kimi send button not found");
|
|
429
|
+
}
|
|
430
|
+
await delay(600, signal);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ============================================================================
|
|
434
|
+
// Response Handling
|
|
435
|
+
// ============================================================================
|
|
436
|
+
|
|
437
|
+
// Extract the assistant answer from full body innerText. Fresh tab per query
|
|
438
|
+
// means exactly one turn: user prompt line -> assistant reply -> input chrome.
|
|
439
|
+
function extractKimiResponse(bodyText, userPrompt = "") {
|
|
440
|
+
if (!bodyText) return null;
|
|
441
|
+
const lines = bodyText.split("\n").map((l) => l.trim()).filter((l) => l);
|
|
442
|
+
if (lines.length === 0) return null;
|
|
443
|
+
|
|
444
|
+
// UI chrome lines to drop (kimi sidebar + composer + message actions)
|
|
445
|
+
const uiSet = new Set([
|
|
446
|
+
"edit", "copy", "share", "regenerate", "like", "dislike", "stop", "stop generating",
|
|
447
|
+
"new chat", "plugins", "scheduled tasks", "swarm", "slides", "deep research",
|
|
448
|
+
"websites", "docs", "sheets", "design", "kimi work", "kimi code", "kimi claw",
|
|
449
|
+
"projects", "new project", "chats", "all chats", "upgrade", "explore inspiration",
|
|
450
|
+
"chat with kimi", "loading", "thinking...", "instant", "thinking", "high",
|
|
451
|
+
"type \"/\" to invoke plugins and skills", "select project", "invite to earn",
|
|
452
|
+
"up to 1-year k3 credits", "audiolibro vs libro", "kimi swarm definition",
|
|
453
|
+
"kimi cline reddit views", "kimi api plugin", "hermes models add section",
|
|
454
|
+
"hermes agent section update",
|
|
455
|
+
]);
|
|
456
|
+
const isUI = (l) => {
|
|
457
|
+
const key = l.toLowerCase().replace(/[.?!]+$/, "");
|
|
458
|
+
if (uiSet.has(key) || uiSet.has(l.toLowerCase())) return true;
|
|
459
|
+
// kimi footer / chrome prefix patterns
|
|
460
|
+
const lower = l.toLowerCase();
|
|
461
|
+
return (
|
|
462
|
+
lower.startsWith("ask anything") ||
|
|
463
|
+
lower.startsWith("high demand") ||
|
|
464
|
+
lower.startsWith("upgrade to use") ||
|
|
465
|
+
lower.startsWith("switched to k") ||
|
|
466
|
+
lower.startsWith("kimi can make mistakes") ||
|
|
467
|
+
lower.startsWith("kimi ai may") ||
|
|
468
|
+
lower.startsWith("explore inspiration") ||
|
|
469
|
+
lower.startsWith("new chat") ||
|
|
470
|
+
lower.startsWith("projects") ||
|
|
471
|
+
lower.startsWith("chats") ||
|
|
472
|
+
lower.startsWith("plugins") ||
|
|
473
|
+
lower.startsWith("live reminders") ||
|
|
474
|
+
lower.startsWith("mark all as read") ||
|
|
475
|
+
lower.startsWith("scroll to explore") ||
|
|
476
|
+
lower.startsWith("too many people are chatting") ||
|
|
477
|
+
lower.startsWith("subscribe to enter a dedicated priority queue") ||
|
|
478
|
+
lower.startsWith("got it") ||
|
|
479
|
+
lower === "completed" ||
|
|
480
|
+
lower === "tips"
|
|
481
|
+
);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
// Locate the LAST occurrence of the user prompt (most recent turn).
|
|
485
|
+
// Prefer an exact normalized line match: replies that echo the prompt
|
|
486
|
+
// word ("hi", "ok") would otherwise match inside the reply and shift the
|
|
487
|
+
// slice past the answer.
|
|
488
|
+
const promptNorm = normalizeLabel(userPrompt).slice(0, 30);
|
|
489
|
+
const promptLineNorms = String(userPrompt || "").split(/\r?\n/)
|
|
490
|
+
.map((line) => line.trim())
|
|
491
|
+
.filter((line) => line.length > 0)
|
|
492
|
+
.map(normalizeLabel);
|
|
493
|
+
const findPromptSequence = () => {
|
|
494
|
+
if (promptLineNorms.length < 2) return -1;
|
|
495
|
+
for (let i = lines.length - promptLineNorms.length; i >= 0; i--) {
|
|
496
|
+
let matches = true;
|
|
497
|
+
for (let j = 0; j < promptLineNorms.length; j++) {
|
|
498
|
+
if (normalizeLabel(lines[i + j]) !== promptLineNorms[j]) {
|
|
499
|
+
matches = false;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (matches) return i + promptLineNorms.length - 1;
|
|
504
|
+
}
|
|
505
|
+
return -1;
|
|
506
|
+
};
|
|
507
|
+
const findPrompt = (exactOnly) => {
|
|
508
|
+
if (!promptNorm) return -1;
|
|
509
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
510
|
+
const n = normalizeLabel(lines[i]);
|
|
511
|
+
if (exactOnly ? n === promptNorm : n.includes(promptNorm)) return i;
|
|
512
|
+
}
|
|
513
|
+
return -1;
|
|
514
|
+
};
|
|
515
|
+
let lastIdx = findPromptSequence();
|
|
516
|
+
if (lastIdx < 0) lastIdx = findPrompt(true);
|
|
517
|
+
if (lastIdx < 0) lastIdx = findPrompt(false);
|
|
518
|
+
let start = lastIdx >= 0 ? lastIdx + 1 : 0;
|
|
519
|
+
|
|
520
|
+
// If everything after the prompt marker is UI chrome (e.g. a reply that
|
|
521
|
+
// echoed the prompt and consumed the match), re-slice from the previous
|
|
522
|
+
// occurrence of the prompt instead of returning chrome as the answer.
|
|
523
|
+
const isChromeLine = (l) =>
|
|
524
|
+
isUI(l) || (l.length <= 1 && !/^[\d.,%$€£+\-—]+$/.test(l));
|
|
525
|
+
const sliceIsChrome = (from) =>
|
|
526
|
+
lines.slice(from).every((l) => isChromeLine(l));
|
|
527
|
+
if (sliceIsChrome(start) && lastIdx >= 0) {
|
|
528
|
+
for (let i = lastIdx - 1; i >= 0; i--) {
|
|
529
|
+
const n = normalizeLabel(lines[i]);
|
|
530
|
+
if (n.includes(promptNorm)) { start = i + 1; break; }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const out = [];
|
|
535
|
+
for (let i = start; i < lines.length; i++) {
|
|
536
|
+
const line = lines[i];
|
|
537
|
+
if (!line) continue;
|
|
538
|
+
if (isUI(line)) continue;
|
|
539
|
+
if (line.length <= 1 && !/^[\d.,%$€£+\-—]+$/.test(line)) continue;
|
|
540
|
+
out.push(line);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (out.length > 0) return out.join("\n").trim();
|
|
544
|
+
// Fallback: everything after the prompt marker, but only if it contains
|
|
545
|
+
// substantive (non-chrome) content - otherwise the reply hasn't started yet
|
|
546
|
+
const rest = lines
|
|
547
|
+
.slice(start)
|
|
548
|
+
.filter((l) => !isUI(l) && !(l.length <= 1 && !/^[\d.,%$€£+\-—]+$/.test(l)))
|
|
549
|
+
.join("\n")
|
|
550
|
+
.trim();
|
|
551
|
+
return rest || null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function waitForResponse(jsEval, signal, timeoutMs = 300000, userPrompt = "") {
|
|
555
|
+
const deadline = Date.now() + timeoutMs;
|
|
556
|
+
let lastText = "";
|
|
557
|
+
let stableCycles = 0;
|
|
558
|
+
let lastChangeAt = Date.now();
|
|
559
|
+
let lastResponse = "";
|
|
560
|
+
|
|
561
|
+
while (Date.now() < deadline) {
|
|
562
|
+
const snapshot = await evaluate(
|
|
563
|
+
jsEval,
|
|
564
|
+
`(() => {
|
|
565
|
+
const bodyText = document.body.innerText || '';
|
|
566
|
+
// Stop control while generating
|
|
567
|
+
const stopBtn = Array.from(document.querySelectorAll('button')).find((b) => {
|
|
568
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
569
|
+
const txt = (b.textContent || '').toLowerCase().trim();
|
|
570
|
+
return (label.includes('stop') || txt === 'stop' || txt.includes('stop generating') || label.includes('停止') || txt.includes('停止'));
|
|
571
|
+
});
|
|
572
|
+
const input = ${FIND_INPUT_JS};
|
|
573
|
+
return {
|
|
574
|
+
bodyText,
|
|
575
|
+
hasStop: !!stopBtn && stopBtn.offsetParent !== null,
|
|
576
|
+
inputReady: !!input && input.offsetParent !== null,
|
|
577
|
+
url: location.href,
|
|
578
|
+
};
|
|
579
|
+
})()`,
|
|
580
|
+
signal
|
|
581
|
+
);
|
|
582
|
+
if (!snapshot || !snapshot.bodyText) {
|
|
583
|
+
await delay(350, signal);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const response = extractKimiResponse(snapshot.bodyText, userPrompt);
|
|
588
|
+
const current = response || "";
|
|
589
|
+
|
|
590
|
+
// Free accounts see a priority-queue notice when Kimi is at capacity.
|
|
591
|
+
// Fail fast with a clear error instead of a 300s silent timeout.
|
|
592
|
+
if (!current && !snapshot.hasStop &&
|
|
593
|
+
/too many people are chatting with kimi|subscribe to enter a dedicated priority queue/i.test(snapshot.bodyText)) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
"Kimi is at capacity (priority-queue notice shown when no paid subscription is active); retry later"
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (current !== lastResponse) {
|
|
600
|
+
lastResponse = current;
|
|
601
|
+
stableCycles = 0;
|
|
602
|
+
lastChangeAt = Date.now();
|
|
603
|
+
} else if (current.length > 0) {
|
|
604
|
+
stableCycles++;
|
|
605
|
+
}
|
|
606
|
+
if (current !== lastText) lastText = current;
|
|
607
|
+
|
|
608
|
+
const stableMs = Date.now() - lastChangeAt;
|
|
609
|
+
const done = current.length > 0 && !snapshot.hasStop && snapshot.inputReady &&
|
|
610
|
+
(stableCycles >= 3 || stableMs >= 2000);
|
|
611
|
+
|
|
612
|
+
if (done) {
|
|
613
|
+
return { text: current, url: snapshot.url, partial: false };
|
|
614
|
+
}
|
|
615
|
+
await delay(400, signal);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Timeout: return whatever we have
|
|
619
|
+
const finalText = extractKimiResponse(lastText || "", userPrompt);
|
|
620
|
+
if (finalText && finalText.trim().length > 0) {
|
|
621
|
+
return { text: finalText, url: undefined, partial: true };
|
|
622
|
+
}
|
|
623
|
+
throw new Error("Response timeout - Kimi did not complete in time");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ============================================================================
|
|
627
|
+
// Main Query
|
|
628
|
+
// ============================================================================
|
|
629
|
+
|
|
630
|
+
async function query(options) {
|
|
631
|
+
const {
|
|
632
|
+
prompt,
|
|
633
|
+
extractionPrompt = prompt,
|
|
634
|
+
model,
|
|
635
|
+
timeout = 300000,
|
|
636
|
+
createTab,
|
|
637
|
+
closeTab,
|
|
638
|
+
jsEval,
|
|
639
|
+
log = () => {},
|
|
640
|
+
signal,
|
|
641
|
+
} = options;
|
|
642
|
+
throwIfAborted(signal);
|
|
643
|
+
|
|
644
|
+
const startTime = Date.now();
|
|
645
|
+
log("Starting Kimi query");
|
|
646
|
+
|
|
647
|
+
const tabInfo = await raceAbort(createTab, signal);
|
|
648
|
+
const tabId = tabInfo && (tabInfo.tabId ?? (tabInfo.tabs && tabInfo.tabs[0] && tabInfo.tabs[0].tabId));
|
|
649
|
+
if (!tabId) {
|
|
650
|
+
throw new Error(`Failed to create Kimi tab: ${JSON.stringify(tabInfo)}`);
|
|
651
|
+
}
|
|
652
|
+
log(`Created tab ${tabId}`);
|
|
653
|
+
|
|
654
|
+
const evalPage = (expression) => raceAbort(() => jsEval(tabId, expression), signal);
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
await waitForPageLoad(evalPage, signal);
|
|
658
|
+
log("Page loaded");
|
|
659
|
+
|
|
660
|
+
const loginStatus = await checkLoginStatus(evalPage, signal);
|
|
661
|
+
if (!loginStatus.loggedIn) {
|
|
662
|
+
throw new Error("kimi.com login required - log in to kimi.com in your browser first");
|
|
663
|
+
}
|
|
664
|
+
log(`Login: yes (${loginStatus.url})`);
|
|
665
|
+
|
|
666
|
+
await waitForKimiReady(evalPage, signal);
|
|
667
|
+
log("Kimi ready");
|
|
668
|
+
|
|
669
|
+
// Fresh chat (kimi.com may restore the previous session)
|
|
670
|
+
try {
|
|
671
|
+
await startNewChat(evalPage, signal);
|
|
672
|
+
log("Started new chat");
|
|
673
|
+
} catch (e) {
|
|
674
|
+
log(`New chat reset failed (continuing): ${e.message}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const warnings = [];
|
|
678
|
+
const targetModel = model || DEFAULT_MODEL;
|
|
679
|
+
let selectedModel = targetModel;
|
|
680
|
+
let modelSelectionFailed = false;
|
|
681
|
+
try {
|
|
682
|
+
selectedModel = await selectModel(evalPage, signal, targetModel);
|
|
683
|
+
log(`Model: ${selectedModel}`);
|
|
684
|
+
// K3 / K3 Swarm / agent rows switch kimi.com into the /agent or
|
|
685
|
+
// /projects workspace, which has a different composer this client does
|
|
686
|
+
// not drive. Navigation can lag the click, so poll the URL briefly.
|
|
687
|
+
let urlState = null;
|
|
688
|
+
const urlDeadline = Date.now() + 3500;
|
|
689
|
+
while (Date.now() < urlDeadline) {
|
|
690
|
+
urlState = await evaluate(
|
|
691
|
+
evalPage,
|
|
692
|
+
`(() => ({ url: location.href }))()`,
|
|
693
|
+
signal
|
|
694
|
+
);
|
|
695
|
+
if (urlState && /\/agent|\/swarm|\/projects/.test(urlState.url)) break;
|
|
696
|
+
await delay(250, signal);
|
|
697
|
+
}
|
|
698
|
+
if (urlState && /\/agent|\/swarm|\/projects/.test(urlState.url)) {
|
|
699
|
+
modelSelectionFailed = true;
|
|
700
|
+
warnings.push(
|
|
701
|
+
`Model "${selectedModel}" switches Kimi into its Agent workspace (${urlState.url}); ` +
|
|
702
|
+
`reverting to the default chat model. Use --model instant (default), thinking, or high for the chat UI.`
|
|
703
|
+
);
|
|
704
|
+
selectedModel = DEFAULT_MODEL;
|
|
705
|
+
await evaluate(evalPage, `(() => { location.href = 'https://www.kimi.com/'; return true; })()`, signal).catch(() => {});
|
|
706
|
+
await delay(3500, signal);
|
|
707
|
+
await waitForKimiReady(evalPage, signal);
|
|
708
|
+
}
|
|
709
|
+
} catch (e) {
|
|
710
|
+
if (signal?.aborted) throw e;
|
|
711
|
+
modelSelectionFailed = true;
|
|
712
|
+
warnings.push(`Model selection failed: ${e.message}. Kimi may auto-select its default model.`);
|
|
713
|
+
log(`Model selection failed: ${e.message}`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
await typePrompt(evalPage, signal, prompt);
|
|
717
|
+
log("Prompt typed");
|
|
718
|
+
|
|
719
|
+
await submitPrompt(evalPage, signal);
|
|
720
|
+
log("Submitted, waiting for response...");
|
|
721
|
+
|
|
722
|
+
const response = await waitForResponse(evalPage, signal, timeout, extractionPrompt);
|
|
723
|
+
log(`Response: ${response.text.length} chars${response.partial ? ' (partial)' : ''}`);
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
response: response.text,
|
|
727
|
+
model: selectedModel,
|
|
728
|
+
requestedModel: targetModel,
|
|
729
|
+
modelSelectionFailed,
|
|
730
|
+
url: response.url,
|
|
731
|
+
partial: response.partial || false,
|
|
732
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
733
|
+
tookMs: Date.now() - startTime,
|
|
734
|
+
};
|
|
735
|
+
} finally {
|
|
736
|
+
try {
|
|
737
|
+
await closeTab(tabId);
|
|
738
|
+
} catch (error) {
|
|
739
|
+
log(`Failed to close Kimi tab ${tabId}: ${error?.message || error}`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ============================================================================
|
|
745
|
+
// Validate - check UI structure and scrape available models
|
|
746
|
+
// ============================================================================
|
|
747
|
+
|
|
748
|
+
async function validate(options) {
|
|
749
|
+
const { createTab, closeTab, jsEval, log = () => {}, signal } = options;
|
|
750
|
+
throwIfAborted(signal);
|
|
751
|
+
const startTime = Date.now();
|
|
752
|
+
log("Starting Kimi validation");
|
|
753
|
+
|
|
754
|
+
const result = {
|
|
755
|
+
kimiValidate: true,
|
|
756
|
+
authenticated: false,
|
|
757
|
+
models: [],
|
|
758
|
+
expectedModels: Object.values(DEFAULT_KIMI_MODELS).map((m) => m.name),
|
|
759
|
+
inputFound: false,
|
|
760
|
+
sendButtonFound: false,
|
|
761
|
+
errors: [],
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
let tabId;
|
|
765
|
+
try {
|
|
766
|
+
const tabInfo = await raceAbort(createTab, signal);
|
|
767
|
+
tabId = tabInfo && (tabInfo.tabId ?? (tabInfo.tabs && tabInfo.tabs[0] && tabInfo.tabs[0].tabId));
|
|
768
|
+
if (!tabId) {
|
|
769
|
+
result.errors.push(`Failed to create tab: ${JSON.stringify(tabInfo)}`);
|
|
770
|
+
return { ...result, tookMs: Date.now() - startTime };
|
|
771
|
+
}
|
|
772
|
+
} catch (e) {
|
|
773
|
+
if (signal?.aborted) throw e;
|
|
774
|
+
result.errors.push(`Tab creation failed: ${e.message}`);
|
|
775
|
+
return { ...result, tookMs: Date.now() - startTime };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const evalPage = (expression) => raceAbort(() => jsEval(tabId, expression), signal);
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
await raceAbort(waitForPageLoad(evalPage, signal), signal);
|
|
782
|
+
const loginStatus = await checkLoginStatus(evalPage, signal);
|
|
783
|
+
result.authenticated = loginStatus.loggedIn;
|
|
784
|
+
if (!loginStatus.loggedIn) {
|
|
785
|
+
result.errors.push("kimi.com shows logged-out state - log in first");
|
|
786
|
+
return { ...result, tookMs: Date.now() - startTime };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
await raceAbort(waitForKimiReady(evalPage, signal), signal);
|
|
790
|
+
log("Kimi ready");
|
|
791
|
+
|
|
792
|
+
const inputCheck = await evaluate(
|
|
793
|
+
evalPage,
|
|
794
|
+
`(() => {
|
|
795
|
+
const input = ${FIND_INPUT_JS};
|
|
796
|
+
return { found: !!input && input.offsetParent !== null };
|
|
797
|
+
})()`,
|
|
798
|
+
signal
|
|
799
|
+
);
|
|
800
|
+
result.inputFound = inputCheck?.found || false;
|
|
801
|
+
|
|
802
|
+
const sendCheck = await evaluate(
|
|
803
|
+
evalPage,
|
|
804
|
+
`(() => {
|
|
805
|
+
const container = document.querySelector('.send-button-container');
|
|
806
|
+
if (container && container.offsetParent !== null) return { found: true };
|
|
807
|
+
const input = ${FIND_INPUT_JS};
|
|
808
|
+
const scope = input ? (input.parentElement || document) : document;
|
|
809
|
+
const btn = Array.from(scope.querySelectorAll('button')).find((b) => {
|
|
810
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
811
|
+
const txt = (b.textContent || '').trim().toLowerCase();
|
|
812
|
+
return label === 'send' || label === 'submit' || label.includes('发送') || txt === 'send';
|
|
813
|
+
});
|
|
814
|
+
return { found: !!btn && btn.offsetParent !== null };
|
|
815
|
+
})()`,
|
|
816
|
+
signal
|
|
817
|
+
);
|
|
818
|
+
result.sendButtonFound = sendCheck?.found || false;
|
|
819
|
+
|
|
820
|
+
// Try scraping the model menu
|
|
821
|
+
// Kimi renders .current-model/.model-name late - wait for it (up to 6s)
|
|
822
|
+
let modelReady = false;
|
|
823
|
+
const readyDeadline = Date.now() + 6000;
|
|
824
|
+
while (Date.now() < readyDeadline && !modelReady) {
|
|
825
|
+
const chk = await evaluate(
|
|
826
|
+
evalPage,
|
|
827
|
+
`(() => {
|
|
828
|
+
const el = document.querySelector('.current-model, .model-name');
|
|
829
|
+
return { ready: !!el && el.offsetParent !== null };
|
|
830
|
+
})()`,
|
|
831
|
+
signal
|
|
832
|
+
);
|
|
833
|
+
modelReady = chk && chk.ready;
|
|
834
|
+
if (!modelReady) await abortableDelay(400, signal);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const modelClicked = await evaluate(
|
|
838
|
+
evalPage,
|
|
839
|
+
`(() => {
|
|
840
|
+
${buildClickDispatcher()}
|
|
841
|
+
const modelDiv = document.querySelector('.current-model, .model-name, [class*="model-picker"], [class*="model-select"]');
|
|
842
|
+
if (modelDiv && modelDiv.offsetParent !== null) {
|
|
843
|
+
dispatchClickSequence(modelDiv);
|
|
844
|
+
return { success: true };
|
|
845
|
+
}
|
|
846
|
+
const input = ${FIND_INPUT_JS};
|
|
847
|
+
const scope = input ? (input.parentElement || document) : document;
|
|
848
|
+
const btn = Array.from(scope.querySelectorAll('button')).find((b) => {
|
|
849
|
+
const text = (b.textContent || '').toLowerCase();
|
|
850
|
+
return /k2|k3|instant|thinking|model/i.test(text);
|
|
851
|
+
});
|
|
852
|
+
if (btn) { dispatchClickSequence(btn); return { success: true }; }
|
|
853
|
+
return { success: false };
|
|
854
|
+
})()`,
|
|
855
|
+
signal
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
if (modelClicked?.success) {
|
|
859
|
+
// Popover may take a moment - retry the scrape for up to ~4s
|
|
860
|
+
let scrapeResult = null;
|
|
861
|
+
const scrapeDeadline = Date.now() + 4000;
|
|
862
|
+
while (Date.now() < scrapeDeadline && !(scrapeResult && scrapeResult.models && scrapeResult.models.length > 0)) {
|
|
863
|
+
await abortableDelay(600, signal);
|
|
864
|
+
scrapeResult = await evaluate(
|
|
865
|
+
evalPage,
|
|
866
|
+
`(() => {
|
|
867
|
+
const popover = document.querySelector('.models-popover') ||
|
|
868
|
+
Array.from(document.querySelectorAll('.n-popover__content, .n-popover')).find((el) => el.offsetParent !== null && (el.textContent || '').length > 0);
|
|
869
|
+
if (!popover) return { models: [] };
|
|
870
|
+
const names = [];
|
|
871
|
+
const seen = new Set();
|
|
872
|
+
for (const el of popover.querySelectorAll('[class*="item"], div')) {
|
|
873
|
+
const first = (el.textContent || '').trim().split('\\n')[0].trim();
|
|
874
|
+
if (!first || first.length < 1 || first.length > 40 || seen.has(first)) continue;
|
|
875
|
+
seen.add(first);
|
|
876
|
+
names.push(first);
|
|
877
|
+
}
|
|
878
|
+
return { models: names.slice(0, 12) };
|
|
879
|
+
})()`,
|
|
880
|
+
signal
|
|
881
|
+
).catch(() => ({ models: [] }));
|
|
882
|
+
}
|
|
883
|
+
result.models = scrapeResult?.models || [];
|
|
884
|
+
log(`Found models: ${result.models.join(', ')}`);
|
|
885
|
+
await evaluate(evalPage, "document.body.click()", signal);
|
|
886
|
+
}
|
|
887
|
+
} catch (e) {
|
|
888
|
+
if (signal?.aborted) throw e;
|
|
889
|
+
result.errors.push(`Validation error: ${e.message}`);
|
|
890
|
+
} finally {
|
|
891
|
+
try {
|
|
892
|
+
await closeTab(tabId);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
log(`Failed to close Kimi validation tab ${tabId}: ${error?.message || error}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
result.tookMs = Date.now() - startTime;
|
|
899
|
+
return result;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
module.exports = {
|
|
903
|
+
query,
|
|
904
|
+
validate,
|
|
905
|
+
extractKimiResponse,
|
|
906
|
+
normalizeLabel,
|
|
907
|
+
getMatchLabels,
|
|
908
|
+
waitForResponse,
|
|
909
|
+
KIMI_URL,
|
|
910
|
+
DEFAULT_KIMI_MODELS,
|
|
911
|
+
DEFAULT_MODEL,
|
|
912
|
+
};
|