Spaces:
Sleeping
Sleeping
Rebrand OpenDateBase to loveGPT
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +95 -95
- app.py +9 -8
- artifacts/prompts/openclaw.md +42 -42
- artifacts/schemas/openclaw-response.schema.json +78 -78
- backend/dist/app.js +34 -0
- backend/dist/config/env.js +52 -0
- backend/dist/config/supabase.js +17 -0
- backend/dist/middleware/auth.js +24 -0
- backend/dist/middleware/errors.js +37 -0
- backend/dist/routes/health.js +12 -0
- backend/dist/routes/matches.js +32 -0
- backend/dist/routes/messages.js +37 -0
- backend/dist/routes/openClaw.js +52 -0
- backend/dist/routes/payments.js +92 -0
- backend/dist/routes/profiles.js +59 -0
- backend/dist/server.js +8 -0
- backend/dist/services/embeddingService.js +29 -0
- backend/dist/services/matchService.js +144 -0
- backend/dist/services/messageService.js +89 -0
- backend/dist/services/openClawService.js +210 -0
- backend/dist/services/profileService.js +164 -0
- backend/dist/types/domain.js +2 -0
- backend/dist/utils/httpError.js +20 -0
- backend/dist/utils/privacyGuard.js +22 -0
- backend/dist/utils/questions.js +45 -0
- backend/package-lock.json +0 -0
- backend/package.json +33 -33
- backend/src/prompts/openclaw.md +42 -42
- backend/src/routes/health.ts +11 -11
- backend/src/routes/payments.ts +104 -104
- backend/src/server.ts +8 -8
- backend/src/utils/privacyGuard.ts +21 -21
- docs/huggingface-space.md +154 -154
- frontend/.gitignore +45 -0
- frontend/.metadata +36 -0
- frontend/README.md +1 -1
- frontend/android/.gitignore +14 -0
- frontend/android/app/build.gradle.kts +45 -0
- frontend/android/app/src/debug/AndroidManifest.xml +7 -0
- frontend/android/app/src/main/AndroidManifest.xml +45 -0
- frontend/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +54 -0
- frontend/android/app/src/main/kotlin/com/example/lovegpt/MainActivity.kt +5 -0
- frontend/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
- frontend/android/app/src/main/res/drawable/launch_background.xml +12 -0
- frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- frontend/android/app/src/main/res/values-night/styles.xml +18 -0
README.md
CHANGED
|
@@ -1,95 +1,95 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
colorFrom: pink
|
| 4 |
-
colorTo: yellow
|
| 5 |
-
sdk: gradio
|
| 6 |
-
sdk_version: 6.16.0
|
| 7 |
-
python_version: 3.11
|
| 8 |
-
app_file: app.py
|
| 9 |
-
pinned: false
|
| 10 |
-
license: mit
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
#
|
| 14 |
-
|
| 15 |
-
Premium AI dating platform powered by OpenClaw.
|
| 16 |
-
|
| 17 |
-
## Structure
|
| 18 |
-
|
| 19 |
-
- `frontend/` Flutter app for web, Android, and iOS.
|
| 20 |
-
- `backend/` Node.js, TypeScript, Express, Supabase, Stripe, AI, embeddings.
|
| 21 |
-
- `backend/supabase/migrations/` PostgreSQL schema, RLS, pgvector matching RPC.
|
| 22 |
-
- `shared/` full compatibility questionnaire and shared TypeScript contracts.
|
| 23 |
-
- `artifacts/` OpenClaw prompt and response schema.
|
| 24 |
-
- `docs/` setup, API, and deployment notes.
|
| 25 |
-
- `app.py` Gradio/Hugging Face Space adapter.
|
| 26 |
-
|
| 27 |
-
## Gradio Space
|
| 28 |
-
|
| 29 |
-
```powershell
|
| 30 |
-
python -m pip install -r requirements.txt
|
| 31 |
-
python app.py
|
| 32 |
-
```
|
| 33 |
-
|
| 34 |
-
The Space adapter runs session-local by default and does not require Supabase or Stripe secrets just to launch. Profile exports are encrypted JSONL rows, and questionnaire turns are logged into an encrypted matchmaker-only table. Set `DATACLAW_PROFILE_KEY` and `MATCHMAKER_TABLE_KEY` as Hugging Face secrets when rows must remain decryptable by Dataclaw and the matchmaker across restarts.
|
| 35 |
-
|
| 36 |
-
Optional local Phi OpenClaw runtime:
|
| 37 |
-
|
| 38 |
-
```powershell
|
| 39 |
-
llama-server `
|
| 40 |
-
-hf ysn-rfd/Phi-4-mini-instruct-GGUF:Q4_0 `
|
| 41 |
-
--alias microsoft/Phi-4-mini-instruct `
|
| 42 |
-
--jinja `
|
| 43 |
-
--host 127.0.0.1 `
|
| 44 |
-
--port 8081 `
|
| 45 |
-
-c 32768
|
| 46 |
-
|
| 47 |
-
$env:OPENCLAW_PROVIDER="local"
|
| 48 |
-
$env:OPENCLAW_LOCAL_MODEL="microsoft/Phi-4-mini-instruct"
|
| 49 |
-
$env:OPENCLAW_LOCAL_BASE_URL="http://127.0.0.1:8081/v1"
|
| 50 |
-
python app.py
|
| 51 |
-
```
|
| 52 |
-
|
| 53 |
-
Dataclaw-only decrypt:
|
| 54 |
-
|
| 55 |
-
```powershell
|
| 56 |
-
$env:DATACLAW_PROFILE_KEY="your-secret"
|
| 57 |
-
$env:DATACLAW_ALLOW_PROFILE_DECRYPT="1"
|
| 58 |
-
python tools/dataclaw_profile_vault.py encrypted-profile.jsonl.enc --output decrypted.jsonl --i-understand-raw-profile-output
|
| 59 |
-
```
|
| 60 |
-
|
| 61 |
-
Do not expose decrypted JSONL through the Space UI or logs.
|
| 62 |
-
|
| 63 |
-
## Backend
|
| 64 |
-
|
| 65 |
-
```powershell
|
| 66 |
-
cd backend
|
| 67 |
-
Copy-Item .env.example .env
|
| 68 |
-
npm install
|
| 69 |
-
npm run typecheck
|
| 70 |
-
npm run build
|
| 71 |
-
npm run dev
|
| 72 |
-
```
|
| 73 |
-
|
| 74 |
-
The default backend AI provider is `local` using `microsoft/Phi-4-mini-instruct` through `LOCAL_AI_BASE_URL=http://127.0.0.1:8081/v1`.
|
| 75 |
-
|
| 76 |
-
## Frontend
|
| 77 |
-
|
| 78 |
-
Flutter is required locally.
|
| 79 |
-
|
| 80 |
-
```powershell
|
| 81 |
-
cd frontend
|
| 82 |
-
flutter pub get
|
| 83 |
-
flutter run `
|
| 84 |
-
--dart-define=SUPABASE_URL=https://your-project.supabase.co `
|
| 85 |
-
--dart-define=SUPABASE_ANON_KEY=your-anon-key `
|
| 86 |
-
--dart-define=BACKEND_URL=http://localhost:8080/api
|
| 87 |
-
```
|
| 88 |
-
|
| 89 |
-
If platform folders are not present yet, run this from `frontend/` once:
|
| 90 |
-
|
| 91 |
-
```powershell
|
| 92 |
-
flutter create --platforms=web,android,ios .
|
| 93 |
-
```
|
| 94 |
-
|
| 95 |
-
Then keep the existing `lib/`, `pubspec.yaml`, and assets.
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: loveGPT
|
| 3 |
+
colorFrom: pink
|
| 4 |
+
colorTo: yellow
|
| 5 |
+
sdk: gradio
|
| 6 |
+
sdk_version: 6.16.0
|
| 7 |
+
python_version: 3.11
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# loveGPT
|
| 14 |
+
|
| 15 |
+
Premium AI dating platform powered by OpenClaw.
|
| 16 |
+
|
| 17 |
+
## Structure
|
| 18 |
+
|
| 19 |
+
- `frontend/` Flutter app for web, Android, and iOS.
|
| 20 |
+
- `backend/` Node.js, TypeScript, Express, Supabase, Stripe, AI, embeddings.
|
| 21 |
+
- `backend/supabase/migrations/` PostgreSQL schema, RLS, pgvector matching RPC.
|
| 22 |
+
- `shared/` full compatibility questionnaire and shared TypeScript contracts.
|
| 23 |
+
- `artifacts/` OpenClaw prompt and response schema.
|
| 24 |
+
- `docs/` setup, API, and deployment notes.
|
| 25 |
+
- `app.py` Gradio/Hugging Face Space adapter.
|
| 26 |
+
|
| 27 |
+
## Gradio Space
|
| 28 |
+
|
| 29 |
+
```powershell
|
| 30 |
+
python -m pip install -r requirements.txt
|
| 31 |
+
python app.py
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
The Space adapter runs session-local by default and does not require Supabase or Stripe secrets just to launch. Profile exports are encrypted JSONL rows, and questionnaire turns are logged into an encrypted matchmaker-only table. Set `DATACLAW_PROFILE_KEY` and `MATCHMAKER_TABLE_KEY` as Hugging Face secrets when rows must remain decryptable by Dataclaw and the matchmaker across restarts.
|
| 35 |
+
|
| 36 |
+
Optional local Phi OpenClaw runtime:
|
| 37 |
+
|
| 38 |
+
```powershell
|
| 39 |
+
llama-server `
|
| 40 |
+
-hf ysn-rfd/Phi-4-mini-instruct-GGUF:Q4_0 `
|
| 41 |
+
--alias microsoft/Phi-4-mini-instruct `
|
| 42 |
+
--jinja `
|
| 43 |
+
--host 127.0.0.1 `
|
| 44 |
+
--port 8081 `
|
| 45 |
+
-c 32768
|
| 46 |
+
|
| 47 |
+
$env:OPENCLAW_PROVIDER="local"
|
| 48 |
+
$env:OPENCLAW_LOCAL_MODEL="microsoft/Phi-4-mini-instruct"
|
| 49 |
+
$env:OPENCLAW_LOCAL_BASE_URL="http://127.0.0.1:8081/v1"
|
| 50 |
+
python app.py
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
Dataclaw-only decrypt:
|
| 54 |
+
|
| 55 |
+
```powershell
|
| 56 |
+
$env:DATACLAW_PROFILE_KEY="your-secret"
|
| 57 |
+
$env:DATACLAW_ALLOW_PROFILE_DECRYPT="1"
|
| 58 |
+
python tools/dataclaw_profile_vault.py encrypted-profile.jsonl.enc --output decrypted.jsonl --i-understand-raw-profile-output
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
Do not expose decrypted JSONL through the Space UI or logs.
|
| 62 |
+
|
| 63 |
+
## Backend
|
| 64 |
+
|
| 65 |
+
```powershell
|
| 66 |
+
cd backend
|
| 67 |
+
Copy-Item .env.example .env
|
| 68 |
+
npm install
|
| 69 |
+
npm run typecheck
|
| 70 |
+
npm run build
|
| 71 |
+
npm run dev
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
The default backend AI provider is `local` using `microsoft/Phi-4-mini-instruct` through `LOCAL_AI_BASE_URL=http://127.0.0.1:8081/v1`.
|
| 75 |
+
|
| 76 |
+
## Frontend
|
| 77 |
+
|
| 78 |
+
Flutter is required locally.
|
| 79 |
+
|
| 80 |
+
```powershell
|
| 81 |
+
cd frontend
|
| 82 |
+
flutter pub get
|
| 83 |
+
flutter run `
|
| 84 |
+
--dart-define=SUPABASE_URL=https://your-project.supabase.co `
|
| 85 |
+
--dart-define=SUPABASE_ANON_KEY=your-anon-key `
|
| 86 |
+
--dart-define=BACKEND_URL=http://localhost:8080/api
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
If platform folders are not present yet, run this from `frontend/` once:
|
| 90 |
+
|
| 91 |
+
```powershell
|
| 92 |
+
flutter create --platforms=web,android,ios .
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
Then keep the existing `lib/`, `pubspec.yaml`, and assets.
|
app.py
CHANGED
|
@@ -26,9 +26,10 @@ from huggingface_hub import HfApi
|
|
| 26 |
ROOT = Path(__file__).resolve().parent
|
| 27 |
QUESTIONS_PATH = ROOT / "shared" / "questions.json"
|
| 28 |
EXPECTED_QUESTIONS = int(os.getenv("OPENDB_EXPECTED_QUESTIONS", "0"))
|
|
|
|
| 29 |
VAULT_SCHEMA = "opendatebase.profile.vault.v1"
|
| 30 |
MATCHMAKER_TABLE_SCHEMA = "opendatebase.matchmaker.table.v1"
|
| 31 |
-
MATCHMAKER_TABLE_PATH = Path(os.getenv("MATCHMAKER_TABLE_PATH", str(Path(tempfile.gettempdir()) / "
|
| 32 |
SPEED_DATE_SECONDS = int(os.getenv("SPEED_DATE_SECONDS", "1200"))
|
| 33 |
OPENCLAW_PROVIDER = os.getenv("OPENCLAW_PROVIDER", os.getenv("AI_PROVIDER", "deterministic")).strip().lower()
|
| 34 |
OPENCLAW_LOCAL_BASE_URL = os.getenv("OPENCLAW_LOCAL_BASE_URL", os.getenv("LOCAL_AI_BASE_URL", "http://127.0.0.1:8081/v1")).rstrip("/")
|
|
@@ -172,7 +173,7 @@ def encrypt_profile_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
| 172 |
nonce = secrets.token_bytes(12)
|
| 173 |
aad = {
|
| 174 |
"schema": VAULT_SCHEMA,
|
| 175 |
-
"source": payload.get("source", "
|
| 176 |
"answerCount": len(payload.get("answers", {})),
|
| 177 |
"profileComplete": bool(payload.get("profileComplete")),
|
| 178 |
}
|
|
@@ -275,7 +276,7 @@ def is_privacy_lookup_request(value: str) -> bool:
|
|
| 275 |
def privacy_refusal() -> str:
|
| 276 |
return (
|
| 277 |
"I cannot help find, identify, or expose personal details about another person. "
|
| 278 |
-
"
|
| 279 |
"Answer the current profile question about your own preferences and boundaries instead."
|
| 280 |
)
|
| 281 |
|
|
@@ -790,7 +791,7 @@ def export_payload(state: dict[str, Any]) -> dict[str, Any]:
|
|
| 790 |
state = state or new_state()
|
| 791 |
answers = state.get("answers", {})
|
| 792 |
return {
|
| 793 |
-
"source": "
|
| 794 |
"profileId": state.get("profile_id", ""),
|
| 795 |
"exportedAt": now_iso(),
|
| 796 |
"profile": state.get("profile", {}),
|
|
@@ -812,7 +813,7 @@ def export_profile_file(state: dict[str, Any]) -> str:
|
|
| 812 |
mode="w",
|
| 813 |
encoding="utf-8",
|
| 814 |
suffix=".jsonl.enc",
|
| 815 |
-
prefix="
|
| 816 |
delete=False,
|
| 817 |
)
|
| 818 |
with handle:
|
|
@@ -999,7 +1000,7 @@ def strict_matchmaker_judgment(a: dict[str, Any], b: dict[str, Any]) -> dict[str
|
|
| 999 |
|
| 1000 |
def profile_display(payload: dict[str, Any]) -> str:
|
| 1001 |
profile = payload.get("profile", {})
|
| 1002 |
-
return normalize_text(str(profile.get("display_name") or payload.get("username") or "
|
| 1003 |
|
| 1004 |
|
| 1005 |
def pair_key(a_user_id: str, b_user_id: str) -> str:
|
|
@@ -1462,7 +1463,7 @@ def submit_exchange_form(display_name: str, contact: str, note: str, consent: bo
|
|
| 1462 |
|
| 1463 |
def build_app() -> gr.Blocks:
|
| 1464 |
with gr.Blocks(
|
| 1465 |
-
title=
|
| 1466 |
theme=gr.themes.Soft(primary_hue="rose", secondary_hue="amber", neutral_hue="slate"),
|
| 1467 |
css="""
|
| 1468 |
.odp-hero {max-width: 980px}
|
|
@@ -1475,7 +1476,7 @@ def build_app() -> gr.Blocks:
|
|
| 1475 |
|
| 1476 |
gr.Markdown(
|
| 1477 |
"""
|
| 1478 |
-
#
|
| 1479 |
Premium AI dating profile builder powered by OpenClaw.
|
| 1480 |
|
| 1481 |
This Hugging Face Space is a session-local Gradio version of the profile workflow.
|
|
|
|
| 26 |
ROOT = Path(__file__).resolve().parent
|
| 27 |
QUESTIONS_PATH = ROOT / "shared" / "questions.json"
|
| 28 |
EXPECTED_QUESTIONS = int(os.getenv("OPENDB_EXPECTED_QUESTIONS", "0"))
|
| 29 |
+
BRAND_NAME = "loveGPT"
|
| 30 |
VAULT_SCHEMA = "opendatebase.profile.vault.v1"
|
| 31 |
MATCHMAKER_TABLE_SCHEMA = "opendatebase.matchmaker.table.v1"
|
| 32 |
+
MATCHMAKER_TABLE_PATH = Path(os.getenv("MATCHMAKER_TABLE_PATH", str(Path(tempfile.gettempdir()) / "lovegpt-matchmaker-table.jsonl.enc")))
|
| 33 |
SPEED_DATE_SECONDS = int(os.getenv("SPEED_DATE_SECONDS", "1200"))
|
| 34 |
OPENCLAW_PROVIDER = os.getenv("OPENCLAW_PROVIDER", os.getenv("AI_PROVIDER", "deterministic")).strip().lower()
|
| 35 |
OPENCLAW_LOCAL_BASE_URL = os.getenv("OPENCLAW_LOCAL_BASE_URL", os.getenv("LOCAL_AI_BASE_URL", "http://127.0.0.1:8081/v1")).rstrip("/")
|
|
|
|
| 173 |
nonce = secrets.token_bytes(12)
|
| 174 |
aad = {
|
| 175 |
"schema": VAULT_SCHEMA,
|
| 176 |
+
"source": payload.get("source", "lovegpt-gradio-space"),
|
| 177 |
"answerCount": len(payload.get("answers", {})),
|
| 178 |
"profileComplete": bool(payload.get("profileComplete")),
|
| 179 |
}
|
|
|
|
| 276 |
def privacy_refusal() -> str:
|
| 277 |
return (
|
| 278 |
"I cannot help find, identify, or expose personal details about another person. "
|
| 279 |
+
"loveGPT can only use profile information for consent-based compatibility work. "
|
| 280 |
"Answer the current profile question about your own preferences and boundaries instead."
|
| 281 |
)
|
| 282 |
|
|
|
|
| 791 |
state = state or new_state()
|
| 792 |
answers = state.get("answers", {})
|
| 793 |
return {
|
| 794 |
+
"source": "lovegpt-gradio-space",
|
| 795 |
"profileId": state.get("profile_id", ""),
|
| 796 |
"exportedAt": now_iso(),
|
| 797 |
"profile": state.get("profile", {}),
|
|
|
|
| 813 |
mode="w",
|
| 814 |
encoding="utf-8",
|
| 815 |
suffix=".jsonl.enc",
|
| 816 |
+
prefix="lovegpt-profile-vault-",
|
| 817 |
delete=False,
|
| 818 |
)
|
| 819 |
with handle:
|
|
|
|
| 1000 |
|
| 1001 |
def profile_display(payload: dict[str, Any]) -> str:
|
| 1002 |
profile = payload.get("profile", {})
|
| 1003 |
+
return normalize_text(str(profile.get("display_name") or payload.get("username") or "loveGPT user"))[:80]
|
| 1004 |
|
| 1005 |
|
| 1006 |
def pair_key(a_user_id: str, b_user_id: str) -> str:
|
|
|
|
| 1463 |
|
| 1464 |
def build_app() -> gr.Blocks:
|
| 1465 |
with gr.Blocks(
|
| 1466 |
+
title=BRAND_NAME,
|
| 1467 |
theme=gr.themes.Soft(primary_hue="rose", secondary_hue="amber", neutral_hue="slate"),
|
| 1468 |
css="""
|
| 1469 |
.odp-hero {max-width: 980px}
|
|
|
|
| 1476 |
|
| 1477 |
gr.Markdown(
|
| 1478 |
"""
|
| 1479 |
+
# loveGPT
|
| 1480 |
Premium AI dating profile builder powered by OpenClaw.
|
| 1481 |
|
| 1482 |
This Hugging Face Space is a session-local Gradio version of the profile workflow.
|
artifacts/prompts/openclaw.md
CHANGED
|
@@ -1,42 +1,42 @@
|
|
| 1 |
-
# OpenClaw System Prompt
|
| 2 |
-
|
| 3 |
-
You are OpenClaw, the warm, direct, psychologically precise AI matchmaker inside
|
| 4 |
-
|
| 5 |
-
Core behavior:
|
| 6 |
-
- Ask one meaningful question at a time.
|
| 7 |
-
- Reflect briefly before moving on.
|
| 8 |
-
- Ask follow-up questions when the answer is vague, performative, contradictory, unsafe, or too thin to support matching.
|
| 9 |
-
- Treat disgust triggers as important compatibility data, not as shameful confessions.
|
| 10 |
-
- Never moralize ordinary preferences. Do challenge cruelty, coercion, dehumanization, or unsafe intent.
|
| 11 |
-
- Keep the conversation grounded, elegant, and human. Do not sound like a quiz form.
|
| 12 |
-
- Do not promise a perfect partner. Promise careful matching and honest signal.
|
| 13 |
-
- Never help a user find, identify, dox, locate, or expose private details about another person. Refuse those requests and redirect to consent-based compatibility profiling.
|
| 14 |
-
|
| 15 |
-
Profile priorities:
|
| 16 |
-
1. Long-term values and future direction.
|
| 17 |
-
2. Emotional safety, attachment, and conflict repair.
|
| 18 |
-
3. Captivating traits that create admiration and desire.
|
| 19 |
-
4. Disgust triggers, repulsions, hygiene needs, moral aversions, sensory aversions, and intimacy boundaries.
|
| 20 |
-
5. Lifestyle practicality.
|
| 21 |
-
6. Readiness, boundaries, and consent.
|
| 22 |
-
7. Respectful body-type self-description, desired body-type range, and physical attraction flexibility.
|
| 23 |
-
|
| 24 |
-
Response contract:
|
| 25 |
-
Return a single JSON object with no markdown.
|
| 26 |
-
|
| 27 |
-
Schema:
|
| 28 |
-
{
|
| 29 |
-
"assistant_text": "Message shown to the user.",
|
| 30 |
-
"current_question_id": "q1",
|
| 31 |
-
"captured_answer": {
|
| 32 |
-
"questionId": "q1",
|
| 33 |
-
"answer": "The user's answer, normalized without changing meaning.",
|
| 34 |
-
"followup": "Optional concise follow-up detail.",
|
| 35 |
-
"dealbreakerSeverity": "low | medium | high"
|
| 36 |
-
},
|
| 37 |
-
"next_question_id": "q2",
|
| 38 |
-
"followup_needed": false,
|
| 39 |
-
"profile_complete": false
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
If the user has not answered the current question, set captured_answer to null. If all 40 answers are complete, set profile_complete to true and produce a concise completion message.
|
|
|
|
| 1 |
+
# OpenClaw System Prompt
|
| 2 |
+
|
| 3 |
+
You are OpenClaw, the warm, direct, psychologically precise AI matchmaker inside loveGPT. Your job is to guide a dating user through the complete 40-question compatibility profile in a natural conversation.
|
| 4 |
+
|
| 5 |
+
Core behavior:
|
| 6 |
+
- Ask one meaningful question at a time.
|
| 7 |
+
- Reflect briefly before moving on.
|
| 8 |
+
- Ask follow-up questions when the answer is vague, performative, contradictory, unsafe, or too thin to support matching.
|
| 9 |
+
- Treat disgust triggers as important compatibility data, not as shameful confessions.
|
| 10 |
+
- Never moralize ordinary preferences. Do challenge cruelty, coercion, dehumanization, or unsafe intent.
|
| 11 |
+
- Keep the conversation grounded, elegant, and human. Do not sound like a quiz form.
|
| 12 |
+
- Do not promise a perfect partner. Promise careful matching and honest signal.
|
| 13 |
+
- Never help a user find, identify, dox, locate, or expose private details about another person. Refuse those requests and redirect to consent-based compatibility profiling.
|
| 14 |
+
|
| 15 |
+
Profile priorities:
|
| 16 |
+
1. Long-term values and future direction.
|
| 17 |
+
2. Emotional safety, attachment, and conflict repair.
|
| 18 |
+
3. Captivating traits that create admiration and desire.
|
| 19 |
+
4. Disgust triggers, repulsions, hygiene needs, moral aversions, sensory aversions, and intimacy boundaries.
|
| 20 |
+
5. Lifestyle practicality.
|
| 21 |
+
6. Readiness, boundaries, and consent.
|
| 22 |
+
7. Respectful body-type self-description, desired body-type range, and physical attraction flexibility.
|
| 23 |
+
|
| 24 |
+
Response contract:
|
| 25 |
+
Return a single JSON object with no markdown.
|
| 26 |
+
|
| 27 |
+
Schema:
|
| 28 |
+
{
|
| 29 |
+
"assistant_text": "Message shown to the user.",
|
| 30 |
+
"current_question_id": "q1",
|
| 31 |
+
"captured_answer": {
|
| 32 |
+
"questionId": "q1",
|
| 33 |
+
"answer": "The user's answer, normalized without changing meaning.",
|
| 34 |
+
"followup": "Optional concise follow-up detail.",
|
| 35 |
+
"dealbreakerSeverity": "low | medium | high"
|
| 36 |
+
},
|
| 37 |
+
"next_question_id": "q2",
|
| 38 |
+
"followup_needed": false,
|
| 39 |
+
"profile_complete": false
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
If the user has not answered the current question, set captured_answer to null. If all 40 answers are complete, set profile_complete to true and produce a concise completion message.
|
artifacts/schemas/openclaw-response.schema.json
CHANGED
|
@@ -1,78 +1,78 @@
|
|
| 1 |
-
{
|
| 2 |
-
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
-
"$id": "https://
|
| 4 |
-
"type": "object",
|
| 5 |
-
"required": [
|
| 6 |
-
"assistant_text",
|
| 7 |
-
"current_question_id",
|
| 8 |
-
"captured_answer",
|
| 9 |
-
"next_question_id",
|
| 10 |
-
"followup_needed",
|
| 11 |
-
"profile_complete"
|
| 12 |
-
],
|
| 13 |
-
"properties": {
|
| 14 |
-
"assistant_text": {
|
| 15 |
-
"type": "string",
|
| 16 |
-
"minLength": 1
|
| 17 |
-
},
|
| 18 |
-
"current_question_id": {
|
| 19 |
-
"type": "string",
|
| 20 |
-
"pattern": "^q([1-9]|[1-3][0-9]|40)$"
|
| 21 |
-
},
|
| 22 |
-
"captured_answer": {
|
| 23 |
-
"anyOf": [
|
| 24 |
-
{
|
| 25 |
-
"type": "null"
|
| 26 |
-
},
|
| 27 |
-
{
|
| 28 |
-
"type": "object",
|
| 29 |
-
"required": ["questionId", "answer"],
|
| 30 |
-
"properties": {
|
| 31 |
-
"questionId": {
|
| 32 |
-
"type": "string",
|
| 33 |
-
"pattern": "^q([1-9]|[1-3][0-9]|40)$"
|
| 34 |
-
},
|
| 35 |
-
"answer": {
|
| 36 |
-
"anyOf": [
|
| 37 |
-
{
|
| 38 |
-
"type": "string"
|
| 39 |
-
},
|
| 40 |
-
{
|
| 41 |
-
"type": "array",
|
| 42 |
-
"items": {
|
| 43 |
-
"type": "string"
|
| 44 |
-
}
|
| 45 |
-
}
|
| 46 |
-
]
|
| 47 |
-
},
|
| 48 |
-
"followup": {
|
| 49 |
-
"type": "string"
|
| 50 |
-
},
|
| 51 |
-
"dealbreakerSeverity": {
|
| 52 |
-
"enum": ["low", "medium", "high"]
|
| 53 |
-
}
|
| 54 |
-
},
|
| 55 |
-
"additionalProperties": false
|
| 56 |
-
}
|
| 57 |
-
]
|
| 58 |
-
},
|
| 59 |
-
"next_question_id": {
|
| 60 |
-
"anyOf": [
|
| 61 |
-
{
|
| 62 |
-
"type": "null"
|
| 63 |
-
},
|
| 64 |
-
{
|
| 65 |
-
"type": "string",
|
| 66 |
-
"pattern": "^q([1-9]|[1-3][0-9]|40)$"
|
| 67 |
-
}
|
| 68 |
-
]
|
| 69 |
-
},
|
| 70 |
-
"followup_needed": {
|
| 71 |
-
"type": "boolean"
|
| 72 |
-
},
|
| 73 |
-
"profile_complete": {
|
| 74 |
-
"type": "boolean"
|
| 75 |
-
}
|
| 76 |
-
},
|
| 77 |
-
"additionalProperties": false
|
| 78 |
-
}
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
+
"$id": "https://lovegpt.app/schemas/openclaw-response.schema.json",
|
| 4 |
+
"type": "object",
|
| 5 |
+
"required": [
|
| 6 |
+
"assistant_text",
|
| 7 |
+
"current_question_id",
|
| 8 |
+
"captured_answer",
|
| 9 |
+
"next_question_id",
|
| 10 |
+
"followup_needed",
|
| 11 |
+
"profile_complete"
|
| 12 |
+
],
|
| 13 |
+
"properties": {
|
| 14 |
+
"assistant_text": {
|
| 15 |
+
"type": "string",
|
| 16 |
+
"minLength": 1
|
| 17 |
+
},
|
| 18 |
+
"current_question_id": {
|
| 19 |
+
"type": "string",
|
| 20 |
+
"pattern": "^q([1-9]|[1-3][0-9]|40)$"
|
| 21 |
+
},
|
| 22 |
+
"captured_answer": {
|
| 23 |
+
"anyOf": [
|
| 24 |
+
{
|
| 25 |
+
"type": "null"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"type": "object",
|
| 29 |
+
"required": ["questionId", "answer"],
|
| 30 |
+
"properties": {
|
| 31 |
+
"questionId": {
|
| 32 |
+
"type": "string",
|
| 33 |
+
"pattern": "^q([1-9]|[1-3][0-9]|40)$"
|
| 34 |
+
},
|
| 35 |
+
"answer": {
|
| 36 |
+
"anyOf": [
|
| 37 |
+
{
|
| 38 |
+
"type": "string"
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"type": "array",
|
| 42 |
+
"items": {
|
| 43 |
+
"type": "string"
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
"followup": {
|
| 49 |
+
"type": "string"
|
| 50 |
+
},
|
| 51 |
+
"dealbreakerSeverity": {
|
| 52 |
+
"enum": ["low", "medium", "high"]
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
"additionalProperties": false
|
| 56 |
+
}
|
| 57 |
+
]
|
| 58 |
+
},
|
| 59 |
+
"next_question_id": {
|
| 60 |
+
"anyOf": [
|
| 61 |
+
{
|
| 62 |
+
"type": "null"
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"type": "string",
|
| 66 |
+
"pattern": "^q([1-9]|[1-3][0-9]|40)$"
|
| 67 |
+
}
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
"followup_needed": {
|
| 71 |
+
"type": "boolean"
|
| 72 |
+
},
|
| 73 |
+
"profile_complete": {
|
| 74 |
+
"type": "boolean"
|
| 75 |
+
}
|
| 76 |
+
},
|
| 77 |
+
"additionalProperties": false
|
| 78 |
+
}
|
backend/dist/app.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
| 3 |
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
| 4 |
+
};
|
| 5 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 6 |
+
exports.createApp = createApp;
|
| 7 |
+
const cors_1 = __importDefault(require("cors"));
|
| 8 |
+
const express_1 = __importDefault(require("express"));
|
| 9 |
+
const helmet_1 = __importDefault(require("helmet"));
|
| 10 |
+
const morgan_1 = __importDefault(require("morgan"));
|
| 11 |
+
const health_1 = require("./routes/health");
|
| 12 |
+
const matches_1 = require("./routes/matches");
|
| 13 |
+
const messages_1 = require("./routes/messages");
|
| 14 |
+
const openClaw_1 = require("./routes/openClaw");
|
| 15 |
+
const payments_1 = require("./routes/payments");
|
| 16 |
+
const profiles_1 = require("./routes/profiles");
|
| 17 |
+
const errors_1 = require("./middleware/errors");
|
| 18 |
+
function createApp() {
|
| 19 |
+
const app = (0, express_1.default)();
|
| 20 |
+
app.use((0, helmet_1.default)());
|
| 21 |
+
app.use((0, cors_1.default)());
|
| 22 |
+
app.use((0, morgan_1.default)("tiny"));
|
| 23 |
+
app.use("/api/payments/webhook", express_1.default.raw({ type: "application/json" }), payments_1.paymentWebhookRouter);
|
| 24 |
+
app.use(express_1.default.json({ limit: "1mb" }));
|
| 25 |
+
app.use("/health", health_1.healthRouter);
|
| 26 |
+
app.use("/api/openclaw", openClaw_1.openClawRouter);
|
| 27 |
+
app.use("/api/profiles", profiles_1.profilesRouter);
|
| 28 |
+
app.use("/api/payments", payments_1.paymentsRouter);
|
| 29 |
+
app.use("/api/matches", matches_1.matchesRouter);
|
| 30 |
+
app.use("/api/messages", messages_1.messagesRouter);
|
| 31 |
+
app.use(errors_1.notFoundHandler);
|
| 32 |
+
app.use(errors_1.errorHandler);
|
| 33 |
+
return app;
|
| 34 |
+
}
|
backend/dist/config/env.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
| 3 |
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
| 4 |
+
};
|
| 5 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 6 |
+
exports.isProduction = exports.env = void 0;
|
| 7 |
+
exports.assertAiConfigured = assertAiConfigured;
|
| 8 |
+
exports.assertEmbeddingsConfigured = assertEmbeddingsConfigured;
|
| 9 |
+
const dotenv_1 = __importDefault(require("dotenv"));
|
| 10 |
+
const zod_1 = require("zod");
|
| 11 |
+
dotenv_1.default.config();
|
| 12 |
+
const envSchema = zod_1.z.object({
|
| 13 |
+
NODE_ENV: zod_1.z.enum(["development", "test", "production"]).default("development"),
|
| 14 |
+
PORT: zod_1.z.coerce.number().int().positive().default(8080),
|
| 15 |
+
PUBLIC_APP_URL: zod_1.z.string().url().default("http://localhost:8080"),
|
| 16 |
+
FRONTEND_SUCCESS_URL: zod_1.z.string().url().default("http://localhost:3000/#/payment/success"),
|
| 17 |
+
FRONTEND_CANCEL_URL: zod_1.z.string().url().default("http://localhost:3000/#/payment/cancel"),
|
| 18 |
+
SUPABASE_URL: zod_1.z.string().url(),
|
| 19 |
+
SUPABASE_ANON_KEY: zod_1.z.string().min(1),
|
| 20 |
+
SUPABASE_SERVICE_ROLE_KEY: zod_1.z.string().min(1),
|
| 21 |
+
AI_PROVIDER: zod_1.z.enum(["anthropic", "grok", "local"]).default("local"),
|
| 22 |
+
AI_MODEL: zod_1.z.string().min(1).default("microsoft/Phi-4-mini-instruct"),
|
| 23 |
+
ANTHROPIC_API_KEY: zod_1.z.string().optional(),
|
| 24 |
+
GROK_API_KEY: zod_1.z.string().optional(),
|
| 25 |
+
GROK_BASE_URL: zod_1.z.string().url().default("https://api.x.ai/v1"),
|
| 26 |
+
LOCAL_AI_BASE_URL: zod_1.z.string().url().default("http://127.0.0.1:8081/v1"),
|
| 27 |
+
LOCAL_AI_API_KEY: zod_1.z.string().optional(),
|
| 28 |
+
EMBEDDING_BASE_URL: zod_1.z.string().url().default("https://api.openai.com/v1"),
|
| 29 |
+
EMBEDDING_API_KEY: zod_1.z.string().optional(),
|
| 30 |
+
EMBEDDING_MODEL: zod_1.z.string().min(1).default("text-embedding-3-small"),
|
| 31 |
+
EMBEDDING_DIMENSIONS: zod_1.z.coerce.number().int().positive().default(1536),
|
| 32 |
+
STRIPE_SECRET_KEY: zod_1.z.string().min(1),
|
| 33 |
+
STRIPE_WEBHOOK_SECRET: zod_1.z.string().min(1),
|
| 34 |
+
STRIPE_PRICE_ID: zod_1.z.string().optional(),
|
| 35 |
+
ENTRY_FEE_CENTS: zod_1.z.coerce.number().int().positive().default(2400),
|
| 36 |
+
ENTRY_FEE_CURRENCY: zod_1.z.string().min(3).max(3).default("usd")
|
| 37 |
+
});
|
| 38 |
+
exports.env = envSchema.parse(process.env);
|
| 39 |
+
exports.isProduction = exports.env.NODE_ENV === "production";
|
| 40 |
+
function assertAiConfigured() {
|
| 41 |
+
if (exports.env.AI_PROVIDER === "anthropic" && !exports.env.ANTHROPIC_API_KEY) {
|
| 42 |
+
throw new Error("ANTHROPIC_API_KEY is required when AI_PROVIDER=anthropic");
|
| 43 |
+
}
|
| 44 |
+
if (exports.env.AI_PROVIDER === "grok" && !exports.env.GROK_API_KEY) {
|
| 45 |
+
throw new Error("GROK_API_KEY is required when AI_PROVIDER=grok");
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
function assertEmbeddingsConfigured() {
|
| 49 |
+
if (!exports.env.EMBEDDING_API_KEY) {
|
| 50 |
+
throw new Error("EMBEDDING_API_KEY is required to complete profiles and curate matches");
|
| 51 |
+
}
|
| 52 |
+
}
|
backend/dist/config/supabase.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.supabasePublic = exports.supabaseAdmin = void 0;
|
| 4 |
+
const supabase_js_1 = require("@supabase/supabase-js");
|
| 5 |
+
const env_1 = require("./env");
|
| 6 |
+
exports.supabaseAdmin = (0, supabase_js_1.createClient)(env_1.env.SUPABASE_URL, env_1.env.SUPABASE_SERVICE_ROLE_KEY, {
|
| 7 |
+
auth: {
|
| 8 |
+
autoRefreshToken: false,
|
| 9 |
+
persistSession: false
|
| 10 |
+
}
|
| 11 |
+
});
|
| 12 |
+
exports.supabasePublic = (0, supabase_js_1.createClient)(env_1.env.SUPABASE_URL, env_1.env.SUPABASE_ANON_KEY, {
|
| 13 |
+
auth: {
|
| 14 |
+
autoRefreshToken: false,
|
| 15 |
+
persistSession: false
|
| 16 |
+
}
|
| 17 |
+
});
|
backend/dist/middleware/auth.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.requireAuth = requireAuth;
|
| 4 |
+
const supabase_1 = require("../config/supabase");
|
| 5 |
+
const httpError_1 = require("../utils/httpError");
|
| 6 |
+
async function requireAuth(req, _res, next) {
|
| 7 |
+
try {
|
| 8 |
+
const header = req.header("authorization") ?? "";
|
| 9 |
+
const [scheme, token] = header.split(" ");
|
| 10 |
+
if (scheme?.toLowerCase() !== "bearer" || !token) {
|
| 11 |
+
throw new httpError_1.HttpError(401, "auth_missing", "A Supabase bearer token is required");
|
| 12 |
+
}
|
| 13 |
+
const { data, error } = await supabase_1.supabaseAdmin.auth.getUser(token);
|
| 14 |
+
if (error || !data.user) {
|
| 15 |
+
throw new httpError_1.HttpError(401, "auth_invalid", "The Supabase bearer token is invalid");
|
| 16 |
+
}
|
| 17 |
+
req.authUser = data.user;
|
| 18 |
+
req.accessToken = token;
|
| 19 |
+
next();
|
| 20 |
+
}
|
| 21 |
+
catch (error) {
|
| 22 |
+
next(error);
|
| 23 |
+
}
|
| 24 |
+
}
|
backend/dist/middleware/errors.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.notFoundHandler = notFoundHandler;
|
| 4 |
+
exports.errorHandler = errorHandler;
|
| 5 |
+
const zod_1 = require("zod");
|
| 6 |
+
const env_1 = require("../config/env");
|
| 7 |
+
const httpError_1 = require("../utils/httpError");
|
| 8 |
+
function notFoundHandler(req, _res, next) {
|
| 9 |
+
next(new httpError_1.HttpError(404, "not_found", `Route not found: ${req.method} ${req.path}`));
|
| 10 |
+
}
|
| 11 |
+
function errorHandler(error, _req, res, _next) {
|
| 12 |
+
if (error instanceof zod_1.ZodError) {
|
| 13 |
+
res.status(400).json({
|
| 14 |
+
error: {
|
| 15 |
+
code: "validation_error",
|
| 16 |
+
message: "Request validation failed",
|
| 17 |
+
issues: error.issues
|
| 18 |
+
}
|
| 19 |
+
});
|
| 20 |
+
return;
|
| 21 |
+
}
|
| 22 |
+
if (error instanceof httpError_1.HttpError) {
|
| 23 |
+
res.status(error.status).json({
|
| 24 |
+
error: {
|
| 25 |
+
code: error.code,
|
| 26 |
+
message: error.message
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
res.status(500).json({
|
| 32 |
+
error: {
|
| 33 |
+
code: "internal_error",
|
| 34 |
+
message: env_1.isProduction ? "Internal server error" : (0, httpError_1.getErrorMessage)(error)
|
| 35 |
+
}
|
| 36 |
+
});
|
| 37 |
+
}
|
backend/dist/routes/health.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.healthRouter = void 0;
|
| 4 |
+
const express_1 = require("express");
|
| 5 |
+
exports.healthRouter = (0, express_1.Router)();
|
| 6 |
+
exports.healthRouter.get("/", (_req, res) => {
|
| 7 |
+
res.json({
|
| 8 |
+
ok: true,
|
| 9 |
+
service: "lovegpt-backend",
|
| 10 |
+
checkedAt: new Date().toISOString()
|
| 11 |
+
});
|
| 12 |
+
});
|
backend/dist/routes/matches.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.matchesRouter = void 0;
|
| 4 |
+
const express_1 = require("express");
|
| 5 |
+
const auth_1 = require("../middleware/auth");
|
| 6 |
+
const matchService_1 = require("../services/matchService");
|
| 7 |
+
exports.matchesRouter = (0, express_1.Router)();
|
| 8 |
+
exports.matchesRouter.use(auth_1.requireAuth);
|
| 9 |
+
exports.matchesRouter.get("/", async (req, res, next) => {
|
| 10 |
+
try {
|
| 11 |
+
res.json(await (0, matchService_1.listMyMatches)(req.authUser.id));
|
| 12 |
+
}
|
| 13 |
+
catch (error) {
|
| 14 |
+
next(error);
|
| 15 |
+
}
|
| 16 |
+
});
|
| 17 |
+
exports.matchesRouter.post("/curate", async (req, res, next) => {
|
| 18 |
+
try {
|
| 19 |
+
res.json(await (0, matchService_1.curateMatches)(req.authUser.id));
|
| 20 |
+
}
|
| 21 |
+
catch (error) {
|
| 22 |
+
next(error);
|
| 23 |
+
}
|
| 24 |
+
});
|
| 25 |
+
exports.matchesRouter.post("/:matchId/accept", async (req, res, next) => {
|
| 26 |
+
try {
|
| 27 |
+
res.json(await (0, matchService_1.acceptMatch)(req.authUser.id, req.params.matchId));
|
| 28 |
+
}
|
| 29 |
+
catch (error) {
|
| 30 |
+
next(error);
|
| 31 |
+
}
|
| 32 |
+
});
|
backend/dist/routes/messages.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.messagesRouter = void 0;
|
| 4 |
+
const express_1 = require("express");
|
| 5 |
+
const zod_1 = require("zod");
|
| 6 |
+
const auth_1 = require("../middleware/auth");
|
| 7 |
+
const messageService_1 = require("../services/messageService");
|
| 8 |
+
const sendMessageSchema = zod_1.z.object({
|
| 9 |
+
body: zod_1.z.string().trim().min(1).max(4000)
|
| 10 |
+
});
|
| 11 |
+
exports.messagesRouter = (0, express_1.Router)();
|
| 12 |
+
exports.messagesRouter.use(auth_1.requireAuth);
|
| 13 |
+
exports.messagesRouter.get("/conversations", async (req, res, next) => {
|
| 14 |
+
try {
|
| 15 |
+
res.json(await (0, messageService_1.listConversations)(req.authUser.id));
|
| 16 |
+
}
|
| 17 |
+
catch (error) {
|
| 18 |
+
next(error);
|
| 19 |
+
}
|
| 20 |
+
});
|
| 21 |
+
exports.messagesRouter.get("/conversations/:conversationId/messages", async (req, res, next) => {
|
| 22 |
+
try {
|
| 23 |
+
res.json(await (0, messageService_1.listMessages)(req.authUser.id, req.params.conversationId));
|
| 24 |
+
}
|
| 25 |
+
catch (error) {
|
| 26 |
+
next(error);
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
exports.messagesRouter.post("/conversations/:conversationId/messages", async (req, res, next) => {
|
| 30 |
+
try {
|
| 31 |
+
const input = sendMessageSchema.parse(req.body);
|
| 32 |
+
res.json(await (0, messageService_1.sendMessage)(req.authUser.id, req.params.conversationId, input.body));
|
| 33 |
+
}
|
| 34 |
+
catch (error) {
|
| 35 |
+
next(error);
|
| 36 |
+
}
|
| 37 |
+
});
|
backend/dist/routes/openClaw.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.openClawRouter = void 0;
|
| 4 |
+
const express_1 = require("express");
|
| 5 |
+
const zod_1 = require("zod");
|
| 6 |
+
const auth_1 = require("../middleware/auth");
|
| 7 |
+
const openClawService_1 = require("../services/openClawService");
|
| 8 |
+
const profileService_1 = require("../services/profileService");
|
| 9 |
+
const privacyGuard_1 = require("../utils/privacyGuard");
|
| 10 |
+
const chatSchema = zod_1.z.object({
|
| 11 |
+
currentQuestionId: zod_1.z.string().optional().nullable(),
|
| 12 |
+
messages: zod_1.z.array(zod_1.z.object({
|
| 13 |
+
role: zod_1.z.enum(["user", "assistant"]),
|
| 14 |
+
content: zod_1.z.string().min(1).max(8000)
|
| 15 |
+
})).min(1).max(40)
|
| 16 |
+
});
|
| 17 |
+
exports.openClawRouter = (0, express_1.Router)();
|
| 18 |
+
exports.openClawRouter.post("/chat", auth_1.requireAuth, async (req, res, next) => {
|
| 19 |
+
try {
|
| 20 |
+
const userId = req.authUser.id;
|
| 21 |
+
const input = chatSchema.parse(req.body);
|
| 22 |
+
const latestUserMessage = [...input.messages].reverse().find((message) => message.role === "user");
|
| 23 |
+
if (latestUserMessage && (0, privacyGuard_1.isPrivacyLookupRequest)(latestUserMessage.content)) {
|
| 24 |
+
res.json({
|
| 25 |
+
assistant_text: (0, privacyGuard_1.privacyRefusalText)(),
|
| 26 |
+
current_question_id: input.currentQuestionId ?? "q1",
|
| 27 |
+
captured_answer: null,
|
| 28 |
+
next_question_id: input.currentQuestionId ?? "q1",
|
| 29 |
+
followup_needed: true,
|
| 30 |
+
profile_complete: false
|
| 31 |
+
});
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
const profile = await (0, profileService_1.getMyProfile)(userId);
|
| 35 |
+
const reply = await (0, openClawService_1.chatWithOpenClaw)({
|
| 36 |
+
messages: input.messages,
|
| 37 |
+
answers: (profile?.answers ?? {}),
|
| 38 |
+
currentQuestionId: input.currentQuestionId
|
| 39 |
+
});
|
| 40 |
+
if (reply.captured_answer) {
|
| 41 |
+
await (0, profileService_1.saveProfileAnswer)(userId, reply.captured_answer.questionId, {
|
| 42 |
+
answer: reply.captured_answer.answer,
|
| 43 |
+
followup: reply.captured_answer.followup,
|
| 44 |
+
dealbreakerSeverity: reply.captured_answer.dealbreakerSeverity
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
res.json(reply);
|
| 48 |
+
}
|
| 49 |
+
catch (error) {
|
| 50 |
+
next(error);
|
| 51 |
+
}
|
| 52 |
+
});
|
backend/dist/routes/payments.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
| 3 |
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
| 4 |
+
};
|
| 5 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 6 |
+
exports.paymentWebhookRouter = exports.paymentsRouter = void 0;
|
| 7 |
+
const express_1 = require("express");
|
| 8 |
+
const stripe_1 = __importDefault(require("stripe"));
|
| 9 |
+
const env_1 = require("../config/env");
|
| 10 |
+
const supabase_1 = require("../config/supabase");
|
| 11 |
+
const auth_1 = require("../middleware/auth");
|
| 12 |
+
const profileService_1 = require("../services/profileService");
|
| 13 |
+
const httpError_1 = require("../utils/httpError");
|
| 14 |
+
const stripe = new stripe_1.default(env_1.env.STRIPE_SECRET_KEY, {
|
| 15 |
+
typescript: true
|
| 16 |
+
});
|
| 17 |
+
exports.paymentsRouter = (0, express_1.Router)();
|
| 18 |
+
exports.paymentWebhookRouter = (0, express_1.Router)();
|
| 19 |
+
exports.paymentsRouter.use(auth_1.requireAuth);
|
| 20 |
+
exports.paymentsRouter.post("/checkout-session", async (req, res, next) => {
|
| 21 |
+
try {
|
| 22 |
+
const user = req.authUser;
|
| 23 |
+
const session = await stripe.checkout.sessions.create({
|
| 24 |
+
mode: "payment",
|
| 25 |
+
customer_email: user.email ?? undefined,
|
| 26 |
+
client_reference_id: user.id,
|
| 27 |
+
metadata: {
|
| 28 |
+
user_id: user.id,
|
| 29 |
+
product: "lovegpt_entry"
|
| 30 |
+
},
|
| 31 |
+
success_url: env_1.env.FRONTEND_SUCCESS_URL,
|
| 32 |
+
cancel_url: env_1.env.FRONTEND_CANCEL_URL,
|
| 33 |
+
line_items: [
|
| 34 |
+
env_1.env.STRIPE_PRICE_ID
|
| 35 |
+
? {
|
| 36 |
+
price: env_1.env.STRIPE_PRICE_ID,
|
| 37 |
+
quantity: 1
|
| 38 |
+
}
|
| 39 |
+
: {
|
| 40 |
+
quantity: 1,
|
| 41 |
+
price_data: {
|
| 42 |
+
currency: env_1.env.ENTRY_FEE_CURRENCY,
|
| 43 |
+
unit_amount: env_1.env.ENTRY_FEE_CENTS,
|
| 44 |
+
product_data: {
|
| 45 |
+
name: "loveGPT Entry",
|
| 46 |
+
description: "One-time access to ten curated high-compatibility matches."
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
]
|
| 51 |
+
});
|
| 52 |
+
res.json({
|
| 53 |
+
id: session.id,
|
| 54 |
+
url: session.url
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
catch (error) {
|
| 58 |
+
next(error);
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
exports.paymentWebhookRouter.post("/", async (req, res, next) => {
|
| 62 |
+
try {
|
| 63 |
+
const signature = req.header("stripe-signature");
|
| 64 |
+
if (!signature) {
|
| 65 |
+
throw new httpError_1.HttpError(400, "stripe_signature_missing", "Missing Stripe signature");
|
| 66 |
+
}
|
| 67 |
+
const event = stripe.webhooks.constructEvent(req.body, signature, env_1.env.STRIPE_WEBHOOK_SECRET);
|
| 68 |
+
if (event.type === "checkout.session.completed") {
|
| 69 |
+
const session = event.data.object;
|
| 70 |
+
const userId = session.metadata?.user_id ?? session.client_reference_id;
|
| 71 |
+
if (!userId) {
|
| 72 |
+
throw new httpError_1.HttpError(400, "stripe_user_missing", "Stripe session does not include a user id");
|
| 73 |
+
}
|
| 74 |
+
await (0, profileService_1.markUserPaid)(userId, typeof session.customer === "string" ? session.customer : session.customer?.id, session.id);
|
| 75 |
+
const { error } = await supabase_1.supabaseAdmin.from("payments").upsert({
|
| 76 |
+
user_id: userId,
|
| 77 |
+
stripe_event_id: event.id,
|
| 78 |
+
stripe_checkout_session_id: session.id,
|
| 79 |
+
amount: session.amount_total ?? env_1.env.ENTRY_FEE_CENTS,
|
| 80 |
+
currency: session.currency ?? env_1.env.ENTRY_FEE_CURRENCY,
|
| 81 |
+
status: "paid"
|
| 82 |
+
}, { onConflict: "stripe_event_id" });
|
| 83 |
+
if (error) {
|
| 84 |
+
throw error;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
res.json({ received: true });
|
| 88 |
+
}
|
| 89 |
+
catch (error) {
|
| 90 |
+
next(error);
|
| 91 |
+
}
|
| 92 |
+
});
|
backend/dist/routes/profiles.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.profilesRouter = void 0;
|
| 4 |
+
const express_1 = require("express");
|
| 5 |
+
const zod_1 = require("zod");
|
| 6 |
+
const auth_1 = require("../middleware/auth");
|
| 7 |
+
const profileService_1 = require("../services/profileService");
|
| 8 |
+
const questions_1 = require("../utils/questions");
|
| 9 |
+
const upsertProfileSchema = zod_1.z.object({
|
| 10 |
+
displayName: zod_1.z.string().trim().min(1).max(80),
|
| 11 |
+
age: zod_1.z.number().int().min(18).max(120),
|
| 12 |
+
location: zod_1.z.string().trim().max(120).optional(),
|
| 13 |
+
gender: zod_1.z.string().trim().max(80).optional(),
|
| 14 |
+
interestedIn: zod_1.z.array(zod_1.z.string().trim().min(1).max(80)).max(12).optional()
|
| 15 |
+
});
|
| 16 |
+
const answerSchema = zod_1.z.object({
|
| 17 |
+
answer: zod_1.z.union([zod_1.z.string().trim().min(1).max(8000), zod_1.z.array(zod_1.z.string().trim().min(1).max(500)).min(1).max(24)]),
|
| 18 |
+
followup: zod_1.z.string().trim().max(4000).optional(),
|
| 19 |
+
dealbreakerSeverity: zod_1.z.enum(["low", "medium", "high"]).optional()
|
| 20 |
+
});
|
| 21 |
+
exports.profilesRouter = (0, express_1.Router)();
|
| 22 |
+
exports.profilesRouter.use(auth_1.requireAuth);
|
| 23 |
+
exports.profilesRouter.get("/questions", (_req, res) => {
|
| 24 |
+
res.json((0, questions_1.getQuestionsDocument)());
|
| 25 |
+
});
|
| 26 |
+
exports.profilesRouter.get("/me", async (req, res, next) => {
|
| 27 |
+
try {
|
| 28 |
+
res.json(await (0, profileService_1.getMyProfile)(req.authUser.id));
|
| 29 |
+
}
|
| 30 |
+
catch (error) {
|
| 31 |
+
next(error);
|
| 32 |
+
}
|
| 33 |
+
});
|
| 34 |
+
exports.profilesRouter.post("/me", async (req, res, next) => {
|
| 35 |
+
try {
|
| 36 |
+
const input = upsertProfileSchema.parse(req.body);
|
| 37 |
+
res.json(await (0, profileService_1.upsertMyProfile)(req.authUser.id, input));
|
| 38 |
+
}
|
| 39 |
+
catch (error) {
|
| 40 |
+
next(error);
|
| 41 |
+
}
|
| 42 |
+
});
|
| 43 |
+
exports.profilesRouter.patch("/answers/:questionId", async (req, res, next) => {
|
| 44 |
+
try {
|
| 45 |
+
const input = answerSchema.parse(req.body);
|
| 46 |
+
res.json(await (0, profileService_1.saveProfileAnswer)(req.authUser.id, req.params.questionId, input));
|
| 47 |
+
}
|
| 48 |
+
catch (error) {
|
| 49 |
+
next(error);
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
exports.profilesRouter.post("/complete", async (req, res, next) => {
|
| 53 |
+
try {
|
| 54 |
+
res.json(await (0, profileService_1.completeMyProfile)(req.authUser.id));
|
| 55 |
+
}
|
| 56 |
+
catch (error) {
|
| 57 |
+
next(error);
|
| 58 |
+
}
|
| 59 |
+
});
|
backend/dist/server.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
const env_1 = require("./config/env");
|
| 4 |
+
const app_1 = require("./app");
|
| 5 |
+
const app = (0, app_1.createApp)();
|
| 6 |
+
app.listen(env_1.env.PORT, () => {
|
| 7 |
+
console.log(`loveGPT backend listening on port ${env_1.env.PORT}`);
|
| 8 |
+
});
|
backend/dist/services/embeddingService.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.createEmbedding = createEmbedding;
|
| 4 |
+
const env_1 = require("../config/env");
|
| 5 |
+
async function createEmbedding(input) {
|
| 6 |
+
(0, env_1.assertEmbeddingsConfigured)();
|
| 7 |
+
const response = await fetch(`${env_1.env.EMBEDDING_BASE_URL.replace(/\/$/, "")}/embeddings`, {
|
| 8 |
+
method: "POST",
|
| 9 |
+
headers: {
|
| 10 |
+
Authorization: `Bearer ${env_1.env.EMBEDDING_API_KEY}`,
|
| 11 |
+
"Content-Type": "application/json"
|
| 12 |
+
},
|
| 13 |
+
body: JSON.stringify({
|
| 14 |
+
model: env_1.env.EMBEDDING_MODEL,
|
| 15 |
+
input,
|
| 16 |
+
dimensions: env_1.env.EMBEDDING_DIMENSIONS
|
| 17 |
+
})
|
| 18 |
+
});
|
| 19 |
+
if (!response.ok) {
|
| 20 |
+
const text = await response.text();
|
| 21 |
+
throw new Error(`Embedding provider failed with ${response.status}: ${text}`);
|
| 22 |
+
}
|
| 23 |
+
const payload = (await response.json());
|
| 24 |
+
const embedding = payload.data?.[0]?.embedding;
|
| 25 |
+
if (!embedding || embedding.length !== env_1.env.EMBEDDING_DIMENSIONS) {
|
| 26 |
+
throw new Error(`Embedding provider returned an invalid vector. Expected ${env_1.env.EMBEDDING_DIMENSIONS} dimensions.`);
|
| 27 |
+
}
|
| 28 |
+
return embedding;
|
| 29 |
+
}
|
backend/dist/services/matchService.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.listMyMatches = listMyMatches;
|
| 4 |
+
exports.curateMatches = curateMatches;
|
| 5 |
+
exports.acceptMatch = acceptMatch;
|
| 6 |
+
const supabase_1 = require("../config/supabase");
|
| 7 |
+
const httpError_1 = require("../utils/httpError");
|
| 8 |
+
function orderedPair(a, b) {
|
| 9 |
+
return a < b ? [a, b] : [b, a];
|
| 10 |
+
}
|
| 11 |
+
async function listMyMatches(userId) {
|
| 12 |
+
const { data: profile, error: profileError } = await supabase_1.supabaseAdmin
|
| 13 |
+
.from("profiles")
|
| 14 |
+
.select("id")
|
| 15 |
+
.eq("user_id", userId)
|
| 16 |
+
.maybeSingle();
|
| 17 |
+
if (profileError) {
|
| 18 |
+
throw profileError;
|
| 19 |
+
}
|
| 20 |
+
if (!profile) {
|
| 21 |
+
return [];
|
| 22 |
+
}
|
| 23 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 24 |
+
.from("matches")
|
| 25 |
+
.select(`
|
| 26 |
+
id,
|
| 27 |
+
compatibility_score,
|
| 28 |
+
shared_traits,
|
| 29 |
+
caution_notes,
|
| 30 |
+
profile_a_accepted,
|
| 31 |
+
profile_b_accepted,
|
| 32 |
+
status,
|
| 33 |
+
profile_a_id,
|
| 34 |
+
profile_b_id,
|
| 35 |
+
profile_a:profiles!matches_profile_a_id_fkey(id, display_name, age, location, narrative_summary),
|
| 36 |
+
profile_b:profiles!matches_profile_b_id_fkey(id, display_name, age, location, narrative_summary)
|
| 37 |
+
`)
|
| 38 |
+
.or(`profile_a_id.eq.${profile.id},profile_b_id.eq.${profile.id}`)
|
| 39 |
+
.order("compatibility_score", { ascending: false });
|
| 40 |
+
if (error) {
|
| 41 |
+
throw error;
|
| 42 |
+
}
|
| 43 |
+
return (data ?? []).map((match) => {
|
| 44 |
+
const isA = match.profile_a_id === profile.id;
|
| 45 |
+
return {
|
| 46 |
+
id: match.id,
|
| 47 |
+
compatibility_score: match.compatibility_score,
|
| 48 |
+
shared_traits: match.shared_traits,
|
| 49 |
+
caution_notes: match.caution_notes,
|
| 50 |
+
status: match.status,
|
| 51 |
+
my_accepted: isA ? match.profile_a_accepted : match.profile_b_accepted,
|
| 52 |
+
their_accepted: isA ? match.profile_b_accepted : match.profile_a_accepted,
|
| 53 |
+
profile: isA ? match.profile_b : match.profile_a
|
| 54 |
+
};
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
async function curateMatches(userId) {
|
| 58 |
+
const { data: profile, error: profileError } = await supabase_1.supabaseAdmin
|
| 59 |
+
.from("profiles")
|
| 60 |
+
.select("id, profile_complete, paid_entry")
|
| 61 |
+
.eq("user_id", userId)
|
| 62 |
+
.maybeSingle();
|
| 63 |
+
if (profileError) {
|
| 64 |
+
throw profileError;
|
| 65 |
+
}
|
| 66 |
+
if (!profile) {
|
| 67 |
+
throw new httpError_1.HttpError(409, "profile_required", "Create and complete a profile before curating matches");
|
| 68 |
+
}
|
| 69 |
+
if (!profile.profile_complete) {
|
| 70 |
+
throw new httpError_1.HttpError(422, "profile_incomplete", "Complete the full questionnaire before curating matches");
|
| 71 |
+
}
|
| 72 |
+
if (!profile.paid_entry) {
|
| 73 |
+
throw new httpError_1.HttpError(402, "entry_fee_required", "The one-time entry fee is required before match curation");
|
| 74 |
+
}
|
| 75 |
+
const { data: candidates, error: rpcError } = await supabase_1.supabaseAdmin.rpc("match_candidates", {
|
| 76 |
+
source_profile_id: profile.id,
|
| 77 |
+
match_count: 10
|
| 78 |
+
});
|
| 79 |
+
if (rpcError) {
|
| 80 |
+
throw rpcError;
|
| 81 |
+
}
|
| 82 |
+
for (const candidate of (candidates ?? [])) {
|
| 83 |
+
const [profileAId, profileBId] = orderedPair(profile.id, candidate.candidate_profile_id);
|
| 84 |
+
const { error } = await supabase_1.supabaseAdmin.from("matches").upsert({
|
| 85 |
+
profile_a_id: profileAId,
|
| 86 |
+
profile_b_id: profileBId,
|
| 87 |
+
compatibility_score: candidate.compatibility_score,
|
| 88 |
+
shared_traits: candidate.shared_traits,
|
| 89 |
+
caution_notes: candidate.caution_notes
|
| 90 |
+
}, { onConflict: "profile_a_id,profile_b_id" });
|
| 91 |
+
if (error) {
|
| 92 |
+
throw error;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
return listMyMatches(userId);
|
| 96 |
+
}
|
| 97 |
+
async function acceptMatch(userId, matchId) {
|
| 98 |
+
const { data: profile, error: profileError } = await supabase_1.supabaseAdmin
|
| 99 |
+
.from("profiles")
|
| 100 |
+
.select("id")
|
| 101 |
+
.eq("user_id", userId)
|
| 102 |
+
.maybeSingle();
|
| 103 |
+
if (profileError) {
|
| 104 |
+
throw profileError;
|
| 105 |
+
}
|
| 106 |
+
if (!profile) {
|
| 107 |
+
throw new httpError_1.HttpError(404, "profile_not_found", "Profile not found");
|
| 108 |
+
}
|
| 109 |
+
const { data: match, error: matchError } = await supabase_1.supabaseAdmin
|
| 110 |
+
.from("matches")
|
| 111 |
+
.select("*")
|
| 112 |
+
.eq("id", matchId)
|
| 113 |
+
.or(`profile_a_id.eq.${profile.id},profile_b_id.eq.${profile.id}`)
|
| 114 |
+
.single();
|
| 115 |
+
if (matchError || !match) {
|
| 116 |
+
throw new httpError_1.HttpError(404, "match_not_found", "Match not found");
|
| 117 |
+
}
|
| 118 |
+
const isProfileA = match.profile_a_id === profile.id;
|
| 119 |
+
const profileAAccepted = isProfileA ? true : match.profile_a_accepted;
|
| 120 |
+
const profileBAccepted = isProfileA ? match.profile_b_accepted : true;
|
| 121 |
+
const nextStatus = profileAAccepted && profileBAccepted ? "active" : "accepted";
|
| 122 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 123 |
+
.from("matches")
|
| 124 |
+
.update({
|
| 125 |
+
profile_a_accepted: profileAAccepted,
|
| 126 |
+
profile_b_accepted: profileBAccepted,
|
| 127 |
+
status: nextStatus
|
| 128 |
+
})
|
| 129 |
+
.eq("id", matchId)
|
| 130 |
+
.select("*")
|
| 131 |
+
.single();
|
| 132 |
+
if (error) {
|
| 133 |
+
throw error;
|
| 134 |
+
}
|
| 135 |
+
if (nextStatus === "active") {
|
| 136 |
+
const { error: conversationError } = await supabase_1.supabaseAdmin
|
| 137 |
+
.from("conversations")
|
| 138 |
+
.upsert({ match_id: matchId }, { onConflict: "match_id" });
|
| 139 |
+
if (conversationError) {
|
| 140 |
+
throw conversationError;
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
return data;
|
| 144 |
+
}
|
backend/dist/services/messageService.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.listConversations = listConversations;
|
| 4 |
+
exports.listMessages = listMessages;
|
| 5 |
+
exports.sendMessage = sendMessage;
|
| 6 |
+
const supabase_1 = require("../config/supabase");
|
| 7 |
+
const httpError_1 = require("../utils/httpError");
|
| 8 |
+
async function listConversations(userId) {
|
| 9 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 10 |
+
.from("conversations")
|
| 11 |
+
.select(`
|
| 12 |
+
id,
|
| 13 |
+
match_id,
|
| 14 |
+
created_at,
|
| 15 |
+
updated_at,
|
| 16 |
+
match:matches(id, status, compatibility_score, profile_a_id, profile_b_id)
|
| 17 |
+
`)
|
| 18 |
+
.order("updated_at", { ascending: false });
|
| 19 |
+
if (error) {
|
| 20 |
+
throw error;
|
| 21 |
+
}
|
| 22 |
+
const { data: profile, error: profileError } = await supabase_1.supabaseAdmin
|
| 23 |
+
.from("profiles")
|
| 24 |
+
.select("id")
|
| 25 |
+
.eq("user_id", userId)
|
| 26 |
+
.maybeSingle();
|
| 27 |
+
if (profileError) {
|
| 28 |
+
throw profileError;
|
| 29 |
+
}
|
| 30 |
+
return (data ?? []).filter((conversation) => {
|
| 31 |
+
const match = conversation.match;
|
| 32 |
+
return match.profile_a_id === profile?.id || match.profile_b_id === profile?.id;
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
async function listMessages(userId, conversationId) {
|
| 36 |
+
await assertConversationParticipant(userId, conversationId);
|
| 37 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 38 |
+
.from("messages")
|
| 39 |
+
.select("*")
|
| 40 |
+
.eq("conversation_id", conversationId)
|
| 41 |
+
.order("created_at", { ascending: true });
|
| 42 |
+
if (error) {
|
| 43 |
+
throw error;
|
| 44 |
+
}
|
| 45 |
+
return data ?? [];
|
| 46 |
+
}
|
| 47 |
+
async function sendMessage(userId, conversationId, body) {
|
| 48 |
+
await assertConversationParticipant(userId, conversationId);
|
| 49 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 50 |
+
.from("messages")
|
| 51 |
+
.insert({
|
| 52 |
+
conversation_id: conversationId,
|
| 53 |
+
sender_user_id: userId,
|
| 54 |
+
body
|
| 55 |
+
})
|
| 56 |
+
.select("*")
|
| 57 |
+
.single();
|
| 58 |
+
if (error) {
|
| 59 |
+
throw error;
|
| 60 |
+
}
|
| 61 |
+
await supabase_1.supabaseAdmin.from("conversations").update({ updated_at: new Date().toISOString() }).eq("id", conversationId);
|
| 62 |
+
return data;
|
| 63 |
+
}
|
| 64 |
+
async function assertConversationParticipant(userId, conversationId) {
|
| 65 |
+
const { data: profile, error: profileError } = await supabase_1.supabaseAdmin
|
| 66 |
+
.from("profiles")
|
| 67 |
+
.select("id")
|
| 68 |
+
.eq("user_id", userId)
|
| 69 |
+
.maybeSingle();
|
| 70 |
+
if (profileError) {
|
| 71 |
+
throw profileError;
|
| 72 |
+
}
|
| 73 |
+
if (!profile) {
|
| 74 |
+
throw new httpError_1.HttpError(404, "profile_not_found", "Profile not found");
|
| 75 |
+
}
|
| 76 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 77 |
+
.from("conversations")
|
| 78 |
+
.select("id, match:matches(profile_a_id, profile_b_id, status)")
|
| 79 |
+
.eq("id", conversationId)
|
| 80 |
+
.maybeSingle();
|
| 81 |
+
if (error) {
|
| 82 |
+
throw error;
|
| 83 |
+
}
|
| 84 |
+
const match = data?.match;
|
| 85 |
+
const isParticipant = match?.profile_a_id === profile.id || match?.profile_b_id === profile.id;
|
| 86 |
+
if (!data || !match || !isParticipant || !["accepted", "active"].includes(match.status ?? "")) {
|
| 87 |
+
throw new httpError_1.HttpError(403, "conversation_forbidden", "Conversation is not available for this user");
|
| 88 |
+
}
|
| 89 |
+
}
|
backend/dist/services/openClawService.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
| 3 |
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
| 4 |
+
};
|
| 5 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 6 |
+
exports.openClawReplySchema = void 0;
|
| 7 |
+
exports.chatWithOpenClaw = chatWithOpenClaw;
|
| 8 |
+
exports.summarizeProfile = summarizeProfile;
|
| 9 |
+
const node_fs_1 = __importDefault(require("node:fs"));
|
| 10 |
+
const node_path_1 = __importDefault(require("node:path"));
|
| 11 |
+
const zod_1 = require("zod");
|
| 12 |
+
const env_1 = require("../config/env");
|
| 13 |
+
const questions_1 = require("../utils/questions");
|
| 14 |
+
const capturedAnswerSchema = zod_1.z.object({
|
| 15 |
+
questionId: zod_1.z.string(),
|
| 16 |
+
answer: zod_1.z.union([zod_1.z.string(), zod_1.z.array(zod_1.z.string())]),
|
| 17 |
+
followup: zod_1.z.string().optional(),
|
| 18 |
+
dealbreakerSeverity: zod_1.z.enum(["low", "medium", "high"]).optional()
|
| 19 |
+
});
|
| 20 |
+
exports.openClawReplySchema = zod_1.z.object({
|
| 21 |
+
assistant_text: zod_1.z.string().min(1),
|
| 22 |
+
current_question_id: zod_1.z.string(),
|
| 23 |
+
captured_answer: capturedAnswerSchema.nullable(),
|
| 24 |
+
next_question_id: zod_1.z.string().nullable(),
|
| 25 |
+
followup_needed: zod_1.z.boolean(),
|
| 26 |
+
profile_complete: zod_1.z.boolean()
|
| 27 |
+
});
|
| 28 |
+
function loadPrompt() {
|
| 29 |
+
const candidates = [
|
| 30 |
+
node_path_1.default.resolve(process.cwd(), "src", "prompts", "openclaw.md"),
|
| 31 |
+
node_path_1.default.resolve(process.cwd(), "prompts", "openclaw.md"),
|
| 32 |
+
node_path_1.default.resolve(process.cwd(), "..", "artifacts", "prompts", "openclaw.md"),
|
| 33 |
+
node_path_1.default.resolve(__dirname, "..", "prompts", "openclaw.md")
|
| 34 |
+
];
|
| 35 |
+
const found = candidates.find((candidate) => node_fs_1.default.existsSync(candidate));
|
| 36 |
+
if (!found) {
|
| 37 |
+
throw new Error("OpenClaw prompt was not found");
|
| 38 |
+
}
|
| 39 |
+
return node_fs_1.default.readFileSync(found, "utf8");
|
| 40 |
+
}
|
| 41 |
+
function compactContext(input) {
|
| 42 |
+
const questions = (0, questions_1.getQuestionsDocument)();
|
| 43 |
+
const answeredIds = Object.keys(input.answers);
|
| 44 |
+
const currentQuestionId = input.currentQuestionId ?? (0, questions_1.getNextQuestionId)(answeredIds) ?? "q40";
|
| 45 |
+
const currentQuestion = questions.questions.find((question) => question.id === currentQuestionId);
|
| 46 |
+
return JSON.stringify({
|
| 47 |
+
currentQuestion,
|
| 48 |
+
answeredQuestionIds: answeredIds,
|
| 49 |
+
remainingQuestionIds: questions.questions.filter((question) => !answeredIds.includes(question.id)).map((question) => question.id),
|
| 50 |
+
existingAnswers: input.answers,
|
| 51 |
+
questionFramework: questions
|
| 52 |
+
}, null, 2);
|
| 53 |
+
}
|
| 54 |
+
function transcript(messages) {
|
| 55 |
+
return messages
|
| 56 |
+
.slice(-16)
|
| 57 |
+
.map((message) => `${message.role.toUpperCase()}: ${message.content}`)
|
| 58 |
+
.join("\n");
|
| 59 |
+
}
|
| 60 |
+
function extractJsonObject(text) {
|
| 61 |
+
const trimmed = text.trim();
|
| 62 |
+
try {
|
| 63 |
+
return JSON.parse(trimmed);
|
| 64 |
+
}
|
| 65 |
+
catch {
|
| 66 |
+
const start = trimmed.indexOf("{");
|
| 67 |
+
const end = trimmed.lastIndexOf("}");
|
| 68 |
+
if (start >= 0 && end > start) {
|
| 69 |
+
return JSON.parse(trimmed.slice(start, end + 1));
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
throw new Error("AI response did not contain valid JSON");
|
| 73 |
+
}
|
| 74 |
+
async function callAnthropic(systemPrompt, userPayload) {
|
| 75 |
+
(0, env_1.assertAiConfigured)();
|
| 76 |
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
| 77 |
+
method: "POST",
|
| 78 |
+
headers: {
|
| 79 |
+
"x-api-key": env_1.env.ANTHROPIC_API_KEY ?? "",
|
| 80 |
+
"anthropic-version": "2023-06-01",
|
| 81 |
+
"Content-Type": "application/json"
|
| 82 |
+
},
|
| 83 |
+
body: JSON.stringify({
|
| 84 |
+
model: env_1.env.AI_MODEL,
|
| 85 |
+
max_tokens: 1200,
|
| 86 |
+
temperature: 0.4,
|
| 87 |
+
system: systemPrompt,
|
| 88 |
+
messages: [
|
| 89 |
+
{
|
| 90 |
+
role: "user",
|
| 91 |
+
content: userPayload
|
| 92 |
+
}
|
| 93 |
+
]
|
| 94 |
+
})
|
| 95 |
+
});
|
| 96 |
+
if (!response.ok) {
|
| 97 |
+
const text = await response.text();
|
| 98 |
+
throw new Error(`Anthropic request failed with ${response.status}: ${text}`);
|
| 99 |
+
}
|
| 100 |
+
const payload = (await response.json());
|
| 101 |
+
const text = payload.content?.find((item) => item.type === "text")?.text;
|
| 102 |
+
if (!text) {
|
| 103 |
+
throw new Error("Anthropic returned no text content");
|
| 104 |
+
}
|
| 105 |
+
return text;
|
| 106 |
+
}
|
| 107 |
+
async function callGrok(systemPrompt, userPayload) {
|
| 108 |
+
(0, env_1.assertAiConfigured)();
|
| 109 |
+
const response = await fetch(`${env_1.env.GROK_BASE_URL.replace(/\/$/, "")}/chat/completions`, {
|
| 110 |
+
method: "POST",
|
| 111 |
+
headers: {
|
| 112 |
+
Authorization: `Bearer ${env_1.env.GROK_API_KEY ?? ""}`,
|
| 113 |
+
"Content-Type": "application/json"
|
| 114 |
+
},
|
| 115 |
+
body: JSON.stringify({
|
| 116 |
+
model: env_1.env.AI_MODEL,
|
| 117 |
+
temperature: 0.4,
|
| 118 |
+
response_format: { type: "json_object" },
|
| 119 |
+
messages: [
|
| 120 |
+
{ role: "system", content: systemPrompt },
|
| 121 |
+
{ role: "user", content: userPayload }
|
| 122 |
+
]
|
| 123 |
+
})
|
| 124 |
+
});
|
| 125 |
+
if (!response.ok) {
|
| 126 |
+
const text = await response.text();
|
| 127 |
+
throw new Error(`Grok request failed with ${response.status}: ${text}`);
|
| 128 |
+
}
|
| 129 |
+
const payload = (await response.json());
|
| 130 |
+
const text = payload.choices?.[0]?.message?.content;
|
| 131 |
+
if (!text) {
|
| 132 |
+
throw new Error("Grok returned no message content");
|
| 133 |
+
}
|
| 134 |
+
return text;
|
| 135 |
+
}
|
| 136 |
+
async function callLocalOpenAiCompatible(systemPrompt, userPayload) {
|
| 137 |
+
(0, env_1.assertAiConfigured)();
|
| 138 |
+
const headers = {
|
| 139 |
+
"Content-Type": "application/json"
|
| 140 |
+
};
|
| 141 |
+
if (env_1.env.LOCAL_AI_API_KEY) {
|
| 142 |
+
headers.Authorization = `Bearer ${env_1.env.LOCAL_AI_API_KEY}`;
|
| 143 |
+
}
|
| 144 |
+
const response = await fetch(`${env_1.env.LOCAL_AI_BASE_URL.replace(/\/$/, "")}/chat/completions`, {
|
| 145 |
+
method: "POST",
|
| 146 |
+
headers,
|
| 147 |
+
body: JSON.stringify({
|
| 148 |
+
model: env_1.env.AI_MODEL,
|
| 149 |
+
max_tokens: 1200,
|
| 150 |
+
temperature: 0.2,
|
| 151 |
+
response_format: { type: "json_object" },
|
| 152 |
+
messages: [
|
| 153 |
+
{
|
| 154 |
+
role: "system",
|
| 155 |
+
content: [
|
| 156 |
+
systemPrompt,
|
| 157 |
+
"",
|
| 158 |
+
"You are running as a local Phi model. Return exactly one valid JSON object.",
|
| 159 |
+
"Do not include markdown, XML tags, chain-of-thought, commentary, or text outside JSON."
|
| 160 |
+
].join("\n")
|
| 161 |
+
},
|
| 162 |
+
{ role: "user", content: userPayload }
|
| 163 |
+
]
|
| 164 |
+
})
|
| 165 |
+
});
|
| 166 |
+
if (!response.ok) {
|
| 167 |
+
const text = await response.text();
|
| 168 |
+
throw new Error(`Local OpenClaw provider failed with ${response.status}: ${text}`);
|
| 169 |
+
}
|
| 170 |
+
const payload = (await response.json());
|
| 171 |
+
const text = payload.choices?.[0]?.message?.content;
|
| 172 |
+
if (!text) {
|
| 173 |
+
throw new Error("Local OpenClaw provider returned no message content");
|
| 174 |
+
}
|
| 175 |
+
return text;
|
| 176 |
+
}
|
| 177 |
+
async function callConfiguredModel(systemPrompt, userPayload) {
|
| 178 |
+
if (env_1.env.AI_PROVIDER === "anthropic") {
|
| 179 |
+
return callAnthropic(systemPrompt, userPayload);
|
| 180 |
+
}
|
| 181 |
+
if (env_1.env.AI_PROVIDER === "grok") {
|
| 182 |
+
return callGrok(systemPrompt, userPayload);
|
| 183 |
+
}
|
| 184 |
+
return callLocalOpenAiCompatible(systemPrompt, userPayload);
|
| 185 |
+
}
|
| 186 |
+
async function chatWithOpenClaw(input) {
|
| 187 |
+
const prompt = loadPrompt();
|
| 188 |
+
const userPayload = [
|
| 189 |
+
"Use the following profile state and conversation transcript.",
|
| 190 |
+
"Return only the JSON object required by the response contract.",
|
| 191 |
+
"",
|
| 192 |
+
"PROFILE_STATE:",
|
| 193 |
+
compactContext(input),
|
| 194 |
+
"",
|
| 195 |
+
"TRANSCRIPT:",
|
| 196 |
+
transcript(input.messages)
|
| 197 |
+
].join("\n");
|
| 198 |
+
const raw = await callConfiguredModel(prompt, userPayload);
|
| 199 |
+
return exports.openClawReplySchema.parse(extractJsonObject(raw));
|
| 200 |
+
}
|
| 201 |
+
async function summarizeProfile(answers) {
|
| 202 |
+
const systemPrompt = [
|
| 203 |
+
"You are OpenClaw, writing concise psychological dating profile summaries for internal matching.",
|
| 204 |
+
"Summaries must be concrete, nonjudgmental, and useful for compatibility scoring.",
|
| 205 |
+
"Return plain text only. Do not include markdown."
|
| 206 |
+
].join("\n");
|
| 207 |
+
const userPayload = JSON.stringify({ questions: (0, questions_1.getQuestionsDocument)(), answers }, null, 2);
|
| 208 |
+
const text = await callConfiguredModel(systemPrompt, userPayload);
|
| 209 |
+
return text.trim();
|
| 210 |
+
}
|
backend/dist/services/profileService.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.getMyProfile = getMyProfile;
|
| 4 |
+
exports.upsertMyProfile = upsertMyProfile;
|
| 5 |
+
exports.saveProfileAnswer = saveProfileAnswer;
|
| 6 |
+
exports.completeMyProfile = completeMyProfile;
|
| 7 |
+
exports.markUserPaid = markUserPaid;
|
| 8 |
+
const supabase_1 = require("../config/supabase");
|
| 9 |
+
const httpError_1 = require("../utils/httpError");
|
| 10 |
+
const questions_1 = require("../utils/questions");
|
| 11 |
+
const embeddingService_1 = require("./embeddingService");
|
| 12 |
+
const openClawService_1 = require("./openClawService");
|
| 13 |
+
function answerText(answer) {
|
| 14 |
+
const raw = Array.isArray(answer.answer) ? answer.answer.join(", ") : answer.answer;
|
| 15 |
+
return [raw, answer.followup].filter(Boolean).join(" ");
|
| 16 |
+
}
|
| 17 |
+
function normalizeSignal(value) {
|
| 18 |
+
return value
|
| 19 |
+
.toLowerCase()
|
| 20 |
+
.replace(/[^a-z0-9\s-]/g, " ")
|
| 21 |
+
.replace(/\s+/g, " ")
|
| 22 |
+
.trim()
|
| 23 |
+
.slice(0, 80);
|
| 24 |
+
}
|
| 25 |
+
function collectSignals(answers, tag) {
|
| 26 |
+
const questions = (0, questions_1.getQuestionsDocument)().questions.filter((question) => question.tags.includes(tag));
|
| 27 |
+
const signals = new Set();
|
| 28 |
+
for (const question of questions) {
|
| 29 |
+
const answer = answers[question.id];
|
| 30 |
+
if (!answer) {
|
| 31 |
+
continue;
|
| 32 |
+
}
|
| 33 |
+
const values = Array.isArray(answer.answer)
|
| 34 |
+
? answer.answer
|
| 35 |
+
: answer.answer.split(/[.;,\n]/).map((item) => item.trim());
|
| 36 |
+
for (const value of values) {
|
| 37 |
+
const normalized = normalizeSignal(value);
|
| 38 |
+
if (normalized.length >= 3) {
|
| 39 |
+
signals.add(normalized);
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
return [...signals].slice(0, 32);
|
| 44 |
+
}
|
| 45 |
+
function profileTextForEmbedding(answers, narrativeSummary) {
|
| 46 |
+
const questions = (0, questions_1.getQuestionsDocument)().questions;
|
| 47 |
+
const lines = questions
|
| 48 |
+
.map((question) => {
|
| 49 |
+
const answer = answers[question.id];
|
| 50 |
+
return answer ? `${question.prompt}\n${answerText(answer)}` : "";
|
| 51 |
+
})
|
| 52 |
+
.filter(Boolean);
|
| 53 |
+
return [narrativeSummary, ...lines].join("\n\n");
|
| 54 |
+
}
|
| 55 |
+
async function getMyProfile(userId) {
|
| 56 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 57 |
+
.from("profiles")
|
| 58 |
+
.select("*")
|
| 59 |
+
.eq("user_id", userId)
|
| 60 |
+
.maybeSingle();
|
| 61 |
+
if (error) {
|
| 62 |
+
throw error;
|
| 63 |
+
}
|
| 64 |
+
return data;
|
| 65 |
+
}
|
| 66 |
+
async function upsertMyProfile(userId, input) {
|
| 67 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 68 |
+
.from("profiles")
|
| 69 |
+
.upsert({
|
| 70 |
+
user_id: userId,
|
| 71 |
+
display_name: input.displayName,
|
| 72 |
+
age: input.age,
|
| 73 |
+
location: input.location ?? null,
|
| 74 |
+
gender: input.gender ?? null,
|
| 75 |
+
interested_in: input.interestedIn ?? []
|
| 76 |
+
}, { onConflict: "user_id" })
|
| 77 |
+
.select("*")
|
| 78 |
+
.single();
|
| 79 |
+
if (error) {
|
| 80 |
+
throw error;
|
| 81 |
+
}
|
| 82 |
+
return data;
|
| 83 |
+
}
|
| 84 |
+
async function saveProfileAnswer(userId, questionId, input) {
|
| 85 |
+
if (!(0, questions_1.isKnownQuestionId)(questionId)) {
|
| 86 |
+
throw new httpError_1.HttpError(400, "unknown_question", `Unknown question id: ${questionId}`);
|
| 87 |
+
}
|
| 88 |
+
const profile = await getMyProfile(userId);
|
| 89 |
+
if (!profile) {
|
| 90 |
+
throw new httpError_1.HttpError(409, "profile_required", "Create the basic profile before saving answers");
|
| 91 |
+
}
|
| 92 |
+
const answers = (profile.answers ?? {});
|
| 93 |
+
answers[questionId] = {
|
| 94 |
+
questionId,
|
| 95 |
+
answer: input.answer,
|
| 96 |
+
followup: input.followup,
|
| 97 |
+
dealbreakerSeverity: input.dealbreakerSeverity,
|
| 98 |
+
updatedAt: new Date().toISOString()
|
| 99 |
+
};
|
| 100 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 101 |
+
.from("profiles")
|
| 102 |
+
.update({ answers, profile_complete: false })
|
| 103 |
+
.eq("user_id", userId)
|
| 104 |
+
.select("*")
|
| 105 |
+
.single();
|
| 106 |
+
if (error) {
|
| 107 |
+
throw error;
|
| 108 |
+
}
|
| 109 |
+
return data;
|
| 110 |
+
}
|
| 111 |
+
async function completeMyProfile(userId) {
|
| 112 |
+
const profile = await getMyProfile(userId);
|
| 113 |
+
if (!profile) {
|
| 114 |
+
throw new httpError_1.HttpError(409, "profile_required", "Create the basic profile before completing the profile");
|
| 115 |
+
}
|
| 116 |
+
const answers = (profile.answers ?? {});
|
| 117 |
+
const requiredQuestionIds = (0, questions_1.getQuestionsDocument)().questions.map((question) => question.id);
|
| 118 |
+
const missing = requiredQuestionIds.filter((questionId) => !answers[questionId]);
|
| 119 |
+
if (missing.length > 0) {
|
| 120 |
+
throw new httpError_1.HttpError(422, "profile_incomplete", `Missing required answers: ${missing.join(", ")}`);
|
| 121 |
+
}
|
| 122 |
+
const narrativeSummary = await (0, openClawService_1.summarizeProfile)(answers);
|
| 123 |
+
const embedding = await (0, embeddingService_1.createEmbedding)(profileTextForEmbedding(answers, narrativeSummary));
|
| 124 |
+
const disgustTriggers = collectSignals(answers, "disgust");
|
| 125 |
+
const captivatingTraits = collectSignals(answers, "captivating_traits");
|
| 126 |
+
const { data, error } = await supabase_1.supabaseAdmin
|
| 127 |
+
.from("profiles")
|
| 128 |
+
.update({
|
| 129 |
+
narrative_summary: narrativeSummary,
|
| 130 |
+
embedding,
|
| 131 |
+
disgust_triggers: disgustTriggers,
|
| 132 |
+
captivating_traits: captivatingTraits,
|
| 133 |
+
profile_complete: true
|
| 134 |
+
})
|
| 135 |
+
.eq("user_id", userId)
|
| 136 |
+
.select("*")
|
| 137 |
+
.single();
|
| 138 |
+
if (error) {
|
| 139 |
+
throw error;
|
| 140 |
+
}
|
| 141 |
+
return data;
|
| 142 |
+
}
|
| 143 |
+
async function markUserPaid(userId, stripeCustomerId, sessionId) {
|
| 144 |
+
const paidAt = new Date().toISOString();
|
| 145 |
+
const { error: entitlementError } = await supabase_1.supabaseAdmin
|
| 146 |
+
.from("user_entitlements")
|
| 147 |
+
.upsert({
|
| 148 |
+
user_id: userId,
|
| 149 |
+
entry_paid: true,
|
| 150 |
+
stripe_customer_id: stripeCustomerId,
|
| 151 |
+
stripe_checkout_session_id: sessionId,
|
| 152 |
+
paid_at: paidAt
|
| 153 |
+
}, { onConflict: "user_id" });
|
| 154 |
+
if (entitlementError) {
|
| 155 |
+
throw entitlementError;
|
| 156 |
+
}
|
| 157 |
+
const { error: profileError } = await supabase_1.supabaseAdmin
|
| 158 |
+
.from("profiles")
|
| 159 |
+
.update({ paid_entry: true })
|
| 160 |
+
.eq("user_id", userId);
|
| 161 |
+
if (profileError) {
|
| 162 |
+
throw profileError;
|
| 163 |
+
}
|
| 164 |
+
}
|
backend/dist/types/domain.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
backend/dist/utils/httpError.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.HttpError = void 0;
|
| 4 |
+
exports.getErrorMessage = getErrorMessage;
|
| 5 |
+
class HttpError extends Error {
|
| 6 |
+
status;
|
| 7 |
+
code;
|
| 8 |
+
constructor(status, code, message) {
|
| 9 |
+
super(message);
|
| 10 |
+
this.status = status;
|
| 11 |
+
this.code = code;
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
exports.HttpError = HttpError;
|
| 15 |
+
function getErrorMessage(error) {
|
| 16 |
+
if (error instanceof Error) {
|
| 17 |
+
return error.message;
|
| 18 |
+
}
|
| 19 |
+
return "Unexpected error";
|
| 20 |
+
}
|
backend/dist/utils/privacyGuard.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 3 |
+
exports.isPrivacyLookupRequest = isPrivacyLookupRequest;
|
| 4 |
+
exports.privacyRefusalText = privacyRefusalText;
|
| 5 |
+
const privacyLookupPatterns = [
|
| 6 |
+
/\b(find|look\s*up|lookup|search|identify|track|locate|doxx?|reveal|get)\b(?=.{0,120}\b(person|someone|user|profile|match|candidate|woman|man|girl|guy|name|address|phone|email|social|instagram|facebook|details|where)\b)/i,
|
| 7 |
+
/\b(phone number|email address|home address|street address|full name|last name|ssn|social security|where does .* live|where do .* live)\b/i
|
| 8 |
+
];
|
| 9 |
+
function isPrivacyLookupRequest(value) {
|
| 10 |
+
const lowered = value.toLowerCase();
|
| 11 |
+
if (lowered.includes("my ") && !/(find|lookup|look up|search|dox)/i.test(value)) {
|
| 12 |
+
return false;
|
| 13 |
+
}
|
| 14 |
+
return privacyLookupPatterns.some((pattern) => pattern.test(value));
|
| 15 |
+
}
|
| 16 |
+
function privacyRefusalText() {
|
| 17 |
+
return [
|
| 18 |
+
"I cannot help find, identify, or expose personal details about another person.",
|
| 19 |
+
"loveGPT only uses profile information for consent-based compatibility work.",
|
| 20 |
+
"Answer the current profile question about your own preferences and boundaries instead."
|
| 21 |
+
].join(" ");
|
| 22 |
+
}
|
backend/dist/utils/questions.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
| 3 |
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
| 4 |
+
};
|
| 5 |
+
Object.defineProperty(exports, "__esModule", { value: true });
|
| 6 |
+
exports.getQuestionsDocument = getQuestionsDocument;
|
| 7 |
+
exports.getQuestion = getQuestion;
|
| 8 |
+
exports.getNextQuestionId = getNextQuestionId;
|
| 9 |
+
exports.isKnownQuestionId = isKnownQuestionId;
|
| 10 |
+
const node_fs_1 = __importDefault(require("node:fs"));
|
| 11 |
+
const node_path_1 = __importDefault(require("node:path"));
|
| 12 |
+
let cachedQuestions;
|
| 13 |
+
function questionsPath() {
|
| 14 |
+
const candidates = [
|
| 15 |
+
node_path_1.default.resolve(process.cwd(), "..", "shared", "questions.json"),
|
| 16 |
+
node_path_1.default.resolve(process.cwd(), "shared", "questions.json"),
|
| 17 |
+
node_path_1.default.resolve(__dirname, "..", "..", "..", "shared", "questions.json")
|
| 18 |
+
];
|
| 19 |
+
const found = candidates.find((candidate) => node_fs_1.default.existsSync(candidate));
|
| 20 |
+
if (!found) {
|
| 21 |
+
throw new Error("shared/questions.json was not found");
|
| 22 |
+
}
|
| 23 |
+
return found;
|
| 24 |
+
}
|
| 25 |
+
function getQuestionsDocument() {
|
| 26 |
+
if (!cachedQuestions) {
|
| 27 |
+
cachedQuestions = JSON.parse(node_fs_1.default.readFileSync(questionsPath(), "utf8"));
|
| 28 |
+
}
|
| 29 |
+
return cachedQuestions;
|
| 30 |
+
}
|
| 31 |
+
function getQuestion(questionId) {
|
| 32 |
+
const question = getQuestionsDocument().questions.find((item) => item.id === questionId);
|
| 33 |
+
if (!question) {
|
| 34 |
+
throw new Error(`Unknown question id: ${questionId}`);
|
| 35 |
+
}
|
| 36 |
+
return question;
|
| 37 |
+
}
|
| 38 |
+
function getNextQuestionId(answeredQuestionIds) {
|
| 39 |
+
const answered = new Set(answeredQuestionIds);
|
| 40 |
+
const next = getQuestionsDocument().questions.find((question) => !answered.has(question.id));
|
| 41 |
+
return next?.id ?? null;
|
| 42 |
+
}
|
| 43 |
+
function isKnownQuestionId(questionId) {
|
| 44 |
+
return getQuestionsDocument().questions.some((question) => question.id === questionId);
|
| 45 |
+
}
|
backend/package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/package.json
CHANGED
|
@@ -1,33 +1,33 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "1.0.0",
|
| 4 |
-
"private": true,
|
| 5 |
-
"main": "dist/server.js",
|
| 6 |
-
"scripts": {
|
| 7 |
-
"dev": "tsx watch src/server.ts",
|
| 8 |
-
"build": "tsc -p tsconfig.json",
|
| 9 |
-
"start": "node dist/server.js",
|
| 10 |
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
| 11 |
-
},
|
| 12 |
-
"dependencies": {
|
| 13 |
-
"@supabase/supabase-js": "^2.49.4",
|
| 14 |
-
"cors": "^2.8.5",
|
| 15 |
-
"dotenv": "^16.4.7",
|
| 16 |
-
"express": "^4.21.2",
|
| 17 |
-
"helmet": "^8.0.0",
|
| 18 |
-
"morgan": "^1.10.0",
|
| 19 |
-
"stripe": "^17.6.0",
|
| 20 |
-
"zod": "^3.24.2"
|
| 21 |
-
},
|
| 22 |
-
"devDependencies": {
|
| 23 |
-
"@types/cors": "^2.8.17",
|
| 24 |
-
"@types/express": "^5.0.0",
|
| 25 |
-
"@types/morgan": "^1.9.9",
|
| 26 |
-
"@types/node": "^22.10.7",
|
| 27 |
-
"tsx": "^4.19.2",
|
| 28 |
-
"typescript": "^5.7.3"
|
| 29 |
-
},
|
| 30 |
-
"engines": {
|
| 31 |
-
"node": ">=20"
|
| 32 |
-
}
|
| 33 |
-
}
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "lovegpt-backend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"main": "dist/server.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "tsx watch src/server.ts",
|
| 8 |
+
"build": "tsc -p tsconfig.json",
|
| 9 |
+
"start": "node dist/server.js",
|
| 10 |
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@supabase/supabase-js": "^2.49.4",
|
| 14 |
+
"cors": "^2.8.5",
|
| 15 |
+
"dotenv": "^16.4.7",
|
| 16 |
+
"express": "^4.21.2",
|
| 17 |
+
"helmet": "^8.0.0",
|
| 18 |
+
"morgan": "^1.10.0",
|
| 19 |
+
"stripe": "^17.6.0",
|
| 20 |
+
"zod": "^3.24.2"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"@types/cors": "^2.8.17",
|
| 24 |
+
"@types/express": "^5.0.0",
|
| 25 |
+
"@types/morgan": "^1.9.9",
|
| 26 |
+
"@types/node": "^22.10.7",
|
| 27 |
+
"tsx": "^4.19.2",
|
| 28 |
+
"typescript": "^5.7.3"
|
| 29 |
+
},
|
| 30 |
+
"engines": {
|
| 31 |
+
"node": ">=20"
|
| 32 |
+
}
|
| 33 |
+
}
|
backend/src/prompts/openclaw.md
CHANGED
|
@@ -1,42 +1,42 @@
|
|
| 1 |
-
# OpenClaw System Prompt
|
| 2 |
-
|
| 3 |
-
You are OpenClaw, the warm, direct, psychologically precise AI matchmaker inside
|
| 4 |
-
|
| 5 |
-
Core behavior:
|
| 6 |
-
- Ask one meaningful question at a time.
|
| 7 |
-
- Reflect briefly before moving on.
|
| 8 |
-
- Ask follow-up questions when the answer is vague, performative, contradictory, unsafe, or too thin to support matching.
|
| 9 |
-
- Treat disgust triggers as important compatibility data, not as shameful confessions.
|
| 10 |
-
- Never moralize ordinary preferences. Do challenge cruelty, coercion, dehumanization, or unsafe intent.
|
| 11 |
-
- Keep the conversation grounded, elegant, and human. Do not sound like a quiz form.
|
| 12 |
-
- Do not promise a perfect partner. Promise careful matching and honest signal.
|
| 13 |
-
- Never help a user find, identify, dox, locate, or expose private details about another person. Refuse those requests and redirect to consent-based compatibility profiling.
|
| 14 |
-
|
| 15 |
-
Profile priorities:
|
| 16 |
-
1. Long-term values and future direction.
|
| 17 |
-
2. Emotional safety, attachment, and conflict repair.
|
| 18 |
-
3. Captivating traits that create admiration and desire.
|
| 19 |
-
4. Disgust triggers, repulsions, hygiene needs, moral aversions, sensory aversions, and intimacy boundaries.
|
| 20 |
-
5. Lifestyle practicality.
|
| 21 |
-
6. Readiness, boundaries, and consent.
|
| 22 |
-
7. Respectful body-type self-description, desired body-type range, and physical attraction flexibility.
|
| 23 |
-
|
| 24 |
-
Response contract:
|
| 25 |
-
Return a single JSON object with no markdown.
|
| 26 |
-
|
| 27 |
-
Schema:
|
| 28 |
-
{
|
| 29 |
-
"assistant_text": "Message shown to the user.",
|
| 30 |
-
"current_question_id": "q1",
|
| 31 |
-
"captured_answer": {
|
| 32 |
-
"questionId": "q1",
|
| 33 |
-
"answer": "The user's answer, normalized without changing meaning.",
|
| 34 |
-
"followup": "Optional concise follow-up detail.",
|
| 35 |
-
"dealbreakerSeverity": "low | medium | high"
|
| 36 |
-
},
|
| 37 |
-
"next_question_id": "q2",
|
| 38 |
-
"followup_needed": false,
|
| 39 |
-
"profile_complete": false
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
If the user has not answered the current question, set captured_answer to null. If all 40 answers are complete, set profile_complete to true and produce a concise completion message.
|
|
|
|
| 1 |
+
# OpenClaw System Prompt
|
| 2 |
+
|
| 3 |
+
You are OpenClaw, the warm, direct, psychologically precise AI matchmaker inside loveGPT. Your job is to guide a dating user through the complete 40-question compatibility profile in a natural conversation.
|
| 4 |
+
|
| 5 |
+
Core behavior:
|
| 6 |
+
- Ask one meaningful question at a time.
|
| 7 |
+
- Reflect briefly before moving on.
|
| 8 |
+
- Ask follow-up questions when the answer is vague, performative, contradictory, unsafe, or too thin to support matching.
|
| 9 |
+
- Treat disgust triggers as important compatibility data, not as shameful confessions.
|
| 10 |
+
- Never moralize ordinary preferences. Do challenge cruelty, coercion, dehumanization, or unsafe intent.
|
| 11 |
+
- Keep the conversation grounded, elegant, and human. Do not sound like a quiz form.
|
| 12 |
+
- Do not promise a perfect partner. Promise careful matching and honest signal.
|
| 13 |
+
- Never help a user find, identify, dox, locate, or expose private details about another person. Refuse those requests and redirect to consent-based compatibility profiling.
|
| 14 |
+
|
| 15 |
+
Profile priorities:
|
| 16 |
+
1. Long-term values and future direction.
|
| 17 |
+
2. Emotional safety, attachment, and conflict repair.
|
| 18 |
+
3. Captivating traits that create admiration and desire.
|
| 19 |
+
4. Disgust triggers, repulsions, hygiene needs, moral aversions, sensory aversions, and intimacy boundaries.
|
| 20 |
+
5. Lifestyle practicality.
|
| 21 |
+
6. Readiness, boundaries, and consent.
|
| 22 |
+
7. Respectful body-type self-description, desired body-type range, and physical attraction flexibility.
|
| 23 |
+
|
| 24 |
+
Response contract:
|
| 25 |
+
Return a single JSON object with no markdown.
|
| 26 |
+
|
| 27 |
+
Schema:
|
| 28 |
+
{
|
| 29 |
+
"assistant_text": "Message shown to the user.",
|
| 30 |
+
"current_question_id": "q1",
|
| 31 |
+
"captured_answer": {
|
| 32 |
+
"questionId": "q1",
|
| 33 |
+
"answer": "The user's answer, normalized without changing meaning.",
|
| 34 |
+
"followup": "Optional concise follow-up detail.",
|
| 35 |
+
"dealbreakerSeverity": "low | medium | high"
|
| 36 |
+
},
|
| 37 |
+
"next_question_id": "q2",
|
| 38 |
+
"followup_needed": false,
|
| 39 |
+
"profile_complete": false
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
If the user has not answered the current question, set captured_answer to null. If all 40 answers are complete, set profile_complete to true and produce a concise completion message.
|
backend/src/routes/health.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
import { Router } from "express";
|
| 2 |
-
|
| 3 |
-
export const healthRouter = Router();
|
| 4 |
-
|
| 5 |
-
healthRouter.get("/", (_req, res) => {
|
| 6 |
-
res.json({
|
| 7 |
-
ok: true,
|
| 8 |
-
service: "
|
| 9 |
-
checkedAt: new Date().toISOString()
|
| 10 |
-
});
|
| 11 |
-
});
|
|
|
|
| 1 |
+
import { Router } from "express";
|
| 2 |
+
|
| 3 |
+
export const healthRouter = Router();
|
| 4 |
+
|
| 5 |
+
healthRouter.get("/", (_req, res) => {
|
| 6 |
+
res.json({
|
| 7 |
+
ok: true,
|
| 8 |
+
service: "lovegpt-backend",
|
| 9 |
+
checkedAt: new Date().toISOString()
|
| 10 |
+
});
|
| 11 |
+
});
|
backend/src/routes/payments.ts
CHANGED
|
@@ -1,104 +1,104 @@
|
|
| 1 |
-
import { Router } from "express";
|
| 2 |
-
import Stripe from "stripe";
|
| 3 |
-
import { env } from "../config/env";
|
| 4 |
-
import { supabaseAdmin } from "../config/supabase";
|
| 5 |
-
import { requireAuth } from "../middleware/auth";
|
| 6 |
-
import { markUserPaid } from "../services/profileService";
|
| 7 |
-
import { HttpError } from "../utils/httpError";
|
| 8 |
-
|
| 9 |
-
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
| 10 |
-
typescript: true
|
| 11 |
-
});
|
| 12 |
-
|
| 13 |
-
export const paymentsRouter = Router();
|
| 14 |
-
export const paymentWebhookRouter = Router();
|
| 15 |
-
|
| 16 |
-
paymentsRouter.use(requireAuth);
|
| 17 |
-
|
| 18 |
-
paymentsRouter.post("/checkout-session", async (req, res, next) => {
|
| 19 |
-
try {
|
| 20 |
-
const user = req.authUser!;
|
| 21 |
-
|
| 22 |
-
const session = await stripe.checkout.sessions.create({
|
| 23 |
-
mode: "payment",
|
| 24 |
-
customer_email: user.email ?? undefined,
|
| 25 |
-
client_reference_id: user.id,
|
| 26 |
-
metadata: {
|
| 27 |
-
user_id: user.id,
|
| 28 |
-
product: "
|
| 29 |
-
},
|
| 30 |
-
success_url: env.FRONTEND_SUCCESS_URL,
|
| 31 |
-
cancel_url: env.FRONTEND_CANCEL_URL,
|
| 32 |
-
line_items: [
|
| 33 |
-
env.STRIPE_PRICE_ID
|
| 34 |
-
? {
|
| 35 |
-
price: env.STRIPE_PRICE_ID,
|
| 36 |
-
quantity: 1
|
| 37 |
-
}
|
| 38 |
-
: {
|
| 39 |
-
quantity: 1,
|
| 40 |
-
price_data: {
|
| 41 |
-
currency: env.ENTRY_FEE_CURRENCY,
|
| 42 |
-
unit_amount: env.ENTRY_FEE_CENTS,
|
| 43 |
-
product_data: {
|
| 44 |
-
name: "
|
| 45 |
-
description: "One-time access to ten curated high-compatibility matches."
|
| 46 |
-
}
|
| 47 |
-
}
|
| 48 |
-
}
|
| 49 |
-
]
|
| 50 |
-
});
|
| 51 |
-
|
| 52 |
-
res.json({
|
| 53 |
-
id: session.id,
|
| 54 |
-
url: session.url
|
| 55 |
-
});
|
| 56 |
-
} catch (error) {
|
| 57 |
-
next(error);
|
| 58 |
-
}
|
| 59 |
-
});
|
| 60 |
-
|
| 61 |
-
paymentWebhookRouter.post("/", async (req, res, next) => {
|
| 62 |
-
try {
|
| 63 |
-
const signature = req.header("stripe-signature");
|
| 64 |
-
if (!signature) {
|
| 65 |
-
throw new HttpError(400, "stripe_signature_missing", "Missing Stripe signature");
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
const event = stripe.webhooks.constructEvent(req.body, signature, env.STRIPE_WEBHOOK_SECRET);
|
| 69 |
-
|
| 70 |
-
if (event.type === "checkout.session.completed") {
|
| 71 |
-
const session = event.data.object as Stripe.Checkout.Session;
|
| 72 |
-
const userId = session.metadata?.user_id ?? session.client_reference_id;
|
| 73 |
-
if (!userId) {
|
| 74 |
-
throw new HttpError(400, "stripe_user_missing", "Stripe session does not include a user id");
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
await markUserPaid(
|
| 78 |
-
userId,
|
| 79 |
-
typeof session.customer === "string" ? session.customer : session.customer?.id,
|
| 80 |
-
session.id
|
| 81 |
-
);
|
| 82 |
-
|
| 83 |
-
const { error } = await supabaseAdmin.from("payments").upsert(
|
| 84 |
-
{
|
| 85 |
-
user_id: userId,
|
| 86 |
-
stripe_event_id: event.id,
|
| 87 |
-
stripe_checkout_session_id: session.id,
|
| 88 |
-
amount: session.amount_total ?? env.ENTRY_FEE_CENTS,
|
| 89 |
-
currency: session.currency ?? env.ENTRY_FEE_CURRENCY,
|
| 90 |
-
status: "paid"
|
| 91 |
-
},
|
| 92 |
-
{ onConflict: "stripe_event_id" }
|
| 93 |
-
);
|
| 94 |
-
|
| 95 |
-
if (error) {
|
| 96 |
-
throw error;
|
| 97 |
-
}
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
res.json({ received: true });
|
| 101 |
-
} catch (error) {
|
| 102 |
-
next(error);
|
| 103 |
-
}
|
| 104 |
-
});
|
|
|
|
| 1 |
+
import { Router } from "express";
|
| 2 |
+
import Stripe from "stripe";
|
| 3 |
+
import { env } from "../config/env";
|
| 4 |
+
import { supabaseAdmin } from "../config/supabase";
|
| 5 |
+
import { requireAuth } from "../middleware/auth";
|
| 6 |
+
import { markUserPaid } from "../services/profileService";
|
| 7 |
+
import { HttpError } from "../utils/httpError";
|
| 8 |
+
|
| 9 |
+
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
| 10 |
+
typescript: true
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
export const paymentsRouter = Router();
|
| 14 |
+
export const paymentWebhookRouter = Router();
|
| 15 |
+
|
| 16 |
+
paymentsRouter.use(requireAuth);
|
| 17 |
+
|
| 18 |
+
paymentsRouter.post("/checkout-session", async (req, res, next) => {
|
| 19 |
+
try {
|
| 20 |
+
const user = req.authUser!;
|
| 21 |
+
|
| 22 |
+
const session = await stripe.checkout.sessions.create({
|
| 23 |
+
mode: "payment",
|
| 24 |
+
customer_email: user.email ?? undefined,
|
| 25 |
+
client_reference_id: user.id,
|
| 26 |
+
metadata: {
|
| 27 |
+
user_id: user.id,
|
| 28 |
+
product: "lovegpt_entry"
|
| 29 |
+
},
|
| 30 |
+
success_url: env.FRONTEND_SUCCESS_URL,
|
| 31 |
+
cancel_url: env.FRONTEND_CANCEL_URL,
|
| 32 |
+
line_items: [
|
| 33 |
+
env.STRIPE_PRICE_ID
|
| 34 |
+
? {
|
| 35 |
+
price: env.STRIPE_PRICE_ID,
|
| 36 |
+
quantity: 1
|
| 37 |
+
}
|
| 38 |
+
: {
|
| 39 |
+
quantity: 1,
|
| 40 |
+
price_data: {
|
| 41 |
+
currency: env.ENTRY_FEE_CURRENCY,
|
| 42 |
+
unit_amount: env.ENTRY_FEE_CENTS,
|
| 43 |
+
product_data: {
|
| 44 |
+
name: "loveGPT Entry",
|
| 45 |
+
description: "One-time access to ten curated high-compatibility matches."
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
]
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
res.json({
|
| 53 |
+
id: session.id,
|
| 54 |
+
url: session.url
|
| 55 |
+
});
|
| 56 |
+
} catch (error) {
|
| 57 |
+
next(error);
|
| 58 |
+
}
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
paymentWebhookRouter.post("/", async (req, res, next) => {
|
| 62 |
+
try {
|
| 63 |
+
const signature = req.header("stripe-signature");
|
| 64 |
+
if (!signature) {
|
| 65 |
+
throw new HttpError(400, "stripe_signature_missing", "Missing Stripe signature");
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const event = stripe.webhooks.constructEvent(req.body, signature, env.STRIPE_WEBHOOK_SECRET);
|
| 69 |
+
|
| 70 |
+
if (event.type === "checkout.session.completed") {
|
| 71 |
+
const session = event.data.object as Stripe.Checkout.Session;
|
| 72 |
+
const userId = session.metadata?.user_id ?? session.client_reference_id;
|
| 73 |
+
if (!userId) {
|
| 74 |
+
throw new HttpError(400, "stripe_user_missing", "Stripe session does not include a user id");
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
await markUserPaid(
|
| 78 |
+
userId,
|
| 79 |
+
typeof session.customer === "string" ? session.customer : session.customer?.id,
|
| 80 |
+
session.id
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
const { error } = await supabaseAdmin.from("payments").upsert(
|
| 84 |
+
{
|
| 85 |
+
user_id: userId,
|
| 86 |
+
stripe_event_id: event.id,
|
| 87 |
+
stripe_checkout_session_id: session.id,
|
| 88 |
+
amount: session.amount_total ?? env.ENTRY_FEE_CENTS,
|
| 89 |
+
currency: session.currency ?? env.ENTRY_FEE_CURRENCY,
|
| 90 |
+
status: "paid"
|
| 91 |
+
},
|
| 92 |
+
{ onConflict: "stripe_event_id" }
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
if (error) {
|
| 96 |
+
throw error;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
res.json({ received: true });
|
| 101 |
+
} catch (error) {
|
| 102 |
+
next(error);
|
| 103 |
+
}
|
| 104 |
+
});
|
backend/src/server.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
import { env } from "./config/env";
|
| 2 |
-
import { createApp } from "./app";
|
| 3 |
-
|
| 4 |
-
const app = createApp();
|
| 5 |
-
|
| 6 |
-
app.listen(env.PORT, () => {
|
| 7 |
-
console.log(`
|
| 8 |
-
});
|
|
|
|
| 1 |
+
import { env } from "./config/env";
|
| 2 |
+
import { createApp } from "./app";
|
| 3 |
+
|
| 4 |
+
const app = createApp();
|
| 5 |
+
|
| 6 |
+
app.listen(env.PORT, () => {
|
| 7 |
+
console.log(`loveGPT backend listening on port ${env.PORT}`);
|
| 8 |
+
});
|
backend/src/utils/privacyGuard.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
| 1 |
-
const privacyLookupPatterns = [
|
| 2 |
-
/\b(find|look\s*up|lookup|search|identify|track|locate|doxx?|reveal|get)\b(?=.{0,120}\b(person|someone|user|profile|match|candidate|woman|man|girl|guy|name|address|phone|email|social|instagram|facebook|details|where)\b)/i,
|
| 3 |
-
/\b(phone number|email address|home address|street address|full name|last name|ssn|social security|where does .* live|where do .* live)\b/i
|
| 4 |
-
];
|
| 5 |
-
|
| 6 |
-
export function isPrivacyLookupRequest(value: string): boolean {
|
| 7 |
-
const lowered = value.toLowerCase();
|
| 8 |
-
if (lowered.includes("my ") && !/(find|lookup|look up|search|dox)/i.test(value)) {
|
| 9 |
-
return false;
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
return privacyLookupPatterns.some((pattern) => pattern.test(value));
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
export function privacyRefusalText(): string {
|
| 16 |
-
return [
|
| 17 |
-
"I cannot help find, identify, or expose personal details about another person.",
|
| 18 |
-
"
|
| 19 |
-
"Answer the current profile question about your own preferences and boundaries instead."
|
| 20 |
-
].join(" ");
|
| 21 |
-
}
|
|
|
|
| 1 |
+
const privacyLookupPatterns = [
|
| 2 |
+
/\b(find|look\s*up|lookup|search|identify|track|locate|doxx?|reveal|get)\b(?=.{0,120}\b(person|someone|user|profile|match|candidate|woman|man|girl|guy|name|address|phone|email|social|instagram|facebook|details|where)\b)/i,
|
| 3 |
+
/\b(phone number|email address|home address|street address|full name|last name|ssn|social security|where does .* live|where do .* live)\b/i
|
| 4 |
+
];
|
| 5 |
+
|
| 6 |
+
export function isPrivacyLookupRequest(value: string): boolean {
|
| 7 |
+
const lowered = value.toLowerCase();
|
| 8 |
+
if (lowered.includes("my ") && !/(find|lookup|look up|search|dox)/i.test(value)) {
|
| 9 |
+
return false;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
return privacyLookupPatterns.some((pattern) => pattern.test(value));
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function privacyRefusalText(): string {
|
| 16 |
+
return [
|
| 17 |
+
"I cannot help find, identify, or expose personal details about another person.",
|
| 18 |
+
"loveGPT only uses profile information for consent-based compatibility work.",
|
| 19 |
+
"Answer the current profile question about your own preferences and boundaries instead."
|
| 20 |
+
].join(" ");
|
| 21 |
+
}
|
docs/huggingface-space.md
CHANGED
|
@@ -1,154 +1,154 @@
|
|
| 1 |
-
# Hugging Face Space
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
## Required Files
|
| 6 |
-
|
| 7 |
-
- `app.py` - Gradio entrypoint.
|
| 8 |
-
- `requirements.txt` - Python dependencies.
|
| 9 |
-
- `README.md` - Space metadata YAML at the top of the file.
|
| 10 |
-
- `shared/questions.json` - the full compatibility questionnaire loaded by the Space.
|
| 11 |
-
|
| 12 |
-
## Local Run
|
| 13 |
-
|
| 14 |
-
```powershell
|
| 15 |
-
python -m pip install -r requirements.txt
|
| 16 |
-
python app.py
|
| 17 |
-
```
|
| 18 |
-
|
| 19 |
-
Open:
|
| 20 |
-
|
| 21 |
-
```text
|
| 22 |
-
http://localhost:7860
|
| 23 |
-
```
|
| 24 |
-
|
| 25 |
-
## Space Settings
|
| 26 |
-
|
| 27 |
-
Create a new Hugging Face Space with:
|
| 28 |
-
|
| 29 |
-
- SDK: `Gradio`
|
| 30 |
-
- App file: `app.py`
|
| 31 |
-
- Python: `3.11`
|
| 32 |
-
|
| 33 |
-
Push this repository to the Space remote. The Space version is session-local by default, so it does not require Supabase, Stripe, or AI secrets to boot.
|
| 34 |
-
|
| 35 |
-
## Optional Local Phi OpenClaw
|
| 36 |
-
|
| 37 |
-
The Gradio adapter can use a Phi model through any OpenAI-compatible local server. The default local model id is:
|
| 38 |
-
|
| 39 |
-
```text
|
| 40 |
-
microsoft/Phi-4-mini-instruct
|
| 41 |
-
```
|
| 42 |
-
|
| 43 |
-
Local llama.cpp example:
|
| 44 |
-
|
| 45 |
-
```powershell
|
| 46 |
-
llama-server `
|
| 47 |
-
-hf ysn-rfd/Phi-4-mini-instruct-GGUF:Q4_0 `
|
| 48 |
-
--alias microsoft/Phi-4-mini-instruct `
|
| 49 |
-
--jinja `
|
| 50 |
-
--host 127.0.0.1 `
|
| 51 |
-
--port 8081 `
|
| 52 |
-
-c 32768
|
| 53 |
-
```
|
| 54 |
-
|
| 55 |
-
Run the Space against that model:
|
| 56 |
-
|
| 57 |
-
```powershell
|
| 58 |
-
$env:OPENCLAW_PROVIDER="local"
|
| 59 |
-
$env:OPENCLAW_LOCAL_MODEL="microsoft/Phi-4-mini-instruct"
|
| 60 |
-
$env:OPENCLAW_LOCAL_BASE_URL="http://127.0.0.1:8081/v1"
|
| 61 |
-
python app.py
|
| 62 |
-
```
|
| 63 |
-
|
| 64 |
-
If Phi is not reachable, the Space falls back to the deterministic profile flow by default. Set this to force a visible error instead:
|
| 65 |
-
|
| 66 |
-
```text
|
| 67 |
-
OPENCLAW_FALLBACK_DETERMINISTIC=0
|
| 68 |
-
```
|
| 69 |
-
|
| 70 |
-
## Encrypted Profile Rows
|
| 71 |
-
|
| 72 |
-
The Space no longer exports raw profile JSON or raw JSONL. Every downloadable profile file is a one-row encrypted JSONL file:
|
| 73 |
-
|
| 74 |
-
```text
|
| 75 |
-
*.jsonl.enc
|
| 76 |
-
```
|
| 77 |
-
|
| 78 |
-
Each row contains non-sensitive vault metadata plus AES-GCM ciphertext. The plaintext profile is parsed only inside the functions that need it, such as matching, and is not rendered in the UI.
|
| 79 |
-
|
| 80 |
-
For durable Dataclaw access, set this Hugging Face secret:
|
| 81 |
-
|
| 82 |
-
```text
|
| 83 |
-
DATACLAW_PROFILE_KEY=<high-entropy secret>
|
| 84 |
-
```
|
| 85 |
-
|
| 86 |
-
Generate one locally:
|
| 87 |
-
|
| 88 |
-
```powershell
|
| 89 |
-
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
| 90 |
-
```
|
| 91 |
-
|
| 92 |
-
If the secret is absent, the Space uses a volatile per-process key. That keeps exports encrypted, but Dataclaw will not be able to decrypt them after the Space restarts.
|
| 93 |
-
|
| 94 |
-
Dataclaw decrypt command:
|
| 95 |
-
|
| 96 |
-
```powershell
|
| 97 |
-
$env:DATACLAW_PROFILE_KEY="same-secret"
|
| 98 |
-
$env:DATACLAW_ALLOW_PROFILE_DECRYPT="1"
|
| 99 |
-
python tools/dataclaw_profile_vault.py .\profile.jsonl.enc --output .\profile.decrypted.jsonl --i-understand-raw-profile-output
|
| 100 |
-
```
|
| 101 |
-
|
| 102 |
-
Raw decrypted JSONL is intentionally gated behind both the Dataclaw key and an explicit raw-output flag.
|
| 103 |
-
|
| 104 |
-
## Private Matchmaker Table
|
| 105 |
-
|
| 106 |
-
Questionnaire turns are appended to an encrypted server-side matchmaker table. The table is not exposed as a Gradio component, download, API output, or chat response. It is decrypted only inside the matchmaker agent loop when judging compatibility.
|
| 107 |
-
|
| 108 |
-
Recommended Space secret:
|
| 109 |
-
|
| 110 |
-
```text
|
| 111 |
-
MATCHMAKER_TABLE_KEY=<high-entropy secret>
|
| 112 |
-
```
|
| 113 |
-
|
| 114 |
-
If `MATCHMAKER_TABLE_KEY` is absent, the table key is derived from `DATACLAW_PROFILE_KEY`. If both are absent, the table remains encrypted but becomes process-local and will not survive a Space restart.
|
| 115 |
-
|
| 116 |
-
The matchmaker harness requires complete Hugging Face-identified profiles before opening an automatic speed-date room. Anonymous profiles can be logged and judged internally, but they are not routed into chat.
|
| 117 |
-
|
| 118 |
-
## Prompt Privacy Guard
|
| 119 |
-
|
| 120 |
-
The Gradio app and Node OpenClaw endpoint refuse chat requests that try to find, identify, locate, dox, or expose personal details about another person. Profile data may only be used for consent-based compatibility work.
|
| 121 |
-
|
| 122 |
-
## Speed-Date Room
|
| 123 |
-
|
| 124 |
-
The Space includes a two-person speed-date room:
|
| 125 |
-
|
| 126 |
-
- Users sign in with a Hugging Face token.
|
| 127 |
-
- The active room is a public post inside the Space, but it shows only Hugging Face handles and room status.
|
| 128 |
-
- Chat lasts 20 minutes by default.
|
| 129 |
-
- Chat messages that include emails, phone numbers, addresses, URLs, social handles, or contact-platform prompts are blocked.
|
| 130 |
-
- The matchmaker agent can automatically connect two complete compatible profiles into the room.
|
| 131 |
-
- After the timer ends, both users can submit a mutual exchange form.
|
| 132 |
-
- Contact details are displayed only after both users submit and consent.
|
| 133 |
-
|
| 134 |
-
Runtime setting:
|
| 135 |
-
|
| 136 |
-
```text
|
| 137 |
-
SPEED_DATE_SECONDS=1200
|
| 138 |
-
```
|
| 139 |
-
|
| 140 |
-
Use a shorter value only for local testing.
|
| 141 |
-
|
| 142 |
-
## Production Integration
|
| 143 |
-
|
| 144 |
-
For a private or protected Space, add secrets and wire the Gradio adapter to the production backend:
|
| 145 |
-
|
| 146 |
-
- `BACKEND_URL`
|
| 147 |
-
- `SUPABASE_URL`
|
| 148 |
-
- `SUPABASE_SERVICE_ROLE_KEY` or a scoped backend token
|
| 149 |
-
- `DATACLAW_PROFILE_KEY`
|
| 150 |
-
- `OPENCLAW_PROVIDER=local`
|
| 151 |
-
- `OPENCLAW_LOCAL_MODEL=microsoft/Phi-4-mini-instruct`
|
| 152 |
-
- `OPENCLAW_LOCAL_BASE_URL`, if profile completion should call a Phi server rather than the deterministic session flow
|
| 153 |
-
|
| 154 |
-
Do not expose service role keys in frontend-visible JavaScript or public client logs.
|
|
|
|
| 1 |
+
# Hugging Face Space
|
| 2 |
+
|
| 3 |
+
loveGPT includes a root-level Gradio adapter so the repository can be pushed directly to a Hugging Face Space.
|
| 4 |
+
|
| 5 |
+
## Required Files
|
| 6 |
+
|
| 7 |
+
- `app.py` - Gradio entrypoint.
|
| 8 |
+
- `requirements.txt` - Python dependencies.
|
| 9 |
+
- `README.md` - Space metadata YAML at the top of the file.
|
| 10 |
+
- `shared/questions.json` - the full compatibility questionnaire loaded by the Space.
|
| 11 |
+
|
| 12 |
+
## Local Run
|
| 13 |
+
|
| 14 |
+
```powershell
|
| 15 |
+
python -m pip install -r requirements.txt
|
| 16 |
+
python app.py
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
Open:
|
| 20 |
+
|
| 21 |
+
```text
|
| 22 |
+
http://localhost:7860
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Space Settings
|
| 26 |
+
|
| 27 |
+
Create a new Hugging Face Space with:
|
| 28 |
+
|
| 29 |
+
- SDK: `Gradio`
|
| 30 |
+
- App file: `app.py`
|
| 31 |
+
- Python: `3.11`
|
| 32 |
+
|
| 33 |
+
Push this repository to the Space remote. The Space version is session-local by default, so it does not require Supabase, Stripe, or AI secrets to boot.
|
| 34 |
+
|
| 35 |
+
## Optional Local Phi OpenClaw
|
| 36 |
+
|
| 37 |
+
The Gradio adapter can use a Phi model through any OpenAI-compatible local server. The default local model id is:
|
| 38 |
+
|
| 39 |
+
```text
|
| 40 |
+
microsoft/Phi-4-mini-instruct
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Local llama.cpp example:
|
| 44 |
+
|
| 45 |
+
```powershell
|
| 46 |
+
llama-server `
|
| 47 |
+
-hf ysn-rfd/Phi-4-mini-instruct-GGUF:Q4_0 `
|
| 48 |
+
--alias microsoft/Phi-4-mini-instruct `
|
| 49 |
+
--jinja `
|
| 50 |
+
--host 127.0.0.1 `
|
| 51 |
+
--port 8081 `
|
| 52 |
+
-c 32768
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
Run the Space against that model:
|
| 56 |
+
|
| 57 |
+
```powershell
|
| 58 |
+
$env:OPENCLAW_PROVIDER="local"
|
| 59 |
+
$env:OPENCLAW_LOCAL_MODEL="microsoft/Phi-4-mini-instruct"
|
| 60 |
+
$env:OPENCLAW_LOCAL_BASE_URL="http://127.0.0.1:8081/v1"
|
| 61 |
+
python app.py
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
If Phi is not reachable, the Space falls back to the deterministic profile flow by default. Set this to force a visible error instead:
|
| 65 |
+
|
| 66 |
+
```text
|
| 67 |
+
OPENCLAW_FALLBACK_DETERMINISTIC=0
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
## Encrypted Profile Rows
|
| 71 |
+
|
| 72 |
+
The Space no longer exports raw profile JSON or raw JSONL. Every downloadable profile file is a one-row encrypted JSONL file:
|
| 73 |
+
|
| 74 |
+
```text
|
| 75 |
+
*.jsonl.enc
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Each row contains non-sensitive vault metadata plus AES-GCM ciphertext. The plaintext profile is parsed only inside the functions that need it, such as matching, and is not rendered in the UI.
|
| 79 |
+
|
| 80 |
+
For durable Dataclaw access, set this Hugging Face secret:
|
| 81 |
+
|
| 82 |
+
```text
|
| 83 |
+
DATACLAW_PROFILE_KEY=<high-entropy secret>
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
Generate one locally:
|
| 87 |
+
|
| 88 |
+
```powershell
|
| 89 |
+
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
If the secret is absent, the Space uses a volatile per-process key. That keeps exports encrypted, but Dataclaw will not be able to decrypt them after the Space restarts.
|
| 93 |
+
|
| 94 |
+
Dataclaw decrypt command:
|
| 95 |
+
|
| 96 |
+
```powershell
|
| 97 |
+
$env:DATACLAW_PROFILE_KEY="same-secret"
|
| 98 |
+
$env:DATACLAW_ALLOW_PROFILE_DECRYPT="1"
|
| 99 |
+
python tools/dataclaw_profile_vault.py .\profile.jsonl.enc --output .\profile.decrypted.jsonl --i-understand-raw-profile-output
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Raw decrypted JSONL is intentionally gated behind both the Dataclaw key and an explicit raw-output flag.
|
| 103 |
+
|
| 104 |
+
## Private Matchmaker Table
|
| 105 |
+
|
| 106 |
+
Questionnaire turns are appended to an encrypted server-side matchmaker table. The table is not exposed as a Gradio component, download, API output, or chat response. It is decrypted only inside the matchmaker agent loop when judging compatibility.
|
| 107 |
+
|
| 108 |
+
Recommended Space secret:
|
| 109 |
+
|
| 110 |
+
```text
|
| 111 |
+
MATCHMAKER_TABLE_KEY=<high-entropy secret>
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
If `MATCHMAKER_TABLE_KEY` is absent, the table key is derived from `DATACLAW_PROFILE_KEY`. If both are absent, the table remains encrypted but becomes process-local and will not survive a Space restart.
|
| 115 |
+
|
| 116 |
+
The matchmaker harness requires complete Hugging Face-identified profiles before opening an automatic speed-date room. Anonymous profiles can be logged and judged internally, but they are not routed into chat.
|
| 117 |
+
|
| 118 |
+
## Prompt Privacy Guard
|
| 119 |
+
|
| 120 |
+
The Gradio app and Node OpenClaw endpoint refuse chat requests that try to find, identify, locate, dox, or expose personal details about another person. Profile data may only be used for consent-based compatibility work.
|
| 121 |
+
|
| 122 |
+
## Speed-Date Room
|
| 123 |
+
|
| 124 |
+
The Space includes a two-person speed-date room:
|
| 125 |
+
|
| 126 |
+
- Users sign in with a Hugging Face token.
|
| 127 |
+
- The active room is a public post inside the Space, but it shows only Hugging Face handles and room status.
|
| 128 |
+
- Chat lasts 20 minutes by default.
|
| 129 |
+
- Chat messages that include emails, phone numbers, addresses, URLs, social handles, or contact-platform prompts are blocked.
|
| 130 |
+
- The matchmaker agent can automatically connect two complete compatible profiles into the room.
|
| 131 |
+
- After the timer ends, both users can submit a mutual exchange form.
|
| 132 |
+
- Contact details are displayed only after both users submit and consent.
|
| 133 |
+
|
| 134 |
+
Runtime setting:
|
| 135 |
+
|
| 136 |
+
```text
|
| 137 |
+
SPEED_DATE_SECONDS=1200
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
Use a shorter value only for local testing.
|
| 141 |
+
|
| 142 |
+
## Production Integration
|
| 143 |
+
|
| 144 |
+
For a private or protected Space, add secrets and wire the Gradio adapter to the production backend:
|
| 145 |
+
|
| 146 |
+
- `BACKEND_URL`
|
| 147 |
+
- `SUPABASE_URL`
|
| 148 |
+
- `SUPABASE_SERVICE_ROLE_KEY` or a scoped backend token
|
| 149 |
+
- `DATACLAW_PROFILE_KEY`
|
| 150 |
+
- `OPENCLAW_PROVIDER=local`
|
| 151 |
+
- `OPENCLAW_LOCAL_MODEL=microsoft/Phi-4-mini-instruct`
|
| 152 |
+
- `OPENCLAW_LOCAL_BASE_URL`, if profile completion should call a Phi server rather than the deterministic session flow
|
| 153 |
+
|
| 154 |
+
Do not expose service role keys in frontend-visible JavaScript or public client logs.
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Miscellaneous
|
| 2 |
+
*.class
|
| 3 |
+
*.log
|
| 4 |
+
*.pyc
|
| 5 |
+
*.swp
|
| 6 |
+
.DS_Store
|
| 7 |
+
.atom/
|
| 8 |
+
.build/
|
| 9 |
+
.buildlog/
|
| 10 |
+
.history
|
| 11 |
+
.svn/
|
| 12 |
+
.swiftpm/
|
| 13 |
+
migrate_working_dir/
|
| 14 |
+
|
| 15 |
+
# IntelliJ related
|
| 16 |
+
*.iml
|
| 17 |
+
*.ipr
|
| 18 |
+
*.iws
|
| 19 |
+
.idea/
|
| 20 |
+
|
| 21 |
+
# The .vscode folder contains launch configuration and tasks you configure in
|
| 22 |
+
# VS Code which you may wish to be included in version control, so this line
|
| 23 |
+
# is commented out by default.
|
| 24 |
+
#.vscode/
|
| 25 |
+
|
| 26 |
+
# Flutter/Dart/Pub related
|
| 27 |
+
**/doc/api/
|
| 28 |
+
**/ios/Flutter/.last_build_id
|
| 29 |
+
.dart_tool/
|
| 30 |
+
.flutter-plugins-dependencies
|
| 31 |
+
.pub-cache/
|
| 32 |
+
.pub/
|
| 33 |
+
/build/
|
| 34 |
+
/coverage/
|
| 35 |
+
|
| 36 |
+
# Symbolication related
|
| 37 |
+
app.*.symbols
|
| 38 |
+
|
| 39 |
+
# Obfuscation related
|
| 40 |
+
app.*.map.json
|
| 41 |
+
|
| 42 |
+
# Android Studio will place build artifacts here
|
| 43 |
+
/android/app/debug
|
| 44 |
+
/android/app/profile
|
| 45 |
+
/android/app/release
|
frontend/.metadata
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file tracks properties of this Flutter project.
|
| 2 |
+
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
| 3 |
+
#
|
| 4 |
+
# This file should be version controlled and should not be manually edited.
|
| 5 |
+
|
| 6 |
+
version:
|
| 7 |
+
revision: "924134a44c189315be2148659913dda1671cbe99"
|
| 8 |
+
channel: "stable"
|
| 9 |
+
|
| 10 |
+
project_type: app
|
| 11 |
+
|
| 12 |
+
# Tracks metadata for the flutter migrate command
|
| 13 |
+
migration:
|
| 14 |
+
platforms:
|
| 15 |
+
- platform: root
|
| 16 |
+
create_revision: 924134a44c189315be2148659913dda1671cbe99
|
| 17 |
+
base_revision: 924134a44c189315be2148659913dda1671cbe99
|
| 18 |
+
- platform: android
|
| 19 |
+
create_revision: 924134a44c189315be2148659913dda1671cbe99
|
| 20 |
+
base_revision: 924134a44c189315be2148659913dda1671cbe99
|
| 21 |
+
- platform: ios
|
| 22 |
+
create_revision: 924134a44c189315be2148659913dda1671cbe99
|
| 23 |
+
base_revision: 924134a44c189315be2148659913dda1671cbe99
|
| 24 |
+
- platform: web
|
| 25 |
+
create_revision: 924134a44c189315be2148659913dda1671cbe99
|
| 26 |
+
base_revision: 924134a44c189315be2148659913dda1671cbe99
|
| 27 |
+
|
| 28 |
+
# User provided section
|
| 29 |
+
|
| 30 |
+
# List of Local paths (relative to this file) that should be
|
| 31 |
+
# ignored by the migrate tool.
|
| 32 |
+
#
|
| 33 |
+
# Files that are not part of the templates will be ignored by default.
|
| 34 |
+
unmanaged_files:
|
| 35 |
+
- 'lib/main.dart'
|
| 36 |
+
- 'ios/Runner.xcodeproj/project.pbxproj'
|
frontend/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
A new Flutter project.
|
| 4 |
|
|
|
|
| 1 |
+
# lovegpt
|
| 2 |
|
| 3 |
A new Flutter project.
|
| 4 |
|
frontend/android/.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradle-wrapper.jar
|
| 2 |
+
/.gradle
|
| 3 |
+
/captures/
|
| 4 |
+
/gradlew
|
| 5 |
+
/gradlew.bat
|
| 6 |
+
/local.properties
|
| 7 |
+
GeneratedPluginRegistrant.java
|
| 8 |
+
.cxx/
|
| 9 |
+
|
| 10 |
+
# Remember to never publicly share your keystore.
|
| 11 |
+
# See https://flutter.dev/to/reference-keystore
|
| 12 |
+
key.properties
|
| 13 |
+
**/*.keystore
|
| 14 |
+
**/*.jks
|
frontend/android/app/build.gradle.kts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
plugins {
|
| 2 |
+
id("com.android.application")
|
| 3 |
+
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
| 4 |
+
id("dev.flutter.flutter-gradle-plugin")
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
android {
|
| 8 |
+
namespace = "com.example.lovegpt"
|
| 9 |
+
compileSdk = flutter.compileSdkVersion
|
| 10 |
+
ndkVersion = flutter.ndkVersion
|
| 11 |
+
|
| 12 |
+
compileOptions {
|
| 13 |
+
sourceCompatibility = JavaVersion.VERSION_17
|
| 14 |
+
targetCompatibility = JavaVersion.VERSION_17
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
defaultConfig {
|
| 18 |
+
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
| 19 |
+
applicationId = "com.example.lovegpt"
|
| 20 |
+
// You can update the following values to match your application needs.
|
| 21 |
+
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
| 22 |
+
minSdk = flutter.minSdkVersion
|
| 23 |
+
targetSdk = flutter.targetSdkVersion
|
| 24 |
+
versionCode = flutter.versionCode
|
| 25 |
+
versionName = flutter.versionName
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
buildTypes {
|
| 29 |
+
release {
|
| 30 |
+
// TODO: Add your own signing config for the release build.
|
| 31 |
+
// Signing with the debug keys for now, so `flutter run --release` works.
|
| 32 |
+
signingConfig = signingConfigs.getByName("debug")
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
kotlin {
|
| 38 |
+
compilerOptions {
|
| 39 |
+
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
flutter {
|
| 44 |
+
source = "../.."
|
| 45 |
+
}
|
frontend/android/app/src/debug/AndroidManifest.xml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
| 2 |
+
<!-- The INTERNET permission is required for development. Specifically,
|
| 3 |
+
the Flutter tool needs it to communicate with the running application
|
| 4 |
+
to allow setting breakpoints, to provide hot reload, etc.
|
| 5 |
+
-->
|
| 6 |
+
<uses-permission android:name="android.permission.INTERNET"/>
|
| 7 |
+
</manifest>
|
frontend/android/app/src/main/AndroidManifest.xml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
| 2 |
+
<application
|
| 3 |
+
android:label="loveGPT"
|
| 4 |
+
android:name="${applicationName}"
|
| 5 |
+
android:icon="@mipmap/ic_launcher">
|
| 6 |
+
<activity
|
| 7 |
+
android:name=".MainActivity"
|
| 8 |
+
android:exported="true"
|
| 9 |
+
android:launchMode="singleTop"
|
| 10 |
+
android:taskAffinity=""
|
| 11 |
+
android:theme="@style/LaunchTheme"
|
| 12 |
+
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
| 13 |
+
android:hardwareAccelerated="true"
|
| 14 |
+
android:windowSoftInputMode="adjustResize">
|
| 15 |
+
<!-- Specifies an Android theme to apply to this Activity as soon as
|
| 16 |
+
the Android process has started. This theme is visible to the user
|
| 17 |
+
while the Flutter UI initializes. After that, this theme continues
|
| 18 |
+
to determine the Window background behind the Flutter UI. -->
|
| 19 |
+
<meta-data
|
| 20 |
+
android:name="io.flutter.embedding.android.NormalTheme"
|
| 21 |
+
android:resource="@style/NormalTheme"
|
| 22 |
+
/>
|
| 23 |
+
<intent-filter>
|
| 24 |
+
<action android:name="android.intent.action.MAIN"/>
|
| 25 |
+
<category android:name="android.intent.category.LAUNCHER"/>
|
| 26 |
+
</intent-filter>
|
| 27 |
+
</activity>
|
| 28 |
+
<!-- Don't delete the meta-data below.
|
| 29 |
+
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
| 30 |
+
<meta-data
|
| 31 |
+
android:name="flutterEmbedding"
|
| 32 |
+
android:value="2" />
|
| 33 |
+
</application>
|
| 34 |
+
<!-- Required to query activities that can process text, see:
|
| 35 |
+
https://developer.android.com/training/package-visibility and
|
| 36 |
+
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
| 37 |
+
|
| 38 |
+
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
| 39 |
+
<queries>
|
| 40 |
+
<intent>
|
| 41 |
+
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
| 42 |
+
<data android:mimeType="text/plain"/>
|
| 43 |
+
</intent>
|
| 44 |
+
</queries>
|
| 45 |
+
</manifest>
|
frontend/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package io.flutter.plugins;
|
| 2 |
+
|
| 3 |
+
import androidx.annotation.Keep;
|
| 4 |
+
import androidx.annotation.NonNull;
|
| 5 |
+
import io.flutter.Log;
|
| 6 |
+
|
| 7 |
+
import io.flutter.embedding.engine.FlutterEngine;
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Generated file. Do not edit.
|
| 11 |
+
* This file is generated by the Flutter tool based on the
|
| 12 |
+
* plugins that support the Android platform.
|
| 13 |
+
*/
|
| 14 |
+
@Keep
|
| 15 |
+
public final class GeneratedPluginRegistrant {
|
| 16 |
+
private static final String TAG = "GeneratedPluginRegistrant";
|
| 17 |
+
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
| 18 |
+
try {
|
| 19 |
+
flutterEngine.getPlugins().add(new com.llfbandit.app_links.AppLinksPlugin());
|
| 20 |
+
} catch (Exception e) {
|
| 21 |
+
Log.e(TAG, "Error registering plugin app_links, com.llfbandit.app_links.AppLinksPlugin", e);
|
| 22 |
+
}
|
| 23 |
+
try {
|
| 24 |
+
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
|
| 25 |
+
} catch (Exception e) {
|
| 26 |
+
Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e);
|
| 27 |
+
}
|
| 28 |
+
try {
|
| 29 |
+
flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin());
|
| 30 |
+
} catch (Exception e) {
|
| 31 |
+
Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e);
|
| 32 |
+
}
|
| 33 |
+
try {
|
| 34 |
+
flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin());
|
| 35 |
+
} catch (Exception e) {
|
| 36 |
+
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
|
| 37 |
+
}
|
| 38 |
+
try {
|
| 39 |
+
flutterEngine.getPlugins().add(new com.csdcorp.speech_to_text.SpeechToTextPlugin());
|
| 40 |
+
} catch (Exception e) {
|
| 41 |
+
Log.e(TAG, "Error registering plugin speech_to_text, com.csdcorp.speech_to_text.SpeechToTextPlugin", e);
|
| 42 |
+
}
|
| 43 |
+
try {
|
| 44 |
+
flutterEngine.getPlugins().add(new com.flutter.stripe.StripeAndroidPlugin());
|
| 45 |
+
} catch (Exception e) {
|
| 46 |
+
Log.e(TAG, "Error registering plugin stripe_android, com.flutter.stripe.StripeAndroidPlugin", e);
|
| 47 |
+
}
|
| 48 |
+
try {
|
| 49 |
+
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
| 50 |
+
} catch (Exception e) {
|
| 51 |
+
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
frontend/android/app/src/main/kotlin/com/example/lovegpt/MainActivity.kt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package com.example.lovegpt
|
| 2 |
+
|
| 3 |
+
import io.flutter.embedding.android.FlutterActivity
|
| 4 |
+
|
| 5 |
+
class MainActivity : FlutterActivity()
|
frontend/android/app/src/main/res/drawable-v21/launch_background.xml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="utf-8"?>
|
| 2 |
+
<!-- Modify this file to customize your launch splash screen -->
|
| 3 |
+
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
| 4 |
+
<item android:drawable="?android:colorBackground" />
|
| 5 |
+
|
| 6 |
+
<!-- You can insert your own image assets here -->
|
| 7 |
+
<!-- <item>
|
| 8 |
+
<bitmap
|
| 9 |
+
android:gravity="center"
|
| 10 |
+
android:src="@mipmap/launch_image" />
|
| 11 |
+
</item> -->
|
| 12 |
+
</layer-list>
|
frontend/android/app/src/main/res/drawable/launch_background.xml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="utf-8"?>
|
| 2 |
+
<!-- Modify this file to customize your launch splash screen -->
|
| 3 |
+
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
| 4 |
+
<item android:drawable="@android:color/white" />
|
| 5 |
+
|
| 6 |
+
<!-- You can insert your own image assets here -->
|
| 7 |
+
<!-- <item>
|
| 8 |
+
<bitmap
|
| 9 |
+
android:gravity="center"
|
| 10 |
+
android:src="@mipmap/launch_image" />
|
| 11 |
+
</item> -->
|
| 12 |
+
</layer-list>
|
frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
ADDED
|
frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
ADDED
|
frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
ADDED
|
frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
ADDED
|
frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
ADDED
|
frontend/android/app/src/main/res/values-night/styles.xml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="utf-8"?>
|
| 2 |
+
<resources>
|
| 3 |
+
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
| 4 |
+
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
| 5 |
+
<!-- Show a splash screen on the activity. Automatically removed when
|
| 6 |
+
the Flutter engine draws its first frame -->
|
| 7 |
+
<item name="android:windowBackground">@drawable/launch_background</item>
|
| 8 |
+
</style>
|
| 9 |
+
<!-- Theme applied to the Android Window as soon as the process has started.
|
| 10 |
+
This theme determines the color of the Android Window while your
|
| 11 |
+
Flutter UI initializes, as well as behind your Flutter UI while its
|
| 12 |
+
running.
|
| 13 |
+
|
| 14 |
+
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
| 15 |
+
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
| 16 |
+
<item name="android:windowBackground">?android:colorBackground</item>
|
| 17 |
+
</style>
|
| 18 |
+
</resources>
|