This commit is contained in:
k
2026-09-21 19:14:55 +02:00
parent 2a34d16b52
commit 5903b77d95
16 changed files with 958 additions and 125 deletions
+7 -1
View File
@@ -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
+3 -2
View File
@@ -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)
+51
View File
@@ -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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
"@types/nodemailer": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="],
"ansi-regex": ["[email protected]", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"chromium-bidi": ["[email protected]", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="],
"cliui": ["[email protected]", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"devtools-protocol": ["[email protected]", "", {}, "sha512-rVARKAvOFJCFkvgX01VLvYqKIvt+cRCqvUxIhB2oAqVGPalxk3w3sb5TUGDagpow/EFblBpm/Zl/29QA2dd+jA=="],
"emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["[email protected]", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="],
"mitt": ["[email protected]", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"modern-tar": ["[email protected]", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="],
"nodemailer": ["[email protected]", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="],
"puppeteer-core": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"typed-query-selector": ["[email protected]", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"webdriver-bidi-protocol": ["[email protected]", "", {}, "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig=="],
"wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"y18n": ["[email protected]", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"cliui/string-width": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
}
}
+14 -3
View File
@@ -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).
+19 -5
View File
@@ -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 `<select>`**; use `Picker` for model and theme choices.
- `Login.tsx` — username + access-key card.
- `Login.tsx` — username + access-key card (`/login`); the Register button
navigates to `/register`, and a fresh registration prefills the form.
- `RegisterPage.tsx` — full-page registration (`/register`) reusing the login
card style; shows the new key once with a copy button, then continues to
`/login`.
- `Sidebar.tsx` — collapsible conversation list with per-item context menu
(right-click or `···`: Rename / Set topic / Share publicly / Archive /
Delete), user chip (opens settings), Accounts button (admin), logout.
+66 -5
View File
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from "react";
import { api, clearToken, getToken } from "./lib/api.ts";
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
import type { Attachment, ChatMessage, Conversation, DriverModel, FilePreview, User } from "./lib/types.ts";
import { navigate, normalizePath, useRoute } from "./lib/route.ts";
import { Login } from "./components/Login.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx";
import { Sidebar } from "./components/Sidebar.tsx";
import { Chat } from "./components/Chat.tsx";
import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./components/dialogs.tsx";
@@ -48,7 +50,18 @@ function applyTheme(theme: string) {
export function App() {
const [user, setUser] = useState<User | null>(null);
const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !REVIEW_ID && !IS_ADMIN_PAGE);
// Boot splash ONLY on the platform routes (/ and /chat, with a token).
// /login and /register never show it — they render instantly.
const [checking, setChecking] = useState(() => {
if (SHARE_ID || RESET_TOKEN || REVIEW_ID || IS_ADMIN_PAGE) return false;
const p = normalizePath(window.location.pathname);
if (p === "/login" || p === "/register") return false;
return !!getToken();
});
// True once we know whether there is a session (no token = instant).
const [authReady, setAuthReady] = useState(() => !getToken());
const rawRoute = useRoute();
const path = normalizePath(rawRoute);
const [models, setModels] = useState<DriverModel[]>([]);
const [model, setModel] = useState("");
const [convs, setConvs] = useState<Conversation[]>([]);
@@ -98,7 +111,7 @@ export function App() {
}
}, []);
// Session restore (boot splash stays at least MIN_SPLASH_MS)
// Session restore (boot splash stays at least MIN_SPLASH_MS on / and /chat)
useEffect(() => {
const t0 = Date.now();
const done = () => {
@@ -106,7 +119,8 @@ export function App() {
window.setTimeout(() => setChecking(false), wait);
};
if (!getToken()) {
done();
setAuthReady(true);
setChecking(false);
return;
}
api.verify()
@@ -125,9 +139,35 @@ export function App() {
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
})
.catch(() => {})
.finally(done);
.finally(() => {
setAuthReady(true);
done();
});
}, []);
// Route guard: / redirects by session, /chat needs auth, /login and
// /register bounce logged-in users to /chat. Public routes return above.
useEffect(() => {
if (SHARE_ID || RESET_TOKEN || REVIEW_ID || IS_ADMIN_PAGE) return;
const noToken = !getToken();
if (path === "/") {
if (noToken) navigate("/login", true);
else if (authReady) navigate(user ? "/chat" : "/login", true);
return;
}
if (path === "/login" || path === "/register") {
if (user) navigate("/chat", true);
return;
}
if (path === "/chat") {
if (noToken || (authReady && !user)) navigate("/login", true);
return;
}
// Unknown path: land on the platform or the login page.
if (noToken) navigate("/login", true);
else if (authReady) navigate(user ? "/chat" : "/login", true);
}, [path, user, authReady]);
// Load messages for active conversation
useEffect(() => {
if (!activeId) {
@@ -148,7 +188,10 @@ export function App() {
function handleLogin(u: User) {
setUser(u);
setAuthReady(true);
setChecking(false);
applyTheme(u.theme);
navigate("/chat", true);
api.models().then((m) => {
setModels(m.models);
if (m.models.length > 0) setModel(m.models[0]!.id);
@@ -161,12 +204,15 @@ export function App() {
api.logout().finally(() => {});
clearToken();
setUser(null);
setAuthReady(true);
setChecking(false);
setConvs([]);
setMessages([]);
setActiveId(null);
setArchivedConv(null);
setAttachments([]);
setFilePreview(null);
navigate("/login", true);
}
async function newChat() {
@@ -334,12 +380,27 @@ export function App() {
await refreshConvs();
}
// Auth pages render instantly (no splash); the platform boots behind one.
if (path === "/login") {
if (user) return null; // bounce to /chat imminent
return <Login onLogin={handleLogin} />;
}
if (path === "/register") {
if (user) return null; // bounce to /chat imminent
return <RegisterPage />;
}
if (checking) {
return <LoadingScreen />;
}
// / (or unknown paths) while the guard above redirects.
if (path !== "/chat") {
return null;
}
if (!user) {
return <Login onLogin={handleLogin} />;
return null; // bounce to /login imminent (no splash flash)
}
const active = convs.find((c) => c.id === activeId) ?? archivedConv;
+16 -8
View File
@@ -3,8 +3,9 @@ import { ArrowRight, KeyRound, MessageSquareText, ShieldCheck, User, UserPlus, Z
import { api, setToken } from "../lib/api.ts";
import { useT } from "../lib/i18n.ts";
import type { User as UserType } from "../lib/types.ts";
import { navigate } from "../lib/route.ts";
import { Button, FlowerMark, Input, LangPicker, Spinner } from "./ui.tsx";
import { RecoverDialog, RegisterDialog } from "./dialogs.tsx";
import { RecoverDialog } from "./dialogs.tsx";
import { AccessRequestModal } from "./ReviewPage.tsx";
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
@@ -16,7 +17,6 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const [recoverOpen, setRecoverOpen] = useState(false);
const [recoveryOn, setRecoveryOn] = useState(false);
const [recoveryFrom, setRecoveryFrom] = useState<string | undefined>(undefined);
const [registerOpen, setRegisterOpen] = useState(false);
const [registrationOn, setRegistrationOn] = useState(false);
const [requestOpen, setRequestOpen] = useState(false);
const [requestOn, setRequestOn] = useState(false);
@@ -37,6 +37,19 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
setRegistrationOn(m.registration);
setRequestOn(m.accessRequest);
}).catch(() => {});
// Fresh registration hands the new key over via sessionStorage so the
// login form comes prefilled (cleared immediately after reading).
try {
const raw = sessionStorage.getItem("ka_login_prefill");
if (raw) {
const p = JSON.parse(raw) as { username?: string; key?: string };
if (p.username) setUsername(p.username);
if (p.key) setKey(p.key);
sessionStorage.removeItem("ka_login_prefill");
}
} catch {
// ignore (private mode, malformed payload)
}
}, []);
async function submit(e: React.FormEvent) {
@@ -171,16 +184,11 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
className="mt-3 w-full"
disabled={!registrationOn}
title={registrationOn ? t("login.regTipOn") : t("login.regTipOff")}
onClick={() => setRegisterOpen(true)}
onClick={() => navigate("/register")}
>
<UserPlus size={16} />
{t("login.register")}
</Button>
<RegisterDialog
open={registerOpen}
onClose={() => setRegisterOpen(false)}
onDone={(u, k) => { setUsername(u); setKey(k); }}
/>
{/* Wishlist path: only when open registration is off AND the admin
enabled access requests. They are mutually exclusive server-side. */}
{!registrationOn && requestOn && (
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, UserPlus } from "lucide-react";
import { api } from "../lib/api.ts";
import { useT } from "../lib/i18n.ts";
import { navigate } from "../lib/route.ts";
import { Button, CopyButton, Field, FlowerMark, Input, LangPicker, Spinner } from "./ui.tsx";
const PREFILL_KEY = "ka_login_prefill";
/** Full-page self-registration (/register). No boot splash on this route. */
export function RegisterPage() {
const { t } = useT();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [key, setKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [registrationOn, setRegistrationOn] = useState(true);
useEffect(() => {
api.methods().then((m) => setRegistrationOn(m.registration)).catch(() => {});
}, []);
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);
}
}
function goLogin() {
const u = username.trim().toLowerCase();
if (key && u) {
try {
sessionStorage.setItem(PREFILL_KEY, JSON.stringify({ username: u, key }));
} catch {
// private mode — the login form just stays empty
}
}
navigate("/login");
}
return (
<div className="grid min-h-full place-items-center overflow-y-auto bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-sm">
<div className="rounded-[2rem] border border-white/60 bg-white/80 p-8 shadow-2xl shadow-accent-900/5 backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-2 flex flex-col items-center text-center">
<FlowerMark size={68} dynamic={false} className="mb-4 drop-shadow-lg" />
<h1 className="text-2xl font-bold tracking-tight">{t("dlg.registerTitle")}</h1>
</div>
{key ? (
<div className="mt-6 space-y-4">
<p className="text-center text-sm opacity-80">
{t("dlg.regWelcome", { user: username.trim().toLowerCase() })}
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{key}</code>
<CopyButton text={key} />
</div>
<Button variant="gradient" size="lg" className="w-full" onClick={goLogin}>
{t("dlg.regContinue")} <ArrowRight size={17} />
</Button>
</div>
) : (
<form onSubmit={submit} className="mt-6 space-y-3">
<Field label={t("common.username")}>
<Input
variant="soft"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="alice"
maxLength={32}
autoComplete="username"
required
/>
</Field>
<Field label={t("common.displayName")}>
<Input
variant="soft"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Alice"
maxLength={60}
autoComplete="nickname"
/>
</Field>
<Field label={t("dlg.emailOpt")}>
<Input
variant="soft"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
inputMode="email"
autoComplete="email"
/>
</Field>
{error && (
<p className="rounded-full bg-red-500/10 px-5 py-2.5 text-center text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
)}
<Button
type="submit"
variant="gradient"
size="lg"
className="w-full"
disabled={busy || !registrationOn}
title={registrationOn ? undefined : t("login.regTipOff")}
>
{busy ? <Spinner /> : (<><UserPlus size={16} /> {t("login.register")}</>)}
</Button>
{!registrationOn && (
<p className="text-center text-sm opacity-60">{t("login.regTipOff")}</p>
)}
</form>
)}
<button
onClick={() => navigate("/login")}
className="mt-4 inline-flex w-full items-center justify-center gap-1.5 text-center text-sm opacity-60 transition hover:opacity-100 hover:underline"
>
<ArrowLeft size={15} />
{t("common.backToLogin")}
</button>
<div className="mt-4 flex justify-center">
<LangPicker />
</div>
</div>
</div>
</div>
);
}
+1 -86
View File
@@ -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<string | null>(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 (
<Modal open={open} onClose={onClose} title={t("dlg.registerTitle")} icon={UserPlus}>
{key ? (
<div className="space-y-4">
<p className="text-sm opacity-80">
{t("dlg.regWelcome", { user: username.trim().toLowerCase() })}
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{key}</code>
<CopyButton text={key} />
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => { onDone(username.trim().toLowerCase(), key); onClose(); }}>{t("dlg.regContinue")}</Button>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<Field label={t("common.username")}>
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" maxLength={32} required />
</Field>
<Field label={t("common.displayName")}>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" maxLength={60} />
</Field>
<Field label={t("dlg.emailOpt")}>
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : t("login.register")}</Button>
</div>
</form>
)}
</Modal>
);
}
/* ---------- Share ---------- */
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
+31
View File
@@ -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;
}
+2 -1
View File
@@ -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",
+86
View File
@@ -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");
}
+8
View File
@@ -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"),
+357
View File
@@ -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
View File
@@ -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(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;
}
async *chatStream(): AsyncGenerator<string, void, void> {
throw new Error('driver "puppeteer-openai" is a stub and is not implemented');
void 0;
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
}
}
+1 -1
View File
@@ -10,5 +10,5 @@
"verbatimModuleSyntax": true,
"types": ["bun"]
},
"include": ["src"]
"include": ["src", "scripts"]
}