1 Commits
Author SHA1 Message Date
westfarn 0d233b077c Publish a signed Play AAB from Deploy Prod.
Unit Tests / test (pull_request) Successful in 14s
Closes #121. Supersedes the separate Deploy Android workflow in #58 by attaching hesychia-<version>-<code>.aab to an android-v* Gitea release after the prod web deploy.
2026-10-19 10:26:36 -05:00
30 changed files with 659 additions and 591 deletions

No files matched your search

-23
View File
@@ -1,23 +0,0 @@
---
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.
+58
View File
@@ -36,3 +36,61 @@ jobs:
--app chat_web_app \ --app chat_web_app \
--env prod \ --env prod \
--ref "${{ gitea.sha }}" --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
+49 -8
View File
@@ -50,12 +50,37 @@ After plugin changes: `npm run build:mobile` (runs `cap sync`).
## Versioning ## Versioning
In `android/app/build.gradle`: `android/app/build.gradle` keeps a floor:
- **versionName** — user-visible string; keep aligned with `package.json` `version` (currently `0.1.0`) - `hesychiaVersionNameFloor` — `"0.1.0"` (user-visible string)
- **versionCode** — integer; **must increase** for every Play Store upload - `hesychiaVersionCodeFloor` — `1` (integer; Play requires a higher code on every upload)
Bump both before each store release. 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
```
## Icons / splash ## Icons / splash
@@ -76,23 +101,39 @@ Same command also regenerates iOS `Assets.xcassets` (see [`IOS.md`](IOS.md)).
## Signing (upload keystore) ## Signing (upload keystore)
**Never commit keystores.** Store outside the repo (e.g. host secrets / password manager). **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.
```bash ```bash
keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \ keytool -genkey -v \
-keystore /home/westfarn/.android/hesychia-upload.jks \
-keyalg RSA -keysize 2048 -validity 10000 \ -keyalg RSA -keysize 2048 -validity 10000 \
-alias aiml-chat-upload -alias aiml-chat-upload
``` ```
Create `llm-fe/android/keystore.properties` (gitignored): `hesychia-keystore.properties` (`storeFile` may be absolute):
```properties ```properties
storeFile=/absolute/path/to/aiml-chat-upload.jks storeFile=/home/westfarn/.android/hesychia-upload.jks
storePassword=... storePassword=...
keyAlias=aiml-chat-upload keyAlias=aiml-chat-upload
keyPassword=... 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: Release App Bundle:
```bash ```bash
+11 -6
View File
@@ -19,7 +19,8 @@ From the **npm root** (`llm-fe/`, one level up):
```bash ```bash
cd .. cd ..
npm ci npm ci
npm run build:mobile # .env.mobile → react-scripts build → cap sync android npm run build:android:store # Play binary: .env.mobile → build → cap sync android
# npm run build:mobile # same web env, also syncs iOS
``` ```
Open this project in Android Studio: Open this project in Android Studio:
@@ -54,11 +55,11 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
## Version before a store release ## Version before a store release
Edit `app/build.gradle` → `defaultConfig`: `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.
| Field | Rule | | Field | Rule |
|-------|------| |-------|------|
| `versionName` | User-visible string; keep in sync with `../package.json` `version` | | `versionName` | User-visible `major.minor.patch` |
| `versionCode` | Integer; **must increase** for every Play upload | | `versionCode` | Integer; **must increase** for every Play upload |
## Sign a release build ## Sign a release build
@@ -68,15 +69,17 @@ Edit `app/build.gradle` → `defaultConfig`:
1. Create an upload keystore (once), outside the repo: 1. Create an upload keystore (once), outside the repo:
```bash ```bash
keytool -genkey -v -keystore ~/Documents/secrets/aiml-chat-upload.jks \ keytool -genkey -v -keystore /home/westfarn/.android/hesychia-upload.jks \
-keyalg RSA -keysize 2048 -validity 10000 \ -keyalg RSA -keysize 2048 -validity 10000 \
-alias aiml-chat-upload -alias aiml-chat-upload
``` ```
2. Create `keystore.properties` in **this** directory (`android/`, gitignored): 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`):
```properties ```properties
storeFile=/absolute/path/to/aiml-chat-upload.jks storeFile=/home/westfarn/.android/hesychia-upload.jks
storePassword=REPLACE_ME storePassword=REPLACE_ME
keyAlias=aiml-chat-upload keyAlias=aiml-chat-upload
keyPassword=REPLACE_ME keyPassword=REPLACE_ME
@@ -105,6 +108,8 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
## Deploy to Google Play ## 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`). 1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`).
2. Complete store listing: privacy policy URL, data-safety form, screenshots. 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): 3. Set **Account deletion URL** to the public instructions page (no sign-in required):
+15 -3
View File
@@ -1,5 +1,17 @@
apply plugin: 'com.android.application' 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 keystorePropertiesFile = rootProject.file('keystore.properties')
def keystoreProperties = new Properties() def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
@@ -13,8 +25,8 @@ android {
applicationId "ai.hesychia.chat" applicationId "ai.hesychia.chat"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode resolvedVersionCode
versionName "0.1.0" versionName resolvedVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -27,7 +39,7 @@ android {
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
keyAlias keystoreProperties['keyAlias'] keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword'] keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile']) storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword'] storePassword keystoreProperties['storePassword']
} }
} }
+37
View File
@@ -64,9 +64,11 @@
"@types/lodash": "~4.17.13", "@types/lodash": "~4.17.13",
"@types/react": "^18.3.16", "@types/react": "^18.3.16",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/react-google-recaptcha": "^2.1.9",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"typescript": "^4.9.5" "typescript": "^4.9.5"
} }
}, },
@@ -6538,6 +6540,15 @@
"@types/react": "^18.0.0" "@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": { "node_modules/@types/react-syntax-highlighter": {
"version": "15.5.13", "version": "15.5.13",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
@@ -19079,6 +19090,19 @@
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" "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": { "node_modules/react-bootstrap": {
"version": "2.10.9", "version": "2.10.9",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz",
@@ -19277,6 +19301,19 @@
"react": ">=16.3.0" "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": { "node_modules/react-is": {
"version": "19.0.0", "version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
+4 -1
View File
@@ -58,13 +58,14 @@
"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: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: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: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:open": "npx cap open android",
"android:sync": "npx cap sync android", "android:sync": "npx cap sync android",
"ios:open": "npx cap open ios", "ios:open": "npx cap open ios",
"ios:sync": "npx cap sync ios", "ios:sync": "npx cap sync ios",
"assets:generate": "npx capacitor-assets generate --ios --android", "assets:generate": "npx capacitor-assets generate --ios --android",
"test": "NODE_ENV=development react-scripts test", "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", "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",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
@@ -92,9 +93,11 @@
"@types/lodash": "~4.17.13", "@types/lodash": "~4.17.13",
"@types/react": "^18.3.16", "@types/react": "^18.3.16",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/react-google-recaptcha": "^2.1.9",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"typescript": "^4.9.5" "typescript": "^4.9.5"
}, },
"overrides": { "overrides": {
+1 -1
View File
@@ -83,7 +83,7 @@
</head> </head>
<body> <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>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> <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>
<!-- <!--
This HTML file is a template. This HTML file is a template.
If you open it directly in the browser, you will see an empty page. If you open it directly in the browser, you will see an empty page.
+1 -1
View File
@@ -2,7 +2,7 @@
> Hesychia is a calm AI assistant with quiet, focused AI chat that stays private by default. > 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 is on the roadmap. Upload an image with a prompt and Hesychia reads the picture. 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.
## Pages ## Pages
+142
View File
@@ -0,0 +1,142 @@
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);
});
}
+113
View File
@@ -0,0 +1,113 @@
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' },
]);
}),
);
});
});
@@ -0,0 +1,152 @@
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,7 +170,6 @@ type ConversationDetailCardProps = {
activityDetail?: string | null; activityDetail?: string | null;
activityHistory?: ActivityHistoryEntry[]; activityHistory?: ActivityHistoryEntry[];
activityInterrupted?: boolean; activityInterrupted?: boolean;
imageSrc?: string | null;
}; };
const MyPlot = ({ format, image }: { format: string; image: string }) => { const MyPlot = ({ format, image }: { format: string; image: string }) => {
@@ -208,7 +207,6 @@ const ConversationDetailCard = ({
activityDetail = null, activityDetail = null,
activityHistory = [], activityHistory = [],
activityInterrupted = false, activityInterrupted = false,
imageSrc = null,
}: ConversationDetailCardProps): JSX.Element => { }: ConversationDetailCardProps): JSX.Element => {
const CitationMark = useCallback( const CitationMark = useCallback(
({ indices }: { indices?: string }) => { ({ indices }: { indices?: string }) => {
@@ -318,19 +316,6 @@ const ConversationDetailCard = ({
return ( return (
<MessageContainer $isUser={user_created}> <MessageContainer $isUser={user_created}>
<Bubble $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 <Markdown
className="display-linebreak" className="display-linebreak"
style={{ whiteSpace: 'pre-line' }} style={{ whiteSpace: 'pre-line' }}
@@ -139,7 +139,7 @@ const ErrorText = styled.p`
`; `;
const CONTEXT_TOOLTIP = const CONTEXT_TOOLTIP =
'Follow-ups use earlier messages in this chat. Turn off to answer each message on its own.'; 'This will use previous conversations to better customize the experience.';
function prefsErrorMessage(error: unknown, fallback: string): string { function prefsErrorMessage(error: unknown, fallback: string): string {
const axiosError = error as { const axiosError = error as {
@@ -162,7 +162,7 @@ const ThemeSettingsCard = (): JSX.Element => {
const themeColors = Object.keys(palettes); const themeColors = Object.keys(palettes);
const [order, setOrder] = useState<boolean>(true); const [order, setOrder] = useState<boolean>(true);
const [useConversationContext, setUseConversationContext] = useState<boolean>(true); const [useConversationContext, setUseConversationContext] = useState<boolean>(false);
const [prefsLoading, setPrefsLoading] = useState<boolean>(true); const [prefsLoading, setPrefsLoading] = useState<boolean>(true);
const [contextSaving, setContextSaving] = useState<boolean>(false); const [contextSaving, setContextSaving] = useState<boolean>(false);
const [prefsError, setPrefsError] = useState<string>(''); const [prefsError, setPrefsError] = useState<string>('');
@@ -276,7 +276,7 @@ const ThemeSettingsCard = (): JSX.Element => {
<SettingRow> <SettingRow>
<SettingLabel> <SettingLabel>
Remember this chat Use previous conversations
<Tooltip title={CONTEXT_TOOLTIP}> <Tooltip title={CONTEXT_TOOLTIP}>
<span> <span>
<InfoIcon aria-label={CONTEXT_TOOLTIP} /> <InfoIcon aria-label={CONTEXT_TOOLTIP} />
@@ -1,128 +0,0 @@
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',
);
});
});
+16 -41
View File
@@ -12,8 +12,6 @@ import {
parseStatusData, parseStatusData,
parseVersionedFrame, parseVersionedFrame,
} from "../utils/wsFrames"; } from "../utils/wsFrames";
import { parseChatErrorPayload } from "../utils/chatErrors";
import { sortPrompts } from "../utils/promptOrder";
type MessageProviderProps ={ type MessageProviderProps ={
children? : ReactNode; children? : ReactNode;
@@ -62,7 +60,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const {selectedConversation, setSelectedConversation} = useContext(ConversationContext); const {selectedConversation, setSelectedConversation} = useContext(ConversationContext);
const [stateMessage, setStateMessage] = useState<string>('') const [stateMessage, setStateMessage] = useState<string>('')
const [conversationDetails, setConversationDetailsState] = useState<ConversationPrompt[]>([]) const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false) const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false) const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
const [activityStage, setActivityStage] = useState<string | null>(null) const [activityStage, setActivityStage] = useState<string | null>(null)
@@ -73,11 +71,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const messageRef = useRef('') const messageRef = useRef('')
const messageResponsePart = useRef(0); const messageResponsePart = useRef(0);
const conversationRef = useRef(conversationDetails) 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 selectedConversationRef = useRef<undefined | number>(undefined)
const isGeneratingRef = useRef(false) const isGeneratingRef = useRef(false)
const prevReconnectGenerationRef = useRef(0) const prevReconnectGenerationRef = useRef(0)
@@ -95,7 +88,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setActivityHistory([]) setActivityHistory([])
} }
const GetConversationDetails = useCallback(async (conversationId: number | undefined) => { async function GetConversationDetails(conversationId: number | undefined) {
if (!conversationId) { if (!conversationId) {
setConversationDetails([]) setConversationDetails([])
return return
@@ -120,8 +113,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
tokens_out: item.tokens_out ?? null, tokens_out: item.tokens_out ?? null,
citations: item.citations ?? [], citations: item.citations ?? [],
feedback: item.feedback ?? null, feedback: item.feedback ?? null,
file_type: item.file_type ?? null,
image: item.image ?? null,
}), }),
) )
if (tempConversations.length === 1) { if (tempConversations.length === 1) {
@@ -130,11 +121,12 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
new ConversationPrompt({ message: '', user_created: false }), new ConversationPrompt({ message: '', user_created: false }),
) )
} }
conversationRef.current = tempConversations
setConversationDetails(tempConversations) setConversationDetails(tempConversations)
} catch (err) { } catch (err) {
console.log('Failed to refetch conversation after reconnect', err) console.log('Failed to refetch conversation after reconnect', err)
} }
}, [setConversationDetails]) }
const schedulePostStreamRefetch = useCallback(() => { const schedulePostStreamRefetch = useCallback(() => {
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current) if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
@@ -145,11 +137,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
refetchTimerRef.current = setTimeout(() => { refetchTimerRef.current = setTimeout(() => {
void GetConversationDetails(conversationId) void GetConversationDetails(conversationId)
}, 800) }, 800)
}, [GetConversationDetails]) }, [])
useEffect(() => { useEffect(() => {
void GetConversationDetails(selectedConversation) GetConversationDetails(selectedConversation)
}, [selectedConversation, GetConversationDetails]) }, [selectedConversation])
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -198,9 +190,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
} }
if (selectedConversationRef.current) { if (selectedConversationRef.current) {
void GetConversationDetails(selectedConversationRef.current) GetConversationDetails(selectedConversationRef.current)
} }
}, [reconnectGeneration, GetConversationDetails, setConversationDetails]) }, [reconnectGeneration])
useEffect(() => { useEffect(() => {
/* register a consistent channel name for identifing this chat messages */ /* register a consistent channel name for identifing this chat messages */
@@ -266,14 +258,15 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){ if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
messageResponsePart.current = 0 messageResponsePart.current = 0
const details = [...conversationRef.current] conversationRef.current.pop()
details.pop()
const finalized = new ConversationPrompt({ const finalized = new ConversationPrompt({
message: `${messageRef.current}`, message: `${messageRef.current}`,
user_created: false, user_created: false,
}) })
setConversationDetails([...details, finalized]) conversationRef.current = [...conversationRef.current, finalized]
setConversationDetails([...conversationRef.current])
messageRef.current = '' messageRef.current = ''
setStateMessage('') setStateMessage('')
isGeneratingRef.current = false isGeneratingRef.current = false
@@ -283,9 +276,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
schedulePostStreamRefetch() schedulePostStreamRefetch()
} }
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){ else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
// Do not copy conversationDetails from this closure. A START frame conversationRef.current = conversationDetails
// can arrive on the subscription from before the optimistic turn,
// and that copy wiped conversationRef (#136).
isGeneratingRef.current = true isGeneratingRef.current = true
setIsGeneratingMessage(true) setIsGeneratingMessage(true)
setStreamInterrupted(false) setStreamInterrupted(false)
@@ -297,22 +288,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setStreamInterrupted(false) setStreamInterrupted(false)
messageResponsePart.current = 1 messageResponsePart.current = 1
}else{ }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){ if (messageResponsePart.current === 1){
isGeneratingRef.current = true isGeneratingRef.current = true
setIsGeneratingMessage(true) setIsGeneratingMessage(true)
@@ -341,7 +316,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
/* unsubscribe from channel during cleanup */ /* unsubscribe from channel during cleanup */
unsubscribe(channelName) unsubscribe(channelName)
} }
}, [account, subscribe, unsubscribe, selectedConversation, setSelectedConversation, schedulePostStreamRefetch, setConversationDetails]) }, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch])
return( return(
-1
View File
@@ -29,7 +29,6 @@ describe('ConversationPrompt', () => {
expect(prompt.message).toBe('hello'); expect(prompt.message).toBe('hello');
expect(prompt.user_created).toBe(true); expect(prompt.user_created).toBe(true);
expect(prompt.created_timestamp).toEqual(created); expect(prompt.created_timestamp).toEqual(created);
expect(prompt.localKey).toBe('id-7');
}); });
}); });
+1 -21
View File
@@ -18,16 +18,10 @@ export interface ConversationPromptType {
tokens_out?: number | null, tokens_out?: number | null,
citations?: Citation[], citations?: Citation[],
feedback?: PromptFeedbackState | null, feedback?: PromptFeedbackState | null,
file_type?: string | null,
image?: string | null,
} }
let localPromptKey = 0;
export class ConversationPrompt{ export class ConversationPrompt{
id: number | undefined; id: number | undefined;
/** Stable React key. Optimistic turns have no server id yet (#136). */
localKey: string = '';
message: string = ''; message: string = '';
user_created: boolean = false; user_created: boolean = false;
created_timestamp: Date = new Date(); created_timestamp: Date = new Date();
@@ -35,15 +29,9 @@ export class ConversationPrompt{
tokens_out: number | null = null; tokens_out: number | null = null;
citations: Citation[] = []; citations: Citation[] = [];
feedback: PromptFeedbackState | null = null; feedback: PromptFeedbackState | null = null;
file_type: string | null = null;
image: string | null = null;
constructor(initializer?: any){ constructor(initializer?: any){
if(!initializer) { if(!initializer) return;
localPromptKey += 1;
this.localKey = `local-${localPromptKey}`;
return;
}
if (initializer.id) this.id = initializer.id; if (initializer.id) this.id = initializer.id;
if (initializer.message) this.message = initializer.message; if (initializer.message) this.message = initializer.message;
if (initializer.user_created) this.user_created = initializer.user_created; if (initializer.user_created) this.user_created = initializer.user_created;
@@ -53,14 +41,6 @@ export class ConversationPrompt{
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out; if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
if (Array.isArray(initializer.citations)) this.citations = initializer.citations; if (Array.isArray(initializer.citations)) this.citations = initializer.citations;
if (initializer.feedback !== undefined) this.feedback = initializer.feedback; 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,7 +20,6 @@ import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
import { exportChat, type ExportFormat } from "../../utils/export/exportChat"; import { exportChat, type ExportFormat } from "../../utils/export/exportChat";
import { showToast } from "../../utils/toastBus"; import { showToast } from "../../utils/toastBus";
import type { PromptRating } from "../../utils/promptFeedback"; import type { PromptRating } from "../../utils/promptFeedback";
import { CHAT_FILE_ACCEPT, imageSrcForPrompt, isImageFile } from "../../utils/visionAttachment";
// Styled Components // Styled Components
const PageContainer = styled.div` const PageContainer = styled.div`
@@ -335,46 +334,6 @@ const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.El
return <div ref={elementRef} />; 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 AsyncDashboardInner = (): JSX.Element => {
const [, , , sendMessage, isConnected, connectionStatus] = const [, , , sendMessage, isConnected, connectionStatus] =
useContext(WebSocketContext); useContext(WebSocketContext);
@@ -473,15 +432,9 @@ const AsyncDashboardInner = (): JSX.Element => {
} }
try { try {
clearActivity(); clearActivity();
const attachedImage = file && isImageFile(file) ? URL.createObjectURL(file) : null;
const tempConversations: ConversationPrompt[] = [ const tempConversations: ConversationPrompt[] = [
...conversationDetails, ...conversationDetails,
new ConversationPrompt({ new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
message: trimmedPrompt,
user_created: true,
image: attachedImage,
file_type: fileType,
}),
new ConversationPrompt({ message: "", user_created: false }), new ConversationPrompt({ message: "", user_created: false }),
]; ];
@@ -504,8 +457,6 @@ const AsyncDashboardInner = (): JSX.Element => {
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
} }
const fileInput = document.getElementById("file-upload") as HTMLInputElement | null;
if (fileInput) fileInput.value = "";
} catch (e) { } catch (e) {
console.log(`error ${e}`); console.log(`error ${e}`);
} }
@@ -622,7 +573,7 @@ const AsyncDashboardInner = (): JSX.Element => {
: convo_detail.message; : convo_detail.message;
return ( return (
<ConversationDetailCard <ConversationDetailCard
key={convo_detail.localKey} key={convo_detail.id || index}
message={displayMessage} message={displayMessage}
user_created={convo_detail.user_created} user_created={convo_detail.user_created}
promptId={convo_detail.id} promptId={convo_detail.id}
@@ -640,7 +591,6 @@ const AsyncDashboardInner = (): JSX.Element => {
activityDetail={isLiveStream ? activityDetail : null} activityDetail={isLiveStream ? activityDetail : null}
activityHistory={isLiveStream ? activityHistory : []} activityHistory={isLiveStream ? activityHistory : []}
activityInterrupted={isLiveStream && streamInterrupted} activityInterrupted={isLiveStream && streamInterrupted}
imageSrc={imageSrcForPrompt(convo_detail.image, convo_detail.file_type)}
/> />
); );
}) })
@@ -702,7 +652,7 @@ const AsyncDashboardInner = (): JSX.Element => {
<VisuallyHiddenInput <VisuallyHiddenInput
id="file-upload" id="file-upload"
type="file" type="file"
accept={CHAT_FILE_ACCEPT} accept=".csv,.xlsx,.txt,.pdf,.PDF"
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
@@ -753,7 +703,16 @@ const AsyncDashboardInner = (): JSX.Element => {
</span> </span>
</Tooltip> </Tooltip>
</StyledInputContainer> </StyledInputContainer>
<AttachmentPreview file={formik.values.file} /> {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>
)}
</Form> </Form>
)} )}
</Formik> </Formik>
@@ -52,7 +52,7 @@ describe('LandingPage', () => {
expect(screen.getByRole('heading', { name: 'Business accounts' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Business accounts' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Roadmap' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Roadmap' })).toBeInTheDocument();
expect(screen.getByText(/Image generation is on the roadmap/i)).toBeInTheDocument(); expect(screen.getByText(/Image generation is on the roadmap/i)).toBeInTheDocument();
expect(screen.getByText(/Hesychia reads the picture/i)).toBeInTheDocument(); expect(screen.getByText(/Vision is on the roadmap/i)).toBeInTheDocument();
expect( expect(
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i), screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -3,13 +3,29 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { MemoryRouter, Route, Routes } from 'react-router-dom';
import PasswordReset from './PasswordReset'; import PasswordReset from './PasswordReset';
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY } from './recaptchaV3';
const mockPost = jest.fn(); const mockPost = jest.fn();
const mockExecute = jest.fn(); const mockExecuteAsync = jest.fn();
const mockReset = jest.fn();
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); 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', () => ({ jest.mock('../../../axiosApi', () => ({
cleanAxiosInstance: { cleanAxiosInstance: {
post: (...args: unknown[]) => mockPost(...args), post: (...args: unknown[]) => mockPost(...args),
@@ -32,23 +48,13 @@ const renderPasswordReset = () =>
describe('PasswordReset', () => { describe('PasswordReset', () => {
beforeEach(() => { beforeEach(() => {
mockPost.mockReset(); mockPost.mockReset();
mockExecute.mockReset(); mockExecuteAsync.mockReset();
mockExecute.mockResolvedValue('captcha-token'); mockReset.mockReset();
mockExecuteAsync.mockResolvedValue('captcha-token');
mockPost.mockResolvedValue({ status: 200 }); mockPost.mockResolvedValue({ status: 200 });
window.grecaptcha = {
ready: (callback) => callback(),
execute: mockExecute,
};
}); });
afterEach(() => { it('submits email with captcha token and navigates to confirmation', async () => {
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(); const user = userEvent.setup();
renderPasswordReset(); renderPasswordReset();
@@ -56,9 +62,6 @@ describe('PasswordReset', () => {
await user.click(screen.getByRole('button', { name: 'Reset Password' })); await user.click(screen.getByRole('button', { name: 'Reset Password' }));
await waitFor(() => { await waitFor(() => {
expect(mockExecute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
action: RECAPTCHA_ACTION,
});
expect(mockPost).toHaveBeenCalledWith('user/reset_password/', { expect(mockPost).toHaveBeenCalledWith('user/reset_password/', {
email: 'user@example.com', email: 'user@example.com',
recaptchaToken: 'captcha-token', recaptchaToken: 'captcha-token',
@@ -67,20 +70,6 @@ describe('PasswordReset', () => {
expect(await screen.findByText('Confirmation page')).toBeInTheDocument(); 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 () => { it('shows error when request fails', async () => {
mockPost.mockRejectedValue(new Error('network')); mockPost.mockRejectedValue(new Error('network'));
const user = userEvent.setup(); const user = userEvent.setup();
@@ -1,11 +1,11 @@
import { Form, Formik, Field } from 'formik'; import { Form, Formik, Field } from 'formik';
import React, { useEffect, useState } from 'react'; import React, { useRef, useState } from 'react';
import { cleanAxiosInstance } from '../../../axiosApi'; import { cleanAxiosInstance } from '../../../axiosApi';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ReCAPTCHA from 'react-google-recaptcha';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import styled from 'styled-components'; import styled from 'styled-components';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
const PageContainer = styled.div` const PageContainer = styled.div`
position: relative; position: relative;
@@ -139,20 +139,20 @@ const validationSchema = Yup.object().shape({
const PasswordReset = (): JSX.Element => { const PasswordReset = (): JSX.Element => {
const navigate = useNavigate(); const navigate = useNavigate();
const recaptchaRef = useRef<ReCAPTCHA>(null);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
loadRecaptcha().catch(() => {
// Submit reports a failure if the v3 script never becomes ready.
});
}, []);
const handlePasswordResetEmail = async ({ const handlePasswordResetEmail = async ({
email, email,
}: EmailPasswordResetValues): Promise<void> => { }: EmailPasswordResetValues): Promise<void> => {
setErrorMessage(''); setErrorMessage('');
try { try {
const token = await executePasswordResetCaptcha(); let token = '';
if (recaptchaRef.current) {
token = (await recaptchaRef.current.executeAsync()) || '';
recaptchaRef.current.reset();
}
await cleanAxiosInstance.post('user/reset_password/', { await cleanAxiosInstance.post('user/reset_password/', {
email, email,
recaptchaToken: token, recaptchaToken: token,
@@ -186,6 +186,14 @@ const PasswordReset = (): JSX.Element => {
placeholder="Email Address" placeholder="Email Address"
type="email" type="email"
/> />
<div style={{ display: 'flex', justifyContent: 'center' }}>
<ReCAPTCHA
ref={recaptchaRef}
sitekey="6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ"
size="invisible"
theme="dark"
/>
</div>
<StyledButton type="submit" disabled={formik.isSubmitting}> <StyledButton type="submit" disabled={formik.isSubmitting}>
Reset Password Reset Password
</StyledButton> </StyledButton>
@@ -1,53 +0,0 @@
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');
});
});
@@ -1,59 +0,0 @@
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;
}
+2 -4
View File
@@ -27,7 +27,7 @@ describe('public crawler files', () => {
expect(sitemap.startsWith('<?xml')).toBe(true); expect(sitemap.startsWith('<?xml')).toBe(true);
expect(llms).toContain('$10/month'); expect(llms).toContain('$10/month');
expect(llms).toContain('locked for the lifetime of the account'); expect(llms).toContain('locked for the lifetime of the account');
expect(llms).toContain('Upload an image with a prompt and Hesychia reads the picture'); expect(llms).toContain('Image generation and vision are on the roadmap');
expect(llms).not.toContain('<html'); expect(llms).not.toContain('<html');
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>'); expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
@@ -56,9 +56,7 @@ describe('public crawler files', () => {
expect(html).toContain( expect(html).toContain(
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.', 'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
); );
expect(html).toContain( expect(html).toContain('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.',
);
expect(html).not.toContain('>here<'); expect(html).not.toContain('>here<');
expect(html.match(/application\/ld\+json/g)).toHaveLength(1); expect(html.match(/application\/ld\+json/g)).toHaveLength(1);
expect(html).toContain('"@type": "WebPage"'); expect(html).toContain('"@type": "WebPage"');
+1 -1
View File
@@ -32,7 +32,7 @@ const HOME = {
roadmapImage: roadmapImage:
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.', 'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
roadmapVision: roadmapVision:
'Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.', 'Vision is on the roadmap, so you can ask questions about an image you upload.',
}; };
const PRICING = { const PRICING = {
@@ -1,61 +0,0 @@
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,
]);
});
});
-22
View File
@@ -1,22 +0,0 @@
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);
}
@@ -1,22 +0,0 @@
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);
});
});
@@ -1,20 +0,0 @@
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}`;
}