This commit is contained in:
k
2026-09-21 20:17:10 +02:00
parent 62370e61bf
commit c0e7899ab3
7 changed files with 157 additions and 58 deletions
+5 -1
View File
@@ -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).
+61
View File
@@ -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) |
+23 -6
View File
@@ -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 <<EOF
+17 -17
View File
@@ -90,17 +90,17 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
}
return (
<div className="grid min-h-full place-items-center overflow-y-auto bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-sm">
<div className="rounded-[2rem] border border-white/60 bg-white/80 p-8 shadow-2xl shadow-accent-900/5 backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-2 flex flex-col items-center text-center">
<FlowerMark size={68} dynamic={false} className="mb-4 drop-shadow-lg" />
<div className="grid min-h-full place-items-center overflow-y-auto bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-4 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-md">
<div className="rounded-[2rem] border border-white/60 bg-white/80 px-6 py-6 shadow-2xl shadow-accent-900/5 backdrop-blur-xl sm:px-8 dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-1 flex flex-col items-center text-center">
<FlowerMark size={52} dynamic={false} className="mb-2 drop-shadow-lg" />
<h1 className="text-2xl font-bold tracking-tight">{t("login.welcome")}</h1>
<p className="mt-1 text-sm opacity-60">{t("login.subtitle")}</p>
<p className="mt-0.5 text-sm opacity-60">{t("login.subtitle")}</p>
</div>
{totpToken ? (
<form onSubmit={submitTotp} className="mt-6 space-y-3">
<form onSubmit={submitTotp} className="mt-4 space-y-2.5">
<p className="text-center text-sm opacity-70">
{t("login.totpIntro", { user: totpUsername })}
</p>
@@ -135,7 +135,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
</button>
</form>
) : (
<form onSubmit={submit} className="mt-6 space-y-3">
<form onSubmit={submit} className="mt-4 space-y-2.5">
<div className="relative">
<User size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
<Input
@@ -173,7 +173,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
</Button>
</form>
)}
<div className="mt-4 flex items-center gap-3 text-xs opacity-40">
<div className="mt-3 flex items-center gap-3 text-xs opacity-40">
<span className="h-px flex-1 bg-current" />
{t("login.newHere")}
<span className="h-px flex-1 bg-current" />
@@ -181,7 +181,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
<Button
variant="secondary"
size="lg"
className="mt-3 w-full"
className="mt-2 w-full"
disabled={!registrationOn}
title={registrationOn ? t("login.regTipOn") : t("login.regTipOff")}
onClick={() => navigate("/register")}
@@ -195,7 +195,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
<Button
variant="secondary"
size="lg"
className="mt-3 w-full"
className="mt-2 w-full"
onClick={() => setRequestOpen(true)}
>
<MessageSquareText size={16} />
@@ -204,21 +204,21 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
)}
<AccessRequestModal open={requestOpen} onClose={() => setRequestOpen(false)} />
{recoveryOn && (
<button onClick={() => setRecoverOpen(true)} className="mt-3 w-full text-center text-sm opacity-60 transition hover:opacity-100 hover:underline">
<button onClick={() => setRecoverOpen(true)} className="mt-2 w-full text-center text-sm opacity-60 transition hover:opacity-100 hover:underline">
{t("login.forgot")}
</button>
)}
<RecoverDialog open={recoverOpen} onClose={() => setRecoverOpen(false)} from={recoveryFrom} />
<div className="mt-4 flex justify-center">
<div className="mt-3 flex justify-center">
<LangPicker />
</div>
<ul className="mt-6 space-y-2 border-t border-stone-200/70 pt-5 dark:border-zinc-800">
<ul className="mt-4 grid grid-cols-1 gap-2 border-t border-stone-200/70 pt-4 sm:grid-cols-2 dark:border-zinc-800">
{PERKS.map((p) => (
<li key={p.text} className="flex items-center gap-2.5 text-[13px] opacity-70">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<p.icon size={14} />
<li key={p.text} className="flex items-center gap-2 text-xs opacity-70">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-accent-600/10 text-accent-600 dark:text-accent-400">
<p.icon size={13} />
</span>
{p.text}
</li>
+32 -30
View File
@@ -54,16 +54,16 @@ export function RegisterPage() {
}
return (
<div className="grid min-h-full place-items-center overflow-y-auto bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-sm">
<div className="rounded-[2rem] border border-white/60 bg-white/80 p-8 shadow-2xl shadow-accent-900/5 backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-2 flex flex-col items-center text-center">
<FlowerMark size={68} dynamic={false} className="mb-4 drop-shadow-lg" />
<div className="grid min-h-full place-items-center overflow-y-auto bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-4 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-md">
<div className="rounded-[2rem] border border-white/60 bg-white/80 px-6 py-6 shadow-2xl shadow-accent-900/5 backdrop-blur-xl sm:px-8 dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-1 flex flex-col items-center text-center">
<FlowerMark size={52} dynamic={false} className="mb-2 drop-shadow-lg" />
<h1 className="text-2xl font-bold tracking-tight">{t("dlg.registerTitle")}</h1>
</div>
{key ? (
<div className="mt-6 space-y-4">
<div className="mt-4 space-y-3">
<p className="text-center text-sm opacity-80">
{t("dlg.regWelcome", { user: username.trim().toLowerCase() })}
</p>
@@ -76,28 +76,30 @@ export function RegisterPage() {
</Button>
</div>
) : (
<form onSubmit={submit} className="mt-6 space-y-3">
<Field label={t("common.username")}>
<Input
variant="soft"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="alice"
maxLength={32}
autoComplete="username"
required
/>
</Field>
<Field label={t("common.displayName")}>
<Input
variant="soft"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Alice"
maxLength={60}
autoComplete="nickname"
/>
</Field>
<form onSubmit={submit} className="mt-4 space-y-2.5">
<div className="grid grid-cols-2 gap-2.5">
<Field label={t("common.username")}>
<Input
variant="soft"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="alice"
maxLength={32}
autoComplete="username"
required
/>
</Field>
<Field label={t("common.displayName")}>
<Input
variant="soft"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Alice"
maxLength={60}
autoComplete="nickname"
/>
</Field>
</div>
<Field label={t("dlg.emailOpt")}>
<Input
variant="soft"
@@ -131,13 +133,13 @@ export function RegisterPage() {
<button
onClick={() => navigate("/login")}
className="mt-4 inline-flex w-full items-center justify-center gap-1.5 text-center text-sm opacity-60 transition hover:opacity-100 hover:underline"
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"
>
<ArrowLeft size={15} />
{t("common.backToLogin")}
</button>
<div className="mt-4 flex justify-center">
<div className="mt-3 flex justify-center">
<LangPicker />
</div>
</div>
+17 -2
View File
@@ -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")"
+2 -2
View File
@@ -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)"),