:3
This commit is contained in:
+10
-9
@@ -43,7 +43,7 @@ RATE_LIMIT_MAX=5
|
|||||||
RATE_LIMIT_WINDOW_MIN=10
|
RATE_LIMIT_WINDOW_MIN=10
|
||||||
|
|
||||||
# --- drivers ---
|
# --- 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_HOST=http://10.66.66.4:11434
|
||||||
OLLAMA_ENABLED=true
|
OLLAMA_ENABLED=true
|
||||||
# Stubs below: leave empty to keep disabled. Implemented last per spec.
|
# Stubs below: leave empty to keep disabled. Implemented last per spec.
|
||||||
@@ -60,11 +60,12 @@ ANTHROPIC_API_KEY=
|
|||||||
ANTHROPIC_ENABLED=false
|
ANTHROPIC_ENABLED=false
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_ENABLED=false
|
OPENAI_ENABLED=false
|
||||||
# ChatGPT web driver (puppeteer-core + Firefox via WebDriver BiDi). POC.
|
# Arcaic backend (headless browser, throwaway profile per process).
|
||||||
# Every process launch creates a throwaway Firefox profile (deleted on exit);
|
# 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.
|
ARCAIC_ENABLED=false
|
||||||
PUPPETEER_ENABLED=false
|
ARCAIC_EXECUTABLE=firefox
|
||||||
PUPPETEER_EXECUTABLE=firefox
|
# Headless by default; set to false to watch the window (debugging, or if a
|
||||||
PUPPETEER_HEADLESS=false
|
# manual login is ever required).
|
||||||
PUPPETEER_LOGIN_TIMEOUT_S=300
|
ARCAIC_HEADLESS=true
|
||||||
PUPPETEER_RESPONSE_TIMEOUT_S=300
|
ARCAIC_LOGIN_TIMEOUT_S=300
|
||||||
|
ARCAIC_RESPONSE_TIMEOUT_S=300
|
||||||
|
|||||||
@@ -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/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/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/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/
|
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)
|
public/ BUILD OUTPUT (Vite). Never hand-edit; rebuild instead. Not committed (see .gitignore)
|
||||||
flake.nix/module.nix NixOS packaging + service module
|
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 `<select>`** — use the `Picker` component in `frontend/src/components/ui.tsx` (also `Button`, `Modal`, `ConfirmDialog`, `ContextMenu`, `Avatar`, `CopyButton` live there).
|
- Icons: lucide-react only. **Never use native `<select>`** — use the `Picker` component in `frontend/src/components/ui.tsx` (also `Button`, `Modal`, `ConfirmDialog`, `ContextMenu`, `Avatar`, `CopyButton` live there).
|
||||||
- Backend responses: `json()` helper, `{ error }` shape on failure, correct HTTP codes (400/401/403/404/409/429/502).
|
- Backend responses: `json()` helper, `{ error }` shape on failure, correct HTTP codes (400/401/403/404/409/429/502).
|
||||||
- DB: synchronous `bun:sqlite` queries, prepared statements, `PRAGMA journal_mode=WAL`. Schema is created idempotently at boot — keep it that way (no migration framework).
|
- DB: synchronous `bun:sqlite` queries, prepared statements, `PRAGMA journal_mode=WAL`. Schema is created idempotently at boot — keep it that way (no migration framework).
|
||||||
- Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The puppeteer scraper stays a stub (ToS risk).
|
- Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The arcaic browser driver is user-facing as "Arcaic-Technology" — never expose its scraping internals (no "puppeteer"/"crawler"/upstream-site names) in API responses, error strings, or UI labels.
|
||||||
- Docs are in English. Update `docs/` + README when adding routes, tables, or env vars.
|
- Docs are in English. Update `docs/` + README when adding routes, tables, or env vars.
|
||||||
|
|
||||||
## NixOS
|
## NixOS
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ accounts, server-side chats and public shares. No cookies — Bearer keys in
|
|||||||
Driver priority:
|
Driver priority:
|
||||||
|
|
||||||
```
|
```
|
||||||
1. ollama → 2. mistral API → 3. glm API → 4. puppeteer-openai (ChatGPT web, POC)
|
1. ollama → 2. mistral API → 3. glm API → 4. arcaic ×3 (arcaic-openai → arcaic-gemini → arcaic-qwen)
|
||||||
→ 5. deepseek → 6. anthropic → 7. openai
|
→ 5. deepseek → 6. anthropic → 7. openai
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,8 +97,8 @@ src/drivers/types.ts LLMDriver interface + DriverModel + errors
|
|||||||
src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream
|
src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream
|
||||||
src/drivers/apiBase.ts ApiDriverBase (OpenAI-compatible SSE) base class
|
src/drivers/apiBase.ts ApiDriverBase (OpenAI-compatible SSE) base class
|
||||||
src/drivers/apis.ts Mistral / DeepSeek / Anthropic / OpenAI drivers
|
src/drivers/apis.ts Mistral / DeepSeek / Anthropic / OpenAI drivers
|
||||||
src/drivers/browser.ts ChatGPTBrowserEngine — throwaway Firefox profile (puppeteer-core/BiDi)
|
src/drivers/browser.ts ArcaicBrowserEngine + site configs (openai/qwen/gemini) — throwaway Firefox profile
|
||||||
src/drivers/puppeteer.ts PuppeteerOpenAIDriver — ChatGPT web POC (PUPPETEER_ENABLED, manual login)
|
src/drivers/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED)
|
||||||
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
|
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
|
||||||
src/db.ts SQLite store (users, sessions, convs, messages, shares)
|
src/db.ts SQLite store (users, sessions, convs, messages, shares)
|
||||||
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
|
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
|
||||||
|
|||||||
+18
-18
@@ -99,7 +99,7 @@ only ever sees reviewed text blocks, never raw files.
|
|||||||
|
|
||||||
## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
|
## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
|
||||||
`DriverRegistry` with global priority
|
`DriverRegistry` with global priority
|
||||||
`ollama → mistral → glm → puppeteer-openai (POC) → deepseek → anthropic → openai`.
|
`ollama → mistral → glm → arcaic-openai → arcaic-gemini → arcaic-qwen → deepseek → anthropic → openai`.
|
||||||
Models are addressed as `"driver:model"` (split on the first `:`).
|
Models are addressed as `"driver:model"` (split on the first `:`).
|
||||||
|
|
||||||
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
||||||
@@ -113,20 +113,20 @@ Models are addressed as `"driver:model"` (split on the first `:`).
|
|||||||
(SSE) implementation; concrete drivers only set base URL, key, and fallback
|
(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**
|
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.
|
its key is non-empty; failing drivers are skipped (not fatal) in listings.
|
||||||
- `PuppeteerOpenAIDriver` (+ `browser.ts` `ChatGPTBrowserEngine`): ChatGPT via
|
- `ArcaicSubDriver` ×3 (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`,
|
||||||
the web UI — puppeteer-core driving Firefox (WebDriver BiDi) with a
|
one model each: `chat`) + `browser.ts` engine: web-UI sessions driven by a
|
||||||
**throwaway profile** (mkdtemp under /tmp, deleted on close). Anonymous
|
headless browser (WebDriver BiDi) with a **throwaway profile** (mkdtemp
|
||||||
session works without login; if the composer is missing the engine waits up
|
under /tmp, deleted on close). Each sub-driver owns its engine (own
|
||||||
to `PUPPETEER_LOGIN_TIMEOUT_S` for a manual login in the window. Flow:
|
browser + profile) and declares a site config — composer selectors, submit
|
||||||
accept the cookie banner, focus the composer (`#mobile-composer-prompt`,
|
mode (send-button click vs Enter key), completion signal (newly **visible**
|
||||||
multi-line via Shift+Enter), submit with `[data-composer-submit]`, stream
|
copy control, or `aria-busy="false"` on the response container for Gemini),
|
||||||
by polling `[data-assistant-stream-block]` text, and treat a newly
|
answer-text selectors. Anonymous sessions work without login; if the
|
||||||
**visible** `[data-copy-message]` button as end-of-response (toolbars exist
|
composer is missing the engine waits up to `ARCAIC_LOGIN_TIMEOUT_S` for a
|
||||||
hidden in the DOM during streaming — visibility is the real signal).
|
manual login in the window. Multi-line prompts are typed with Shift+Enter.
|
||||||
Navigation uses domcontentloaded + retries (chatgpt.com stalls sometimes).
|
Off unless `ARCAIC_ENABLED=true`. Headless by default
|
||||||
POC — ToS-risky, off unless `PUPPETEER_ENABLED=true`. Headless validated
|
(`ARCAIC_HEADLESS=false` for a visible debug window: `bun
|
||||||
(`PUPPETEER_HEADLESS=true`, see `scripts/poc-chatgpt.ts --headless`); on
|
scripts/poc-chatgpt.ts --headed`); on NixOS use
|
||||||
NixOS use `services.kisassistant.enableBrowserDriver` for a headless
|
`services.kisassistant.enableBrowserDriver` for a headless Firefox in the
|
||||||
Firefox in the service, or wrap it all in a declarative `containers.*`
|
service, or wrap it all in a declarative `containers.*` (see
|
||||||
(see `docs/NIXOS-HOSTING.md`). Smoke test:
|
`docs/NIXOS-HOSTING.md`). Smoke test: `bun scripts/poc-chatgpt.ts
|
||||||
`bun scripts/poc-chatgpt.ts` (headed, `--probe` for a state report).
|
--site openai|qwen|gemini` (`--probe` for a state report).
|
||||||
|
|||||||
+16
-14
@@ -129,26 +129,28 @@ tool (`POST /api/tools/ocr`, image attachments) works out of the box.
|
|||||||
> `services.kisassistant.extraEnv = { MISTRAL_ENABLED = "true"; };` plus a
|
> `services.kisassistant.extraEnv = { MISTRAL_ENABLED = "true"; };` plus a
|
||||||
> drop-in containing the key.
|
> drop-in containing the key.
|
||||||
|
|
||||||
### ChatGPT web driver (`enableBrowserDriver`)
|
### Arcaic backend (`enableBrowserDriver`)
|
||||||
|
|
||||||
The `puppeteer-openai` driver answers through the ChatGPT web UI: a headless
|
The `arcaic-*` sub-drivers (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`)
|
||||||
Firefox from the nix store runs **inside the service** and drives
|
answer through web-UI sessions: a headless Firefox from the nix store runs
|
||||||
`chatgpt.com` via puppeteer-core (WebDriver BiDi). Anonymous sessions work —
|
**inside the service** and drives the sites via a browser-automation session
|
||||||
no ChatGPT login needed — and every browser launch gets a throwaway profile
|
(WebDriver BiDi). Anonymous sessions work — no login needed — and every
|
||||||
(mkdtemp under `/tmp`, deleted on exit).
|
browser launch gets a throwaway profile (mkdtemp under `/tmp`, deleted on
|
||||||
|
exit).
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
services.kisassistant = {
|
services.kisassistant = {
|
||||||
enable = true;
|
enable = true;
|
||||||
# …
|
# …
|
||||||
enableBrowserDriver = true; # adds firefox to the service PATH and sets
|
enableBrowserDriver = true; # adds firefox to the service PATH and sets
|
||||||
}; # PUPPETEER_ENABLED/HEADLESS/EXECUTABLE
|
}; # ARCAIC_ENABLED/HEADLESS/EXECUTABLE
|
||||||
```
|
```
|
||||||
|
|
||||||
Overridable through `extraEnv` (values win over the module defaults):
|
Overridable through `extraEnv` (values win over the module defaults):
|
||||||
`PUPPETEER_LOGIN_TIMEOUT_S` (default 300), `PUPPETEER_RESPONSE_TIMEOUT_S`
|
`ARCAIC_LOGIN_TIMEOUT_S` (default 300), `ARCAIC_RESPONSE_TIMEOUT_S`
|
||||||
(default 300). The browser only starts on the **first request** that uses the
|
(default 300). The browser only starts on the **first request** that uses one
|
||||||
`puppeteer-openai:chatgpt` model — boot cost stays zero otherwise.
|
of the `arcaic-*:chat` models (`arcaic-openai`, `arcaic-gemini`,
|
||||||
|
`arcaic-qwen`) — boot cost stays zero otherwise.
|
||||||
|
|
||||||
**Full isolation: run the whole app in a declarative NixOS container.**
|
**Full isolation: run the whole app in a declarative NixOS container.**
|
||||||
The driver launches Firefox as a subprocess, so the browser must live in the
|
The driver launches Firefox as a subprocess, so the browser must live in the
|
||||||
@@ -184,9 +186,9 @@ services.nginx.virtualHosts."chat.kisakay.com" = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Headless mode needs no display server anywhere (validated on CI-less builds:
|
Headless mode needs no display server anywhere (validated: cookie banner
|
||||||
cookie banner accepted, message sent, response streamed). If ChatGPT ever
|
accepted, message sent, response streamed). If the site ever starts
|
||||||
starts headless-shaming, fall back to `PUPPETEER_HEADLESS=false` inside the
|
headless-shaming, fall back to `ARCAIC_HEADLESS=false` inside the
|
||||||
container plus an `Xvfb` service — same driver, virtual display.
|
container plus an `Xvfb` service — same driver, virtual display.
|
||||||
|
|
||||||
## 5. TLS reverse proxy (`enableNginx`)
|
## 5. TLS reverse proxy (`enableNginx`)
|
||||||
@@ -288,5 +290,5 @@ After dependency bumps the first build may fail with a `bunDepsHash` mismatch
|
|||||||
| `ollamaHost` | str, `http://localhost:11434` | Ollama endpoint |
|
| `ollamaHost` | str, `http://localhost:11434` | Ollama endpoint |
|
||||||
| `domain` | null \| str | nginx vhost name, required when `enableNginx = true` |
|
| `domain` | null \| str | nginx vhost name, required when `enableNginx = true` |
|
||||||
| `enableNginx` | null \| bool, `null` | `true`/`false` force the bundled reverse proxy on/off; `null` = nginx iff `domain` set (legacy) |
|
| `enableNginx` | null \| bool, `null` | `true`/`false` force the bundled reverse proxy on/off; `null` = nginx iff `domain` set (legacy) |
|
||||||
| `enableBrowserDriver` | bool, `false` | headless Firefox in the service for the `puppeteer-openai` ChatGPT web driver (sets `PUPPETEER_ENABLED`/`PUPPETEER_HEADLESS`/`PUPPETEER_EXECUTABLE`, overridable via `extraEnv`) |
|
| `enableBrowserDriver` | bool, `false` | headless Firefox in the service for the `arcaic` driver ("Arcaic-Technology" model; sets `ARCAIC_ENABLED`/`ARCAIC_HEADLESS`/`ARCAIC_EXECUTABLE`, overridable via `extraEnv`) |
|
||||||
| `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) |
|
| `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
|
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
|
||||||
import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
||||||
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
||||||
@@ -23,8 +23,11 @@ export function AccessRequestsPanel() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
const listBusy = useRef(false);
|
||||||
|
|
||||||
async function refresh(select?: string) {
|
async function refresh(select?: string) {
|
||||||
|
if (listBusy.current) return;
|
||||||
|
listBusy.current = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.adminAccessList();
|
const res = await api.adminAccessList();
|
||||||
setRequests(res.requests);
|
setRequests(res.requests);
|
||||||
@@ -32,12 +35,19 @@ export function AccessRequestsPanel() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
|
listBusy.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
|
// Live list: new requests / replies pop without manual refresh.
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
}, 15000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const visible = requests.filter((r) => filter === "all" || r.status === filter);
|
const visible = requests.filter((r) => filter === "all" || r.status === filter);
|
||||||
@@ -122,12 +132,47 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||||
|
// Latest callbacks / server state without restarting the poll loop.
|
||||||
|
const onChangedRef = useRef(onChanged);
|
||||||
|
onChangedRef.current = onChanged;
|
||||||
|
const known = useRef({ status: request.status, reason: request.reason });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReason(request.reason);
|
setReason(request.reason);
|
||||||
setFreshKey(null);
|
setFreshKey(null);
|
||||||
api.adminAccessGet(request.id).then((res) => setMessages(res.messages)).catch(() => {});
|
known.current = { status: request.status, reason: request.reason };
|
||||||
}, [request.id, request.reason]);
|
let stopped = false;
|
||||||
|
let busy = false;
|
||||||
|
// Live thread: requester replies appear without manual refresh, and a
|
||||||
|
// status change made elsewhere syncs the list (badges, filters).
|
||||||
|
async function fetchOnce() {
|
||||||
|
if (busy || document.hidden) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const res = await api.adminAccessGet(request.id);
|
||||||
|
if (stopped) return;
|
||||||
|
setMessages(res.messages);
|
||||||
|
if (
|
||||||
|
res.request.status !== known.current.status ||
|
||||||
|
res.request.reason !== known.current.reason
|
||||||
|
) {
|
||||||
|
known.current = { status: res.request.status, reason: res.request.reason };
|
||||||
|
onChangedRef.current();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Transient failure: keep last state, retry next tick.
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchOnce();
|
||||||
|
const id = setInterval(fetchOnce, 5000);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [request.id]);
|
||||||
|
|
||||||
const closed = request.status === "accepted" || request.status === "refused";
|
const closed = request.status === "accepted" || request.status === "refused";
|
||||||
|
|
||||||
@@ -137,6 +182,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
|||||||
try {
|
try {
|
||||||
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
|
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
|
||||||
if (res.key) setFreshKey(res.key);
|
if (res.key) setFreshKey(res.key);
|
||||||
|
known.current = { status: res.request.status, reason: res.request.reason };
|
||||||
onChanged();
|
onChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("access.updateFailed"));
|
setError(err instanceof Error ? err.message : t("access.updateFailed"));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function AdminCenter() {
|
|||||||
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
|
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [openAccessCount, setOpenAccessCount] = useState(0);
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
|
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
|
||||||
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
||||||
@@ -45,6 +46,32 @@ export function AdminCenter() {
|
|||||||
.catch(() => setAllowed(false));
|
.catch(() => setAllowed(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Live badge on the Access tab: poll the open (pending + reviewing)
|
||||||
|
// request count so new demands pop without opening the tab.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowed) return;
|
||||||
|
let stopped = false;
|
||||||
|
async function fetchCount() {
|
||||||
|
try {
|
||||||
|
const res = await api.adminAccessList();
|
||||||
|
if (stopped) return;
|
||||||
|
setOpenAccessCount(
|
||||||
|
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Transient failure: keep last count, retry next tick.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCount();
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (!document.hidden) fetchCount();
|
||||||
|
}, 15000);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [allowed]);
|
||||||
|
|
||||||
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
|
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -95,7 +122,7 @@ export function AdminCenter() {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => setTab(t.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||||
tab === t.id
|
tab === t.id
|
||||||
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||||
: "opacity-60 hover:opacity-100",
|
: "opacity-60 hover:opacity-100",
|
||||||
@@ -103,6 +130,15 @@ export function AdminCenter() {
|
|||||||
>
|
>
|
||||||
<t.icon size={15} />
|
<t.icon size={15} />
|
||||||
{t.label}
|
{t.label}
|
||||||
|
{t.id === "access" && openAccessCount > 0 && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={`${t.label}: ${openAccessCount}`}
|
||||||
|
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
|
||||||
|
>
|
||||||
|
{openAccessCount > 99 ? "99+" : openAccessCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,11 +90,12 @@ export function Chat({
|
|||||||
const modelGroups: PickerGroup[] = (() => {
|
const modelGroups: PickerGroup[] = (() => {
|
||||||
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
const list = byDriver.get(m.driver) ?? [];
|
const group = m.group ?? m.driver;
|
||||||
|
const list = byDriver.get(group) ?? [];
|
||||||
list.push({ value: m.id, label: m.label, hint: m.id });
|
list.push({ value: m.id, label: m.label, hint: m.id });
|
||||||
byDriver.set(m.driver, list);
|
byDriver.set(group, list);
|
||||||
}
|
}
|
||||||
return [...byDriver.entries()].map(([driver, options]) => ({ group: driver, options }));
|
return [...byDriver.entries()].map(([group, options]) => ({ group, options }));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -83,7 +83,31 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let stopped = false;
|
||||||
load();
|
load();
|
||||||
|
// Live updates: short-poll the ticket so admin replies and the
|
||||||
|
// decision show up without a manual refresh. No WS infra on the
|
||||||
|
// backend — traffic here is tiny, polling is the clean fit.
|
||||||
|
let busy = false;
|
||||||
|
async function poll() {
|
||||||
|
if (stopped || busy || document.hidden) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const res = await api.accessTicket(ticketId);
|
||||||
|
if (stopped) return;
|
||||||
|
setRequest(res.request);
|
||||||
|
setMessages(res.messages);
|
||||||
|
} catch {
|
||||||
|
// Transient failure: keep last known state, retry next tick.
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = setInterval(poll, 5000);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ticketId]);
|
}, [ticketId]);
|
||||||
|
|
||||||
@@ -147,9 +171,18 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{request.status === "accepted" && (
|
{request.status === "accepted" && (
|
||||||
|
<>
|
||||||
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
|
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
|
||||||
{t("review.acceptedHint")}
|
{t("review.acceptedHint")}
|
||||||
</p>
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => (window.location.href = "/")}
|
||||||
|
>
|
||||||
|
{t("common.backToLogin")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface DriverModel {
|
|||||||
name: string;
|
name: string;
|
||||||
driver: string;
|
driver: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
|
|||||||
+7
-7
@@ -48,9 +48,9 @@ in {
|
|||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
description = ''
|
description = ''
|
||||||
Enable the ChatGPT web driver (puppeteer-openai): headless Firefox
|
Enable the Arcaic backend (headless Firefox from the nix store
|
||||||
from the nix store runs inside the service and drives chatgpt.com.
|
running a web-UI session inside the service).
|
||||||
Sets PUPPETEER_ENABLED/PUPPETEER_HEADLESS/PUPPETEER_EXECUTABLE
|
Sets ARCAIC_ENABLED/ARCAIC_HEADLESS/ARCAIC_EXECUTABLE
|
||||||
(still overridable via extraEnv).
|
(still overridable via extraEnv).
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -94,9 +94,9 @@ in {
|
|||||||
DRIVER_DEBUG = "false";
|
DRIVER_DEBUG = "false";
|
||||||
OLLAMA_HOST = cfg.ollamaHost;
|
OLLAMA_HOST = cfg.ollamaHost;
|
||||||
} // lib.optionalAttrs cfg.enableBrowserDriver {
|
} // lib.optionalAttrs cfg.enableBrowserDriver {
|
||||||
PUPPETEER_ENABLED = "true";
|
ARCAIC_ENABLED = "true";
|
||||||
PUPPETEER_HEADLESS = "true";
|
ARCAIC_HEADLESS = "true";
|
||||||
PUPPETEER_EXECUTABLE = "${pkgs.firefox}/bin/firefox";
|
ARCAIC_EXECUTABLE = "${pkgs.firefox}/bin/firefox";
|
||||||
} // cfg.extraEnv;
|
} // cfg.extraEnv;
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
User = cfg.user;
|
User = cfg.user;
|
||||||
@@ -108,7 +108,7 @@ in {
|
|||||||
LoadCredential = "app-password:${cfg.passwordFile}";
|
LoadCredential = "app-password:${cfg.passwordFile}";
|
||||||
};
|
};
|
||||||
# tesseract binary for the OCR platform tool (see TESSERACT_BIN);
|
# 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 ];
|
path = [ pkgs.tesseract ] ++ lib.optionals cfg.enableBrowserDriver [ pkgs.firefox ];
|
||||||
script = ''
|
script = ''
|
||||||
${lib.optionalString (cfg.passwordFile != null) ''
|
${lib.optionalString (cfg.passwordFile != null) ''
|
||||||
|
|||||||
+23
-13
@@ -1,17 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* POC — drive chatgpt.com inside a totally temporary Firefox profile
|
* POC — drive an Arcaic web-UI backend inside a totally temporary Firefox
|
||||||
* (puppeteer-core, WebDriver BiDi, headed). The profile is created in
|
* profile (puppeteer-core, WebDriver BiDi, headless by default). The profile
|
||||||
* /tmp at launch and deleted on exit; login is done manually in the
|
* is created in /tmp at launch and deleted on exit; login is done manually
|
||||||
* window when the script asks for it.
|
* in the window (with --headed) when the script asks for it.
|
||||||
*
|
*
|
||||||
* Usage:
|
* 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 "hello" # custom message (\n supported)
|
||||||
* bun scripts/poc-chatgpt.ts --message $'l1\nl2' # multi-line test
|
* 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 --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
|
* 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.";
|
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;
|
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);
|
const executablePath = exe.includes("/") ? exe : Bun.which(exe);
|
||||||
if (!executablePath) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +39,16 @@ if (!Number.isFinite(responseTimeoutS) || responseTimeoutS <= 0) {
|
|||||||
process.exit(1);
|
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,
|
executablePath,
|
||||||
headless: flag("--headless"),
|
headless: !flag("--headed"),
|
||||||
loginTimeoutMs: 600_000,
|
loginTimeoutMs: 600_000,
|
||||||
responseTimeoutMs: responseTimeoutS * 1000,
|
responseTimeoutMs: responseTimeoutS * 1000,
|
||||||
});
|
});
|
||||||
@@ -57,15 +66,16 @@ const step = (msg: string) => console.log(`[${((Date.now() - t0) / 1000).toFixed
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (flag("--probe")) {
|
if (flag("--probe")) {
|
||||||
step(`launching Firefox (${executablePath}) with a fresh throwaway profile`);
|
step(`launching Firefox (${executablePath}) with a fresh throwaway profile [site=${site.key}]`);
|
||||||
const info = await engine.probe();
|
const info = await engine.probe(site);
|
||||||
console.log(JSON.stringify(info, null, 2));
|
console.log(JSON.stringify(info, null, 2));
|
||||||
step(info.composer ? `composer ready: ${info.composer}` : "composer NOT found — login wall or challenge page");
|
step(info.composer ? `composer ready: ${info.composer}` : "composer NOT found — login wall or challenge page");
|
||||||
} else {
|
} else {
|
||||||
const message = option("--message") ?? DEFAULT_MESSAGE;
|
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 = "";
|
let streamed = "";
|
||||||
const final = await engine.send(message, {
|
const final = await engine.send(message, {
|
||||||
|
site,
|
||||||
onLoginHint: () => step("login required: log in inside the Firefox window (waiting up to 10 min)"),
|
onLoginHint: () => step("login required: log in inside the Firefox window (waiting up to 10 min)"),
|
||||||
onToken: (delta) => {
|
onToken: (delta) => {
|
||||||
streamed += delta;
|
streamed += delta;
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ export interface AccessMessage {
|
|||||||
|
|
||||||
export function initAccessTables(): void {
|
export function initAccessTables(): void {
|
||||||
const d = getDb();
|
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 (
|
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
@@ -45,6 +62,56 @@ export function initAccessTables(): void {
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
)`).run();
|
)`).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 (
|
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
|
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.
|
// mainland China (open.bigmodel.cn) — override for the latter.
|
||||||
glmBaseUrl: env("GLM_BASE_URL", "https://api.z.ai/api/paas/v4"),
|
glmBaseUrl: env("GLM_BASE_URL", "https://api.z.ai/api/paas/v4"),
|
||||||
|
|
||||||
// ChatGPT web driver (puppeteer-core + Firefox, throwaway profile per
|
// Arcaic web-UI backend (headless browser, throwaway profile per process).
|
||||||
// process, manual login inside the window while it waits).
|
arcaicEnabled: envBool("ARCAIC_ENABLED", false),
|
||||||
puppeteerEnabled: envBool("PUPPETEER_ENABLED", false),
|
arcaicExecutable: env("ARCAIC_EXECUTABLE", "firefox"),
|
||||||
puppeteerExecutable: env("PUPPETEER_EXECUTABLE", "firefox"),
|
arcaicHeadless: envBool("ARCAIC_HEADLESS", true),
|
||||||
puppeteerHeadless: envBool("PUPPETEER_HEADLESS", false),
|
arcaicLoginTimeoutS: envInt("ARCAIC_LOGIN_TIMEOUT_S", 300),
|
||||||
puppeteerLoginTimeoutS: envInt("PUPPETEER_LOGIN_TIMEOUT_S", 300),
|
arcaicResponseTimeoutS: envInt("ARCAIC_RESPONSE_TIMEOUT_S", 300),
|
||||||
puppeteerResponseTimeoutS: envInt("PUPPETEER_RESPONSE_TIMEOUT_S", 300),
|
|
||||||
|
|
||||||
// Public docs wiki (served by the hosted git forge, e.g. Gitea wiki pages).
|
// 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.
|
// The app redirects GET /wiki there (302) so the target stays configurable.
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import { config } from "../config.ts";
|
import { config } from "../config.ts";
|
||||||
import { driverLog, fmtBytes, fmtMs } from "./log.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 type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
|
||||||
import { DriverDisabledError } from "./types.ts";
|
import { DriverDisabledError } from "./types.ts";
|
||||||
|
|
||||||
const MODELS = ["chatgpt"];
|
export interface ArcaicSpec {
|
||||||
|
key: string;
|
||||||
function resolveExecutable(): string {
|
label: string;
|
||||||
const conf = config.puppeteerExecutable;
|
site: BrowserSite;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* composer: system text as a preamble, earlier turns as a transcript, and
|
||||||
* the latest user message sent as-is.
|
* 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
|
* One Arcaic sub-driver: a named web-UI backend (openai / gemini / qwen)
|
||||||
* puppeteer-core (WebDriver BiDi). ToS-risky and fragile by nature —
|
* sharing the throwaway-browser engine logic. Each sub-driver owns its
|
||||||
* disabled unless PUPPETEER_ENABLED=true. Login is manual for now: the
|
* engine — one browser + one temp profile per backend.
|
||||||
* first request opens a window and waits for the composer to appear.
|
|
||||||
*/
|
*/
|
||||||
export class PuppeteerOpenAIDriver implements LLMDriver {
|
export class ArcaicSubDriver implements LLMDriver {
|
||||||
readonly name = "puppeteer-openai";
|
readonly name: string;
|
||||||
readonly enabled = config.puppeteerEnabled;
|
readonly enabled = config.arcaicEnabled;
|
||||||
private engine: ChatGPTBrowserEngine | null = null;
|
private readonly spec: ArcaicSpec;
|
||||||
|
private engine: ArcaicBrowserEngine | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor(spec: ArcaicSpec) {
|
||||||
if (this.enabled) driverLog(this.name, "enabled (chatgpt.com via Firefox, throwaway profile, manual login)");
|
this.spec = spec;
|
||||||
|
this.name = spec.key;
|
||||||
|
if (this.enabled) driverLog(this.name, "enabled (web UI session, throwaway profile, headless)");
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureEnabled(): void {
|
private ensureEnabled(): void {
|
||||||
if (!this.enabled) throw new DriverDisabledError(this.name);
|
if (!this.enabled) throw new DriverDisabledError(this.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getEngine(): ChatGPTBrowserEngine {
|
private getEngine(): ArcaicBrowserEngine {
|
||||||
this.ensureEnabled();
|
this.ensureEnabled();
|
||||||
if (!this.engine) {
|
if (!this.engine) {
|
||||||
this.engine = new ChatGPTBrowserEngine({
|
this.engine = new ArcaicBrowserEngine({
|
||||||
executablePath: resolveExecutable(),
|
executablePath: resolveExecutable(),
|
||||||
headless: config.puppeteerHeadless,
|
headless: config.arcaicHeadless,
|
||||||
loginTimeoutMs: config.puppeteerLoginTimeoutS * 1000,
|
loginTimeoutMs: config.arcaicLoginTimeoutS * 1000,
|
||||||
responseTimeoutMs: config.puppeteerResponseTimeoutS * 1000,
|
responseTimeoutMs: config.arcaicResponseTimeoutS * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this.engine;
|
return this.engine;
|
||||||
@@ -109,16 +119,25 @@ export class PuppeteerOpenAIDriver implements LLMDriver {
|
|||||||
|
|
||||||
async listModels(): Promise<DriverModel[]> {
|
async listModels(): Promise<DriverModel[]> {
|
||||||
this.ensureEnabled();
|
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> {
|
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
||||||
this.ensureEnabled();
|
this.ensureEnabled();
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const text = await this.getEngine().send(flattenMessages(messages), {
|
const text = await this.getEngine().send(flattenMessages(messages), {
|
||||||
|
site: this.spec.site,
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
fresh: true,
|
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)}`);
|
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(text.length)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
return text;
|
return text;
|
||||||
@@ -133,9 +152,10 @@ export class PuppeteerOpenAIDriver implements LLMDriver {
|
|||||||
let jobDone = false;
|
let jobDone = false;
|
||||||
const job = this.getEngine()
|
const job = this.getEngine()
|
||||||
.send(flattenMessages(messages), {
|
.send(flattenMessages(messages), {
|
||||||
|
site: this.spec.site,
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
fresh: true,
|
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) => {
|
onToken: (tok) => {
|
||||||
chars += tok.length;
|
chars += tok.length;
|
||||||
opts.onToken?.(tok);
|
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)}`);
|
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;
|
||||||
|
}
|
||||||
+211
-92
@@ -4,15 +4,72 @@ import { join } from "node:path";
|
|||||||
import type { Browser, Page } from "puppeteer-core";
|
import type { Browser, Page } from "puppeteer-core";
|
||||||
import { driverLog } from "./log.ts";
|
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']"],
|
composer: ["#mobile-composer-prompt", "#prompt-textarea", "div[contenteditable='true']"],
|
||||||
|
submit: "button",
|
||||||
sendButton: "button[data-composer-submit]",
|
sendButton: "button[data-composer-submit]",
|
||||||
copyButton: "button[data-copy-message]",
|
copyButton: "button[data-copy-message]",
|
||||||
assistantMarkdown: "div[data-assistant-markdown]",
|
messageContainer: "div[data-assistant-markdown]",
|
||||||
streamBlock: "[data-assistant-stream-block]",
|
answer: "[data-assistant-stream-block]",
|
||||||
} as const;
|
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));
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
@@ -27,29 +84,30 @@ export interface ChatGPTBrowserOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatGPTSendCallbacks {
|
export interface ChatGPTSendCallbacks {
|
||||||
|
site: BrowserSite;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onToken?: (delta: string) => void;
|
onToken?: (delta: string) => void;
|
||||||
onLoginHint?: () => void;
|
onLoginHint?: () => void;
|
||||||
/** Start a fresh conversation (goto CHATGPT_URL) before sending. */
|
/** Start a fresh conversation before sending. */
|
||||||
fresh?: boolean;
|
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 {
|
export class BrowserLoginError extends Error {
|
||||||
constructor(timeoutMs: number) {
|
constructor(timeoutMs: number) {
|
||||||
super(
|
super(`Arcaic backend is not ready (no session after ${Math.round(timeoutMs / 1000)}s) — try again in a moment`);
|
||||||
`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";
|
this.name = "BrowserLoginError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throwaway Firefox session driving chatgpt.com via puppeteer-core (WebDriver
|
* Throwaway Firefox session driving web-UI backends via puppeteer-core
|
||||||
* BiDi). Each launch creates a fresh mkdtemp profile that is removed on
|
* (WebDriver BiDi). Each launch creates a fresh mkdtemp profile that is
|
||||||
* close(): zero persistence between runs. One engine = one browser + one tab.
|
* 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 browser: Browser | null = null;
|
||||||
private page: Page | null = null;
|
private page: Page | null = null;
|
||||||
private profileDir: string | null = null;
|
private profileDir: string | null = null;
|
||||||
@@ -64,29 +122,34 @@ export class ChatGPTBrowserEngine {
|
|||||||
return this.browser !== null;
|
return this.browser !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async open(): Promise<void> {
|
async open(site: BrowserSite): Promise<void> {
|
||||||
if (this.browser) return;
|
|
||||||
if (this.closed) throw new Error("engine already closed");
|
if (this.closed) throw new Error("engine already closed");
|
||||||
|
if (!this.browser) {
|
||||||
this.opening ??= this.launch().finally(() => {
|
this.opening ??= this.launch().finally(() => {
|
||||||
this.opening = null;
|
this.opening = null;
|
||||||
});
|
});
|
||||||
await this.opening;
|
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). */
|
/** One-shot state report (used by the POC probe mode). */
|
||||||
async probe(): Promise<{ title: string; url: string; composer: string | null }> {
|
async probe(site: BrowserSite): Promise<{ title: string; url: string; composer: string | null }> {
|
||||||
await this.open();
|
await this.open(site);
|
||||||
const page = this.requirePage();
|
const page = this.requirePage();
|
||||||
await sleep(3000);
|
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
|
* Type a prompt into the composer, submit, stream the answer through
|
||||||
* onToken and resolve with the final text. Completion = a new
|
* onToken and resolve with the final text. Serialized: one send at a time.
|
||||||
* "Copy response" button appearing. Serialized: one send at a time.
|
|
||||||
*/
|
*/
|
||||||
send(prompt: string, cb: ChatGPTSendCallbacks = {}): Promise<string> {
|
send(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
|
||||||
const task = this.chain
|
const task = this.chain
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(() => this.doSend(prompt, cb));
|
.then(() => this.doSend(prompt, cb));
|
||||||
@@ -115,14 +178,14 @@ export class ChatGPTBrowserEngine {
|
|||||||
this.profileDir = null;
|
this.profileDir = null;
|
||||||
if (dir) {
|
if (dir) {
|
||||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
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> {
|
private async launch(): Promise<void> {
|
||||||
const puppeteer = await import("puppeteer-core");
|
const puppeteer = await import("puppeteer-core");
|
||||||
this.profileDir = await mkdtemp(join(tmpdir(), "ka-chatgpt-"));
|
this.profileDir = await mkdtemp(join(tmpdir(), "ka-arcaic-"));
|
||||||
driverLog("chatgpt", `launching firefox headless=${this.opts.headless} profile=${this.profileDir}`);
|
driverLog("arcaic", `launching firefox headless=${this.opts.headless} profile=${this.profileDir}`);
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
browser: "firefox",
|
browser: "firefox",
|
||||||
executablePath: this.opts.executablePath,
|
executablePath: this.opts.executablePath,
|
||||||
@@ -135,51 +198,50 @@ export class ChatGPTBrowserEngine {
|
|||||||
if (this.browser === browser) {
|
if (this.browser === browser) {
|
||||||
this.browser = null;
|
this.browser = null;
|
||||||
this.page = null;
|
this.page = null;
|
||||||
driverLog("chatgpt", "browser disconnected unexpectedly");
|
driverLog("arcaic", "browser disconnected unexpectedly");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.browser = browser;
|
this.browser = browser;
|
||||||
this.page = await browser.newPage();
|
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. */
|
/** Target site stalls on domcontentloaded sometimes — retry a few times. */
|
||||||
private async gotoChatGPT(page: Page): Promise<void> {
|
private async gotoSite(page: Page, site: BrowserSite): Promise<void> {
|
||||||
let lastErr: unknown = null;
|
let lastErr: unknown = null;
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
await page.goto(CHATGPT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
await page.goto(site.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastErr = 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);
|
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
|
* 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;
|
const deadline = Date.now() + waitMs;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const clicked = await page
|
const clicked = await page
|
||||||
.evaluate(() => {
|
.evaluate((texts: string[]) => {
|
||||||
const btn = Array.from(document.querySelectorAll("button")).find((b) => {
|
const btn = Array.from(document.querySelectorAll("button")).find((b) => {
|
||||||
const t = (b.textContent ?? "").trim().toLowerCase();
|
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;
|
if (!btn) return false;
|
||||||
(btn as HTMLElement).click();
|
(btn as HTMLElement).click();
|
||||||
return true;
|
return true;
|
||||||
})
|
}, accept)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
if (clicked) {
|
if (clicked) {
|
||||||
driverLog("chatgpt", "cookie banner dismissed (Accept all)");
|
driverLog("arcaic", `${site.key}: cookie banner dismissed`);
|
||||||
await sleep(800);
|
await sleep(800);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -190,6 +252,7 @@ export class ChatGPTBrowserEngine {
|
|||||||
|
|
||||||
private async doSend(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
|
private async doSend(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
|
||||||
if (this.closed) throw new Error("engine already closed");
|
if (this.closed) throw new Error("engine already closed");
|
||||||
|
const site = cb.site;
|
||||||
const pollMs = this.opts.pollMs ?? 250;
|
const pollMs = this.opts.pollMs ?? 250;
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
this.currentAbort = ac;
|
this.currentAbort = ac;
|
||||||
@@ -197,41 +260,59 @@ export class ChatGPTBrowserEngine {
|
|||||||
cb.signal?.addEventListener("abort", forward);
|
cb.signal?.addEventListener("abort", forward);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
try {
|
try {
|
||||||
await this.open();
|
await this.open(site);
|
||||||
const page = this.requirePage();
|
const page = this.requirePage();
|
||||||
if (cb.fresh) {
|
if (cb.fresh) {
|
||||||
await this.gotoChatGPT(page);
|
await this.gotoSite(page, site);
|
||||||
await this.dismissCookieBanner(page, 1_500);
|
await this.dismissCookieBanner(page, site, 1_500);
|
||||||
}
|
}
|
||||||
const composer = await this.waitForComposer(cb.onLoginHint);
|
const composer = await this.waitForComposer(page, site, cb.onLoginHint);
|
||||||
const copyBefore = await this.countCopyButtons();
|
const copyBefore = await this.countVisible(page, site);
|
||||||
await this.typeIntoComposer(page, composer, prompt);
|
await this.typeIntoComposer(page, composer, prompt);
|
||||||
await this.waitSendable(page);
|
await this.submitMessage(page, site);
|
||||||
await page.click(CHATGPT_SELECTORS.sendButton);
|
driverLog("arcaic", `${site.key}: sent ${prompt.length} chars via ${composer}, waiting for response`);
|
||||||
driverLog("chatgpt", `sent ${prompt.length} chars via ${composer}, waiting for response`);
|
|
||||||
let last = "";
|
let last = "";
|
||||||
|
let stableTicks = 0;
|
||||||
const deadline = Date.now() + this.opts.responseTimeoutMs;
|
const deadline = Date.now() + this.opts.responseTimeoutMs;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (ac.signal.aborted) {
|
if (ac.signal.aborted) {
|
||||||
await this.stopGenerating();
|
await this.stopGenerating(page, site);
|
||||||
throw new DOMException("ChatGPT browser request aborted", "AbortError");
|
throw new DOMException("Arcaic backend request aborted", "AbortError");
|
||||||
}
|
}
|
||||||
const text = await this.readAssistantText();
|
const read = await this.readAnswer(page, site).catch(() => ({ text: "", busy: null as string | null }));
|
||||||
if (text.startsWith(last) && text.length > last.length) {
|
if (!read.text) {
|
||||||
const delta = text.slice(last.length);
|
stableTicks++;
|
||||||
last = text;
|
} 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);
|
cb.onToken?.(delta);
|
||||||
|
} else {
|
||||||
|
last = read.text;
|
||||||
}
|
}
|
||||||
if ((await this.countCopyButtons()) > copyBefore) {
|
stableTicks = 0;
|
||||||
const finalText = await this.readAssistantText();
|
} else {
|
||||||
|
stableTicks++;
|
||||||
|
}
|
||||||
|
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));
|
if (finalText.length > last.length) cb.onToken?.(finalText.slice(last.length));
|
||||||
const out = finalText.length ? finalText : last;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
if (Date.now() > deadline) {
|
if (Date.now() > deadline) {
|
||||||
await this.stopGenerating();
|
await this.stopGenerating(page, site);
|
||||||
throw new Error(`ChatGPT response timed out after ${Math.round(this.opts.responseTimeoutMs / 1000)}s`);
|
throw new Error(`Arcaic backend timed out after ${Math.round(this.opts.responseTimeoutMs / 1000)}s — try again`);
|
||||||
}
|
}
|
||||||
await sleep(pollMs);
|
await sleep(pollMs);
|
||||||
}
|
}
|
||||||
@@ -241,12 +322,11 @@ export class ChatGPTBrowserEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForComposer(hint?: () => void): Promise<string> {
|
private async waitForComposer(page: Page, site: BrowserSite, hint?: () => void): Promise<string> {
|
||||||
const page = this.requirePage();
|
|
||||||
const deadline = Date.now() + this.opts.loginTimeoutMs;
|
const deadline = Date.now() + this.opts.loginTimeoutMs;
|
||||||
let hinted = false;
|
let hinted = false;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const sel = await this.findComposer();
|
const sel = await this.findComposer(page, site);
|
||||||
if (sel) return sel;
|
if (sel) return sel;
|
||||||
if (!hinted) {
|
if (!hinted) {
|
||||||
hinted = true;
|
hinted = true;
|
||||||
@@ -257,10 +337,8 @@ export class ChatGPTBrowserEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findComposer(): Promise<string | null> {
|
private async findComposer(page: Page, site: BrowserSite): Promise<string | null> {
|
||||||
const page = this.page;
|
for (const sel of site.composer) {
|
||||||
if (!page) return null;
|
|
||||||
for (const sel of CHATGPT_SELECTORS.composer) {
|
|
||||||
try {
|
try {
|
||||||
if (await page.$(sel)) return sel;
|
if (await page.$(sel)) return sel;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -272,7 +350,7 @@ export class ChatGPTBrowserEngine {
|
|||||||
|
|
||||||
private async typeIntoComposer(page: Page, sel: string, text: string): Promise<void> {
|
private async typeIntoComposer(page: Page, sel: string, text: string): Promise<void> {
|
||||||
const input = await page.$(sel);
|
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();
|
await input.focus();
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
for (let i = 0; i < lines.length; i++) {
|
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;
|
const deadline = Date.now() + 10_000;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const ok = await page
|
const ok = await page
|
||||||
.$eval(CHATGPT_SELECTORS.sendButton, (el) => {
|
.$eval(sendButton, (el) => {
|
||||||
const b = el as HTMLButtonElement;
|
const b = el as HTMLButtonElement;
|
||||||
return !b.disabled && b.getAttribute("aria-disabled") !== "true";
|
return !b.disabled && b.getAttribute("aria-disabled") !== "true";
|
||||||
})
|
})
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
if (ok) return;
|
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);
|
await sleep(250);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async stopGenerating(): Promise<void> {
|
private async stopGenerating(page: Page, site: BrowserSite): Promise<void> {
|
||||||
const page = this.page;
|
if (site.submit !== "button" || !site.sendButton) return;
|
||||||
if (!page) return;
|
|
||||||
try {
|
try {
|
||||||
await page.click(CHATGPT_SELECTORS.sendButton);
|
await page.click(site.sendButton);
|
||||||
} catch {
|
} catch {
|
||||||
// button already back in send state — nothing to stop
|
// button already back in send state — nothing to stop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countCopyButtons(): Promise<number> {
|
private async countVisible(page: Page, site: BrowserSite): Promise<number> {
|
||||||
const page = this.page;
|
|
||||||
if (!page) return 0;
|
|
||||||
try {
|
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,
|
els.filter((el) => (el as HTMLElement).getClientRects().length > 0).length,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -323,27 +441,28 @@ export class ChatGPTBrowserEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readAssistantText(): Promise<string> {
|
private async readAnswer(page: Page, site: BrowserSite): Promise<{ text: string; busy: string | null }> {
|
||||||
const page = this.page;
|
|
||||||
if (!page) return "";
|
|
||||||
try {
|
|
||||||
return await page.$$eval(
|
return await page.$$eval(
|
||||||
CHATGPT_SELECTORS.assistantMarkdown,
|
site.messageContainer,
|
||||||
(els, blockSel: string) => {
|
(els, answerSel: string) => {
|
||||||
const last = els[els.length - 1];
|
const last = els[els.length - 1];
|
||||||
if (!last) return "";
|
if (!last) return { text: "", busy: null as string | null };
|
||||||
const blocks = Array.from(last.querySelectorAll(blockSel));
|
const el = last as HTMLElement;
|
||||||
if (blocks.length === 0) return (last as HTMLElement).innerText;
|
const busyEl = el.querySelector("[aria-busy]");
|
||||||
return blocks
|
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())
|
.map((b) => (b as HTMLElement).innerText.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n"),
|
||||||
|
busy,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
CHATGPT_SELECTORS.streamBlock,
|
site.answer ?? "",
|
||||||
);
|
);
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private requirePage(): Page {
|
private requirePage(): Page {
|
||||||
|
|||||||
+16
-5
@@ -1,14 +1,25 @@
|
|||||||
import { AnthropicDriver, DeepSeekDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
|
import { AnthropicDriver, DeepSeekDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts";
|
||||||
import { OllamaDriver } from "./ollama.ts";
|
import { OllamaDriver } from "./ollama.ts";
|
||||||
import { PuppeteerOpenAIDriver } from "./puppeteer.ts";
|
import { createArcaicDrivers } from "./arcaic.ts";
|
||||||
import type { DriverModel, LLMDriver } from "./types.ts";
|
import type { DriverModel, LLMDriver } from "./types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global driver priority (per spec):
|
* Global driver priority (per spec):
|
||||||
* 1. ollama (local) -> 2. mistral -> 3. glm -> 4. puppeteer-openai (stub)
|
* 1. ollama (local) -> 2. mistral -> 3. glm
|
||||||
* -> 5. deepseek -> 6. anthropic -> 7. openai
|
* -> 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. */
|
/** How long the user-facing model list stays in memory before re-hitting upstream. */
|
||||||
export const MODEL_CACHE_TTL_MS = 60_000;
|
export const MODEL_CACHE_TTL_MS = 60_000;
|
||||||
@@ -26,7 +37,7 @@ export class DriverRegistry {
|
|||||||
new OllamaDriver(),
|
new OllamaDriver(),
|
||||||
new MistralDriver(),
|
new MistralDriver(),
|
||||||
new GlmDriver(),
|
new GlmDriver(),
|
||||||
new PuppeteerOpenAIDriver(),
|
...createArcaicDrivers(),
|
||||||
new DeepSeekDriver(),
|
new DeepSeekDriver(),
|
||||||
new AnthropicDriver(),
|
new AnthropicDriver(),
|
||||||
new OpenAIDriver(),
|
new OpenAIDriver(),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface DriverModel {
|
|||||||
/** Driver name, e.g. "ollama" */
|
/** Driver name, e.g. "ollama" */
|
||||||
driver: string;
|
driver: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Display category in the model picker (defaults to `driver`). */
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatOptions {
|
export interface ChatOptions {
|
||||||
|
|||||||
Reference in New Issue
Block a user