:3
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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")"
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
Reference in New Issue
Block a user