:3
This commit is contained in:
@@ -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,
|
||||
|
||||
+6
-7
@@ -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.
|
||||
|
||||
@@ -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<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DriverModel[]> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
+220
-101
@@ -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<string, BrowserSite> = {
|
||||
openai: OPENAI_SITE,
|
||||
qwen: QWEN_SITE,
|
||||
gemini: GEMINI_SITE,
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> => 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<void> {
|
||||
if (this.browser) return;
|
||||
async open(site: BrowserSite): Promise<void> {
|
||||
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<string> {
|
||||
send(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
|
||||
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<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}`);
|
||||
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<void> {
|
||||
/** Target site stalls on domcontentloaded sometimes — retry a few times. */
|
||||
private async gotoSite(page: Page, site: BrowserSite): Promise<void> {
|
||||
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<void> {
|
||||
private async dismissCookieBanner(page: Page, site: BrowserSite, waitMs: number): Promise<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const page = this.requirePage();
|
||||
private async waitForComposer(page: Page, site: BrowserSite, hint?: () => void): Promise<string> {
|
||||
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<string | null> {
|
||||
const page = this.page;
|
||||
if (!page) return null;
|
||||
for (const sel of CHATGPT_SELECTORS.composer) {
|
||||
private async findComposer(page: Page, site: BrowserSite): Promise<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
private async composerHasText(page: Page, sel: string): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const page = this.page;
|
||||
if (!page) return;
|
||||
private async stopGenerating(page: Page, site: BrowserSite): Promise<void> {
|
||||
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<number> {
|
||||
const page = this.page;
|
||||
if (!page) return 0;
|
||||
private async countVisible(page: Page, site: BrowserSite): Promise<number> {
|
||||
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<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
|
||||
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 {
|
||||
|
||||
+16
-5
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user