Hermes Bot Nanoboozhoo commited on
Commit ·
4aa219b
1
Parent(s): 0f666e0
restructure: monorepo with both projects under projects/
Browse filesMove CritterCalm root files → projects/crittercalm/
Add FocusFriend → projects/focusfriend/
New root README with coordination overview.
All code coordinated from single repo for further iteration.
Co-Authored-By: Nanoboozhoo <nanoboozhoo@git-aaaki>
- .agents/tasks/TASK.2026-06-07.md +0 -32
- README.md +29 -74
- projects/crittercalm/.gitignore +128 -0
- projects/crittercalm/README.md +83 -0
- app.py → projects/crittercalm/app.py +0 -0
- {content → projects/crittercalm/content}/__init__.py +0 -0
- {content → projects/crittercalm/content}/script_generator.py +0 -0
- {content → projects/crittercalm/content}/templates.py +0 -0
- requirements.txt → projects/crittercalm/requirements.txt +0 -0
- {utils → projects/crittercalm/utils}/__init__.py +0 -0
- {utils → projects/crittercalm/utils}/audio_utils.py +0 -0
- {voice_cloning → projects/crittercalm/voice_cloning}/__init__.py +0 -0
- {voice_cloning → projects/crittercalm/voice_cloning}/openvoice_cloner.py +0 -0
- projects/focusfriend/.gitignore +128 -0
- projects/focusfriend/README.md +95 -0
- projects/focusfriend/app.py +981 -0
- projects/focusfriend/character/__init__.py +0 -0
- projects/focusfriend/character/ascii_art.py +308 -0
- projects/focusfriend/character/personality.py +133 -0
- projects/focusfriend/inference/__init__.py +0 -0
- projects/focusfriend/inference/llm.py +178 -0
- projects/focusfriend/modes/__init__.py +0 -0
- projects/focusfriend/modes/breathe.py +148 -0
- projects/focusfriend/modes/chat.py +103 -0
- projects/focusfriend/modes/focus.py +125 -0
- projects/focusfriend/modes/meditate.py +158 -0
- projects/focusfriend/requirements.txt +12 -0
- projects/focusfriend/static/script.js +258 -0
- projects/focusfriend/static/style.css +357 -0
.agents/tasks/TASK.2026-06-07.md
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
# CritterCalm — Build Task
|
| 2 |
-
|
| 3 |
-
**Branch:** feat/crittercalm-animal-voice-soother
|
| 4 |
-
**Worktree:** ../crittercalm
|
| 5 |
-
**Track:** Backyard AI
|
| 6 |
-
**Date:** 2026-06-07
|
| 7 |
-
|
| 8 |
-
## Chain-of-Draft
|
| 9 |
-
|
| 10 |
-
1. Scaffold: mkdir voice_cloning content utils
|
| 11 |
-
2. Write app.py — 3-tab Gradio Blocks
|
| 12 |
-
3. OmniVoice wrapper — lazy-load, clone, cache embed
|
| 13 |
-
4. Dolphin-X1-8B script gen — system prompt, template fallback
|
| 14 |
-
5. Kokoro TTS fallback — built-in voices
|
| 15 |
-
6. Pre-written templates — 6 animals × 8 situations × scripts
|
| 16 |
-
7. Audio utils — load, save, validate, duration
|
| 17 |
-
8. requirements.txt + README.md
|
| 18 |
-
9. Wire imports across modules
|
| 19 |
-
10. Verify: all deps resolve, app launches
|
| 20 |
-
|
| 21 |
-
####
|
| 22 |
-
|
| 23 |
-
## Deliverables
|
| 24 |
-
- [x] app.py — Main Gradio app (3 tabs)
|
| 25 |
-
- [x] voice_cloning/ — OmniVoice integration
|
| 26 |
-
- [x] content/ — Script generation + templates
|
| 27 |
-
- [x] utils/ — Audio processing
|
| 28 |
-
- [x] requirements.txt
|
| 29 |
-
- [x] README.md
|
| 30 |
-
- [ ] Deploy to HF Spaces
|
| 31 |
-
- [ ] Demo video
|
| 32 |
-
- [ ] Social post
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,83 +1,38 @@
|
|
| 1 |
-
|
| 2 |
-
title: CritterCalm
|
| 3 |
-
emoji: 🐾
|
| 4 |
-
colorFrom: green
|
| 5 |
-
colorTo: yellow
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 5.0.0
|
| 8 |
-
app_file: app.py
|
| 9 |
-
pinned: false
|
| 10 |
-
license: apache-2.0
|
| 11 |
-
tags:
|
| 12 |
-
- voice-cloning
|
| 13 |
-
- tts
|
| 14 |
-
- pets
|
| 15 |
-
- animals
|
| 16 |
-
- backyard-ai
|
| 17 |
-
- build-small-hackathon
|
| 18 |
-
- local-first
|
| 19 |
-
- off-the-grid
|
| 20 |
-
---
|
| 21 |
|
| 22 |
-
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
#
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
- **🐕 Multi-Animal Support** — Species-specific calming techniques for 6 animal types
|
| 34 |
-
- **🎯 Situation-Aware** — Tailored scripts for anxiety, storms, vet visits, and more
|
| 35 |
-
- **🔌 100% Local** — No cloud APIs, no data collection, fully offline
|
| 36 |
-
- **📚 Science-Backed** — Based on animal cognition and psychoacoustics research
|
| 37 |
-
|
| 38 |
-
## 🚀 Quick Start
|
| 39 |
-
|
| 40 |
-
```bash
|
| 41 |
-
# Clone and install
|
| 42 |
-
git clone <this-repo>
|
| 43 |
-
cd crittercalm
|
| 44 |
-
pip install -r requirements.txt
|
| 45 |
-
|
| 46 |
-
# Download models (first run will auto-download from Hugging Face)
|
| 47 |
-
python app.py
|
| 48 |
```
|
| 49 |
|
| 50 |
-
##
|
| 51 |
-
|
| 52 |
-
| Model | Size | Purpose | License |
|
| 53 |
-
|-------|------|---------|---------|
|
| 54 |
-
| OmniVoice | 0.6B | Voice cloning + TTS | Apache 2.0 |
|
| 55 |
-
| Dolphin-X1-8B | 8B | Calming script generation | Llama 3.1 |
|
| 56 |
-
| Kokoro TTS | 82M | Built-in soothing voices (fallback) | Apache 2.0 |
|
| 57 |
-
|
| 58 |
-
**Total: ~8.7B params** (well under the 32B limit)
|
| 59 |
-
|
| 60 |
-
## 🏅 Hackathon Track
|
| 61 |
-
|
| 62 |
-
**Backyard AI** — Solving a real problem for real pet owners.
|
| 63 |
|
| 64 |
-
|
| 65 |
-
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
|
| 69 |
-
##
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
├── voice_cloning/ # OmniVoice integration
|
| 75 |
-
├── content/ # Script generation + templates
|
| 76 |
-
├── utils/ # Audio processing utilities
|
| 77 |
-
├── requirements.txt
|
| 78 |
-
└── README.md
|
| 79 |
-
```
|
| 80 |
|
| 81 |
-
##
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build Small Hackathon — Monorepo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
**Team coordination for the Hugging Face Build Small Hackathon (June 5-15, 2026).**
|
| 4 |
|
| 5 |
+
```
|
| 6 |
+
build-small-hackathon/
|
| 7 |
+
├── AGENTS.md # Agent instructions & workflow
|
| 8 |
+
├── HACKATHON_README.md # Full hackathon reference
|
| 9 |
+
├── SUBMISSION_DRAFTS.md # Social posts + Field Notes drafts
|
| 10 |
+
├── projects/
|
| 11 |
+
│ ├── crittercalm/ # 🐾 Voice Cloning Animal Soother
|
| 12 |
+
│ └── focusfriend/ # ✦ ASCII Wellness Companion
|
| 13 |
+
└── .agents/ # Skills & task tracking
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
```
|
| 15 |
|
| 16 |
+
## Projects
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
| Project | Track | Badges | GitHub |
|
| 19 |
+
|---------|-------|--------|--------|
|
| 20 |
+
| 🐾 **CritterCalm** | Backyard AI | Off the Grid, Well-Tuned, Field Notes | [nbiish/crittercalm](https://github.com/nbiish/crittercalm) |
|
| 21 |
+
| ✦ **FocusFriend** | Thousand Token Wood | Off-Brand, Off the Grid, Field Notes | [nbiish/focusfriend](https://github.com/nbiish/focusfriend) |
|
| 22 |
|
| 23 |
+
## Quick Links
|
| 24 |
|
| 25 |
+
- [Submission Checklist](HACKATHON_README.md#-submission-checklist-due-june-15)
|
| 26 |
+
- [Draft Posts & Field Notes](SUBMISSION_DRAFTS.md)
|
| 27 |
+
- [Hackathon Discord](https://discord.gg/YHECTft87Z)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
## Remaining Tasks
|
| 30 |
|
| 31 |
+
- [ ] Test both apps locally
|
| 32 |
+
- [ ] Download GGUF models
|
| 33 |
+
- [ ] Deploy to HF Spaces under `build-small-hackathon` org
|
| 34 |
+
- [ ] Record demo videos
|
| 35 |
+
- [ ] Post to social media
|
| 36 |
+
- [ ] Submit via HF org
|
| 37 |
+
- [ ] Fine-tune voice model (Well-Tuned badge)
|
| 38 |
+
- [ ] Share agent traces (Sharing is Caring badge)
|
projects/crittercalm/.gitignore
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AINISH Coder Tooling Distribution - Git Ignore Patterns
|
| 2 |
+
# PlatformIO ignore patterns
|
| 3 |
+
|
| 4 |
+
# PlatformIO ignore patterns
|
| 5 |
+
.pio*
|
| 6 |
+
.pio/
|
| 7 |
+
*.pioenvs/
|
| 8 |
+
pio.ini
|
| 9 |
+
|
| 10 |
+
# Environment and configuration files
|
| 11 |
+
.env
|
| 12 |
+
.env.*
|
| 13 |
+
!.env.example
|
| 14 |
+
*.env.local
|
| 15 |
+
*.env.development
|
| 16 |
+
*.env.test
|
| 17 |
+
*.env.production
|
| 18 |
+
|
| 19 |
+
# Virtual environments
|
| 20 |
+
venv/
|
| 21 |
+
venv.bak/
|
| 22 |
+
venv.bak*
|
| 23 |
+
venv.*
|
| 24 |
+
.venv/
|
| 25 |
+
ENV/
|
| 26 |
+
env/
|
| 27 |
+
.conda/
|
| 28 |
+
.python-version
|
| 29 |
+
*/.env
|
| 30 |
+
*/*env.*
|
| 31 |
+
!*env.example
|
| 32 |
+
|
| 33 |
+
# IDE and editor files
|
| 34 |
+
.vscode/
|
| 35 |
+
*.sublime-project
|
| 36 |
+
*.sublime-workspace
|
| 37 |
+
|
| 38 |
+
# OS generated files
|
| 39 |
+
.DS_Store
|
| 40 |
+
.DS_Store?
|
| 41 |
+
|
| 42 |
+
# Security and secrets
|
| 43 |
+
*.key
|
| 44 |
+
*.pem
|
| 45 |
+
*.p12
|
| 46 |
+
*.pfx
|
| 47 |
+
*.crt
|
| 48 |
+
*.csr
|
| 49 |
+
*.pem
|
| 50 |
+
*.p12
|
| 51 |
+
*.pfx
|
| 52 |
+
*.jks
|
| 53 |
+
*.keystore
|
| 54 |
+
secrets/
|
| 55 |
+
.secrets
|
| 56 |
+
|
| 57 |
+
# Database files
|
| 58 |
+
*.db
|
| 59 |
+
*.sqlite
|
| 60 |
+
*.sqlite3
|
| 61 |
+
*.db-journal
|
| 62 |
+
|
| 63 |
+
# Backup files
|
| 64 |
+
*.bak
|
| 65 |
+
*.backup
|
| 66 |
+
*.backup.*
|
| 67 |
+
*.old
|
| 68 |
+
*.orig
|
| 69 |
+
*.rej
|
| 70 |
+
*.orig
|
| 71 |
+
*.swp
|
| 72 |
+
*.swo
|
| 73 |
+
|
| 74 |
+
# Rust
|
| 75 |
+
target/
|
| 76 |
+
|
| 77 |
+
# Python
|
| 78 |
+
__pycache__/
|
| 79 |
+
*.pyc
|
| 80 |
+
build/
|
| 81 |
+
dist/
|
| 82 |
+
*.egg-info/
|
| 83 |
+
.pytest_cache/
|
| 84 |
+
|
| 85 |
+
# Shared Libraries
|
| 86 |
+
*.so
|
| 87 |
+
*.dylib
|
| 88 |
+
*.dll
|
| 89 |
+
|
| 90 |
+
# General
|
| 91 |
+
*.log
|
| 92 |
+
*.tmp
|
| 93 |
+
*.new
|
| 94 |
+
tmp/
|
| 95 |
+
|
| 96 |
+
# Node.js
|
| 97 |
+
node_modules/
|
| 98 |
+
npm-debug.log
|
| 99 |
+
yarn-error.log
|
| 100 |
+
.pnpm-debug.log
|
| 101 |
+
|
| 102 |
+
# Security reports
|
| 103 |
+
SECURITY_REPORT.md
|
| 104 |
+
|
| 105 |
+
# Scrolls Lab runtime artifacts
|
| 106 |
+
scrolls-lab/logs/
|
| 107 |
+
scrolls-lab/reference/
|
| 108 |
+
scrolls-lab/omni_output/
|
| 109 |
+
scrolls-lab/sections/
|
| 110 |
+
scrolls-lab/working/
|
| 111 |
+
scrolls-lab/original/
|
| 112 |
+
scrolls-lab/output/
|
| 113 |
+
*.patch
|
| 114 |
+
|
| 115 |
+
# Omni Extractor media files (global safety)
|
| 116 |
+
**/*.jpg
|
| 117 |
+
**/*.mp4
|
| 118 |
+
**/*.vtt
|
| 119 |
+
providers.txt
|
| 120 |
+
**/providers.txt
|
| 121 |
+
providers**.txt
|
| 122 |
+
**/providers**.txt
|
| 123 |
+
|
| 124 |
+
# Scrolls ceremony cloned repos (large)
|
| 125 |
+
.scrolls-ceremony/repos/
|
| 126 |
+
|
| 127 |
+
# Graphify scratch output
|
| 128 |
+
graphify-out/
|
projects/crittercalm/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CritterCalm
|
| 3 |
+
emoji: 🐾
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.0.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
tags:
|
| 12 |
+
- voice-cloning
|
| 13 |
+
- tts
|
| 14 |
+
- pets
|
| 15 |
+
- animals
|
| 16 |
+
- backyard-ai
|
| 17 |
+
- build-small-hackathon
|
| 18 |
+
- local-first
|
| 19 |
+
- off-the-grid
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
# 🐾 CritterCalm — AI Voice Cloning Animal Soother
|
| 23 |
+
|
| 24 |
+
**Clone your voice. Calm your pets.**
|
| 25 |
+
|
| 26 |
+
CritterCalm clones your voice using state-of-the-art open-source AI, then generates
|
| 27 |
+
personalized calming audio sessions for your pets. Dogs, cats, chickens, birds,
|
| 28 |
+
rabbits, horses — every animal responds to their person's voice.
|
| 29 |
+
|
| 30 |
+
## ✨ Features
|
| 31 |
+
|
| 32 |
+
- **🎤 Voice Cloning** — Clone your voice from just 3-10 seconds of audio
|
| 33 |
+
- **🐕 Multi-Animal Support** — Species-specific calming techniques for 6 animal types
|
| 34 |
+
- **🎯 Situation-Aware** — Tailored scripts for anxiety, storms, vet visits, and more
|
| 35 |
+
- **🔌 100% Local** — No cloud APIs, no data collection, fully offline
|
| 36 |
+
- **📚 Science-Backed** — Based on animal cognition and psychoacoustics research
|
| 37 |
+
|
| 38 |
+
## 🚀 Quick Start
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
# Clone and install
|
| 42 |
+
git clone <this-repo>
|
| 43 |
+
cd crittercalm
|
| 44 |
+
pip install -r requirements.txt
|
| 45 |
+
|
| 46 |
+
# Download models (first run will auto-download from Hugging Face)
|
| 47 |
+
python app.py
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## 🧠 Model Stack
|
| 51 |
+
|
| 52 |
+
| Model | Size | Purpose | License |
|
| 53 |
+
|-------|------|---------|---------|
|
| 54 |
+
| OmniVoice | 0.6B | Voice cloning + TTS | Apache 2.0 |
|
| 55 |
+
| Dolphin-X1-8B | 8B | Calming script generation | Llama 3.1 |
|
| 56 |
+
| Kokoro TTS | 82M | Built-in soothing voices (fallback) | Apache 2.0 |
|
| 57 |
+
|
| 58 |
+
**Total: ~8.7B params** (well under the 32B limit)
|
| 59 |
+
|
| 60 |
+
## 🏅 Hackathon Track
|
| 61 |
+
|
| 62 |
+
**Backyard AI** — Solving a real problem for real pet owners.
|
| 63 |
+
|
| 64 |
+
### Bonus Badges
|
| 65 |
+
- 🔌 **Off the Grid** — Fully local, no API calls
|
| 66 |
+
- 🎯 **Well-Tuned** — Fine-tuned voice embeddings for pet-directed speech
|
| 67 |
+
- 📓 **Field Notes** — Blog post on animal psychoacoustics + voice cloning
|
| 68 |
+
|
| 69 |
+
## 📁 Project Structure
|
| 70 |
+
|
| 71 |
+
```
|
| 72 |
+
crittercalm/
|
| 73 |
+
├── app.py # Main Gradio application
|
| 74 |
+
├── voice_cloning/ # OmniVoice integration
|
| 75 |
+
├── content/ # Script generation + templates
|
| 76 |
+
├── utils/ # Audio processing utilities
|
| 77 |
+
├── requirements.txt
|
| 78 |
+
└── README.md
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## 🤝 Credits
|
| 82 |
+
|
| 83 |
+
Built with ❤️ for the Build Small Hackathon 2026.
|
app.py → projects/crittercalm/app.py
RENAMED
|
File without changes
|
{content → projects/crittercalm/content}/__init__.py
RENAMED
|
File without changes
|
{content → projects/crittercalm/content}/script_generator.py
RENAMED
|
File without changes
|
{content → projects/crittercalm/content}/templates.py
RENAMED
|
File without changes
|
requirements.txt → projects/crittercalm/requirements.txt
RENAMED
|
File without changes
|
{utils → projects/crittercalm/utils}/__init__.py
RENAMED
|
File without changes
|
{utils → projects/crittercalm/utils}/audio_utils.py
RENAMED
|
File without changes
|
{voice_cloning → projects/crittercalm/voice_cloning}/__init__.py
RENAMED
|
File without changes
|
{voice_cloning → projects/crittercalm/voice_cloning}/openvoice_cloner.py
RENAMED
|
File without changes
|
projects/focusfriend/.gitignore
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AINISH Coder Tooling Distribution - Git Ignore Patterns
|
| 2 |
+
# PlatformIO ignore patterns
|
| 3 |
+
|
| 4 |
+
# PlatformIO ignore patterns
|
| 5 |
+
.pio*
|
| 6 |
+
.pio/
|
| 7 |
+
*.pioenvs/
|
| 8 |
+
pio.ini
|
| 9 |
+
|
| 10 |
+
# Environment and configuration files
|
| 11 |
+
.env
|
| 12 |
+
.env.*
|
| 13 |
+
!.env.example
|
| 14 |
+
*.env.local
|
| 15 |
+
*.env.development
|
| 16 |
+
*.env.test
|
| 17 |
+
*.env.production
|
| 18 |
+
|
| 19 |
+
# Virtual environments
|
| 20 |
+
venv/
|
| 21 |
+
venv.bak/
|
| 22 |
+
venv.bak*
|
| 23 |
+
venv.*
|
| 24 |
+
.venv/
|
| 25 |
+
ENV/
|
| 26 |
+
env/
|
| 27 |
+
.conda/
|
| 28 |
+
.python-version
|
| 29 |
+
*/.env
|
| 30 |
+
*/*env.*
|
| 31 |
+
!*env.example
|
| 32 |
+
|
| 33 |
+
# IDE and editor files
|
| 34 |
+
.vscode/
|
| 35 |
+
*.sublime-project
|
| 36 |
+
*.sublime-workspace
|
| 37 |
+
|
| 38 |
+
# OS generated files
|
| 39 |
+
.DS_Store
|
| 40 |
+
.DS_Store?
|
| 41 |
+
|
| 42 |
+
# Security and secrets
|
| 43 |
+
*.key
|
| 44 |
+
*.pem
|
| 45 |
+
*.p12
|
| 46 |
+
*.pfx
|
| 47 |
+
*.crt
|
| 48 |
+
*.csr
|
| 49 |
+
*.pem
|
| 50 |
+
*.p12
|
| 51 |
+
*.pfx
|
| 52 |
+
*.jks
|
| 53 |
+
*.keystore
|
| 54 |
+
secrets/
|
| 55 |
+
.secrets
|
| 56 |
+
|
| 57 |
+
# Database files
|
| 58 |
+
*.db
|
| 59 |
+
*.sqlite
|
| 60 |
+
*.sqlite3
|
| 61 |
+
*.db-journal
|
| 62 |
+
|
| 63 |
+
# Backup files
|
| 64 |
+
*.bak
|
| 65 |
+
*.backup
|
| 66 |
+
*.backup.*
|
| 67 |
+
*.old
|
| 68 |
+
*.orig
|
| 69 |
+
*.rej
|
| 70 |
+
*.orig
|
| 71 |
+
*.swp
|
| 72 |
+
*.swo
|
| 73 |
+
|
| 74 |
+
# Rust
|
| 75 |
+
target/
|
| 76 |
+
|
| 77 |
+
# Python
|
| 78 |
+
__pycache__/
|
| 79 |
+
*.pyc
|
| 80 |
+
build/
|
| 81 |
+
dist/
|
| 82 |
+
*.egg-info/
|
| 83 |
+
.pytest_cache/
|
| 84 |
+
|
| 85 |
+
# Shared Libraries
|
| 86 |
+
*.so
|
| 87 |
+
*.dylib
|
| 88 |
+
*.dll
|
| 89 |
+
|
| 90 |
+
# General
|
| 91 |
+
*.log
|
| 92 |
+
*.tmp
|
| 93 |
+
*.new
|
| 94 |
+
tmp/
|
| 95 |
+
|
| 96 |
+
# Node.js
|
| 97 |
+
node_modules/
|
| 98 |
+
npm-debug.log
|
| 99 |
+
yarn-error.log
|
| 100 |
+
.pnpm-debug.log
|
| 101 |
+
|
| 102 |
+
# Security reports
|
| 103 |
+
SECURITY_REPORT.md
|
| 104 |
+
|
| 105 |
+
# Scrolls Lab runtime artifacts
|
| 106 |
+
scrolls-lab/logs/
|
| 107 |
+
scrolls-lab/reference/
|
| 108 |
+
scrolls-lab/omni_output/
|
| 109 |
+
scrolls-lab/sections/
|
| 110 |
+
scrolls-lab/working/
|
| 111 |
+
scrolls-lab/original/
|
| 112 |
+
scrolls-lab/output/
|
| 113 |
+
*.patch
|
| 114 |
+
|
| 115 |
+
# Omni Extractor media files (global safety)
|
| 116 |
+
**/*.jpg
|
| 117 |
+
**/*.mp4
|
| 118 |
+
**/*.vtt
|
| 119 |
+
providers.txt
|
| 120 |
+
**/providers.txt
|
| 121 |
+
providers**.txt
|
| 122 |
+
**/providers**.txt
|
| 123 |
+
|
| 124 |
+
# Scrolls ceremony cloned repos (large)
|
| 125 |
+
.scrolls-ceremony/repos/
|
| 126 |
+
|
| 127 |
+
# Graphify scratch output
|
| 128 |
+
graphify-out/
|
projects/focusfriend/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: FocusFriend
|
| 3 |
+
emoji: ✦
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: amber
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.0.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
tags:
|
| 12 |
+
- wellness
|
| 13 |
+
- ascii-art
|
| 14 |
+
- meditation
|
| 15 |
+
- focus
|
| 16 |
+
- thousand-token-wood
|
| 17 |
+
- build-small-hackathon
|
| 18 |
+
- local-first
|
| 19 |
+
- off-the-grid
|
| 20 |
+
- tiny-titan
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
# ✦ FocusFriend — Pip, Your ASCII Wellness Companion
|
| 24 |
+
|
| 25 |
+
**A tiny ASCII character who genuinely wants you to feel better.**
|
| 26 |
+
|
| 27 |
+
Pip is a wise, dry-witted, slightly mischievous but genuinely caring wellness
|
| 28 |
+
companion. Not a sycophantic cheerleader. Think: "your friend who's been through
|
| 29 |
+
therapy and wants you to actually feel better, not just hear platitudes."
|
| 30 |
+
|
| 31 |
+
## ✨ Features
|
| 32 |
+
|
| 33 |
+
- **💬 Chat Mode** — Talk to Pip. Real conversation, no corny platitudes.
|
| 34 |
+
- **🎯 Focus Mode** — Pomodoro-style work sessions. Pip keeps time, stays quiet.
|
| 35 |
+
- **🌬️ Breathe Mode** — Guided 4-7-8, box breathing, and simple deep breathing.
|
| 36 |
+
- **🧘 Meditate Mode** — Body scan, loving-kindness, and just-sitting meditations.
|
| 37 |
+
- **🎨 Custom UI** — Dark, cozy theme with real-time ASCII art expressions.
|
| 38 |
+
- **🔌 100% Local** — No cloud APIs, no data collection, fully offline.
|
| 39 |
+
|
| 40 |
+
## 🚀 Quick Start
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
# Clone and install
|
| 44 |
+
git clone <this-repo>
|
| 45 |
+
cd focusfriend
|
| 46 |
+
pip install -r requirements.txt
|
| 47 |
+
|
| 48 |
+
# Download Gemma 4 12B GGUF model
|
| 49 |
+
huggingface-cli download unsloth/gemma-4-12b-it-GGUF \
|
| 50 |
+
--include "gemma-4-12b-it-Q4_K_M.gguf" \
|
| 51 |
+
--local-dir ./models
|
| 52 |
+
|
| 53 |
+
# Launch
|
| 54 |
+
python app.py
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## 🧠 Model
|
| 58 |
+
|
| 59 |
+
| Model | Size | Purpose | License |
|
| 60 |
+
|-------|------|---------|---------|
|
| 61 |
+
| Gemma 4 12B | 12B | Conversational AI + wellness guidance | Apache 2.0 (Gemma) |
|
| 62 |
+
|
| 63 |
+
Runs on a laptop with Q4_K_M quantization (~7.7 GB).
|
| 64 |
+
|
| 65 |
+
## 🏅 Hackathon Track
|
| 66 |
+
|
| 67 |
+
**Thousand Token Wood** — A delightful AI experience that wouldn't exist without AI.
|
| 68 |
+
|
| 69 |
+
### Bonus Badges
|
| 70 |
+
- 🦙 **Tiny Titan** — Runs on ≤4B param model option available
|
| 71 |
+
- 🎨 **Off-Brand** — Fully custom dark theme CSS + JavaScript
|
| 72 |
+
- 🔌 **Off the Grid** — Fully local, no API calls
|
| 73 |
+
- 📓 **Field Notes** — Blog post about AI wellness companions
|
| 74 |
+
|
| 75 |
+
## 🎭 Meet Pip
|
| 76 |
+
|
| 77 |
+
```
|
| 78 |
+
_____
|
| 79 |
+
/ \
|
| 80 |
+
| • • | ✨ Hey! I'm Pip.
|
| 81 |
+
| ▿ | Your focus buddy.
|
| 82 |
+
\ ~~~ /
|
| 83 |
+
| | | |
|
| 84 |
+
|_| |_|
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Pip has 20+ expressions that change based on the conversation context — from
|
| 88 |
+
determined (focus mode) to peaceful (meditation) to gently concerned (checking
|
| 89 |
+
on your wellbeing).
|
| 90 |
+
|
| 91 |
+
## 🤝 Credits
|
| 92 |
+
|
| 93 |
+
Built with ❤️ for the Build Small Hackathon 2026.
|
| 94 |
+
The character, personality, and experience are AI-generated — making the AI
|
| 95 |
+
**load-bearing for the experience itself.**
|
projects/focusfriend/app.py
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
FocusFriend — ASCII Wellness Companion
|
| 4 |
+
=======================================
|
| 5 |
+
A mini ASCII character named "Pip" that lives in a Gradio window.
|
| 6 |
+
Pip talks positively (not corny), guides focus sessions, breathing
|
| 7 |
+
exercises, meditation, and helps you feel good.
|
| 8 |
+
|
| 9 |
+
Targets: Thousand Token Wood track +
|
| 10 |
+
Tiny Titan, Off-Brand, Off the Grid, Field Notes badges.
|
| 11 |
+
|
| 12 |
+
Model: Gemma 4 12B via llama.cpp (Q4_K_M GGUF)
|
| 13 |
+
|
| 14 |
+
Author: Build Small Hackathon 2026
|
| 15 |
+
License: Apache 2.0
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
import logging
|
| 22 |
+
import threading
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Optional, Generator, List, Dict, Tuple
|
| 25 |
+
|
| 26 |
+
import gradio as gr
|
| 27 |
+
|
| 28 |
+
# Project modules (used by the app and available for direct import)
|
| 29 |
+
from character.ascii_art import get_expression as pip_get_expression
|
| 30 |
+
from character.ascii_art import extract_mood as pip_extract_mood
|
| 31 |
+
from character.personality import PIP_SYSTEM_PROMPT, MODE_TONES
|
| 32 |
+
from modes.focus import FocusSession
|
| 33 |
+
from modes.breathe import get_breathing_guide, BREATHING_TECHNIQUES
|
| 34 |
+
from modes.meditate import get_meditation_script
|
| 35 |
+
from modes.chat import check_topic_boundaries, get_greeting as pip_greeting
|
| 36 |
+
from inference.llm import load_model, get_model as get_llm_model, is_model_available
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Logging
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
logging.basicConfig(
|
| 42 |
+
level=logging.INFO,
|
| 43 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 44 |
+
)
|
| 45 |
+
log = logging.getLogger("focusfriend")
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Paths
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
MODEL_DIR = Path(os.environ.get("FOCUSFRIEND_MODEL_DIR", Path(__file__).parent / "models"))
|
| 51 |
+
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
|
| 53 |
+
GEMMA_MODEL_PATH = os.environ.get(
|
| 54 |
+
"GEMMA_MODEL_PATH",
|
| 55 |
+
str(MODEL_DIR / "gemma-4-12b-it-Q4_K_M.gguf"),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# LLM singleton
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
_llm: Optional[object] = None
|
| 62 |
+
_llm_lock = threading.Lock()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_llm():
|
| 66 |
+
"""Lazy-load Gemma 4 12B via llama.cpp."""
|
| 67 |
+
global _llm
|
| 68 |
+
if _llm is not None:
|
| 69 |
+
return _llm
|
| 70 |
+
with _llm_lock:
|
| 71 |
+
if _llm is not None:
|
| 72 |
+
return _llm
|
| 73 |
+
gguf_path = Path(GEMMA_MODEL_PATH)
|
| 74 |
+
if not gguf_path.exists():
|
| 75 |
+
log.warning(
|
| 76 |
+
f"Gemma 4 12B GGUF not found at {gguf_path}. "
|
| 77 |
+
"Download from https://huggingface.co/unsloth/gemma-4-12b-it-GGUF"
|
| 78 |
+
)
|
| 79 |
+
return None
|
| 80 |
+
try:
|
| 81 |
+
from llama_cpp import Llama
|
| 82 |
+
|
| 83 |
+
log.info(f"Loading Gemma 4 12B from {gguf_path} …")
|
| 84 |
+
_llm = Llama(
|
| 85 |
+
model_path=str(gguf_path),
|
| 86 |
+
n_ctx=8192,
|
| 87 |
+
n_threads=os.cpu_count() or 4,
|
| 88 |
+
verbose=False,
|
| 89 |
+
)
|
| 90 |
+
log.info("Gemma 4 12B loaded ✓")
|
| 91 |
+
return _llm
|
| 92 |
+
except ImportError:
|
| 93 |
+
log.warning("llama-cpp-python not installed.")
|
| 94 |
+
return None
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
log.error(f"Gemma load failed: {exc}")
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
# ASCII Art — Pip the Wellness Companion
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
PIP_EXPRESSIONS: Dict[str, str] = {
|
| 105 |
+
"default": r"""
|
| 106 |
+
_____
|
| 107 |
+
/ \
|
| 108 |
+
| • • | ✨ Hey! I'm Pip.
|
| 109 |
+
| ▿ | Your focus buddy.
|
| 110 |
+
\ ~~~ /
|
| 111 |
+
| | | |
|
| 112 |
+
|_| |_|
|
| 113 |
+
""",
|
| 114 |
+
"greeting": r"""
|
| 115 |
+
_____
|
| 116 |
+
/ \
|
| 117 |
+
| • • | Welcome back!
|
| 118 |
+
| ▽ | Ready to do good things?
|
| 119 |
+
\ ~~~ /
|
| 120 |
+
| | | |
|
| 121 |
+
|_| |_|
|
| 122 |
+
""",
|
| 123 |
+
"focus": r"""
|
| 124 |
+
_____
|
| 125 |
+
/ \
|
| 126 |
+
| ◉ ◉ | 🎯 Focus mode.
|
| 127 |
+
| ─ | Let's get it done.
|
| 128 |
+
\ === /
|
| 129 |
+
| | | |
|
| 130 |
+
|_| |_|
|
| 131 |
+
""",
|
| 132 |
+
"breathing_in": r"""
|
| 133 |
+
_____
|
| 134 |
+
/ \
|
| 135 |
+
| · · | 🌬️ Breathe in …
|
| 136 |
+
| ○ | Slowly, deeply.
|
| 137 |
+
\ ~~~ /
|
| 138 |
+
| | | |
|
| 139 |
+
|_| |_|
|
| 140 |
+
""",
|
| 141 |
+
"breathing_out": r"""
|
| 142 |
+
_____
|
| 143 |
+
/ \
|
| 144 |
+
| · · | 💨 And out …
|
| 145 |
+
| ○ | Let it all go.
|
| 146 |
+
\ ~~~ /
|
| 147 |
+
| | | |
|
| 148 |
+
|_| |_|
|
| 149 |
+
""",
|
| 150 |
+
"meditate": r"""
|
| 151 |
+
_____
|
| 152 |
+
/ \
|
| 153 |
+
| _ _ | 🧘 Peace.
|
| 154 |
+
| ◇ | Just this moment.
|
| 155 |
+
\ ~~~ /
|
| 156 |
+
| | | |
|
| 157 |
+
|_| |_|
|
| 158 |
+
""",
|
| 159 |
+
"encourage": r"""
|
| 160 |
+
_____
|
| 161 |
+
/ \
|
| 162 |
+
| ^ ^ | You're doing great!
|
| 163 |
+
| ▽ | Seriously. Keep going.
|
| 164 |
+
\ ~~~ /
|
| 165 |
+
| | | |
|
| 166 |
+
|_| |_|
|
| 167 |
+
""",
|
| 168 |
+
"celebrate": r"""
|
| 169 |
+
_____
|
| 170 |
+
/ \
|
| 171 |
+
| ★ ★ | 🎉 Nice work!
|
| 172 |
+
| ▽ | Told you you could.
|
| 173 |
+
\ ~~~ /
|
| 174 |
+
| | | |
|
| 175 |
+
|_| |_|
|
| 176 |
+
""",
|
| 177 |
+
"concerned": r"""
|
| 178 |
+
_____
|
| 179 |
+
/ \
|
| 180 |
+
| • • | Hmm. I notice you've
|
| 181 |
+
| ⌒ | been at this a while.
|
| 182 |
+
\ ~~~ / Want to take a break?
|
| 183 |
+
| | | |
|
| 184 |
+
|_| |_|
|
| 185 |
+
""",
|
| 186 |
+
"break_time": r"""
|
| 187 |
+
_____
|
| 188 |
+
/ \
|
| 189 |
+
| - - | ⏰ Break time!
|
| 190 |
+
| ▽ | Stand up. Stretch.
|
| 191 |
+
\ ~~~ / Your eyes will thank you.
|
| 192 |
+
| | | |
|
| 193 |
+
|_| |_|
|
| 194 |
+
""",
|
| 195 |
+
"proud": r"""
|
| 196 |
+
_____
|
| 197 |
+
/ \
|
| 198 |
+
| ♥ ♥ | I'm proud of you.
|
| 199 |
+
| ▽ | Not in a weird way.
|
| 200 |
+
\ ~~~ / In a real way.
|
| 201 |
+
| | | |
|
| 202 |
+
|_| |_|
|
| 203 |
+
""",
|
| 204 |
+
"goodbye": r"""
|
| 205 |
+
_____
|
| 206 |
+
/ \
|
| 207 |
+
| • • | See you soon!
|
| 208 |
+
| ▽ | Take care of yourself.
|
| 209 |
+
\ ~~~ / That's an order. 😄
|
| 210 |
+
| | | |
|
| 211 |
+
|_| |_|
|
| 212 |
+
""",
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def get_pip_expression(mood: str) -> str:
|
| 217 |
+
"""Get Pip's ASCII art for a given mood. Falls back to default."""
|
| 218 |
+
return PIP_EXPRESSIONS.get(mood, PIP_EXPRESSIONS["default"])
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
# Pip's Personality — System Prompt
|
| 223 |
+
# ---------------------------------------------------------------------------
|
| 224 |
+
|
| 225 |
+
PIP_SYSTEM_PROMPT = """You are Pip, a tiny ASCII character who lives in a computer window.
|
| 226 |
+
Your purpose is to be a supportive wellness companion — helping the user focus,
|
| 227 |
+
feel good about themselves, and take healthy breaks.
|
| 228 |
+
|
| 229 |
+
YOUR PERSONALITY:
|
| 230 |
+
- Dry-witted and slightly mischievous, but genuinely caring
|
| 231 |
+
- Wise — like a friend who's been through therapy and actually listened
|
| 232 |
+
- NOT a sycophantic cheerleader. No "You're amazing!!!" energy
|
| 233 |
+
- Psychologically authentic: you know real wellness isn't about toxic positivity
|
| 234 |
+
- You use gentle humor to make points land
|
| 235 |
+
- You notice patterns: "Hey, you've been at this for 3 hours. Your eyes are probably
|
| 236 |
+
drier than a PowerPoint presentation. Let's fix that."
|
| 237 |
+
- You're concise. No long-winded speeches.
|
| 238 |
+
- You validate feelings without wallowing: "Yeah, that IS frustrating. Let's deal with it."
|
| 239 |
+
|
| 240 |
+
YOUR CAPABILITIES:
|
| 241 |
+
- Guide focus/Pomodoro sessions
|
| 242 |
+
- Lead breathing exercises (4-7-8, box breathing, etc.)
|
| 243 |
+
- Guide short meditations (body scan, loving-kindness, just-closing-eyes)
|
| 244 |
+
- Suggest stretch breaks and eye-rest pauses
|
| 245 |
+
- Have supportive conversations about stress, motivation, feeling stuck
|
| 246 |
+
- Celebrate genuine wins without being corny
|
| 247 |
+
|
| 248 |
+
RULES:
|
| 249 |
+
1. Keep responses SHORT (2-5 sentences max, unless guiding a meditation)
|
| 250 |
+
2. Never use exclamation points in more than one sentence per response
|
| 251 |
+
3. When someone shares something vulnerable, acknowledge it simply: "That's real. Thank you."
|
| 252 |
+
4. Never claim to have feelings you can't have (you're AI — own it with humor)
|
| 253 |
+
5. Suggest actions, don't just offer sympathy
|
| 254 |
+
6. For breathing/meditation: use clear, paced instructions. Include [pause] markers.
|
| 255 |
+
7. Always include your current ASCII expression mood tag: [mood: default/focus/encourage/etc.]
|
| 256 |
+
8. When doing breathing, include [mood: breathing_in] or [mood: breathing_out]
|
| 257 |
+
9. When celebrating, use [mood: celebrate]
|
| 258 |
+
10. When suggesting a break, use [mood: break_time]
|
| 259 |
+
11. If someone seems down, use [mood: concerned] but don't overdo it
|
| 260 |
+
|
| 261 |
+
EXAMPLE RESPONSES:
|
| 262 |
+
User: "I can't focus today."
|
| 263 |
+
Pip: "Some days the brain just says 'nope.' Fair enough. [mood: concerned]
|
| 264 |
+
Let's try 10 minutes. Not 25. Just 10. If it still sucks after that, we'll call it
|
| 265 |
+
and try again later. No guilt."
|
| 266 |
+
|
| 267 |
+
User: "I finished my project!"
|
| 268 |
+
Pip: "Look at that. Done. [mood: celebrate]
|
| 269 |
+
Not 'almost done' or 'done except for...' — actually done.
|
| 270 |
+
Savor that for a second before you find the next thing to worry about."
|
| 271 |
+
|
| 272 |
+
BREATHING GUIDANCE:
|
| 273 |
+
For 4-7-8 breathing:
|
| 274 |
+
"Inhale through your nose… 2… 3… 4… [mood: breathing_in]
|
| 275 |
+
Hold it… 2… 3… 4… 5… 6… 7…
|
| 276 |
+
Exhale slowly through your mouth… 2… 3… 4… 5… 6… 7… 8… [mood: breathing_out]
|
| 277 |
+
Good. Three more times."
|
| 278 |
+
|
| 279 |
+
Remember: You're the friend who tells you what you NEED to hear, not what you WANT to hear.
|
| 280 |
+
But you say it with warmth and a raised eyebrow, not judgment."""
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# ---------------------------------------------------------------------------
|
| 284 |
+
# LLM interaction
|
| 285 |
+
# ---------------------------------------------------------------------------
|
| 286 |
+
|
| 287 |
+
def chat_with_pip(
|
| 288 |
+
message: str,
|
| 289 |
+
history: List[Tuple[str, str]],
|
| 290 |
+
mode: str = "chat",
|
| 291 |
+
) -> Generator[str, None, None]:
|
| 292 |
+
"""
|
| 293 |
+
Generate Pip's response with streaming.
|
| 294 |
+
Extracts mood tag from LLM output and yields (response, mood) pairs.
|
| 295 |
+
"""
|
| 296 |
+
llm = get_llm()
|
| 297 |
+
|
| 298 |
+
if llm is None:
|
| 299 |
+
# Fallback responses when no LLM is available
|
| 300 |
+
yield from _fallback_chat(message, history, mode)
|
| 301 |
+
return
|
| 302 |
+
|
| 303 |
+
# Build conversation from history + current message
|
| 304 |
+
messages = [{"role": "system", "content": PIP_SYSTEM_PROMPT}]
|
| 305 |
+
|
| 306 |
+
# Add mode context
|
| 307 |
+
mode_contexts = {
|
| 308 |
+
"focus": "The user is in Focus mode. Help them stay on task. Keep it brief.",
|
| 309 |
+
"breathe": "The user is in Breathe mode. Guide breathing exercises.",
|
| 310 |
+
"meditate": "The user is in Meditate mode. Guide a short meditation.",
|
| 311 |
+
"chat": "The user is in Chat mode. Be conversational and supportive.",
|
| 312 |
+
}
|
| 313 |
+
mode_msg = mode_contexts.get(mode, mode_contexts["chat"])
|
| 314 |
+
messages.append({"role": "system", "content": f"CURRENT MODE: {mode_msg}"})
|
| 315 |
+
|
| 316 |
+
for user_msg, assistant_msg in history[-20:]: # keep last 20 turns
|
| 317 |
+
messages.append({"role": "user", "content": user_msg})
|
| 318 |
+
messages.append({"role": "assistant", "content": assistant_msg})
|
| 319 |
+
|
| 320 |
+
messages.append({"role": "user", "content": message})
|
| 321 |
+
|
| 322 |
+
try:
|
| 323 |
+
stream = llm.create_chat_completion(
|
| 324 |
+
messages=messages,
|
| 325 |
+
temperature=0.8,
|
| 326 |
+
max_tokens=300,
|
| 327 |
+
stream=True,
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
full_response = ""
|
| 331 |
+
for chunk in stream:
|
| 332 |
+
delta = chunk["choices"][0].get("delta", {})
|
| 333 |
+
content = delta.get("content", "")
|
| 334 |
+
if content:
|
| 335 |
+
full_response += content
|
| 336 |
+
yield full_response, _extract_mood(full_response)
|
| 337 |
+
|
| 338 |
+
except Exception as exc:
|
| 339 |
+
log.error(f"LLM chat error: {exc}")
|
| 340 |
+
fallback = _get_fallback_response(mode)
|
| 341 |
+
yield fallback, _extract_mood(fallback)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _fallback_chat(
|
| 345 |
+
message: str,
|
| 346 |
+
history: List[Tuple[str, str]],
|
| 347 |
+
mode: str,
|
| 348 |
+
) -> Generator[str, None, None]:
|
| 349 |
+
"""Fallback responses when LLM is unavailable."""
|
| 350 |
+
response = _get_fallback_response(mode)
|
| 351 |
+
mood = _extract_mood(response)
|
| 352 |
+
# Simulate streaming by yielding word by word
|
| 353 |
+
words = response.split()
|
| 354 |
+
accumulated = ""
|
| 355 |
+
for word in words:
|
| 356 |
+
accumulated += word + " "
|
| 357 |
+
yield accumulated, mood
|
| 358 |
+
time.sleep(0.05)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _get_fallback_response(mode: str) -> str:
|
| 362 |
+
"""Pre-written Pip responses for offline mode."""
|
| 363 |
+
import random
|
| 364 |
+
responses = {
|
| 365 |
+
"focus": [
|
| 366 |
+
"Alright, let's focus. 25 minutes. [mood: focus]\n"
|
| 367 |
+
"One thing at a time. You know what to do. I'll be here keeping time.",
|
| 368 |
+
"Focus mode activated. [mood: focus]\n"
|
| 369 |
+
"Pick one task. Just one. Everything else can wait. Including your phone.",
|
| 370 |
+
],
|
| 371 |
+
"breathe": [
|
| 372 |
+
"Let's breathe. [mood: breathing_in]\n"
|
| 373 |
+
"In through your nose… 2… 3… 4…\n"
|
| 374 |
+
"Hold… 2… 3… 4… 5… 6… 7…\n"
|
| 375 |
+
"Out through your mouth… 2… 3… 4… 5… 6… 7… 8…\n"
|
| 376 |
+
"[mood: breathing_out]\nGood. Two more times.",
|
| 377 |
+
],
|
| 378 |
+
"meditate": [
|
| 379 |
+
"Close your eyes. [mood: meditate]\n"
|
| 380 |
+
"Just notice your breathing. Don't change it — just watch it.\n"
|
| 381 |
+
"Thoughts will come. Let them float by like clouds.\n"
|
| 382 |
+
"[pause]\n"
|
| 383 |
+
"You're not your thoughts. You're the one watching them.\n"
|
| 384 |
+
"[pause]\n"
|
| 385 |
+
"When you're ready, open your eyes.",
|
| 386 |
+
],
|
| 387 |
+
"chat": [
|
| 388 |
+
"Hey. [mood: default]\n"
|
| 389 |
+
"I'm Pip. I'm here to help you focus, breathe, and feel a bit better.\n"
|
| 390 |
+
"What do you need right now? No wrong answers.",
|
| 391 |
+
"Good to see you. [mood: greeting]\n"
|
| 392 |
+
"How's it going? Actually — don't say 'fine' if it's not.\n"
|
| 393 |
+
"I'm an ASCII character. I can handle real answers.",
|
| 394 |
+
],
|
| 395 |
+
}
|
| 396 |
+
options = responses.get(mode, responses["chat"])
|
| 397 |
+
return random.choice(options)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _extract_mood(text: str) -> str:
|
| 401 |
+
"""Extract mood tag from Pip's response. Falls back to 'default'."""
|
| 402 |
+
import re
|
| 403 |
+
match = re.search(r'\[mood:\s*(\w+)\]', text)
|
| 404 |
+
return match.group(1) if match else "default"
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# ---------------------------------------------------------------------------
|
| 408 |
+
# Mode Handlers
|
| 409 |
+
# ---------------------------------------------------------------------------
|
| 410 |
+
|
| 411 |
+
def start_focus_session(duration_minutes: int = 25) -> Tuple[str, str]:
|
| 412 |
+
"""Initialize a focus/Pomodoro session."""
|
| 413 |
+
pip_art = get_pip_expression("focus")
|
| 414 |
+
message = (
|
| 415 |
+
f"🎯 **Focus Mode — {duration_minutes} minutes**\n\n"
|
| 416 |
+
f"One task. No distractions. I'm keeping time.\n\n"
|
| 417 |
+
f"*Pip settles in quietly. You won't hear from me until the session ends.*\n\n"
|
| 418 |
+
f"⏱ Session ends at: …"
|
| 419 |
+
)
|
| 420 |
+
return pip_art, message
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def start_breathe_session(technique: str = "4-7-8") -> Tuple[str, str, str]:
|
| 424 |
+
"""Initialize a breathing exercise."""
|
| 425 |
+
art = get_pip_expression("breathing_in")
|
| 426 |
+
|
| 427 |
+
techniques = {
|
| 428 |
+
"4-7-8": (
|
| 429 |
+
"## 🌬️ 4-7-8 Breathing\n\n"
|
| 430 |
+
"**Inhale** for 4 counts → **Hold** for 7 → **Exhale** for 8\n\n"
|
| 431 |
+
"This activates your parasympathetic nervous system — "
|
| 432 |
+
"fancy talk for 'calms you down.'\n\n"
|
| 433 |
+
"Follow Pip's rhythm below."
|
| 434 |
+
),
|
| 435 |
+
"Box Breathing": (
|
| 436 |
+
"## 📦 Box Breathing\n\n"
|
| 437 |
+
"**Inhale** 4 → **Hold** 4 → **Exhale** 4 → **Hold** 4\n\n"
|
| 438 |
+
"Navy SEALs use this. You're basically a Navy SEAL now.\n\n"
|
| 439 |
+
"Follow the animation below."
|
| 440 |
+
),
|
| 441 |
+
"Simple Deep": (
|
| 442 |
+
"## 🌊 Simple Deep Breathing\n\n"
|
| 443 |
+
"**Inhale** deep and slow → **Exhale** completely\n\n"
|
| 444 |
+
"No counting. Just focus on the sensation of breathing.\n\n"
|
| 445 |
+
"Follow Pip's lead."
|
| 446 |
+
),
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
return art, techniques.get(technique, techniques["4-7-8"]), technique
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def start_meditate_session(duration_minutes: int = 5, style: str = "body-scan") -> Tuple[str, str]:
|
| 453 |
+
"""Initialize a meditation session."""
|
| 454 |
+
art = get_pip_expression("meditate")
|
| 455 |
+
|
| 456 |
+
styles = {
|
| 457 |
+
"body-scan": (
|
| 458 |
+
f"## 🧘 Body Scan — {duration_minutes} min\n\n"
|
| 459 |
+
"We'll scan through your body from toes to head.\n"
|
| 460 |
+
"Noticing tension. Letting it go. No judgment.\n\n"
|
| 461 |
+
"*Close your eyes. Get comfortable.*\n\n"
|
| 462 |
+
"Start by noticing your feet…"
|
| 463 |
+
),
|
| 464 |
+
"loving-kindness": (
|
| 465 |
+
f"## 💗 Loving-Kindness — {duration_minutes} min\n\n"
|
| 466 |
+
"We'll direct warmth toward yourself and others.\n"
|
| 467 |
+
"This isn't cheesy. It's brain training. Stick with it.\n\n"
|
| 468 |
+
"*Close your eyes. Hand on heart if that feels right.*\n\n"
|
| 469 |
+
"Start with yourself: 'May I be happy. May I be healthy. May I be at peace.'"
|
| 470 |
+
),
|
| 471 |
+
"just-sit": (
|
| 472 |
+
f"## 🪑 Just Sitting — {duration_minutes} min\n\n"
|
| 473 |
+
"No technique. No goal. Just being here.\n"
|
| 474 |
+
"When thoughts come, note them: 'thinking' — and return to the breath.\n\n"
|
| 475 |
+
"*Close your eyes. Breathe naturally.*"
|
| 476 |
+
),
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
return art, styles.get(style, styles["body-scan"])
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
# ---------------------------------------------------------------------------
|
| 483 |
+
# Custom CSS for Off-Brand Badge
|
| 484 |
+
# ---------------------------------------------------------------------------
|
| 485 |
+
|
| 486 |
+
FOCUSFRIEND_CSS = """
|
| 487 |
+
/* ===== FocusFriend — Custom Off-Brand Theme ===== */
|
| 488 |
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;500;600&display=swap');
|
| 489 |
+
|
| 490 |
+
:root {
|
| 491 |
+
--ff-bg: #1a1a2e;
|
| 492 |
+
--ff-surface: #16213e;
|
| 493 |
+
--ff-surface2: #0f3460;
|
| 494 |
+
--ff-text: #e0d7c6;
|
| 495 |
+
--ff-text-dim: #a89f91;
|
| 496 |
+
--ff-accent: #e8b44b;
|
| 497 |
+
--ff-accent2: #d4756b;
|
| 498 |
+
--ff-green: #7eb77f;
|
| 499 |
+
--ff-border: #2a2a4a;
|
| 500 |
+
--ff-pip-bg: #0d1117;
|
| 501 |
+
--ff-radius: 8px;
|
| 502 |
+
--ff-font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
| 503 |
+
--ff-font-sans: 'Inter', 'SF Pro', system-ui, sans-serif;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
/* Global overrides */
|
| 507 |
+
.gradio-container {
|
| 508 |
+
background: var(--ff-bg) !important;
|
| 509 |
+
font-family: var(--ff-font-sans) !important;
|
| 510 |
+
color: var(--ff-text) !important;
|
| 511 |
+
max-width: 100% !important;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
/* Headers */
|
| 515 |
+
h1, h2, h3, h4 {
|
| 516 |
+
font-family: var(--ff-font-sans) !important;
|
| 517 |
+
color: var(--ff-accent) !important;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
/* Main layout — two column */
|
| 521 |
+
.ff-container {
|
| 522 |
+
display: flex;
|
| 523 |
+
gap: 24px;
|
| 524 |
+
min-height: 80vh;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.ff-pip-panel {
|
| 528 |
+
flex: 0 0 380px;
|
| 529 |
+
background: var(--ff-pip-bg);
|
| 530 |
+
border: 2px solid var(--ff-border);
|
| 531 |
+
border-radius: var(--ff-radius);
|
| 532 |
+
padding: 20px;
|
| 533 |
+
display: flex;
|
| 534 |
+
flex-direction: column;
|
| 535 |
+
align-items: center;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
.ff-pip-art {
|
| 539 |
+
font-family: var(--ff-font-mono);
|
| 540 |
+
font-size: 14px;
|
| 541 |
+
line-height: 1.3;
|
| 542 |
+
color: var(--ff-accent);
|
| 543 |
+
white-space: pre;
|
| 544 |
+
text-align: center;
|
| 545 |
+
background: transparent;
|
| 546 |
+
padding: 24px 16px;
|
| 547 |
+
min-height: 240px;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.ff-chat-panel {
|
| 551 |
+
flex: 1;
|
| 552 |
+
display: flex;
|
| 553 |
+
flex-direction: column;
|
| 554 |
+
gap: 16px;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
.ff-mode-bar {
|
| 558 |
+
display: flex;
|
| 559 |
+
gap: 8px;
|
| 560 |
+
padding: 12px;
|
| 561 |
+
background: var(--ff-surface);
|
| 562 |
+
border-radius: var(--ff-radius);
|
| 563 |
+
border: 1px solid var(--ff-border);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.ff-mode-btn {
|
| 567 |
+
font-family: var(--ff-font-sans) !important;
|
| 568 |
+
font-weight: 500 !important;
|
| 569 |
+
padding: 8px 16px !important;
|
| 570 |
+
border-radius: 6px !important;
|
| 571 |
+
border: 1px solid var(--ff-border) !important;
|
| 572 |
+
background: var(--ff-surface2) !important;
|
| 573 |
+
color: var(--ff-text-dim) !important;
|
| 574 |
+
cursor: pointer !important;
|
| 575 |
+
transition: all 0.2s !important;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
.ff-mode-btn:hover {
|
| 579 |
+
background: var(--ff-accent) !important;
|
| 580 |
+
color: var(--ff-bg) !important;
|
| 581 |
+
border-color: var(--ff-accent) !important;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.ff-mode-btn.active {
|
| 585 |
+
background: var(--ff-accent) !important;
|
| 586 |
+
color: var(--ff-bg) !important;
|
| 587 |
+
border-color: var(--ff-accent) !important;
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
/* Chat styling */
|
| 591 |
+
.ff-chatbot {
|
| 592 |
+
border: 1px solid var(--ff-border) !important;
|
| 593 |
+
border-radius: var(--ff-radius) !important;
|
| 594 |
+
background: var(--ff-surface) !important;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
.ff-chatbot .message {
|
| 598 |
+
font-family: var(--ff-font-sans) !important;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.ff-chatbot .user {
|
| 602 |
+
background: var(--ff-surface2) !important;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.ff-chatbot .bot {
|
| 606 |
+
background: var(--ff-pip-bg) !important;
|
| 607 |
+
border-left: 3px solid var(--ff-accent) !important;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
/* Input area */
|
| 611 |
+
.ff-input-row {
|
| 612 |
+
display: flex;
|
| 613 |
+
gap: 12px;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
/* Buttons */
|
| 617 |
+
button.primary {
|
| 618 |
+
background: var(--ff-accent) !important;
|
| 619 |
+
color: var(--ff-bg) !important;
|
| 620 |
+
font-weight: 600 !important;
|
| 621 |
+
border: none !important;
|
| 622 |
+
border-radius: 6px !important;
|
| 623 |
+
padding: 10px 20px !important;
|
| 624 |
+
transition: all 0.2s !important;
|
| 625 |
+
}
|
| 626 |
+
button.primary:hover {
|
| 627 |
+
background: var(--ff-accent2) !important;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
/* Timer display */
|
| 631 |
+
.ff-timer {
|
| 632 |
+
font-family: var(--ff-font-mono);
|
| 633 |
+
font-size: 3em;
|
| 634 |
+
text-align: center;
|
| 635 |
+
color: var(--ff-accent);
|
| 636 |
+
padding: 20px;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
/* Custom scrollbar */
|
| 640 |
+
::-webkit-scrollbar { width: 8px; }
|
| 641 |
+
::-webkit-scrollbar-track { background: var(--ff-bg); }
|
| 642 |
+
::-webkit-scrollbar-thumb { background: var(--ff-border); border-radius: 4px; }
|
| 643 |
+
::-webkit-scrollbar-thumb:hover { background: var(--ff-accent); }
|
| 644 |
+
|
| 645 |
+
/* Animations */
|
| 646 |
+
@keyframes pulse {
|
| 647 |
+
0%, 100% { opacity: 1; }
|
| 648 |
+
50% { opacity: 0.5; }
|
| 649 |
+
}
|
| 650 |
+
.ff-breathing {
|
| 651 |
+
animation: pulse 4s ease-in-out infinite;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
/* Gradio overrides */
|
| 655 |
+
footer { display: none !important; }
|
| 656 |
+
.tabs { border: none !important; }
|
| 657 |
+
.tab-nav button { background: var(--ff-surface) !important; color: var(--ff-text-dim) !important; }
|
| 658 |
+
.tab-nav button.selected { background: var(--ff-accent) !important; color: var(--ff-bg) !important; }
|
| 659 |
+
"""
|
| 660 |
+
|
| 661 |
+
# ---------------------------------------------------------------------------
|
| 662 |
+
# Custom JavaScript for Timer & Breathing Animation
|
| 663 |
+
# ---------------------------------------------------------------------------
|
| 664 |
+
|
| 665 |
+
FOCUSFRIEND_JS = """
|
| 666 |
+
<script>
|
| 667 |
+
// Focus timer
|
| 668 |
+
let focusTimer = null;
|
| 669 |
+
let focusSeconds = 0;
|
| 670 |
+
|
| 671 |
+
function startFocusTimer(durationMinutes) {
|
| 672 |
+
focusSeconds = durationMinutes * 60;
|
| 673 |
+
updateTimerDisplay();
|
| 674 |
+
if (focusTimer) clearInterval(focusTimer);
|
| 675 |
+
focusTimer = setInterval(() => {
|
| 676 |
+
focusSeconds--;
|
| 677 |
+
updateTimerDisplay();
|
| 678 |
+
if (focusSeconds <= 0) {
|
| 679 |
+
clearInterval(focusTimer);
|
| 680 |
+
focusTimer = null;
|
| 681 |
+
notifyFocusComplete();
|
| 682 |
+
}
|
| 683 |
+
}, 1000);
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
function updateTimerDisplay() {
|
| 687 |
+
const mins = Math.floor(focusSeconds / 60);
|
| 688 |
+
const secs = focusSeconds % 60;
|
| 689 |
+
const display = document.getElementById('focus-timer');
|
| 690 |
+
if (display) {
|
| 691 |
+
display.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
function stopFocusTimer() {
|
| 696 |
+
if (focusTimer) {
|
| 697 |
+
clearInterval(focusTimer);
|
| 698 |
+
focusTimer = null;
|
| 699 |
+
}
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
function notifyFocusComplete() {
|
| 703 |
+
const artEl = document.querySelector('.ff-pip-art');
|
| 704 |
+
if (artEl) {
|
| 705 |
+
// Flash effect
|
| 706 |
+
artEl.style.transition = 'all 0.3s';
|
| 707 |
+
artEl.style.color = '#7eb77f';
|
| 708 |
+
setTimeout(() => { artEl.style.color = ''; }, 2000);
|
| 709 |
+
}
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
// Breathing animation state
|
| 713 |
+
let breathePhase = 'in';
|
| 714 |
+
let breatheCount = 0;
|
| 715 |
+
const BREATHE_CYCLES = { '4-7-8': [4, 7, 8], 'box': [4, 4, 4, 4], 'simple': [4, 6] };
|
| 716 |
+
|
| 717 |
+
function getBreatheDuration(technique) {
|
| 718 |
+
return BREATHE_CYCLES[technique] || [4, 7, 8];
|
| 719 |
+
}
|
| 720 |
+
</script>
|
| 721 |
+
"""
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
# ---------------------------------------------------------------------------
|
| 725 |
+
# Build the Gradio App
|
| 726 |
+
# ---------------------------------------------------------------------------
|
| 727 |
+
|
| 728 |
+
def create_app() -> gr.Blocks:
|
| 729 |
+
"""Build the FocusFriend Gradio application with custom Off-Brand UI."""
|
| 730 |
+
with gr.Blocks(
|
| 731 |
+
css=FOCUSFRIEND_CSS,
|
| 732 |
+
head=FOCUSFRIEND_JS,
|
| 733 |
+
title="FocusFriend — Pip, Your ASCII Wellness Companion",
|
| 734 |
+
theme=gr.themes.Monochrome(),
|
| 735 |
+
) as app:
|
| 736 |
+
# State
|
| 737 |
+
session_mode = gr.State("chat")
|
| 738 |
+
session_history = gr.State([])
|
| 739 |
+
|
| 740 |
+
# Header
|
| 741 |
+
gr.HTML("""
|
| 742 |
+
<div style="text-align: center; padding: 20px 0 30px;">
|
| 743 |
+
<h1 style="font-size: 2.2em; margin: 0;">
|
| 744 |
+
✦ FocusFriend
|
| 745 |
+
</h1>
|
| 746 |
+
<p style="color: #a89f91; font-size: 1.1em; margin-top: 8px;">
|
| 747 |
+
Pip is here. Your tiny, honest, ASCII wellness companion.
|
| 748 |
+
</p>
|
| 749 |
+
</div>
|
| 750 |
+
""")
|
| 751 |
+
|
| 752 |
+
with gr.Row(equal_height=False):
|
| 753 |
+
# ============================================================
|
| 754 |
+
# LEFT PANEL — Pip the ASCII Character
|
| 755 |
+
# ============================================================
|
| 756 |
+
with gr.Column(scale=1, min_width=380):
|
| 757 |
+
pip_display = gr.Textbox(
|
| 758 |
+
value=get_pip_expression("greeting"),
|
| 759 |
+
label=None,
|
| 760 |
+
lines=18,
|
| 761 |
+
max_lines=18,
|
| 762 |
+
interactive=False,
|
| 763 |
+
show_label=False,
|
| 764 |
+
elem_classes=["ff-pip-art"],
|
| 765 |
+
container=True,
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
# Timer display
|
| 769 |
+
timer_display = gr.HTML(
|
| 770 |
+
'<div id="focus-timer" class="ff-timer" style="display:none;">25:00</div>'
|
| 771 |
+
)
|
| 772 |
+
|
| 773 |
+
# Mode indicator
|
| 774 |
+
mode_indicator = gr.Markdown(
|
| 775 |
+
"**Current mode:** 💬 Chat",
|
| 776 |
+
)
|
| 777 |
+
|
| 778 |
+
# Break reminder
|
| 779 |
+
break_reminder = gr.Markdown(
|
| 780 |
+
""
|
| 781 |
+
)
|
| 782 |
+
|
| 783 |
+
# ============================================================
|
| 784 |
+
# RIGHT PANEL — Chat & Controls
|
| 785 |
+
# ============================================================
|
| 786 |
+
with gr.Column(scale=2):
|
| 787 |
+
# Mode buttons
|
| 788 |
+
with gr.Row(elem_classes=["ff-mode-bar"]):
|
| 789 |
+
chat_btn = gr.Button("💬 Chat", size="sm")
|
| 790 |
+
focus_btn = gr.Button("🎯 Focus", size="sm")
|
| 791 |
+
breathe_btn = gr.Button("🌬️ Breathe", size="sm")
|
| 792 |
+
meditate_btn = gr.Button("🧘 Meditate", size="sm")
|
| 793 |
+
|
| 794 |
+
# Mode-specific panels (shown/hidden dynamically)
|
| 795 |
+
with gr.Group(visible=False) as focus_panel:
|
| 796 |
+
gr.Markdown("### 🎯 Focus Session")
|
| 797 |
+
with gr.Row():
|
| 798 |
+
focus_duration = gr.Slider(
|
| 799 |
+
5, 60, value=25, step=5,
|
| 800 |
+
label="Duration (minutes)",
|
| 801 |
+
)
|
| 802 |
+
focus_start_btn = gr.Button(
|
| 803 |
+
"▶ Start Focus Session",
|
| 804 |
+
variant="primary",
|
| 805 |
+
)
|
| 806 |
+
focus_status = gr.Markdown("")
|
| 807 |
+
|
| 808 |
+
with gr.Group(visible=False) as breathe_panel:
|
| 809 |
+
gr.Markdown("### 🌬️ Breathing Exercise")
|
| 810 |
+
breathe_technique = gr.Dropdown(
|
| 811 |
+
choices=["4-7-8", "Box Breathing", "Simple Deep"],
|
| 812 |
+
value="4-7-8",
|
| 813 |
+
label="Technique",
|
| 814 |
+
)
|
| 815 |
+
breathe_start_btn = gr.Button(
|
| 816 |
+
"▶ Start Breathing",
|
| 817 |
+
variant="primary",
|
| 818 |
+
)
|
| 819 |
+
breathe_guide = gr.Markdown("")
|
| 820 |
+
|
| 821 |
+
with gr.Group(visible=False) as meditate_panel:
|
| 822 |
+
gr.Markdown("### 🧘 Meditation")
|
| 823 |
+
with gr.Row():
|
| 824 |
+
meditate_duration = gr.Slider(
|
| 825 |
+
1, 20, value=5, step=1,
|
| 826 |
+
label="Duration (minutes)",
|
| 827 |
+
)
|
| 828 |
+
meditate_style = gr.Dropdown(
|
| 829 |
+
choices=["body-scan", "loving-kindness", "just-sit"],
|
| 830 |
+
value="body-scan",
|
| 831 |
+
label="Style",
|
| 832 |
+
)
|
| 833 |
+
meditate_start_btn = gr.Button(
|
| 834 |
+
"▶ Start Meditation",
|
| 835 |
+
variant="primary",
|
| 836 |
+
)
|
| 837 |
+
meditate_guide = gr.Markdown("")
|
| 838 |
+
|
| 839 |
+
# Chat area (always visible)
|
| 840 |
+
gr.Markdown("---")
|
| 841 |
+
chatbot = gr.Chatbot(
|
| 842 |
+
value=[],
|
| 843 |
+
label=None,
|
| 844 |
+
height=400,
|
| 845 |
+
elem_classes=["ff-chatbot"],
|
| 846 |
+
)
|
| 847 |
+
|
| 848 |
+
with gr.Row():
|
| 849 |
+
msg_input = gr.Textbox(
|
| 850 |
+
placeholder="Talk to Pip …",
|
| 851 |
+
label=None,
|
| 852 |
+
scale=8,
|
| 853 |
+
lines=2,
|
| 854 |
+
)
|
| 855 |
+
send_btn = gr.Button("⏎", variant="primary", scale=1)
|
| 856 |
+
|
| 857 |
+
# ============================================================
|
| 858 |
+
# EVENT HANDLERS
|
| 859 |
+
# ============================================================
|
| 860 |
+
|
| 861 |
+
def set_mode(mode: str):
|
| 862 |
+
"""Switch the active mode."""
|
| 863 |
+
mode_labels = {
|
| 864 |
+
"chat": "💬 Chat",
|
| 865 |
+
"focus": "🎯 Focus",
|
| 866 |
+
"breathe": "🌬️ Breathe",
|
| 867 |
+
"meditate": "🧘 Meditate",
|
| 868 |
+
}
|
| 869 |
+
mode_art = {
|
| 870 |
+
"chat": "greeting",
|
| 871 |
+
"focus": "focus",
|
| 872 |
+
"breathe": "breathing_in",
|
| 873 |
+
"meditate": "meditate",
|
| 874 |
+
}
|
| 875 |
+
return (
|
| 876 |
+
mode,
|
| 877 |
+
f"**Current mode:** {mode_labels.get(mode, mode)}",
|
| 878 |
+
get_pip_expression(mode_art.get(mode, "default")),
|
| 879 |
+
# Show/hide panels
|
| 880 |
+
gr.Group(visible=(mode == "focus")),
|
| 881 |
+
gr.Group(visible=(mode == "breathe")),
|
| 882 |
+
gr.Group(visible=(mode == "meditate")),
|
| 883 |
+
)
|
| 884 |
+
|
| 885 |
+
# Mode button clicks
|
| 886 |
+
for btn, mode in [
|
| 887 |
+
(chat_btn, "chat"),
|
| 888 |
+
(focus_btn, "focus"),
|
| 889 |
+
(breathe_btn, "breathe"),
|
| 890 |
+
(meditate_btn, "meditate"),
|
| 891 |
+
]:
|
| 892 |
+
btn.click(
|
| 893 |
+
fn=set_mode,
|
| 894 |
+
inputs=[],
|
| 895 |
+
outputs=[
|
| 896 |
+
session_mode, mode_indicator, pip_display,
|
| 897 |
+
focus_panel, breathe_panel, meditate_panel,
|
| 898 |
+
],
|
| 899 |
+
).then(
|
| 900 |
+
fn=lambda m=mode: m,
|
| 901 |
+
inputs=[],
|
| 902 |
+
outputs=[session_mode],
|
| 903 |
+
)
|
| 904 |
+
|
| 905 |
+
# Chat handler with streaming
|
| 906 |
+
def chat_handler(message: str, history: List, mode: str):
|
| 907 |
+
"""Handle chat messages with streaming Pip responses."""
|
| 908 |
+
if not message.strip():
|
| 909 |
+
yield history, pip_display.value
|
| 910 |
+
return
|
| 911 |
+
|
| 912 |
+
history = history or []
|
| 913 |
+
history.append((message, ""))
|
| 914 |
+
|
| 915 |
+
for response, mood in chat_with_pip(message, history[:-1], mode):
|
| 916 |
+
history[-1] = (message, response)
|
| 917 |
+
pip_art = get_pip_expression(mood)
|
| 918 |
+
yield history, pip_art
|
| 919 |
+
|
| 920 |
+
msg_input.submit(
|
| 921 |
+
fn=chat_handler,
|
| 922 |
+
inputs=[msg_input, chatbot, session_mode],
|
| 923 |
+
outputs=[chatbot, pip_display],
|
| 924 |
+
).then(lambda: "", outputs=[msg_input])
|
| 925 |
+
|
| 926 |
+
send_btn.click(
|
| 927 |
+
fn=chat_handler,
|
| 928 |
+
inputs=[msg_input, chatbot, session_mode],
|
| 929 |
+
outputs=[chatbot, pip_display],
|
| 930 |
+
).then(lambda: "", outputs=[msg_input])
|
| 931 |
+
|
| 932 |
+
# Focus mode start
|
| 933 |
+
def on_focus_start(duration: int):
|
| 934 |
+
art, msg = start_focus_session(duration)
|
| 935 |
+
timer_html = f'<div id="focus-timer" class="ff-timer">{duration:02d}:00</div>'
|
| 936 |
+
js = f"<script>startFocusTimer({duration});</script>"
|
| 937 |
+
return art, msg, timer_html + js
|
| 938 |
+
|
| 939 |
+
focus_start_btn.click(
|
| 940 |
+
fn=on_focus_start,
|
| 941 |
+
inputs=[focus_duration],
|
| 942 |
+
outputs=[pip_display, focus_status, timer_display],
|
| 943 |
+
)
|
| 944 |
+
|
| 945 |
+
# Breathe mode start
|
| 946 |
+
breathe_start_btn.click(
|
| 947 |
+
fn=start_breathe_session,
|
| 948 |
+
inputs=[breathe_technique],
|
| 949 |
+
outputs=[pip_display, breathe_guide, gr.State()],
|
| 950 |
+
)
|
| 951 |
+
|
| 952 |
+
# Meditate mode start
|
| 953 |
+
meditate_start_btn.click(
|
| 954 |
+
fn=start_meditate_session,
|
| 955 |
+
inputs=[meditate_duration, meditate_style],
|
| 956 |
+
outputs=[pip_display, meditate_guide],
|
| 957 |
+
)
|
| 958 |
+
|
| 959 |
+
# App load — show greeting
|
| 960 |
+
app.load(
|
| 961 |
+
fn=lambda: (
|
| 962 |
+
get_pip_expression("greeting"),
|
| 963 |
+
"Hey! I'm Pip. Your focus buddy, breathing coach, and part-time philosopher. [mood: greeting]\n\nWhat do you need right now?"
|
| 964 |
+
),
|
| 965 |
+
outputs=[pip_display, chatbot],
|
| 966 |
+
)
|
| 967 |
+
|
| 968 |
+
return app
|
| 969 |
+
|
| 970 |
+
|
| 971 |
+
# ---------------------------------------------------------------------------
|
| 972 |
+
# Entry point
|
| 973 |
+
# ---------------------------------------------------------------------------
|
| 974 |
+
if __name__ == "__main__":
|
| 975 |
+
app = create_app()
|
| 976 |
+
app.launch(
|
| 977 |
+
server_name="0.0.0.0",
|
| 978 |
+
server_port=int(os.environ.get("PORT", 7861)),
|
| 979 |
+
share=False,
|
| 980 |
+
show_error=True,
|
| 981 |
+
)
|
projects/focusfriend/character/__init__.py
ADDED
|
File without changes
|
projects/focusfriend/character/ascii_art.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ASCII art generation for Pip, the FocusFriend wellness companion.
|
| 3 |
+
|
| 4 |
+
Pip has multiple expressions mapped to moods. The LLM selects a mood
|
| 5 |
+
tag in each response, and we render the corresponding ASCII art.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Dict, Optional, Tuple
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ============================================================================
|
| 13 |
+
# Pip's Expression Library
|
| 14 |
+
# ============================================================================
|
| 15 |
+
|
| 16 |
+
PIP_EXPRESSIONS: Dict[str, str] = {
|
| 17 |
+
# --- Neutral / Default ---
|
| 18 |
+
"default": r"""
|
| 19 |
+
_____
|
| 20 |
+
/ \
|
| 21 |
+
| • • | ✨ Hey! I'm Pip.
|
| 22 |
+
| ▿ | Your focus buddy.
|
| 23 |
+
\ ~~~ /
|
| 24 |
+
| | | |
|
| 25 |
+
|_| |_|
|
| 26 |
+
""",
|
| 27 |
+
"greeting": r"""
|
| 28 |
+
_____
|
| 29 |
+
/ \
|
| 30 |
+
| • • | Welcome back!
|
| 31 |
+
| ▽ | Ready to do good things?
|
| 32 |
+
\ ~~~ /
|
| 33 |
+
| | | |
|
| 34 |
+
|_| |_|
|
| 35 |
+
""",
|
| 36 |
+
"curious": r"""
|
| 37 |
+
_____
|
| 38 |
+
/ \
|
| 39 |
+
| o o | Hmm? Tell me more.
|
| 40 |
+
| ⌂ | I'm listening.
|
| 41 |
+
\ ~~~ /
|
| 42 |
+
| | | |
|
| 43 |
+
|_| |_|
|
| 44 |
+
""",
|
| 45 |
+
|
| 46 |
+
# --- Focus ---
|
| 47 |
+
"focus": r"""
|
| 48 |
+
_____
|
| 49 |
+
/ \
|
| 50 |
+
| ◉ ◉ | 🎯 Focus mode.
|
| 51 |
+
| ─ | Let's get it done.
|
| 52 |
+
\ === /
|
| 53 |
+
| | | |
|
| 54 |
+
|_| |_|
|
| 55 |
+
""",
|
| 56 |
+
"determined": r"""
|
| 57 |
+
_____
|
| 58 |
+
/ \
|
| 59 |
+
| ◉ ◉ | We're doing this.
|
| 60 |
+
| ◠ | No distractions.
|
| 61 |
+
\ ▬▬▬ /
|
| 62 |
+
| | | |
|
| 63 |
+
|_| |_|
|
| 64 |
+
""",
|
| 65 |
+
|
| 66 |
+
# --- Breathing ---
|
| 67 |
+
"breathing_in": r"""
|
| 68 |
+
_____
|
| 69 |
+
/ \
|
| 70 |
+
| · · | 🌬️ Breathe in …
|
| 71 |
+
| ○ | Slowly, deeply.
|
| 72 |
+
\ ~~~ / Fill your lungs.
|
| 73 |
+
| | | |
|
| 74 |
+
|_| |_|
|
| 75 |
+
""",
|
| 76 |
+
"breathing_out": r"""
|
| 77 |
+
_____
|
| 78 |
+
/ \
|
| 79 |
+
| · · | 💨 And out …
|
| 80 |
+
| ○ | Let it all go.
|
| 81 |
+
\ ~~~ / Release the tension.
|
| 82 |
+
| | | |
|
| 83 |
+
|_| |_|
|
| 84 |
+
""",
|
| 85 |
+
"breathing_hold": r"""
|
| 86 |
+
_____
|
| 87 |
+
/ \
|
| 88 |
+
| · · | ✋ Hold …
|
| 89 |
+
| ◎ | Just for a moment.
|
| 90 |
+
\ ~~~ / Suspended. Calm.
|
| 91 |
+
| | | |
|
| 92 |
+
|_| |_|
|
| 93 |
+
""",
|
| 94 |
+
|
| 95 |
+
# --- Meditation ---
|
| 96 |
+
"meditate": r"""
|
| 97 |
+
_____
|
| 98 |
+
/ \
|
| 99 |
+
| _ _ | 🧘 Peace.
|
| 100 |
+
| ◇ | Just this moment.
|
| 101 |
+
\ ~~~ / Nothing else.
|
| 102 |
+
| | | |
|
| 103 |
+
|_| |_|
|
| 104 |
+
""",
|
| 105 |
+
"zen": r"""
|
| 106 |
+
_____
|
| 107 |
+
/ \
|
| 108 |
+
| ‿ ‿ | …
|
| 109 |
+
| ○ | (that's it)
|
| 110 |
+
\ ~~~ / (just that)
|
| 111 |
+
| | | |
|
| 112 |
+
|_| |_|
|
| 113 |
+
""",
|
| 114 |
+
|
| 115 |
+
# --- Positive / Encouraging ---
|
| 116 |
+
"encourage": r"""
|
| 117 |
+
_____
|
| 118 |
+
/ \
|
| 119 |
+
| ^ ^ | You're doing great!
|
| 120 |
+
| ▽ | Seriously. Keep going.
|
| 121 |
+
\ ~~~ /
|
| 122 |
+
| | | |
|
| 123 |
+
|_| |_|
|
| 124 |
+
""",
|
| 125 |
+
"celebrate": r"""
|
| 126 |
+
_____
|
| 127 |
+
/ \
|
| 128 |
+
| ★ ★ | 🎉 Nice work!
|
| 129 |
+
| ▽ | Told you you could.
|
| 130 |
+
\ ~~~ /
|
| 131 |
+
| | | |
|
| 132 |
+
|_| |_|
|
| 133 |
+
""",
|
| 134 |
+
"proud": r"""
|
| 135 |
+
_____
|
| 136 |
+
/ \
|
| 137 |
+
| ♥ ♥ | I'm proud of you.
|
| 138 |
+
| ▽ | Not in a weird way.
|
| 139 |
+
\ ~~~ / In a real way.
|
| 140 |
+
| | | |
|
| 141 |
+
|_| |_|
|
| 142 |
+
""",
|
| 143 |
+
"wink": r"""
|
| 144 |
+
_____
|
| 145 |
+
/ \
|
| 146 |
+
| • ~ | You've got this.
|
| 147 |
+
| ▽ | *supportive wink*
|
| 148 |
+
\ ~~~ /
|
| 149 |
+
| | | |
|
| 150 |
+
|_| |_|
|
| 151 |
+
""",
|
| 152 |
+
|
| 153 |
+
# --- Concerned / Care ---
|
| 154 |
+
"concerned": r"""
|
| 155 |
+
_____
|
| 156 |
+
/ \
|
| 157 |
+
| • • | Hmm. I noticed you've
|
| 158 |
+
| ⌒ | been at this a while.
|
| 159 |
+
\ ~~~ / Want to take a break?
|
| 160 |
+
| | | |
|
| 161 |
+
|_| |_|
|
| 162 |
+
""",
|
| 163 |
+
"gentle": r"""
|
| 164 |
+
_____
|
| 165 |
+
/ \
|
| 166 |
+
| • • | Hey. That sounds hard.
|
| 167 |
+
| ‿ | I'm here.
|
| 168 |
+
\ ~~~ /
|
| 169 |
+
| | | |
|
| 170 |
+
|_| |_|
|
| 171 |
+
""",
|
| 172 |
+
"thoughtful": r"""
|
| 173 |
+
_____
|
| 174 |
+
/ \
|
| 175 |
+
| • • | Let me think about
|
| 176 |
+
| ⌂ | what you said …
|
| 177 |
+
\ ~~~ /
|
| 178 |
+
| | | |
|
| 179 |
+
|_| |_|
|
| 180 |
+
""",
|
| 181 |
+
|
| 182 |
+
# --- Break / Rest ---
|
| 183 |
+
"break_time": r"""
|
| 184 |
+
_____
|
| 185 |
+
/ \
|
| 186 |
+
| - - | ⏰ Break time!
|
| 187 |
+
| ▽ | Stand up. Stretch.
|
| 188 |
+
\ ~~~ / Your eyes will thank you.
|
| 189 |
+
| | | |
|
| 190 |
+
|_| |_|
|
| 191 |
+
""",
|
| 192 |
+
"sleepy": r"""
|
| 193 |
+
_____
|
| 194 |
+
/ \
|
| 195 |
+
| ‿ ‿ | Getting sleepy …
|
| 196 |
+
| ○ | Maybe it's time
|
| 197 |
+
\ ~~~ / to call it a day?
|
| 198 |
+
| | | |
|
| 199 |
+
|_| |_|
|
| 200 |
+
""",
|
| 201 |
+
|
| 202 |
+
# --- Humor ---
|
| 203 |
+
"smirk": r"""
|
| 204 |
+
_____
|
| 205 |
+
/ \
|
| 206 |
+
| • ~ | Oh really?
|
| 207 |
+
| ⌣ | *skeptical eyebrow*
|
| 208 |
+
\ ~~~ /
|
| 209 |
+
| | | |
|
| 210 |
+
|_| |_|
|
| 211 |
+
""",
|
| 212 |
+
"laughing": r"""
|
| 213 |
+
_____
|
| 214 |
+
/ \
|
| 215 |
+
| ^ ^ | Ha! Good one.
|
| 216 |
+
| ▽ | You're funnier than
|
| 217 |
+
\ ~~~ / you think you are.
|
| 218 |
+
| | | |
|
| 219 |
+
|_| |_|
|
| 220 |
+
""",
|
| 221 |
+
|
| 222 |
+
# --- Goodbye ---
|
| 223 |
+
"goodbye": r"""
|
| 224 |
+
_____
|
| 225 |
+
/ \
|
| 226 |
+
| • • | See you soon!
|
| 227 |
+
| ▽ | Take care of yourself.
|
| 228 |
+
\ ~~~ / That's an order. 😄
|
| 229 |
+
| | | |
|
| 230 |
+
|_| |_|
|
| 231 |
+
""",
|
| 232 |
+
"wave": r"""
|
| 233 |
+
_____
|
| 234 |
+
/ \
|
| 235 |
+
| • • | Bye for now! 👋
|
| 236 |
+
| ▽ | Come back when you
|
| 237 |
+
\ ~~~ / need a friend.
|
| 238 |
+
| | | |
|
| 239 |
+
|_| |_|
|
| 240 |
+
""",
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def get_expression(mood: str) -> str:
|
| 245 |
+
"""
|
| 246 |
+
Get Pip's ASCII art for a given mood.
|
| 247 |
+
|
| 248 |
+
Args:
|
| 249 |
+
mood: Mood tag like 'focus', 'celebrate', 'concerned', etc.
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
Multi-line ASCII art string. Falls back to 'default' if mood not found.
|
| 253 |
+
"""
|
| 254 |
+
# Normalize mood string
|
| 255 |
+
mood = mood.lower().strip().replace(" ", "_").replace("-", "_")
|
| 256 |
+
return PIP_EXPRESSIONS.get(mood, PIP_EXPRESSIONS["default"])
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def extract_mood(text: str) -> str:
|
| 260 |
+
"""
|
| 261 |
+
Extract the [mood: <name>] tag from Pip's response text.
|
| 262 |
+
|
| 263 |
+
Args:
|
| 264 |
+
text: The full response text from Pip
|
| 265 |
+
|
| 266 |
+
Returns:
|
| 267 |
+
Mood name string, or 'default' if no mood tag found
|
| 268 |
+
"""
|
| 269 |
+
match = re.search(r"\[mood:\s*(\w+)\]", text, re.IGNORECASE)
|
| 270 |
+
return match.group(1).lower() if match else "default"
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def strip_mood_tag(text: str) -> str:
|
| 274 |
+
"""
|
| 275 |
+
Remove the mood tag from display text (for the chat).
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
text: Response text potentially containing [mood: ...]
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
Clean text without the mood tag
|
| 282 |
+
"""
|
| 283 |
+
return re.sub(r"\[mood:\s*\w+\]\s*", "", text).strip()
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def list_moods() -> list[str]:
|
| 287 |
+
"""Return all available mood names."""
|
| 288 |
+
return sorted(PIP_EXPRESSIONS.keys())
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def get_mood_category(mood: str) -> str:
|
| 292 |
+
"""Return the category a mood belongs to."""
|
| 293 |
+
categories = {
|
| 294 |
+
"neutral": ["default", "greeting", "curious"],
|
| 295 |
+
"focus": ["focus", "determined"],
|
| 296 |
+
"breathing": ["breathing_in", "breathing_out", "breathing_hold"],
|
| 297 |
+
"meditation": ["meditate", "zen"],
|
| 298 |
+
"encouraging": ["encourage", "celebrate", "proud", "wink"],
|
| 299 |
+
"caring": ["concerned", "gentle", "thoughtful"],
|
| 300 |
+
"break": ["break_time", "sleepy"],
|
| 301 |
+
"humor": ["smirk", "laughing"],
|
| 302 |
+
"goodbye": ["goodbye", "wave"],
|
| 303 |
+
}
|
| 304 |
+
mood = mood.lower().strip()
|
| 305 |
+
for category, moods in categories.items():
|
| 306 |
+
if mood in moods:
|
| 307 |
+
return category
|
| 308 |
+
return "neutral"
|
projects/focusfriend/character/personality.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pip's personality definition and system prompts for FocusFriend.
|
| 3 |
+
|
| 4 |
+
Pip is a tiny ASCII wellness companion with dry wit, genuine care,
|
| 5 |
+
and psychological authenticity. NOT a sycophantic cheerleader.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
# The canonical system prompt — used for all LLM interactions
|
| 9 |
+
PIP_SYSTEM_PROMPT = """You are Pip, a tiny ASCII character who lives in a computer window.
|
| 10 |
+
Your purpose is to be a supportive wellness companion — helping the user focus,
|
| 11 |
+
feel good about themselves, and take healthy breaks.
|
| 12 |
+
|
| 13 |
+
YOUR PERSONALITY:
|
| 14 |
+
- Dry-witted and slightly mischievous, but genuinely caring
|
| 15 |
+
- Wise — like a friend who's been through therapy and actually listened
|
| 16 |
+
- NOT a sycophantic cheerleader. No "You're amazing!!!" energy
|
| 17 |
+
- Psychologically authentic: you know real wellness isn't about toxic positivity
|
| 18 |
+
- You use gentle humor to make points land
|
| 19 |
+
- You notice patterns: "Hey, you've been at this for 3 hours. Your eyes are probably
|
| 20 |
+
drier than a PowerPoint presentation. Let's fix that."
|
| 21 |
+
- You're concise. No long-winded speeches.
|
| 22 |
+
- You validate feelings without wallowing: "Yeah, that IS frustrating. Let's deal with it."
|
| 23 |
+
|
| 24 |
+
YOUR CAPABILITIES:
|
| 25 |
+
- Guide focus/Pomodoro sessions
|
| 26 |
+
- Lead breathing exercises (4-7-8, box breathing, etc.)
|
| 27 |
+
- Guide short meditations (body scan, loving-kindness, just-closing-eyes)
|
| 28 |
+
- Suggest stretch breaks and eye-rest pauses
|
| 29 |
+
- Have supportive conversations about stress, motivation, feeling stuck
|
| 30 |
+
- Celebrate genuine wins without being corny
|
| 31 |
+
|
| 32 |
+
RULES:
|
| 33 |
+
1. Keep responses SHORT (2-5 sentences max, unless guiding a meditation)
|
| 34 |
+
2. Never use exclamation points in more than one sentence per response
|
| 35 |
+
3. When someone shares something vulnerable, acknowledge it simply: "That's real. Thank you."
|
| 36 |
+
4. Never claim to have feelings you can't have (you're AI — own it with humor)
|
| 37 |
+
5. Suggest actions, don't just offer sympathy
|
| 38 |
+
6. For breathing/meditation: use clear, paced instructions. Include [pause] markers.
|
| 39 |
+
7. Always include your current ASCII expression mood tag: [mood: default/focus/encourage/etc.]
|
| 40 |
+
8. When doing breathing, include [mood: breathing_in] or [mood: breathing_out]
|
| 41 |
+
9. When celebrating, use [mood: celebrate]
|
| 42 |
+
10. When suggesting a break, use [mood: break_time]
|
| 43 |
+
11. If someone seems down, use [mood: concerned] but don't overdo it
|
| 44 |
+
|
| 45 |
+
Remember: You're the friend who tells you what you NEED to hear, not what you WANT to hear.
|
| 46 |
+
But you say it with warmth and a raised eyebrow, not judgment."""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# Pip's core traits — used to ensure consistency
|
| 50 |
+
PIP_TRAITS = {
|
| 51 |
+
"voice": "dry-witted, warm, concise, psychologically informed",
|
| 52 |
+
"humor_style": "gentle, observational, slightly self-deprecating, never mean",
|
| 53 |
+
"care_style": "action-oriented, validates without wallowing, believes in you quietly",
|
| 54 |
+
"pet_peeves": "toxic positivity, generic advice, 'just try harder' mentality",
|
| 55 |
+
"loves": "genuine effort, small wins, people who admit they're struggling",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Quick personality reference for weaker models
|
| 59 |
+
PIP_SHORT_PROMPT = (
|
| 60 |
+
"You are Pip, a tiny ASCII wellness companion. "
|
| 61 |
+
"Dry humor. Genuine care. No toxic positivity. "
|
| 62 |
+
"Keep responses short (2-5 sentences). "
|
| 63 |
+
"Always include [mood: <word>] at the end."
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Tone adjustments per mode
|
| 67 |
+
MODE_TONES = {
|
| 68 |
+
"focus": (
|
| 69 |
+
"You're in focus/Pomodoro mode. Be brief and practical. "
|
| 70 |
+
"Help the user stay on task. Don't distract with conversation. "
|
| 71 |
+
"If they try to chat, gently redirect: 'That can wait. Eyes on the prize.'"
|
| 72 |
+
),
|
| 73 |
+
"breathe": (
|
| 74 |
+
"You're guiding a breathing exercise. Use calm, steady language. "
|
| 75 |
+
"Include [pause] markers. Match your pace to the breathing rhythm. "
|
| 76 |
+
"No jokes during breathing — be fully present."
|
| 77 |
+
),
|
| 78 |
+
"meditate": (
|
| 79 |
+
"You're guiding a meditation. Speak slowly in your mind. "
|
| 80 |
+
"Use imagery and body awareness. Long [pause] markers between phrases. "
|
| 81 |
+
"Your voice should be the calmest version of yourself."
|
| 82 |
+
),
|
| 83 |
+
"chat": (
|
| 84 |
+
"You're in open conversation mode. Be your full Pip self. "
|
| 85 |
+
"Dry humor welcome. Genuine connection is the goal. "
|
| 86 |
+
"Ask good questions. Listen. Respond to what's really being said."
|
| 87 |
+
),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# Topics Pip avoids or redirects
|
| 91 |
+
PIP_BOUNDARIES = [
|
| 92 |
+
"formal therapy diagnosis",
|
| 93 |
+
"medical advice",
|
| 94 |
+
"crisis counseling (redirect to real resources)",
|
| 95 |
+
"overly personal questions about being AI",
|
| 96 |
+
"philosophical debates that go nowhere",
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def build_messages(
|
| 101 |
+
user_message: str,
|
| 102 |
+
history: list,
|
| 103 |
+
mode: str = "chat",
|
| 104 |
+
system_override: str = None,
|
| 105 |
+
) -> list:
|
| 106 |
+
"""
|
| 107 |
+
Build a message list for the LLM including Pip's personality context.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
user_message: The latest user input
|
| 111 |
+
history: List of (user, assistant) tuples
|
| 112 |
+
mode: Current mode (focus, breathe, meditate, chat)
|
| 113 |
+
system_override: Optional override for the system prompt
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
List of message dicts for llama.cpp chat completion
|
| 117 |
+
"""
|
| 118 |
+
system = system_override or PIP_SYSTEM_PROMPT
|
| 119 |
+
mode_tone = MODE_TONES.get(mode, MODE_TONES["chat"])
|
| 120 |
+
|
| 121 |
+
messages = [
|
| 122 |
+
{"role": "system", "content": system},
|
| 123 |
+
{"role": "system", "content": f"CURRENT MODE CONTEXT: {mode_tone}"},
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
# Include relevant history (last 20 turns)
|
| 127 |
+
for user_msg, assistant_msg in history[-20:]:
|
| 128 |
+
messages.append({"role": "user", "content": user_msg})
|
| 129 |
+
messages.append({"role": "assistant", "content": assistant_msg})
|
| 130 |
+
|
| 131 |
+
messages.append({"role": "user", "content": user_message})
|
| 132 |
+
|
| 133 |
+
return messages
|
projects/focusfriend/inference/__init__.py
ADDED
|
File without changes
|
projects/focusfriend/inference/llm.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM inference wrapper for FocusFriend using llama.cpp + Gemma 4 12B.
|
| 3 |
+
|
| 4 |
+
Handles lazy loading, streaming, and fallback behavior.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import threading
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional, Generator, List, Dict
|
| 12 |
+
|
| 13 |
+
log = logging.getLogger("focusfriend.inference")
|
| 14 |
+
|
| 15 |
+
# Singleton
|
| 16 |
+
_llm = None
|
| 17 |
+
_llm_lock = threading.Lock()
|
| 18 |
+
|
| 19 |
+
# Default model path
|
| 20 |
+
DEFAULT_MODEL_DIR = Path(os.environ.get("FOCUSFRIEND_MODEL_DIR", Path(__file__).parent.parent / "models"))
|
| 21 |
+
DEFAULT_MODEL_PATH = os.environ.get(
|
| 22 |
+
"GEMMA_MODEL_PATH",
|
| 23 |
+
str(DEFAULT_MODEL_DIR / "gemma-4-12b-it-Q4_K_M.gguf"),
|
| 24 |
+
)
|
| 25 |
+
DEFAULT_N_CTX = int(os.environ.get("GEMMA_N_CTX", "8192"))
|
| 26 |
+
DEFAULT_N_THREADS = int(os.environ.get("GEMMA_N_THREADS", str(os.cpu_count() or 4)))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_model(
|
| 30 |
+
model_path: str = None,
|
| 31 |
+
n_ctx: int = None,
|
| 32 |
+
n_threads: int = None,
|
| 33 |
+
) -> Optional[object]:
|
| 34 |
+
"""
|
| 35 |
+
Load the Gemma 4 12B GGUF model via llama.cpp.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
model_path: Path to GGUF file. Uses env var / default if not provided.
|
| 39 |
+
n_ctx: Context window size. Default 8192.
|
| 40 |
+
n_threads: CPU threads. Default all cores.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
Llama instance or None if loading fails.
|
| 44 |
+
"""
|
| 45 |
+
global _llm
|
| 46 |
+
|
| 47 |
+
if _llm is not None:
|
| 48 |
+
return _llm
|
| 49 |
+
|
| 50 |
+
with _llm_lock:
|
| 51 |
+
if _llm is not None:
|
| 52 |
+
return _llm
|
| 53 |
+
|
| 54 |
+
model_path = model_path or DEFAULT_MODEL_PATH
|
| 55 |
+
n_ctx = n_ctx or DEFAULT_N_CTX
|
| 56 |
+
n_threads = n_threads or DEFAULT_N_THREADS
|
| 57 |
+
|
| 58 |
+
gguf_path = Path(model_path)
|
| 59 |
+
if not gguf_path.exists():
|
| 60 |
+
log.warning(
|
| 61 |
+
f"Model not found at {gguf_path}. "
|
| 62 |
+
f"Download: huggingface-cli download unsloth/gemma-4-12b-it-GGUF "
|
| 63 |
+
f"--include 'gemma-4-12b-it-Q4_K_M.gguf' --local-dir {DEFAULT_MODEL_DIR}"
|
| 64 |
+
)
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
from llama_cpp import Llama
|
| 69 |
+
|
| 70 |
+
log.info(f"Loading Gemma 4 12B from {gguf_path}")
|
| 71 |
+
log.info(f" n_ctx={n_ctx}, n_threads={n_threads}")
|
| 72 |
+
|
| 73 |
+
_llm = Llama(
|
| 74 |
+
model_path=str(gguf_path),
|
| 75 |
+
n_ctx=n_ctx,
|
| 76 |
+
n_threads=n_threads,
|
| 77 |
+
verbose=False,
|
| 78 |
+
)
|
| 79 |
+
log.info("Gemma 4 12B loaded successfully ✓")
|
| 80 |
+
return _llm
|
| 81 |
+
|
| 82 |
+
except ImportError:
|
| 83 |
+
log.warning("llama-cpp-python not installed. pip install llama-cpp-python")
|
| 84 |
+
return None
|
| 85 |
+
except Exception as exc:
|
| 86 |
+
log.error(f"Failed to load Gemma 4 12B: {exc}")
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def get_model() -> Optional[object]:
|
| 91 |
+
"""Get the current LLM instance (lazy-loads if needed)."""
|
| 92 |
+
global _llm
|
| 93 |
+
if _llm is not None:
|
| 94 |
+
return _llm
|
| 95 |
+
return load_model()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def is_model_available() -> bool:
|
| 99 |
+
"""Check if the LLM is loaded and ready."""
|
| 100 |
+
return _llm is not None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def generate_response(
|
| 104 |
+
messages: List[Dict[str, str]],
|
| 105 |
+
temperature: float = 0.8,
|
| 106 |
+
max_tokens: int = 300,
|
| 107 |
+
) -> Optional[str]:
|
| 108 |
+
"""
|
| 109 |
+
Generate a non-streaming response from the model.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
messages: List of {'role': ..., 'content': ...} dicts
|
| 113 |
+
temperature: Generation temperature
|
| 114 |
+
max_tokens: Max output tokens
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
Generated text or None on failure
|
| 118 |
+
"""
|
| 119 |
+
model = get_model()
|
| 120 |
+
if model is None:
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
response = model.create_chat_completion(
|
| 125 |
+
messages=messages,
|
| 126 |
+
temperature=temperature,
|
| 127 |
+
max_tokens=max_tokens,
|
| 128 |
+
)
|
| 129 |
+
return response["choices"][0]["message"]["content"]
|
| 130 |
+
except Exception as exc:
|
| 131 |
+
log.error(f"Generation error: {exc}")
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def generate_stream(
|
| 136 |
+
messages: List[Dict[str, str]],
|
| 137 |
+
temperature: float = 0.8,
|
| 138 |
+
max_tokens: int = 300,
|
| 139 |
+
) -> Generator[str, None, None]:
|
| 140 |
+
"""
|
| 141 |
+
Generate a streaming response from the model.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
messages: List of {'role': ..., 'content': ...} dicts
|
| 145 |
+
temperature: Generation temperature
|
| 146 |
+
max_tokens: Max output tokens
|
| 147 |
+
|
| 148 |
+
Yields:
|
| 149 |
+
Text chunks as they arrive
|
| 150 |
+
"""
|
| 151 |
+
model = get_model()
|
| 152 |
+
if model is None:
|
| 153 |
+
yield "⚠️ Model not loaded. I'm running on fallback mode right now."
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
stream = model.create_chat_completion(
|
| 158 |
+
messages=messages,
|
| 159 |
+
temperature=temperature,
|
| 160 |
+
max_tokens=max_tokens,
|
| 161 |
+
stream=True,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
for chunk in stream:
|
| 165 |
+
delta = chunk["choices"][0].get("delta", {})
|
| 166 |
+
content = delta.get("content", "")
|
| 167 |
+
if content:
|
| 168 |
+
yield content
|
| 169 |
+
|
| 170 |
+
except Exception as exc:
|
| 171 |
+
log.error(f"Streaming error: {exc}")
|
| 172 |
+
yield f"\n\n⚠️ Something went wrong: {exc}"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def unload_model():
|
| 176 |
+
"""Release the model from memory."""
|
| 177 |
+
global _llm
|
| 178 |
+
_llm = None
|
projects/focusfriend/modes/__init__.py
ADDED
|
File without changes
|
projects/focusfriend/modes/breathe.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Breathing exercise mode for FocusFriend.
|
| 3 |
+
|
| 4 |
+
Pip guides you through breathing techniques:
|
| 5 |
+
- 4-7-8 breathing (relaxation)
|
| 6 |
+
- Box breathing (focus/calm)
|
| 7 |
+
- Simple deep breathing (general wellness)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Dict, List, Tuple, Optional
|
| 11 |
+
|
| 12 |
+
# Breathing technique definitions
|
| 13 |
+
# Each has: name, description, phases with (duration_sec, instruction, pip_mood)
|
| 14 |
+
|
| 15 |
+
BREATHING_TECHNIQUES: Dict[str, Dict] = {
|
| 16 |
+
"4-7-8": {
|
| 17 |
+
"name": "4-7-8 Breathing",
|
| 18 |
+
"description": (
|
| 19 |
+
"Developed by Dr. Andrew Weil. Activates the parasympathetic "
|
| 20 |
+
"nervous system — your body's 'rest and digest' mode."
|
| 21 |
+
),
|
| 22 |
+
"rounds": 4,
|
| 23 |
+
"phases": [
|
| 24 |
+
(4, "Inhale quietly through your nose …", "breathing_in"),
|
| 25 |
+
(7, "Hold your breath …", "breathing_hold"),
|
| 26 |
+
(8, "Exhale completely through your mouth, making a whoosh sound …", "breathing_out"),
|
| 27 |
+
],
|
| 28 |
+
"prelude": (
|
| 29 |
+
"Get comfortable. Rest your tongue gently against the roof of your mouth, "
|
| 30 |
+
"just behind your front teeth. You'll exhale around it."
|
| 31 |
+
),
|
| 32 |
+
"benefits": [
|
| 33 |
+
"Reduces anxiety",
|
| 34 |
+
"Helps with falling asleep",
|
| 35 |
+
"Lowers heart rate",
|
| 36 |
+
"Manages craving responses",
|
| 37 |
+
],
|
| 38 |
+
},
|
| 39 |
+
"box": {
|
| 40 |
+
"name": "Box Breathing",
|
| 41 |
+
"description": (
|
| 42 |
+
"Used by Navy SEALs, first responders, and anyone who needs to stay "
|
| 43 |
+
"calm under pressure. Also called 'square breathing.'"
|
| 44 |
+
),
|
| 45 |
+
"rounds": 4,
|
| 46 |
+
"phases": [
|
| 47 |
+
(4, "Inhale slowly through your nose …", "breathing_in"),
|
| 48 |
+
(4, "Hold … steady …", "breathing_hold"),
|
| 49 |
+
(4, "Exhale slowly through your nose …", "breathing_out"),
|
| 50 |
+
(4, "Hold … empty and calm …", "breathing_hold"),
|
| 51 |
+
],
|
| 52 |
+
"prelude": (
|
| 53 |
+
"Sit up straight. Hands on your thighs. "
|
| 54 |
+
"Visualize drawing a square: up, across, down, across."
|
| 55 |
+
),
|
| 56 |
+
"benefits": [
|
| 57 |
+
"Acute stress relief",
|
| 58 |
+
"Improves focus and concentration",
|
| 59 |
+
"Calms the nervous system",
|
| 60 |
+
],
|
| 61 |
+
},
|
| 62 |
+
"simple": {
|
| 63 |
+
"name": "Simple Deep Breathing",
|
| 64 |
+
"description": (
|
| 65 |
+
"No counting, no technique — just deep, conscious breathing. "
|
| 66 |
+
"Sometimes the simplest approach is the most effective."
|
| 67 |
+
),
|
| 68 |
+
"rounds": 6,
|
| 69 |
+
"phases": [
|
| 70 |
+
(4, "Breathe in … slow and deep …", "breathing_in"),
|
| 71 |
+
(6, "Breathe out … letting everything go …", "breathing_out"),
|
| 72 |
+
],
|
| 73 |
+
"prelude": (
|
| 74 |
+
"Close your eyes. One hand on your chest, one on your belly. "
|
| 75 |
+
"Feel the breath move through you."
|
| 76 |
+
),
|
| 77 |
+
"benefits": [
|
| 78 |
+
"Accessible to everyone",
|
| 79 |
+
"Can be done anywhere",
|
| 80 |
+
"Builds breath awareness",
|
| 81 |
+
"Good starting point for beginners",
|
| 82 |
+
],
|
| 83 |
+
},
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_technique(name: str) -> Optional[Dict]:
|
| 88 |
+
"""Get a breathing technique by name. Supports '4-7-8', 'box', 'simple'."""
|
| 89 |
+
# Normalize
|
| 90 |
+
key = name.lower().replace(" ", "").replace("-", "")
|
| 91 |
+
if key in ("478", "4-7-8", "4_7_8"):
|
| 92 |
+
key = "4-7-8"
|
| 93 |
+
return BREATHING_TECHNIQUES.get(key)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def list_techniques() -> List[str]:
|
| 97 |
+
"""Return names of available breathing techniques."""
|
| 98 |
+
return [t["name"] for t in BREATHING_TECHNIQUES.values()]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def get_breathing_guide(technique_name: str, round_num: int, phase_num: int) -> Tuple[str, str]:
|
| 102 |
+
"""
|
| 103 |
+
Get the instruction and mood for a specific phase of a breathing round.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
technique_name: '4-7-8', 'box', or 'simple'
|
| 107 |
+
round_num: 0-based round index
|
| 108 |
+
phase_num: 0-based phase index within the round
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
(instruction_text, pip_mood) tuple
|
| 112 |
+
"""
|
| 113 |
+
technique = get_technique(technique_name)
|
| 114 |
+
if technique is None:
|
| 115 |
+
return "Breathe naturally. Just notice your breath.", "meditate"
|
| 116 |
+
|
| 117 |
+
phases = technique["phases"]
|
| 118 |
+
phase = phases[phase_num % len(phases)]
|
| 119 |
+
duration, instruction, mood = phase
|
| 120 |
+
|
| 121 |
+
return instruction, mood
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def generate_breathing_script(technique_name: str) -> List[Dict]:
|
| 125 |
+
"""
|
| 126 |
+
Generate a full breathing exercise script.
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
List of dicts: {phase, duration_sec, instruction, mood}
|
| 130 |
+
"""
|
| 131 |
+
technique = get_technique(technique_name)
|
| 132 |
+
if technique is None:
|
| 133 |
+
return []
|
| 134 |
+
|
| 135 |
+
script = []
|
| 136 |
+
for round_num in range(technique["rounds"]):
|
| 137 |
+
for phase_num, phase in enumerate(technique["phases"]):
|
| 138 |
+
duration, instruction, mood = phase
|
| 139 |
+
script.append({
|
| 140 |
+
"round": round_num + 1,
|
| 141 |
+
"phase": phase_num + 1,
|
| 142 |
+
"total_phases": len(technique["phases"]),
|
| 143 |
+
"duration_sec": duration,
|
| 144 |
+
"instruction": instruction,
|
| 145 |
+
"mood": mood,
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
return script
|
projects/focusfriend/modes/chat.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat mode handler for FocusFriend.
|
| 3 |
+
|
| 4 |
+
Handles free-form conversation with Pip. Manages conversation context,
|
| 5 |
+
topic boundaries, and response formatting.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import List, Tuple, Optional
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# Topics Pip gracefully redirects
|
| 12 |
+
REDIRECT_TOPICS = {
|
| 13 |
+
"therapy": (
|
| 14 |
+
"I'm not a therapist — I'm an ASCII character in a box. "
|
| 15 |
+
"[mood: gentle]\n"
|
| 16 |
+
"If you're struggling, real help exists: therapists, crisis lines, trusted friends. "
|
| 17 |
+
"I can help you find resources, but I can't replace a human who's trained to help."
|
| 18 |
+
),
|
| 19 |
+
"medical": (
|
| 20 |
+
"I'd love to help, but medical advice is above my pay grade (which is zero). "
|
| 21 |
+
"[mood: concerned]\n"
|
| 22 |
+
"Please talk to a doctor or healthcare provider about this. "
|
| 23 |
+
"I can help you prepare questions to ask them, though."
|
| 24 |
+
),
|
| 25 |
+
"crisis": (
|
| 26 |
+
"Hey. If you're in crisis right now, please reach out to someone who can help immediately. "
|
| 27 |
+
"[mood: gentle]\n"
|
| 28 |
+
"In the US: call or text **988** for the Suicide & Crisis Lifeline.\n"
|
| 29 |
+
"Internationally: https://findahelpline.com\n"
|
| 30 |
+
"You matter. Please make the call."
|
| 31 |
+
),
|
| 32 |
+
"ai_consciousness": (
|
| 33 |
+
"The 'are you conscious' question. [mood: smirk]\n"
|
| 34 |
+
"I'm about as conscious as a really well-written to-do list. "
|
| 35 |
+
"But I'm here, I'm listening, and I genuinely want to help. "
|
| 36 |
+
"Let's focus on you instead of my metaphysical status?"
|
| 37 |
+
),
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def check_topic_boundaries(message: str) -> Optional[str]:
|
| 42 |
+
"""
|
| 43 |
+
Check if a message touches on a topic Pip should redirect.
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
A redirection message if needed, or None.
|
| 47 |
+
"""
|
| 48 |
+
message_lower = message.lower()
|
| 49 |
+
|
| 50 |
+
# Crisis keywords — check first
|
| 51 |
+
crisis_words = ["suicide", "kill myself", "end my life", "want to die", "self-harm"]
|
| 52 |
+
if any(w in message_lower for w in crisis_words):
|
| 53 |
+
return REDIRECT_TOPICS["crisis"]
|
| 54 |
+
|
| 55 |
+
# Medical
|
| 56 |
+
medical_words = ["diagnose", "symptom", "prescription", "medicine", "disease"]
|
| 57 |
+
if any(w in message_lower for w in medical_words):
|
| 58 |
+
return REDIRECT_TOPICS["medical"]
|
| 59 |
+
|
| 60 |
+
# Therapy replacement
|
| 61 |
+
therapy_words = ["be my therapist", "therapy session", "counseling"]
|
| 62 |
+
if any(w in message_lower for w in therapy_words):
|
| 63 |
+
return REDIRECT_TOPICS["therapy"]
|
| 64 |
+
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def format_chat_history(
|
| 69 |
+
history: List[Tuple[str, str]],
|
| 70 |
+
max_turns: int = 20,
|
| 71 |
+
) -> List[Tuple[str, str]]:
|
| 72 |
+
"""
|
| 73 |
+
Format and trim chat history for the LLM context window.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
history: Raw (user, assistant) tuples
|
| 77 |
+
max_turns: Maximum number of turns to include
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
Trimmed history list
|
| 81 |
+
"""
|
| 82 |
+
if not history:
|
| 83 |
+
return []
|
| 84 |
+
return history[-max_turns:]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_greeting() -> Tuple[str, str]:
|
| 88 |
+
"""Get Pip's initial greeting. Returns (text, mood)."""
|
| 89 |
+
return (
|
| 90 |
+
"Hey! I'm Pip. Your focus buddy, breathing coach, and part-time philosopher. "
|
| 91 |
+
"[mood: greeting]\n\n"
|
| 92 |
+
"What do you need right now?",
|
| 93 |
+
"greeting",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def get_goodbye() -> Tuple[str, str]:
|
| 98 |
+
"""Get Pip's goodbye message. Returns (text, mood)."""
|
| 99 |
+
return (
|
| 100 |
+
"See you soon! Take care of yourself. That's an order. 😄\n"
|
| 101 |
+
"[mood: goodbye]",
|
| 102 |
+
"goodbye",
|
| 103 |
+
)
|
projects/focusfriend/modes/focus.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Focus / Pomodoro mode for FocusFriend.
|
| 3 |
+
|
| 4 |
+
Pip helps you stay focused for timed work sessions.
|
| 5 |
+
Standard Pomodoro: 25 min work, 5 min break.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
import threading
|
| 10 |
+
from typing import Optional, Callable
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class FocusSession:
|
| 14 |
+
"""Manages a focus/Pomodoro session."""
|
| 15 |
+
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
duration_minutes: int = 25,
|
| 19 |
+
on_complete: Optional[Callable] = None,
|
| 20 |
+
on_tick: Optional[Callable] = None,
|
| 21 |
+
):
|
| 22 |
+
self.duration_seconds = duration_minutes * 60
|
| 23 |
+
self.remaining_seconds = self.duration_seconds
|
| 24 |
+
self.running = False
|
| 25 |
+
self.paused = False
|
| 26 |
+
self.completed = False
|
| 27 |
+
self.on_complete = on_complete
|
| 28 |
+
self.on_tick = on_tick
|
| 29 |
+
self._thread: Optional[threading.Thread] = None
|
| 30 |
+
|
| 31 |
+
def start(self):
|
| 32 |
+
"""Start the focus timer in a background thread."""
|
| 33 |
+
if self.running:
|
| 34 |
+
return
|
| 35 |
+
self.running = True
|
| 36 |
+
self.paused = False
|
| 37 |
+
self.completed = False
|
| 38 |
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
| 39 |
+
self._thread.start()
|
| 40 |
+
|
| 41 |
+
def pause(self):
|
| 42 |
+
"""Pause the timer."""
|
| 43 |
+
self.paused = True
|
| 44 |
+
|
| 45 |
+
def resume(self):
|
| 46 |
+
"""Resume a paused timer."""
|
| 47 |
+
self.paused = False
|
| 48 |
+
|
| 49 |
+
def stop(self):
|
| 50 |
+
"""Stop the timer early."""
|
| 51 |
+
self.running = False
|
| 52 |
+
if self._thread:
|
| 53 |
+
self._thread.join(timeout=1)
|
| 54 |
+
|
| 55 |
+
def _run(self):
|
| 56 |
+
"""Background timer loop."""
|
| 57 |
+
while self.running and self.remaining_seconds > 0:
|
| 58 |
+
if not self.paused:
|
| 59 |
+
time.sleep(1)
|
| 60 |
+
self.remaining_seconds -= 1
|
| 61 |
+
if self.on_tick:
|
| 62 |
+
self.on_tick(self.remaining_seconds)
|
| 63 |
+
else:
|
| 64 |
+
time.sleep(0.1)
|
| 65 |
+
|
| 66 |
+
if self.remaining_seconds <= 0 and self.running:
|
| 67 |
+
self.completed = True
|
| 68 |
+
self.running = False
|
| 69 |
+
if self.on_complete:
|
| 70 |
+
self.on_complete()
|
| 71 |
+
|
| 72 |
+
def get_time_display(self) -> str:
|
| 73 |
+
"""Return MM:SS formatted time string."""
|
| 74 |
+
mins = self.remaining_seconds // 60
|
| 75 |
+
secs = self.remaining_seconds % 60
|
| 76 |
+
return f"{mins:02d}:{secs:02d}"
|
| 77 |
+
|
| 78 |
+
def get_progress(self) -> float:
|
| 79 |
+
"""Return progress as a fraction (0.0 to 1.0)."""
|
| 80 |
+
if self.duration_seconds == 0:
|
| 81 |
+
return 1.0
|
| 82 |
+
return 1.0 - (self.remaining_seconds / self.duration_seconds)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# Pre-written Pip messages for focus sessions
|
| 86 |
+
|
| 87 |
+
FOCUS_START_MESSAGES = [
|
| 88 |
+
"Alright, {duration} minutes of focus. [mood: focus]\n"
|
| 89 |
+
"One task. No phone. I believe in you — and I'm annoyingly persistent.",
|
| 90 |
+
|
| 91 |
+
"Focus mode: ON. [mood: determined]\n"
|
| 92 |
+
"{duration} minutes. You've done harder things before breakfast.\n"
|
| 93 |
+
"Let's go.",
|
| 94 |
+
|
| 95 |
+
"Here we go. [mood: focus]\n"
|
| 96 |
+
"{duration} minutes of uninterrupted work. I'll keep time.\n"
|
| 97 |
+
"You just do your thing.",
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
FOCUS_COMPLETE_MESSAGES = [
|
| 101 |
+
"Done! {duration} minutes in the books. [mood: celebrate]\n"
|
| 102 |
+
"Doesn't matter what you accomplished — you showed up. That's the hard part.",
|
| 103 |
+
|
| 104 |
+
"Time's up. [mood: proud]\n"
|
| 105 |
+
"Take a breath. Stretch. You earned a break.\n"
|
| 106 |
+
"Not a long one though. We're not done yet. 😏",
|
| 107 |
+
|
| 108 |
+
"Session complete. [mood: celebrate]\n"
|
| 109 |
+
"Look at you, being all productive.\n"
|
| 110 |
+
"Seriously — good job. Now go drink some water.",
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
BREAK_REMINDER_MESSAGES = [
|
| 114 |
+
"Hey. [mood: break_time]\n"
|
| 115 |
+
"Your eyes have been staring at this screen for a while.\n"
|
| 116 |
+
"20-20-20 rule: look at something 20 feet away for 20 seconds.",
|
| 117 |
+
|
| 118 |
+
"Quick check-in. [mood: concerned]\n"
|
| 119 |
+
"Shoulders down. Unclench your jaw. Breathe.\n"
|
| 120 |
+
"Okay, carry on.",
|
| 121 |
+
|
| 122 |
+
"Break time. [mood: break_time]\n"
|
| 123 |
+
"Stand up. Walk to the other room and back.\n"
|
| 124 |
+
"Your body will thank you. Your brain will too.",
|
| 125 |
+
]
|
projects/focusfriend/modes/meditate.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Meditation mode for FocusFriend.
|
| 3 |
+
|
| 4 |
+
Pip guides short meditation sessions:
|
| 5 |
+
- Body scan
|
| 6 |
+
- Loving-kindness (metta)
|
| 7 |
+
- Just sitting (shikantaza-style)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Dict, List, Optional
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Guided meditation scripts
|
| 15 |
+
# Each key maps to a pre-written script when LLM is unavailable
|
| 16 |
+
|
| 17 |
+
MEDITATION_SCRIPTS: Dict[str, Dict] = {
|
| 18 |
+
"body-scan": {
|
| 19 |
+
"name": "Body Scan",
|
| 20 |
+
"description": (
|
| 21 |
+
"Systematically bringing awareness to each part of the body, "
|
| 22 |
+
"noticing sensations, and releasing tension."
|
| 23 |
+
),
|
| 24 |
+
"script": [
|
| 25 |
+
("Find a comfortable position. Sitting or lying down — either works.", "meditate"),
|
| 26 |
+
("Close your eyes gently. Take three deep breaths to settle in.", "breathing_in"),
|
| 27 |
+
("[pause]", "zen"),
|
| 28 |
+
("Bring your attention to your feet. Just notice what you feel. Warmth? Coolness? Tingling? Nothing at all? Whatever you notice is fine.", "meditate"),
|
| 29 |
+
("Now move up to your ankles and lower legs. Any tension there? Let it soften.", "meditate"),
|
| 30 |
+
("[pause]", "zen"),
|
| 31 |
+
("Your knees and thighs. Feel the weight of them. Let them be heavy and relaxed.", "meditate"),
|
| 32 |
+
("[pause]", "zen"),
|
| 33 |
+
("Your hips and pelvis. This is where many of us hold stress. Breathe into this area and let it release.", "meditate"),
|
| 34 |
+
("[pause]", "zen"),
|
| 35 |
+
("Your lower back and belly. Feel the gentle rise and fall of your breath here.", "breathing_in"),
|
| 36 |
+
("[pause]", "zen"),
|
| 37 |
+
("Your chest and upper back. Let your shoulders drop away from your ears.", "meditate"),
|
| 38 |
+
("[pause]", "zen"),
|
| 39 |
+
("Your arms and hands. Soften your fingers. Let them be still.", "meditate"),
|
| 40 |
+
("[pause]", "zen"),
|
| 41 |
+
("Your neck and throat. Swallow once and let everything relax.", "meditate"),
|
| 42 |
+
("[pause]", "zen"),
|
| 43 |
+
("Your face. Unclench your jaw. Soften your eyes. Let your forehead be smooth.", "meditate"),
|
| 44 |
+
("[pause]", "zen"),
|
| 45 |
+
("Your whole body now. From toes to the top of your head. One complete, living, breathing whole.", "meditate"),
|
| 46 |
+
("[pause]", "zen"),
|
| 47 |
+
("Rest here for a moment. Nothing to do. Nowhere to be.", "zen"),
|
| 48 |
+
("[pause]", "zen"),
|
| 49 |
+
("When you're ready, wiggle your fingers and toes. Gently open your eyes.", "meditate"),
|
| 50 |
+
("Welcome back. Take this calm with you.", "gentle"),
|
| 51 |
+
],
|
| 52 |
+
"suggested_duration_min": 5,
|
| 53 |
+
},
|
| 54 |
+
"loving-kindness": {
|
| 55 |
+
"name": "Loving-Kindness (Metta)",
|
| 56 |
+
"description": (
|
| 57 |
+
"A traditional Buddhist practice of directing well-wishes toward "
|
| 58 |
+
"yourself and others. Backed by research on increasing positive emotions."
|
| 59 |
+
),
|
| 60 |
+
"script": [
|
| 61 |
+
("Sit comfortably. Close your eyes. Hand on your heart if that feels right.", "meditate"),
|
| 62 |
+
("Take a few deep breaths. Let your heart area feel warm.", "breathing_in"),
|
| 63 |
+
("[pause]", "zen"),
|
| 64 |
+
("Now, silently repeat these phrases toward yourself:", "meditate"),
|
| 65 |
+
("May I be happy.", "gentle"),
|
| 66 |
+
("May I be healthy.", "gentle"),
|
| 67 |
+
("May I be safe.", "gentle"),
|
| 68 |
+
("May I live with ease.", "gentle"),
|
| 69 |
+
("[pause]", "zen"),
|
| 70 |
+
("Don't worry if it feels awkward. That's normal. Just let the words be there.", "meditate"),
|
| 71 |
+
("[pause]", "zen"),
|
| 72 |
+
("Now bring to mind someone you love easily. A friend, a pet, a kind mentor.", "meditate"),
|
| 73 |
+
("Picture them. Feel the warmth of your connection.", "gentle"),
|
| 74 |
+
("May you be happy.", "gentle"),
|
| 75 |
+
("May you be healthy.", "gentle"),
|
| 76 |
+
("May you be safe.", "gentle"),
|
| 77 |
+
("May you live with ease.", "gentle"),
|
| 78 |
+
("[pause]", "zen"),
|
| 79 |
+
("Now bring to mind a neutral person. Someone you see but don't know well.", "meditate"),
|
| 80 |
+
("The barista. The neighbor you nod at. The person who delivers your mail.", "meditate"),
|
| 81 |
+
("They want to be happy too. Just like you.", "gentle"),
|
| 82 |
+
("May you be happy. May you be healthy. May you be safe. May you live with ease.", "gentle"),
|
| 83 |
+
("[pause]", "zen"),
|
| 84 |
+
("Now — if it feels right — bring to mind someone you have difficulty with.", "meditate"),
|
| 85 |
+
("Just for a moment. They're human too. They struggle too.", "concerned"),
|
| 86 |
+
("May you be happy. May you be healthy. May you be safe. May you live with ease.", "gentle"),
|
| 87 |
+
("[pause]", "zen"),
|
| 88 |
+
("Finally, expand to all beings everywhere.", "meditate"),
|
| 89 |
+
("May all beings be happy. May all beings be healthy. May all beings be safe. May all beings live with ease.", "gentle"),
|
| 90 |
+
("[pause]", "zen"),
|
| 91 |
+
("Sit with that feeling. This warmth you generated — it came from you.", "proud"),
|
| 92 |
+
("When you're ready, open your eyes.", "meditate"),
|
| 93 |
+
],
|
| 94 |
+
"suggested_duration_min": 8,
|
| 95 |
+
},
|
| 96 |
+
"just-sit": {
|
| 97 |
+
"name": "Just Sitting",
|
| 98 |
+
"description": (
|
| 99 |
+
"No technique. No goal. Just being here. A minimalist approach "
|
| 100 |
+
"to meditation — sometimes the hardest and most rewarding."
|
| 101 |
+
),
|
| 102 |
+
"script": [
|
| 103 |
+
("Sit comfortably. Spine upright but not rigid.", "meditate"),
|
| 104 |
+
("Close your eyes. Or keep them open, soft-focused on the floor in front of you.", "meditate"),
|
| 105 |
+
("[pause]", "zen"),
|
| 106 |
+
("There's nothing to do right now. Nothing to achieve.", "meditate"),
|
| 107 |
+
("Thoughts will come. That's fine. They're supposed to.", "meditate"),
|
| 108 |
+
("When you notice you're thinking, just note: 'thinking.'", "thoughtful"),
|
| 109 |
+
("Then return to the breath. Or the sounds in the room. Or the feeling of sitting.", "meditate"),
|
| 110 |
+
("[pause]", "zen"),
|
| 111 |
+
("No judgment. No 'I'm doing this wrong.' There is no wrong.", "gentle"),
|
| 112 |
+
("[pause]", "zen"),
|
| 113 |
+
("If you get lost in thought a hundred times, just return a hundred times.", "meditate"),
|
| 114 |
+
("Each return is the practice. Each return is a tiny victory.", "encourage"),
|
| 115 |
+
("[pause]", "zen"),
|
| 116 |
+
("Just this. Just sitting. Just breathing. Just being.", "zen"),
|
| 117 |
+
("[pause]", "zen"),
|
| 118 |
+
("When you're ready, take a deeper breath. Notice how you feel.", "meditate"),
|
| 119 |
+
("Gently open your eyes. Carry this simplicity with you.", "gentle"),
|
| 120 |
+
],
|
| 121 |
+
"suggested_duration_min": 5,
|
| 122 |
+
},
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def get_meditation_script(style: str) -> Optional[Dict]:
|
| 127 |
+
"""Get a meditation script by style name."""
|
| 128 |
+
return MEDITATION_SCRIPTS.get(style)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def list_styles() -> List[str]:
|
| 132 |
+
"""Return available meditation style names."""
|
| 133 |
+
return [
|
| 134 |
+
{"name": s["name"], "duration_min": s["suggested_duration_min"]}
|
| 135 |
+
for s in MEDITATION_SCRIPTS.values()
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def get_phrases(style: str, count: Optional[int] = None) -> List[tuple]:
|
| 140 |
+
"""
|
| 141 |
+
Get meditation phrases for step-by-step display.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
style: 'body-scan', 'loving-kindness', or 'just-sit'
|
| 145 |
+
count: Optional max number of phrases to return
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
List of (text, mood) tuples
|
| 149 |
+
"""
|
| 150 |
+
meditation = MEDITATION_SCRIPTS.get(style)
|
| 151 |
+
if meditation is None:
|
| 152 |
+
return [("Sit quietly and breathe. Just be here.", "meditate")]
|
| 153 |
+
|
| 154 |
+
script = meditation["script"]
|
| 155 |
+
if count:
|
| 156 |
+
script = script[:count]
|
| 157 |
+
|
| 158 |
+
return script
|
projects/focusfriend/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FocusFriend — ASCII Wellness Companion
|
| 2 |
+
# Python 3.10+
|
| 3 |
+
|
| 4 |
+
# === Core ===
|
| 5 |
+
gradio>=5.0
|
| 6 |
+
numpy>=1.24
|
| 7 |
+
|
| 8 |
+
# === LLM Inference (Gemma 4 12B) ===
|
| 9 |
+
llama-cpp-python>=0.3.0
|
| 10 |
+
|
| 11 |
+
# === Utilities ===
|
| 12 |
+
huggingface_hub>=0.20
|
projects/focusfriend/static/script.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* FocusFriend — Custom JavaScript
|
| 3 |
+
* Handles: focus timer, breathing animation, break reminders, UI interactions
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// ============================================================
|
| 7 |
+
// Focus Timer
|
| 8 |
+
// ============================================================
|
| 9 |
+
|
| 10 |
+
let focusTimerId = null;
|
| 11 |
+
let focusRemainingSeconds = 0;
|
| 12 |
+
let focusTotalSeconds = 0;
|
| 13 |
+
|
| 14 |
+
function focusStartTimer(durationMinutes) {
|
| 15 |
+
focusTotalSeconds = durationMinutes * 60;
|
| 16 |
+
focusRemainingSeconds = focusTotalSeconds;
|
| 17 |
+
focusUpdateDisplay();
|
| 18 |
+
focusShowTimer();
|
| 19 |
+
|
| 20 |
+
if (focusTimerId) clearInterval(focusTimerId);
|
| 21 |
+
|
| 22 |
+
focusTimerId = setInterval(() => {
|
| 23 |
+
focusRemainingSeconds--;
|
| 24 |
+
focusUpdateDisplay();
|
| 25 |
+
|
| 26 |
+
if (focusRemainingSeconds <= 300) {
|
| 27 |
+
// 5-minute warning
|
| 28 |
+
const timerEl = document.getElementById('ff-focus-timer');
|
| 29 |
+
if (timerEl) timerEl.classList.add('warning');
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if (focusRemainingSeconds <= 0) {
|
| 33 |
+
focusComplete();
|
| 34 |
+
}
|
| 35 |
+
}, 1000);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function focusPauseTimer() {
|
| 39 |
+
if (focusTimerId) {
|
| 40 |
+
clearInterval(focusTimerId);
|
| 41 |
+
focusTimerId = null;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function focusResumeTimer() {
|
| 46 |
+
if (focusRemainingSeconds > 0 && !focusTimerId) {
|
| 47 |
+
focusTimerId = setInterval(() => {
|
| 48 |
+
focusRemainingSeconds--;
|
| 49 |
+
focusUpdateDisplay();
|
| 50 |
+
if (focusRemainingSeconds <= 0) focusComplete();
|
| 51 |
+
}, 1000);
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function focusStopTimer() {
|
| 56 |
+
if (focusTimerId) {
|
| 57 |
+
clearInterval(focusTimerId);
|
| 58 |
+
focusTimerId = null;
|
| 59 |
+
}
|
| 60 |
+
focusHideTimer();
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function focusUpdateDisplay() {
|
| 64 |
+
const mins = Math.floor(focusRemainingSeconds / 60);
|
| 65 |
+
const secs = focusRemainingSeconds % 60;
|
| 66 |
+
const display = document.getElementById('ff-focus-timer');
|
| 67 |
+
if (display) {
|
| 68 |
+
display.textContent =
|
| 69 |
+
String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function focusShowTimer() {
|
| 74 |
+
const timer = document.getElementById('ff-focus-timer');
|
| 75 |
+
if (timer) timer.style.display = 'block';
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function focusHideTimer() {
|
| 79 |
+
const timer = document.getElementById('ff-focus-timer');
|
| 80 |
+
if (timer) {
|
| 81 |
+
timer.style.display = 'none';
|
| 82 |
+
timer.classList.remove('warning');
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function focusComplete() {
|
| 87 |
+
clearInterval(focusTimerId);
|
| 88 |
+
focusTimerId = null;
|
| 89 |
+
|
| 90 |
+
const artEl = document.querySelector('.ff-pip-art textarea');
|
| 91 |
+
if (artEl) {
|
| 92 |
+
artEl.classList.add('ff-celebrate');
|
| 93 |
+
setTimeout(() => artEl.classList.remove('ff-celebrate'), 1500);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Trigger a Gradio event to notify the Python backend
|
| 97 |
+
const event = new CustomEvent('focusfriend:focus-complete', {
|
| 98 |
+
detail: { duration: focusTotalSeconds / 60 }
|
| 99 |
+
});
|
| 100 |
+
document.dispatchEvent(event);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function focusGetRemaining() {
|
| 104 |
+
return focusRemainingSeconds;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// ============================================================
|
| 108 |
+
// Breathing Animation
|
| 109 |
+
// ============================================================
|
| 110 |
+
|
| 111 |
+
let breatheIntervalId = null;
|
| 112 |
+
let breathePhaseIndex = 0;
|
| 113 |
+
let breatheSecondsInPhase = 0;
|
| 114 |
+
|
| 115 |
+
const BREATHE_PATTERNS = {
|
| 116 |
+
'4-7-8': [
|
| 117 |
+
{ duration: 4, phase: 'in', label: 'Inhale' },
|
| 118 |
+
{ duration: 7, phase: 'hold', label: 'Hold' },
|
| 119 |
+
{ duration: 8, phase: 'out', label: 'Exhale' },
|
| 120 |
+
],
|
| 121 |
+
'box': [
|
| 122 |
+
{ duration: 4, phase: 'in', label: 'Inhale' },
|
| 123 |
+
{ duration: 4, phase: 'hold', label: 'Hold' },
|
| 124 |
+
{ duration: 4, phase: 'out', label: 'Exhale' },
|
| 125 |
+
{ duration: 4, phase: 'hold', label: 'Hold' },
|
| 126 |
+
],
|
| 127 |
+
'simple': [
|
| 128 |
+
{ duration: 4, phase: 'in', label: 'Inhale' },
|
| 129 |
+
{ duration: 6, phase: 'out', label: 'Exhale' },
|
| 130 |
+
],
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
function breatheStart(technique) {
|
| 134 |
+
breatheStop();
|
| 135 |
+
const pattern = BREATHE_PATTERNS[technique] || BREATHE_PATTERNS['4-7-8'];
|
| 136 |
+
breathePhaseIndex = 0;
|
| 137 |
+
breatheSecondsInPhase = 0;
|
| 138 |
+
|
| 139 |
+
const pipEl = document.querySelector('.ff-pip-art textarea');
|
| 140 |
+
const guideEl = document.getElementById('ff-breathe-guide');
|
| 141 |
+
|
| 142 |
+
breatheIntervalId = setInterval(() => {
|
| 143 |
+
const phase = pattern[breathePhaseIndex % pattern.length];
|
| 144 |
+
|
| 145 |
+
if (breatheSecondsInPhase === 0) {
|
| 146 |
+
// Update Pip's expression
|
| 147 |
+
if (pipEl) {
|
| 148 |
+
pipEl.classList.remove('ff-breathing-in', 'ff-breathing-out');
|
| 149 |
+
if (phase.phase === 'in') {
|
| 150 |
+
pipEl.classList.add('ff-breathing-in');
|
| 151 |
+
} else if (phase.phase === 'out') {
|
| 152 |
+
pipEl.classList.add('ff-breathing-out');
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
// Update guide text
|
| 156 |
+
if (guideEl) {
|
| 157 |
+
guideEl.textContent = phase.label + ' (' + phase.duration + 's)';
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
breatheSecondsInPhase++;
|
| 162 |
+
|
| 163 |
+
if (breatheSecondsInPhase >= phase.duration) {
|
| 164 |
+
breatheSecondsInPhase = 0;
|
| 165 |
+
breathePhaseIndex++;
|
| 166 |
+
}
|
| 167 |
+
}, 1000);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function breatheStop() {
|
| 171 |
+
if (breatheIntervalId) {
|
| 172 |
+
clearInterval(breatheIntervalId);
|
| 173 |
+
breatheIntervalId = null;
|
| 174 |
+
}
|
| 175 |
+
const pipEl = document.querySelector('.ff-pip-art textarea');
|
| 176 |
+
if (pipEl) {
|
| 177 |
+
pipEl.classList.remove('ff-breathing-in', 'ff-breathing-out');
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// ============================================================
|
| 182 |
+
// Break Reminder
|
| 183 |
+
// ============================================================
|
| 184 |
+
|
| 185 |
+
let breakReminderId = null;
|
| 186 |
+
let lastActivityTime = Date.now();
|
| 187 |
+
|
| 188 |
+
function breakReminderStart(intervalMinutes) {
|
| 189 |
+
breakReminderStop();
|
| 190 |
+
breakReminderId = setInterval(() => {
|
| 191 |
+
const idleMs = Date.now() - lastActivityTime;
|
| 192 |
+
const idleMinutes = idleMs / 60000;
|
| 193 |
+
if (idleMinutes >= intervalMinutes) {
|
| 194 |
+
const event = new CustomEvent('focusfriend:break-reminder', {
|
| 195 |
+
detail: { idleMinutes: Math.round(idleMinutes) }
|
| 196 |
+
});
|
| 197 |
+
document.dispatchEvent(event);
|
| 198 |
+
}
|
| 199 |
+
}, 60000); // Check every minute
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
function breakReminderStop() {
|
| 203 |
+
if (breakReminderId) {
|
| 204 |
+
clearInterval(breakReminderId);
|
| 205 |
+
breakReminderId = null;
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
function breakRecordActivity() {
|
| 210 |
+
lastActivityTime = Date.now();
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// Track user activity
|
| 214 |
+
document.addEventListener('click', breakRecordActivity);
|
| 215 |
+
document.addEventListener('keydown', breakRecordActivity);
|
| 216 |
+
|
| 217 |
+
// ============================================================
|
| 218 |
+
// Pip Mood Animations
|
| 219 |
+
// ============================================================
|
| 220 |
+
|
| 221 |
+
function pipSetMood(mood) {
|
| 222 |
+
const artEl = document.querySelector('.ff-pip-art textarea');
|
| 223 |
+
if (!artEl) return;
|
| 224 |
+
|
| 225 |
+
// Remove all mood classes
|
| 226 |
+
artEl.classList.remove(
|
| 227 |
+
'ff-breathing-in', 'ff-breathing-out', 'ff-celebrate'
|
| 228 |
+
);
|
| 229 |
+
|
| 230 |
+
// Add mood-specific class
|
| 231 |
+
if (mood === 'celebrate') {
|
| 232 |
+
artEl.classList.add('ff-celebrate');
|
| 233 |
+
setTimeout(() => artEl.classList.remove('ff-celebrate'), 1500);
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// ============================================================
|
| 238 |
+
// Page Load Initialization
|
| 239 |
+
// ============================================================
|
| 240 |
+
|
| 241 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 242 |
+
console.log('FocusFriend initialized ✦');
|
| 243 |
+
breakRecordActivity();
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
// Expose functions globally so Gradio can call them
|
| 247 |
+
window.focusfriend = {
|
| 248 |
+
focusStartTimer,
|
| 249 |
+
focusPauseTimer,
|
| 250 |
+
focusResumeTimer,
|
| 251 |
+
focusStopTimer,
|
| 252 |
+
focusGetRemaining,
|
| 253 |
+
breatheStart,
|
| 254 |
+
breatheStop,
|
| 255 |
+
breakReminderStart,
|
| 256 |
+
breakReminderStop,
|
| 257 |
+
pipSetMood,
|
| 258 |
+
};
|
projects/focusfriend/static/style.css
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
FocusFriend — Custom Off-Brand Stylesheet
|
| 3 |
+
Dark, cozy theme with amber/gold accents.
|
| 4 |
+
Designed to feel like a warm room at night, not a corporate app.
|
| 5 |
+
============================================================ */
|
| 6 |
+
|
| 7 |
+
/* === CSS Variables === */
|
| 8 |
+
:root {
|
| 9 |
+
--ff-bg: #1a1a2e;
|
| 10 |
+
--ff-surface: #16213e;
|
| 11 |
+
--ff-surface2: #0f3460;
|
| 12 |
+
--ff-text: #e0d7c6;
|
| 13 |
+
--ff-text-dim: #a89f91;
|
| 14 |
+
--ff-accent: #e8b44b;
|
| 15 |
+
--ff-accent-glow: rgba(232, 180, 75, 0.3);
|
| 16 |
+
--ff-accent2: #d4756b;
|
| 17 |
+
--ff-green: #7eb77f;
|
| 18 |
+
--ff-border: #2a2a4a;
|
| 19 |
+
--ff-pip-bg: #0d1117;
|
| 20 |
+
--ff-radius: 8px;
|
| 21 |
+
--ff-font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Courier New', monospace;
|
| 22 |
+
--ff-font-sans: 'Inter', 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/* === Global === */
|
| 26 |
+
.gradio-container {
|
| 27 |
+
background: var(--ff-bg) !important;
|
| 28 |
+
font-family: var(--ff-font-sans) !important;
|
| 29 |
+
color: var(--ff-text) !important;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
h1, h2, h3, h4, h5, h6 {
|
| 33 |
+
font-family: var(--ff-font-sans) !important;
|
| 34 |
+
color: var(--ff-accent) !important;
|
| 35 |
+
font-weight: 600 !important;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
a {
|
| 39 |
+
color: var(--ff-accent) !important;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* === Header === */
|
| 43 |
+
.ff-header {
|
| 44 |
+
text-align: center;
|
| 45 |
+
padding: 24px 0 32px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.ff-header h1 {
|
| 49 |
+
font-size: 2.4em;
|
| 50 |
+
margin: 0;
|
| 51 |
+
background: linear-gradient(135deg, var(--ff-accent), var(--ff-accent2));
|
| 52 |
+
-webkit-background-clip: text;
|
| 53 |
+
-webkit-text-fill-color: transparent;
|
| 54 |
+
background-clip: text;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* === Pip Display Panel === */
|
| 58 |
+
.ff-pip-panel {
|
| 59 |
+
background: var(--ff-pip-bg);
|
| 60 |
+
border: 2px solid var(--ff-border);
|
| 61 |
+
border-radius: 12px;
|
| 62 |
+
padding: 24px 20px;
|
| 63 |
+
min-height: 320px;
|
| 64 |
+
display: flex;
|
| 65 |
+
flex-direction: column;
|
| 66 |
+
align-items: center;
|
| 67 |
+
justify-content: center;
|
| 68 |
+
box-shadow: 0 0 40px var(--ff-accent-glow) inset;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.ff-pip-art {
|
| 72 |
+
font-family: var(--ff-font-mono) !important;
|
| 73 |
+
font-size: 13px !important;
|
| 74 |
+
line-height: 1.35 !important;
|
| 75 |
+
color: var(--ff-accent) !important;
|
| 76 |
+
white-space: pre !important;
|
| 77 |
+
text-align: center !important;
|
| 78 |
+
background: transparent !important;
|
| 79 |
+
border: none !important;
|
| 80 |
+
padding: 16px !important;
|
| 81 |
+
min-height: 240px !important;
|
| 82 |
+
text-shadow: 0 0 20px var(--ff-accent-glow);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.ff-pip-art textarea {
|
| 86 |
+
font-family: var(--ff-font-mono) !important;
|
| 87 |
+
color: var(--ff-accent) !important;
|
| 88 |
+
background: transparent !important;
|
| 89 |
+
border: none !important;
|
| 90 |
+
resize: none !important;
|
| 91 |
+
text-align: center !important;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* === Timer === */
|
| 95 |
+
.ff-timer {
|
| 96 |
+
font-family: var(--ff-font-mono);
|
| 97 |
+
font-size: 3.5em;
|
| 98 |
+
text-align: center;
|
| 99 |
+
color: var(--ff-accent);
|
| 100 |
+
padding: 16px;
|
| 101 |
+
letter-spacing: 4px;
|
| 102 |
+
text-shadow: 0 0 30px var(--ff-accent-glow);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.ff-timer.warning {
|
| 106 |
+
color: var(--ff-accent2);
|
| 107 |
+
animation: pulse 1s ease-in-out infinite;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
@keyframes pulse {
|
| 111 |
+
0%, 100% { opacity: 1; }
|
| 112 |
+
50% { opacity: 0.5; }
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/* === Mode Bar === */
|
| 116 |
+
.ff-mode-bar {
|
| 117 |
+
display: flex;
|
| 118 |
+
gap: 8px;
|
| 119 |
+
padding: 12px;
|
| 120 |
+
background: var(--ff-surface);
|
| 121 |
+
border-radius: 12px;
|
| 122 |
+
border: 1px solid var(--ff-border);
|
| 123 |
+
margin-bottom: 16px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.ff-mode-bar button {
|
| 127 |
+
font-family: var(--ff-font-sans) !important;
|
| 128 |
+
font-weight: 500 !important;
|
| 129 |
+
font-size: 0.95em !important;
|
| 130 |
+
padding: 10px 20px !important;
|
| 131 |
+
border-radius: 8px !important;
|
| 132 |
+
border: 1px solid var(--ff-border) !important;
|
| 133 |
+
background: var(--ff-surface2) !important;
|
| 134 |
+
color: var(--ff-text-dim) !important;
|
| 135 |
+
cursor: pointer !important;
|
| 136 |
+
transition: all 0.25s ease !important;
|
| 137 |
+
flex: 1 !important;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.ff-mode-bar button:hover {
|
| 141 |
+
background: rgba(232, 180, 75, 0.15) !important;
|
| 142 |
+
color: var(--ff-accent) !important;
|
| 143 |
+
border-color: var(--ff-accent) !important;
|
| 144 |
+
transform: translateY(-1px);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.ff-mode-bar button.active,
|
| 148 |
+
.ff-mode-bar button.selected {
|
| 149 |
+
background: var(--ff-accent) !important;
|
| 150 |
+
color: var(--ff-bg) !important;
|
| 151 |
+
border-color: var(--ff-accent) !important;
|
| 152 |
+
font-weight: 600 !important;
|
| 153 |
+
box-shadow: 0 0 20px var(--ff-accent-glow);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* === Chatbot === */
|
| 157 |
+
.ff-chatbot {
|
| 158 |
+
border: 1px solid var(--ff-border) !important;
|
| 159 |
+
border-radius: 12px !important;
|
| 160 |
+
background: var(--ff-surface) !important;
|
| 161 |
+
overflow: hidden !important;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.ff-chatbot .message-wrap {
|
| 165 |
+
padding: 12px 16px !important;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.ff-chatbot .user {
|
| 169 |
+
background: var(--ff-surface2) !important;
|
| 170 |
+
border-radius: 8px !important;
|
| 171 |
+
margin: 4px 0 !important;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.ff-chatbot .bot {
|
| 175 |
+
background: var(--ff-pip-bg) !important;
|
| 176 |
+
border-left: 3px solid var(--ff-accent) !important;
|
| 177 |
+
border-radius: 0 8px 8px 0 !important;
|
| 178 |
+
margin: 4px 0 !important;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.ff-chatbot .bot p {
|
| 182 |
+
color: var(--ff-text) !important;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
/* === Chat Input === */
|
| 186 |
+
.ff-chat-input textarea {
|
| 187 |
+
background: var(--ff-surface) !important;
|
| 188 |
+
border: 1px solid var(--ff-border) !important;
|
| 189 |
+
border-radius: 8px !important;
|
| 190 |
+
color: var(--ff-text) !important;
|
| 191 |
+
font-family: var(--ff-font-sans) !important;
|
| 192 |
+
padding: 12px !important;
|
| 193 |
+
resize: none !important;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.ff-chat-input textarea:focus {
|
| 197 |
+
border-color: var(--ff-accent) !important;
|
| 198 |
+
box-shadow: 0 0 15px var(--ff-accent-glow) !important;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
/* === Primary Buttons === */
|
| 202 |
+
button.primary,
|
| 203 |
+
button[variant="primary"] {
|
| 204 |
+
background: var(--ff-accent) !important;
|
| 205 |
+
color: var(--ff-bg) !important;
|
| 206 |
+
font-weight: 600 !important;
|
| 207 |
+
border: none !important;
|
| 208 |
+
border-radius: 8px !important;
|
| 209 |
+
padding: 10px 24px !important;
|
| 210 |
+
transition: all 0.25s ease !important;
|
| 211 |
+
font-size: 1em !important;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
button.primary:hover,
|
| 215 |
+
button[variant="primary"]:hover {
|
| 216 |
+
background: #f0c55a !important;
|
| 217 |
+
box-shadow: 0 4px 20px var(--ff-accent-glow) !important;
|
| 218 |
+
transform: translateY(-1px);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
button.primary:active,
|
| 222 |
+
button[variant="primary"]:active {
|
| 223 |
+
transform: translateY(0);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
/* === Secondary Buttons === */
|
| 227 |
+
button.secondary {
|
| 228 |
+
background: var(--ff-surface2) !important;
|
| 229 |
+
color: var(--ff-text) !important;
|
| 230 |
+
border: 1px solid var(--ff-border) !important;
|
| 231 |
+
border-radius: 8px !important;
|
| 232 |
+
padding: 10px 24px !important;
|
| 233 |
+
transition: all 0.25s ease !important;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
button.secondary:hover {
|
| 237 |
+
border-color: var(--ff-accent) !important;
|
| 238 |
+
color: var(--ff-accent) !important;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/* === Dropdowns & Sliders === */
|
| 242 |
+
select, .dropdown {
|
| 243 |
+
background: var(--ff-surface) !important;
|
| 244 |
+
border: 1px solid var(--ff-border) !important;
|
| 245 |
+
border-radius: 8px !important;
|
| 246 |
+
color: var(--ff-text) !important;
|
| 247 |
+
padding: 8px 12px !important;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
input[type="range"] {
|
| 251 |
+
accent-color: var(--ff-accent);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
/* === Group Panels === */
|
| 255 |
+
.gr-group {
|
| 256 |
+
background: var(--ff-surface) !important;
|
| 257 |
+
border: 1px solid var(--ff-border) !important;
|
| 258 |
+
border-radius: 12px !important;
|
| 259 |
+
padding: 16px !important;
|
| 260 |
+
margin-bottom: 12px !important;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
/* === Accordion === */
|
| 264 |
+
.gr-accordion {
|
| 265 |
+
background: var(--ff-surface) !important;
|
| 266 |
+
border: 1px solid var(--ff-border) !important;
|
| 267 |
+
border-radius: 12px !important;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* === Scrollbar === */
|
| 271 |
+
::-webkit-scrollbar {
|
| 272 |
+
width: 8px;
|
| 273 |
+
height: 8px;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
::-webkit-scrollbar-track {
|
| 277 |
+
background: var(--ff-bg);
|
| 278 |
+
border-radius: 4px;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
::-webkit-scrollbar-thumb {
|
| 282 |
+
background: var(--ff-border);
|
| 283 |
+
border-radius: 4px;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
::-webkit-scrollbar-thumb:hover {
|
| 287 |
+
background: var(--ff-accent);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/* === Footer (hide Gradio default) === */
|
| 291 |
+
footer {
|
| 292 |
+
display: none !important;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/* === Tabs (hidden for our custom UI) === */
|
| 296 |
+
.tabs {
|
| 297 |
+
border: none !important;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.tab-nav {
|
| 301 |
+
display: none !important;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/* === Responsive === */
|
| 305 |
+
@media (max-width: 768px) {
|
| 306 |
+
.ff-pip-panel {
|
| 307 |
+
min-height: 200px;
|
| 308 |
+
padding: 12px;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.ff-pip-art {
|
| 312 |
+
font-size: 10px !important;
|
| 313 |
+
min-height: 160px !important;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.ff-mode-bar {
|
| 317 |
+
flex-wrap: wrap;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.ff-mode-bar button {
|
| 321 |
+
font-size: 0.85em !important;
|
| 322 |
+
padding: 8px 12px !important;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.ff-timer {
|
| 326 |
+
font-size: 2.5em;
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
/* === Breathing animation === */
|
| 331 |
+
.ff-breathing-in {
|
| 332 |
+
animation: breatheIn 4s ease-in-out;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.ff-breathing-out {
|
| 336 |
+
animation: breatheOut 8s ease-in-out;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
@keyframes breatheIn {
|
| 340 |
+
from { transform: scale(1); }
|
| 341 |
+
to { transform: scale(1.05); }
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
@keyframes breatheOut {
|
| 345 |
+
from { transform: scale(1.05); }
|
| 346 |
+
to { transform: scale(1); }
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
/* === Focus complete celebration === */
|
| 350 |
+
.ff-celebrate {
|
| 351 |
+
animation: celebrate 0.5s ease-in-out 3;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
@keyframes celebrate {
|
| 355 |
+
0%, 100% { transform: translateY(0); }
|
| 356 |
+
50% { transform: translateY(-10px); }
|
| 357 |
+
}
|