/
This commit is contained in:
+7
-1
@@ -60,5 +60,11 @@ ANTHROPIC_API_KEY=
|
|||||||
ANTHROPIC_ENABLED=false
|
ANTHROPIC_ENABLED=false
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_ENABLED=false
|
OPENAI_ENABLED=false
|
||||||
# Puppeteer driver is a stub on purpose (fragile ToS-risky). Never enabled for now.
|
# ChatGPT web driver (puppeteer-core + Firefox via WebDriver BiDi). POC.
|
||||||
|
# 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.
|
||||||
PUPPETEER_ENABLED=false
|
PUPPETEER_ENABLED=false
|
||||||
|
PUPPETEER_EXECUTABLE=firefox
|
||||||
|
PUPPETEER_HEADLESS=false
|
||||||
|
PUPPETEER_LOGIN_TIMEOUT_S=300
|
||||||
|
PUPPETEER_RESPONSE_TIMEOUT_S=300
|
||||||
|
|||||||
@@ -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 (stub, not implemented)
|
1. ollama → 2. mistral API → 3. glm API → 4. puppeteer-openai (ChatGPT web, POC)
|
||||||
→ 5. deepseek → 6. anthropic → 7. openai
|
→ 5. deepseek → 6. anthropic → 7. openai
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,7 +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/puppeteer.ts PuppeteerOpenAIDriver — stub (always disabled)
|
src/drivers/browser.ts ChatGPTBrowserEngine — throwaway Firefox profile (puppeteer-core/BiDi)
|
||||||
|
src/drivers/puppeteer.ts PuppeteerOpenAIDriver — ChatGPT web POC (PUPPETEER_ENABLED, manual login)
|
||||||
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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"name": "kisassistant",
|
"name": "kisassistant",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nodemailer": "^10.0.10",
|
"nodemailer": "^10.0.10",
|
||||||
|
"puppeteer-core": "^25.11.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
@@ -14,16 +15,66 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@puppeteer/browsers": ["@puppeteer/[email protected]", "", { "dependencies": { "modern-tar": "^0.8.4", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
|
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
|
||||||
|
|
||||||
"@types/nodemailer": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="],
|
"@types/nodemailer": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["[email protected]", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||||
|
|
||||||
|
"chromium-bidi": ["[email protected]", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="],
|
||||||
|
|
||||||
|
"cliui": ["[email protected]", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||||
|
|
||||||
|
"devtools-protocol": ["[email protected]", "", {}, "sha512-rVARKAvOFJCFkvgX01VLvYqKIvt+cRCqvUxIhB2oAqVGPalxk3w3sb5TUGDagpow/EFblBpm/Zl/29QA2dd+jA=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["[email protected]", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="],
|
||||||
|
|
||||||
|
"mitt": ["[email protected]", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||||
|
|
||||||
|
"modern-tar": ["[email protected]", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="],
|
||||||
|
|
||||||
"nodemailer": ["[email protected]", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="],
|
"nodemailer": ["[email protected]", "", {}, "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A=="],
|
||||||
|
|
||||||
|
"puppeteer-core": ["[email protected]", "", { "dependencies": { "@puppeteer/browsers": "3.2.2", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1680125", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.3", "ws": "^8.21.3" } }, "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw=="],
|
||||||
|
|
||||||
|
"string-width": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"typed-query-selector": ["[email protected]", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
|
||||||
|
|
||||||
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||||
|
|
||||||
|
"webdriver-bidi-protocol": ["[email protected]", "", {}, "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
|
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||||
|
|
||||||
|
"y18n": ["[email protected]", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
|
|
||||||
|
"yargs": ["[email protected]", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="],
|
||||||
|
|
||||||
|
"yargs-parser": ["[email protected]", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||||
|
|
||||||
|
"zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"cliui/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"wrap-ansi/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -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 (stub) → deepseek → anthropic → openai`.
|
`ollama → mistral → glm → puppeteer-openai (POC) → 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,5 +113,16 @@ 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`: intentional stub, always disabled (headless scraping
|
- `PuppeteerOpenAIDriver` (+ `browser.ts` `ChatGPTBrowserEngine`): ChatGPT via
|
||||||
of chatgpt.com is fragile and against ToS).
|
the web UI — puppeteer-core driving Firefox (WebDriver BiDi) with a
|
||||||
|
**throwaway profile** (mkdtemp under /tmp, deleted on close). Anonymous
|
||||||
|
session works without login; if the composer is missing the engine waits up
|
||||||
|
to `PUPPETEER_LOGIN_TIMEOUT_S` for a manual login in the window. Flow:
|
||||||
|
accept the cookie banner, focus the composer (`#mobile-composer-prompt`,
|
||||||
|
multi-line via Shift+Enter), submit with `[data-composer-submit]`, stream
|
||||||
|
by polling `[data-assistant-stream-block]` text, and treat a newly
|
||||||
|
**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:
|
||||||
|
`bun scripts/poc-chatgpt.ts` (headed, `--probe` for a state report).
|
||||||
|
|||||||
+19
-5
@@ -7,9 +7,18 @@ directory is a build artifact, never hand-edit, not committed).
|
|||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|
||||||
No router library — `App.tsx` matches `window.location.pathname` once (module
|
No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
||||||
level, constant for the page lifetime): `/share/:publicId` renders the public
|
`useRoute()`, trailing-slash normalization) drives pathname routing:
|
||||||
read-only `SharePage` (no auth), everything else renders the authenticated app.
|
|
||||||
|
- `/login` — sign-in card (username + access key + TOTP step). Renders
|
||||||
|
instantly, **never shows the boot splash**.
|
||||||
|
- `/register` — full-page self-registration (`RegisterPage.tsx`; key shown
|
||||||
|
once, then hands username+key to `/login` via `sessionStorage`). No splash.
|
||||||
|
- `/chat` — the platform itself (auth required; boots behind the splash).
|
||||||
|
- `/` — redirects to `/chat` when logged in, `/login` otherwise (placeholder
|
||||||
|
for a future landing page).
|
||||||
|
- Public routes (unchanged): `/share/:publicId`, `/reset/:token`,
|
||||||
|
`/review/:id`, `/admin`.
|
||||||
|
|
||||||
## State & data flow
|
## State & data flow
|
||||||
|
|
||||||
@@ -20,7 +29,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
|
|||||||
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401
|
`{ error }` parsing, `ApiError` with status, auto logout+reload on 401
|
||||||
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
|
(except on `/share/*`). `chatStream()` parses SSE `token`/`done`/`error`.
|
||||||
- Boot shows `LoadingScreen` for at least 1.5 s (`MIN_SPLASH_MS` in `App.tsx`)
|
- Boot shows `LoadingScreen` for at least 1.5 s (`MIN_SPLASH_MS` in `App.tsx`)
|
||||||
so the bloom animation always plays, even when the session restores instantly.
|
so the bloom animation always plays, even when the session restores
|
||||||
|
instantly — **only on `/` and `/chat`**. `/login` and `/register` skip it.
|
||||||
- Sending: ensure a server conversation exists (created lazily on first send),
|
- Sending: ensure a server conversation exists (created lazily on first send),
|
||||||
optimistic user message, stream tokens into `streaming`, then append the full
|
optimistic user message, stream tokens into `streaming`, then append the full
|
||||||
assistant message and refresh the list (server auto-titles new chats).
|
assistant message and refresh the list (server auto-titles new chats).
|
||||||
@@ -33,7 +43,11 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
|
|||||||
**`Picker`** — the framework-styled dropdown (pill button + popover, optional
|
**`Picker`** — the framework-styled dropdown (pill button + popover, optional
|
||||||
option groups, lucide `ChevronDown`/`Check`, Escape/outside-click to close).
|
option groups, lucide `ChevronDown`/`Check`, Escape/outside-click to close).
|
||||||
**Never use a native `<select>`**; use `Picker` for model and theme choices.
|
**Never use a native `<select>`**; use `Picker` for model and theme choices.
|
||||||
- `Login.tsx` — username + access-key card.
|
- `Login.tsx` — username + access-key card (`/login`); the Register button
|
||||||
|
navigates to `/register`, and a fresh registration prefills the form.
|
||||||
|
- `RegisterPage.tsx` — full-page registration (`/register`) reusing the login
|
||||||
|
card style; shows the new key once with a copy button, then continues to
|
||||||
|
`/login`.
|
||||||
- `Sidebar.tsx` — collapsible conversation list with per-item context menu
|
- `Sidebar.tsx` — collapsible conversation list with per-item context menu
|
||||||
(right-click or `···`: Rename / Set topic / Share publicly / Archive /
|
(right-click or `···`: Rename / Set topic / Share publicly / Archive /
|
||||||
Delete), user chip (opens settings), Accounts button (admin), logout.
|
Delete), user chip (opens settings), Accounts button (admin), logout.
|
||||||
|
|||||||
+66
-5
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { api, clearToken, getToken } from "./lib/api.ts";
|
import { api, clearToken, getToken } from "./lib/api.ts";
|
||||||
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
|
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
|
||||||
import type { Attachment, ChatMessage, Conversation, DriverModel, FilePreview, User } from "./lib/types.ts";
|
import type { Attachment, ChatMessage, Conversation, DriverModel, FilePreview, User } from "./lib/types.ts";
|
||||||
|
import { navigate, normalizePath, useRoute } from "./lib/route.ts";
|
||||||
import { Login } from "./components/Login.tsx";
|
import { Login } from "./components/Login.tsx";
|
||||||
|
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||||
import { Sidebar } from "./components/Sidebar.tsx";
|
import { Sidebar } from "./components/Sidebar.tsx";
|
||||||
import { Chat } from "./components/Chat.tsx";
|
import { Chat } from "./components/Chat.tsx";
|
||||||
import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./components/dialogs.tsx";
|
import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./components/dialogs.tsx";
|
||||||
@@ -48,7 +50,18 @@ function applyTheme(theme: string) {
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !REVIEW_ID && !IS_ADMIN_PAGE);
|
// Boot splash ONLY on the platform routes (/ and /chat, with a token).
|
||||||
|
// /login and /register never show it — they render instantly.
|
||||||
|
const [checking, setChecking] = useState(() => {
|
||||||
|
if (SHARE_ID || RESET_TOKEN || REVIEW_ID || IS_ADMIN_PAGE) return false;
|
||||||
|
const p = normalizePath(window.location.pathname);
|
||||||
|
if (p === "/login" || p === "/register") return false;
|
||||||
|
return !!getToken();
|
||||||
|
});
|
||||||
|
// True once we know whether there is a session (no token = instant).
|
||||||
|
const [authReady, setAuthReady] = useState(() => !getToken());
|
||||||
|
const rawRoute = useRoute();
|
||||||
|
const path = normalizePath(rawRoute);
|
||||||
const [models, setModels] = useState<DriverModel[]>([]);
|
const [models, setModels] = useState<DriverModel[]>([]);
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [convs, setConvs] = useState<Conversation[]>([]);
|
const [convs, setConvs] = useState<Conversation[]>([]);
|
||||||
@@ -98,7 +111,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Session restore (boot splash stays at least MIN_SPLASH_MS)
|
// Session restore (boot splash stays at least MIN_SPLASH_MS on / and /chat)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const done = () => {
|
const done = () => {
|
||||||
@@ -106,7 +119,8 @@ export function App() {
|
|||||||
window.setTimeout(() => setChecking(false), wait);
|
window.setTimeout(() => setChecking(false), wait);
|
||||||
};
|
};
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
done();
|
setAuthReady(true);
|
||||||
|
setChecking(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.verify()
|
api.verify()
|
||||||
@@ -125,9 +139,35 @@ export function App() {
|
|||||||
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
|
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(done);
|
.finally(() => {
|
||||||
|
setAuthReady(true);
|
||||||
|
done();
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Route guard: / redirects by session, /chat needs auth, /login and
|
||||||
|
// /register bounce logged-in users to /chat. Public routes return above.
|
||||||
|
useEffect(() => {
|
||||||
|
if (SHARE_ID || RESET_TOKEN || REVIEW_ID || IS_ADMIN_PAGE) return;
|
||||||
|
const noToken = !getToken();
|
||||||
|
if (path === "/") {
|
||||||
|
if (noToken) navigate("/login", true);
|
||||||
|
else if (authReady) navigate(user ? "/chat" : "/login", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === "/login" || path === "/register") {
|
||||||
|
if (user) navigate("/chat", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === "/chat") {
|
||||||
|
if (noToken || (authReady && !user)) navigate("/login", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Unknown path: land on the platform or the login page.
|
||||||
|
if (noToken) navigate("/login", true);
|
||||||
|
else if (authReady) navigate(user ? "/chat" : "/login", true);
|
||||||
|
}, [path, user, authReady]);
|
||||||
|
|
||||||
// Load messages for active conversation
|
// Load messages for active conversation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
@@ -148,7 +188,10 @@ export function App() {
|
|||||||
|
|
||||||
function handleLogin(u: User) {
|
function handleLogin(u: User) {
|
||||||
setUser(u);
|
setUser(u);
|
||||||
|
setAuthReady(true);
|
||||||
|
setChecking(false);
|
||||||
applyTheme(u.theme);
|
applyTheme(u.theme);
|
||||||
|
navigate("/chat", true);
|
||||||
api.models().then((m) => {
|
api.models().then((m) => {
|
||||||
setModels(m.models);
|
setModels(m.models);
|
||||||
if (m.models.length > 0) setModel(m.models[0]!.id);
|
if (m.models.length > 0) setModel(m.models[0]!.id);
|
||||||
@@ -161,12 +204,15 @@ export function App() {
|
|||||||
api.logout().finally(() => {});
|
api.logout().finally(() => {});
|
||||||
clearToken();
|
clearToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setAuthReady(true);
|
||||||
|
setChecking(false);
|
||||||
setConvs([]);
|
setConvs([]);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setFilePreview(null);
|
setFilePreview(null);
|
||||||
|
navigate("/login", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function newChat() {
|
async function newChat() {
|
||||||
@@ -334,12 +380,27 @@ export function App() {
|
|||||||
await refreshConvs();
|
await refreshConvs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth pages render instantly (no splash); the platform boots behind one.
|
||||||
|
if (path === "/login") {
|
||||||
|
if (user) return null; // bounce to /chat imminent
|
||||||
|
return <Login onLogin={handleLogin} />;
|
||||||
|
}
|
||||||
|
if (path === "/register") {
|
||||||
|
if (user) return null; // bounce to /chat imminent
|
||||||
|
return <RegisterPage />;
|
||||||
|
}
|
||||||
|
|
||||||
if (checking) {
|
if (checking) {
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// / (or unknown paths) while the guard above redirects.
|
||||||
|
if (path !== "/chat") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Login onLogin={handleLogin} />;
|
return null; // bounce to /login imminent (no splash flash)
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = convs.find((c) => c.id === activeId) ?? archivedConv;
|
const active = convs.find((c) => c.id === activeId) ?? archivedConv;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { ArrowRight, KeyRound, MessageSquareText, ShieldCheck, User, UserPlus, Z
|
|||||||
import { api, setToken } from "../lib/api.ts";
|
import { api, setToken } from "../lib/api.ts";
|
||||||
import { useT } from "../lib/i18n.ts";
|
import { useT } from "../lib/i18n.ts";
|
||||||
import type { User as UserType } from "../lib/types.ts";
|
import type { User as UserType } from "../lib/types.ts";
|
||||||
|
import { navigate } from "../lib/route.ts";
|
||||||
import { Button, FlowerMark, Input, LangPicker, Spinner } from "./ui.tsx";
|
import { Button, FlowerMark, Input, LangPicker, Spinner } from "./ui.tsx";
|
||||||
import { RecoverDialog, RegisterDialog } from "./dialogs.tsx";
|
import { RecoverDialog } from "./dialogs.tsx";
|
||||||
import { AccessRequestModal } from "./ReviewPage.tsx";
|
import { AccessRequestModal } from "./ReviewPage.tsx";
|
||||||
|
|
||||||
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||||
@@ -16,7 +17,6 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
|||||||
const [recoverOpen, setRecoverOpen] = useState(false);
|
const [recoverOpen, setRecoverOpen] = useState(false);
|
||||||
const [recoveryOn, setRecoveryOn] = useState(false);
|
const [recoveryOn, setRecoveryOn] = useState(false);
|
||||||
const [recoveryFrom, setRecoveryFrom] = useState<string | undefined>(undefined);
|
const [recoveryFrom, setRecoveryFrom] = useState<string | undefined>(undefined);
|
||||||
const [registerOpen, setRegisterOpen] = useState(false);
|
|
||||||
const [registrationOn, setRegistrationOn] = useState(false);
|
const [registrationOn, setRegistrationOn] = useState(false);
|
||||||
const [requestOpen, setRequestOpen] = useState(false);
|
const [requestOpen, setRequestOpen] = useState(false);
|
||||||
const [requestOn, setRequestOn] = useState(false);
|
const [requestOn, setRequestOn] = useState(false);
|
||||||
@@ -37,6 +37,19 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
|||||||
setRegistrationOn(m.registration);
|
setRegistrationOn(m.registration);
|
||||||
setRequestOn(m.accessRequest);
|
setRequestOn(m.accessRequest);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
// Fresh registration hands the new key over via sessionStorage so the
|
||||||
|
// login form comes prefilled (cleared immediately after reading).
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("ka_login_prefill");
|
||||||
|
if (raw) {
|
||||||
|
const p = JSON.parse(raw) as { username?: string; key?: string };
|
||||||
|
if (p.username) setUsername(p.username);
|
||||||
|
if (p.key) setKey(p.key);
|
||||||
|
sessionStorage.removeItem("ka_login_prefill");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore (private mode, malformed payload)
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
@@ -171,16 +184,11 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
|||||||
className="mt-3 w-full"
|
className="mt-3 w-full"
|
||||||
disabled={!registrationOn}
|
disabled={!registrationOn}
|
||||||
title={registrationOn ? t("login.regTipOn") : t("login.regTipOff")}
|
title={registrationOn ? t("login.regTipOn") : t("login.regTipOff")}
|
||||||
onClick={() => setRegisterOpen(true)}
|
onClick={() => navigate("/register")}
|
||||||
>
|
>
|
||||||
<UserPlus size={16} />
|
<UserPlus size={16} />
|
||||||
{t("login.register")}
|
{t("login.register")}
|
||||||
</Button>
|
</Button>
|
||||||
<RegisterDialog
|
|
||||||
open={registerOpen}
|
|
||||||
onClose={() => setRegisterOpen(false)}
|
|
||||||
onDone={(u, k) => { setUsername(u); setKey(k); }}
|
|
||||||
/>
|
|
||||||
{/* Wishlist path: only when open registration is off AND the admin
|
{/* Wishlist path: only when open registration is off AND the admin
|
||||||
enabled access requests. They are mutually exclusive server-side. */}
|
enabled access requests. They are mutually exclusive server-side. */}
|
||||||
{!registrationOn && requestOn && (
|
{!registrationOn && requestOn && (
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight, UserPlus } from "lucide-react";
|
||||||
|
import { api } from "../lib/api.ts";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
import { navigate } from "../lib/route.ts";
|
||||||
|
import { Button, CopyButton, Field, FlowerMark, Input, LangPicker, Spinner } from "./ui.tsx";
|
||||||
|
|
||||||
|
const PREFILL_KEY = "ka_login_prefill";
|
||||||
|
|
||||||
|
/** Full-page self-registration (/register). No boot splash on this route. */
|
||||||
|
export function RegisterPage() {
|
||||||
|
const { t } = useT();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [key, setKey] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [registrationOn, setRegistrationOn] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.methods().then((m) => setRegistrationOn(m.registration)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await api.register({
|
||||||
|
username: username.trim().toLowerCase(),
|
||||||
|
displayName: displayName.trim() || undefined,
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
});
|
||||||
|
setKey(res.key);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("dlg.regFailed"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goLogin() {
|
||||||
|
const u = username.trim().toLowerCase();
|
||||||
|
if (key && u) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(PREFILL_KEY, JSON.stringify({ username: u, key }));
|
||||||
|
} catch {
|
||||||
|
// private mode — the login form just stays empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
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" />
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{t("dlg.registerTitle")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{key ? (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<p className="text-center text-sm opacity-80">
|
||||||
|
{t("dlg.regWelcome", { user: username.trim().toLowerCase() })}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{key}</code>
|
||||||
|
<CopyButton text={key} />
|
||||||
|
</div>
|
||||||
|
<Button variant="gradient" size="lg" className="w-full" onClick={goLogin}>
|
||||||
|
{t("dlg.regContinue")} <ArrowRight size={17} />
|
||||||
|
</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>
|
||||||
|
<Field label={t("dlg.emailOpt")}>
|
||||||
|
<Input
|
||||||
|
variant="soft"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-full bg-red-500/10 px-5 py-2.5 text-center text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="gradient"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={busy || !registrationOn}
|
||||||
|
title={registrationOn ? undefined : t("login.regTipOff")}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner /> : (<><UserPlus size={16} /> {t("login.register")}</>)}
|
||||||
|
</Button>
|
||||||
|
{!registrationOn && (
|
||||||
|
<p className="text-center text-sm opacity-60">{t("login.regTipOff")}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={15} />
|
||||||
|
{t("common.backToLogin")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<LangPicker />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, UserPlus, X } from "lucide-react";
|
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react";
|
||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
|
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
|
||||||
@@ -204,91 +204,6 @@ export function FilePreviewModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Self-registration ---------- */
|
|
||||||
|
|
||||||
export function RegisterDialog({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onDone,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onDone: (username: string, key: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useT();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [key, setKey] = useState<string | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setUsername("");
|
|
||||||
setDisplayName("");
|
|
||||||
setEmail("");
|
|
||||||
setKey(null);
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
}, [open ]);
|
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const res = await api.register({
|
|
||||||
username: username.trim().toLowerCase(),
|
|
||||||
displayName: displayName.trim() || undefined,
|
|
||||||
email: email.trim() || undefined,
|
|
||||||
});
|
|
||||||
setKey(res.key);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("dlg.regFailed"));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={open} onClose={onClose} title={t("dlg.registerTitle")} icon={UserPlus}>
|
|
||||||
{key ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm opacity-80">
|
|
||||||
{t("dlg.regWelcome", { user: username.trim().toLowerCase() })}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-sm dark:bg-zinc-800">{key}</code>
|
|
||||||
<CopyButton text={key} />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button size="sm" onClick={() => { onDone(username.trim().toLowerCase(), key); onClose(); }}>{t("dlg.regContinue")}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<Field label={t("common.username")}>
|
|
||||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" maxLength={32} required />
|
|
||||||
</Field>
|
|
||||||
<Field label={t("common.displayName")}>
|
|
||||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" maxLength={60} />
|
|
||||||
</Field>
|
|
||||||
<Field label={t("dlg.emailOpt")}>
|
|
||||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
|
|
||||||
</Field>
|
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
|
|
||||||
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : t("login.register")}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Share ---------- */
|
/* ---------- Share ---------- */
|
||||||
|
|
||||||
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
|
export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClose: () => void }) {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Tiny pathname router (no dependency): /login, /register, /chat, /. */
|
||||||
|
|
||||||
|
export function normalizePath(p: string): string {
|
||||||
|
if (p.length > 1 && p.endsWith("/")) return p.slice(0, -1);
|
||||||
|
return p || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPath(): string {
|
||||||
|
return normalizePath(window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-side navigation without reload (same-origin path only). */
|
||||||
|
export function navigate(to: string, replace = false): void {
|
||||||
|
const dest = normalizePath(to);
|
||||||
|
if (getPath() === dest) return;
|
||||||
|
if (replace) window.history.replaceState(null, "", dest);
|
||||||
|
else window.history.pushState(null, "", dest);
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoute(): string {
|
||||||
|
const [route, setRoute] = useState(getPath);
|
||||||
|
useEffect(() => {
|
||||||
|
const h = () => setRoute(getPath());
|
||||||
|
window.addEventListener("popstate", h);
|
||||||
|
return () => window.removeEventListener("popstate", h);
|
||||||
|
}, []);
|
||||||
|
return route;
|
||||||
|
}
|
||||||
+2
-1
@@ -23,7 +23,8 @@
|
|||||||
"typecheck:frontend": "bun --cwd frontend tsc --noEmit"
|
"typecheck:frontend": "bun --cwd frontend tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nodemailer": "^10.0.10"
|
"nodemailer": "^10.0.10",
|
||||||
|
"puppeteer-core": "^25.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* POC — drive chatgpt.com inside a totally temporary Firefox profile
|
||||||
|
* (puppeteer-core, WebDriver BiDi, headed). The profile is created in
|
||||||
|
* /tmp at launch and deleted on exit; login is done manually in the
|
||||||
|
* window when the script asks for it.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun scripts/poc-chatgpt.ts # send default test message
|
||||||
|
* 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 --probe # just open + report page state
|
||||||
|
* bun scripts/poc-chatgpt.ts --timeout-s 600 # longer response timeout
|
||||||
|
*/
|
||||||
|
import { ChatGPTBrowserEngine } from "../src/drivers/browser.ts";
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE = "Hello! Answer with one short sentence so we know the pipeline works.";
|
||||||
|
|
||||||
|
function flag(name: string): boolean {
|
||||||
|
return process.argv.includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function option(name: string): string | undefined {
|
||||||
|
const i = process.argv.indexOf(name);
|
||||||
|
return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exe = process.env.PUPPETEER_EXECUTABLE ?? "firefox";
|
||||||
|
const executablePath = exe.includes("/") ? exe : Bun.which(exe);
|
||||||
|
if (!executablePath) {
|
||||||
|
console.error("firefox not found — set PUPPETEER_EXECUTABLE to the Firefox binary path");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTimeoutS = Number(option("--timeout-s") ?? 300);
|
||||||
|
if (!Number.isFinite(responseTimeoutS) || responseTimeoutS <= 0) {
|
||||||
|
console.error("--timeout-s must be a positive number of seconds");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new ChatGPTBrowserEngine({
|
||||||
|
executablePath,
|
||||||
|
headless: false,
|
||||||
|
loginTimeoutMs: 600_000,
|
||||||
|
responseTimeoutMs: responseTimeoutS * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let interrupted = 0;
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
interrupted++;
|
||||||
|
if (interrupted > 1) process.exit(130);
|
||||||
|
console.log("\n[interrupt] closing browser and deleting the temporary profile…");
|
||||||
|
void engine.close().finally(() => process.exit(130));
|
||||||
|
});
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
const step = (msg: string) => console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${msg}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (flag("--probe")) {
|
||||||
|
step(`launching Firefox (${executablePath}) with a fresh throwaway profile`);
|
||||||
|
const info = await engine.probe();
|
||||||
|
console.log(JSON.stringify(info, null, 2));
|
||||||
|
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`);
|
||||||
|
let streamed = "";
|
||||||
|
const final = await engine.send(message, {
|
||||||
|
onLoginHint: () => step("login required: log in inside the Firefox window (waiting up to 10 min)"),
|
||||||
|
onToken: (delta) => {
|
||||||
|
streamed += delta;
|
||||||
|
process.stdout.write(delta);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log();
|
||||||
|
if (!streamed.trim()) console.log(final);
|
||||||
|
step(`done — response above (${final.length} chars)`);
|
||||||
|
}
|
||||||
|
process.exitCode = 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`\n[error] ${(e as Error).message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await engine.close();
|
||||||
|
step("browser closed, temporary profile deleted");
|
||||||
|
}
|
||||||
@@ -48,6 +48,14 @@ 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
|
||||||
|
// process, manual login inside the window while it waits).
|
||||||
|
puppeteerEnabled: envBool("PUPPETEER_ENABLED", false),
|
||||||
|
puppeteerExecutable: env("PUPPETEER_EXECUTABLE", "firefox"),
|
||||||
|
puppeteerHeadless: envBool("PUPPETEER_HEADLESS", false),
|
||||||
|
puppeteerLoginTimeoutS: envInt("PUPPETEER_LOGIN_TIMEOUT_S", 300),
|
||||||
|
puppeteerResponseTimeoutS: envInt("PUPPETEER_RESPONSE_TIMEOUT_S", 300),
|
||||||
|
|
||||||
// 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.
|
||||||
wikiUrl: env("WIKI_URL", "https://git.kisakay.com/k/chat/wiki"),
|
wikiUrl: env("WIKI_URL", "https://git.kisakay.com/k/chat/wiki"),
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Browser, Page } from "puppeteer-core";
|
||||||
|
import { driverLog } from "./log.ts";
|
||||||
|
|
||||||
|
export const CHATGPT_URL = "https://chatgpt.com/";
|
||||||
|
|
||||||
|
export const CHATGPT_SELECTORS = {
|
||||||
|
composer: ["#mobile-composer-prompt", "#prompt-textarea", "div[contenteditable='true']"],
|
||||||
|
sendButton: "button[data-composer-submit]",
|
||||||
|
copyButton: "button[data-copy-message]",
|
||||||
|
assistantMarkdown: "div[data-assistant-markdown]",
|
||||||
|
streamBlock: "[data-assistant-stream-block]",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
export interface ChatGPTBrowserOptions {
|
||||||
|
/** Firefox binary (puppeteer-core requires an explicit executable). */
|
||||||
|
executablePath: string;
|
||||||
|
headless: boolean;
|
||||||
|
/** Grace period for a manual login before giving up on the composer. */
|
||||||
|
loginTimeoutMs: number;
|
||||||
|
responseTimeoutMs: number;
|
||||||
|
pollMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatGPTSendCallbacks {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onToken?: (delta: string) => void;
|
||||||
|
onLoginHint?: () => void;
|
||||||
|
/** Start a fresh conversation (goto CHATGPT_URL) before sending. */
|
||||||
|
fresh?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual login grace period expired (no composer on the page). */
|
||||||
|
export class BrowserLoginError extends Error {
|
||||||
|
constructor(timeoutMs: number) {
|
||||||
|
super(
|
||||||
|
`ChatGPT composer not available after ${Math.round(timeoutMs / 1000)}s — login required (run with PUPPETEER_HEADLESS=false and log in inside the window)`,
|
||||||
|
);
|
||||||
|
this.name = "BrowserLoginError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throwaway Firefox session driving chatgpt.com via puppeteer-core (WebDriver
|
||||||
|
* BiDi). Each launch creates a fresh mkdtemp profile that is removed on
|
||||||
|
* close(): zero persistence between runs. One engine = one browser + one tab.
|
||||||
|
*/
|
||||||
|
export class ChatGPTBrowserEngine {
|
||||||
|
private browser: Browser | null = null;
|
||||||
|
private page: Page | null = null;
|
||||||
|
private profileDir: string | null = null;
|
||||||
|
private opening: Promise<void> | null = null;
|
||||||
|
private closed = false;
|
||||||
|
private chain: Promise<unknown> = Promise.resolve();
|
||||||
|
private currentAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly opts: ChatGPTBrowserOptions) {}
|
||||||
|
|
||||||
|
get isOpen(): boolean {
|
||||||
|
return this.browser !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(): Promise<void> {
|
||||||
|
if (this.browser) return;
|
||||||
|
if (this.closed) throw new Error("engine already closed");
|
||||||
|
this.opening ??= this.launch().finally(() => {
|
||||||
|
this.opening = null;
|
||||||
|
});
|
||||||
|
await this.opening;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot state report (used by the POC probe mode). */
|
||||||
|
async probe(): Promise<{ title: string; url: string; composer: string | null }> {
|
||||||
|
await this.open();
|
||||||
|
const page = this.requirePage();
|
||||||
|
await sleep(3000);
|
||||||
|
return { title: await page.title(), url: page.url(), composer: await this.findComposer() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type a prompt into the composer, submit, stream the answer through
|
||||||
|
* onToken and resolve with the final text. Completion = a new
|
||||||
|
* "Copy response" button appearing. Serialized: one send at a time.
|
||||||
|
*/
|
||||||
|
send(prompt: string, cb: ChatGPTSendCallbacks = {}): Promise<string> {
|
||||||
|
const task = this.chain
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => this.doSend(prompt, cb));
|
||||||
|
this.chain = task.catch(() => undefined);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortCurrent(): void {
|
||||||
|
this.currentAbort?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.closed = true;
|
||||||
|
this.abortCurrent();
|
||||||
|
const browser = this.browser;
|
||||||
|
this.browser = null;
|
||||||
|
this.page = null;
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
await browser.close();
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dir = this.profileDir;
|
||||||
|
this.profileDir = null;
|
||||||
|
if (dir) {
|
||||||
|
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
driverLog("chatgpt", `temporary profile removed (${dir})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async launch(): Promise<void> {
|
||||||
|
const puppeteer = await import("puppeteer-core");
|
||||||
|
this.profileDir = await mkdtemp(join(tmpdir(), "ka-chatgpt-"));
|
||||||
|
driverLog("chatgpt", `launching firefox headless=${this.opts.headless} profile=${this.profileDir}`);
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
browser: "firefox",
|
||||||
|
executablePath: this.opts.executablePath,
|
||||||
|
headless: this.opts.headless,
|
||||||
|
userDataDir: this.profileDir,
|
||||||
|
defaultViewport: null,
|
||||||
|
protocolTimeout: 300_000,
|
||||||
|
});
|
||||||
|
browser.once("disconnected", () => {
|
||||||
|
if (this.browser === browser) {
|
||||||
|
this.browser = null;
|
||||||
|
this.page = null;
|
||||||
|
driverLog("chatgpt", "browser disconnected unexpectedly");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.browser = browser;
|
||||||
|
this.page = await browser.newPage();
|
||||||
|
await this.gotoChatGPT(this.page);
|
||||||
|
await this.dismissCookieBanner(this.page, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** chatgpt.com intermittently stalls on domcontentloaded — retry a few times. */
|
||||||
|
private async gotoChatGPT(page: Page): Promise<void> {
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try {
|
||||||
|
await page.goto(CHATGPT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
driverLog("chatgpt", `navigation failed (attempt ${attempt}/3): ${(e as Error).message}`);
|
||||||
|
await sleep(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr instanceof Error ? lastErr : new Error("navigation to chatgpt.com failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fresh profile = cookie consent banner on first load. It swallows input
|
||||||
|
* until "Accept all" (button[type=submit], text) is clicked.
|
||||||
|
*/
|
||||||
|
private async dismissCookieBanner(page: Page, waitMs: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + waitMs;
|
||||||
|
for (;;) {
|
||||||
|
const clicked = await page
|
||||||
|
.evaluate(() => {
|
||||||
|
const btn = Array.from(document.querySelectorAll("button")).find((b) => {
|
||||||
|
const t = (b.textContent ?? "").trim().toLowerCase();
|
||||||
|
return (t === "accept all" || t === "accept") && (b as HTMLElement).getClientRects().length > 0;
|
||||||
|
});
|
||||||
|
if (!btn) return false;
|
||||||
|
(btn as HTMLElement).click();
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch(() => false);
|
||||||
|
if (clicked) {
|
||||||
|
driverLog("chatgpt", "cookie banner dismissed (Accept all)");
|
||||||
|
await sleep(800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) return;
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSend(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
|
||||||
|
if (this.closed) throw new Error("engine already closed");
|
||||||
|
const pollMs = this.opts.pollMs ?? 250;
|
||||||
|
const ac = new AbortController();
|
||||||
|
this.currentAbort = ac;
|
||||||
|
const forward = () => ac.abort();
|
||||||
|
cb.signal?.addEventListener("abort", forward);
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
await this.open();
|
||||||
|
const page = this.requirePage();
|
||||||
|
if (cb.fresh) {
|
||||||
|
await this.gotoChatGPT(page);
|
||||||
|
await this.dismissCookieBanner(page, 1_500);
|
||||||
|
}
|
||||||
|
const composer = await this.waitForComposer(cb.onLoginHint);
|
||||||
|
const copyBefore = await this.countCopyButtons();
|
||||||
|
await this.typeIntoComposer(page, composer, prompt);
|
||||||
|
await this.waitSendable(page);
|
||||||
|
await page.click(CHATGPT_SELECTORS.sendButton);
|
||||||
|
driverLog("chatgpt", `sent ${prompt.length} chars via ${composer}, waiting for response`);
|
||||||
|
let last = "";
|
||||||
|
const deadline = Date.now() + this.opts.responseTimeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
if (ac.signal.aborted) {
|
||||||
|
await this.stopGenerating();
|
||||||
|
throw new DOMException("ChatGPT browser request aborted", "AbortError");
|
||||||
|
}
|
||||||
|
const text = await this.readAssistantText();
|
||||||
|
if (text.startsWith(last) && text.length > last.length) {
|
||||||
|
const delta = text.slice(last.length);
|
||||||
|
last = text;
|
||||||
|
cb.onToken?.(delta);
|
||||||
|
}
|
||||||
|
if ((await this.countCopyButtons()) > copyBefore) {
|
||||||
|
const finalText = await this.readAssistantText();
|
||||||
|
if (finalText.length > last.length) cb.onToken?.(finalText.slice(last.length));
|
||||||
|
const out = finalText.length ? finalText : last;
|
||||||
|
driverLog("chatgpt", `response complete: ${out.length} chars in ${fmtS(Date.now() - t0)}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
await this.stopGenerating();
|
||||||
|
throw new Error(`ChatGPT response timed out after ${Math.round(this.opts.responseTimeoutMs / 1000)}s`);
|
||||||
|
}
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cb.signal?.removeEventListener("abort", forward);
|
||||||
|
if (this.currentAbort === ac) this.currentAbort = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForComposer(hint?: () => void): Promise<string> {
|
||||||
|
const page = this.requirePage();
|
||||||
|
const deadline = Date.now() + this.opts.loginTimeoutMs;
|
||||||
|
let hinted = false;
|
||||||
|
for (;;) {
|
||||||
|
const sel = await this.findComposer();
|
||||||
|
if (sel) return sel;
|
||||||
|
if (!hinted) {
|
||||||
|
hinted = true;
|
||||||
|
hint?.();
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) throw new BrowserLoginError(this.opts.loginTimeoutMs);
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findComposer(): Promise<string | null> {
|
||||||
|
const page = this.page;
|
||||||
|
if (!page) return null;
|
||||||
|
for (const sel of CHATGPT_SELECTORS.composer) {
|
||||||
|
try {
|
||||||
|
if (await page.$(sel)) return sel;
|
||||||
|
} catch {
|
||||||
|
// navigation in flight — retry on next poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async typeIntoComposer(page: Page, sel: string, text: string): Promise<void> {
|
||||||
|
const input = await page.$(sel);
|
||||||
|
if (!input) throw new Error(`composer ${sel} not found`);
|
||||||
|
await input.focus();
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i]) await page.keyboard.type(lines[i], { delay: 4 });
|
||||||
|
if (i < lines.length - 1) {
|
||||||
|
await page.keyboard.down("Shift");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.keyboard.up("Shift");
|
||||||
|
await sleep(30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitSendable(page: Page): Promise<void> {
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
for (;;) {
|
||||||
|
const ok = await page
|
||||||
|
.$eval(CHATGPT_SELECTORS.sendButton, (el) => {
|
||||||
|
const b = el as HTMLButtonElement;
|
||||||
|
return !b.disabled && b.getAttribute("aria-disabled") !== "true";
|
||||||
|
})
|
||||||
|
.catch(() => false);
|
||||||
|
if (ok) return;
|
||||||
|
if (Date.now() > deadline) throw new Error("ChatGPT send button never became clickable");
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopGenerating(): Promise<void> {
|
||||||
|
const page = this.page;
|
||||||
|
if (!page) return;
|
||||||
|
try {
|
||||||
|
await page.click(CHATGPT_SELECTORS.sendButton);
|
||||||
|
} catch {
|
||||||
|
// button already back in send state — nothing to stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countCopyButtons(): Promise<number> {
|
||||||
|
const page = this.page;
|
||||||
|
if (!page) return 0;
|
||||||
|
try {
|
||||||
|
return await page.$$eval(CHATGPT_SELECTORS.copyButton, (els) =>
|
||||||
|
els.filter((el) => (el as HTMLElement).getClientRects().length > 0).length,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAssistantText(): Promise<string> {
|
||||||
|
const page = this.page;
|
||||||
|
if (!page) return "";
|
||||||
|
try {
|
||||||
|
return await page.$$eval(
|
||||||
|
CHATGPT_SELECTORS.assistantMarkdown,
|
||||||
|
(els, blockSel: string) => {
|
||||||
|
const last = els[els.length - 1];
|
||||||
|
if (!last) return "";
|
||||||
|
const blocks = Array.from(last.querySelectorAll(blockSel));
|
||||||
|
if (blocks.length === 0) return (last as HTMLElement).innerText;
|
||||||
|
return blocks
|
||||||
|
.map((b) => (b as HTMLElement).innerText.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
},
|
||||||
|
CHATGPT_SELECTORS.streamBlock,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requirePage(): Page {
|
||||||
|
if (!this.page) throw new Error("browser session is not open");
|
||||||
|
return this.page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtS(ms: number): string {
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
+149
-13
@@ -1,27 +1,163 @@
|
|||||||
|
import { config } from "../config.ts";
|
||||||
|
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
|
||||||
|
import { ChatGPTBrowserEngine } 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";
|
||||||
|
|
||||||
|
const MODELS = ["chatgpt"];
|
||||||
|
|
||||||
|
function resolveExecutable(): string {
|
||||||
|
const conf = config.puppeteerExecutable;
|
||||||
|
if (conf.includes("/")) return conf;
|
||||||
|
const found = Bun.which(conf);
|
||||||
|
if (!found) throw new Error(`browser executable "${conf}" not found in PATH — set PUPPETEER_EXECUTABLE`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puppeteer/headless-browser driver stub (scrapes chatgpt.com).
|
* Flatten a KisAssistant conversation into one prompt for the ChatGPT web
|
||||||
* Intentionally NOT implemented: fragile, ToS-risky, needs a real
|
* composer: system text as a preamble, earlier turns as a transcript, and
|
||||||
* account session. Kept as a stub so the Driver interface reserves
|
* the latest user message sent as-is.
|
||||||
* its slot at global priority #3.
|
*/
|
||||||
|
export function flattenMessages(messages: ChatMessage[]): string {
|
||||||
|
const system = messages
|
||||||
|
.filter((m) => m.role === "system")
|
||||||
|
.map((m) => m.content)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
const rest = messages.filter((m) => m.role !== "system");
|
||||||
|
const last = rest[rest.length - 1];
|
||||||
|
const earlier = rest.slice(0, -1);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (system) parts.push(`[Instructions]\n${system}\n`);
|
||||||
|
if (earlier.length) {
|
||||||
|
parts.push("[Conversation so far]");
|
||||||
|
for (const m of earlier) parts.push(`${m.role === "user" ? "User" : "Assistant"}: ${m.content}`);
|
||||||
|
parts.push("[End of conversation so far]\n");
|
||||||
|
}
|
||||||
|
if (last) parts.push(last.content);
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buffers tokens produced by a push-style callback into a pull-style iterator. */
|
||||||
|
class AsyncTokenQueue implements AsyncIterable<string> {
|
||||||
|
private buf: string[] = [];
|
||||||
|
private error: Error | null = null;
|
||||||
|
private ended = false;
|
||||||
|
private waiters: Array<(r: IteratorResult<string> | null) => void> = [];
|
||||||
|
|
||||||
|
push(token: string): void {
|
||||||
|
const w = this.waiters.shift();
|
||||||
|
if (w) w({ value: token, done: false });
|
||||||
|
else this.buf.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
end(err?: Error): void {
|
||||||
|
if (err) this.error = err;
|
||||||
|
this.ended = true;
|
||||||
|
for (const w of this.waiters.splice(0)) w(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async next(): Promise<IteratorResult<string>> {
|
||||||
|
for (;;) {
|
||||||
|
if (this.buf.length) return { value: this.buf.shift() as string, done: false };
|
||||||
|
if (this.ended) {
|
||||||
|
if (this.error) throw this.error;
|
||||||
|
return { value: undefined, done: true };
|
||||||
|
}
|
||||||
|
const wake = await new Promise<IteratorResult<string> | null>((resolve) => this.waiters.push(resolve));
|
||||||
|
if (wake) return wake;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncIterator](): AsyncIterator<string> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatGPT via the web UI: throwaway Firefox profile driven by
|
||||||
|
* puppeteer-core (WebDriver BiDi). ToS-risky and fragile by nature —
|
||||||
|
* disabled unless PUPPETEER_ENABLED=true. Login is manual for now: the
|
||||||
|
* first request opens a window and waits for the composer to appear.
|
||||||
*/
|
*/
|
||||||
export class PuppeteerOpenAIDriver implements LLMDriver {
|
export class PuppeteerOpenAIDriver implements LLMDriver {
|
||||||
readonly name = "puppeteer-openai";
|
readonly name = "puppeteer-openai";
|
||||||
readonly enabled = false;
|
readonly enabled = config.puppeteerEnabled;
|
||||||
|
private engine: ChatGPTBrowserEngine | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (this.enabled) driverLog(this.name, "enabled (chatgpt.com via Firefox, throwaway profile, manual login)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureEnabled(): void {
|
||||||
|
if (!this.enabled) throw new DriverDisabledError(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEngine(): ChatGPTBrowserEngine {
|
||||||
|
this.ensureEnabled();
|
||||||
|
if (!this.engine) {
|
||||||
|
this.engine = new ChatGPTBrowserEngine({
|
||||||
|
executablePath: resolveExecutable(),
|
||||||
|
headless: config.puppeteerHeadless,
|
||||||
|
loginTimeoutMs: config.puppeteerLoginTimeoutS * 1000,
|
||||||
|
responseTimeoutMs: config.puppeteerResponseTimeoutS * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.engine;
|
||||||
|
}
|
||||||
|
|
||||||
async listModels(): Promise<DriverModel[]> {
|
async listModels(): Promise<DriverModel[]> {
|
||||||
return [];
|
this.ensureEnabled();
|
||||||
|
return MODELS.map((m) => ({ id: `${this.name}:${m}`, name: m, driver: this.name, label: "ChatGPT (web)" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async chat(_messages: ChatMessage[], _opts: ChatOptions): Promise<string> {
|
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
|
||||||
throw new Error(
|
this.ensureEnabled();
|
||||||
'driver "puppeteer-openai" is a stub and is not implemented (headless scraping of chatgpt.com is fragile and against ToS; use ollama or official APIs instead)',
|
const t0 = Date.now();
|
||||||
|
const text = await this.getEngine().send(flattenMessages(messages), {
|
||||||
|
signal: opts.signal,
|
||||||
|
fresh: true,
|
||||||
|
onLoginHint: () => driverLog(this.name, "login required — complete it inside the browser window"),
|
||||||
|
});
|
||||||
|
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(text.length)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
|
||||||
|
this.ensureEnabled();
|
||||||
|
const t0 = Date.now();
|
||||||
|
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
|
||||||
|
const queue = new AsyncTokenQueue();
|
||||||
|
let chars = 0;
|
||||||
|
let jobDone = false;
|
||||||
|
const job = this.getEngine()
|
||||||
|
.send(flattenMessages(messages), {
|
||||||
|
signal: opts.signal,
|
||||||
|
fresh: true,
|
||||||
|
onLoginHint: () => driverLog(this.name, "login required — complete it inside the browser window"),
|
||||||
|
onToken: (tok) => {
|
||||||
|
chars += tok.length;
|
||||||
|
opts.onToken?.(tok);
|
||||||
|
queue.push(tok);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
jobDone = true;
|
||||||
|
queue.end();
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
jobDone = true;
|
||||||
|
queue.end(e as Error);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
yield* queue;
|
||||||
|
} finally {
|
||||||
|
if (!jobDone) this.getEngine().abortCurrent();
|
||||||
|
void job;
|
||||||
}
|
}
|
||||||
|
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
|
||||||
async *chatStream(): AsyncGenerator<string, void, void> {
|
|
||||||
throw new Error('driver "puppeteer-openai" is a stub and is not implemented');
|
|
||||||
void 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,5 +10,5 @@
|
|||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"types": ["bun"]
|
"types": ["bun"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "scripts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user