Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608fb37413 | ||
|
|
ae59c2731d | ||
|
|
6b6df4247f | ||
|
|
83c52a06dd | ||
|
|
ac165a2db4 | ||
|
|
89228719b5 |
No files matched your search
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Run ESLint with warnings as errors before pushing a merge request
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ESLint before push
|
||||
|
||||
Beta deploy runs `npm run build:beta` with `CI=true`. Create React App turns ESLint warnings into build errors. Unit tests do not catch this. The failure shows up only on the deploy runner (`ai-server-4080`), where `CI=true` is set.
|
||||
|
||||
Before `git push` or opening a pull request that changes `llm-fe`:
|
||||
|
||||
1. From `llm-fe`, lint every changed file under `src` with warnings as errors:
|
||||
|
||||
```bash
|
||||
CI=true ./node_modules/.bin/eslint --max-warnings 0 path/to/ChangedFile.tsx
|
||||
```
|
||||
|
||||
2. Fix every warning in those files. Do not push while that command exits non-zero.
|
||||
3. Do not set `DISABLE_ESLINT_PLUGIN` or `CI=false` to get the deploy through.
|
||||
|
||||
A full `eslint src` run reports warnings in files the production build does not compile. Those do not fail deploy. The gate is warnings in files the build compiles.
|
||||
|
||||
`react-hooks/exhaustive-deps` is the usual failure. Wrap the function in `useCallback` and list it in the dependency array. An empty dependency array that calls a component function fails the beta build.
|
||||
@@ -36,61 +36,3 @@ jobs:
|
||||
--app chat_web_app \
|
||||
--env prod \
|
||||
--ref "${{ gitea.sha }}"
|
||||
|
||||
# Same commit the workflow was started from (the branch picked in Run workflow).
|
||||
android-release:
|
||||
needs: deploy-prod
|
||||
runs-on: self-hosted
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: llm-fe
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.sha }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Resolve Play version
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
run: node scripts/android-version.mjs
|
||||
|
||||
- name: Build store web bundle
|
||||
run: npm run build:android:store
|
||||
|
||||
- name: Sign with the Play upload key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f /home/westfarn/.android/hesychia-keystore.properties
|
||||
test -f /home/westfarn/.android/hesychia-upload.jks
|
||||
test -d /home/westfarn/Android/Sdk
|
||||
ln -sfn /home/westfarn/.android/hesychia-keystore.properties android/keystore.properties
|
||||
printf 'sdk.dir=%s\n' /home/westfarn/Android/Sdk > android/local.properties
|
||||
|
||||
- name: Package signed app bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd android
|
||||
chmod +x ./gradlew
|
||||
./gradlew bundleRelease \
|
||||
-PhesychiaVersionCode="$HESYCHIA_VERSION_CODE" \
|
||||
-PhesychiaVersionName="$HESYCHIA_VERSION_NAME"
|
||||
jarsigner -verify app/build/outputs/bundle/release/app-release.aab >/tmp/jarsigner-aab.txt 2>&1
|
||||
echo "jar verified"
|
||||
|
||||
- name: Publish Gitea release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||
run: node scripts/publish-android-gitea-release.mjs
|
||||
+8
-49
@@ -50,37 +50,12 @@ After plugin changes: `npm run build:mobile` (runs `cap sync`).
|
||||
|
||||
## Versioning
|
||||
|
||||
`android/app/build.gradle` keeps a floor:
|
||||
In `android/app/build.gradle`:
|
||||
|
||||
- `hesychiaVersionNameFloor` — `"0.1.0"` (user-visible string)
|
||||
- `hesychiaVersionCodeFloor` — `1` (integer; Play requires a higher code on every upload)
|
||||
- **versionName** — user-visible string; keep aligned with `package.json` `version` (currently `0.1.0`)
|
||||
- **versionCode** — integer; **must increase** for every Play Store upload
|
||||
|
||||
Local `./gradlew bundleRelease` uses that floor when you do not pass properties. **Deploy Prod** does not edit the file. After the web deploy it looks up existing `android-v*` Gitea releases, reuses the pair when the tag already points at this commit, otherwise ships the next patch and the next `versionCode` via `-PhesychiaVersionName` / `-PhesychiaVersionCode`.
|
||||
|
||||
Raise the floor in git only when you want the next automatic release to jump ahead of older tags.
|
||||
|
||||
## Deploy Prod publishes the Play bundle
|
||||
|
||||
**Deploy Prod** (`.gitea/workflows/deploy-prod.yml`) runs `android-release` after the prod web deploy of the same commit. The job:
|
||||
|
||||
1. Resolves the Play version (`llm-fe/scripts/android-version.mjs`)
|
||||
2. `npm run build:android:store` — `.env.mobile` (prod API) plus `cap sync android`
|
||||
3. Signs with the upload keystore on the runner (`~/.android/`, not in git)
|
||||
4. `jarsigner -verify` on `app-release.aab`
|
||||
5. Creates or reuses a Gitea release tagged `android-v<versionName>-<versionCode>` and attaches `hesychia-<versionName>-<versionCode>.aab`
|
||||
|
||||
Example of the same shape on RealPath: [android-v1.0.2-3](https://git.aimloperations.com/Ditch_The_Agent/dta_webapp/releases/tag/android-v1.0.2-3).
|
||||
|
||||
Download that AAB from the Gitea release and upload it to the Play **internal** track. Deploy Prod does not call the Play Developer API. A separate **Deploy Android** workflow is not used ([#58](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/58) is superseded by this job, [#121](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/121)).
|
||||
|
||||
Manual store binary (same web bundle the job builds):
|
||||
|
||||
```bash
|
||||
cd llm-fe
|
||||
npm run build:android:store
|
||||
cd android
|
||||
./gradlew bundleRelease
|
||||
```
|
||||
Bump both before each store release.
|
||||
|
||||
## Icons / splash
|
||||
|
||||
@@ -101,39 +76,23 @@ Same command also regenerates iOS `Assets.xcassets` (see [`IOS.md`](IOS.md)).
|
||||
|
||||
## Signing (upload keystore)
|
||||
|
||||
**Never commit keystores or `keystore.properties`.** Passwords stay on the runner, not in Gitea Actions secrets.
|
||||
|
||||
Deploy Prod expects these host files (separate from RealPath’s `keystore.properties`, so the two apps do not share one symlink target):
|
||||
|
||||
```text
|
||||
/home/westfarn/.android/hesychia-upload.jks
|
||||
/home/westfarn/.android/hesychia-keystore.properties # mode 600
|
||||
```
|
||||
|
||||
Create the upload key **once**. If Play already has an upload key for `ai.hesychia.chat`, reuse that keystore. A second keystore will not upload.
|
||||
**Never commit keystores.** Store outside the repo (e.g. host secrets / password manager).
|
||||
|
||||
```bash
|
||||
keytool -genkey -v \
|
||||
-keystore /home/westfarn/.android/hesychia-upload.jks \
|
||||
keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias aiml-chat-upload
|
||||
```
|
||||
|
||||
`hesychia-keystore.properties` (`storeFile` may be absolute):
|
||||
Create `llm-fe/android/keystore.properties` (gitignored):
|
||||
|
||||
```properties
|
||||
storeFile=/home/westfarn/.android/hesychia-upload.jks
|
||||
storeFile=/absolute/path/to/aiml-chat-upload.jks
|
||||
storePassword=...
|
||||
keyAlias=aiml-chat-upload
|
||||
keyPassword=...
|
||||
```
|
||||
|
||||
The workflow symlinks that file to `llm-fe/android/keystore.properties` and writes `android/local.properties` with `sdk.dir=/home/westfarn/Android/Sdk`. For a local release build, put the same `keystore.properties` in `llm-fe/android/` (gitignored).
|
||||
|
||||
Also on the runner: JDK 17+ and Android SDK Platform 35 at `/home/westfarn/Android/Sdk`.
|
||||
|
||||
Gitea Actions secret on `chat_web_app`: `GITHUB_TOKEN` (repo release write). The workflow maps it to `GITEA_TOKEN` for version lookup and release upload. Purchase verification uses backend `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`, not this keystore.
|
||||
|
||||
Release App Bundle:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,8 +19,7 @@ From the **npm root** (`llm-fe/`, one level up):
|
||||
```bash
|
||||
cd ..
|
||||
npm ci
|
||||
npm run build:android:store # Play binary: .env.mobile → build → cap sync android
|
||||
# npm run build:mobile # same web env, also syncs iOS
|
||||
npm run build:mobile # .env.mobile → react-scripts build → cap sync android
|
||||
```
|
||||
|
||||
Open this project in Android Studio:
|
||||
@@ -55,11 +54,11 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
## Version before a store release
|
||||
|
||||
`app/build.gradle` stores the floor (`hesychiaVersionNameFloor` / `hesychiaVersionCodeFloor`). A local `bundleRelease` uses that floor. **Deploy Prod** passes the next pair with `-PhesychiaVersionName` and `-PhesychiaVersionCode` after reading `android-v*` Gitea releases, so a prod run does not need a version commit.
|
||||
Edit `app/build.gradle` → `defaultConfig`:
|
||||
|
||||
| Field | Rule |
|
||||
|-------|------|
|
||||
| `versionName` | User-visible `major.minor.patch` |
|
||||
| `versionName` | User-visible string; keep in sync with `../package.json` `version` |
|
||||
| `versionCode` | Integer; **must increase** for every Play upload |
|
||||
|
||||
## Sign a release build
|
||||
@@ -69,17 +68,15 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
1. Create an upload keystore (once), outside the repo:
|
||||
|
||||
```bash
|
||||
keytool -genkey -v -keystore /home/westfarn/.android/hesychia-upload.jks \
|
||||
keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias aiml-chat-upload
|
||||
```
|
||||
|
||||
Reuse an existing Play upload key. Do not generate a second keystore for an app that already has one.
|
||||
|
||||
2. Create `keystore.properties` in **this** directory (`android/`, gitignored). On the Deploy Prod runner the file is `/home/westfarn/.android/hesychia-keystore.properties` (not RealPath’s `keystore.properties`):
|
||||
2. Create `keystore.properties` in **this** directory (`android/`, gitignored):
|
||||
|
||||
```properties
|
||||
storeFile=/home/westfarn/.android/hesychia-upload.jks
|
||||
storeFile=/absolute/path/to/aiml-chat-upload.jks
|
||||
storePassword=REPLACE_ME
|
||||
keyAlias=aiml-chat-upload
|
||||
keyPassword=REPLACE_ME
|
||||
@@ -108,8 +105,6 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
|
||||
|
||||
## Deploy to Google Play
|
||||
|
||||
**Deploy Prod** builds the signed AAB and attaches it to a Gitea release (`android-v<versionName>-<versionCode>`, asset `hesychia-<versionName>-<versionCode>.aab`). Download that asset and upload it yourself. See [`../ANDROID.md`](../ANDROID.md).
|
||||
|
||||
1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`).
|
||||
2. Complete store listing: privacy policy URL, data-safety form, screenshots.
|
||||
3. Set **Account deletion URL** to the public instructions page (no sign-in required):
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
// Last bundle recorded in git. Prod deploy passes -PhesychiaVersionCode and
|
||||
// -PhesychiaVersionName; these stay the floor when those properties are absent.
|
||||
def hesychiaVersionCodeFloor = 1
|
||||
def hesychiaVersionNameFloor = "0.1.0"
|
||||
|
||||
// Resolve before the android block. A parenthesized expression on the versionCode
|
||||
// setter is parsed as a cast of the setter result, so AGP throws "Value is null".
|
||||
def hesychiaVersionCodeProp = project.findProperty('hesychiaVersionCode')?.toString()?.trim()
|
||||
def hesychiaVersionNameProp = project.findProperty('hesychiaVersionName')?.toString()?.trim()
|
||||
def resolvedVersionCode = hesychiaVersionCodeProp ? hesychiaVersionCodeProp.toInteger() : hesychiaVersionCodeFloor
|
||||
def resolvedVersionName = hesychiaVersionNameProp ? hesychiaVersionNameProp : hesychiaVersionNameFloor
|
||||
|
||||
def keystorePropertiesFile = rootProject.file('keystore.properties')
|
||||
def keystoreProperties = new Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
@@ -25,8 +13,8 @@ android {
|
||||
applicationId "ai.hesychia.chat"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode resolvedVersionCode
|
||||
versionName resolvedVersionName
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -39,7 +27,7 @@ android {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-37
@@ -64,11 +64,9 @@
|
||||
"@types/lodash": "~4.17.13",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
},
|
||||
@@ -6540,15 +6538,6 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-google-recaptcha": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
|
||||
"integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
@@ -19090,19 +19079,6 @@
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
|
||||
},
|
||||
"node_modules/react-async-script": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
|
||||
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"prop-types": "^15.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-bootstrap": {
|
||||
"version": "2.10.9",
|
||||
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz",
|
||||
@@ -19301,19 +19277,6 @@
|
||||
"react": ">=16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-google-recaptcha": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
|
||||
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.0",
|
||||
"react-async-script": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
|
||||
|
||||
+1
-4
@@ -58,14 +58,13 @@
|
||||
"build:prod": "node scripts/writeSitemap.js && NODE_ENV=production react-scripts build && node scripts/stampCanonicals.js && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/ && node scripts/pingIndexNow.js",
|
||||
"build:beta": "node scripts/writeSitemap.js && bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && node scripts/stampCanonicals.js && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/",
|
||||
"build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync",
|
||||
"build:android:store": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync android",
|
||||
"android:open": "npx cap open android",
|
||||
"android:sync": "npx cap sync android",
|
||||
"ios:open": "npx cap open ios",
|
||||
"ios:sync": "npx cap sync ios",
|
||||
"assets:generate": "npx capacitor-assets generate --ios --android",
|
||||
"test": "NODE_ENV=development react-scripts test",
|
||||
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false && node --test scripts/stampCanonicals.test.js scripts/sitemap.test.js scripts/android-version.test.mjs",
|
||||
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false && node --test scripts/stampCanonicals.test.js scripts/sitemap.test.js",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
@@ -93,11 +92,9 @@
|
||||
"@types/lodash": "~4.17.13",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"><h1>Hesychia is a calm AI assistant.</h1><div id="crawler-prose"><p>Quiet, focused AI chat — private by default — is how this Hesychia product is built.</p><p>It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.</p><p>We store your account, your conversations, and the documents you upload.</p><p>We do not sell your data, and we do not use advertising cookies.</p><p>Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.</p><p>It remembers what you told it and your documents, so you never start over.</p><p>Your documents stay stored and searchable, and answers are grounded in your own files.</p><p>Business accounts are simple management for your team, and company managers can manage team accounts.</p><p>Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.</p><p>Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.</p><p>Vision is on the roadmap, so you can ask questions about an image you upload.</p></div><nav aria-label="Related pages"><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a><a href="/terms_of_service/">Hesychia terms of service</a><a href="/account-deletion/">How to request Hesychia account deletion</a></nav></div>
|
||||
<div id="root"><h1>Hesychia is a calm AI assistant.</h1><div id="crawler-prose"><p>Quiet, focused AI chat — private by default — is how this Hesychia product is built.</p><p>It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.</p><p>We store your account, your conversations, and the documents you upload.</p><p>We do not sell your data, and we do not use advertising cookies.</p><p>Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.</p><p>It remembers what you told it and your documents, so you never start over.</p><p>Your documents stay stored and searchable, and answers are grounded in your own files.</p><p>Business accounts are simple management for your team, and company managers can manage team accounts.</p><p>Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.</p><p>Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.</p><p>Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.</p></div><nav aria-label="Related pages"><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a><a href="/terms_of_service/">Hesychia terms of service</a><a href="/account-deletion/">How to request Hesychia account deletion</a></nav></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Hesychia is a calm AI assistant with quiet, focused AI chat that stays private by default.
|
||||
|
||||
Hesychia is a $10/month AI chat product from AI ML Operations, LLC. Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up. It stores your documents so answers can use your files, and company managers can manage team accounts. Conversation memory is a setting you control, and canceling a subscription does not delete documents. Account deletion is a soft-delete, and stored account data may remain. Image generation and vision are on the roadmap.
|
||||
Hesychia is a $10/month AI chat product from AI ML Operations, LLC. Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up. It stores your documents so answers can use your files, and company managers can manage team accounts. Conversation memory is a setting you control, and canceling a subscription does not delete documents. Account deletion is a soft-delete, and stored account data may remain. Image generation is on the roadmap. Upload an image with a prompt and Hesychia reads the picture.
|
||||
|
||||
## Pages
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { appendFileSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const ANDROID_TAG = /^android-v(\d+\.\d+\.\d+)-(\d+)$/;
|
||||
|
||||
export function parseAndroidTag(tag) {
|
||||
const match = ANDROID_TAG.exec(tag || '');
|
||||
if (!match) return null;
|
||||
return { versionName: match[1], versionCode: Number(match[2]) };
|
||||
}
|
||||
|
||||
export function bumpPatch(versionName) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(versionName);
|
||||
if (!match) {
|
||||
throw new Error(`versionName must be major.minor.patch, got ${versionName}`);
|
||||
}
|
||||
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
|
||||
}
|
||||
|
||||
export function readBaseline(gradle) {
|
||||
const versionCode = Number(gradle.match(/hesychiaVersionCodeFloor\s*=\s*(\d+)/)?.[1]);
|
||||
const versionName = gradle.match(/hesychiaVersionNameFloor\s*=\s*"([^"]+)"/)?.[1];
|
||||
if (!Number.isInteger(versionCode) || !versionName) {
|
||||
throw new Error(
|
||||
'Could not read hesychiaVersionCodeFloor/hesychiaVersionNameFloor from build.gradle',
|
||||
);
|
||||
}
|
||||
return { versionCode, versionName };
|
||||
}
|
||||
|
||||
export function nextAndroidVersion({ baseline, releases, sha }) {
|
||||
const existing = releases.find((release) => release.sha === sha);
|
||||
if (existing) {
|
||||
return {
|
||||
versionName: existing.versionName,
|
||||
versionCode: existing.versionCode,
|
||||
reused: true,
|
||||
};
|
||||
}
|
||||
|
||||
const highest = releases.reduce((best, release) => {
|
||||
if (!best || release.versionCode > best.versionCode) return release;
|
||||
return best;
|
||||
}, null);
|
||||
|
||||
const codeBase = Math.max(baseline.versionCode, highest?.versionCode ?? baseline.versionCode);
|
||||
const nameBase =
|
||||
highest && highest.versionCode >= baseline.versionCode
|
||||
? highest.versionName
|
||||
: baseline.versionName;
|
||||
|
||||
return {
|
||||
versionName: bumpPatch(nameBase),
|
||||
versionCode: codeBase + 1,
|
||||
reused: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAndroidReleases({ apiUrl, token, owner, repo, fetchImpl = fetch }) {
|
||||
const releases = [];
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const response = await fetchImpl(
|
||||
`${apiUrl}/repos/${owner}/${repo}/releases?limit=50&page=${page}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`List releases failed (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const batch = Array.isArray(payload) ? payload : [];
|
||||
for (const release of batch) {
|
||||
const parsed = parseAndroidTag(release.tag_name);
|
||||
if (!parsed) continue;
|
||||
releases.push({ ...parsed, sha: release.target_commitish });
|
||||
}
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
function releaseEnv() {
|
||||
const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN;
|
||||
const repository = process.env.GITHUB_REPOSITORY || process.env.GITEA_REPOSITORY;
|
||||
const sha = process.env.GITEA_SHA || process.env.GITHUB_SHA;
|
||||
const apiUrl = (
|
||||
process.env.GITHUB_API_URL ||
|
||||
process.env.GITEA_API_URL ||
|
||||
(process.env.GITHUB_SERVER_URL
|
||||
? `${process.env.GITHUB_SERVER_URL.replace(/\/$/, '')}/api/v1`
|
||||
: '')
|
||||
).replace(/\/$/, '');
|
||||
|
||||
if (!token || !repository || !sha || !apiUrl) {
|
||||
throw new Error('Missing Gitea token, repository, sha, or API URL for the Play version lookup');
|
||||
}
|
||||
const [owner, repo] = repository.split('/');
|
||||
if (!owner || !repo) {
|
||||
throw new Error(`Repository must be owner/repo, got ${repository}`);
|
||||
}
|
||||
return { token, owner, repo, sha, apiUrl };
|
||||
}
|
||||
|
||||
export async function resolveAndroidVersion() {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const gradle = readFileSync(join(root, 'android', 'app', 'build.gradle'), 'utf8');
|
||||
const baseline = readBaseline(gradle);
|
||||
const { token, owner, repo, sha, apiUrl } = releaseEnv();
|
||||
const releases = await listAndroidReleases({ apiUrl, token, owner, repo });
|
||||
return nextAndroidVersion({ baseline, releases, sha });
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
resolveAndroidVersion()
|
||||
.then((next) => {
|
||||
const lines = [
|
||||
`HESYCHIA_VERSION_NAME=${next.versionName}`,
|
||||
`HESYCHIA_VERSION_CODE=${next.versionCode}`,
|
||||
];
|
||||
if (process.env.GITHUB_ENV) {
|
||||
appendFileSync(process.env.GITHUB_ENV, `${lines.join('\n')}\n`);
|
||||
}
|
||||
console.log(lines.join('\n'));
|
||||
const action = next.reused ? 'Reusing' : 'Next Play version is';
|
||||
console.error(`${action} ${next.versionName} (${next.versionCode})`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bumpPatch,
|
||||
listAndroidReleases,
|
||||
nextAndroidVersion,
|
||||
parseAndroidTag,
|
||||
readBaseline,
|
||||
} from './android-version.mjs';
|
||||
|
||||
const gradle = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), '../android/app/build.gradle'),
|
||||
'utf8',
|
||||
);
|
||||
const floor = { versionName: '0.1.0', versionCode: 1 };
|
||||
|
||||
describe('android version', () => {
|
||||
it('reads the floor still written in build.gradle', () => {
|
||||
assert.deepEqual(readBaseline(gradle), floor);
|
||||
});
|
||||
|
||||
it('assigns versionCode from a resolved int, not a parenthesized cast', () => {
|
||||
assert.match(gradle, /versionCode resolvedVersionCode/);
|
||||
assert.doesNotMatch(gradle, /versionCode\s*\(/);
|
||||
});
|
||||
|
||||
it('parses Play release tags and ignores other tags', () => {
|
||||
assert.deepEqual(parseAndroidTag('android-v1.0.2-3'), {
|
||||
versionName: '1.0.2',
|
||||
versionCode: 3,
|
||||
});
|
||||
assert.equal(parseAndroidTag('web-v1.0.2'), null);
|
||||
});
|
||||
|
||||
it('bumps the patch', () => {
|
||||
assert.equal(bumpPatch('0.1.0'), '0.1.1');
|
||||
assert.equal(bumpPatch('1.0.9'), '1.0.10');
|
||||
});
|
||||
|
||||
it('steps past the floor when nothing has been released yet', () => {
|
||||
assert.deepEqual(
|
||||
nextAndroidVersion({ baseline: floor, releases: [], sha: 'abc' }),
|
||||
{
|
||||
versionName: '0.1.1',
|
||||
versionCode: 2,
|
||||
reused: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the release that already points at this commit', () => {
|
||||
assert.deepEqual(
|
||||
nextAndroidVersion({
|
||||
baseline: floor,
|
||||
sha: 'abc',
|
||||
releases: [{ versionName: '0.1.1', versionCode: 2, sha: 'abc' }],
|
||||
}),
|
||||
{ versionName: '0.1.1', versionCode: 2, reused: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('bumps from the highest release rather than the gradle floor', () => {
|
||||
assert.deepEqual(
|
||||
nextAndroidVersion({
|
||||
baseline: floor,
|
||||
sha: 'def',
|
||||
releases: [
|
||||
{ versionName: '0.1.1', versionCode: 2, sha: 'abc' },
|
||||
{ versionName: '0.1.4', versionCode: 5, sha: 'old' },
|
||||
],
|
||||
}),
|
||||
{ versionName: '0.1.5', versionCode: 6, reused: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a hand-raised floor ahead of older releases', () => {
|
||||
assert.deepEqual(
|
||||
nextAndroidVersion({
|
||||
baseline: { versionName: '1.1.0', versionCode: 10 },
|
||||
sha: 'def',
|
||||
releases: [{ versionName: '0.1.4', versionCode: 5, sha: 'old' }],
|
||||
}),
|
||||
{ versionName: '1.1.1', versionCode: 11, reused: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('lists only android release tags', async () => {
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [
|
||||
{ tag_name: 'android-v0.1.1-2', target_commitish: 'abc' },
|
||||
{ tag_name: 'v0.1.1', target_commitish: 'nope' },
|
||||
],
|
||||
});
|
||||
await assert.doesNotReject(
|
||||
listAndroidReleases({
|
||||
apiUrl: 'https://git.example/api/v1',
|
||||
token: 'token',
|
||||
owner: 'ai_ml_operations',
|
||||
repo: 'chat_web_app',
|
||||
fetchImpl,
|
||||
}).then((releases) => {
|
||||
assert.deepEqual(releases, [
|
||||
{ versionName: '0.1.1', versionCode: 2, sha: 'abc' },
|
||||
]);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const gradlePath = join(root, 'android', 'app', 'build.gradle');
|
||||
const aabPath = join(root, 'android', 'app', 'build', 'outputs', 'bundle', 'release', 'app-release.aab');
|
||||
|
||||
const gradle = readFileSync(gradlePath, 'utf8');
|
||||
const versionCode =
|
||||
process.env.HESYCHIA_VERSION_CODE ||
|
||||
gradle.match(/hesychiaVersionCodeFloor\s*=\s*(\d+)/)?.[1];
|
||||
const versionName =
|
||||
process.env.HESYCHIA_VERSION_NAME ||
|
||||
gradle.match(/hesychiaVersionNameFloor\s*=\s*"([^"]+)"/)?.[1];
|
||||
if (!versionCode || !versionName) {
|
||||
console.error(
|
||||
`Could not read versionCode/versionName from the environment or ${gradlePath}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tag = `android-v${versionName}-${versionCode}`;
|
||||
const assetName = `hesychia-${versionName}-${versionCode}.aab`;
|
||||
|
||||
const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN;
|
||||
const repository = process.env.GITHUB_REPOSITORY || process.env.GITEA_REPOSITORY;
|
||||
const sha = process.env.GITEA_SHA || process.env.GITHUB_SHA;
|
||||
const refName = process.env.GITEA_REF_NAME || process.env.GITHUB_REF_NAME || sha;
|
||||
const apiUrl = (
|
||||
process.env.GITHUB_API_URL ||
|
||||
process.env.GITEA_API_URL ||
|
||||
(process.env.GITHUB_SERVER_URL
|
||||
? `${process.env.GITHUB_SERVER_URL.replace(/\/$/, '')}/api/v1`
|
||||
: '')
|
||||
).replace(/\/$/, '');
|
||||
|
||||
if (!token || !repository || !sha || !apiUrl) {
|
||||
console.error('Missing required env for Gitea Android release:', {
|
||||
apiUrl: Boolean(apiUrl),
|
||||
token: Boolean(token),
|
||||
repository,
|
||||
sha,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repo] = repository.split('/');
|
||||
if (!owner || !repo) {
|
||||
console.error(`GITHUB_REPOSITORY must be owner/repo, got ${repository}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function api(path, { method = 'GET', json, form } = {}) {
|
||||
const headers = {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
let body;
|
||||
if (json !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(json);
|
||||
} else if (form) {
|
||||
body = form;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/repos/${owner}/${repo}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
return { status: response.status, payload };
|
||||
}
|
||||
|
||||
function releaseUrl(release) {
|
||||
return release?.html_url || release?.url || tag;
|
||||
}
|
||||
|
||||
function hasAsset(release) {
|
||||
return Array.isArray(release?.assets) && release.assets.some((asset) => asset.name === assetName);
|
||||
}
|
||||
|
||||
async function uploadAsset(releaseId) {
|
||||
const bytes = readFileSync(aabPath);
|
||||
const form = new FormData();
|
||||
form.append('attachment', new Blob([bytes]), assetName);
|
||||
const { status, payload } = await api(
|
||||
`/releases/${releaseId}/assets?name=${encodeURIComponent(assetName)}`,
|
||||
{ method: 'POST', form },
|
||||
);
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(`Asset upload failed (${status}): ${JSON.stringify(payload)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const body = [
|
||||
`Play store bundle for the prod deploy of \`${refName}\`.`,
|
||||
'',
|
||||
`- Commit: \`${sha}\``,
|
||||
`- versionName: \`${versionName}\``,
|
||||
`- versionCode: \`${versionCode}\``,
|
||||
'- Application id: `ai.hesychia.chat`',
|
||||
'',
|
||||
`Upload \`${assetName}\` to Play Console.`,
|
||||
].join('\n');
|
||||
|
||||
const existing = await api(`/releases/tags/${encodeURIComponent(tag)}`);
|
||||
let release = existing.payload;
|
||||
|
||||
if (existing.status === 404) {
|
||||
const created = await api('/releases', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
tag_name: tag,
|
||||
target_commitish: sha,
|
||||
name: `Android ${versionName} (${versionCode})`,
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
},
|
||||
});
|
||||
if (created.status < 200 || created.status >= 300) {
|
||||
throw new Error(`Create release failed (${created.status}): ${JSON.stringify(created.payload)}`);
|
||||
}
|
||||
release = created.payload;
|
||||
console.log(`Created ${tag} at ${releaseUrl(release)}`);
|
||||
} else if (existing.status < 200 || existing.status >= 300) {
|
||||
throw new Error(`Lookup ${tag} failed (${existing.status}): ${JSON.stringify(existing.payload)}`);
|
||||
} else if (release.target_commitish !== sha) {
|
||||
console.error(
|
||||
`${tag} already points at ${release.target_commitish}, not ${sha}. Refusing to replace that Play version.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`${tag} already exists for ${sha}`);
|
||||
}
|
||||
|
||||
if (hasAsset(release)) {
|
||||
console.log(`${assetName} already attached: ${releaseUrl(release)}`);
|
||||
} else {
|
||||
await uploadAsset(release.id);
|
||||
console.log(`Attached ${assetName} to ${releaseUrl(release)}`);
|
||||
}
|
||||
@@ -170,6 +170,7 @@ type ConversationDetailCardProps = {
|
||||
activityDetail?: string | null;
|
||||
activityHistory?: ActivityHistoryEntry[];
|
||||
activityInterrupted?: boolean;
|
||||
imageSrc?: string | null;
|
||||
};
|
||||
|
||||
const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||
@@ -207,6 +208,7 @@ const ConversationDetailCard = ({
|
||||
activityDetail = null,
|
||||
activityHistory = [],
|
||||
activityInterrupted = false,
|
||||
imageSrc = null,
|
||||
}: ConversationDetailCardProps): JSX.Element => {
|
||||
const CitationMark = useCallback(
|
||||
({ indices }: { indices?: string }) => {
|
||||
@@ -316,6 +318,19 @@ const ConversationDetailCard = ({
|
||||
return (
|
||||
<MessageContainer $isUser={user_created}>
|
||||
<Bubble $isUser={user_created}>
|
||||
{imageSrc && (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Attachment"
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: 240,
|
||||
borderRadius: 8,
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Markdown
|
||||
className="display-linebreak"
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
|
||||
@@ -139,7 +139,7 @@ const ErrorText = styled.p`
|
||||
`;
|
||||
|
||||
const CONTEXT_TOOLTIP =
|
||||
'This will use previous conversations to better customize the experience.';
|
||||
'Follow-ups use earlier messages in this chat. Turn off to answer each message on its own.';
|
||||
|
||||
function prefsErrorMessage(error: unknown, fallback: string): string {
|
||||
const axiosError = error as {
|
||||
@@ -162,7 +162,7 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
const themeColors = Object.keys(palettes);
|
||||
|
||||
const [order, setOrder] = useState<boolean>(true);
|
||||
const [useConversationContext, setUseConversationContext] = useState<boolean>(false);
|
||||
const [useConversationContext, setUseConversationContext] = useState<boolean>(true);
|
||||
const [prefsLoading, setPrefsLoading] = useState<boolean>(true);
|
||||
const [contextSaving, setContextSaving] = useState<boolean>(false);
|
||||
const [prefsError, setPrefsError] = useState<string>('');
|
||||
@@ -276,7 +276,7 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
Use previous conversations
|
||||
Remember this chat
|
||||
<Tooltip title={CONTEXT_TOOLTIP}>
|
||||
<span>
|
||||
<InfoIcon aria-label={CONTEXT_TOOLTIP} />
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MessageContext, MessageProvider } from './MessageContext';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import { AccountContext } from './AccountContext';
|
||||
import { ConversationContext } from './ConversationContext';
|
||||
import { ConversationPrompt } from '../data';
|
||||
|
||||
jest.mock('../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
get: jest.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../utils/analytics', () => ({
|
||||
AnalyticsEvents: {
|
||||
CONVERSATION_CREATED: 'Conversation Created',
|
||||
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
|
||||
},
|
||||
trackEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
const OrderProbe = () => {
|
||||
const ctx = useContext(MessageContext);
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="order">
|
||||
{ctx.conversationDetails.map((detail) => detail.message).join('|')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
ctx.setConversationDetails([
|
||||
new ConversationPrompt({
|
||||
id: 4,
|
||||
message: 'I dont know',
|
||||
user_created: true,
|
||||
created_timestamp: new Date('2020-01-01T00:03:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 2,
|
||||
message: 'Tell me a joke',
|
||||
user_created: true,
|
||||
created_timestamp: new Date('2020-01-01T00:01:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
message: '',
|
||||
user_created: false,
|
||||
created_timestamp: new Date('2020-01-01T00:04:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 3,
|
||||
message: 'meditation ladder',
|
||||
user_created: false,
|
||||
created_timestamp: new Date('2020-01-01T00:02:00Z'),
|
||||
}),
|
||||
])
|
||||
}
|
||||
>
|
||||
seed
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('MessageContext turn order (#136)', () => {
|
||||
it('sorts a scrambled transcript and keeps that order when the reply finishes', async () => {
|
||||
let callback: ((message: string) => void) | null = null;
|
||||
render(
|
||||
<AccountContext.Provider
|
||||
value={{ account: { email: 'test@example.com' } as never, setAccount: () => {} }}
|
||||
>
|
||||
<ConversationContext.Provider
|
||||
value={{
|
||||
conversations: [],
|
||||
setConversations: () => {},
|
||||
selectedConversation: 7,
|
||||
setSelectedConversation: () => {},
|
||||
deleteConversation: () => {},
|
||||
}}
|
||||
>
|
||||
<WebSocketContext.Provider
|
||||
value={
|
||||
[
|
||||
(_channel: string, cb: (message: string) => void) => {
|
||||
callback = cb;
|
||||
},
|
||||
jest.fn(),
|
||||
null,
|
||||
jest.fn(),
|
||||
true,
|
||||
'CONNECTED',
|
||||
0,
|
||||
] as never
|
||||
}
|
||||
>
|
||||
<MessageProvider>
|
||||
<OrderProbe />
|
||||
</MessageProvider>
|
||||
</WebSocketContext.Provider>
|
||||
</ConversationContext.Provider>
|
||||
</AccountContext.Provider>,
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'seed' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('order')).toHaveTextContent(
|
||||
'Tell me a joke|meditation ladder|I dont know|',
|
||||
);
|
||||
|
||||
const send = (message: string) => {
|
||||
act(() => {
|
||||
callback?.(message);
|
||||
});
|
||||
};
|
||||
send('CONVERSATION_ID');
|
||||
send('7');
|
||||
send('START_OF_THE_STREAM_ENDER_GAME_42');
|
||||
send('To help everyone step');
|
||||
send('END_OF_THE_STREAM_ENDER_GAME_42');
|
||||
|
||||
expect(screen.getByTestId('order').textContent).toBe(
|
||||
'Tell me a joke|meditation ladder|I dont know|To help everyone step',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
parseStatusData,
|
||||
parseVersionedFrame,
|
||||
} from "../utils/wsFrames";
|
||||
import { parseChatErrorPayload } from "../utils/chatErrors";
|
||||
import { sortPrompts } from "../utils/promptOrder";
|
||||
|
||||
type MessageProviderProps ={
|
||||
children? : ReactNode;
|
||||
@@ -60,7 +62,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const {selectedConversation, setSelectedConversation} = useContext(ConversationContext);
|
||||
|
||||
const [stateMessage, setStateMessage] = useState<string>('')
|
||||
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
|
||||
const [conversationDetails, setConversationDetailsState] = useState<ConversationPrompt[]>([])
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
|
||||
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
|
||||
const [activityStage, setActivityStage] = useState<string | null>(null)
|
||||
@@ -71,6 +73,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const messageRef = useRef('')
|
||||
const messageResponsePart = useRef(0);
|
||||
const conversationRef = useRef(conversationDetails)
|
||||
const setConversationDetails = useCallback((next: ConversationPrompt[]) => {
|
||||
const ordered = sortPrompts(next)
|
||||
conversationRef.current = ordered
|
||||
setConversationDetailsState(ordered)
|
||||
}, [])
|
||||
const selectedConversationRef = useRef<undefined | number>(undefined)
|
||||
const isGeneratingRef = useRef(false)
|
||||
const prevReconnectGenerationRef = useRef(0)
|
||||
@@ -88,7 +95,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
setActivityHistory([])
|
||||
}
|
||||
|
||||
async function GetConversationDetails(conversationId: number | undefined) {
|
||||
const GetConversationDetails = useCallback(async (conversationId: number | undefined) => {
|
||||
if (!conversationId) {
|
||||
setConversationDetails([])
|
||||
return
|
||||
@@ -113,6 +120,8 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
tokens_out: item.tokens_out ?? null,
|
||||
citations: item.citations ?? [],
|
||||
feedback: item.feedback ?? null,
|
||||
file_type: item.file_type ?? null,
|
||||
image: item.image ?? null,
|
||||
}),
|
||||
)
|
||||
if (tempConversations.length === 1) {
|
||||
@@ -121,12 +130,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
new ConversationPrompt({ message: '', user_created: false }),
|
||||
)
|
||||
}
|
||||
conversationRef.current = tempConversations
|
||||
setConversationDetails(tempConversations)
|
||||
} catch (err) {
|
||||
console.log('Failed to refetch conversation after reconnect', err)
|
||||
}
|
||||
}
|
||||
}, [setConversationDetails])
|
||||
|
||||
const schedulePostStreamRefetch = useCallback(() => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
|
||||
@@ -137,11 +145,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
void GetConversationDetails(conversationId)
|
||||
}, 800)
|
||||
}, [])
|
||||
}, [GetConversationDetails])
|
||||
|
||||
useEffect(() => {
|
||||
GetConversationDetails(selectedConversation)
|
||||
}, [selectedConversation])
|
||||
void GetConversationDetails(selectedConversation)
|
||||
}, [selectedConversation, GetConversationDetails])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -190,9 +198,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
}
|
||||
|
||||
if (selectedConversationRef.current) {
|
||||
GetConversationDetails(selectedConversationRef.current)
|
||||
void GetConversationDetails(selectedConversationRef.current)
|
||||
}
|
||||
}, [reconnectGeneration])
|
||||
}, [reconnectGeneration, GetConversationDetails, setConversationDetails])
|
||||
|
||||
useEffect(() => {
|
||||
/* register a consistent channel name for identifing this chat messages */
|
||||
@@ -259,14 +267,13 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
|
||||
messageResponsePart.current = 0
|
||||
|
||||
conversationRef.current.pop()
|
||||
|
||||
const details = [...conversationRef.current]
|
||||
details.pop()
|
||||
const finalized = new ConversationPrompt({
|
||||
message: `${messageRef.current}`,
|
||||
user_created: false,
|
||||
})
|
||||
conversationRef.current = [...conversationRef.current, finalized]
|
||||
setConversationDetails([...conversationRef.current])
|
||||
setConversationDetails([...details, finalized])
|
||||
messageRef.current = ''
|
||||
setStateMessage('')
|
||||
isGeneratingRef.current = false
|
||||
@@ -276,7 +283,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
schedulePostStreamRefetch()
|
||||
}
|
||||
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
||||
conversationRef.current = conversationDetails
|
||||
// Do not copy conversationDetails from this closure. A START frame
|
||||
// can arrive on the subscription from before the optimistic turn,
|
||||
// and that copy wiped conversationRef (#136).
|
||||
isGeneratingRef.current = true
|
||||
setIsGeneratingMessage(true)
|
||||
setStreamInterrupted(false)
|
||||
@@ -288,6 +297,22 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
setStreamInterrupted(false)
|
||||
messageResponsePart.current = 1
|
||||
}else{
|
||||
if (messageResponsePart.current === 0) {
|
||||
const err = parseChatErrorPayload(message)
|
||||
if (err?.content) {
|
||||
const details = [...conversationRef.current]
|
||||
const last = details[details.length - 1]
|
||||
if (last && !last.user_created) {
|
||||
details[details.length - 1] = new ConversationPrompt({
|
||||
...last,
|
||||
message,
|
||||
})
|
||||
conversationRef.current = details
|
||||
setConversationDetails(details)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (messageResponsePart.current === 1){
|
||||
isGeneratingRef.current = true
|
||||
setIsGeneratingMessage(true)
|
||||
@@ -316,7 +341,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
/* unsubscribe from channel during cleanup */
|
||||
unsubscribe(channelName)
|
||||
}
|
||||
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch])
|
||||
}, [account, subscribe, unsubscribe, selectedConversation, setSelectedConversation, schedulePostStreamRefetch, setConversationDetails])
|
||||
|
||||
|
||||
return(
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('ConversationPrompt', () => {
|
||||
expect(prompt.message).toBe('hello');
|
||||
expect(prompt.user_created).toBe(true);
|
||||
expect(prompt.created_timestamp).toEqual(created);
|
||||
expect(prompt.localKey).toBe('id-7');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,10 +18,16 @@ export interface ConversationPromptType {
|
||||
tokens_out?: number | null,
|
||||
citations?: Citation[],
|
||||
feedback?: PromptFeedbackState | null,
|
||||
file_type?: string | null,
|
||||
image?: string | null,
|
||||
}
|
||||
|
||||
let localPromptKey = 0;
|
||||
|
||||
export class ConversationPrompt{
|
||||
id: number | undefined;
|
||||
/** Stable React key. Optimistic turns have no server id yet (#136). */
|
||||
localKey: string = '';
|
||||
message: string = '';
|
||||
user_created: boolean = false;
|
||||
created_timestamp: Date = new Date();
|
||||
@@ -29,9 +35,15 @@ export class ConversationPrompt{
|
||||
tokens_out: number | null = null;
|
||||
citations: Citation[] = [];
|
||||
feedback: PromptFeedbackState | null = null;
|
||||
file_type: string | null = null;
|
||||
image: string | null = null;
|
||||
|
||||
constructor(initializer?: any){
|
||||
if(!initializer) return;
|
||||
if(!initializer) {
|
||||
localPromptKey += 1;
|
||||
this.localKey = `local-${localPromptKey}`;
|
||||
return;
|
||||
}
|
||||
if (initializer.id) this.id = initializer.id;
|
||||
if (initializer.message) this.message = initializer.message;
|
||||
if (initializer.user_created) this.user_created = initializer.user_created;
|
||||
@@ -41,6 +53,14 @@ export class ConversationPrompt{
|
||||
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
|
||||
if (Array.isArray(initializer.citations)) this.citations = initializer.citations;
|
||||
if (initializer.feedback !== undefined) this.feedback = initializer.feedback;
|
||||
if (initializer.file_type) this.file_type = initializer.file_type;
|
||||
if (initializer.image) this.image = initializer.image;
|
||||
if (initializer.localKey) this.localKey = String(initializer.localKey);
|
||||
else if (initializer.id) this.localKey = `id-${initializer.id}`;
|
||||
else {
|
||||
localPromptKey += 1;
|
||||
this.localKey = `local-${localPromptKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
|
||||
import { exportChat, type ExportFormat } from "../../utils/export/exportChat";
|
||||
import { showToast } from "../../utils/toastBus";
|
||||
import type { PromptRating } from "../../utils/promptFeedback";
|
||||
import { CHAT_FILE_ACCEPT, imageSrcForPrompt, isImageFile } from "../../utils/visionAttachment";
|
||||
|
||||
// Styled Components
|
||||
const PageContainer = styled.div`
|
||||
@@ -334,6 +335,46 @@ const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.El
|
||||
return <div ref={elementRef} />;
|
||||
};
|
||||
|
||||
const AttachmentPreview = ({ file }: { file: Blob | null }): JSX.Element | null => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!file || !isImageFile(file)) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
const next = URL.createObjectURL(file);
|
||||
setUrl(next);
|
||||
return () => URL.revokeObjectURL(next);
|
||||
}, [file]);
|
||||
if (url) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{
|
||||
display: "block",
|
||||
margin: "0.5rem auto 0",
|
||||
maxHeight: 120,
|
||||
maxWidth: "100%",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!file) return null;
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.8rem",
|
||||
color: theme?.darkMode ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.6)",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
Attached: {file instanceof File ? file.name : "attachment"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AsyncDashboardInner = (): JSX.Element => {
|
||||
const [, , , sendMessage, isConnected, connectionStatus] =
|
||||
useContext(WebSocketContext);
|
||||
@@ -432,9 +473,15 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
}
|
||||
try {
|
||||
clearActivity();
|
||||
const attachedImage = file && isImageFile(file) ? URL.createObjectURL(file) : null;
|
||||
const tempConversations: ConversationPrompt[] = [
|
||||
...conversationDetails,
|
||||
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
|
||||
new ConversationPrompt({
|
||||
message: trimmedPrompt,
|
||||
user_created: true,
|
||||
image: attachedImage,
|
||||
file_type: fileType,
|
||||
}),
|
||||
new ConversationPrompt({ message: "", user_created: false }),
|
||||
];
|
||||
|
||||
@@ -457,6 +504,8 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
const fileInput = document.getElementById("file-upload") as HTMLInputElement | null;
|
||||
if (fileInput) fileInput.value = "";
|
||||
} catch (e) {
|
||||
console.log(`error ${e}`);
|
||||
}
|
||||
@@ -573,7 +622,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
: convo_detail.message;
|
||||
return (
|
||||
<ConversationDetailCard
|
||||
key={convo_detail.id || index}
|
||||
key={convo_detail.localKey}
|
||||
message={displayMessage}
|
||||
user_created={convo_detail.user_created}
|
||||
promptId={convo_detail.id}
|
||||
@@ -591,6 +640,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
activityDetail={isLiveStream ? activityDetail : null}
|
||||
activityHistory={isLiveStream ? activityHistory : []}
|
||||
activityInterrupted={isLiveStream && streamInterrupted}
|
||||
imageSrc={imageSrcForPrompt(convo_detail.image, convo_detail.file_type)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -652,7 +702,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
<VisuallyHiddenInput
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.txt,.pdf,.PDF"
|
||||
accept={CHAT_FILE_ACCEPT}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -703,16 +753,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
</span>
|
||||
</Tooltip>
|
||||
</StyledInputContainer>
|
||||
{formik.values.file && (
|
||||
<div style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
color: theme?.darkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Attached: {(formik.values.file as File).name}
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPreview file={formik.values.file} />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('LandingPage', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Business accounts' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Roadmap' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Image generation is on the roadmap/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Vision is on the roadmap/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Hesychia reads the picture/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -3,29 +3,13 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import PasswordReset from './PasswordReset';
|
||||
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY } from './recaptchaV3';
|
||||
|
||||
const mockPost = jest.fn();
|
||||
const mockExecuteAsync = jest.fn();
|
||||
const mockReset = jest.fn();
|
||||
const mockExecute = jest.fn();
|
||||
|
||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||
|
||||
jest.mock('react-google-recaptcha', () => {
|
||||
const ReactLib = require('react');
|
||||
return ReactLib.forwardRef(
|
||||
(
|
||||
_props: unknown,
|
||||
ref: React.Ref<{ executeAsync: () => Promise<string>; reset: () => void }>
|
||||
) => {
|
||||
ReactLib.useImperativeHandle(ref, () => ({
|
||||
executeAsync: mockExecuteAsync,
|
||||
reset: mockReset,
|
||||
}));
|
||||
return <div data-testid="recaptcha" />;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
jest.mock('../../../axiosApi', () => ({
|
||||
cleanAxiosInstance: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
@@ -48,13 +32,23 @@ const renderPasswordReset = () =>
|
||||
describe('PasswordReset', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
mockExecuteAsync.mockReset();
|
||||
mockReset.mockReset();
|
||||
mockExecuteAsync.mockResolvedValue('captcha-token');
|
||||
mockExecute.mockReset();
|
||||
mockExecute.mockResolvedValue('captcha-token');
|
||||
mockPost.mockResolvedValue({ status: 200 });
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: mockExecute,
|
||||
};
|
||||
});
|
||||
|
||||
it('submits email with captcha token and navigates to confirmation', async () => {
|
||||
afterEach(() => {
|
||||
delete window.grecaptcha;
|
||||
document
|
||||
.querySelectorAll('script[data-recaptcha-v3="true"]')
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it('submits email with a v3 captcha token and navigates to confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPasswordReset();
|
||||
|
||||
@@ -62,6 +56,9 @@ describe('PasswordReset', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
|
||||
action: RECAPTCHA_ACTION,
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith('user/reset_password/', {
|
||||
email: 'user@example.com',
|
||||
recaptchaToken: 'captcha-token',
|
||||
@@ -70,6 +67,20 @@ describe('PasswordReset', () => {
|
||||
expect(await screen.findByText('Confirmation page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when captcha fails and does not post', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('invalid site key'));
|
||||
const user = userEvent.setup();
|
||||
renderPasswordReset();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Email Address'), 'user@example.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Unable to reset password. Please try again.')
|
||||
).toBeInTheDocument();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when request fails', async () => {
|
||||
mockPost.mockRejectedValue(new Error('network'));
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Form, Formik, Field } from 'formik';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { cleanAxiosInstance } from '../../../axiosApi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
import styled from 'styled-components';
|
||||
import * as Yup from 'yup';
|
||||
import { executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -139,20 +139,20 @@ const validationSchema = Yup.object().shape({
|
||||
|
||||
const PasswordReset = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const recaptchaRef = useRef<ReCAPTCHA>(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadRecaptcha().catch(() => {
|
||||
// Submit reports a failure if the v3 script never becomes ready.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePasswordResetEmail = async ({
|
||||
email,
|
||||
}: EmailPasswordResetValues): Promise<void> => {
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let token = '';
|
||||
if (recaptchaRef.current) {
|
||||
token = (await recaptchaRef.current.executeAsync()) || '';
|
||||
recaptchaRef.current.reset();
|
||||
}
|
||||
|
||||
const token = await executePasswordResetCaptcha();
|
||||
await cleanAxiosInstance.post('user/reset_password/', {
|
||||
email,
|
||||
recaptchaToken: token,
|
||||
@@ -186,14 +186,6 @@ const PasswordReset = (): JSX.Element => {
|
||||
placeholder="Email Address"
|
||||
type="email"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey="6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ"
|
||||
size="invisible"
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
||||
Reset Password
|
||||
</StyledButton>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY, executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
|
||||
|
||||
describe('recaptchaV3', () => {
|
||||
afterEach(() => {
|
||||
delete window.grecaptcha;
|
||||
document
|
||||
.querySelectorAll('script[data-recaptcha-v3="true"]')
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it('loads the v3 script once when grecaptcha is missing', async () => {
|
||||
const pending = loadRecaptcha();
|
||||
const again = loadRecaptcha();
|
||||
const scripts = document.querySelectorAll('script[data-recaptcha-v3="true"]');
|
||||
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(scripts[0]).toHaveAttribute(
|
||||
'src',
|
||||
`https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}`
|
||||
);
|
||||
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: jest.fn(),
|
||||
};
|
||||
scripts[0].dispatchEvent(new Event('load'));
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
await expect(again).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('executes the password_reset action', async () => {
|
||||
const execute = jest.fn().mockResolvedValue('token-1');
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute,
|
||||
};
|
||||
|
||||
await expect(executePasswordResetCaptcha()).resolves.toBe('token-1');
|
||||
expect(execute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
|
||||
action: RECAPTCHA_ACTION,
|
||||
});
|
||||
expect(document.querySelector('script[data-recaptcha-v3="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty token', async () => {
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: jest.fn().mockResolvedValue(''),
|
||||
};
|
||||
|
||||
await expect(executePasswordResetCaptcha()).rejects.toThrow('empty reCAPTCHA token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
export const RECAPTCHA_SITE_KEY = '6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ';
|
||||
export const RECAPTCHA_ACTION = 'password_reset';
|
||||
|
||||
type Grecaptcha = {
|
||||
ready: (callback: () => void) => void;
|
||||
execute: (siteKey: string, options: { action: string }) => Promise<string>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha?: Grecaptcha;
|
||||
}
|
||||
}
|
||||
|
||||
let loading: Promise<void> | null = null;
|
||||
|
||||
export function loadRecaptcha(siteKey: string = RECAPTCHA_SITE_KEY): Promise<void> {
|
||||
if (window.grecaptcha) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (loading && document.querySelector('script[data-recaptcha-v3="true"]')) {
|
||||
return loading;
|
||||
}
|
||||
|
||||
loading = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;
|
||||
script.async = true;
|
||||
script.dataset.recaptchaV3 = 'true';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
loading = null;
|
||||
script.remove();
|
||||
reject(new Error('reCAPTCHA failed to load'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
export async function executePasswordResetCaptcha(
|
||||
siteKey: string = RECAPTCHA_SITE_KEY
|
||||
): Promise<string> {
|
||||
await loadRecaptcha(siteKey);
|
||||
const grecaptcha = window.grecaptcha;
|
||||
if (!grecaptcha) {
|
||||
throw new Error('reCAPTCHA unavailable');
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
grecaptcha.ready(resolve);
|
||||
});
|
||||
|
||||
const token = await grecaptcha.execute(siteKey, { action: RECAPTCHA_ACTION });
|
||||
if (!token) {
|
||||
throw new Error('empty reCAPTCHA token');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ describe('public crawler files', () => {
|
||||
expect(sitemap.startsWith('<?xml')).toBe(true);
|
||||
expect(llms).toContain('$10/month');
|
||||
expect(llms).toContain('locked for the lifetime of the account');
|
||||
expect(llms).toContain('Image generation and vision are on the roadmap');
|
||||
expect(llms).toContain('Upload an image with a prompt and Hesychia reads the picture');
|
||||
expect(llms).not.toContain('<html');
|
||||
|
||||
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
|
||||
@@ -56,7 +56,9 @@ describe('public crawler files', () => {
|
||||
expect(html).toContain(
|
||||
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
|
||||
);
|
||||
expect(html).toContain('Vision is on the roadmap, so you can ask questions about an image you upload.');
|
||||
expect(html).toContain(
|
||||
'Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.',
|
||||
);
|
||||
expect(html).not.toContain('>here<');
|
||||
expect(html.match(/application\/ld\+json/g)).toHaveLength(1);
|
||||
expect(html).toContain('"@type": "WebPage"');
|
||||
|
||||
@@ -32,7 +32,7 @@ const HOME = {
|
||||
roadmapImage:
|
||||
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
|
||||
roadmapVision:
|
||||
'Vision is on the roadmap, so you can ask questions about an image you upload.',
|
||||
'Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.',
|
||||
};
|
||||
|
||||
const PRICING = {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ConversationPrompt } from '../data';
|
||||
import { sortPrompts } from './promptOrder';
|
||||
|
||||
describe('sortPrompts', () => {
|
||||
it('orders by created time, not the array the server happened to return', () => {
|
||||
const prompts = [
|
||||
new ConversationPrompt({
|
||||
id: 4,
|
||||
message: 'I dont know',
|
||||
user_created: true,
|
||||
created_timestamp: new Date('2026-10-25T12:03:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 2,
|
||||
message: 'Tell me a joke',
|
||||
user_created: true,
|
||||
created_timestamp: new Date('2026-10-25T12:01:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 5,
|
||||
message: 'form errors',
|
||||
user_created: false,
|
||||
created_timestamp: new Date('2026-10-25T12:04:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 3,
|
||||
message: 'meditation ladder',
|
||||
user_created: false,
|
||||
created_timestamp: new Date('2026-10-25T12:02:00Z'),
|
||||
}),
|
||||
];
|
||||
|
||||
expect(sortPrompts(prompts).map((prompt) => prompt.message)).toEqual([
|
||||
'Tell me a joke',
|
||||
'meditation ladder',
|
||||
'I dont know',
|
||||
'form errors',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a same-millisecond user turn ahead of the empty assistant placeholder', () => {
|
||||
const stamp = new Date('2026-10-25T12:05:00Z');
|
||||
const prompts = [
|
||||
new ConversationPrompt({
|
||||
message: '',
|
||||
user_created: false,
|
||||
created_timestamp: stamp,
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
message: 'I dont know',
|
||||
user_created: true,
|
||||
created_timestamp: stamp,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(sortPrompts(prompts).map((prompt) => prompt.user_created)).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ConversationPrompt } from '../data';
|
||||
|
||||
function timeMs(prompt: ConversationPrompt): number {
|
||||
const stamp = prompt.created_timestamp;
|
||||
const ms = stamp instanceof Date ? stamp.getTime() : NaN;
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** Oldest first. Same instant: lower id, then the user turn before the assistant placeholder. */
|
||||
export function comparePrompts(a: ConversationPrompt, b: ConversationPrompt): number {
|
||||
const dt = timeMs(a) - timeMs(b);
|
||||
if (dt !== 0) return dt;
|
||||
const ia = a.id ?? Number.MAX_SAFE_INTEGER;
|
||||
const ib = b.id ?? Number.MAX_SAFE_INTEGER;
|
||||
if (ia !== ib) return ia - ib;
|
||||
if (a.user_created !== b.user_created) return a.user_created ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortPrompts(prompts: ConversationPrompt[]): ConversationPrompt[] {
|
||||
return [...prompts].sort(comparePrompts);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { imageSrcForPrompt, isImageFile } from './visionAttachment';
|
||||
|
||||
describe('visionAttachment', () => {
|
||||
it('builds a data url from stored image bytes', () => {
|
||||
expect(imageSrcForPrompt('abc', 'png')).toBe('data:image/png;base64,abc');
|
||||
expect(imageSrcForPrompt('abc', 'jpeg')).toBe('data:image/jpeg;base64,abc');
|
||||
});
|
||||
|
||||
it('keeps blob and data urls', () => {
|
||||
expect(imageSrcForPrompt('blob:http://local/1', 'png')).toBe('blob:http://local/1');
|
||||
expect(imageSrcForPrompt('data:image/png;base64,abc', 'png')).toBe(
|
||||
'data:image/png;base64,abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('detects image files', () => {
|
||||
expect(isImageFile(new File(['x'], 'a.png', { type: 'image/png' }))).toBe(true);
|
||||
expect(isImageFile(new File(['x'], 'a.pdf', { type: 'application/pdf' }))).toBe(false);
|
||||
expect(isImageFile(new Blob(['x'], { type: 'image/png' }))).toBe(true);
|
||||
expect(isImageFile(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export const CHAT_FILE_ACCEPT =
|
||||
'.csv,.xlsx,.txt,.pdf,.PDF,image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
export function isImageFile(file: Blob | null | undefined): boolean {
|
||||
return Boolean(file && file.type.startsWith('image/'));
|
||||
}
|
||||
|
||||
/** Preview URL or data URL for a stored prompt image. */
|
||||
export function imageSrcForPrompt(
|
||||
image?: string | null,
|
||||
fileType?: string | null,
|
||||
): string | null {
|
||||
if (!image) return null;
|
||||
if (image.startsWith('blob:') || image.startsWith('data:')) return image;
|
||||
const raw = (fileType || 'png').toLowerCase();
|
||||
const mime = raw.startsWith('image/')
|
||||
? raw
|
||||
: `image/${raw === 'jpg' ? 'jpeg' : raw}`;
|
||||
return `data:${mime};base64,${image}`;
|
||||
}
|
||||
Reference in new issue
Block a user