/
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Browser, Page } from "puppeteer-core";
|
||||
import { driverLog } from "./log.ts";
|
||||
|
||||
export const CHATGPT_URL = "https://chatgpt.com/";
|
||||
|
||||
export const CHATGPT_SELECTORS = {
|
||||
composer: ["#mobile-composer-prompt", "#prompt-textarea", "div[contenteditable='true']"],
|
||||
sendButton: "button[data-composer-submit]",
|
||||
copyButton: "button[data-copy-message]",
|
||||
assistantMarkdown: "div[data-assistant-markdown]",
|
||||
streamBlock: "[data-assistant-stream-block]",
|
||||
} as const;
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export interface ChatGPTBrowserOptions {
|
||||
/** Firefox binary (puppeteer-core requires an explicit executable). */
|
||||
executablePath: string;
|
||||
headless: boolean;
|
||||
/** Grace period for a manual login before giving up on the composer. */
|
||||
loginTimeoutMs: number;
|
||||
responseTimeoutMs: number;
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
export interface ChatGPTSendCallbacks {
|
||||
signal?: AbortSignal;
|
||||
onToken?: (delta: string) => void;
|
||||
onLoginHint?: () => void;
|
||||
/** Start a fresh conversation (goto CHATGPT_URL) before sending. */
|
||||
fresh?: boolean;
|
||||
}
|
||||
|
||||
/** Manual login grace period expired (no composer on the page). */
|
||||
export class BrowserLoginError extends Error {
|
||||
constructor(timeoutMs: number) {
|
||||
super(
|
||||
`ChatGPT composer not available after ${Math.round(timeoutMs / 1000)}s — login required (run with PUPPETEER_HEADLESS=false and log in inside the window)`,
|
||||
);
|
||||
this.name = "BrowserLoginError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throwaway Firefox session driving chatgpt.com via puppeteer-core (WebDriver
|
||||
* BiDi). Each launch creates a fresh mkdtemp profile that is removed on
|
||||
* close(): zero persistence between runs. One engine = one browser + one tab.
|
||||
*/
|
||||
export class ChatGPTBrowserEngine {
|
||||
private browser: Browser | null = null;
|
||||
private page: Page | null = null;
|
||||
private profileDir: string | null = null;
|
||||
private opening: Promise<void> | null = null;
|
||||
private closed = false;
|
||||
private chain: Promise<unknown> = Promise.resolve();
|
||||
private currentAbort: AbortController | null = null;
|
||||
|
||||
constructor(private readonly opts: ChatGPTBrowserOptions) {}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.browser !== null;
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (this.browser) return;
|
||||
if (this.closed) throw new Error("engine already closed");
|
||||
this.opening ??= this.launch().finally(() => {
|
||||
this.opening = null;
|
||||
});
|
||||
await this.opening;
|
||||
}
|
||||
|
||||
/** One-shot state report (used by the POC probe mode). */
|
||||
async probe(): Promise<{ title: string; url: string; composer: string | null }> {
|
||||
await this.open();
|
||||
const page = this.requirePage();
|
||||
await sleep(3000);
|
||||
return { title: await page.title(), url: page.url(), composer: await this.findComposer() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Type a prompt into the composer, submit, stream the answer through
|
||||
* onToken and resolve with the final text. Completion = a new
|
||||
* "Copy response" button appearing. Serialized: one send at a time.
|
||||
*/
|
||||
send(prompt: string, cb: ChatGPTSendCallbacks = {}): Promise<string> {
|
||||
const task = this.chain
|
||||
.catch(() => undefined)
|
||||
.then(() => this.doSend(prompt, cb));
|
||||
this.chain = task.catch(() => undefined);
|
||||
return task;
|
||||
}
|
||||
|
||||
abortCurrent(): void {
|
||||
this.currentAbort?.abort();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.abortCurrent();
|
||||
const browser = this.browser;
|
||||
this.browser = null;
|
||||
this.page = null;
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
const dir = this.profileDir;
|
||||
this.profileDir = null;
|
||||
if (dir) {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
driverLog("chatgpt", `temporary profile removed (${dir})`);
|
||||
}
|
||||
}
|
||||
|
||||
private async launch(): Promise<void> {
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
this.profileDir = await mkdtemp(join(tmpdir(), "ka-chatgpt-"));
|
||||
driverLog("chatgpt", `launching firefox headless=${this.opts.headless} profile=${this.profileDir}`);
|
||||
const browser = await puppeteer.launch({
|
||||
browser: "firefox",
|
||||
executablePath: this.opts.executablePath,
|
||||
headless: this.opts.headless,
|
||||
userDataDir: this.profileDir,
|
||||
defaultViewport: null,
|
||||
protocolTimeout: 300_000,
|
||||
});
|
||||
browser.once("disconnected", () => {
|
||||
if (this.browser === browser) {
|
||||
this.browser = null;
|
||||
this.page = null;
|
||||
driverLog("chatgpt", "browser disconnected unexpectedly");
|
||||
}
|
||||
});
|
||||
this.browser = browser;
|
||||
this.page = await browser.newPage();
|
||||
await this.gotoChatGPT(this.page);
|
||||
await this.dismissCookieBanner(this.page, 10_000);
|
||||
}
|
||||
|
||||
/** chatgpt.com intermittently stalls on domcontentloaded — retry a few times. */
|
||||
private async gotoChatGPT(page: Page): Promise<void> {
|
||||
let lastErr: unknown = null;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await page.goto(CHATGPT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
return;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
driverLog("chatgpt", `navigation failed (attempt ${attempt}/3): ${(e as Error).message}`);
|
||||
await sleep(3000);
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error("navigation to chatgpt.com failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh profile = cookie consent banner on first load. It swallows input
|
||||
* until "Accept all" (button[type=submit], text) is clicked.
|
||||
*/
|
||||
private async dismissCookieBanner(page: Page, waitMs: number): Promise<void> {
|
||||
const deadline = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
const clicked = await page
|
||||
.evaluate(() => {
|
||||
const btn = Array.from(document.querySelectorAll("button")).find((b) => {
|
||||
const t = (b.textContent ?? "").trim().toLowerCase();
|
||||
return (t === "accept all" || t === "accept") && (b as HTMLElement).getClientRects().length > 0;
|
||||
});
|
||||
if (!btn) return false;
|
||||
(btn as HTMLElement).click();
|
||||
return true;
|
||||
})
|
||||
.catch(() => false);
|
||||
if (clicked) {
|
||||
driverLog("chatgpt", "cookie banner dismissed (Accept all)");
|
||||
await sleep(800);
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadline) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private async doSend(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
|
||||
if (this.closed) throw new Error("engine already closed");
|
||||
const pollMs = this.opts.pollMs ?? 250;
|
||||
const ac = new AbortController();
|
||||
this.currentAbort = ac;
|
||||
const forward = () => ac.abort();
|
||||
cb.signal?.addEventListener("abort", forward);
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await this.open();
|
||||
const page = this.requirePage();
|
||||
if (cb.fresh) {
|
||||
await this.gotoChatGPT(page);
|
||||
await this.dismissCookieBanner(page, 1_500);
|
||||
}
|
||||
const composer = await this.waitForComposer(cb.onLoginHint);
|
||||
const copyBefore = await this.countCopyButtons();
|
||||
await this.typeIntoComposer(page, composer, prompt);
|
||||
await this.waitSendable(page);
|
||||
await page.click(CHATGPT_SELECTORS.sendButton);
|
||||
driverLog("chatgpt", `sent ${prompt.length} chars via ${composer}, waiting for response`);
|
||||
let last = "";
|
||||
const deadline = Date.now() + this.opts.responseTimeoutMs;
|
||||
for (;;) {
|
||||
if (ac.signal.aborted) {
|
||||
await this.stopGenerating();
|
||||
throw new DOMException("ChatGPT browser request aborted", "AbortError");
|
||||
}
|
||||
const text = await this.readAssistantText();
|
||||
if (text.startsWith(last) && text.length > last.length) {
|
||||
const delta = text.slice(last.length);
|
||||
last = text;
|
||||
cb.onToken?.(delta);
|
||||
}
|
||||
if ((await this.countCopyButtons()) > copyBefore) {
|
||||
const finalText = await this.readAssistantText();
|
||||
if (finalText.length > last.length) cb.onToken?.(finalText.slice(last.length));
|
||||
const out = finalText.length ? finalText : last;
|
||||
driverLog("chatgpt", `response complete: ${out.length} chars in ${fmtS(Date.now() - t0)}`);
|
||||
return out;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
await this.stopGenerating();
|
||||
throw new Error(`ChatGPT response timed out after ${Math.round(this.opts.responseTimeoutMs / 1000)}s`);
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
} finally {
|
||||
cb.signal?.removeEventListener("abort", forward);
|
||||
if (this.currentAbort === ac) this.currentAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForComposer(hint?: () => void): Promise<string> {
|
||||
const page = this.requirePage();
|
||||
const deadline = Date.now() + this.opts.loginTimeoutMs;
|
||||
let hinted = false;
|
||||
for (;;) {
|
||||
const sel = await this.findComposer();
|
||||
if (sel) return sel;
|
||||
if (!hinted) {
|
||||
hinted = true;
|
||||
hint?.();
|
||||
}
|
||||
if (Date.now() > deadline) throw new BrowserLoginError(this.opts.loginTimeoutMs);
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private async findComposer(): Promise<string | null> {
|
||||
const page = this.page;
|
||||
if (!page) return null;
|
||||
for (const sel of CHATGPT_SELECTORS.composer) {
|
||||
try {
|
||||
if (await page.$(sel)) return sel;
|
||||
} catch {
|
||||
// navigation in flight — retry on next poll
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async typeIntoComposer(page: Page, sel: string, text: string): Promise<void> {
|
||||
const input = await page.$(sel);
|
||||
if (!input) throw new Error(`composer ${sel} not found`);
|
||||
await input.focus();
|
||||
const lines = text.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i]) await page.keyboard.type(lines[i], { delay: 4 });
|
||||
if (i < lines.length - 1) {
|
||||
await page.keyboard.down("Shift");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.up("Shift");
|
||||
await sleep(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async waitSendable(page: Page): Promise<void> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
for (;;) {
|
||||
const ok = await page
|
||||
.$eval(CHATGPT_SELECTORS.sendButton, (el) => {
|
||||
const b = el as HTMLButtonElement;
|
||||
return !b.disabled && b.getAttribute("aria-disabled") !== "true";
|
||||
})
|
||||
.catch(() => false);
|
||||
if (ok) return;
|
||||
if (Date.now() > deadline) throw new Error("ChatGPT send button never became clickable");
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
private async stopGenerating(): Promise<void> {
|
||||
const page = this.page;
|
||||
if (!page) return;
|
||||
try {
|
||||
await page.click(CHATGPT_SELECTORS.sendButton);
|
||||
} catch {
|
||||
// button already back in send state — nothing to stop
|
||||
}
|
||||
}
|
||||
|
||||
private async countCopyButtons(): Promise<number> {
|
||||
const page = this.page;
|
||||
if (!page) return 0;
|
||||
try {
|
||||
return await page.$$eval(CHATGPT_SELECTORS.copyButton, (els) =>
|
||||
els.filter((el) => (el as HTMLElement).getClientRects().length > 0).length,
|
||||
);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async readAssistantText(): Promise<string> {
|
||||
const page = this.page;
|
||||
if (!page) return "";
|
||||
try {
|
||||
return await page.$$eval(
|
||||
CHATGPT_SELECTORS.assistantMarkdown,
|
||||
(els, blockSel: string) => {
|
||||
const last = els[els.length - 1];
|
||||
if (!last) return "";
|
||||
const blocks = Array.from(last.querySelectorAll(blockSel));
|
||||
if (blocks.length === 0) return (last as HTMLElement).innerText;
|
||||
return blocks
|
||||
.map((b) => (b as HTMLElement).innerText.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
},
|
||||
CHATGPT_SELECTORS.streamBlock,
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private requirePage(): Page {
|
||||
if (!this.page) throw new Error("browser session is not open");
|
||||
return this.page;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtS(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
+149
-13
@@ -1,27 +1,163 @@
|
||||
import { config } from "../config.ts";
|
||||
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
|
||||
import { ChatGPTBrowserEngine } from "./browser.ts";
|
||||
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||
import { DriverDisabledError } from "./types.ts";
|
||||
|
||||
const MODELS = ["chatgpt"];
|
||||
|
||||
function resolveExecutable(): string {
|
||||
const conf = config.puppeteerExecutable;
|
||||
if (conf.includes("/")) return conf;
|
||||
const found = Bun.which(conf);
|
||||
if (!found) throw new Error(`browser executable "${conf}" not found in PATH — set PUPPETEER_EXECUTABLE`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puppeteer/headless-browser driver stub (scrapes chatgpt.com).
|
||||
* Intentionally NOT implemented: fragile, ToS-risky, needs a real
|
||||
* account session. Kept as a stub so the Driver interface reserves
|
||||
* its slot at global priority #3.
|
||||
* Flatten a KisAssistant conversation into one prompt for the ChatGPT web
|
||||
* composer: system text as a preamble, earlier turns as a transcript, and
|
||||
* the latest user message sent as-is.
|
||||
*/
|
||||
export function flattenMessages(messages: ChatMessage[]): string {
|
||||
const system = messages
|
||||
.filter((m) => m.role === "system")
|
||||
.map((m) => m.content)
|
||||
.join("\n")
|
||||
.trim();
|
||||
const rest = messages.filter((m) => m.role !== "system");
|
||||
const last = rest[rest.length - 1];
|
||||
const earlier = rest.slice(0, -1);
|
||||
const parts: string[] = [];
|
||||
if (system) parts.push(`[Instructions]\n${system}\n`);
|
||||
if (earlier.length) {
|
||||
parts.push("[Conversation so far]");
|
||||
for (const m of earlier) parts.push(`${m.role === "user" ? "User" : "Assistant"}: ${m.content}`);
|
||||
parts.push("[End of conversation so far]\n");
|
||||
}
|
||||
if (last) parts.push(last.content);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** Buffers tokens produced by a push-style callback into a pull-style iterator. */
|
||||
class AsyncTokenQueue implements AsyncIterable<string> {
|
||||
private buf: string[] = [];
|
||||
private error: Error | null = null;
|
||||
private ended = false;
|
||||
private waiters: Array<(r: IteratorResult<string> | null) => void> = [];
|
||||
|
||||
push(token: string): void {
|
||||
const w = this.waiters.shift();
|
||||
if (w) w({ value: token, done: false });
|
||||
else this.buf.push(token);
|
||||
}
|
||||
|
||||
end(err?: Error): void {
|
||||
if (err) this.error = err;
|
||||
this.ended = true;
|
||||
for (const w of this.waiters.splice(0)) w(null);
|
||||
}
|
||||
|
||||
async next(): Promise<IteratorResult<string>> {
|
||||
for (;;) {
|
||||
if (this.buf.length) return { value: this.buf.shift() as string, done: false };
|
||||
if (this.ended) {
|
||||
if (this.error) throw this.error;
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
const wake = await new Promise<IteratorResult<string> | null>((resolve) => this.waiters.push(resolve));
|
||||
if (wake) return wake;
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<string> {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatGPT via the web UI: throwaway Firefox profile driven by
|
||||
* puppeteer-core (WebDriver BiDi). ToS-risky and fragile by nature —
|
||||
* disabled unless PUPPETEER_ENABLED=true. Login is manual for now: the
|
||||
* first request opens a window and waits for the composer to appear.
|
||||
*/
|
||||
export class PuppeteerOpenAIDriver implements LLMDriver {
|
||||
readonly name = "puppeteer-openai";
|
||||
readonly enabled = false;
|
||||
readonly enabled = config.puppeteerEnabled;
|
||||
private engine: ChatGPTBrowserEngine | null = null;
|
||||
|
||||
constructor() {
|
||||
if (this.enabled) driverLog(this.name, "enabled (chatgpt.com via Firefox, throwaway profile, manual login)");
|
||||
}
|
||||
|
||||
private ensureEnabled(): void {
|
||||
if (!this.enabled) throw new DriverDisabledError(this.name);
|
||||
}
|
||||
|
||||
private getEngine(): ChatGPTBrowserEngine {
|
||||
this.ensureEnabled();
|
||||
if (!this.engine) {
|
||||
this.engine = new ChatGPTBrowserEngine({
|
||||
executablePath: resolveExecutable(),
|
||||
headless: config.puppeteerHeadless,
|
||||
loginTimeoutMs: config.puppeteerLoginTimeoutS * 1000,
|
||||
responseTimeoutMs: config.puppeteerResponseTimeoutS * 1000,
|
||||
});
|
||||
}
|
||||
return this.engine;
|
||||
}
|
||||
|
||||
async listModels(): Promise<DriverModel[]> {
|
||||
return [];
|
||||
this.ensureEnabled();
|
||||
return MODELS.map((m) => ({ id: `${this.name}:${m}`, name: m, driver: this.name, label: "ChatGPT (web)" }));
|
||||
}
|
||||
|
||||
async chat(_messages: ChatMessage[], _opts: ChatOptions): Promise<string> {
|
||||
throw new Error(
|
||||
'driver "puppeteer-openai" is a stub and is not implemented (headless scraping of chatgpt.com is fragile and against ToS; use ollama or official APIs instead)',
|
||||
);
|
||||
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
||||
this.ensureEnabled();
|
||||
const t0 = Date.now();
|
||||
const text = await this.getEngine().send(flattenMessages(messages), {
|
||||
signal: opts.signal,
|
||||
fresh: true,
|
||||
onLoginHint: () => driverLog(this.name, "login required — complete it inside the browser window"),
|
||||
});
|
||||
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(text.length)} in ${fmtMs(Date.now() - t0)}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
async *chatStream(): AsyncGenerator<string, void, void> {
|
||||
throw new Error('driver "puppeteer-openai" is a stub and is not implemented');
|
||||
void 0;
|
||||
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
|
||||
this.ensureEnabled();
|
||||
const t0 = Date.now();
|
||||
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
|
||||
const queue = new AsyncTokenQueue();
|
||||
let chars = 0;
|
||||
let jobDone = false;
|
||||
const job = this.getEngine()
|
||||
.send(flattenMessages(messages), {
|
||||
signal: opts.signal,
|
||||
fresh: true,
|
||||
onLoginHint: () => driverLog(this.name, "login required — complete it inside the browser window"),
|
||||
onToken: (tok) => {
|
||||
chars += tok.length;
|
||||
opts.onToken?.(tok);
|
||||
queue.push(tok);
|
||||
},
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
jobDone = true;
|
||||
queue.end();
|
||||
},
|
||||
(e) => {
|
||||
jobDone = true;
|
||||
queue.end(e as Error);
|
||||
},
|
||||
);
|
||||
try {
|
||||
yield* queue;
|
||||
} finally {
|
||||
if (!jobDone) this.getEngine().abortCurrent();
|
||||
void job;
|
||||
}
|
||||
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user