Author SHA1 Message Date
westfarn d6cfdeb6f0 Default chat to THINKING; open citations in new tab (#106, #108).
Drop Sources card; inline [n] opens the source URL. Lock composer to THINKING while model picker stays hidden.
2026-08-04 12:22:05 -05:00
westfarn a13108b840 Remove FAST/THINKING model dropdown from chat composer (#106).
Unit Tests / test (pull_request) Successful in 12s
Hide mode selection from users; keep sending fixed FAST so the backend contract stays intact.
2026-08-04 12:16:44 -05:00
114 changed files with 388 additions and 4819 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.
+13 -15
View File
@@ -40,13 +40,19 @@ npm run android:sync
Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`.
### Play Billing (#78)
### RevenueCat IAP (#100)
Native billing uses the local Capacitor plugin `native-store-billing` (Play Billing on Android, StoreKit on iOS). Web keeps Stripe Checkout / Customer Portal.
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
The Play product id is the plan's `store_product_id`. The backend verifies the purchase token with the Play Developer API (`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
After plugin changes: `npm run build:mobile` (runs `cap sync`).
| Variable | Purpose |
|----------|---------|
| `REACT_APP_REVENUECAT_APPLE_API_KEY` | iOS public SDK key |
| `REACT_APP_REVENUECAT_GOOGLE_API_KEY` | Android public SDK key |
| `REACT_APP_REVENUECAT_OFFERING_ID` | Optional offering pin (else current) |
After install / key changes: `npm run build:mobile` (runs `cap sync`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
## Versioning
@@ -126,20 +132,12 @@ Out of band ops (not automated here):
1. Google Play developer account
2. Privacy policy URL (product site / legal)
3. **Account deletion URL** (Data safety / Account deletion) → public page:
- Canonical: `https://hesychia.ai/account-deletion/`
- Legacy host until DNS cutover: `https://chat.aimloperations.com/account-deletion/`
- Capacitor HashRouter: `/#/account-deletion/`
4. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
5. Screenshots for phone (+ tablet if targeting)
6. Upload `.aab` to **internal testing** track before production
In-app delete remains Account → **Delete my account** (`DELETE /api/user/`). The public URL
above is for Play reviewers and users who never open the app (#103).
3. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
4. Screenshots for phone (+ tablet if targeting)
5. Upload `.aab` to **internal testing** track before production
## Related issues
- [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — this Android wrap
- [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — iOS (reuses this scaffolding)
- [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing
- [#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103) — Play public account-deletion URL
+25 -8
View File
@@ -1,23 +1,40 @@
# Monetization (web Stripe + native store billing)
# Monetization (web Stripe + native RevenueCat)
## Channels
| Runtime | Checkout / manage | Ledger |
|---------|-------------------|--------|
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
| Native Capacitor | StoreKit (iOS) and Play Billing (Android) | `POST /monetization/store/confirm/` verifies the store transaction and writes the same invoice/payment tables |
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
There is no App Store, Play, or RevenueCat webhook. After the store sheet succeeds, the app sends the signed StoreKit transaction or the Play purchase token. The backend checks it before granting access. Renewals and restores send the current store entitlement again. Access ends when the verified period end passes.
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` (`app_store`, `play_store`, or legacy `revenuecat`) plus `revenuecat_store`.
## App user id
## Store account token
RevenueCat `appUserID` = Django user pk as string.
`GET /monetization/subscription/` returns `store_account_token`. The native purchase passes that value as StoreKit `appAccountToken` and Play Billing `obfuscatedAccountId`. The backend rejects a token that belongs to a different user, and rejects a transaction already linked to another account.
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
Plan rows expose `store_product_id` (the store product identifier). Configure it on `SubscriptionPlan.revenuecat_product_id`, or set `REVENUECAT_PRODUCT_PLAN_MAP` so a product id maps to a plan slug.
Backend webhook resolver accepts numeric pk (or email fallback).
## Mobile env keys
In `.env.mobile`:
- `REACT_APP_REVENUECAT_APPLE_API_KEY`
- `REACT_APP_REVENUECAT_GOOGLE_API_KEY`
- `REACT_APP_REVENUECAT_OFFERING_ID` (optional)
Package identifiers in the RC dashboard should include plan slugs (e.g. `founders`) so `purchasePlan(planSlug)` can match packages/products. Optional backend `REVENUECAT_PRODUCT_PLAN_MAP` maps product id → plan slug.
## Auth hooks
- `Purchases.configure` once on native (first purchase / logIn).
- `logIn(appUserID)` after SignIn / SignUp / AuthCallback / AccountContext load.
- `logOut` on Header2 sign-out and Delete account.
## Related
- Backend issue #78
- Issue #100 (FE) + companion backend RevenueCat webhook PR
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
+3 -9
View File
@@ -107,15 +107,9 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
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):
- `https://hesychia.ai/account-deletion/` (preferred product host)
- or `https://chat.aimloperations.com/account-deletion/` until hesychia.ai cutover
4. **Testing → Internal testing** → create release → upload `app-release.aab`.
5. Add testers, roll out internal track, verify on devices.
6. Promote to closed / open / production only after internal QA passes.
See also [`../ANDROID.md`](../ANDROID.md) Play checklist and issue
[#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103).
3. **Testing → Internal testing** → create release → upload `app-release.aab`.
4. Add testers, roll out internal track, verify on devices.
5. Promote to closed / open / production only after internal QA passes.
Each new upload needs a higher `versionCode`.
@@ -13,7 +13,6 @@ dependencies {
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar')
implementation project(':native-store-billing')
}
-3
View File
@@ -13,6 +13,3 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/@capaci
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':native-store-billing'
project(':native-store-billing').projectDir = new File('../plugins/native-store-billing/android')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

+4 -4
View File
@@ -283,7 +283,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -334,7 +334,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -350,7 +350,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
@@ -370,7 +370,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
+1 -2
View File
@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '15.0'
platform :ios, '14.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -15,7 +15,6 @@ def capacitor_pods
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'NativeStoreBilling', :path => '../../plugins/native-store-billing'
end
target 'App' do
+56 -124
View File
@@ -19,9 +19,9 @@
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@heycatch/sdk": "^0.8.3",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
@@ -39,7 +39,6 @@
"lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1",
"native-store-billing": "file:plugins/native-store-billing",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6",
@@ -64,9 +63,11 @@
"@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"
}
},
@@ -3082,103 +3083,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@heycatch/sdk": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@heycatch/sdk/-/sdk-0.8.3.tgz",
"integrity": "sha512-7knqXfptgctAIHu5IXwd/WnivY+wwYAws2Xr0ZuARottBnbQteFbxZP7TyEDWCyz6A5+PcGlzlPxeTdq5uyGoA==",
"license": "MIT",
"dependencies": {
"posthog-react-native": "^4.61.1"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"expo-router": "*",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@heycatch/sdk/node_modules/posthog-react-native": {
"version": "4.81.2",
"resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.81.2.tgz",
"integrity": "sha512-w9UUocpnS9u+CO+37Q32W/zSgL8/fSFhIB9tUZfSDc1D4rE7XShyfCwwXD6efjmP19Gu941kuXdRgTbuV8Mkig==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.59.1",
"@posthog/types": "^1.416.0"
},
"bin": {
"posthog-react-native-xcode": "tooling/posthog-xcode.sh"
},
"peerDependencies": {
"@posthog/react-native-plugin": ">= 2.9.3",
"@react-native-async-storage/async-storage": ">=1.0.0",
"@react-navigation/native": ">= 5.0.0",
"expo-application": ">= 4.0.0",
"expo-device": ">= 4.0.0",
"expo-file-system": ">= 13.0.0",
"expo-localization": ">= 11.0.0",
"expo-updates": ">= 0.25.0",
"posthog-react-native-session-replay": ">= 1.6.0",
"react-native-device-info": ">= 10.0.0",
"react-native-localize": ">= 3.0.0",
"react-native-navigation": ">= 6.0.0",
"react-native-safe-area-context": ">= 4.0.0",
"react-native-svg": ">= 15.0.0"
},
"peerDependenciesMeta": {
"@posthog/react-native-plugin": {
"optional": true
},
"@react-native-async-storage/async-storage": {
"optional": true
},
"@react-navigation/native": {
"optional": true
},
"expo-application": {
"optional": true
},
"expo-device": {
"optional": true
},
"expo-file-system": {
"optional": true
},
"expo-localization": {
"optional": true
},
"expo-updates": {
"optional": true
},
"posthog-react-native-session-replay": {
"optional": true
},
"react-native-device-info": {
"optional": true
},
"react-native-localize": {
"optional": true
},
"react-native-navigation": {
"optional": true
},
"react-native-safe-area-context": {
"optional": true
},
"react-native-svg": {
"optional": true
}
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -5251,21 +5155,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@posthog/core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.59.1.tgz",
"integrity": "sha512-P39YgzQVDh6oz6wAzQXEnoHgTYpM7gifonmVKq1scAJco79h31XmmNVurRq9tTmmyIrr1uX8FHT0riCsyfV9AQ==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.416.0"
}
},
"node_modules/@posthog/types": {
"version": "1.416.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz",
"integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==",
"license": "MIT"
},
"node_modules/@prettier/plugin-xml": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-2.2.0.tgz",
@@ -5350,6 +5239,24 @@
"react": ">=16.14.0"
}
},
"node_modules/@revenuecat/purchases-capacitor": {
"version": "11.3.2",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-capacitor/-/purchases-capacitor-11.3.2.tgz",
"integrity": "sha512-3T4/lcAwpbPagrT4DuXvJ+8RewzFmHf3fQJQuhVy+uTQk5HGMHNlXcm2A/UdyHtWqU+/VqWbCClPFYGnpnNXAQ==",
"license": "MIT",
"dependencies": {
"@revenuecat/purchases-typescript-internal-esm": "17.25.0"
},
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@revenuecat/purchases-typescript-internal-esm": {
"version": "17.25.0",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal-esm/-/purchases-typescript-internal-esm-17.25.0.tgz",
"integrity": "sha512-KC4BjFaQclXqFafG1Enh7t8GSwdg8p905by7UrHq0WSJmLhOR6yXSj6WDv1iwsFSAZxPPwgozNxt+U6IOFTdCA==",
"license": "MIT"
},
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -6538,6 +6445,15 @@
"@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",
@@ -16723,10 +16639,6 @@
"node": ">= 10.x"
}
},
"node_modules/native-store-billing": {
"resolved": "plugins/native-store-billing",
"link": true
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -19079,6 +18991,19 @@
"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",
@@ -19277,6 +19202,19 @@
"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",
@@ -24046,12 +23984,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"plugins/native-store-billing": {
"version": "0.1.0",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"vendor/brace-expansion-compat": {
"name": "brace-expansion",
"version": "5.0.8",
+7 -6
View File
@@ -14,9 +14,9 @@
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@heycatch/sdk": "^0.8.3",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11",
"@revenuecat/purchases-capacitor": "^11.3.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
@@ -34,7 +34,6 @@
"lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1",
"native-store-billing": "file:plugins/native-store-billing",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6",
@@ -54,9 +53,9 @@
},
"scripts": {
"start": "NODE_ENV=development react-scripts start",
"build": "node scripts/writeSitemap.js && NODE_ENV=production react-scripts build && node scripts/stampCanonicals.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": "NODE_ENV=production react-scripts build",
"build:prod": "NODE_ENV=production react-scripts build && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/",
"build:beta": "bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && 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",
"android:open": "npx cap open android",
"android:sync": "npx cap sync android",
@@ -64,7 +63,7 @@
"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",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
"eject": "react-scripts eject"
},
"eslintConfig": {
@@ -92,9 +91,11 @@
"@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": {
@@ -1,17 +0,0 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
Pod::Spec.new do |s|
s.name = 'NativeStoreBilling'
s.version = package['version']
s.summary = package['description']
s.license = 'MIT'
s.homepage = 'https://aimloperations.com'
s.author = 'AI ML Operations'
s.source = { :path => '.' }
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
s.ios.deployment_target = '15.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end
@@ -1,30 +0,0 @@
apply plugin: 'com.android.library'
android {
namespace "ai.hesychia.chat.billing"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1
versionName "1.0"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
lintOptions {
abortOnError false
}
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "com.android.billingclient:billing:7.1.1"
}
@@ -1,2 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -1,243 +0,0 @@
package ai.hesychia.chat.billing;
import android.app.Activity;
import com.android.billingclient.api.AcknowledgePurchaseParams;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.PendingPurchasesParams;
import com.android.billingclient.api.ProductDetails;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.QueryPurchasesParams;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.json.JSONObject;
@CapacitorPlugin(name = "NativeStoreBilling")
public class NativeStoreBillingPlugin extends Plugin implements PurchasesUpdatedListener {
private BillingClient billingClient;
private PluginCall pendingPurchase;
private String pendingProductId = "";
@PluginMethod
public void purchase(PluginCall call) {
String productId = call.getString("productId", "");
String accountToken = call.getString("accountToken", "");
if (productId == null || productId.isEmpty()) {
call.reject("productId is required");
return;
}
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() ->
connect(call, () -> queryAndLaunch(call, productId, accountToken == null ? "" : accountToken))
);
}
@PluginMethod
public void restore(PluginCall call) {
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() -> connect(call, () -> queryOwned(call, "")));
}
private void connect(PluginCall call, Runnable whenReady) {
if (billingClient != null && billingClient.isReady()) {
whenReady.run();
return;
}
billingClient = BillingClient.newBuilder(getContext())
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build();
billingClient.startConnection(
new BillingClientStateListener() {
@Override
public void onBillingSetupFinished(BillingResult billingResult) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
whenReady.run();
return;
}
call.reject("Play Billing setup failed: " + billingResult.getDebugMessage());
}
@Override
public void onBillingServiceDisconnected() {
// The next purchase reconnects.
}
}
);
}
private void queryAndLaunch(PluginCall call, String productId, String accountToken) {
QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder()
.setProductList(
Collections.singletonList(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
)
.build();
billingClient.queryProductDetailsAsync(params, (billingResult, productDetailsList) -> {
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
call.reject("Could not load Play product: " + billingResult.getDebugMessage());
return;
}
if (productDetailsList == null || productDetailsList.isEmpty()) {
call.reject("Play product was not found: " + productId);
return;
}
ProductDetails details = productDetailsList.get(0);
List<ProductDetails.SubscriptionOfferDetails> offers = details.getSubscriptionOfferDetails();
if (offers == null || offers.isEmpty()) {
call.reject("Play product has no subscription offer");
return;
}
String offerToken = offers.get(0).getOfferToken();
BillingFlowParams.ProductDetailsParams productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(offerToken)
.build();
BillingFlowParams.Builder flow = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(Collections.singletonList(productParams));
if (!accountToken.isEmpty()) {
flow.setObfuscatedAccountId(accountToken);
}
Activity activity = getActivity();
if (activity == null) {
call.reject("Play Billing needs a foreground activity");
return;
}
activity.runOnUiThread(() -> {
pendingPurchase = call;
pendingProductId = productId;
BillingResult launched = billingClient.launchBillingFlow(activity, flow.build());
if (launched.getResponseCode() != BillingClient.BillingResponseCode.OK) {
pendingPurchase = null;
call.reject("Could not open Play purchase sheet: " + launched.getDebugMessage());
}
});
});
}
@Override
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
PluginCall call = pendingPurchase;
pendingPurchase = null;
if (call == null) {
return;
}
int code = billingResult.getResponseCode();
if (code == BillingClient.BillingResponseCode.USER_CANCELED) {
JSObject data = new JSObject();
data.put("userCancelled", true);
call.reject("Purchase cancelled", "PURCHASE_CANCELLED", null, data);
return;
}
if (code == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {
queryOwned(call, pendingProductId);
return;
}
if (code != BillingClient.BillingResponseCode.OK || purchases == null || purchases.isEmpty()) {
call.reject("Play purchase failed: " + billingResult.getDebugMessage());
return;
}
Purchase match = null;
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
continue;
}
if (pendingProductId.isEmpty() || purchase.getProducts().contains(pendingProductId)) {
match = purchase;
break;
}
}
if (match == null) {
call.reject("Play purchase did not complete");
return;
}
acknowledge(match);
call.resolve(purchaseToJson(match));
}
private void queryOwned(PluginCall call, String productId) {
QueryPurchasesParams params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build();
billingClient.queryPurchasesAsync(params, (billingResult, purchases) -> {
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
call.reject("Could not restore Play purchases: " + billingResult.getDebugMessage());
return;
}
JSArray rows = new JSArray();
Purchase single = null;
List<Purchase> owned = purchases == null ? new ArrayList<>() : purchases;
for (Purchase purchase : owned) {
if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
continue;
}
if (!productId.isEmpty() && !purchase.getProducts().contains(productId)) {
continue;
}
acknowledge(purchase);
JSONObject row = purchaseToJson(purchase);
rows.put(row);
single = purchase;
}
if (!productId.isEmpty()) {
if (single == null) {
call.reject("No active Play purchase for " + productId);
return;
}
call.resolve(purchaseToJson(single));
return;
}
JSObject result = new JSObject();
result.put("transactions", rows);
call.resolve(result);
});
}
private void acknowledge(Purchase purchase) {
if (purchase.isAcknowledged() || billingClient == null) {
return;
}
AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build();
billingClient.acknowledgePurchase(params, billingResult -> { });
}
private JSObject purchaseToJson(Purchase purchase) {
String productId = purchase.getProducts().isEmpty() ? "" : purchase.getProducts().get(0);
String orderId = purchase.getOrderId() == null ? purchase.getPurchaseToken() : purchase.getOrderId();
JSObject row = new JSObject();
row.put("store", "play_store");
row.put("productId", productId);
row.put("transactionId", orderId);
row.put("purchaseToken", purchase.getPurchaseToken());
row.put("signedTransaction", "");
return row;
}
}
-1
View File
@@ -1 +0,0 @@
export {};
-2
View File
@@ -1,2 +0,0 @@
// Native code is loaded by Capacitor. The web app registers the plugin from src.
module.exports = {};
@@ -1,94 +0,0 @@
import Foundation
import Capacitor
import StoreKit
@objc(NativeStoreBillingPlugin)
public class NativeStoreBillingPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "NativeStoreBillingPlugin"
public let jsName = "NativeStoreBilling"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "restore", returnType: CAPPluginReturnPromise)
]
@objc func purchase(_ call: CAPPluginCall) {
let productId = call.getString("productId") ?? ""
let accountToken = call.getString("accountToken") ?? ""
if productId.isEmpty {
call.reject("productId is required")
return
}
Task {
do {
let products = try await Product.products(for: [productId])
guard let product = products.first else {
call.reject("App Store product was not found: \(productId)")
return
}
var options = Set<Product.PurchaseOption>()
if let uuid = UUID(uuidString: accountToken) {
options.insert(.appAccountToken(uuid))
}
let result = try await product.purchase(options: options)
switch result {
case .success(let verification):
let transaction = try Self.checkVerified(verification)
let payload = Self.payload(transaction: transaction, jws: verification.jwsRepresentation)
await transaction.finish()
call.resolve(payload)
case .userCancelled:
call.reject("Purchase cancelled", "PURCHASE_CANCELLED", nil, ["userCancelled": true])
case .pending:
call.reject("App Store purchase is still pending")
@unknown default:
call.reject("Unknown App Store purchase result")
}
} catch {
call.reject(error.localizedDescription)
}
}
}
@objc func restore(_ call: CAPPluginCall) {
Task {
var rows: [JSObject] = []
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
if transaction.revocationDate != nil {
continue
}
rows.append(Self.payload(transaction: transaction, jws: result.jwsRepresentation))
}
call.resolve(["transactions": rows])
}
}
private static func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw NSError(
domain: "NativeStoreBilling",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "App Store could not verify this transaction"]
)
case .verified(let safe):
return safe
}
}
private static func payload(transaction: Transaction, jws: String) -> JSObject {
var row: JSObject = [
"store": "app_store",
"productId": transaction.productID,
"transactionId": String(transaction.id),
"purchaseToken": "",
"signedTransaction": jws
]
if let expires = transaction.expirationDate {
row["expiresAt"] = ISO8601DateFormatter().string(from: expires)
}
return row
}
}
@@ -1,26 +0,0 @@
{
"name": "native-store-billing",
"version": "0.1.0",
"private": true,
"description": "StoreKit and Play Billing purchases for the Hesychia Capacitor shell.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"capacitor": {
"ios": {
"src": "ios"
},
"android": {
"src": "android"
}
},
"files": [
"android/src/main/",
"android/build.gradle",
"ios/Sources",
"NativeStoreBilling.podspec",
"dist/"
],
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
}
-48
View File
@@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>The page is not found — Hesychia</title>
<meta name="description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta property="og:title" content="The page is not found — Hesychia" />
<meta property="og:description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta property="og:url" content="https://hesychia.ai/404.html" />
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="The page is not found — Hesychia" />
<meta name="twitter:description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"headline": "The page is not found — Hesychia",
"url": "https://hesychia.ai/404.html",
"mainEntityOfPage": "https://hesychia.ai/404.html"
},
{
"@type": "Organization",
"name": "Hesychia",
"url": "https://hesychia.ai",
"logo": "https://hesychia.ai/apple-touch-icon.png",
"parentOrganization": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
}
]
}
</script>
</head>
<body>
<h1>Not Found</h1>
<p>The page is not found, and this address is not a Hesychia page.</p>
<p>Open the home page to read what the product does, what it costs, and how to start an account.</p>
<nav aria-label="Related pages"><a href="/">What Hesychia is</a><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></nav>
</body>
</html>
@@ -1 +0,0 @@
8d1667dd71c6ef137171989c26eeb632
+6 -57
View File
@@ -8,62 +8,11 @@
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta name="description" content="Hesychia — a calm AI assistant by AI ML Operations, LLC" />
<meta name="application-name" content="Hesychia" />
<link rel="canonical" href="https://hesychia.ai/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Hesychia — quiet, focused AI chat" />
<meta property="og:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta property="og:url" content="https://hesychia.ai/" />
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hesychia — quiet, focused AI chat" />
<meta name="twitter:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"headline": "Hesychia — quiet, focused AI chat",
"url": "https://hesychia.ai/",
"mainEntityOfPage": "https://hesychia.ai/"
},
{
"@type": "Organization",
"name": "Hesychia",
"url": "https://hesychia.ai",
"logo": "https://hesychia.ai/apple-touch-icon.png",
"parentOrganization": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
},
{
"@type": "SoftwareApplication",
"name": "Hesychia",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"url": "https://hesychia.ai/",
"description": "Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files.",
"offers": {
"@type": "Offer",
"price": "10.00",
"priceCurrency": "USD"
},
"provider": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
}
]
}
</script>
<meta property="og:title" content="Hesychia" />
<meta property="og:description" content="Hesychia — quiet, focused AI chat at hesychia.ai" />
<meta property="og:url" content="https://hesychia.ai" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
@@ -79,11 +28,11 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Hesychia — quiet, focused AI chat</title>
<title>Hesychia</title>
</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>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"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
-18
View File
@@ -1,18 +0,0 @@
# Hesychia
> 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.
## Pages
- [Home](https://hesychia.ai/): what Hesychia is, who it is for, and how to start
- [Pricing](https://hesychia.ai/pricing/): $10/month early adopter, locked for life, never goes up
- [Privacy](https://hesychia.ai/privacy/): what is stored, what is not sold, what cancel does to documents
- [Changelog](https://hesychia.ai/changelog/): dated product updates
- [Terms of Service](https://hesychia.ai/terms_of_service/): the binding terms
- [Account deletion](https://hesychia.ai/account-deletion/): how to delete an account and what soft-delete keeps
## Operator
AI ML Operations, LLC — https://www.aimloperations.com
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

+1 -14
View File
@@ -1,16 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Allow: /
Disallow: /signin/
Disallow: /signup/
Disallow: /account/
Disallow: /document_storage
Disallow: /feedback/
Disallow: /analytics/
Disallow: /password_reset/
Disallow: /password_reset_confirmation/
Disallow: /set_password/
Disallow: /auth/
Disallow: /billing/
Sitemap: https://hesychia.ai/sitemap.xml
Disallow:
-27
View File
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://hesychia.ai/</loc>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/pricing/</loc>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/privacy/</loc>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/changelog/</loc>
<lastmod>2026-08-04</lastmod>
</url>
<url>
<loc>https://hesychia.ai/terms_of_service/</loc>
<lastmod>2026-07-27</lastmod>
</url>
<url>
<loc>https://hesychia.ai/account-deletion/</loc>
<lastmod>2026-08-04</lastmod>
</url>
</urlset>
-40
View File
@@ -1,40 +0,0 @@
/**
* One IndexNow POST for the URLs in the sitemap. Run after the prod files are published.
*/
const fs = require('fs');
const path = require('path');
const { INDEXNOW_KEY, indexNowBody, sitemapEntries } = require('./sitemap');
async function pingIndexNow(fetchImpl = fetch, urls) {
const keyPath = path.join(__dirname, '..', 'public', `${INDEXNOW_KEY}.txt`);
const key = fs.readFileSync(keyPath, 'utf8').trim();
if (key !== INDEXNOW_KEY) {
throw new Error(`IndexNow key file does not match ${INDEXNOW_KEY}`);
}
const urlList = urls || sitemapEntries().map((entry) => entry.loc);
const body = indexNowBody(key, urlList);
const response = await fetchImpl('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await response.text();
if (response.status !== 200 && response.status !== 202) {
throw new Error(`IndexNow ${response.status}: ${text}`);
}
return { status: response.status, body };
}
async function main() {
const result = await pingIndexNow();
process.stdout.write(`IndexNow ${result.status}\n`);
}
module.exports = { pingIndexNow };
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}
-153
View File
@@ -1,153 +0,0 @@
/**
* Sitemap lastmod comes from the page source, not from the build clock.
* Changelog uses the newest entry date. Terms use the "Last Updated" line.
* Other public pages use the last git commit that touched that file.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const ORIGIN = 'https://hesychia.ai';
const INDEXNOW_KEY = '8d1667dd71c6ef137171989c26eeb632';
const INDEXNOW_HOST = 'hesychia.ai';
const llmFe = path.resolve(__dirname, '..');
const repoRoot = path.resolve(llmFe, '..');
/** Public pages only. Same URLs the site already lists for crawlers. */
const SITEMAP_PAGES = [
{
path: '/',
file: 'llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx',
lastmod: 'git',
},
{
path: '/pricing/',
file: 'llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx',
lastmod: 'git',
},
{
path: '/privacy/',
file: 'llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx',
lastmod: 'git',
},
{
path: '/changelog/',
file: 'llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts',
lastmod: 'changelog',
},
{
path: '/terms_of_service/',
file: 'llm-fe/src/llm-fe/components/legal/TermsOfServiceDocument.tsx',
lastmod: 'terms',
},
{
path: '/account-deletion/',
file: 'llm-fe/src/llm-fe/pages/AccountDeletion/AccountDeletionPage.tsx',
lastmod: 'git',
},
];
const MONTHS = {
January: '01',
February: '02',
March: '03',
April: '04',
May: '05',
June: '06',
July: '07',
August: '08',
September: '09',
October: '10',
November: '11',
December: '12',
};
function newestChangelogDate(source) {
const dates = [...source.matchAll(/date:\s*'(\d{4}-\d{2}-\d{2})'/g)].map((match) => match[1]);
if (dates.length === 0) {
throw new Error('changelog entries have no dates');
}
return dates.sort().at(-1);
}
function termsUpdated(source) {
const match = source.match(/Last Updated:\s+([A-Z][a-z]+)\s+(\d{1,2}),\s+(\d{4})/);
if (!match) {
throw new Error('terms have no Last Updated date');
}
const month = MONTHS[match[1]];
if (!month) {
throw new Error(`unknown month in terms date: ${match[1]}`);
}
return `${match[3]}-${month}-${match[2].padStart(2, '0')}`;
}
function gitLastmod(file) {
const out = execFileSync('git', ['log', '-1', '--format=%cs', '--', file], {
cwd: repoRoot,
encoding: 'utf8',
}).trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(out)) {
throw new Error(`no git commit date for ${file}`);
}
return out;
}
function lastmodFor(page) {
const abs = path.join(repoRoot, page.file);
const source = fs.readFileSync(abs, 'utf8');
if (page.lastmod === 'changelog') return newestChangelogDate(source);
if (page.lastmod === 'terms') return termsUpdated(source);
return gitLastmod(page.file);
}
function sitemapEntries() {
return SITEMAP_PAGES.map((page) => ({
loc: `${ORIGIN}${page.path}`,
lastmod: lastmodFor(page),
}));
}
function renderSitemap(entries) {
const urls = entries
.map(
(entry) => ` <url>
<loc>${entry.loc}</loc>
<lastmod>${entry.lastmod}</lastmod>
</url>`,
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`;
}
function writeSitemap() {
const xml = renderSitemap(sitemapEntries());
fs.writeFileSync(path.join(llmFe, 'public', 'sitemap.xml'), xml);
return xml;
}
function indexNowBody(key, urls) {
return {
host: INDEXNOW_HOST,
key,
keyLocation: `https://${INDEXNOW_HOST}/${key}.txt`,
urlList: urls,
};
}
module.exports = {
INDEXNOW_KEY,
SITEMAP_PAGES,
newestChangelogDate,
termsUpdated,
gitLastmod,
sitemapEntries,
renderSitemap,
writeSitemap,
indexNowBody,
};
-87
View File
@@ -1,87 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const {
newestChangelogDate,
termsUpdated,
renderSitemap,
gitLastmod,
indexNowBody,
INDEXNOW_KEY,
} = require('./sitemap');
const { pingIndexNow } = require('./pingIndexNow');
const repoRoot = path.resolve(__dirname, '../..');
test('changelog lastmod is the newest entry date', () => {
const source = `
{ date: '2026-08-01', title: 'Documents' },
{ date: '2026-08-04', title: 'Account deletion' },
`;
assert.equal(newestChangelogDate(source), '2026-08-04');
});
test('terms lastmod is the Last Updated line, not today', () => {
assert.equal(
termsUpdated('Last Updated: July 27, 2026'),
'2026-07-27',
);
});
test('live page sources supply lastmod', () => {
const changelog = fs.readFileSync(
path.join(repoRoot, 'llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts'),
'utf8',
);
const terms = fs.readFileSync(
path.join(repoRoot, 'llm-fe/src/llm-fe/components/legal/TermsOfServiceDocument.tsx'),
'utf8',
);
assert.equal(newestChangelogDate(changelog), '2026-08-04');
assert.equal(termsUpdated(terms), '2026-07-27');
for (const file of [
'llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx',
'llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx',
'llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx',
'llm-fe/src/llm-fe/pages/AccountDeletion/AccountDeletionPage.tsx',
]) {
try {
assert.match(gitLastmod(file), /^\d{4}-\d{2}-\d{2}$/);
} catch (error) {
if (!String(error.message).includes('no git commit date')) throw error;
}
}
const xml = renderSitemap([
{ loc: 'https://hesychia.ai/changelog/', lastmod: newestChangelogDate(changelog) },
{ loc: 'https://hesychia.ai/terms_of_service/', lastmod: termsUpdated(terms) },
]);
assert.match(xml, /<loc>https:\/\/hesychia\.ai\/changelog\/<\/loc>\n\s*<lastmod>2026-08-04<\/lastmod>/);
assert.match(xml, /<lastmod>2026-07-27<\/lastmod>/);
assert.equal(xml.includes('<html'), false);
});
test('IndexNow POST body lists the published URLs and the hosted key', async () => {
const urls = ['https://hesychia.ai/', 'https://hesychia.ai/pricing/'];
assert.deepEqual(indexNowBody(INDEXNOW_KEY, urls), {
host: 'hesychia.ai',
key: INDEXNOW_KEY,
keyLocation: `https://hesychia.ai/${INDEXNOW_KEY}.txt`,
urlList: urls,
});
let posted;
const result = await pingIndexNow(async (url, init) => {
posted = { url, init };
return { status: 202, text: async () => '' };
}, urls);
assert.equal(posted.url, 'https://api.indexnow.org/indexnow');
assert.equal(posted.init.method, 'POST');
assert.equal(posted.init.headers['Content-Type'], 'application/json');
const body = JSON.parse(posted.init.body);
assert.equal(body.keyLocation, `https://hesychia.ai/${INDEXNOW_KEY}.txt`);
assert.deepEqual(body.urlList, urls);
assert.equal(result.status, 202);
});
-175
View File
@@ -1,175 +0,0 @@
/**
* CRA serves one index.html for every route. A canonical in that shared file
* would point every page at the homepage. After `react-scripts build`, write a
* copy per route whose canonical href is that route's own absolute URL.
*/
const fs = require('fs');
const path = require('path');
const { ORIGIN, OG_IMAGE, PAGE_PATHS, h1ForPath, titleForPath, descriptionForPath } = require('../src/llm-fe/utils/canonicals');
const { relatedNavHtml } = require('../src/llm-fe/utils/internalLinks');
const { proseHtml } = require('../src/llm-fe/utils/pageProse');
const { jsonLdHtml } = require('../src/llm-fe/utils/structuredData');
const CANONICAL_RE = /<link\s+rel=["']canonical["']\s+href=["'][^"']*["']\s*\/?>/i;
const H1_RE = /<h1[^>]*>[\s\S]*?<\/h1>/i;
const TITLE_RE = /<title>[^<]*<\/title>/i;
const DESCRIPTION_RE = /<meta\s+name=["']description["']\s+content=["'][^"']*["']\s*\/?>/i;
const RELATED_NAV_RE = /<nav\s+aria-label=["']Related pages["']>[\s\S]*?<\/nav>/i;
const PROSE_RE = /<div id="crawler-prose">[\s\S]*?<\/div>/i;
const JSONLD_SOURCE = '<script type="application\\/ld\\+json">[\\s\\S]*?<\\/script>';
/**
* @param {string} html
* @param {'property' | 'name'} attr
* @param {string} key
* @param {string} content
* @returns {string}
*/
function replaceSocial(html, attr, key, content) {
const re = new RegExp(
`<meta\\s+${attr}=["']${key}["']\\s+content=["'][^"']*["']\\s*\\/?>`,
'i',
);
if (!re.test(html)) {
throw new Error(`missing meta ${attr}="${key}"`);
}
return html.replace(re, () => `<meta ${attr}="${key}" content="${content}" />`);
}
/**
* @param {string} homeHtml
* @param {string} pathname
* @returns {string}
*/
function htmlForPath(homeHtml, pathname) {
const found = homeHtml.match(new RegExp(CANONICAL_RE.source, 'gi'));
if (!found || found.length !== 1) {
throw new Error(
`expected exactly one <link rel="canonical"> in index.html, found ${found ? found.length : 0}`,
);
}
const url = `${ORIGIN}${pathname}`;
let next = homeHtml.replace(CANONICAL_RE, `<link rel="canonical" href="${url}" />`);
const h1s = next.match(/<h1\b/gi);
if (!h1s || h1s.length !== 1) {
throw new Error(`expected exactly one <h1> in index.html, found ${h1s ? h1s.length : 0}`);
}
const headline = h1ForPath(pathname);
if (!headline) {
throw new Error(`no h1 for ${pathname}`);
}
next = next.replace(H1_RE, `<h1>${headline}</h1>`);
if (next.match(/<h1\b/gi).length !== 1) {
throw new Error(`stamped ${pathname} does not have exactly one h1`);
}
const titles = next.match(/<title\b/gi);
if (!titles || titles.length !== 1) {
throw new Error(`expected exactly one <title> in index.html, found ${titles ? titles.length : 0}`);
}
const title = titleForPath(pathname);
if (!title) {
throw new Error(`no title for ${pathname}`);
}
next = next.replace(TITLE_RE, `<title>${title}</title>`);
if (next.match(/<title\b/gi).length !== 1) {
throw new Error(`stamped ${pathname} does not have exactly one title`);
}
const descriptions = next.match(/<meta\s+name=["']description["']/gi);
if (!descriptions || descriptions.length !== 1) {
throw new Error(
`expected exactly one meta description in index.html, found ${descriptions ? descriptions.length : 0}`,
);
}
const description = descriptionForPath(pathname);
if (!description) {
throw new Error(`no description for ${pathname}`);
}
next = next.replace(DESCRIPTION_RE, `<meta name="description" content="${description}" />`);
if (next.match(/<meta\s+name=["']description["']/gi).length !== 1) {
throw new Error(`stamped ${pathname} does not have exactly one meta description`);
}
next = replaceSocial(next, 'property', 'og:title', title);
next = replaceSocial(next, 'property', 'og:description', description);
next = replaceSocial(next, 'property', 'og:url', url);
next = replaceSocial(next, 'name', 'twitter:title', title);
next = replaceSocial(next, 'name', 'twitter:description', description);
if (!next.includes(`property="og:image" content="${OG_IMAGE}"`)) {
throw new Error(`stamped ${pathname} is missing the 1200x630 og:image`);
}
if (!next.includes('name="twitter:card" content="summary_large_image"')) {
throw new Error(`stamped ${pathname} is missing twitter:card`);
}
const prose = proseHtml(pathname);
if (PROSE_RE.test(next)) {
next = next.replace(PROSE_RE, () => prose);
} else {
next = next.replace(/<\/h1>/i, () => `</h1>${prose}`);
}
const nav = relatedNavHtml(pathname);
if (RELATED_NAV_RE.test(next)) {
next = next.replace(RELATED_NAV_RE, () => nav);
} else {
next = next.replace(/<\/h1>/i, () => `</h1>${nav}`);
}
if (next.match(/<nav\s+aria-label=["']Related pages["']/gi)?.length !== 1) {
throw new Error(`stamped ${pathname} does not have exactly one related-pages nav`);
}
const block = jsonLdHtml(pathname);
const jsonLdRe = new RegExp(JSONLD_SOURCE, 'gi');
const existing = next.match(jsonLdRe);
if (existing) {
let replaced = false;
next = next.replace(new RegExp(JSONLD_SOURCE, 'gi'), () => {
if (replaced) return '';
replaced = true;
return block;
});
} else {
next = next.replace(/<\/head>/i, () => `${block}\n</head>`);
}
const scripts = next.match(/<script type="application\/ld\+json">/gi);
if (!scripts || scripts.length !== 1) {
throw new Error(`stamped ${pathname} should have one JSON-LD script, found ${scripts ? scripts.length : 0}`);
}
const parsed = JSON.parse(next.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/i)[1]);
const page = parsed['@graph'] ? parsed['@graph'].find((node) => node['@type'] === 'WebPage') : parsed;
if (!page || page.headline !== title) {
throw new Error(`JSON-LD headline for ${pathname} does not match the title`);
}
return next;
}
/**
* @param {string} buildDir
* @param {string} pathname
* @returns {string}
*/
function destForPath(buildDir, pathname) {
if (pathname === '/') return path.join(buildDir, 'index.html');
if (pathname.endsWith('/')) {
return path.join(buildDir, pathname.slice(1), 'index.html');
}
// Exact URI with no extension. nginx serves these with default_type text/html
// so /document_storage is this file, not the SPA fallback.
return path.join(buildDir, pathname.slice(1));
}
/**
* @param {string} buildDir
*/
function stampCanonicals(buildDir) {
const indexPath = path.join(buildDir, 'index.html');
const homeHtml = fs.readFileSync(indexPath, 'utf8');
for (const pathname of PAGE_PATHS) {
const dest = destForPath(buildDir, pathname);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, htmlForPath(homeHtml, pathname));
}
}
module.exports = { htmlForPath, destForPath, stampCanonicals, CANONICAL_RE };
if (require.main === module) {
const buildDir = process.argv[2] || path.resolve(__dirname, '../build');
stampCanonicals(buildDir);
}
-134
View File
@@ -1,134 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { PAGE_PATHS, h1ForPath, titleForPath, descriptionForPath } = require('../src/llm-fe/utils/canonicals');
const { htmlForPath, stampCanonicals } = require('./stampCanonicals');
const HOME = `<!DOCTYPE html><html><head>
<link rel="canonical" href="https://hesychia.ai/" />
<meta property="og:title" content="Hesychia — quiet, focused AI chat" />
<meta property="og:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta property="og:url" content="https://hesychia.ai/" />
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hesychia — quiet, focused AI chat" />
<meta name="twitter:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
<meta name="description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
<title>Hesychia — quiet, focused AI chat</title>
</head><body><div id="root"><h1>Hesychia is a calm AI assistant.</h1></div></body></html>`;
test('each page canonical is that page, and other head tags stay', () => {
const html = htmlForPath(HOME, '/pricing/');
assert.match(html, /<link rel="canonical" href="https:\/\/hesychia\.ai\/pricing\/" \/>/);
assert.equal(html.match(/rel="canonical"/g).length, 1);
assert.match(html, /property="og:url" content="https:\/\/hesychia\.ai\/pricing\/"/);
assert.match(html, /property="og:title" content="Hesychia pricing — \$10\/month, no ads"/);
assert.match(html, /property="og:description" content="\$10\/month, flat\. No ads, no upsells, one price\./);
assert.match(html, /property="og:image" content="https:\/\/hesychia\.ai\/og-image\.png"/);
assert.match(html, /name="twitter:card" content="summary_large_image"/);
assert.match(html, /name="twitter:title" content="Hesychia pricing — \$10\/month, no ads"/);
const nav = html.match(/<nav aria-label="Related pages">([\s\S]*?)<\/nav>/);
assert.ok(nav);
const anchors = nav[1].match(/<a href=/g);
assert.ok(anchors && anchors.length >= 2 && anchors.length <= 5);
assert.match(nav[1], /href="\/">What Hesychia is</);
assert.doesNotMatch(nav[1], /href="\/pricing\/"/);
assert.doesNotMatch(nav[1], />here</);
assert.match(html, /id="crawler-prose"/);
assert.equal(html.match(/application\/ld\+json/g).length, 1);
const pricingLd = JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
const pricingPage = pricingLd['@graph'].find((node) => node['@type'] === 'WebPage');
assert.equal(pricingPage.headline, 'Hesychia pricing — $10/month, no ads');
assert.equal(pricingPage.url, 'https://hesychia.ai/pricing/');
assert.match(html, /\$10\/month, flat, with no ads, no upsells, and one price/);
assert.doesNotMatch(
html.match(/<div id="crawler-prose">([\s\S]*?)<\/div>/)[1],
/Quiet, focused AI chat/,
);
assert.match(html, /<title>Hesychia pricing — \$10\/month, no ads<\/title>/);
assert.equal(html.match(/<title\b/g).length, 1);
assert.match(html, /name="description" content="\$10\/month, flat\. No ads, no upsells, one price\./);
assert.equal(html.match(/name="description"/g).length, 1);
assert.equal(html.match(/<h1\b/g).length, 1);
assert.match(html, /<h1>Pricing<\/h1>/);
});
test('stamp writes a shell per route and leaves the homepage self-referencing', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'canon-'));
fs.writeFileSync(path.join(dir, 'index.html'), HOME);
stampCanonicals(dir);
const home = fs.readFileSync(path.join(dir, 'index.html'), 'utf8');
assert.match(home, /href="https:\/\/hesychia\.ai\/"/);
const homeLd = JSON.parse(home.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
const homeOrg = homeLd['@graph'].find((node) => node['@type'] === 'Organization' && node.name === 'Hesychia');
assert.equal(homeOrg.url, 'https://hesychia.ai');
assert.equal(homeOrg.logo, 'https://hesychia.ai/apple-touch-icon.png');
assert.doesNotMatch(home, /href="https:\/\/hesychia\.ai\/pricing\/"/);
assert.equal(home.match(/<h1\b/g).length, 1);
assert.match(home, /<h1>Hesychia is a calm AI assistant.<\/h1>/);
const titles = PAGE_PATHS.map((pathname) => titleForPath(pathname));
assert.equal(new Set(titles).size, titles.length);
const descriptions = PAGE_PATHS.map((pathname) => descriptionForPath(pathname));
assert.equal(new Set(descriptions).size, descriptions.length);
for (const pathname of PAGE_PATHS) {
if (pathname === '/') continue;
const rel = pathname.endsWith('/')
? path.join(pathname.slice(1), 'index.html')
: pathname.slice(1);
const html = fs.readFileSync(path.join(dir, rel), 'utf8');
const title = titleForPath(pathname);
assert.match(html, new RegExp(`href="https://hesychia\\.ai${pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
assert.match(html, /name="twitter:card" content="summary_large_image"/);
const related = html.match(/<nav aria-label="Related pages">([\s\S]*?)<\/nav>/);
assert.ok(related, `${pathname} missing related nav`);
const relatedAnchors = related[1].match(/<a href=/g);
assert.ok(
relatedAnchors && relatedAnchors.length >= 2 && relatedAnchors.length <= 5,
`${pathname} related link count`,
);
assert.doesNotMatch(related[1], new RegExp(`href="${pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
assert.match(html, /property="og:image" content="https:\/\/hesychia\.ai\/og-image\.png"/);
assert.match(
html,
new RegExp(`property="og:title" content="${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
);
assert.equal(html.match(/rel="canonical"/g).length, 1);
assert.equal(html.match(/<h1\b/g).length, 1);
assert.match(html, new RegExp(`<h1>${h1ForPath(pathname).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</h1>`));
assert.ok(title.length >= 30 && title.length <= 60, `${pathname} title length ${title.length}`);
assert.match(html, new RegExp(`<title>${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</title>`));
assert.equal(html.match(/application\/ld\+json/g).length, 1);
const ld = JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
const webPage = ld['@graph'].find((node) => node['@type'] === 'WebPage');
assert.equal(webPage.headline, title, `${pathname} JSON-LD headline`);
const org = ld['@graph'].find((node) => node['@type'] === 'Organization' && node.name === 'Hesychia');
assert.ok(org, `${pathname} missing Organization`);
assert.equal(org.url, 'https://hesychia.ai');
assert.equal(org.logo, 'https://hesychia.ai/apple-touch-icon.png');
if (pathname === '/changelog/') {
const posts = ld['@graph'].filter((node) => node['@type'] === 'BlogPosting');
assert.ok(posts.length >= 2);
for (const post of posts) {
assert.ok(post.headline);
assert.equal(post.author.name, 'AI ML Operations, LLC');
assert.match(post.datePublished, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(post.dateModified, post.datePublished);
}
}
const description = descriptionForPath(pathname);
assert.ok(
description.length >= 110 && description.length <= 160,
`${pathname} description length ${description.length}`,
);
assert.match(
html,
new RegExp(`content="${description.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
);
}
});
-3
View File
@@ -1,3 +0,0 @@
const { writeSitemap } = require('./sitemap');
writeSitemap();
+2 -13
View File
@@ -11,8 +11,8 @@ import NotFound from './llm-fe/pages/NotFound/NotFound';
import { AuthContext } from './llm-fe/contexts/AuthContext';
import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService';
import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage';
import AccountDeletionPage from './llm-fe/pages/AccountDeletion/AccountDeletionPage';
import SetPassword from './llm-fe/components/SetPassword/SetPassword';
import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2';
import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2';
import DocumentStoragePage from './llm-fe/pages/DocumentStoragePage/DocumentStoragePage';
import Account2 from './llm-fe/pages/Account2/Account2';
@@ -25,11 +25,6 @@ import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/A
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
import ToastHost from './llm-fe/components/ToastHost/ToastHost';
import HomeRoute from './llm-fe/components/HomeRoute/HomeRoute';
import RouteMeta from './llm-fe/components/RouteMeta/RouteMeta';
import PricingPage from './llm-fe/pages/Pricing/PricingPage';
import PrivacyPage from './llm-fe/pages/Privacy/PrivacyPage';
import ChangelogPage from './llm-fe/pages/Changelog/ChangelogPage';
const ProtectedRoutes = () => {
const { authenticated, loading } = useContext(AuthContext);
@@ -56,7 +51,6 @@ class App extends Component {
<AnalyticsSession />
<ToastHost />
<AnalyticsConsentBanner />
<RouteMeta />
<div className='site'>
<main>
<div className="main-container">
@@ -77,14 +71,9 @@ class App extends Component {
{/* Public legal pages (also used for post-login ToS acknowledge) */}
<Route path={"/terms_of_service/"} Component={TermsOfService} />
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
{/* Play Console account-deletion URL (#103) — must stay public */}
<Route path={"/account-deletion/"} Component={AccountDeletionPage} />
<Route path={"/"} Component={HomeRoute} />
<Route path={"/pricing/"} Component={PricingPage} />
<Route path={"/privacy/"} Component={PrivacyPage} />
<Route path={"/changelog/"} Component={ChangelogPage} />
<Route element={<ProtectedRoutes />}>
<Route path={"/"} index={true} Component={AsyncDashboard2} />
<Route path={"/account/"} Component={Account2} />
<Route path={"/document_storage"} Component={DocumentStoragePage} />
<Route path={"/feedback/"} Component={FeedbackPage2} />
+5 -14
View File
@@ -11,7 +11,6 @@ import {
isTokenNotValidError,
} from "./llm-fe/auth/jwtHelpers";
import { redirectToAppPath } from "./llm-fe/platform/nativePlatform";
import { shouldRedirectToSignIn } from "./llm-fe/auth/authRedirect";
const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
@@ -98,10 +97,8 @@ axiosInstance.interceptors.response.use(
if (error.response.status === 401 && isRefreshCall) {
await clearTokens();
applyAccessToken(null);
// Stay on the product page. Only bounce protected app routes.
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
redirectToAppPath("/signin/");
return Promise.reject(error);
}
@@ -112,9 +109,7 @@ axiosInstance.interceptors.response.use(
if (!refresh_token) {
await clearTokens();
applyAccessToken(null);
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
redirectToAppPath("/signin/");
return Promise.reject(error);
}
@@ -132,18 +127,14 @@ axiosInstance.interceptors.response.use(
} catch (err) {
await clearTokens();
applyAccessToken(null);
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
redirectToAppPath("/signin/");
return Promise.reject(err);
}
}
await clearTokens();
applyAccessToken(null);
if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
redirectToAppPath("/signin/");
}
return Promise.reject(error);
-1
View File
@@ -1,4 +1,3 @@
import './llm-fe/utils/heycatch';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
@@ -1,15 +0,0 @@
import { shouldRedirectToSignIn } from './authRedirect';
describe('shouldRedirectToSignIn', () => {
it('keeps a logged-out visitor on the product page', () => {
expect(shouldRedirectToSignIn('/public/usage/', '/')).toBe(false);
expect(shouldRedirectToSignIn('/public/settings/', '/pricing/')).toBe(false);
expect(shouldRedirectToSignIn('/user/get/', '/')).toBe(false);
expect(shouldRedirectToSignIn('/user/get/', '/changelog/')).toBe(false);
});
it('still sends an expired session on the app to sign in', () => {
expect(shouldRedirectToSignIn('/user/get/', '/account/')).toBe(true);
expect(shouldRedirectToSignIn('/token/refresh/', '/document_storage')).toBe(true);
});
});
-38
View File
@@ -1,38 +0,0 @@
/** Public marketing routes. An auth failure here must not replace the page with Sign In. */
const PUBLIC_PATHS = new Set([
"/",
"/pricing",
"/privacy",
"/changelog",
"/terms_of_service",
"/legal/terms-of-service",
"/account-deletion",
"/signin",
"/signup",
"/password_reset",
"/password_reset_confirmation",
"/set_password",
]);
export function normalizeAppPath(pathname: string | null | undefined): string {
if (!pathname || pathname === "/") return "/";
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
export function isPublicApiRequest(requestUrl: string | null | undefined): boolean {
return String(requestUrl || "").includes("/public/");
}
export function isPublicAppPath(pathname: string | null | undefined): boolean {
return PUBLIC_PATHS.has(normalizeAppPath(pathname));
}
export function shouldRedirectToSignIn(
requestUrl: string | null | undefined,
pathname: string | null | undefined,
): boolean {
if (isPublicApiRequest(requestUrl)) return false;
if (isPublicAppPath(pathname)) return false;
return true;
}
@@ -3,7 +3,6 @@ import { AccountContext } from '../../contexts/AccountContext';
import { AuthContext } from '../../contexts/AuthContext';
import { useAnalyticsConsent } from '../../contexts/AnalyticsConsentContext';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { identifyHeyCatch, resetHeyCatch } from '../../utils/heycatch';
/**
* Identify on authenticated session; track Logout when session ends.
@@ -16,18 +15,7 @@ const AnalyticsSession = (): null => {
const wasAuthenticatedRef = useRef<boolean | null>(null);
useEffect(() => {
if (loading) return;
if (account) {
identifyHeyCatch(account);
} else if (wasAuthenticatedRef.current && !authenticated) {
resetHeyCatch();
}
if (!hasConsent) {
wasAuthenticatedRef.current = authenticated;
return;
}
if (!hasConsent || loading) return;
if (account) {
identifyAccount(account);
@@ -367,9 +367,7 @@ const BillingSection = (): JSX.Element => {
date: payment.paid_at || payment.created,
description: 'Payment',
provider:
['revenuecat', 'app_store', 'play_store'].includes(
(payment.provider || '').toLowerCase()
)
(payment.provider || '').toLowerCase() === 'revenuecat'
? 'Store'
: (payment.provider || '').toLowerCase() === 'stripe'
? 'Stripe'
@@ -416,7 +414,9 @@ const BillingSection = (): JSX.Element => {
...(planSlug ? { plan_slug: planSlug } : {}),
});
await purchasePlan(planSlug);
setActionNotice('Purchase complete.');
setActionNotice(
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
);
await loadBilling();
} catch (error: unknown) {
if (isPurchaseCancelledError(error)) {
@@ -170,7 +170,6 @@ type ConversationDetailCardProps = {
activityDetail?: string | null;
activityHistory?: ActivityHistoryEntry[];
activityInterrupted?: boolean;
imageSrc?: string | null;
};
const MyPlot = ({ format, image }: { format: string; image: string }) => {
@@ -208,7 +207,6 @@ const ConversationDetailCard = ({
activityDetail = null,
activityHistory = [],
activityInterrupted = false,
imageSrc = null,
}: ConversationDetailCardProps): JSX.Element => {
const CitationMark = useCallback(
({ indices }: { indices?: string }) => {
@@ -318,19 +316,6 @@ 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' }}
@@ -25,8 +25,5 @@ describe('Footer', () => {
'href',
'/terms_of_service/',
);
expect(screen.getByRole('link', { name: 'Pricing' })).toHaveAttribute('href', '/pricing/');
expect(screen.getByRole('link', { name: 'Privacy' })).toHaveAttribute('href', '/privacy/');
expect(screen.getByRole('link', { name: 'Changelog' })).toHaveAttribute('href', '/changelog/');
});
});
@@ -1,7 +1,6 @@
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import MDTypography from '../../ui-kit/components/MDTypography';
import { PUBLIC_FOOTER_LINKS } from '../PublicSite/publicNav';
const Footer = (): JSX.Element => {
return (
@@ -9,12 +8,8 @@ const Footer = (): JSX.Element => {
<MDTypography>
&copy; 2026 Hesychia | Developed by{' '}
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
{PUBLIC_FOOTER_LINKS.map((link) => (
<React.Fragment key={link.to}>
{' · '}
<RouterLink to={link.to}>{link.label}</RouterLink>
</React.Fragment>
))}
{' · '}
<RouterLink to="/terms_of_service/">Terms of Service</RouterLink>
</MDTypography>
</div>
);
@@ -1,47 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import HomeRoute from './HomeRoute';
import { AuthContext } from '../../contexts/AuthContext';
jest.mock('../../pages/AsyncDashboard2/AsyncDashboard2', () => () => <div>Chat app</div>);
jest.mock('../../pages/Landing/LandingPage', () => () => <h1>Hesychia is a calm AI assistant.</h1>);
const renderHome = (authenticated: boolean, loading: boolean) => {
render(
<MemoryRouter>
<AuthContext.Provider
value={{
authenticated,
setAuthentication: () => {},
needsNewPassword: false,
setNeedsNewPassword: () => {},
loading,
}}
>
<HomeRoute />
</AuthContext.Provider>
</MemoryRouter>,
);
};
describe('HomeRoute', () => {
it('shows a loading state before auth is known', () => {
renderHome(false, true);
expect(screen.getByText('Loading')).toBeInTheDocument();
expect(screen.queryByText('Chat app')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /calm AI assistant/i })).not.toBeInTheDocument();
});
it('shows the public landing page when signed out', () => {
renderHome(false, false);
expect(screen.getByRole('heading', { name: /calm AI assistant/i })).toBeInTheDocument();
expect(screen.queryByText('Chat app')).not.toBeInTheDocument();
});
it('keeps the chat app on / for a signed-in user', () => {
renderHome(true, false);
expect(screen.getByText('Chat app')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /calm AI assistant/i })).not.toBeInTheDocument();
});
});
@@ -1,21 +0,0 @@
import { useContext } from 'react';
import { AuthContext } from '../../contexts/AuthContext';
import AsyncDashboard2 from '../../pages/AsyncDashboard2/AsyncDashboard2';
import LandingPage from '../../pages/Landing/LandingPage';
/** Logged-out `/` is the public site. Logged-in `/` stays the chat. */
const HomeRoute = (): JSX.Element => {
const { authenticated, loading } = useContext(AuthContext);
if (loading) {
return <div>Loading</div>;
}
if (authenticated) {
return <AsyncDashboard2 />;
}
return <LandingPage />;
};
export default HomeRoute;
@@ -1,222 +0,0 @@
import React, { FormEvent, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
import { publicAccent, publicCard, publicLine, publicText } from './publicColors';
const Stack = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
max-width: 28rem;
`;
const Row = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
`;
const Primary = styled(Link)`
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
text-decoration: none;
border-radius: 0.5rem;
padding: 0.9rem 1.25rem;
font-weight: 600;
font-size: 1rem;
`;
const Secondary = styled(Link)`
color: ${publicAccent};
font-weight: 600;
`;
const Quiet = styled(Link)`
color: ${publicText};
opacity: 0.75;
font-size: 0.95rem;
`;
const Form = styled.form`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
width: 100%;
`;
const Label = styled.label`
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
`;
const Input = styled.input`
flex: 1 1 14rem;
background: ${publicCard};
border: 1px solid ${publicLine};
border-radius: 0.5rem;
padding: 0.9rem 1rem;
color: ${publicText};
font-size: 1rem;
`;
const Submit = styled.button`
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 0.5rem;
color: #fff;
padding: 0.9rem 1.25rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
`;
const Note = styled.p`
margin: 0;
color: ${publicText};
font-size: 0.95rem;
`;
type GetStartedProps = {
source: 'landing' | 'pricing';
idPrefix?: string;
showPricingLink?: boolean;
};
/**
* Create account when registration is on. Email waitlist when it is off or unknown.
* Settings default to off in the API, so a failed lookup stays on the waitlist.
*/
const GetStarted = ({
source,
idPrefix = 'waitlist',
showPricingLink = true,
}: GetStartedProps): JSX.Element => {
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
axiosInstance
.get('/public/settings/')
.then((response) => {
if (cancelled) return;
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
})
.catch(() => {
if (!cancelled) setRegistrationEnabled(false);
});
return () => {
cancelled = true;
};
}, []);
const onCreateAccount = () => {
trackEvent(AnalyticsEvents.LANDING_PRIMARY_CTA, { source, action: 'create_account' });
};
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = email.trim();
if (!trimmed) {
setError('Enter an email address.');
setMessage(null);
return;
}
setSubmitting(true);
setError(null);
setMessage(null);
try {
await axiosInstance.post('/public/waitlist/', { email: trimmed });
trackEvent(AnalyticsEvents.WAITLIST_JOINED, { source });
setMessage("You're on the list.");
setEmail('');
} catch {
setError('Could not join the waitlist. Try again.');
} finally {
setSubmitting(false);
}
};
if (registrationEnabled === null) {
return <Note>Checking signup…</Note>;
}
if (registrationEnabled) {
return (
<Stack>
<Row>
<Primary to="/signup/" onClick={onCreateAccount}>
Create account
</Primary>
{showPricingLink && (
<Secondary
to="/pricing/"
onClick={() => trackEvent(AnalyticsEvents.LANDING_PRICING_CTA, { source })}
>
See pricing
</Secondary>
)}
</Row>
<Quiet to="/signin/">Sign in</Quiet>
</Stack>
);
}
return (
<Stack>
<Form onSubmit={onSubmit}>
<Label htmlFor={`${idPrefix}-email`}>Email</Label>
<Input
id={`${idPrefix}-email`}
name="email"
type="email"
autoComplete="email"
placeholder="Email address"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<Submit type="submit" disabled={submitting}>
{submitting ? 'Joining…' : 'Join the waitlist'}
</Submit>
</Form>
{message && <Note role="status">{message}</Note>}
{error && <Note role="alert">{error}</Note>}
<Row>
{showPricingLink && (
<Secondary
to="/pricing/"
onClick={() => trackEvent(AnalyticsEvents.LANDING_PRICING_CTA, { source })}
>
See pricing
</Secondary>
)}
<Quiet to="/signin/">Sign in</Quiet>
</Row>
</Stack>
);
};
export default GetStarted;
@@ -1,136 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import ParticleBackground from '../ParticleBackground/ParticleBackground';
import RelatedPages from './RelatedPages';
import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav';
const text = ({ theme }: { theme?: { colors?: { text?: string } } }) =>
theme?.colors?.text ?? '#344767';
const line = ({ theme }: { theme?: { colors?: { cardBorder?: string } } }) =>
theme?.colors?.cardBorder ?? 'rgba(0, 0, 0, 0.08)';
const Page = styled.div`
position: relative;
z-index: 1;
min-height: 100vh;
background: transparent;
color: ${text};
font-family: Inter, sans-serif;
display: flex;
flex-direction: column;
`;
const Header = styled.header`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid ${line};
`;
const Brand = styled(Link)`
color: ${text};
font-weight: 700;
font-size: 1.15rem;
text-decoration: none;
letter-spacing: 0.02em;
`;
const Nav = styled.nav`
display: flex;
flex-wrap: wrap;
gap: 1rem 1.25rem;
align-items: center;
`;
const NavLink = styled(Link)`
color: ${text};
text-decoration: none;
font-size: 0.95rem;
opacity: 0.85;
&:hover {
opacity: 1;
}
`;
const SignInLink = styled(Link)`
color: ${text};
text-decoration: none;
border: 1px solid ${line};
border-radius: 0.5rem;
padding: 0.45rem 0.8rem;
font-size: 0.95rem;
`;
const Main = styled.main`
width: min(880px, 100%);
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
flex: 1;
`;
const FooterBar = styled.footer`
border-top: 1px solid ${line};
padding: 1.5rem;
color: ${text};
font-size: 0.9rem;
opacity: 0.85;
`;
const FooterNav = styled.nav`
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
margin-bottom: 0.75rem;
`;
const FooterLink = styled(Link)`
color: ${text};
`;
type PublicSiteLayoutProps = {
children: React.ReactNode;
};
const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => {
return (
<Page>
<ParticleBackground />
<Header>
<Brand to="/">Hesychia</Brand>
<Nav aria-label="Primary">
{PUBLIC_HEADER_LINKS.map((link) => (
<NavLink key={link.to} to={link.to}>
{link.label}
</NavLink>
))}
<SignInLink to="/signin/">Sign in</SignInLink>
</Nav>
</Header>
<Main>
{children}
<RelatedPages />
</Main>
<FooterBar>
<FooterNav aria-label="Footer">
{PUBLIC_FOOTER_LINKS.map((link) => (
<FooterLink key={link.to} to={link.to}>
{link.label}
</FooterLink>
))}
</FooterNav>
<div>
© 2026 Hesychia · Developed by{' '}
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
</div>
</FooterBar>
</Page>
);
};
export default PublicSiteLayout;
@@ -1,36 +0,0 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { linksForPath } from '../../utils/internalLinks';
import { publicAccent } from './publicColors';
const Nav = styled.nav`
margin-top: 2.5rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
`;
const Item = styled(Link)`
color: ${publicAccent};
`;
/** Same neighbours the static shell exposes to crawlers that do not run JavaScript. */
const RelatedPages = (): JSX.Element | null => {
const { pathname } = useLocation();
const links = linksForPath(pathname);
if (links.length < 2) return null;
return (
<Nav aria-label="Related pages">
{links.map((link) => (
<Item key={link.href} to={link.href}>
{link.label}
</Item>
))}
</Nav>
);
};
export default RelatedPages;
@@ -1,84 +0,0 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { formatPublicCount, shouldShowPublicUsage } from './publicCount';
import { publicText } from './publicColors';
const Section = styled.section`
margin-top: 1.5rem;
color: ${publicText};
font-size: 0.95rem;
`;
const Figures = styled.p`
margin: 0 0 0.35rem;
font-weight: 600;
`;
const AsOf = styled.p`
margin: 0;
color: ${publicText};
opacity: 0.7;
`;
type PublicUsage = {
account_count: number;
document_count: number;
as_of: string;
};
/**
* Live counts only. Hidden when the API fails, or until 20 accounts exist, so the page
* never shows a made-up number. Once shown, both figures are rounded down and marked with +.
* Avatars are omitted: no one has consented to publish a photo.
*/
const UsageCounts = (): JSX.Element | null => {
const [usage, setUsage] = useState<PublicUsage | null>(null);
useEffect(() => {
let cancelled = false;
axiosInstance
.get('/public/usage/')
.then((response) => {
if (cancelled) return;
const data = response.data;
if (
typeof data?.account_count !== 'number' ||
typeof data?.document_count !== 'number' ||
typeof data?.as_of !== 'string'
) {
return;
}
setUsage({
account_count: data.account_count,
document_count: data.document_count,
as_of: data.as_of,
});
})
.catch(() => {
/* leave hidden */
});
return () => {
cancelled = true;
};
}, []);
if (!usage || !shouldShowPublicUsage(usage.account_count)) return null;
const asOfDate = usage.as_of.slice(0, 10);
return (
<Section aria-label="Usage">
<Figures>
{formatPublicCount(usage.account_count, 'accounts')}
{' · '}
{formatPublicCount(usage.document_count, 'documents stored')}
</Figures>
<AsOf>
As of <time dateTime={usage.as_of}>{asOfDate}</time>
</AsOf>
</Section>
);
};
export default UsageCounts;
@@ -1,17 +0,0 @@
type PublicTheme = {
colors?: {
text?: string;
cardBackground?: string;
cardBorder?: string;
};
};
export const publicText = ({ theme }: { theme?: PublicTheme }) => theme?.colors?.text ?? '#344767';
export const publicCard = ({ theme }: { theme?: PublicTheme }) =>
theme?.colors?.cardBackground ?? 'rgba(255, 255, 255, 0.72)';
export const publicLine = ({ theme }: { theme?: PublicTheme }) =>
theme?.colors?.cardBorder ?? 'rgba(0, 0, 0, 0.08)';
export const publicAccent = '#667eea';
@@ -1,28 +0,0 @@
import { formatPublicCount, roundedPublicCount, shouldShowPublicUsage } from './publicCount';
describe('public usage counts', () => {
it('stays hidden until 20 accounts', () => {
expect(shouldShowPublicUsage(0)).toBe(false);
expect(shouldShowPublicUsage(19)).toBe(false);
expect(shouldShowPublicUsage(19.5)).toBe(false);
expect(shouldShowPublicUsage(20)).toBe(true);
expect(shouldShowPublicUsage(156)).toBe(true);
});
it('rounds down to one significant digit and marks the bucket with +', () => {
expect(roundedPublicCount(9)).toBeNull();
expect(roundedPublicCount(10)).toBe(10);
expect(roundedPublicCount(23)).toBe(20);
expect(roundedPublicCount(99)).toBe(90);
expect(roundedPublicCount(100)).toBe(100);
expect(roundedPublicCount(156)).toBe(100);
expect(roundedPublicCount(999)).toBe(900);
expect(roundedPublicCount(1000)).toBe(1000);
expect(roundedPublicCount(2500)).toBe(2000);
expect(formatPublicCount(4, 'documents stored')).toBe('under 10 documents stored');
expect(formatPublicCount(23, 'accounts')).toBe('20+ accounts');
expect(formatPublicCount(156, 'documents stored')).toBe('100+ documents stored');
expect(formatPublicCount(2500, 'accounts')).toBe('2,000+ accounts');
});
});
@@ -1,34 +0,0 @@
/** Hide live counts until this many accounts exist. Below that, the figures are noise. */
export const MIN_PUBLIC_ACCOUNTS = 20;
export function shouldShowPublicUsage(accountCount: number): boolean {
return Number.isInteger(accountCount) && accountCount >= MIN_PUBLIC_ACCOUNTS;
}
/** Largest power of ten that does not exceed count. 23 → 10, 156 → 100, 2500 → 1000. */
function stepFor(count: number): number {
let step = 1;
while (step * 10 <= count) step *= 10;
return step;
}
/**
* Floor to one significant digit so the public figure never exceeds the real count.
* Counts under 10 have no honest "+" bucket.
*/
export function roundedPublicCount(count: number): number | null {
if (!Number.isInteger(count) || count < 10) return null;
const step = stepFor(count);
return Math.floor(count / step) * step;
}
function grouped(value: number): string {
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/** "20+ accounts" or "under 10 documents stored". Noun is the already-plural label. */
export function formatPublicCount(count: number, noun: string): string {
const rounded = roundedPublicCount(count);
if (rounded === null) return `under 10 ${noun}`;
return `${grouped(rounded)}+ ${noun}`;
}
@@ -1,18 +0,0 @@
/** Shared public links. Header stays short; footer adds legal pages. No About until a founder is named. */
export type PublicNavLink = {
to: string;
label: string;
};
export const PUBLIC_HEADER_LINKS: readonly PublicNavLink[] = [
{ to: '/#product', label: 'Product' },
{ to: '/pricing/', label: 'Pricing' },
{ to: '/changelog/', label: 'Changelog' },
];
export const PUBLIC_FOOTER_LINKS: readonly PublicNavLink[] = [
...PUBLIC_HEADER_LINKS,
{ to: '/privacy/', label: 'Privacy' },
{ to: '/terms_of_service/', label: 'Terms of Service' },
];
@@ -1,19 +0,0 @@
import { useContext, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { AuthContext } from '../../contexts/AuthContext';
import { applyRouteMeta, metaForPath } from '../../utils/routeMeta';
/** Sets document title and canonical from the current route. */
const RouteMeta = (): null => {
const { pathname } = useLocation();
const { authenticated, loading } = useContext(AuthContext);
useEffect(() => {
if (loading) return;
applyRouteMeta(metaForPath(pathname, authenticated));
}, [pathname, authenticated, loading]);
return null;
};
export default RouteMeta;
@@ -139,7 +139,7 @@ const ErrorText = styled.p`
`;
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 {
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>(true);
const [useConversationContext, setUseConversationContext] = useState<boolean>(false);
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>
Remember this chat
Use previous conversations
<Tooltip title={CONTEXT_TOOLTIP}>
<span>
<InfoIcon aria-label={CONTEXT_TOOLTIP} />
@@ -1,25 +1,14 @@
import React from 'react';
import { Capacitor } from '@capacitor/core';
import { render } from '@testing-library/react';
import Tracker from './Tracker';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
const GTAG_SCRIPT_ID = 'hesychia-gtag';
const GTAG_SRC = 'https://www.googletagmanager.com/gtag/js?id=G-V12DLRLPWR';
jest.mock('@capacitor/core', () => ({
Capacitor: {
getPlatform: jest.fn(() => 'web'),
},
}));
describe('Tracker', () => {
const originalEnv = process.env;
beforeEach(() => {
(Capacitor.getPlatform as jest.Mock).mockReturnValue('web');
document.body.innerHTML = '';
document.head.innerHTML = '';
process.env = {
...originalEnv,
REACT_APP_DEPLOY_ENV: 'production',
@@ -39,11 +28,6 @@ describe('Tracker', () => {
expect(script).not.toBeNull();
expect(script?.src).toBe('https://tianji.aimloperations.com/tracker.js');
expect(script?.getAttribute('data-website-id')).toBe('cm7x7m52m03kbddswbswrt17y');
const gtag = document.getElementById(GTAG_SCRIPT_ID) as HTMLScriptElement | null;
expect(gtag?.src).toBe(GTAG_SRC);
expect(gtag?.async).toBe(true);
expect(window.gtag).toEqual(expect.any(Function));
});
it('loads distinct website id for beta builds', () => {
@@ -57,21 +41,6 @@ describe('Tracker', () => {
const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null;
expect(script?.getAttribute('data-website-id')).toBe('beta-website-id-test');
expect(document.getElementById(GTAG_SCRIPT_ID)).toBeNull();
});
it('loads Google tag on the iOS app even when the web env is beta', () => {
(Capacitor.getPlatform as jest.Mock).mockReturnValue('ios');
process.env = {
...originalEnv,
REACT_APP_DEPLOY_ENV: 'beta',
REACT_APP_TIANJI_WEBSITE_ID: 'beta-website-id-test',
};
render(<Tracker />);
const gtag = document.getElementById(GTAG_SCRIPT_ID) as HTMLScriptElement | null;
expect(gtag?.src).toBe(GTAG_SRC);
});
it('does not load Tianji tracker outside analytics environments', () => {
@@ -85,6 +54,5 @@ describe('Tracker', () => {
render(<Tracker />);
expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull();
expect(document.getElementById(GTAG_SCRIPT_ID)).toBeNull();
});
});
@@ -1,28 +1,8 @@
import React, { useEffect } from 'react';
import { flushAnalyticsQueue } from '../../utils/analytics';
import { isAnalyticsEnvironment } from '../../utils/analyticsConsent';
import { isProductAnalyticsEnabled } from '../../utils/productAnalytics';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
const GTAG_SCRIPT_ID = 'hesychia-gtag';
const GTAG_ID = 'G-V12DLRLPWR';
function loadGoogleTag(): void {
if (document.getElementById(GTAG_SCRIPT_ID)) return;
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag(...args: unknown[]) {
window.dataLayer?.push(args);
};
window.gtag('js', new Date());
window.gtag('config', GTAG_ID);
const script = document.createElement('script');
script.id = GTAG_SCRIPT_ID;
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${GTAG_ID}`;
document.head.appendChild(script);
}
/**
* Loads Tianji tracker.js for automatic page views on prod/beta builds.
@@ -30,35 +10,29 @@ function loadGoogleTag(): void {
*/
const Tracker: React.FC = () => {
useEffect(() => {
if (isProductAnalyticsEnabled()) {
loadGoogleTag();
}
if (!isAnalyticsEnvironment()) return;
const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || '';
if (isAnalyticsEnvironment() && websiteId && !document.getElementById(TRACKER_SCRIPT_ID)) {
const script = document.createElement('script');
script.id = TRACKER_SCRIPT_ID;
script.src = 'https://tianji.aimloperations.com/tracker.js';
script.async = true;
script.defer = true;
script.setAttribute('data-website-id', websiteId);
script.setAttribute('data-do-not-track', 'true');
script.onload = () => {
flushAnalyticsQueue();
};
if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return;
document.body.appendChild(script);
}
const script = document.createElement('script');
script.id = TRACKER_SCRIPT_ID;
script.src = 'https://tianji.aimloperations.com/tracker.js';
script.async = true;
script.defer = true;
script.setAttribute('data-website-id', websiteId);
script.setAttribute('data-do-not-track', 'true');
script.onload = () => {
flushAnalyticsQueue();
};
document.body.appendChild(script);
return () => {
const loaded = document.getElementById(TRACKER_SCRIPT_ID);
if (loaded?.parentNode) {
loaded.parentNode.removeChild(loaded);
}
const gtag = document.getElementById(GTAG_SCRIPT_ID);
if (gtag?.parentNode) {
gtag.parentNode.removeChild(gtag);
}
};
}, []);
@@ -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,
parseVersionedFrame,
} from "../utils/wsFrames";
import { parseChatErrorPayload } from "../utils/chatErrors";
import { sortPrompts } from "../utils/promptOrder";
type MessageProviderProps ={
children? : ReactNode;
@@ -62,7 +60,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
const {selectedConversation, setSelectedConversation} = useContext(ConversationContext);
const [stateMessage, setStateMessage] = useState<string>('')
const [conversationDetails, setConversationDetailsState] = useState<ConversationPrompt[]>([])
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
const [activityStage, setActivityStage] = useState<string | null>(null)
@@ -73,11 +71,6 @@ 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)
@@ -95,7 +88,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
setActivityHistory([])
}
const GetConversationDetails = useCallback(async (conversationId: number | undefined) => {
async function GetConversationDetails(conversationId: number | undefined) {
if (!conversationId) {
setConversationDetails([])
return
@@ -120,8 +113,6 @@ 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) {
@@ -130,11 +121,12 @@ 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)
@@ -145,11 +137,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
refetchTimerRef.current = setTimeout(() => {
void GetConversationDetails(conversationId)
}, 800)
}, [GetConversationDetails])
}, [])
useEffect(() => {
void GetConversationDetails(selectedConversation)
}, [selectedConversation, GetConversationDetails])
GetConversationDetails(selectedConversation)
}, [selectedConversation])
useEffect(() => {
return () => {
@@ -198,9 +190,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
}
if (selectedConversationRef.current) {
void GetConversationDetails(selectedConversationRef.current)
GetConversationDetails(selectedConversationRef.current)
}
}, [reconnectGeneration, GetConversationDetails, setConversationDetails])
}, [reconnectGeneration])
useEffect(() => {
/* 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'){
messageResponsePart.current = 0
const details = [...conversationRef.current]
details.pop()
conversationRef.current.pop()
const finalized = new ConversationPrompt({
message: `${messageRef.current}`,
user_created: false,
})
setConversationDetails([...details, finalized])
conversationRef.current = [...conversationRef.current, finalized]
setConversationDetails([...conversationRef.current])
messageRef.current = ''
setStateMessage('')
isGeneratingRef.current = false
@@ -283,9 +276,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
schedulePostStreamRefetch()
}
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
// 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).
conversationRef.current = conversationDetails
isGeneratingRef.current = true
setIsGeneratingMessage(true)
setStreamInterrupted(false)
@@ -297,22 +288,6 @@ 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)
@@ -341,7 +316,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
/* unsubscribe from channel during cleanup */
unsubscribe(channelName)
}
}, [account, subscribe, unsubscribe, selectedConversation, setSelectedConversation, schedulePostStreamRefetch, setConversationDetails])
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch])
return(
-1
View File
@@ -29,7 +29,6 @@ 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');
});
});
+1 -21
View File
@@ -18,16 +18,10 @@ 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();
@@ -35,15 +29,9 @@ 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) {
localPromptKey += 1;
this.localKey = `local-${localPromptKey}`;
return;
}
if(!initializer) 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;
@@ -53,14 +41,6 @@ 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}`;
}
}
}
@@ -1,35 +0,0 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import AccountDeletionPage from './AccountDeletionPage';
describe('AccountDeletionPage', () => {
it('renders Hesychia deletion steps and retention copy without auth', () => {
render(
<MemoryRouter>
<AccountDeletionPage />
</MemoryRouter>,
);
expect(
screen.getByRole('heading', { name: /Hesychia — Request account deletion/i }),
).toBeInTheDocument();
expect(screen.getByText(/ai\.hesychia\.chat/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /How to delete your account/i }),
).toBeInTheDocument();
expect(screen.getByText(/Delete my account/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Deactivated immediately/i }),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Data retained after soft-delete/i }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Retention period/i })).toBeInTheDocument();
expect(screen.getByText(/no automatic hard-purge schedule/i)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /Cannot access your account/i }),
).toBeInTheDocument();
expect(screen.getByText(/30 days/i)).toBeInTheDocument();
});
});
@@ -1,119 +0,0 @@
import React from 'react';
import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import RelatedPages from '../../components/PublicSite/RelatedPages';
import { ACCOUNT_DELETION } from '../../utils/pageProse';
/**
* Public Play / App Store account-deletion instructions (#103).
* No auth required — reviewers and signed-out users must be able to read this page.
*/
const AccountDeletionPage = (): JSX.Element => (
<Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}>
<Card sx={{ maxWidth: 900, mx: 'auto', width: '100%' }}>
<CardContent>
<Typography variant="h3" component="h1" gutterBottom>
Hesychia — Request account deletion
</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
Hesychia (ai.hesychia.chat) · hesychia.ai · AI ML Operations, LLC
</Typography>
<Typography variant="body1" color="text.secondary">
{ACCOUNT_DELETION.intro}
</Typography>
</CardContent>
<Divider />
<CardContent>
<Stack spacing={3}>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
How to delete your account
</Typography>
<Typography variant="body1" component="ol" sx={{ pl: 2.5, m: 0 }}>
<li>
Open <strong>Hesychia</strong> in the mobile app or at{' '}
<a href="https://hesychia.ai" rel="noopener noreferrer">
hesychia.ai
</a>{' '}
for your account.
</li>
<li>{ACCOUNT_DELETION.signIn}</li>
<li>{ACCOUNT_DELETION.goToAccount}</li>
<li>{ACCOUNT_DELETION.confirm}</li>
</Typography>
</Box>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
What happens when you delete
</Typography>
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
Deactivated immediately
</Typography>
<Typography variant="body1" component="ul" sx={{ pl: 2.5, m: 0 }}>
<li>{ACCOUNT_DELETION.deactivated}</li>
<li>{ACCOUNT_DELETION.loseAccess}</li>
<li>{ACCOUNT_DELETION.sessions}</li>
</Typography>
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
Data retained after soft-delete
</Typography>
<Typography variant="body1" paragraph>
{ACCOUNT_DELETION.softDelete} {ACCOUNT_DELETION.remain} {ACCOUNT_DELETION.includes}{' '}
{ACCOUNT_DELETION.purposes}
</Typography>
<Typography variant="h6" component="h3" gutterBottom>
Retention period
</Typography>
<Typography variant="body1" paragraph sx={{ mb: 0 }}>
{ACCOUNT_DELETION.retention} {ACCOUNT_DELETION.request} {ACCOUNT_DELETION.noClaim}
</Typography>
</Box>
<Box>
<Typography variant="h5" component="h2" gutterBottom>
Cannot access your account?
</Typography>
<Typography variant="body1" paragraph>
If you cannot sign in to complete in-app deletion, contact AI ML Operations, LLC via{' '}
<a href="https://www.aimloperations.com" rel="noopener noreferrer">
aimloperations.com
</a>
. {ACCOUNT_DELETION.email} {ACCOUNT_DELETION.days}
</Typography>
<Typography variant="body2" color="text.secondary">
{ACCOUNT_DELETION.staff}
</Typography>
</Box>
</Stack>
</CardContent>
<Divider />
<CardContent>
<Typography variant="body2" color="text.secondary">
Already have an account?{' '}
<Button component={RouterLink} to="/signin/" size="small">
Sign in
</Button>{' '}
·{' '}
<Button component={RouterLink} to="/account/" size="small">
Account
</Button>{' '}
·{' '}
<Button component={RouterLink} to="/terms_of_service/" size="small">
Terms of Service
</Button>
</Typography>
</CardContent>
</Card>
<RelatedPages />
</Box>
);
export default AccountDeletionPage;
@@ -20,7 +20,6 @@ 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`
@@ -335,46 +334,6 @@ 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);
@@ -473,15 +432,9 @@ 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,
image: attachedImage,
file_type: fileType,
}),
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
new ConversationPrompt({ message: "", user_created: false }),
];
@@ -504,8 +457,6 @@ 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}`);
}
@@ -622,7 +573,7 @@ const AsyncDashboardInner = (): JSX.Element => {
: convo_detail.message;
return (
<ConversationDetailCard
key={convo_detail.localKey}
key={convo_detail.id || index}
message={displayMessage}
user_created={convo_detail.user_created}
promptId={convo_detail.id}
@@ -640,7 +591,6 @@ const AsyncDashboardInner = (): JSX.Element => {
activityDetail={isLiveStream ? activityDetail : null}
activityHistory={isLiveStream ? activityHistory : []}
activityInterrupted={isLiveStream && streamInterrupted}
imageSrc={imageSrcForPrompt(convo_detail.image, convo_detail.file_type)}
/>
);
})
@@ -702,7 +652,7 @@ const AsyncDashboardInner = (): JSX.Element => {
<VisuallyHiddenInput
id="file-upload"
type="file"
accept={CHAT_FILE_ACCEPT}
accept=".csv,.xlsx,.txt,.pdf,.PDF"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
@@ -753,7 +703,16 @@ const AsyncDashboardInner = (): JSX.Element => {
</span>
</Tooltip>
</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>
)}
</Formik>
@@ -1,27 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import ChangelogPage from './ChangelogPage';
import { CHANGELOG_ENTRIES } from './changelogEntries';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
describe('ChangelogPage', () => {
it('lists dated shipped notes and no founder', () => {
render(
<MemoryRouter>
<ChangelogPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Changelog' })).toBeInTheDocument();
expect(screen.getAllByText('2026-08-04')).toHaveLength(2);
expect(screen.getAllByText('2026-08-01')).toHaveLength(2);
for (const entry of CHANGELOG_ENTRIES) {
expect(screen.getByRole('heading', { name: entry.title })).toBeInTheDocument();
}
expect(screen.getByText(/Conversation memory/)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.toBeInTheDocument();
expect(CHANGELOG_ENTRIES.every((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date))).toBe(true);
});
});
@@ -1,62 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { CHANGELOG_ENTRIES } from './changelogEntries';
import { publicLine, publicText } from '../../components/PublicSite/publicColors';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 1.5rem;
`;
const List = styled.ol`
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 1.25rem;
`;
const Item = styled.li`
border-top: 1px solid ${publicLine};
padding-top: 1rem;
`;
const When = styled.time`
display: block;
color: ${publicText};
opacity: 0.65;
font-size: 0.9rem;
margin-bottom: 0.35rem;
`;
const Heading = styled.h2`
font-size: 1.15rem;
margin: 0 0 0.35rem;
`;
const Note = styled.p`
margin: 0;
color: ${publicText};
line-height: 1.5;
max-width: 40rem;
`;
const ChangelogPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Changelog</Title>
<List>
{CHANGELOG_ENTRIES.map((entry) => (
<Item key={`${entry.date}-${entry.title}`}>
<When dateTime={entry.date}>{entry.date}</When>
<Heading>{entry.title}</Heading>
<Note>{entry.note}</Note>
</Item>
))}
</List>
</PublicSiteLayout>
);
};
export default ChangelogPage;
@@ -1,31 +0,0 @@
import { CHANGELOG } from '../../utils/pageProse';
export type ChangelogEntry = {
date: string;
title: string;
note: string;
};
/** Shipped product changes only. Dates match the git history. No founder byline. */
export const CHANGELOG_ENTRIES: readonly ChangelogEntry[] = [
{
date: '2026-08-04',
title: 'Conversation memory',
note: `${CHANGELOG.memory} ${CHANGELOG.memoryOff}`,
},
{
date: '2026-08-04',
title: 'Account deletion',
note: CHANGELOG.deletion,
},
{
date: '2026-08-01',
title: 'Documents',
note: CHANGELOG.documents,
},
{
date: '2026-08-01',
title: 'Subscriptions',
note: CHANGELOG.subscriptions,
},
];
@@ -1,138 +0,0 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import LandingPage from './LandingPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
const mockGet = jest.fn();
const mockPost = jest.fn();
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
},
}));
const settings = (enabled: boolean) => ({ data: { enable_account_registration: enabled } });
const usage = {
data: {
account_count: 3,
document_count: 12,
as_of: '2026-10-17T15:00:00+00:00',
},
};
describe('LandingPage', () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
it('says what Hesychia is and offers a waitlist when registration is off', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
if (url === '/public/usage/') return Promise.resolve(usage);
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Hesychia is a calm AI assistant.' })).toBeInTheDocument();
expect(screen.getByText(/Quiet, focused AI chat — private by default/i)).toBeInTheDocument();
expect(screen.getByAltText('Hesychia logo')).toBeInTheDocument();
expect(screen.getByText(/Your documents, stored and searchable/i)).toBeInTheDocument();
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(/Hesychia reads the picture/i)).toBeInTheDocument();
expect(
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
).toBeInTheDocument();
expect(screen.getByText(/We do not sell your data/i)).toBeInTheDocument();
expect(screen.getByText(/Canceling does not delete documents/i)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Privacy details' })).toHaveAttribute('href', '/privacy/');
expect(screen.getAllByRole('link', { name: 'Sign in' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: 'Pricing' })[0]).toHaveAttribute('href', '/pricing/');
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/public/usage/'));
expect(screen.queryByLabelText('Usage')).not.toBeInTheDocument();
expect(screen.queryByText(/3 accounts/)).not.toBeInTheDocument();
expect(screen.queryByText(/12 documents/)).not.toBeInTheDocument();
});
it('shows rounded account and document counts once there are 20 accounts', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
if (url === '/public/usage/') {
return Promise.resolve({
data: { account_count: 23, document_count: 156, as_of: '2026-10-17T15:00:00+00:00' },
});
}
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
expect(await screen.findByText(/20\+ accounts/)).toBeInTheDocument();
expect(screen.getByText(/100\+ documents stored/)).toBeInTheDocument();
expect(screen.getByText('2026-10-17')).toBeInTheDocument();
expect(screen.queryByText(/23 accounts/)).not.toBeInTheDocument();
expect(screen.queryByText(/156 documents/)).not.toBeInTheDocument();
});
it('links to create account when registration is on', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(true));
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
const create = await screen.findByRole('link', { name: 'Create account' });
expect(create).toHaveAttribute('href', '/signup/');
expect(screen.queryByRole('button', { name: 'Join the waitlist' })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Usage')).not.toBeInTheDocument();
});
it('stores a waitlist email', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
return Promise.reject(new Error(url));
});
mockPost.mockResolvedValue({ data: { detail: "You're on the list." } });
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
const user = userEvent.setup();
await user.type(await screen.findByLabelText('Email'), 'person@example.com');
await user.click(screen.getByRole('button', { name: 'Join the waitlist' }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/public/waitlist/', { email: 'person@example.com' });
});
expect(await screen.findByRole('status')).toHaveTextContent("You're on the list.");
});
});
@@ -1,152 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import UsageCounts from '../../components/PublicSite/UsageCounts';
import { publicAccent, publicCard, publicLine, publicText } from '../../components/PublicSite/publicColors';
import { HOME } from '../../utils/pageProse';
const Hero = styled.header`
max-width: 40rem;
`;
const Mark = styled.img`
width: 4rem;
height: 4rem;
border-radius: 50%;
`;
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.15;
margin: 1.25rem 0 0.75rem;
font-weight: 700;
`;
const Lede = styled.p`
font-size: 1.25rem;
line-height: 1.45;
margin: 0 0 0.75rem;
color: ${publicText};
`;
const Memory = styled.p`
font-size: 1.05rem;
line-height: 1.5;
margin: 0 0 1.5rem;
color: ${publicText};
opacity: 0.85;
`;
const Privacy = styled.section`
margin-top: 1.75rem;
max-width: 40rem;
`;
const PrivacyList = styled.ul`
margin: 0.5rem 0 0.75rem;
padding-left: 1.2rem;
color: ${publicText};
opacity: 0.85;
line-height: 1.5;
`;
const TextLink = styled(Link)`
color: ${publicAccent};
`;
const Features = styled.section`
margin-top: 3rem;
display: grid;
gap: 1rem;
`;
const Feature = styled.article`
border: 1px solid ${publicLine};
border-radius: 0.75rem;
padding: 1.25rem 1.35rem;
background: ${publicCard};
`;
const FeatureTitle = styled.h2`
font-size: 1.15rem;
margin: 0 0 0.4rem;
`;
const FeatureBody = styled.p`
margin: 0;
color: ${publicText};
opacity: 0.85;
line-height: 1.5;
`;
const PriceLock = styled.p`
font-size: 1.05rem;
line-height: 1.5;
margin: 0 0 1.5rem;
color: ${publicText};
font-weight: 600;
`;
const Roadmap = styled.section`
margin-top: 1rem;
border: 1px solid ${publicLine};
border-radius: 0.75rem;
padding: 1.25rem 1.35rem;
background: ${publicCard};
display: grid;
gap: 0.75rem;
`;
const RoadmapTitle = styled.h2`
font-size: 1.15rem;
margin: 0;
`;
const LandingPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Hero>
<Mark src={hesychiaMark} alt="Hesychia logo" />
<Title>Hesychia is a calm AI assistant.</Title>
<Lede>{HOME.lede}</Lede>
<Memory>{HOME.memory}</Memory>
<PriceLock>{HOME.earlyAdopter}</PriceLock>
<GetStarted source="landing" />
<Privacy aria-label="Privacy">
<h2>What we do with your data</h2>
<PrivacyList>
<li>{HOME.stored}</li>
<li>{HOME.notSold}</li>
<li>{HOME.cancel}</li>
</PrivacyList>
<TextLink to="/privacy/">Privacy details</TextLink>
</Privacy>
<UsageCounts />
</Hero>
<Features id="product" aria-label="Product">
<Feature>
<FeatureTitle>Memory that stays with the work</FeatureTitle>
<FeatureBody>{HOME.memoryFeature}</FeatureBody>
</Feature>
<Feature>
<FeatureTitle>Your documents, stored and searchable</FeatureTitle>
<FeatureBody>{HOME.documentsFeature}</FeatureBody>
</Feature>
<Feature>
<FeatureTitle>Business accounts</FeatureTitle>
<FeatureBody>{HOME.businessFeature}</FeatureBody>
</Feature>
<Roadmap aria-labelledby="roadmap-title">
<RoadmapTitle id="roadmap-title">Roadmap</RoadmapTitle>
<FeatureBody>{HOME.roadmapImage}</FeatureBody>
<FeatureBody>{HOME.roadmapVision}</FeatureBody>
</Roadmap>
</Features>
</PublicSiteLayout>
);
};
export default LandingPage;
@@ -3,13 +3,29 @@ 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 mockExecute = jest.fn();
const mockExecuteAsync = jest.fn();
const mockReset = 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),
@@ -32,23 +48,13 @@ const renderPasswordReset = () =>
describe('PasswordReset', () => {
beforeEach(() => {
mockPost.mockReset();
mockExecute.mockReset();
mockExecute.mockResolvedValue('captcha-token');
mockExecuteAsync.mockReset();
mockReset.mockReset();
mockExecuteAsync.mockResolvedValue('captcha-token');
mockPost.mockResolvedValue({ status: 200 });
window.grecaptcha = {
ready: (callback) => callback(),
execute: mockExecute,
};
});
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 () => {
it('submits email with captcha token and navigates to confirmation', async () => {
const user = userEvent.setup();
renderPasswordReset();
@@ -56,9 +62,6 @@ 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',
@@ -67,20 +70,6 @@ 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, { useEffect, useState } from 'react';
import React, { useRef, 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 {
const token = await executePasswordResetCaptcha();
let token = '';
if (recaptchaRef.current) {
token = (await recaptchaRef.current.executeAsync()) || '';
recaptchaRef.current.reset();
}
await cleanAxiosInstance.post('user/reset_password/', {
email,
recaptchaToken: token,
@@ -186,6 +186,14 @@ 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>
@@ -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;
}
@@ -1,32 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PricingPage from './PricingPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: () => Promise.resolve({ data: { enable_account_registration: false } }),
post: jest.fn(),
},
}));
describe('PricingPage', () => {
it('states the public $10 price and the waitlist', async () => {
render(
<MemoryRouter>
<PricingPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Pricing' })).toBeInTheDocument();
expect(screen.getByText(/\$10\/month, flat, with no ads, no upsells, and one price/)).toBeInTheDocument();
expect(
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
).toBeInTheDocument();
expect(screen.getByText(/that price never goes up/i)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'See pricing' })).not.toBeInTheDocument();
});
});
@@ -1,46 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
import { PRICING } from '../../utils/pageProse';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 0.75rem;
`;
const Price = styled.p`
font-size: 1.35rem;
margin: 0 0 0.5rem;
`;
const Body = styled.p`
color: ${publicText};
line-height: 1.5;
max-width: 36rem;
`;
const Back = styled(Link)`
color: ${publicAccent};
`;
const PricingPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Pricing</Title>
<Price>{PRICING.price}</Price>
<Body>{PRICING.lock}</Body>
<Body>
{PRICING.plan} {PRICING.compare}
</Body>
<GetStarted source="pricing" idPrefix="pricing-waitlist" showPricingLink={false} />
<p>
<Back to="/">Back to Hesychia</Back>
</p>
</PublicSiteLayout>
);
};
export default PricingPage;
@@ -1,27 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PrivacyPage from './PrivacyPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
describe('PrivacyPage', () => {
it('states storage, no ads, and that cancel does not delete documents', () => {
render(
<MemoryRouter>
<PrivacyPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Privacy' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'What is stored' })).toBeInTheDocument();
expect(screen.getByText(/do not sell your information/i)).toBeInTheDocument();
expect(screen.getByText(/do not use advertising cookies/i)).toBeInTheDocument();
expect(screen.getByText(/does not delete uploaded documents/i)).toBeInTheDocument();
expect(screen.getByText(/soft-delete/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /how account deletion works/i })).toHaveAttribute(
'href',
'/account-deletion/',
);
});
});
@@ -1,64 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
import { PRIVACY } from '../../utils/pageProse';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 1rem;
`;
const Section = styled.section`
margin-bottom: 1.5rem;
max-width: 40rem;
`;
const Heading = styled.h2`
font-size: 1.2rem;
margin: 0 0 0.4rem;
`;
const Body = styled.p`
margin: 0;
color: ${publicText};
line-height: 1.55;
`;
const TextLink = styled(Link)`
color: ${publicAccent};
`;
const PrivacyPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Privacy</Title>
<Section>
<Heading>What is stored</Heading>
<Body>
{PRIVACY.stored} {PRIVACY.analytics}
</Body>
</Section>
<Section>
<Heading>What we do not do</Heading>
<Body>
{PRIVACY.notSold} {PRIVACY.thirdParties} The{' '}
<TextLink to="/terms_of_service/#data-confidentiality">Terms of Service</TextLink> and
the <TextLink to="/terms_of_service/#analytics">analytics section</TextLink> are the
binding description.
</Body>
</Section>
<Section>
<Heading>If you cancel</Heading>
<Body>
{PRIVACY.cancel} {PRIVACY.documents} {PRIVACY.softDelete} {PRIVACY.purge} See{' '}
<TextLink to="/account-deletion/">how account deletion works</TextLink>, including the
soft-delete steps and what data may remain.
</Body>
</Section>
</PublicSiteLayout>
);
};
export default PrivacyPage;
+3 -18
View File
@@ -79,10 +79,10 @@ describe('SignIn', () => {
expect(await screen.findByRole('button', { name: /Don't have an account\? Sign up/i })).toBeInTheDocument();
});
it('shows SSO buttons when registration enabled and oauth providers configured', async () => {
it('shows SSO buttons when oauth providers configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: true,
enable_account_registration: false,
oauth: { google: true, microsoft: true },
},
});
@@ -92,25 +92,10 @@ describe('SignIn', () => {
expect(screen.getByRole('button', { name: 'Sign in with Microsoft' })).toBeInTheDocument();
});
it('hides SSO buttons when registration disabled even if oauth configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: false,
oauth: { google: true, microsoft: true },
},
});
renderSignIn();
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Continue with Google' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Sign in with Microsoft' })).not.toBeInTheDocument();
});
});
it('hides SSO buttons when oauth not configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: true,
enable_account_registration: false,
oauth: { google: false, microsoft: false },
},
});
+6 -8
View File
@@ -264,7 +264,7 @@ const SignIn = (): JSX.Element => {
<ContentWrapper>
<GlassCard>
<BrandBlock>
<BrandMark src={hesychiaMark} alt="Hesychia logo" />
<BrandMark src={hesychiaMark} alt="" />
<BrandWordmark>Hesychia.ai</BrandWordmark>
</BrandBlock>
<CardTitle>Sign In</CardTitle>
@@ -294,13 +294,11 @@ const SignIn = (): JSX.Element => {
<StyledButton type="submit" disabled={formik.isSubmitting}>
Sign In
</StyledButton>
{registrationEnabled && (
<SsoButtons
intent="login"
providers={oauthProviders}
disabled={formik.isSubmitting}
/>
)}
<SsoButtons
intent="login"
providers={oauthProviders}
disabled={formik.isSubmitting}
/>
</Form>
)}
</Formik>
@@ -10,7 +10,6 @@ import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { trackHeyCatch } from '../../utils/heycatch';
import { checkoutReturnUrls } from '../../utils/finance';
import { isNativePlatform } from '../../platform/nativePlatform';
import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat';
@@ -246,7 +245,6 @@ const SignUp = (): JSX.Element => {
const account = await loadAccount();
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
identifyAccount(account);
trackHeyCatch('signup_completed');
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
console.warn('RevenueCat logIn', err)
);
@@ -6,7 +6,6 @@ import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext';
import { Account } from '../../data';
import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument';
import RelatedPages from '../../components/PublicSite/RelatedPages';
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
@@ -104,17 +103,13 @@ const TermsOfService = ({ publicView = false }: TermsOfServiceProps): JSX.Elemen
return (
<Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}>
{documentBody}
<RelatedPages />
</Box>
);
}
return (
<PageWrapperLayout>
<Box sx={{ margin: '0 auto', width: { xs: '95%', md: '80%' }, py: 2 }}>
{documentBody}
<RelatedPages />
</Box>
<Box sx={{ margin: '0 auto', width: { xs: '95%', md: '80%' }, py: 2 }}>{documentBody}</Box>
</PageWrapperLayout>
);
};
@@ -1,25 +0,0 @@
import { registerPlugin } from '@capacitor/core';
export type StoreName = 'app_store' | 'play_store';
export interface NativeStoreTransaction {
store: StoreName;
productId: string;
transactionId: string;
purchaseToken?: string;
signedTransaction?: string;
}
export interface NativeStoreBillingPlugin {
purchase(options: {
productId: string;
accountToken: string;
}): Promise<NativeStoreTransaction>;
restore(options: {
accountToken: string;
}): Promise<{ transactions: NativeStoreTransaction[] }>;
}
export const NativeStoreBilling = registerPlugin<NativeStoreBillingPlugin>(
'NativeStoreBilling'
);
@@ -1,94 +0,0 @@
import fs from 'fs';
import path from 'path';
const publicDir = path.resolve(__dirname, '../../public');
describe('public crawler files', () => {
it('serves robots, sitemap, and llms as static text that names the public pages', () => {
const robots = fs.readFileSync(path.join(publicDir, 'robots.txt'), 'utf8');
const sitemap = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf8');
const llms = fs.readFileSync(path.join(publicDir, 'llms.txt'), 'utf8');
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
expect(robots).toContain('Sitemap: https://hesychia.ai/sitemap.xml');
expect(robots).toContain('Disallow: /signin/');
expect(robots).not.toContain('<html');
for (const url of [
'https://hesychia.ai/',
'https://hesychia.ai/pricing/',
'https://hesychia.ai/privacy/',
'https://hesychia.ai/changelog/',
'https://hesychia.ai/terms_of_service/',
]) {
expect(sitemap).toContain(url);
expect(llms).toContain(url);
}
expect(sitemap.startsWith('<?xml')).toBe(true);
expect(llms).toContain('$10/month');
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).not.toContain('<html');
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
expect(html.match(/<h1\b/g)).toHaveLength(1);
expect(html).toContain('<h1>Hesychia is a calm AI assistant.</h1>');
expect(html).toContain('id="crawler-prose"');
expect(html).toContain(
'Quiet, focused AI chat — private by default — is how this Hesychia product is built.',
);
expect(html).toContain(
'content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files."',
);
expect(html).toContain('rel="canonical" href="https://hesychia.ai/"');
expect(html).toContain('property="og:title" content="Hesychia — quiet, focused AI chat"');
expect(html).toContain('property="og:image" content="https://hesychia.ai/og-image.png"');
expect(html).toContain('property="og:image:width" content="1200"');
expect(html).toContain('property="og:image:height" content="630"');
expect(html).toContain('name="twitter:card" content="summary_large_image"');
expect(html).toContain('aria-label="Related pages"');
expect(html).toContain('href="/pricing/">Hesychia pricing — $10/month, no ads');
expect(html).toContain('href="/privacy/">What Hesychia stores and does not sell');
expect(html).toContain('href="/account-deletion/">How to request Hesychia account deletion');
expect(html).toContain(
'Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.',
);
expect(html).toContain(
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
);
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"');
expect(html).toContain('"headline": "Hesychia — quiet, focused AI chat"');
expect(html).toContain('"@type": "Organization"');
expect(html).toContain('"@type": "SoftwareApplication"');
expect(html).toContain('"price": "10.00"');
expect(html).toContain('AI ML Operations, LLC');
});
it('ships a 404 document that is not the app shell', () => {
const notFound = fs.readFileSync(path.join(publicDir, '404.html'), 'utf8');
expect(notFound).toContain('<title>The page is not found — Hesychia</title>');
expect(notFound).toContain('"headline": "The page is not found — Hesychia"');
expect(notFound).toContain('"name": "Hesychia"');
expect(notFound).toContain('"logo": "https://hesychia.ai/apple-touch-icon.png"');
expect(notFound).toContain(
'content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account."',
);
expect(notFound).toContain('<h1>Not Found</h1>');
expect(notFound).toContain(
'The page is not found, and this address is not a Hesychia page.',
);
expect(notFound).toContain(
'Open the home page to read what the product does, what it costs, and how to start an account.',
);
expect(notFound).not.toContain('id="root"');
expect(notFound).toContain('property="og:image" content="https://hesychia.ai/og-image.png"');
expect(notFound).toContain('name="twitter:card" content="summary_large_image"');
expect(notFound).toContain('href="/">What Hesychia is');
expect(notFound).toContain('aria-label="Related pages"');
});
});
-2
View File
@@ -6,7 +6,5 @@ declare global {
track: (eventName: string, data?: Record<string, unknown>) => void;
identify: (userInfo: Record<string, unknown>) => void;
};
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
}
}
@@ -1,12 +1,6 @@
import { flushAnalyticsQueue, identifyAccount, identifyUser, trackEvent } from './analytics';
import { Account } from '../data';
jest.mock('./heycatch', () => ({
identifyHeyCatch: jest.fn(),
resetHeyCatch: jest.fn(),
trackHeyCatch: jest.fn(),
}));
describe('analytics event helpers', () => {
const originalEnv = process.env;
-8
View File
@@ -1,6 +1,5 @@
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
import type { Account } from '../data';
import { identifyHeyCatch } from './heycatch';
type QueuedCall =
| { type: 'track'; eventName: string; data?: Record<string, unknown> }
@@ -28,9 +27,6 @@ const queue: QueuedCall[] = [];
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
* | Activity Stage Completed | Live activity status stage rolled over (#96) |
* | Landing Primary CTA | Create-account click on the public site |
* | Landing Pricing CTA | See-pricing click on the public site |
* | Waitlist Joined | Email accepted by the public waitlist |
*/
export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success',
@@ -57,9 +53,6 @@ export const AnalyticsEvents = {
MESSAGE_RATING_REASON: 'Message Rating Reason',
MESSAGE_EXPORTED: 'Message Exported',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
LANDING_PRIMARY_CTA: 'Landing Primary CTA',
LANDING_PRICING_CTA: 'Landing Pricing CTA',
WAITLIST_JOINED: 'Waitlist Joined',
} as const;
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
@@ -117,7 +110,6 @@ export const identifyUser = (userInfo: Record<string, unknown>): void => {
/** Identify with stable non-sensitive traits only (no names / prompts). */
export const identifyAccount = (account: Account): void => {
identifyHeyCatch(account);
const traits: Record<string, unknown> = {
userId: account.email,
};
-10
View File
@@ -1,10 +0,0 @@
export const ORIGIN: string;
export const OG_IMAGE: string;
export const PAGE_PATHS: readonly string[];
export const PAGE_H1: Readonly<Record<string, string>>;
export function canonicalForPath(pathname: string): string | null;
export function h1ForPath(pathname: string): string | null;
export const PAGE_TITLES: Readonly<Record<string, string>>;
export function titleForPath(pathname: string): string | null;
export const PAGE_DESCRIPTIONS: Readonly<Record<string, string>>;
export function descriptionForPath(pathname: string): string | null;
-166
View File
@@ -1,166 +0,0 @@
/** Public origin. Each page's canonical is this origin plus that page's own path. */
const ORIGIN = 'https://hesychia.ai';
const OG_IMAGE = `${ORIGIN}/og-image.png`;
/** Real routes from App.tsx. `/` is the shared index.html; other paths get their own file at build. */
const PAGE_PATHS = [
'/',
'/pricing/',
'/privacy/',
'/changelog/',
'/terms_of_service/',
'/legal/terms-of-service',
'/account-deletion/',
'/signin/',
'/signup/',
'/auth/callback/',
'/billing/success',
'/billing/cancel',
'/password_reset/',
'/password_reset_confirmation/',
'/set_password/',
'/account/',
'/document_storage',
'/feedback/',
'/analytics/',
];
/**
* The one headline already on that page. Static HTML must use this text;
* the React page renders the same words in its single h1.
* @type {Record<string, string>}
*/
const PAGE_H1 = {
'/': 'Hesychia is a calm AI assistant.',
'/pricing/': 'Pricing',
'/privacy/': 'Privacy',
'/changelog/': 'Changelog',
'/terms_of_service/': 'Terms of Service',
'/legal/terms-of-service': 'Terms of Service',
'/account-deletion/': 'Hesychia — Request account deletion',
'/signin/': 'Hesychia.ai',
'/signup/': 'Sign Up',
'/auth/callback/': 'Sign In',
'/billing/success': 'Payment received',
'/billing/cancel': 'Checkout canceled',
'/password_reset/': 'Reset Password',
'/password_reset_confirmation/': 'Check Your Email',
'/set_password/': 'Hesychia',
'/account/': 'Account — Hesychia',
'/document_storage': 'Documents — Hesychia',
'/feedback/': 'Feedback — Hesychia',
'/analytics/': 'Analytics — Hesychia',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function canonicalForPath(pathname) {
if (!PAGE_PATHS.includes(pathname)) return null;
return `${ORIGIN}${pathname}`;
}
/**
* @param {string} pathname
* @returns {string | null}
*/
function h1ForPath(pathname) {
return PAGE_H1[pathname] || null;
}
/**
* Unique document titles, 30–60 characters as rendered. Main term first.
* Too short or too long throws. Nothing is sliced to fit.
* @type {Record<string, string>}
*/
const PAGE_TITLES = {
'/': 'Hesychia — quiet, focused AI chat',
'/pricing/': 'Hesychia pricing — $10/month, no ads',
'/privacy/': 'Hesychia privacy — stored, never sold',
'/changelog/': 'Hesychia changelog — dated product updates',
'/terms_of_service/': 'Hesychia terms of service — binding terms',
'/legal/terms-of-service': 'Hesychia legal terms — terms of service',
'/account-deletion/': 'Hesychia account deletion — soft-delete',
'/signin/': 'Sign in to Hesychia — quiet, focused chat',
'/signup/': 'Sign up for Hesychia — $10/month, no ads',
'/auth/callback/': 'Hesychia sign-in — returning you to chat',
'/billing/success': 'Hesychia payment received — subscription on',
'/billing/cancel': 'Hesychia checkout canceled — no charge made',
'/password_reset/': 'Reset your Hesychia password — email link',
'/password_reset_confirmation/': 'Hesychia password reset — check your email',
'/set_password/': 'Set a new Hesychia password — account access',
'/account/': 'Hesychia account — profile and subscription',
'/document_storage': 'Hesychia documents — stored and searchable',
'/feedback/': 'Hesychia feedback — tell us what to change',
'/analytics/': 'Hesychia analytics — usage for your account',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function titleForPath(pathname) {
const title = PAGE_TITLES[pathname];
if (!title) return null;
if (title.length < 30 || title.length > 60) {
throw new Error(
`title for ${pathname} is ${title.length} characters (want 30-60), left uncut: ${title}`,
);
}
return title;
}
/**
* What the reader gets on that page, 110–160 characters. No silent trim.
* @type {Record<string, string>}
*/
const PAGE_DESCRIPTIONS = {
'/': 'Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files.',
'/pricing/': '$10/month, flat. No ads, no upsells, one price. One public plan includes AI chat, your documents, and business accounts for your team.',
'/privacy/': 'Hesychia stores your account, conversations, and uploaded documents so answers can use your files. It does not sell your information or use advertising cookies.',
'/changelog/': 'Dated notes of shipped Hesychia changes: conversation memory, account deletion, document upload, and subscriptions you can upgrade, change, or cancel.',
'/terms_of_service/': 'These Terms of Service are a legally binding agreement between you and AI ML Operations, LLC governing access to and use of the Hesychia chat service.',
'/legal/terms-of-service': 'Public copy of the Hesychia Terms of Service, last updated July 27, 2026. The terms govern use of the chat service operated by AI ML Operations, LLC.',
'/account-deletion/': 'Steps to request deletion of a Hesychia account without signing in. Deletion is a soft-delete: profile, conversations, and documents may remain.',
'/signin/': 'Sign in to an existing Hesychia account with email and password, or use the SSO buttons when registration is open. New visitors can create an account.',
'/signup/': 'Create a Hesychia account, then complete payment on Stripe. The public price is $10/month with no ads and no upsells. Sign in if you already have an account.',
'/auth/callback/': 'Hesychia is finishing sign-in and sending you back to the app. If the provider returns an error, this page shows it and links back to the sign-in form.',
'/billing/success': 'Your Hesychia subscription checkout completed and no further payment step is required here. Sign in with the account you just created to continue into the app.',
'/billing/cancel': 'No charge was made. Your Hesychia account may already exist. Sign in and open billing again, or return to sign up to retry Stripe Checkout.',
'/password_reset/': 'Enter the email on your Hesychia account and we will send a link to set a new password. The link opens a page where you choose that password.',
'/password_reset_confirmation/': 'If an account exists for that address, Hesychia sent a link to set a new password. Check the inbox for that email, then return to sign in.',
'/set_password/': 'Choose a new password for your Hesychia account using the link from the reset email. After it is saved, sign in with that password.',
'/account/': 'View the Hesychia account profile and subscription. Upgrade, change, or cancel the plan, and company managers can invite or update team accounts.',
'/document_storage': 'Upload documents to Hesychia and search them from chat. Personal and company libraries stay separate. Files remain until you delete them or delete the account.',
'/feedback/': 'Submit feedback about Hesychia and read feedback you already sent. The form records the note you write. It does not change your subscription or documents.',
'/analytics/': 'See Hesychia usage for your account: prompt volume, conversation volume, team seat activity, and response times. Company managers can open company insights.',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function descriptionForPath(pathname) {
const description = PAGE_DESCRIPTIONS[pathname];
if (!description) return null;
if (description.length < 110 || description.length > 160) {
throw new Error(
`description for ${pathname} is ${description.length} characters (want 110-160), left uncut: ${description}`,
);
}
return description;
}
module.exports = {
ORIGIN,
OG_IMAGE,
PAGE_PATHS,
PAGE_H1,
PAGE_TITLES,
PAGE_DESCRIPTIONS,
canonicalForPath,
h1ForPath,
titleForPath,
descriptionForPath,
};
-5
View File
@@ -127,11 +127,6 @@ describe('finance helpers', () => {
it('classifies subscription sources', () => {
expect(isStoreSubscription('revenuecat')).toBe(true);
expect(isStoreSubscription('app_store')).toBe(true);
expect(isStoreSubscription('play_store')).toBe(true);
expect(invoiceProviderLabel(baseInvoice({ provider: 'app_store' }))).toBe(
'App Store'
);
expect(isStripeSubscription('stripe')).toBe(true);
expect(
isComplimentarySubscription(
+1 -10
View File
@@ -55,7 +55,6 @@ export type SubscriptionPlanInfo = {
prompt_window_hours: number;
monthly_token_quota: number | null;
sort_order: number;
store_product_id?: string;
};
export type SubscriptionUsage = {
@@ -82,7 +81,6 @@ export type SubscriptionMe = {
revenuecat_original_transaction_id?: string;
cancel_at_period_end?: boolean;
current_period_end?: string | null;
store_account_token?: string;
usage: SubscriptionUsage;
};
@@ -90,8 +88,6 @@ export type SubscriptionSource =
| 'none'
| 'stripe'
| 'revenuecat'
| 'app_store'
| 'play_store'
| 'backer'
| 'admin'
| string;
@@ -99,10 +95,7 @@ export type SubscriptionSource =
export function isStoreSubscription(
source: string | null | undefined
): boolean {
const value = (source || '').toLowerCase();
return (
value === 'revenuecat' || value === 'app_store' || value === 'play_store'
);
return source === 'revenuecat';
}
export function isStripeSubscription(
@@ -116,8 +109,6 @@ export function invoiceProviderLabel(
invoice: Pick<FinanceInvoice, 'provider' | 'revenuecat_store'>
): string {
const provider = (invoice.provider || '').toLowerCase();
if (provider === 'app_store') return 'App Store';
if (provider === 'play_store') return 'Play Store';
if (provider === 'revenuecat') {
const store = (invoice.revenuecat_store || '').toUpperCase();
if (
-31
View File
@@ -1,31 +0,0 @@
import { analytics } from '@heycatch/sdk';
import type { Account } from '../data';
import { isProductAnalyticsEnabled } from './productAnalytics';
if (isProductAnalyticsEnabled()) {
analytics.init({
projectKey: 'hck_pk_jTFCp-wijbruu8IV50prV0c9W3Sb_kYd',
install: {
framework: 'react',
frameworkVersion: '18',
agent: 'cursor',
},
});
}
export function identifyHeyCatch(account: Account): void {
if (account.id == null) return;
const name = [account.first_name, account.last_name].filter(Boolean).join(' ');
analytics.setIdentity(String(account.id), {
email: account.email,
...(name ? { name } : {}),
});
}
export function resetHeyCatch(): void {
analytics.resetIdentity();
}
export function trackHeyCatch(eventName: string): void {
analytics.trackEvent(eventName);
}
-7
View File
@@ -1,7 +0,0 @@
export type InternalLink = {
href: string;
label: string;
};
export function linksForPath(pathname: string): InternalLink[];
export function relatedNavHtml(pathname: string): string;
-87
View File
@@ -1,87 +0,0 @@
/**
* Neighbours for the public cluster. Anchors name the page; none say "here".
* App routes fall back to the overview's neighbours so a stamped shell is not an orphan.
*/
/** @typedef {{ href: string, label: string }} InternalLink */
/** @type {Record<string, InternalLink[]>} */
const RELATED = {
'/': [
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/changelog/', label: 'Dated Hesychia product updates' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/pricing/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/changelog/', label: 'Dated Hesychia product updates' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
],
'/privacy/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/changelog/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
],
'/terms_of_service/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/account-deletion/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
],
};
const GENERIC_ANCHOR = /^(here|click here|read more|learn more)$/i;
/**
* @param {string} pathname
* @returns {InternalLink[]}
*/
function linksForPath(pathname) {
if (pathname === '/legal/terms-of-service') return RELATED['/terms_of_service/'];
return RELATED[pathname] || RELATED['/'].slice(0, 4);
}
/**
* @param {string} pathname
* @returns {string}
*/
function relatedNavHtml(pathname) {
const links = linksForPath(pathname);
if (links.length < 2 || links.length > 5) {
throw new Error(`related links for ${pathname} must be 2-5, found ${links.length}`);
}
const inner = links
.map((link) => {
if (link.href === pathname) {
throw new Error(`related nav on ${pathname} links to itself`);
}
if (GENERIC_ANCHOR.test(link.label.trim())) {
throw new Error(`generic anchor on ${pathname}: ${link.label}`);
}
return `<a href="${link.href}">${link.label}</a>`;
})
.join('');
return `<nav aria-label="Related pages">${inner}</nav>`;
}
module.exports = {
RELATED,
linksForPath,
relatedNavHtml,
};
-67
View File
@@ -1,67 +0,0 @@
export const HOME: {
lede: string;
memory: string;
stored: string;
notSold: string;
cancel: string;
memoryFeature: string;
documentsFeature: string;
businessFeature: string;
earlyAdopter: string;
roadmapImage: string;
roadmapVision: string;
};
export const PRICING: {
price: string;
lock: string;
plan: string;
compare: string;
};
export const PRIVACY: {
stored: string;
analytics: string;
notSold: string;
thirdParties: string;
binding: string;
cancel: string;
documents: string;
softDelete: string;
purge: string;
};
export const CHANGELOG: {
memory: string;
memoryOff: string;
deletion: string;
documents: string;
subscriptions: string;
};
export const ACCOUNT_DELETION: {
intro: string;
open: string;
signIn: string;
goToAccount: string;
confirm: string;
deactivated: string;
loseAccess: string;
sessions: string;
softDelete: string;
remain: string;
includes: string;
purposes: string;
retention: string;
request: string;
noClaim: string;
contact: string;
email: string;
days: string;
staff: string;
};
export const NOT_FOUND_SENTENCES: readonly string[];
export function sentencesForPath(pathname: string): string[];
export function proseHtml(pathname: string): string;
export function wordCount(sentence: string): number;
-237
View File
@@ -1,237 +0,0 @@
/**
* Visible sentences for each public route. Each one is 10–25 words.
*/
const { PAGE_PATHS } = require('./canonicals');
/** @param {string} sentence */
function wordCount(sentence) {
return sentence
.replace(/[—–]/g, ' ')
.replace(/[^A-Za-z0-9$/'’.+-]+/g, ' ')
.split(/\s+/)
.filter(Boolean).length;
}
const EARLY_ADOPTER =
'Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.';
const HOME = {
lede: 'Quiet, focused AI chat — private by default — is how this Hesychia product is built.',
memory:
'It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.',
stored: 'We store your account, your conversations, and the documents you upload.',
notSold: 'We do not sell your data, and we do not use advertising cookies.',
cancel:
'Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.',
memoryFeature: 'It remembers what you told it and your documents, so you never start over.',
documentsFeature:
'Your documents stay stored and searchable, and answers are grounded in your own files.',
businessFeature:
'Business accounts are simple management for your team, and company managers can manage team accounts.',
earlyAdopter: EARLY_ADOPTER,
roadmapImage:
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
roadmapVision:
'Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.',
};
const PRICING = {
price: '$10/month, flat, with no ads, no upsells, and one price.',
lock: EARLY_ADOPTER,
plan: 'One public plan covers quiet, focused AI chat, your documents, and business accounts for your team.',
compare: 'Hard to justify $20 a month, and this is $10.',
};
const PRIVACY = {
stored:
'Your account (email, name, and an optional company), your conversations, and the documents you upload so answers can use your files.',
analytics:
'Optional product analytics, if you accept them, record actions such as sign-in and checkout, and they do not include message or document contents.',
notSold: 'We do not sell your information, and we do not use advertising cookies.',
thirdParties:
'Information you enter is not shared with third parties without your consent, except to operate the service or when the law requires it.',
binding: 'The Terms of Service and the analytics section are the binding description.',
cancel: 'Canceling stops paid access at the end of the current period.',
documents:
'It does not delete uploaded documents, and documents stay in your workspace until you delete them or until you delete the account.',
softDelete:
'Account deletion is a soft-delete of profile details, conversations, uploaded documents, and authentication records.',
purge:
'Those records may remain for administration, security, and audit, and there is no automatic hard purge.',
};
const CHANGELOG = {
memory:
'Account settings can use earlier turns in a chat as context, so you do not re-explain the same project on every message.',
memoryOff: 'It stays off until you turn it on in account settings.',
deletion: 'A public page explains how to delete an account and that deletion is a soft-delete.',
documents:
'Upload documents and search them from chat, and personal and company libraries stay separate.',
subscriptions:
'Upgrade, change, or cancel from the account page, and the public price is $10 a month.',
};
const ACCOUNT_DELETION = {
intro:
'Use the steps below to request deletion of your Hesychia account, and this page does not require sign-in to read.',
open: 'Open Hesychia in the mobile app or at hesychia.ai for your account.',
signIn: 'Sign in with the account you want deleted before you continue.',
goToAccount: 'Go to the Account page after you sign in to Hesychia.',
confirm: 'Choose Delete my account, then confirm by typing your account email.',
deactivated:
'Your account is marked deleted and set inactive, and your conversations are hidden from the product.',
loseAccess: 'You lose access to Hesychia and cannot sign in again with that account.',
sessions: 'Active sessions are invalidated, and the refresh tokens are blacklisted.',
softDelete:
'Hesychia currently uses a soft-delete process, and this is not a full erasure of all stored data.',
remain: 'After you delete your account, personal data associated with the account may remain.',
includes:
'That data includes profile details, conversation content, uploaded documents, RAG vectors, and authentication event records.',
purposes: 'The data may remain for administration, security, and audit purposes.',
retention:
'There is currently no automatic hard-purge schedule, and soft-deleted data is retained until a future purge process is implemented.',
request:
'It is also retained until we process a separate retention or erasure request through the contact path below.',
noClaim: 'We will not claim complete erasure while soft-delete-only remains in effect.',
contact:
'If you cannot sign in to complete in-app deletion, contact AI ML Operations, LLC via aimloperations.com.',
email: 'Include the email address used for your Hesychia account in that request.',
days: 'We aim to respond to account-deletion requests within 30 days.',
staff: 'Staff accounts cannot self-delete in the app, so use the contact path above.',
};
const TERMS = [
'These Terms of Service are a legally binding agreement between you and AI ML Operations, LLC.',
'The agreement governs access to and use of the Hesychia chat service.',
];
const LEGAL_TERMS = [
'This is the public copy of the Hesychia Terms of Service, last updated July 27, 2026.',
'The terms govern use of the chat service operated by AI ML Operations, LLC.',
];
/** @type {Record<string, string[]>} */
const PAGE_SENTENCES = {
'/': Object.values(HOME),
'/pricing/': Object.values(PRICING),
'/privacy/': Object.values(PRIVACY),
'/changelog/': Object.values(CHANGELOG),
'/terms_of_service/': TERMS,
'/legal/terms-of-service': LEGAL_TERMS,
'/account-deletion/': Object.values(ACCOUNT_DELETION),
'/signin/': [
'Sign in to an existing Hesychia account with email and password, or use the SSO buttons when registration is open.',
'New visitors can create an account on the sign-up page.',
],
'/signup/': [
'Create a Hesychia account, then complete payment on Stripe for the public price of $10/month with no ads and no upsells.',
'Sign in on the sign-in page if you already have a Hesychia account.',
],
'/auth/callback/': [
'Hesychia is finishing sign-in and sending you back to the app.',
'If the provider returns an error, this page shows it and links back to the sign-in form.',
],
'/billing/success': [
'Your Hesychia subscription checkout completed and no further payment step is required here.',
'Sign in with the account you just created to continue into the app.',
],
'/billing/cancel': [
'No charge was made, and your Hesychia account may already exist.',
'Sign in and open billing again, or return to sign up to retry Stripe Checkout.',
],
'/password_reset/': [
'Enter the email on your Hesychia account and we will send a link to set a new password.',
'The link opens a page where you choose that password.',
],
'/password_reset_confirmation/': [
'If an account exists for that address, Hesychia sent a link to set a new password.',
'Check the inbox for that email, then return to sign in.',
],
'/set_password/': [
'Choose a new password for your Hesychia account using the link from the reset email.',
'After the new password is saved, sign in with that password.',
],
'/account/': [
'View the Hesychia account profile and subscription on this page.',
'Upgrade, change, or cancel the plan, and company managers can invite or update team accounts.',
],
'/document_storage': [
'Upload documents to Hesychia and search them from chat, and personal and company libraries stay separate.',
'Files remain until you delete them or delete the account.',
],
'/feedback/': [
'Submit feedback about Hesychia and read feedback you already sent.',
'The form records the note you write, and it does not change your subscription or documents.',
],
'/analytics/': [
'See Hesychia usage for your account, including prompt volume, conversation volume, team seat activity, and response times.',
'Company managers can open the company insights for this account.',
],
};
const NOT_FOUND_SENTENCES = [
'The page is not found, and this address is not a Hesychia page.',
'Open the home page to read what the product does, what it costs, and how to start an account.',
];
/**
* @param {string} pathname
* @param {string[]} sentences
*/
function assertSentenceBand(pathname, sentences) {
if (sentences.length < 2) {
throw new Error(`${pathname} needs at least two sentences`);
}
const counts = sentences.map(wordCount);
counts.forEach((count, index) => {
if (count < 10 || count > 25) {
throw new Error(
`${pathname} sentence ${index + 1} is ${count} words (want 10-25): ${sentences[index]}`,
);
}
});
const average = counts.reduce((sum, count) => sum + count, 0) / counts.length;
if (average < 10 || average > 25) {
throw new Error(`${pathname} average sentence length is ${average}`);
}
}
for (const pathname of PAGE_PATHS) {
const sentences = PAGE_SENTENCES[pathname];
if (!sentences) throw new Error(`no prose for ${pathname}`);
assertSentenceBand(pathname, sentences);
}
assertSentenceBand('/404.html', NOT_FOUND_SENTENCES);
/**
* @param {string} pathname
* @returns {string[]}
*/
function sentencesForPath(pathname) {
const sentences = PAGE_SENTENCES[pathname];
if (!sentences) throw new Error(`no prose for ${pathname}`);
return sentences;
}
/**
* @param {string} pathname
* @returns {string}
*/
function proseHtml(pathname) {
const inner = sentencesForPath(pathname)
.map((sentence) => `<p>${sentence}</p>`)
.join('');
return `<div id="crawler-prose">${inner}</div>`;
}
module.exports = {
HOME,
PRICING,
PRIVACY,
CHANGELOG,
ACCOUNT_DELETION,
NOT_FOUND_SENTENCES,
sentencesForPath,
proseHtml,
wordCount,
};
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more