diff --git a/.env.example b/.env.example
index b162157..3d64f8b 100644
--- a/.env.example
+++ b/.env.example
@@ -43,7 +43,7 @@ RATE_LIMIT_MAX=5
RATE_LIMIT_WINDOW_MIN=10
# --- drivers ---
-# Priority (global): ollama -> mistral -> glm -> puppeteer-openai(stub) -> deepseek -> anthropic -> openai
+# Priority (global): ollama -> mistral -> glm -> arcaic -> deepseek -> anthropic -> openai
OLLAMA_HOST=http://10.66.66.4:11434
OLLAMA_ENABLED=true
# Stubs below: leave empty to keep disabled. Implemented last per spec.
@@ -60,11 +60,12 @@ ANTHROPIC_API_KEY=
ANTHROPIC_ENABLED=false
OPENAI_API_KEY=
OPENAI_ENABLED=false
-# 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
+# Arcaic backend (headless browser, throwaway profile per process).
+# Every process launch creates a throwaway Firefox profile (deleted on exit).
+ARCAIC_ENABLED=false
+ARCAIC_EXECUTABLE=firefox
+# Headless by default; set to false to watch the window (debugging, or if a
+# manual login is ever required).
+ARCAIC_HEADLESS=true
+ARCAIC_LOGIN_TIMEOUT_S=300
+ARCAIC_RESPONSE_TIMEOUT_S=300
diff --git a/AGENTS.md b/AGENTS.md
index a133161..ecc84f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,7 +26,7 @@ src/config.ts env parsing (PORT, HOST, APP_PASSWORD=admin key, OLLAMA_HO
src/db.ts SQLite store: users, sessions, conversations, messages, shares (+ admin row bootstrap)
src/auth.ts login(username,key), hashed Bearer sessions, sliding-window login rate limit
src/static.ts static file server + SPA fallback (covers /share/:id)
-src/drivers/ LLMDriver interface, OllamaDriver, ApiDriverBase, Mistral/DeepSeek/Anthropic/OpenAI, puppeteer stub, DriverRegistry
+src/drivers/ LLMDriver interface, OllamaDriver, ApiDriverBase, Mistral/DeepSeek/Anthropic/OpenAI, arcaic browser sub-drivers (arcaic-openai/gemini/qwen), DriverRegistry
frontend/src/ App.tsx (routing+state), lib/api.ts (fetch wrapper), components/
public/ BUILD OUTPUT (Vite). Never hand-edit; rebuild instead. Not committed (see .gitignore)
flake.nix/module.nix NixOS packaging + service module
@@ -46,7 +46,7 @@ flake.nix/module.nix NixOS packaging + service module
- Icons: lucide-react only. **Never use native `
)}
{request.status === "accepted" && (
-
- {t("review.acceptedHint")}
-
+ <>
+
+ {t("review.acceptedHint")}
+
+
+ >
)}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 6b6b050..fa6d567 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -30,6 +30,7 @@ export interface DriverModel {
name: string;
driver: string;
label: string;
+ group?: string;
}
export interface Attachment {
diff --git a/module.nix b/module.nix
index c46f0d3..fd1c4f8 100644
--- a/module.nix
+++ b/module.nix
@@ -48,9 +48,9 @@ in {
type = lib.types.bool;
default = false;
description = ''
- Enable the ChatGPT web driver (puppeteer-openai): headless Firefox
- from the nix store runs inside the service and drives chatgpt.com.
- Sets PUPPETEER_ENABLED/PUPPETEER_HEADLESS/PUPPETEER_EXECUTABLE
+ Enable the Arcaic backend (headless Firefox from the nix store
+ running a web-UI session inside the service).
+ Sets ARCAIC_ENABLED/ARCAIC_HEADLESS/ARCAIC_EXECUTABLE
(still overridable via extraEnv).
'';
};
@@ -94,9 +94,9 @@ in {
DRIVER_DEBUG = "false";
OLLAMA_HOST = cfg.ollamaHost;
} // lib.optionalAttrs cfg.enableBrowserDriver {
- PUPPETEER_ENABLED = "true";
- PUPPETEER_HEADLESS = "true";
- PUPPETEER_EXECUTABLE = "${pkgs.firefox}/bin/firefox";
+ ARCAIC_ENABLED = "true";
+ ARCAIC_HEADLESS = "true";
+ ARCAIC_EXECUTABLE = "${pkgs.firefox}/bin/firefox";
} // cfg.extraEnv;
serviceConfig = {
User = cfg.user;
@@ -108,7 +108,7 @@ in {
LoadCredential = "app-password:${cfg.passwordFile}";
};
# tesseract binary for the OCR platform tool (see TESSERACT_BIN);
- # firefox for the ChatGPT web driver (enableBrowserDriver).
+ # firefox for the Arcaic backend (enableBrowserDriver).
path = [ pkgs.tesseract ] ++ lib.optionals cfg.enableBrowserDriver [ pkgs.firefox ];
script = ''
${lib.optionalString (cfg.passwordFile != null) ''
diff --git a/scripts/poc-chatgpt.ts b/scripts/poc-chatgpt.ts
index 3324b26..28599c2 100644
--- a/scripts/poc-chatgpt.ts
+++ b/scripts/poc-chatgpt.ts
@@ -1,17 +1,19 @@
/**
- * 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.
+ * POC — drive an Arcaic web-UI backend inside a totally temporary Firefox
+ * profile (puppeteer-core, WebDriver BiDi, headless by default). The profile
+ * is created in /tmp at launch and deleted on exit; login is done manually
+ * in the window (with --headed) when the script asks for it.
*
* Usage:
- * bun scripts/poc-chatgpt.ts # send default test message
+ * bun scripts/poc-chatgpt.ts # send default test message (openai site)
+ * bun scripts/poc-chatgpt.ts --site qwen # openai | qwen | gemini
* 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 --headed # visible window (debug / manual login)
* bun scripts/poc-chatgpt.ts --timeout-s 600 # longer response timeout
*/
-import { ChatGPTBrowserEngine } from "../src/drivers/browser.ts";
+import { ArcaicBrowserEngine, SITES } from "../src/drivers/browser.ts";
const DEFAULT_MESSAGE = "Hello! Answer with one short sentence so we know the pipeline works.";
@@ -24,10 +26,10 @@ function option(name: string): string | undefined {
return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined;
}
-const exe = process.env.PUPPETEER_EXECUTABLE ?? "firefox";
+const exe = process.env.ARCAIC_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");
+ console.error("firefox not found — set ARCAIC_EXECUTABLE to the Firefox binary path");
process.exit(1);
}
@@ -37,9 +39,16 @@ if (!Number.isFinite(responseTimeoutS) || responseTimeoutS <= 0) {
process.exit(1);
}
-const engine = new ChatGPTBrowserEngine({
+const siteKey = option("--site") ?? "openai";
+const site = SITES[siteKey];
+if (!site) {
+ console.error(`--site must be one of: ${Object.keys(SITES).join(" | ")}`);
+ process.exit(1);
+}
+
+const engine = new ArcaicBrowserEngine({
executablePath,
- headless: flag("--headless"),
+ headless: !flag("--headed"),
loginTimeoutMs: 600_000,
responseTimeoutMs: responseTimeoutS * 1000,
});
@@ -57,15 +66,16 @@ const step = (msg: string) => console.log(`[${((Date.now() - t0) / 1000).toFixed
try {
if (flag("--probe")) {
- step(`launching Firefox (${executablePath}) with a fresh throwaway profile`);
- const info = await engine.probe();
+ step(`launching Firefox (${executablePath}) with a fresh throwaway profile [site=${site.key}]`);
+ const info = await engine.probe(site);
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}${flag("--headless") ? ", headless" : ""}) — temporary profile, deleted on exit`);
+ step(`launching Firefox (${executablePath}${flag("--headed") ? ", headed" : ", headless"}) — temporary profile, deleted on exit [site=${site.key}]`);
let streamed = "";
const final = await engine.send(message, {
+ site,
onLoginHint: () => step("login required: log in inside the Firefox window (waiting up to 10 min)"),
onToken: (delta) => {
streamed += delta;
diff --git a/src/access.ts b/src/access.ts
index 46b286b..41ee0d4 100644
--- a/src/access.ts
+++ b/src/access.ts
@@ -35,6 +35,23 @@ export interface AccessMessage {
export function initAccessTables(): void {
const d = getDb();
+ const createRequests = `CREATE TABLE access_requests (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ email TEXT NOT NULL,
+ message TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'pending',
+ reason TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )`;
+ const createMessages = `CREATE TABLE access_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
+ author TEXT NOT NULL,
+ body TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+ )`;
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
@@ -45,6 +62,56 @@ export function initAccessTables(): void {
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
+ // Idempotent migration (must run BEFORE access_messages exists with an FK
+ // on access_requests — SQLite rewrites dependent FKs on DROP COLUMN and
+ // would otherwise poison access_messages with a dangling reference).
+ // Older builds created access_requests with a NOT NULL UNIQUE token_hash
+ // column (since removed — tickets are authed by their unguessable UUID).
+ // CREATE TABLE IF NOT EXISTS won't fix existing DBs, and inserts without
+ // token_hash then fail with SQLITE_CONSTRAINT_NOTNULL.
+ try {
+ const cols = d.query("PRAGMA table_info(access_requests)").all() as { name: string }[];
+ if (cols.some((c) => c.name === "token_hash")) {
+ try {
+ d.query("PRAGMA foreign_keys=OFF").run();
+ try {
+ d.query("ALTER TABLE access_requests DROP COLUMN token_hash").run();
+ } catch {
+ // SQLite builds without DROP COLUMN support: rebuild table.
+ d.query("ALTER TABLE access_requests RENAME TO access_requests_old").run();
+ d.query(createRequests).run();
+ d.query(`INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at)
+ SELECT id, username, email, message, status, reason, created_at, updated_at FROM access_requests_old`).run();
+ d.query("DROP TABLE access_requests_old").run();
+ }
+ } finally {
+ d.query("PRAGMA foreign_keys=ON").run();
+ }
+ }
+ } catch {
+ // Leave schema as-is; the insert error will surface normally.
+ }
+ // Repair: a previous boot migrated in the wrong order (messages table
+ // created before the DROP COLUMN), leaving access_messages with
+ // REFERENCES "access_requests_old". Rebuild it with the correct FK.
+ try {
+ const row = d.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'").get() as { sql: string } | null;
+ if (row?.sql?.includes("access_requests_old")) {
+ d.query("PRAGMA foreign_keys=OFF").run();
+ try {
+ d.query("DROP TABLE IF EXISTS access_messages_old").run();
+ d.query("ALTER TABLE access_messages RENAME TO access_messages_old").run();
+ d.query(createMessages).run();
+ d.query(`INSERT INTO access_messages (id, request_id, author, body, created_at)
+ SELECT id, request_id, author, body, created_at FROM access_messages_old`).run();
+ d.query("DROP TABLE access_messages_old").run();
+ } finally {
+ d.query("PRAGMA foreign_keys=ON").run();
+ }
+ }
+ } catch {
+ // Leave schema as-is; errors will surface on write.
+ }
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
diff --git a/src/config.ts b/src/config.ts
index 907bacf..3a8d67f 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -48,13 +48,12 @@ 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),
+ // Arcaic web-UI backend (headless browser, throwaway profile per process).
+ arcaicEnabled: envBool("ARCAIC_ENABLED", false),
+ arcaicExecutable: env("ARCAIC_EXECUTABLE", "firefox"),
+ arcaicHeadless: envBool("ARCAIC_HEADLESS", true),
+ arcaicLoginTimeoutS: envInt("ARCAIC_LOGIN_TIMEOUT_S", 300),
+ arcaicResponseTimeoutS: envInt("ARCAIC_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.
diff --git a/src/drivers/puppeteer.ts b/src/drivers/arcaic.ts
similarity index 65%
rename from src/drivers/puppeteer.ts
rename to src/drivers/arcaic.ts
index 536dd9b..5d3650a 100644
--- a/src/drivers/puppeteer.ts
+++ b/src/drivers/arcaic.ts
@@ -1,21 +1,29 @@
import { config } from "../config.ts";
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
-import { ChatGPTBrowserEngine } from "./browser.ts";
+import {
+ ArcaicBrowserEngine,
+ GEMINI_SITE,
+ OPENAI_SITE,
+ QWEN_SITE,
+ type BrowserSite,
+} 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;
+export interface ArcaicSpec {
+ key: string;
+ label: string;
+ site: BrowserSite;
}
+const SPECS: ArcaicSpec[] = [
+ { key: "arcaic-openai", label: "arcaic-openai", site: OPENAI_SITE },
+ { key: "arcaic-gemini", label: "arcaic-gemini", site: GEMINI_SITE },
+ { key: "arcaic-qwen", label: "arcaic-qwen", site: QWEN_SITE },
+];
+
/**
- * Flatten a KisAssistant conversation into one prompt for the ChatGPT web
+ * Flatten a KisAssistant conversation into one prompt for the Arcaic web
* composer: system text as a preamble, earlier turns as a transcript, and
* the latest user message sent as-is.
*/
@@ -76,32 +84,34 @@ class AsyncTokenQueue implements AsyncIterable {
}
/**
- * 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.
+ * One Arcaic sub-driver: a named web-UI backend (openai / gemini / qwen)
+ * sharing the throwaway-browser engine logic. Each sub-driver owns its
+ * engine — one browser + one temp profile per backend.
*/
-export class PuppeteerOpenAIDriver implements LLMDriver {
- readonly name = "puppeteer-openai";
- readonly enabled = config.puppeteerEnabled;
- private engine: ChatGPTBrowserEngine | null = null;
+export class ArcaicSubDriver implements LLMDriver {
+ readonly name: string;
+ readonly enabled = config.arcaicEnabled;
+ private readonly spec: ArcaicSpec;
+ private engine: ArcaicBrowserEngine | null = null;
- constructor() {
- if (this.enabled) driverLog(this.name, "enabled (chatgpt.com via Firefox, throwaway profile, manual login)");
+ constructor(spec: ArcaicSpec) {
+ this.spec = spec;
+ this.name = spec.key;
+ if (this.enabled) driverLog(this.name, "enabled (web UI session, throwaway profile, headless)");
}
private ensureEnabled(): void {
if (!this.enabled) throw new DriverDisabledError(this.name);
}
- private getEngine(): ChatGPTBrowserEngine {
+ private getEngine(): ArcaicBrowserEngine {
this.ensureEnabled();
if (!this.engine) {
- this.engine = new ChatGPTBrowserEngine({
+ this.engine = new ArcaicBrowserEngine({
executablePath: resolveExecutable(),
- headless: config.puppeteerHeadless,
- loginTimeoutMs: config.puppeteerLoginTimeoutS * 1000,
- responseTimeoutMs: config.puppeteerResponseTimeoutS * 1000,
+ headless: config.arcaicHeadless,
+ loginTimeoutMs: config.arcaicLoginTimeoutS * 1000,
+ responseTimeoutMs: config.arcaicResponseTimeoutS * 1000,
});
}
return this.engine;
@@ -109,16 +119,25 @@ export class PuppeteerOpenAIDriver implements LLMDriver {
async listModels(): Promise {
this.ensureEnabled();
- return MODELS.map((m) => ({ id: `${this.name}:${m}`, name: m, driver: this.name, label: "ChatGPT (web)" }));
+ return [
+ {
+ id: `${this.name}:chat`,
+ name: "chat",
+ driver: this.name,
+ label: this.spec.label,
+ group: "arcaic",
+ },
+ ];
}
async chat(messages: ChatMessage[], opts: ChatOptions): Promise {
this.ensureEnabled();
const t0 = Date.now();
const text = await this.getEngine().send(flattenMessages(messages), {
+ site: this.spec.site,
signal: opts.signal,
fresh: true,
- onLoginHint: () => driverLog(this.name, "login required — complete it inside the browser window"),
+ onLoginHint: () => driverLog(this.name, "session not ready — waiting for it inside the browser window"),
});
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(text.length)} in ${fmtMs(Date.now() - t0)}`);
return text;
@@ -133,9 +152,10 @@ export class PuppeteerOpenAIDriver implements LLMDriver {
let jobDone = false;
const job = this.getEngine()
.send(flattenMessages(messages), {
+ site: this.spec.site,
signal: opts.signal,
fresh: true,
- onLoginHint: () => driverLog(this.name, "login required — complete it inside the browser window"),
+ onLoginHint: () => driverLog(this.name, "session not ready — waiting for it inside the browser window"),
onToken: (tok) => {
chars += tok.length;
opts.onToken?.(tok);
@@ -161,3 +181,15 @@ export class PuppeteerOpenAIDriver implements LLMDriver {
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
}
}
+
+export function createArcaicDrivers(): ArcaicSubDriver[] {
+ return SPECS.map((spec) => new ArcaicSubDriver(spec));
+}
+
+function resolveExecutable(): string {
+ const conf = config.arcaicExecutable;
+ if (conf.includes("/")) return conf;
+ const found = Bun.which(conf);
+ if (!found) throw new Error("Arcaic backend is not configured (browser executable missing) — contact the administrator");
+ return found;
+}
diff --git a/src/drivers/browser.ts b/src/drivers/browser.ts
index 27841da..418ea61 100644
--- a/src/drivers/browser.ts
+++ b/src/drivers/browser.ts
@@ -4,15 +4,72 @@ 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 interface BrowserSite {
+ key: string;
+ url: string;
+ composer: string[];
+ submit: "button" | "enter";
+ sendButton?: string;
+ /** Completion signal (counted visible, before vs after send). */
+ copyButton: string;
+ /** Restrict the copy-button count to this container (excludes user messages). */
+ copyScope?: string;
+ /** Latest assistant message container (last match wins). */
+ messageContainer: string;
+ /** Answer-text nodes inside the container (joined); container innerText if unset. */
+ answer?: string;
+ /**
+ * How completion is detected. "copy-button": a newly visible copy control.
+ * "aria-busy": the response container reports aria-busy="false" (plus text).
+ */
+ completion?: "copy-button" | "aria-busy";
+ acceptTexts?: string[];
+}
-export const CHATGPT_SELECTORS = {
+export const OPENAI_SITE: BrowserSite = {
+ key: "openai",
+ url: "https://chatgpt.com/",
composer: ["#mobile-composer-prompt", "#prompt-textarea", "div[contenteditable='true']"],
+ submit: "button",
sendButton: "button[data-composer-submit]",
copyButton: "button[data-copy-message]",
- assistantMarkdown: "div[data-assistant-markdown]",
- streamBlock: "[data-assistant-stream-block]",
-} as const;
+ messageContainer: "div[data-assistant-markdown]",
+ answer: "[data-assistant-stream-block]",
+ completion: "copy-button",
+};
+
+export const QWEN_SITE: BrowserSite = {
+ key: "qwen",
+ url: "https://chat.qwen.ai/",
+ composer: ["textarea.message-input-textarea", "textarea[placeholder='Ask Qwen']"],
+ submit: "enter",
+ copyButton: "button[aria-label='Copy']",
+ copyScope: ".qwen-chat-message-assistant",
+ messageContainer: ".qwen-chat-message-assistant",
+ answer: ".custom-qwen-markdown",
+ completion: "copy-button",
+};
+
+export const GEMINI_SITE: BrowserSite = {
+ key: "gemini",
+ url: "https://gemini.google.com/app",
+ composer: [
+ "div[aria-label='Enter a prompt for Gemini']",
+ "div.ql-editor[contenteditable='true']",
+ "[data-test-id='textarea-inner'] div[contenteditable='true']",
+ ],
+ submit: "enter",
+ copyButton: "button[aria-label='Copy']",
+ messageContainer: "structured-content-container",
+ answer: ".markdown",
+ completion: "aria-busy",
+};
+
+export const SITES: Record = {
+ openai: OPENAI_SITE,
+ qwen: QWEN_SITE,
+ gemini: GEMINI_SITE,
+};
const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
@@ -27,29 +84,30 @@ export interface ChatGPTBrowserOptions {
}
export interface ChatGPTSendCallbacks {
+ site: BrowserSite;
signal?: AbortSignal;
onToken?: (delta: string) => void;
onLoginHint?: () => void;
- /** Start a fresh conversation (goto CHATGPT_URL) before sending. */
+ /** Start a fresh conversation before sending. */
fresh?: boolean;
}
-/** Manual login grace period expired (no composer on the page). */
+/** Session not ready: the web UI never became interactive in time. */
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)`,
- );
+ super(`Arcaic backend is not ready (no session after ${Math.round(timeoutMs / 1000)}s) — try again in a moment`);
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.
+ * Throwaway Firefox session driving web-UI backends 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; the per-send site config selects the target
+ * (chatgpt.com, chat.qwen.ai, gemini.google.com/app).
*/
-export class ChatGPTBrowserEngine {
+export class ArcaicBrowserEngine {
private browser: Browser | null = null;
private page: Page | null = null;
private profileDir: string | null = null;
@@ -64,29 +122,34 @@ export class ChatGPTBrowserEngine {
return this.browser !== null;
}
- async open(): Promise {
- if (this.browser) return;
+ async open(site: BrowserSite): Promise {
if (this.closed) throw new Error("engine already closed");
- this.opening ??= this.launch().finally(() => {
- this.opening = null;
- });
- await this.opening;
+ if (!this.browser) {
+ this.opening ??= this.launch().finally(() => {
+ this.opening = null;
+ });
+ await this.opening;
+ }
+ const page = this.requirePage();
+ if (!page.url().startsWith(site.url)) {
+ await this.gotoSite(page, site);
+ await this.dismissCookieBanner(page, site, 10_000);
+ }
}
/** One-shot state report (used by the POC probe mode). */
- async probe(): Promise<{ title: string; url: string; composer: string | null }> {
- await this.open();
+ async probe(site: BrowserSite): Promise<{ title: string; url: string; composer: string | null }> {
+ await this.open(site);
const page = this.requirePage();
await sleep(3000);
- return { title: await page.title(), url: page.url(), composer: await this.findComposer() };
+ return { title: await page.title(), url: page.url(), composer: await this.findComposer(page, site) };
}
/**
* 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.
+ * onToken and resolve with the final text. Serialized: one send at a time.
*/
- send(prompt: string, cb: ChatGPTSendCallbacks = {}): Promise {
+ send(prompt: string, cb: ChatGPTSendCallbacks): Promise {
const task = this.chain
.catch(() => undefined)
.then(() => this.doSend(prompt, cb));
@@ -115,14 +178,14 @@ export class ChatGPTBrowserEngine {
this.profileDir = null;
if (dir) {
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
- driverLog("chatgpt", `temporary profile removed (${dir})`);
+ driverLog("arcaic", `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}`);
+ this.profileDir = await mkdtemp(join(tmpdir(), "ka-arcaic-"));
+ driverLog("arcaic", `launching firefox headless=${this.opts.headless} profile=${this.profileDir}`);
const browser = await puppeteer.launch({
browser: "firefox",
executablePath: this.opts.executablePath,
@@ -135,51 +198,50 @@ export class ChatGPTBrowserEngine {
if (this.browser === browser) {
this.browser = null;
this.page = null;
- driverLog("chatgpt", "browser disconnected unexpectedly");
+ driverLog("arcaic", "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 {
+ /** Target site stalls on domcontentloaded sometimes — retry a few times. */
+ private async gotoSite(page: Page, site: BrowserSite): Promise {
let lastErr: unknown = null;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
- await page.goto(CHATGPT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
+ await page.goto(site.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
return;
} catch (e) {
lastErr = e;
- driverLog("chatgpt", `navigation failed (attempt ${attempt}/3): ${(e as Error).message}`);
+ driverLog("arcaic", `${site.key}: navigation failed (attempt ${attempt}/3): ${(e as Error).message}`);
await sleep(3000);
}
}
- throw lastErr instanceof Error ? lastErr : new Error("navigation to chatgpt.com failed");
+ throw lastErr instanceof Error ? lastErr : new Error("navigation to the Arcaic backend failed — try again");
}
/**
* Fresh profile = cookie consent banner on first load. It swallows input
- * until "Accept all" (button[type=submit], text) is clicked.
+ * until accepted.
*/
- private async dismissCookieBanner(page: Page, waitMs: number): Promise {
+ private async dismissCookieBanner(page: Page, site: BrowserSite, waitMs: number): Promise {
+ const accept = site.acceptTexts ?? ["accept all", "accept"];
const deadline = Date.now() + waitMs;
for (;;) {
const clicked = await page
- .evaluate(() => {
+ .evaluate((texts: string[]) => {
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;
+ return texts.includes(t) && (b as HTMLElement).getClientRects().length > 0;
});
if (!btn) return false;
(btn as HTMLElement).click();
return true;
- })
+ }, accept)
.catch(() => false);
if (clicked) {
- driverLog("chatgpt", "cookie banner dismissed (Accept all)");
+ driverLog("arcaic", `${site.key}: cookie banner dismissed`);
await sleep(800);
return;
}
@@ -190,6 +252,7 @@ export class ChatGPTBrowserEngine {
private async doSend(prompt: string, cb: ChatGPTSendCallbacks): Promise {
if (this.closed) throw new Error("engine already closed");
+ const site = cb.site;
const pollMs = this.opts.pollMs ?? 250;
const ac = new AbortController();
this.currentAbort = ac;
@@ -197,41 +260,59 @@ export class ChatGPTBrowserEngine {
cb.signal?.addEventListener("abort", forward);
const t0 = Date.now();
try {
- await this.open();
+ await this.open(site);
const page = this.requirePage();
if (cb.fresh) {
- await this.gotoChatGPT(page);
- await this.dismissCookieBanner(page, 1_500);
+ await this.gotoSite(page, site);
+ await this.dismissCookieBanner(page, site, 1_500);
}
- const composer = await this.waitForComposer(cb.onLoginHint);
- const copyBefore = await this.countCopyButtons();
+ const composer = await this.waitForComposer(page, site, cb.onLoginHint);
+ const copyBefore = await this.countVisible(page, site);
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`);
+ await this.submitMessage(page, site);
+ driverLog("arcaic", `${site.key}: sent ${prompt.length} chars via ${composer}, waiting for response`);
let last = "";
+ let stableTicks = 0;
const deadline = Date.now() + this.opts.responseTimeoutMs;
for (;;) {
if (ac.signal.aborted) {
- await this.stopGenerating();
- throw new DOMException("ChatGPT browser request aborted", "AbortError");
+ await this.stopGenerating(page, site);
+ throw new DOMException("Arcaic backend 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);
+ const read = await this.readAnswer(page, site).catch(() => ({ text: "", busy: null as string | null }));
+ if (!read.text) {
+ stableTicks++;
+ } else if (read.text !== last) {
+ if (read.text.length > last.length && read.text.startsWith(last)) {
+ const delta = read.text.slice(last.length);
+ last = read.text;
+ cb.onToken?.(delta);
+ } else {
+ last = read.text;
+ }
+ stableTicks = 0;
+ } else {
+ stableTicks++;
}
- if ((await this.countCopyButtons()) > copyBefore) {
- const finalText = await this.readAssistantText();
+ let signaled: boolean;
+ if (site.completion === "aria-busy") {
+ signaled =
+ read.busy === null
+ ? last.length > 0 && stableTicks >= 3
+ : last.length > 0 && read.busy !== "true";
+ } else {
+ signaled = (await this.countVisible(page, site)) > copyBefore;
+ }
+ if (signaled && stableTicks >= 1) {
+ const finalText = (await this.readAnswer(page, site).catch(() => null))?.text ?? "";
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)}`);
+ driverLog("arcaic", `${site.key}: 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 this.stopGenerating(page, site);
+ throw new Error(`Arcaic backend timed out after ${Math.round(this.opts.responseTimeoutMs / 1000)}s — try again`);
}
await sleep(pollMs);
}
@@ -241,12 +322,11 @@ export class ChatGPTBrowserEngine {
}
}
- private async waitForComposer(hint?: () => void): Promise {
- const page = this.requirePage();
+ private async waitForComposer(page: Page, site: BrowserSite, hint?: () => void): Promise {
const deadline = Date.now() + this.opts.loginTimeoutMs;
let hinted = false;
for (;;) {
- const sel = await this.findComposer();
+ const sel = await this.findComposer(page, site);
if (sel) return sel;
if (!hinted) {
hinted = true;
@@ -257,10 +337,8 @@ export class ChatGPTBrowserEngine {
}
}
- private async findComposer(): Promise {
- const page = this.page;
- if (!page) return null;
- for (const sel of CHATGPT_SELECTORS.composer) {
+ private async findComposer(page: Page, site: BrowserSite): Promise {
+ for (const sel of site.composer) {
try {
if (await page.$(sel)) return sel;
} catch {
@@ -272,7 +350,7 @@ export class ChatGPTBrowserEngine {
private async typeIntoComposer(page: Page, sel: string, text: string): Promise {
const input = await page.$(sel);
- if (!input) throw new Error(`composer ${sel} not found`);
+ if (!input) throw new Error("Arcaic backend session error — try again");
await input.focus();
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
@@ -286,36 +364,76 @@ export class ChatGPTBrowserEngine {
}
}
- private async waitSendable(page: Page): Promise {
+ private async composerHasText(page: Page, sel: string): Promise {
+ try {
+ const text = await page.$eval(sel, (el) => {
+ const v = (el as HTMLTextAreaElement).value;
+ if (typeof v === "string") return v;
+ return (el as HTMLElement).innerText ?? "";
+ });
+ return text.trim().length > 0;
+ } catch {
+ return false;
+ }
+ }
+
+ private async submitMessage(page: Page, site: BrowserSite): Promise {
+ if (site.submit === "button" && site.sendButton) {
+ await this.waitSendable(page, site);
+ await page.click(site.sendButton);
+ return;
+ }
+ if (!(await this.composerHasText(page, site.composer[0]))) {
+ throw new Error("Arcaic backend is not responding (send failed) — try again");
+ }
+ await page.keyboard.press("Enter");
+ }
+
+ private async waitSendable(page: Page, site: BrowserSite): Promise {
+ if (!site.sendButton) return;
+ const sendButton = site.sendButton;
const deadline = Date.now() + 10_000;
for (;;) {
const ok = await page
- .$eval(CHATGPT_SELECTORS.sendButton, (el) => {
+ .$eval(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");
+ if (Date.now() > deadline) throw new Error("Arcaic backend is not responding (send failed) — try again");
await sleep(250);
}
}
- private async stopGenerating(): Promise {
- const page = this.page;
- if (!page) return;
+ private async stopGenerating(page: Page, site: BrowserSite): Promise {
+ if (site.submit !== "button" || !site.sendButton) return;
try {
- await page.click(CHATGPT_SELECTORS.sendButton);
+ await page.click(site.sendButton);
} catch {
// button already back in send state — nothing to stop
}
}
- private async countCopyButtons(): Promise {
- const page = this.page;
- if (!page) return 0;
+ private async countVisible(page: Page, site: BrowserSite): Promise {
try {
- return await page.$$eval(CHATGPT_SELECTORS.copyButton, (els) =>
+ if (site.copyScope) {
+ const scope = site.copyScope;
+ const sel = site.copyButton;
+ return await page.$$eval(
+ scope,
+ (els, s: string) => {
+ let n = 0;
+ for (const el of els) {
+ n += Array.from(el.querySelectorAll(s)).filter((b) => (b as HTMLElement).getClientRects().length > 0)
+ .length;
+ }
+ return n;
+ },
+ sel,
+ );
+ }
+ return await page.$$eval(site.copyButton, (els) =>
els.filter((el) => (el as HTMLElement).getClientRects().length > 0).length,
);
} catch {
@@ -323,27 +441,28 @@ export class ChatGPTBrowserEngine {
}
}
- 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
+ private async readAnswer(page: Page, site: BrowserSite): Promise<{ text: string; busy: string | null }> {
+ return await page.$$eval(
+ site.messageContainer,
+ (els, answerSel: string) => {
+ const last = els[els.length - 1];
+ if (!last) return { text: "", busy: null as string | null };
+ const el = last as HTMLElement;
+ const busyEl = el.querySelector("[aria-busy]");
+ const busy = busyEl ? busyEl.getAttribute("aria-busy") : null;
+ if (!answerSel) return { text: el.innerText ?? "", busy };
+ const parts = Array.from(el.querySelectorAll(answerSel));
+ if (parts.length === 0) return { text: el.innerText ?? "", busy };
+ return {
+ text: parts
.map((b) => (b as HTMLElement).innerText.trim())
.filter(Boolean)
- .join("\n\n");
- },
- CHATGPT_SELECTORS.streamBlock,
- );
- } catch {
- return "";
- }
+ .join("\n\n"),
+ busy,
+ };
+ },
+ site.answer ?? "",
+ );
}
private requirePage(): Page {
diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts
index 8c4b7e6..e129cc8 100644
--- a/src/drivers/registry.ts
+++ b/src/drivers/registry.ts
@@ -1,14 +1,25 @@
import { AnthropicDriver, DeepSeekDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
import { OllamaDriver } from "./ollama.ts";
-import { PuppeteerOpenAIDriver } from "./puppeteer.ts";
+import { createArcaicDrivers } from "./arcaic.ts";
import type { DriverModel, LLMDriver } from "./types.ts";
/**
* Global driver priority (per spec):
- * 1. ollama (local) -> 2. mistral -> 3. glm -> 4. puppeteer-openai (stub)
- * -> 5. deepseek -> 6. anthropic -> 7. openai
+ * 1. ollama (local) -> 2. mistral -> 3. glm
+ * -> 4. arcaic-openai -> 5. arcaic-gemini -> 6. arcaic-qwen
+ * -> 7. deepseek -> 8. anthropic -> 9. openai
*/
-const PRIORITY = ["ollama", "mistral", "glm", "puppeteer-openai", "deepseek", "anthropic", "openai"];
+const PRIORITY = [
+ "ollama",
+ "mistral",
+ "glm",
+ "arcaic-openai",
+ "arcaic-gemini",
+ "arcaic-qwen",
+ "deepseek",
+ "anthropic",
+ "openai",
+];
/** How long the user-facing model list stays in memory before re-hitting upstream. */
export const MODEL_CACHE_TTL_MS = 60_000;
@@ -26,7 +37,7 @@ export class DriverRegistry {
new OllamaDriver(),
new MistralDriver(),
new GlmDriver(),
- new PuppeteerOpenAIDriver(),
+ ...createArcaicDrivers(),
new DeepSeekDriver(),
new AnthropicDriver(),
new OpenAIDriver(),
diff --git a/src/drivers/types.ts b/src/drivers/types.ts
index 1bb78aa..c2addaf 100644
--- a/src/drivers/types.ts
+++ b/src/drivers/types.ts
@@ -11,6 +11,8 @@ export interface DriverModel {
/** Driver name, e.g. "ollama" */
driver: string;
label: string;
+ /** Display category in the model picker (defaults to `driver`). */
+ group?: string;
}
export interface ChatOptions {