surf-cli 2.0.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/LICENSE +21 -0
- package/README.md +426 -0
- package/dist/content/accessibility-tree.js +11 -0
- package/dist/content/accessibility-tree.js.map +1 -0
- package/dist/content/visual-indicator.js +111 -0
- package/dist/content/visual-indicator.js.map +1 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/manifest.json +70 -0
- package/dist/options/options.html +30 -0
- package/dist/options/options.js +30 -0
- package/dist/options/options.js.map +1 -0
- package/dist/service-worker/index.js +156 -0
- package/dist/service-worker/index.js.map +1 -0
- package/dist/service-worker-loader.js +1 -0
- package/native/CHANGELOG.md +136 -0
- package/native/README.md +141 -0
- package/native/chatgpt-client.cjs +455 -0
- package/native/cli.cjs +2424 -0
- package/native/config.cjs +87 -0
- package/native/device-presets.cjs +211 -0
- package/native/formatters/network.cjs +402 -0
- package/native/gemini-client.cjs +637 -0
- package/native/host-helpers.cjs +989 -0
- package/native/host-wrapper.py +15 -0
- package/native/host.cjs +1271 -0
- package/native/host.sh +2 -0
- package/native/mcp-server.cjs +511 -0
- package/native/network-store.cjs +851 -0
- package/native/perplexity-client.cjs +561 -0
- package/native/protocol.cjs +27 -0
- package/native/test-host.py +41 -0
- package/native/tests/cli-tests.sh +115 -0
- package/package.json +70 -0
- package/scripts/install-native-host.cjs +308 -0
- package/scripts/uninstall-native-host.cjs +194 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perplexity Web Client for surf-cli
|
|
3
|
+
*
|
|
4
|
+
* CDP-based client for perplexity.ai using browser automation.
|
|
5
|
+
* Similar approach to the ChatGPT client.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const PERPLEXITY_URL = "https://www.perplexity.ai/";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Helpers
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
function delay(ms) {
|
|
15
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildClickDispatcher() {
|
|
19
|
+
return `function dispatchClickSequence(target) {
|
|
20
|
+
if (!target || !(target instanceof EventTarget)) return false;
|
|
21
|
+
const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
|
|
22
|
+
for (const type of types) {
|
|
23
|
+
const common = { bubbles: true, cancelable: true, view: window };
|
|
24
|
+
let event;
|
|
25
|
+
if (type.startsWith('pointer') && 'PointerEvent' in window) {
|
|
26
|
+
event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
|
|
27
|
+
} else {
|
|
28
|
+
event = new MouseEvent(type, common);
|
|
29
|
+
}
|
|
30
|
+
target.dispatchEvent(event);
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function evaluate(cdp, expression) {
|
|
37
|
+
const result = await cdp(expression);
|
|
38
|
+
if (result.exceptionDetails) {
|
|
39
|
+
const desc = result.exceptionDetails.exception?.description ||
|
|
40
|
+
result.exceptionDetails.text ||
|
|
41
|
+
"Evaluation failed";
|
|
42
|
+
throw new Error(desc);
|
|
43
|
+
}
|
|
44
|
+
if (result.error) {
|
|
45
|
+
throw new Error(result.error);
|
|
46
|
+
}
|
|
47
|
+
return result.result?.value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Page State Functions
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
async function waitForPageLoad(cdp, timeoutMs = 30000) {
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
while (Date.now() < deadline) {
|
|
57
|
+
const ready = await evaluate(cdp, "document.readyState");
|
|
58
|
+
if (ready === "complete" || ready === "interactive") {
|
|
59
|
+
// Extra wait for Perplexity's React app to hydrate
|
|
60
|
+
await delay(1000);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await delay(100);
|
|
64
|
+
}
|
|
65
|
+
throw new Error("Page did not load in time");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function checkLoginStatus(cdp) {
|
|
69
|
+
const result = await evaluate(cdp, `(() => {
|
|
70
|
+
const buttons = Array.from(document.querySelectorAll('button, a'));
|
|
71
|
+
|
|
72
|
+
// Look for sign-in indicators (not logged in)
|
|
73
|
+
const hasSignIn = buttons.some(b => {
|
|
74
|
+
const text = (b.textContent || '').toLowerCase().trim();
|
|
75
|
+
return text === 'sign in' || text === 'log in';
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Look for account menu (logged in)
|
|
79
|
+
const hasAccount = buttons.some(b => {
|
|
80
|
+
const text = (b.textContent || '').toLowerCase();
|
|
81
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
82
|
+
return text.includes('account') || label.includes('account') || label.includes('profile');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Check for upgrade button (logged in but not Pro)
|
|
86
|
+
const hasUpgrade = buttons.some(b => {
|
|
87
|
+
const text = (b.textContent || '').toLowerCase().trim();
|
|
88
|
+
return text === 'upgrade';
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
loggedIn: hasAccount || hasUpgrade || !hasSignIn,
|
|
93
|
+
isPro: hasAccount && !hasUpgrade,
|
|
94
|
+
};
|
|
95
|
+
})()`);
|
|
96
|
+
|
|
97
|
+
return result || { loggedIn: false, isPro: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function waitForPromptReady(cdp, timeoutMs = 20000) {
|
|
101
|
+
// Wait for page to be interactive and Perplexity's React app to hydrate
|
|
102
|
+
// Instead of complex element detection, just wait for the page to settle
|
|
103
|
+
const deadline = Date.now() + timeoutMs;
|
|
104
|
+
|
|
105
|
+
// First wait for basic page ready
|
|
106
|
+
while (Date.now() < deadline) {
|
|
107
|
+
const state = await evaluate(cdp, `document.readyState`);
|
|
108
|
+
if (state === 'complete') break;
|
|
109
|
+
await delay(200);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Extra wait for React hydration
|
|
113
|
+
await delay(2000);
|
|
114
|
+
|
|
115
|
+
// Try to verify the page has the expected structure
|
|
116
|
+
const verified = await evaluate(cdp, `(() => {
|
|
117
|
+
// Check if we're on Perplexity and the page is loaded
|
|
118
|
+
const isPerplexity = location.hostname.includes('perplexity');
|
|
119
|
+
const hasInput = document.body.innerText.includes('Ask anything') ||
|
|
120
|
+
document.body.innerText.includes('Ask a follow-up');
|
|
121
|
+
return { ready: isPerplexity || hasInput, url: location.href };
|
|
122
|
+
})()`);
|
|
123
|
+
|
|
124
|
+
if (verified && verified.ready) {
|
|
125
|
+
return verified;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Even if verification fails, proceed anyway after timeout
|
|
129
|
+
// since the page might have different text
|
|
130
|
+
return { ready: true, fallback: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Mode and Model Selection
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
async function selectMode(cdp, mode) {
|
|
138
|
+
const normalizedMode = mode.toLowerCase();
|
|
139
|
+
|
|
140
|
+
const result = await evaluate(cdp, `(() => {
|
|
141
|
+
${buildClickDispatcher()}
|
|
142
|
+
|
|
143
|
+
const targetMode = ${JSON.stringify(normalizedMode)};
|
|
144
|
+
const radios = document.querySelectorAll('[role=radio]');
|
|
145
|
+
|
|
146
|
+
for (const radio of radios) {
|
|
147
|
+
const text = (radio.textContent || '').toLowerCase().trim();
|
|
148
|
+
if (text.includes(targetMode)) {
|
|
149
|
+
if (radio.getAttribute('aria-checked') === 'true') {
|
|
150
|
+
return { success: true, alreadySelected: true, mode: text };
|
|
151
|
+
}
|
|
152
|
+
if (radio.hasAttribute('disabled')) {
|
|
153
|
+
return { success: false, error: 'Mode is disabled (may require Pro)', mode: text };
|
|
154
|
+
}
|
|
155
|
+
dispatchClickSequence(radio);
|
|
156
|
+
return { success: true, mode: text };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { success: false, error: 'Mode not found' };
|
|
161
|
+
})()`);
|
|
162
|
+
|
|
163
|
+
if (!result || !result.success) {
|
|
164
|
+
throw new Error(`Failed to select mode: ${result?.error || 'unknown'}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await delay(300);
|
|
168
|
+
return result.mode;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function selectModel(cdp, model, timeoutMs = 8000) {
|
|
172
|
+
// Click the model selector button
|
|
173
|
+
const buttonClicked = await evaluate(cdp, `(() => {
|
|
174
|
+
${buildClickDispatcher()}
|
|
175
|
+
|
|
176
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
177
|
+
const modelBtn = buttons.find(b => {
|
|
178
|
+
const text = (b.textContent || '').toLowerCase();
|
|
179
|
+
return text.includes('choose a model') ||
|
|
180
|
+
text.includes('model') ||
|
|
181
|
+
(text.includes('sonar') || text.includes('gpt') || text.includes('claude'));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (!modelBtn) return { success: false, error: 'Model button not found' };
|
|
185
|
+
|
|
186
|
+
dispatchClickSequence(modelBtn);
|
|
187
|
+
return { success: true };
|
|
188
|
+
})()`);
|
|
189
|
+
|
|
190
|
+
if (!buttonClicked || !buttonClicked.success) {
|
|
191
|
+
throw new Error(`Model selector not found: ${buttonClicked?.error}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await delay(500);
|
|
195
|
+
|
|
196
|
+
// Select from menu
|
|
197
|
+
const normalizedModel = model.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
198
|
+
|
|
199
|
+
const result = await evaluate(cdp, `(async () => {
|
|
200
|
+
${buildClickDispatcher()}
|
|
201
|
+
|
|
202
|
+
const targetModel = ${JSON.stringify(normalizedModel)};
|
|
203
|
+
const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
204
|
+
const deadline = Date.now() + ${timeoutMs};
|
|
205
|
+
|
|
206
|
+
while (Date.now() < deadline) {
|
|
207
|
+
const menuItems = document.querySelectorAll('[role=menuitem], [role=menuitemradio], [role=option]');
|
|
208
|
+
|
|
209
|
+
if (menuItems.length === 0) {
|
|
210
|
+
await new Promise(r => setTimeout(r, 100));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let bestMatch = null;
|
|
215
|
+
let bestScore = 0;
|
|
216
|
+
|
|
217
|
+
for (const item of menuItems) {
|
|
218
|
+
const text = normalize(item.textContent || '');
|
|
219
|
+
let score = 0;
|
|
220
|
+
|
|
221
|
+
if (text.includes(targetModel)) score = 100;
|
|
222
|
+
else if (targetModel.includes(text) && text.length > 3) score = 50;
|
|
223
|
+
|
|
224
|
+
if (score > bestScore) {
|
|
225
|
+
bestScore = score;
|
|
226
|
+
bestMatch = item;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (bestMatch) {
|
|
231
|
+
dispatchClickSequence(bestMatch);
|
|
232
|
+
await new Promise(r => setTimeout(r, 200));
|
|
233
|
+
return { success: true, model: bestMatch.textContent?.trim() };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await new Promise(r => setTimeout(r, 100));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Close menu by clicking elsewhere
|
|
240
|
+
document.body.click();
|
|
241
|
+
return { success: false, error: 'Model not found in menu' };
|
|
242
|
+
})()`);
|
|
243
|
+
|
|
244
|
+
if (!result || !result.success) {
|
|
245
|
+
throw new Error(`Failed to select model: ${result?.error}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return result.model;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============================================================================
|
|
252
|
+
// Input and Submission
|
|
253
|
+
// ============================================================================
|
|
254
|
+
|
|
255
|
+
async function typePrompt(cdp, inputCdp, prompt) {
|
|
256
|
+
// Click on the input area to focus it
|
|
257
|
+
// Perplexity uses a complex input - just click in the general area
|
|
258
|
+
const clicked = await evaluate(cdp, `(() => {
|
|
259
|
+
${buildClickDispatcher()}
|
|
260
|
+
|
|
261
|
+
// Strategy 1: Find element with "Ask anything" placeholder text
|
|
262
|
+
const allElements = document.querySelectorAll('*');
|
|
263
|
+
for (const el of allElements) {
|
|
264
|
+
const text = el.textContent || '';
|
|
265
|
+
const placeholder = el.getAttribute('placeholder') || el.getAttribute('aria-placeholder') || '';
|
|
266
|
+
if ((placeholder.includes('Ask') || text === 'Ask anything') && el.offsetParent) {
|
|
267
|
+
dispatchClickSequence(el);
|
|
268
|
+
el.focus?.();
|
|
269
|
+
return { success: true, method: 'placeholder' };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Strategy 2: Find textarea or contenteditable
|
|
274
|
+
const inputs = document.querySelectorAll('textarea, [contenteditable=true], [role=textbox]');
|
|
275
|
+
for (const el of inputs) {
|
|
276
|
+
if (el.offsetParent) {
|
|
277
|
+
dispatchClickSequence(el);
|
|
278
|
+
el.focus?.();
|
|
279
|
+
return { success: true, method: 'input' };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Strategy 3: Click in the center of the page (where input usually is)
|
|
284
|
+
const centerX = window.innerWidth / 2;
|
|
285
|
+
const centerY = window.innerHeight / 2;
|
|
286
|
+
const centerEl = document.elementFromPoint(centerX, centerY);
|
|
287
|
+
if (centerEl) {
|
|
288
|
+
dispatchClickSequence(centerEl);
|
|
289
|
+
return { success: true, method: 'center' };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { success: false, error: 'Could not find input' };
|
|
293
|
+
})()`);
|
|
294
|
+
|
|
295
|
+
await delay(500);
|
|
296
|
+
|
|
297
|
+
// Type using CDP Input API (this works regardless of element type)
|
|
298
|
+
await inputCdp("Input.insertText", { text: prompt });
|
|
299
|
+
await delay(300);
|
|
300
|
+
|
|
301
|
+
// Backspace then re-add last char to reveal submit button
|
|
302
|
+
await inputCdp("Input.dispatchKeyEvent", {
|
|
303
|
+
type: "keyDown",
|
|
304
|
+
key: "Backspace",
|
|
305
|
+
code: "Backspace",
|
|
306
|
+
windowsVirtualKeyCode: 8,
|
|
307
|
+
nativeVirtualKeyCode: 8,
|
|
308
|
+
});
|
|
309
|
+
await inputCdp("Input.dispatchKeyEvent", {
|
|
310
|
+
type: "keyUp",
|
|
311
|
+
key: "Backspace",
|
|
312
|
+
code: "Backspace",
|
|
313
|
+
windowsVirtualKeyCode: 8,
|
|
314
|
+
nativeVirtualKeyCode: 8,
|
|
315
|
+
});
|
|
316
|
+
await delay(100);
|
|
317
|
+
|
|
318
|
+
// Re-add last character
|
|
319
|
+
const lastChar = prompt.slice(-1);
|
|
320
|
+
await inputCdp("Input.insertText", { text: lastChar });
|
|
321
|
+
await delay(300);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function submitPrompt(cdp, inputCdp) {
|
|
325
|
+
// Get submit button coordinates
|
|
326
|
+
const btnInfo = await evaluate(cdp, "(function() { const btn = document.querySelector('button[aria-label=Submit]'); if (!btn) return null; const r = btn.getBoundingClientRect(); return { x: r.x + r.width/2, y: r.y + r.height/2 }; })()");
|
|
327
|
+
|
|
328
|
+
if (btnInfo && btnInfo.x && btnInfo.y) {
|
|
329
|
+
// Click using CDP
|
|
330
|
+
await inputCdp("Input.dispatchMouseEvent", {
|
|
331
|
+
type: "mousePressed",
|
|
332
|
+
x: btnInfo.x,
|
|
333
|
+
y: btnInfo.y,
|
|
334
|
+
button: "left",
|
|
335
|
+
clickCount: 1
|
|
336
|
+
});
|
|
337
|
+
await inputCdp("Input.dispatchMouseEvent", {
|
|
338
|
+
type: "mouseReleased",
|
|
339
|
+
x: btnInfo.x,
|
|
340
|
+
y: btnInfo.y,
|
|
341
|
+
button: "left",
|
|
342
|
+
clickCount: 1
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
// Fallback: press Enter
|
|
346
|
+
await inputCdp("Input.dispatchKeyEvent", {
|
|
347
|
+
type: "keyDown",
|
|
348
|
+
key: "Enter",
|
|
349
|
+
code: "Enter",
|
|
350
|
+
windowsVirtualKeyCode: 13,
|
|
351
|
+
nativeVirtualKeyCode: 13,
|
|
352
|
+
text: "\r",
|
|
353
|
+
});
|
|
354
|
+
await inputCdp("Input.dispatchKeyEvent", {
|
|
355
|
+
type: "keyUp",
|
|
356
|
+
key: "Enter",
|
|
357
|
+
code: "Enter",
|
|
358
|
+
windowsVirtualKeyCode: 13,
|
|
359
|
+
nativeVirtualKeyCode: 13,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await delay(500);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ============================================================================
|
|
367
|
+
// Response Handling
|
|
368
|
+
// ============================================================================
|
|
369
|
+
|
|
370
|
+
async function waitForResponse(cdp, timeoutMs = 120000) {
|
|
371
|
+
const deadline = Date.now() + timeoutMs;
|
|
372
|
+
let previousText = '';
|
|
373
|
+
let stableCycles = 0;
|
|
374
|
+
const requiredStableCycles = 10;
|
|
375
|
+
let lastChangeAt = Date.now();
|
|
376
|
+
const minStableMs = 2500;
|
|
377
|
+
|
|
378
|
+
// First, wait for navigation to search results page
|
|
379
|
+
const navDeadline = Date.now() + 15000;
|
|
380
|
+
while (Date.now() < navDeadline) {
|
|
381
|
+
const url = await evaluate(cdp, 'location.href');
|
|
382
|
+
if (url && url.includes('/search/')) {
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
await delay(200);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Wait a bit for the response area to render
|
|
389
|
+
await delay(1000);
|
|
390
|
+
|
|
391
|
+
// Now poll for response completion
|
|
392
|
+
while (Date.now() < deadline) {
|
|
393
|
+
const snapshot = await evaluate(cdp, `(function() {
|
|
394
|
+
const prose = document.querySelector('.prose');
|
|
395
|
+
const text = prose ? prose.innerText : '';
|
|
396
|
+
const hasStop = !!document.querySelector('button[aria-label*=stop], button[aria-label*=Stop]');
|
|
397
|
+
const hasCopy = !!document.querySelector('button[aria-label*=copy], button[aria-label*=Copy]');
|
|
398
|
+
const hasRelated = document.body.innerText.indexOf('Related') > -1;
|
|
399
|
+
return {
|
|
400
|
+
text: text,
|
|
401
|
+
generating: hasStop,
|
|
402
|
+
hasActions: hasCopy,
|
|
403
|
+
hasRelated: hasRelated,
|
|
404
|
+
hasFollowUp: false,
|
|
405
|
+
sourcesCount: 0,
|
|
406
|
+
url: location.href
|
|
407
|
+
};
|
|
408
|
+
})()`);
|
|
409
|
+
|
|
410
|
+
if (!snapshot) {
|
|
411
|
+
await delay(300);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const currentText = snapshot.text || '';
|
|
416
|
+
|
|
417
|
+
// Track text changes
|
|
418
|
+
if (currentText !== previousText && currentText.length > previousText.length) {
|
|
419
|
+
previousText = currentText;
|
|
420
|
+
stableCycles = 0;
|
|
421
|
+
lastChangeAt = Date.now();
|
|
422
|
+
} else {
|
|
423
|
+
stableCycles++;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const stableMs = Date.now() - lastChangeAt;
|
|
427
|
+
|
|
428
|
+
// Response is complete if:
|
|
429
|
+
// 1. Not generating (no stop button)
|
|
430
|
+
// 2. Has action buttons OR Related section OR follow-up input OR stable for long enough
|
|
431
|
+
// 3. Has meaningful content
|
|
432
|
+
const isStable = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
|
|
433
|
+
const hasCompletionIndicators = snapshot.hasActions || snapshot.hasRelated || snapshot.hasFollowUp;
|
|
434
|
+
const isDone = !snapshot.generating && (hasCompletionIndicators || isStable);
|
|
435
|
+
|
|
436
|
+
if (isDone && currentText.length > 20) {
|
|
437
|
+
// Clean up the response text
|
|
438
|
+
let cleanText = currentText;
|
|
439
|
+
|
|
440
|
+
// Remove "Related" section if present at the end
|
|
441
|
+
const relatedIdx = cleanText.lastIndexOf('\nRelated\n');
|
|
442
|
+
if (relatedIdx > 0) {
|
|
443
|
+
cleanText = cleanText.substring(0, relatedIdx).trim();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
text: cleanText,
|
|
448
|
+
sources: snapshot.sourcesCount,
|
|
449
|
+
url: snapshot.url,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
await delay(300);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Timeout - return whatever we have
|
|
457
|
+
if (previousText.length > 20) {
|
|
458
|
+
return {
|
|
459
|
+
text: previousText,
|
|
460
|
+
sources: 0,
|
|
461
|
+
url: await evaluate(cdp, 'location.href'),
|
|
462
|
+
partial: true,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
throw new Error("Response timeout - Perplexity did not complete in time");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ============================================================================
|
|
470
|
+
// Main Query Function
|
|
471
|
+
// ============================================================================
|
|
472
|
+
|
|
473
|
+
async function query(options) {
|
|
474
|
+
const {
|
|
475
|
+
prompt,
|
|
476
|
+
model,
|
|
477
|
+
mode = 'search',
|
|
478
|
+
timeout = 120000,
|
|
479
|
+
createTab,
|
|
480
|
+
closeTab,
|
|
481
|
+
cdpEvaluate,
|
|
482
|
+
cdpCommand,
|
|
483
|
+
log = () => {},
|
|
484
|
+
} = options;
|
|
485
|
+
|
|
486
|
+
const startTime = Date.now();
|
|
487
|
+
log("Starting Perplexity query");
|
|
488
|
+
|
|
489
|
+
// Create tab
|
|
490
|
+
const tabInfo = await createTab();
|
|
491
|
+
log(`createTab returned: ${JSON.stringify(tabInfo)}`);
|
|
492
|
+
const { tabId } = tabInfo || {};
|
|
493
|
+
|
|
494
|
+
if (!tabId) {
|
|
495
|
+
throw new Error(`Failed to create Perplexity tab: ${JSON.stringify(tabInfo)}`);
|
|
496
|
+
}
|
|
497
|
+
log(`Created tab ${tabId}`);
|
|
498
|
+
|
|
499
|
+
const cdp = (expr) => cdpEvaluate(tabId, expr);
|
|
500
|
+
const inputCdp = (method, params) => cdpCommand(tabId, method, params);
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
// Wait for page load
|
|
504
|
+
await waitForPageLoad(cdp);
|
|
505
|
+
log("Page loaded");
|
|
506
|
+
|
|
507
|
+
// Check login status (informational)
|
|
508
|
+
const loginStatus = await checkLoginStatus(cdp);
|
|
509
|
+
log(`Login: ${loginStatus.loggedIn ? 'yes' : 'anonymous'}${loginStatus.isPro ? ' (Pro)' : ''}`);
|
|
510
|
+
|
|
511
|
+
// Wait for input
|
|
512
|
+
await waitForPromptReady(cdp);
|
|
513
|
+
log("Prompt ready");
|
|
514
|
+
|
|
515
|
+
// Select mode if not default
|
|
516
|
+
if (mode && mode.toLowerCase() !== 'search') {
|
|
517
|
+
try {
|
|
518
|
+
const selectedMode = await selectMode(cdp, mode);
|
|
519
|
+
log(`Mode: ${selectedMode}`);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
log(`Mode selection failed: ${e.message}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Select model if specified
|
|
526
|
+
if (model) {
|
|
527
|
+
try {
|
|
528
|
+
const selectedModel = await selectModel(cdp, model);
|
|
529
|
+
log(`Model: ${selectedModel}`);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
log(`Model selection failed: ${e.message}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Type prompt
|
|
536
|
+
await typePrompt(cdp, inputCdp, prompt);
|
|
537
|
+
log("Prompt typed");
|
|
538
|
+
|
|
539
|
+
// Submit
|
|
540
|
+
await submitPrompt(cdp, inputCdp);
|
|
541
|
+
log("Submitted, waiting for response...");
|
|
542
|
+
|
|
543
|
+
// Wait for response
|
|
544
|
+
const response = await waitForResponse(cdp, timeout);
|
|
545
|
+
log(`Response: ${response.text.length} chars, ${response.sources} sources${response.partial ? ' (partial)' : ''}`);
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
response: response.text,
|
|
549
|
+
sources: response.sources,
|
|
550
|
+
url: response.url,
|
|
551
|
+
model: model || 'default',
|
|
552
|
+
mode: mode || 'search',
|
|
553
|
+
partial: response.partial || false,
|
|
554
|
+
tookMs: Date.now() - startTime,
|
|
555
|
+
};
|
|
556
|
+
} finally {
|
|
557
|
+
await closeTab(tabId).catch(() => {});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
module.exports = { query, PERPLEXITY_URL };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const encodeMessage = (obj) => {
|
|
2
|
+
const json = JSON.stringify(obj);
|
|
3
|
+
const buf = Buffer.alloc(4 + Buffer.byteLength(json));
|
|
4
|
+
buf.writeUInt32LE(Buffer.byteLength(json), 0);
|
|
5
|
+
buf.write(json, 4);
|
|
6
|
+
return buf;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const createMessageReader = (onMessage) => {
|
|
10
|
+
let buffer = Buffer.alloc(0);
|
|
11
|
+
return (chunk) => {
|
|
12
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
13
|
+
while (buffer.length >= 4) {
|
|
14
|
+
const msgLen = buffer.readUInt32LE(0);
|
|
15
|
+
if (buffer.length < 4 + msgLen) break;
|
|
16
|
+
const json = buffer.slice(4, 4 + msgLen).toString();
|
|
17
|
+
buffer = buffer.slice(4 + msgLen);
|
|
18
|
+
try {
|
|
19
|
+
onMessage(JSON.parse(json));
|
|
20
|
+
} catch {
|
|
21
|
+
onMessage({ error: "Invalid JSON" });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
module.exports = { encodeMessage, createMessageReader };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
import struct
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
# Log to file
|
|
7
|
+
with open("/tmp/pi-chrome-host.log", "a") as f:
|
|
8
|
+
f.write("Python host starting...\n")
|
|
9
|
+
|
|
10
|
+
def send_message(message):
|
|
11
|
+
encoded = json.dumps(message).encode('utf-8')
|
|
12
|
+
sys.stdout.buffer.write(struct.pack('I', len(encoded)))
|
|
13
|
+
sys.stdout.buffer.write(encoded)
|
|
14
|
+
sys.stdout.buffer.flush()
|
|
15
|
+
|
|
16
|
+
# Send ready message
|
|
17
|
+
send_message({"type": "HOST_READY"})
|
|
18
|
+
|
|
19
|
+
with open("/tmp/pi-chrome-host.log", "a") as f:
|
|
20
|
+
f.write("Sent HOST_READY, waiting for messages...\n")
|
|
21
|
+
|
|
22
|
+
# Read messages
|
|
23
|
+
while True:
|
|
24
|
+
try:
|
|
25
|
+
length_bytes = sys.stdin.buffer.read(4)
|
|
26
|
+
if len(length_bytes) == 0:
|
|
27
|
+
break
|
|
28
|
+
length = struct.unpack('I', length_bytes)[0]
|
|
29
|
+
message = sys.stdin.buffer.read(length).decode('utf-8')
|
|
30
|
+
data = json.loads(message)
|
|
31
|
+
with open("/tmp/pi-chrome-host.log", "a") as f:
|
|
32
|
+
f.write(f"Received: {data}\n")
|
|
33
|
+
# Echo back
|
|
34
|
+
send_message({"id": data.get("id"), "success": True})
|
|
35
|
+
except Exception as e:
|
|
36
|
+
with open("/tmp/pi-chrome-host.log", "a") as f:
|
|
37
|
+
f.write(f"Error: {e}\n")
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
with open("/tmp/pi-chrome-host.log", "a") as f:
|
|
41
|
+
f.write("Host exiting\n")
|