diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a002c63..bbbe89d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -124,5 +124,9 @@ Models are addressed as `"driver:model"` (split on the first `:`). **visible** `[data-copy-message]` button as end-of-response (toolbars exist hidden in the DOM during streaming — visibility is the real signal). Navigation uses domcontentloaded + retries (chatgpt.com stalls sometimes). - POC — ToS-risky, off unless `PUPPETEER_ENABLED=true`. Smoke test: + POC — ToS-risky, off unless `PUPPETEER_ENABLED=true`. Headless validated + (`PUPPETEER_HEADLESS=true`, see `scripts/poc-chatgpt.ts --headless`); on + NixOS use `services.kisassistant.enableBrowserDriver` for a headless + Firefox in the service, or wrap it all in a declarative `containers.*` + (see `docs/NIXOS-HOSTING.md`). Smoke test: `bun scripts/poc-chatgpt.ts` (headed, `--probe` for a state report). diff --git a/docs/NIXOS-HOSTING.md b/docs/NIXOS-HOSTING.md index 83cacae..8af4593 100644 --- a/docs/NIXOS-HOSTING.md +++ b/docs/NIXOS-HOSTING.md @@ -129,6 +129,66 @@ tool (`POST /api/tools/ocr`, image attachments) works out of the box. > `services.kisassistant.extraEnv = { MISTRAL_ENABLED = "true"; };` plus a > drop-in containing the key. +### ChatGPT web driver (`enableBrowserDriver`) + +The `puppeteer-openai` driver answers through the ChatGPT web UI: a headless +Firefox from the nix store runs **inside the service** and drives +`chatgpt.com` via puppeteer-core (WebDriver BiDi). Anonymous sessions work — +no ChatGPT login needed — and every browser launch gets a throwaway profile +(mkdtemp under `/tmp`, deleted on exit). + +```nix +services.kisassistant = { + enable = true; + # … + enableBrowserDriver = true; # adds firefox to the service PATH and sets +}; # PUPPETEER_ENABLED/HEADLESS/EXECUTABLE +``` + +Overridable through `extraEnv` (values win over the module defaults): +`PUPPETEER_LOGIN_TIMEOUT_S` (default 300), `PUPPETEER_RESPONSE_TIMEOUT_S` +(default 300). The browser only starts on the **first request** that uses the +`puppeteer-openai:chatgpt` model — boot cost stays zero otherwise. + +**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 +same namespace as the backend — the cleanest isolation is an nspawn +container wrapping the entire service: + +```nix +containers.kisassistant = { + autoStart = true; + privateNetwork = true; + hostAddress = "10.99.0.1"; + localAddress = "10.99.0.2"; + config = { config, pkgs, ... }: { + imports = [ kisassistant.nixosModules.default ]; + services.kisassistant = { + enable = true; + package = kisassistant.packages.x86_64-linux.default; + host = "0.0.0.0"; + enableBrowserDriver = true; + passwordFile = "/var/lib/kisassistant-password"; # provision inside + }; + }; +}; + +# Host: nginx proxies to the container (enableNginx stays unset/false) +services.nginx.virtualHosts."chat.kisakay.com" = { + forceSSL = true; + enableACME = true; + locations."/" = { + proxyPass = "http://10.99.0.2:3000"; + extraConfig = "proxy_buffering off;"; + }; +}; +``` + +Headless mode needs no display server anywhere (validated on CI-less builds: +cookie banner accepted, message sent, response streamed). If ChatGPT ever +starts headless-shaming, fall back to `PUPPETEER_HEADLESS=false` inside the +container plus an `Xvfb` service — same driver, virtual display. + ## 5. TLS reverse proxy (`enableNginx`) The conventional switch is `enableNginx` — a tri-state: @@ -228,4 +288,5 @@ After dependency bumps the first build may fail with a `bunDepsHash` mismatch | `ollamaHost` | str, `http://localhost:11434` | Ollama endpoint | | `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) | +| `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`) | | `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) | diff --git a/flake.nix b/flake.nix index 4886328..036ba2d 100644 --- a/flake.nix +++ b/flake.nix @@ -53,19 +53,36 @@ cp -r ../public/. $out/ ''; }; + # Backend runtime deps from bun.lock (fixed-output, has network). + # Gives the package its node_modules (nodemailer, puppeteer-core, …). + # If the build fails with a hash mismatch, paste the "got: sha256-…" + # value from the error into `outputHash` of `backendDeps` below. + backendDeps = pkgs.stdenv.mkDerivation { + pname = "kisassistant-backend-bun-deps"; + inherit version; + src = ./.; + nativeBuildInputs = [ pkgs.bun ]; + buildPhase = '' + export HOME=$TMPDIR + export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache + bun install --frozen-lockfile --ignore-scripts + ''; + installPhase = '' + cp -r node_modules $out + ''; + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + outputHash = "sha256-gQmtcFBDcGeMGIZf1VaMG6mlxJ6r08e9iHelS7wvYZ8="; + }; in pkgs.stdenv.mkDerivation { pname = "kisassistant"; inherit version; src = ./.; - nativeBuildInputs = [ pkgs.bun ]; - buildPhase = '' - export HOME=$TMPDIR - # Backend has zero runtime deps; install only for @types/bun (typecheck). - bun install --frozen-lockfile 2>/dev/null || bun install --no-save || true - ''; installPhase = '' mkdir -p $out/share/kisassistant cp -r src package.json tsconfig.json .env.example $out/share/kisassistant/ + cp -a ${backendDeps} $out/share/kisassistant/node_modules + chmod -R u+w $out/share/kisassistant/node_modules cp -r ${frontend} $out/share/kisassistant/public mkdir -p $out/bin cat > $out/bin/kisassistant < void }) { } return ( -
-
-
-
- +
+
+
+
+

{t("login.welcome")}

-

{t("login.subtitle")}

+

{t("login.subtitle")}

{totpToken ? ( -
+

{t("login.totpIntro", { user: totpUsername })}

@@ -135,7 +135,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
) : ( -
+
void }) { )} -
+
{t("login.newHere")} @@ -181,7 +181,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) { )} setRecoverOpen(false)} from={recoveryFrom} /> -
+
-
    +
      {PERKS.map((p) => ( -
    • - - +
    • + + {p.text}
    • diff --git a/frontend/src/components/RegisterPage.tsx b/frontend/src/components/RegisterPage.tsx index f7d328f..580d8a7 100644 --- a/frontend/src/components/RegisterPage.tsx +++ b/frontend/src/components/RegisterPage.tsx @@ -54,16 +54,16 @@ export function RegisterPage() { } return ( -
      -
      -
      -
      - +
      +
      +
      +
      +

      {t("dlg.registerTitle")}

      {key ? ( -
      +

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

      @@ -76,28 +76,30 @@ export function RegisterPage() {
      ) : ( -
      - - setUsername(e.target.value)} - placeholder="alice" - maxLength={32} - autoComplete="username" - required - /> - - - setDisplayName(e.target.value)} - placeholder="Alice" - maxLength={60} - autoComplete="nickname" - /> - + +
      + + setUsername(e.target.value)} + placeholder="alice" + maxLength={32} + autoComplete="username" + required + /> + + + setDisplayName(e.target.value)} + placeholder="Alice" + maxLength={60} + autoComplete="nickname" + /> + +
      navigate("/login")} - className="mt-4 inline-flex w-full items-center justify-center gap-1.5 text-center text-sm opacity-60 transition hover:opacity-100 hover:underline" + className="mt-3 inline-flex w-full items-center justify-center gap-1.5 text-center text-sm opacity-60 transition hover:opacity-100 hover:underline" > {t("common.backToLogin")} -
      +
      diff --git a/module.nix b/module.nix index a4f7642..c46f0d3 100644 --- a/module.nix +++ b/module.nix @@ -44,6 +44,16 @@ in { - `null` (default): legacy behaviour — nginx is configured iff `domain` is set. ''; }; + enableBrowserDriver = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Enable the ChatGPT web driver (puppeteer-openai): headless Firefox + from the nix store runs inside the service and drives chatgpt.com. + Sets PUPPETEER_ENABLED/PUPPETEER_HEADLESS/PUPPETEER_EXECUTABLE + (still overridable via extraEnv). + ''; + }; extraEnv = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = {}; @@ -83,6 +93,10 @@ in { CDN_DIR = "${cfg.dataDir}/cdn"; DRIVER_DEBUG = "false"; OLLAMA_HOST = cfg.ollamaHost; + } // lib.optionalAttrs cfg.enableBrowserDriver { + PUPPETEER_ENABLED = "true"; + PUPPETEER_HEADLESS = "true"; + PUPPETEER_EXECUTABLE = "${pkgs.firefox}/bin/firefox"; } // cfg.extraEnv; serviceConfig = { User = cfg.user; @@ -93,8 +107,9 @@ in { } // lib.optionalAttrs (cfg.passwordFile != null) { LoadCredential = "app-password:${cfg.passwordFile}"; }; - # tesseract binary for the OCR platform tool (see TESSERACT_BIN). - path = [ pkgs.tesseract ]; + # tesseract binary for the OCR platform tool (see TESSERACT_BIN); + # firefox for the ChatGPT web driver (enableBrowserDriver). + path = [ pkgs.tesseract ] ++ lib.optionals cfg.enableBrowserDriver [ pkgs.firefox ]; script = '' ${lib.optionalString (cfg.passwordFile != null) '' export APP_PASSWORD="$(cat "$CREDENTIALS_DIRECTORY/app-password")" diff --git a/scripts/poc-chatgpt.ts b/scripts/poc-chatgpt.ts index 7adb288..3324b26 100644 --- a/scripts/poc-chatgpt.ts +++ b/scripts/poc-chatgpt.ts @@ -39,7 +39,7 @@ if (!Number.isFinite(responseTimeoutS) || responseTimeoutS <= 0) { const engine = new ChatGPTBrowserEngine({ executablePath, - headless: false, + headless: flag("--headless"), loginTimeoutMs: 600_000, responseTimeoutMs: responseTimeoutS * 1000, }); @@ -63,7 +63,7 @@ try { step(info.composer ? `composer ready: ${info.composer}` : "composer NOT found — login wall or challenge page"); } else { const message = option("--message") ?? DEFAULT_MESSAGE; - step(`launching Firefox (${executablePath}) — temporary profile, deleted on exit`); + step(`launching Firefox (${executablePath}${flag("--headless") ? ", headless" : ""}) — temporary profile, deleted on exit`); let streamed = ""; const final = await engine.send(message, { onLoginHint: () => step("login required: log in inside the Firefox window (waiting up to 10 min)"),