From 5903b77d954956666a1cc8c47b527755944dced9 Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Mon, 21 Sep 2026 19:14:55 +0200 Subject: [PATCH] / --- .env.example | 8 +- README.md | 5 +- bun.lock | 51 ++++ docs/ARCHITECTURE.md | 17 +- docs/FRONTEND.md | 24 +- frontend/src/App.tsx | 71 ++++- frontend/src/components/Login.tsx | 24 +- frontend/src/components/RegisterPage.tsx | 147 ++++++++++ frontend/src/components/dialogs.tsx | 87 +----- frontend/src/lib/route.ts | 31 ++ package.json | 3 +- scripts/poc-chatgpt.ts | 86 ++++++ src/config.ts | 8 + src/drivers/browser.ts | 357 +++++++++++++++++++++++ src/drivers/puppeteer.ts | 162 +++++++++- tsconfig.json | 2 +- 16 files changed, 958 insertions(+), 125 deletions(-) create mode 100644 frontend/src/components/RegisterPage.tsx create mode 100644 frontend/src/lib/route.ts create mode 100644 scripts/poc-chatgpt.ts create mode 100644 src/drivers/browser.ts diff --git a/.env.example b/.env.example index 101929a..b162157 100644 --- a/.env.example +++ b/.env.example @@ -60,5 +60,11 @@ ANTHROPIC_API_KEY= ANTHROPIC_ENABLED=false OPENAI_API_KEY= OPENAI_ENABLED=false -# Puppeteer driver is a stub on purpose (fragile ToS-risky). Never enabled for now. +# ChatGPT web driver (puppeteer-core + Firefox via WebDriver BiDi). POC. +# Every process launch creates a throwaway Firefox profile (deleted on exit); +# the first request opens a window where you log in manually while it waits. PUPPETEER_ENABLED=false +PUPPETEER_EXECUTABLE=firefox +PUPPETEER_HEADLESS=false +PUPPETEER_LOGIN_TIMEOUT_S=300 +PUPPETEER_RESPONSE_TIMEOUT_S=300 diff --git a/README.md b/README.md index 19c5498..a376a2b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ accounts, server-side chats and public shares. No cookies — Bearer keys in Driver priority: ``` -1. ollama → 2. mistral API → 3. glm API → 4. puppeteer-openai (stub, not implemented) +1. ollama → 2. mistral API → 3. glm API → 4. puppeteer-openai (ChatGPT web, POC) → 5. deepseek → 6. anthropic → 7. openai ``` @@ -97,7 +97,8 @@ src/drivers/types.ts LLMDriver interface + DriverModel + errors src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream src/drivers/apiBase.ts ApiDriverBase (OpenAI-compatible SSE) base class src/drivers/apis.ts Mistral / DeepSeek / Anthropic / OpenAI drivers -src/drivers/puppeteer.ts PuppeteerOpenAIDriver — stub (always disabled) +src/drivers/browser.ts ChatGPTBrowserEngine — throwaway Firefox profile (puppeteer-core/BiDi) +src/drivers/puppeteer.ts PuppeteerOpenAIDriver — ChatGPT web POC (PUPPETEER_ENABLED, manual login) src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing src/db.ts SQLite store (users, sessions, convs, messages, shares) src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD) diff --git a/bun.lock b/bun.lock index 26744a3..034e151 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "kisassistant", "dependencies": { "nodemailer": "^10.0.10", + "puppeteer-core": "^25.11.0", }, "devDependencies": { "@types/bun": "latest", @@ -14,16 +15,66 @@ }, }, "packages": { + "@puppeteer/browsers": ["@puppeteer/browsers@3.2.2", "", { "dependencies": { "modern-tar": "^0.8.4", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@types/nodemailer": ["@types/nodemailer@8.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="], + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "devtools-protocol": ["devtools-protocol@0.0.1680125", "", {}, "sha512-rVARKAvOFJCFkvgX01VLvYqKIvt+cRCqvUxIhB2oAqVGPalxk3w3sb5TUGDagpow/EFblBpm/Zl/29QA2dd+jA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.8.5", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="], + "nodemailer": ["nodemailer@10.0.10", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="], + "puppeteer-core": ["puppeteer-core@25.11.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.2", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1680125", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.3", "ws": "^8.21.3" } }, "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.3", "", {}, "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d405b9..a002c63 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,7 +99,7 @@ only ever sees reviewed text blocks, never raw files. ## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`), `DriverRegistry` with global priority -`ollama → mistral → glm → puppeteer-openai (stub) → deepseek → anthropic → openai`. +`ollama → mistral → glm → puppeteer-openai (POC) → deepseek → anthropic → openai`. Models are addressed as `"driver:model"` (split on the first `:`). - `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via @@ -113,5 +113,16 @@ Models are addressed as `"driver:model"` (split on the first `:`). (SSE) implementation; concrete drivers only set base URL, key, and fallback model list. A driver is enabled only when its `*_ENABLED` flag is set **and** its key is non-empty; failing drivers are skipped (not fatal) in listings. -- `PuppeteerOpenAIDriver`: intentional stub, always disabled (headless scraping - of chatgpt.com is fragile and against ToS). +- `PuppeteerOpenAIDriver` (+ `browser.ts` `ChatGPTBrowserEngine`): ChatGPT via + the web UI — puppeteer-core driving Firefox (WebDriver BiDi) with a + **throwaway profile** (mkdtemp under /tmp, deleted on close). Anonymous + session works without login; if the composer is missing the engine waits up + to `PUPPETEER_LOGIN_TIMEOUT_S` for a manual login in the window. Flow: + accept the cookie banner, focus the composer (`#mobile-composer-prompt`, + multi-line via Shift+Enter), submit with `[data-composer-submit]`, stream + by polling `[data-assistant-stream-block]` text, and treat a newly + **visible** `[data-copy-message]` button as end-of-response (toolbars exist + hidden in the DOM during streaming — visibility is the real signal). + Navigation uses domcontentloaded + retries (chatgpt.com stalls sometimes). + POC — ToS-risky, off unless `PUPPETEER_ENABLED=true`. Smoke test: + `bun scripts/poc-chatgpt.ts` (headed, `--probe` for a state report). diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 3e39d42..e59d9f2 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -7,9 +7,18 @@ directory is a build artifact, never hand-edit, not committed). ## Routing -No router library — `App.tsx` matches `window.location.pathname` once (module -level, constant for the page lifetime): `/share/:publicId` renders the public -read-only `SharePage` (no auth), everything else renders the authenticated app. +No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`, +`useRoute()`, trailing-slash normalization) drives pathname routing: + +- `/login` — sign-in card (username + access key + TOTP step). Renders + instantly, **never shows the boot splash**. +- `/register` — full-page self-registration (`RegisterPage.tsx`; key shown + once, then hands username+key to `/login` via `sessionStorage`). No splash. +- `/chat` — the platform itself (auth required; boots behind the splash). +- `/` — redirects to `/chat` when logged in, `/login` otherwise (placeholder + for a future landing page). +- Public routes (unchanged): `/share/:publicId`, `/reset/:token`, + `/review/:id`, `/admin`. ## State & data flow @@ -20,7 +29,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app. `{ error }` parsing, `ApiError` with status, auto logout+reload on 401 (except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`. - Boot shows `LoadingScreen` for at least 1.5 s (`MIN_SPLASH_MS` in `App.tsx`) - so the bloom animation always plays, even when the session restores instantly. + so the bloom animation always plays, even when the session restores + instantly — **only on `/` and `/chat`**. `/login` and `/register` skip it. - Sending: ensure a server conversation exists (created lazily on first send), optimistic user message, stream tokens into `streaming`, then append the full assistant message and refresh the list (server auto-titles new chats). @@ -33,7 +43,11 @@ read-only `SharePage` (no auth), everything else renders the authenticated app. **`Picker`** — the framework-styled dropdown (pill button + popover, optional option groups, lucide `ChevronDown`/`Check`, Escape/outside-click to close). **Never use a native ` setUsername(e.target.value)} + placeholder="alice" + maxLength={32} + autoComplete="username" + required + /> + + + setDisplayName(e.target.value)} + placeholder="Alice" + maxLength={60} + autoComplete="nickname" + /> + + + setEmail(e.target.value)} + placeholder="alice@example.com" + inputMode="email" + autoComplete="email" + /> + + {error && ( +

+ {error} +

+ )} + + {!registrationOn && ( +

{t("login.regTipOff")}

+ )} + + )} + + + +
+ +
+ + + + ); +} diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index 801b2fe..21223b0 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, UserPlus, X } from "lucide-react"; +import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react"; import { api } from "../lib/api.ts"; import { cn } from "../lib/cn.ts"; import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts"; @@ -204,91 +204,6 @@ export function FilePreviewModal({ ); } -/* ---------- Self-registration ---------- */ - -export function RegisterDialog({ - open, - onClose, - onDone, -}: { - open: boolean; - onClose: () => void; - onDone: (username: string, key: string) => void; -}) { - const { t } = useT(); - const [username, setUsername] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [email, setEmail] = useState(""); - const [key, setKey] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - useEffect(() => { - if (open) { - setUsername(""); - setDisplayName(""); - setEmail(""); - setKey(null); - setError(""); - } - }, [open ]); - - async function submit(e: React.FormEvent) { - e.preventDefault(); - if (busy) return; - setBusy(true); - setError(""); - try { - const res = await api.register({ - username: username.trim().toLowerCase(), - displayName: displayName.trim() || undefined, - email: email.trim() || undefined, - }); - setKey(res.key); - } catch (err) { - setError(err instanceof Error ? err.message : t("dlg.regFailed")); - } finally { - setBusy(false); - } - } - - return ( - - {key ? ( -
-

- {t("dlg.regWelcome", { user: username.trim().toLowerCase() })} -

-
- {key} - -
-
- -
-
- ) : ( -
- - setUsername(e.target.value)} placeholder="alice" maxLength={32} required /> - - - setDisplayName(e.target.value)} placeholder="Alice" maxLength={60} /> - - - setEmail(e.target.value)} placeholder="alice@example.com" inputMode="email" /> - - {error &&

{error}

} -
- - -
-
- )} -
- ); -} - /* ---------- Share ---------- */ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) { diff --git a/frontend/src/lib/route.ts b/frontend/src/lib/route.ts new file mode 100644 index 0000000..38a68cb --- /dev/null +++ b/frontend/src/lib/route.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +/** Tiny pathname router (no dependency): /login, /register, /chat, /. */ + +export function normalizePath(p: string): string { + if (p.length > 1 && p.endsWith("/")) return p.slice(0, -1); + return p || "/"; +} + +export function getPath(): string { + return normalizePath(window.location.pathname); +} + +/** Client-side navigation without reload (same-origin path only). */ +export function navigate(to: string, replace = false): void { + const dest = normalizePath(to); + if (getPath() === dest) return; + if (replace) window.history.replaceState(null, "", dest); + else window.history.pushState(null, "", dest); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function useRoute(): string { + const [route, setRoute] = useState(getPath); + useEffect(() => { + const h = () => setRoute(getPath()); + window.addEventListener("popstate", h); + return () => window.removeEventListener("popstate", h); + }, []); + return route; +} diff --git a/package.json b/package.json index 1b76b72..5229e27 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "typecheck:frontend": "bun --cwd frontend tsc --noEmit" }, "dependencies": { - "nodemailer": "^10.0.10" + "nodemailer": "^10.0.10", + "puppeteer-core": "^25.11.0" }, "devDependencies": { "@types/bun": "latest", diff --git a/scripts/poc-chatgpt.ts b/scripts/poc-chatgpt.ts new file mode 100644 index 0000000..7adb288 --- /dev/null +++ b/scripts/poc-chatgpt.ts @@ -0,0 +1,86 @@ +/** + * POC — drive chatgpt.com inside a totally temporary Firefox profile + * (puppeteer-core, WebDriver BiDi, headed). The profile is created in + * /tmp at launch and deleted on exit; login is done manually in the + * window when the script asks for it. + * + * Usage: + * bun scripts/poc-chatgpt.ts # send default test message + * bun scripts/poc-chatgpt.ts --message "hello" # custom message (\n supported) + * bun scripts/poc-chatgpt.ts --message $'l1\nl2' # multi-line test + * bun scripts/poc-chatgpt.ts --probe # just open + report page state + * bun scripts/poc-chatgpt.ts --timeout-s 600 # longer response timeout + */ +import { ChatGPTBrowserEngine } from "../src/drivers/browser.ts"; + +const DEFAULT_MESSAGE = "Hello! Answer with one short sentence so we know the pipeline works."; + +function flag(name: string): boolean { + return process.argv.includes(name); +} + +function option(name: string): string | undefined { + const i = process.argv.indexOf(name); + return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined; +} + +const exe = process.env.PUPPETEER_EXECUTABLE ?? "firefox"; +const executablePath = exe.includes("/") ? exe : Bun.which(exe); +if (!executablePath) { + console.error("firefox not found — set PUPPETEER_EXECUTABLE to the Firefox binary path"); + process.exit(1); +} + +const responseTimeoutS = Number(option("--timeout-s") ?? 300); +if (!Number.isFinite(responseTimeoutS) || responseTimeoutS <= 0) { + console.error("--timeout-s must be a positive number of seconds"); + process.exit(1); +} + +const engine = new ChatGPTBrowserEngine({ + executablePath, + headless: false, + loginTimeoutMs: 600_000, + responseTimeoutMs: responseTimeoutS * 1000, +}); + +let interrupted = 0; +process.on("SIGINT", () => { + interrupted++; + if (interrupted > 1) process.exit(130); + console.log("\n[interrupt] closing browser and deleting the temporary profile…"); + void engine.close().finally(() => process.exit(130)); +}); + +const t0 = Date.now(); +const step = (msg: string) => console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${msg}`); + +try { + if (flag("--probe")) { + step(`launching Firefox (${executablePath}) with a fresh throwaway profile`); + const info = await engine.probe(); + console.log(JSON.stringify(info, null, 2)); + step(info.composer ? `composer ready: ${info.composer}` : "composer NOT found — login wall or challenge page"); + } else { + const message = option("--message") ?? DEFAULT_MESSAGE; + step(`launching Firefox (${executablePath}) — temporary profile, deleted on exit`); + let streamed = ""; + const final = await engine.send(message, { + onLoginHint: () => step("login required: log in inside the Firefox window (waiting up to 10 min)"), + onToken: (delta) => { + streamed += delta; + process.stdout.write(delta); + }, + }); + console.log(); + if (!streamed.trim()) console.log(final); + step(`done — response above (${final.length} chars)`); + } + process.exitCode = 0; +} catch (e) { + console.error(`\n[error] ${(e as Error).message}`); + process.exitCode = 1; +} finally { + await engine.close(); + step("browser closed, temporary profile deleted"); +} diff --git a/src/config.ts b/src/config.ts index e1ae1d9..907bacf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,6 +48,14 @@ export const config = { // mainland China (open.bigmodel.cn) — override for the latter. glmBaseUrl: env("GLM_BASE_URL", "https://api.z.ai/api/paas/v4"), + // ChatGPT web driver (puppeteer-core + Firefox, throwaway profile per + // process, manual login inside the window while it waits). + puppeteerEnabled: envBool("PUPPETEER_ENABLED", false), + puppeteerExecutable: env("PUPPETEER_EXECUTABLE", "firefox"), + puppeteerHeadless: envBool("PUPPETEER_HEADLESS", false), + puppeteerLoginTimeoutS: envInt("PUPPETEER_LOGIN_TIMEOUT_S", 300), + puppeteerResponseTimeoutS: envInt("PUPPETEER_RESPONSE_TIMEOUT_S", 300), + // Public docs wiki (served by the hosted git forge, e.g. Gitea wiki pages). // The app redirects GET /wiki there (302) so the target stays configurable. wikiUrl: env("WIKI_URL", "https://git.kisakay.com/k/chat/wiki"), diff --git a/src/drivers/browser.ts b/src/drivers/browser.ts new file mode 100644 index 0000000..27841da --- /dev/null +++ b/src/drivers/browser.ts @@ -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 => 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 | null = null; + private closed = false; + private chain: Promise = Promise.resolve(); + private currentAbort: AbortController | null = null; + + constructor(private readonly opts: ChatGPTBrowserOptions) {} + + get isOpen(): boolean { + return this.browser !== null; + } + + async open(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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`; +} diff --git a/src/drivers/puppeteer.ts b/src/drivers/puppeteer.ts index 63a569a..536dd9b 100644 --- a/src/drivers/puppeteer.ts +++ b/src/drivers/puppeteer.ts @@ -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 { + private buf: string[] = []; + private error: Error | null = null; + private ended = false; + private waiters: Array<(r: IteratorResult | 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> { + 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 | null>((resolve) => this.waiters.push(resolve)); + if (wake) return wake; + } + } + + [Symbol.asyncIterator](): AsyncIterator { + 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 { - 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 { - 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 { + 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 { - throw new Error('driver "puppeteer-openai" is a stub and is not implemented'); - void 0; + async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator { + 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)}`); } } diff --git a/tsconfig.json b/tsconfig.json index b1d630f..e4a6229 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "verbatimModuleSyntax": true, "types": ["bun"] }, - "include": ["src"] + "include": ["src", "scripts"] }