Spaces:
Running
Running
Francesco Laiti
commited on
Commit
·
9793f25
0
Parent(s):
Initial commit
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +1 -0
- .gitignore +338 -0
- Dockerfile +42 -0
- README.md +16 -0
- backend/.gitkeep +1 -0
- backend/.python-version +1 -0
- backend/README.md +0 -0
- backend/app/__init__.py +0 -0
- backend/app/ai_manager.py +673 -0
- backend/app/crud.py +195 -0
- backend/app/database.py +50 -0
- backend/app/init_db.py +21 -0
- backend/app/main.py +957 -0
- backend/app/models.py +81 -0
- backend/app/schemas.py +216 -0
- backend/prompts.json +34 -0
- backend/pyproject.toml +25 -0
- backend/requirements.txt +69 -0
- backend/uv.lock +0 -0
- frontend/.gitignore +24 -0
- frontend/.gitkeep +1 -0
- frontend/README.md +69 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +43 -0
- frontend/public/quiet_room_extended.png +3 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.tsx +39 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/components/chat/MessageBubble.tsx +40 -0
- frontend/src/components/chat/TypingIndicator.tsx +18 -0
- frontend/src/components/common/DevelopmentBanner.tsx +67 -0
- frontend/src/components/common/Disclaimer.tsx +12 -0
- frontend/src/components/common/ErrorBoundary.tsx +86 -0
- frontend/src/components/common/ErrorDisplay.tsx +175 -0
- frontend/src/components/common/LoadingSpinner.tsx +72 -0
- frontend/src/components/common/MarkdownRenderer.tsx +75 -0
- frontend/src/components/common/ModelCapabilityError.tsx +133 -0
- frontend/src/components/home/AppOverview.tsx +51 -0
- frontend/src/components/home/InsightsSummary.tsx +150 -0
- frontend/src/components/home/PersonalGrowth.tsx +241 -0
- frontend/src/components/home/QuickActions.tsx +95 -0
- frontend/src/components/home/Statistics.tsx +120 -0
- frontend/src/components/home/WelcomeScreen.tsx +114 -0
- frontend/src/components/journal/AIAnalysisPanel.tsx +215 -0
- frontend/src/components/journal/AudioInput.tsx +243 -0
- frontend/src/components/journal/CrisisSupport.tsx +157 -0
- frontend/src/components/journal/EntryDetail.tsx +320 -0
- frontend/src/components/journal/FormattedTextPreview.tsx +64 -0
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,node,python
|
| 2 |
+
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,node,python
|
| 3 |
+
|
| 4 |
+
### Node ###
|
| 5 |
+
# Logs
|
| 6 |
+
logs
|
| 7 |
+
*.log
|
| 8 |
+
npm-debug.log*
|
| 9 |
+
yarn-debug.log*
|
| 10 |
+
yarn-error.log*
|
| 11 |
+
lerna-debug.log*
|
| 12 |
+
.pnpm-debug.log*
|
| 13 |
+
|
| 14 |
+
# Diagnostic reports (https://nodejs.org/api/report.html)
|
| 15 |
+
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
| 16 |
+
|
| 17 |
+
# Runtime data
|
| 18 |
+
pids
|
| 19 |
+
*.pid
|
| 20 |
+
*.seed
|
| 21 |
+
*.pid.lock
|
| 22 |
+
|
| 23 |
+
# Directory for instrumented libs generated by jscoverage/JSCover
|
| 24 |
+
lib-cov
|
| 25 |
+
|
| 26 |
+
# Coverage directory used by tools like istanbul
|
| 27 |
+
coverage
|
| 28 |
+
*.lcov
|
| 29 |
+
|
| 30 |
+
# nyc test coverage
|
| 31 |
+
.nyc_output
|
| 32 |
+
|
| 33 |
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
| 34 |
+
.grunt
|
| 35 |
+
|
| 36 |
+
# Bower dependency directory (https://bower.io/)
|
| 37 |
+
bower_components
|
| 38 |
+
|
| 39 |
+
# node-waf configuration
|
| 40 |
+
.lock-wscript
|
| 41 |
+
|
| 42 |
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
| 43 |
+
build/Release
|
| 44 |
+
|
| 45 |
+
# Dependency directories
|
| 46 |
+
node_modules/
|
| 47 |
+
jspm_packages/
|
| 48 |
+
|
| 49 |
+
# Snowpack dependency directory (https://snowpack.dev/)
|
| 50 |
+
web_modules/
|
| 51 |
+
|
| 52 |
+
# TypeScript cache
|
| 53 |
+
*.tsbuildinfo
|
| 54 |
+
|
| 55 |
+
# Optional npm cache directory
|
| 56 |
+
.npm
|
| 57 |
+
|
| 58 |
+
# Optional eslint cache
|
| 59 |
+
.eslintcache
|
| 60 |
+
|
| 61 |
+
# Optional stylelint cache
|
| 62 |
+
.stylelintcache
|
| 63 |
+
|
| 64 |
+
# Microbundle cache
|
| 65 |
+
.rpt2_cache/
|
| 66 |
+
.rts2_cache_cjs/
|
| 67 |
+
.rts2_cache_es/
|
| 68 |
+
.rts2_cache_umd/
|
| 69 |
+
|
| 70 |
+
# Optional REPL history
|
| 71 |
+
.node_repl_history
|
| 72 |
+
|
| 73 |
+
# Output of 'npm pack'
|
| 74 |
+
*.tgz
|
| 75 |
+
|
| 76 |
+
# Yarn Integrity file
|
| 77 |
+
.yarn-integrity
|
| 78 |
+
|
| 79 |
+
# dotenv environment variable files
|
| 80 |
+
.env
|
| 81 |
+
.env.development.local
|
| 82 |
+
.env.test.local
|
| 83 |
+
.env.production.local
|
| 84 |
+
.env.local
|
| 85 |
+
|
| 86 |
+
# parcel-bundler cache (https://parceljs.org/)
|
| 87 |
+
.cache
|
| 88 |
+
.parcel-cache
|
| 89 |
+
|
| 90 |
+
# Next.js build output
|
| 91 |
+
.next
|
| 92 |
+
out
|
| 93 |
+
|
| 94 |
+
# Nuxt.js build / generate output
|
| 95 |
+
.nuxt
|
| 96 |
+
dist
|
| 97 |
+
|
| 98 |
+
# Gatsby files
|
| 99 |
+
.cache/
|
| 100 |
+
# Comment in the public line in if your project uses Gatsby and not Next.js
|
| 101 |
+
# https://nextjs.org/blog/next-9-1#public-directory-support
|
| 102 |
+
# public
|
| 103 |
+
|
| 104 |
+
# vuepress build output
|
| 105 |
+
.vuepress/dist
|
| 106 |
+
|
| 107 |
+
# vuepress v2.x temp and cache directory
|
| 108 |
+
.temp
|
| 109 |
+
|
| 110 |
+
# Docusaurus cache and generated files
|
| 111 |
+
.docusaurus
|
| 112 |
+
|
| 113 |
+
# Serverless directories
|
| 114 |
+
.serverless/
|
| 115 |
+
|
| 116 |
+
# FuseBox cache
|
| 117 |
+
.fusebox/
|
| 118 |
+
|
| 119 |
+
# DynamoDB Local files
|
| 120 |
+
.dynamodb/
|
| 121 |
+
|
| 122 |
+
# TernJS port file
|
| 123 |
+
.tern-port
|
| 124 |
+
|
| 125 |
+
# Stores VSCode versions used for testing VSCode extensions
|
| 126 |
+
.vscode-test
|
| 127 |
+
|
| 128 |
+
# yarn v2
|
| 129 |
+
.yarn/cache
|
| 130 |
+
.yarn/unplugged
|
| 131 |
+
.yarn/build-state.yml
|
| 132 |
+
.yarn/install-state.gz
|
| 133 |
+
.pnp.*
|
| 134 |
+
|
| 135 |
+
### Node Patch ###
|
| 136 |
+
# Serverless Webpack directories
|
| 137 |
+
.webpack/
|
| 138 |
+
|
| 139 |
+
# Optional stylelint cache
|
| 140 |
+
|
| 141 |
+
# SvelteKit build / generate output
|
| 142 |
+
.svelte-kit
|
| 143 |
+
|
| 144 |
+
### Python ###
|
| 145 |
+
# Byte-compiled / optimized / DLL files
|
| 146 |
+
__pycache__/
|
| 147 |
+
*.py[cod]
|
| 148 |
+
*$py.class
|
| 149 |
+
|
| 150 |
+
# C extensions
|
| 151 |
+
*.so
|
| 152 |
+
|
| 153 |
+
# Distribution / packaging
|
| 154 |
+
.Python
|
| 155 |
+
build/
|
| 156 |
+
develop-eggs/
|
| 157 |
+
dist/
|
| 158 |
+
downloads/
|
| 159 |
+
eggs/
|
| 160 |
+
.eggs/
|
| 161 |
+
lib/
|
| 162 |
+
!frontend/src/lib
|
| 163 |
+
lib64/
|
| 164 |
+
parts/
|
| 165 |
+
sdist/
|
| 166 |
+
var/
|
| 167 |
+
wheels/
|
| 168 |
+
share/python-wheels/
|
| 169 |
+
*.egg-info/
|
| 170 |
+
.installed.cfg
|
| 171 |
+
*.egg
|
| 172 |
+
MANIFEST
|
| 173 |
+
|
| 174 |
+
# PyInstaller
|
| 175 |
+
# Usually these files are written by a python script from a template
|
| 176 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 177 |
+
*.manifest
|
| 178 |
+
*.spec
|
| 179 |
+
|
| 180 |
+
# Installer logs
|
| 181 |
+
pip-log.txt
|
| 182 |
+
pip-delete-this-directory.txt
|
| 183 |
+
|
| 184 |
+
# Unit test / coverage reports
|
| 185 |
+
htmlcov/
|
| 186 |
+
.tox/
|
| 187 |
+
.nox/
|
| 188 |
+
.coverage
|
| 189 |
+
.coverage.*
|
| 190 |
+
nosetests.xml
|
| 191 |
+
coverage.xml
|
| 192 |
+
*.cover
|
| 193 |
+
*.py,cover
|
| 194 |
+
.hypothesis/
|
| 195 |
+
.pytest_cache/
|
| 196 |
+
cover/
|
| 197 |
+
|
| 198 |
+
# Translations
|
| 199 |
+
*.mo
|
| 200 |
+
*.pot
|
| 201 |
+
|
| 202 |
+
# Django stuff:
|
| 203 |
+
local_settings.py
|
| 204 |
+
db.sqlite3
|
| 205 |
+
db.sqlite3-journal
|
| 206 |
+
|
| 207 |
+
# Flask stuff:
|
| 208 |
+
instance/
|
| 209 |
+
.webassets-cache
|
| 210 |
+
|
| 211 |
+
# Scrapy stuff:
|
| 212 |
+
.scrapy
|
| 213 |
+
|
| 214 |
+
# Sphinx documentation
|
| 215 |
+
docs/_build/
|
| 216 |
+
|
| 217 |
+
# PyBuilder
|
| 218 |
+
.pybuilder/
|
| 219 |
+
target/
|
| 220 |
+
|
| 221 |
+
# Jupyter Notebook
|
| 222 |
+
.ipynb_checkpoints
|
| 223 |
+
|
| 224 |
+
# IPython
|
| 225 |
+
profile_default/
|
| 226 |
+
ipython_config.py
|
| 227 |
+
|
| 228 |
+
# pyenv
|
| 229 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 230 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 231 |
+
# .python-version
|
| 232 |
+
|
| 233 |
+
# pipenv
|
| 234 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 235 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 236 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 237 |
+
# install all needed dependencies.
|
| 238 |
+
#Pipfile.lock
|
| 239 |
+
|
| 240 |
+
# poetry
|
| 241 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 242 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 243 |
+
# commonly ignored for libraries.
|
| 244 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 245 |
+
#poetry.lock
|
| 246 |
+
|
| 247 |
+
# pdm
|
| 248 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 249 |
+
#pdm.lock
|
| 250 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
| 251 |
+
# in version control.
|
| 252 |
+
# https://pdm.fming.dev/#use-with-ide
|
| 253 |
+
.pdm.toml
|
| 254 |
+
|
| 255 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 256 |
+
__pypackages__/
|
| 257 |
+
|
| 258 |
+
# Celery stuff
|
| 259 |
+
celerybeat-schedule
|
| 260 |
+
celerybeat.pid
|
| 261 |
+
|
| 262 |
+
# SageMath parsed files
|
| 263 |
+
*.sage.py
|
| 264 |
+
|
| 265 |
+
# Environments
|
| 266 |
+
.venv
|
| 267 |
+
env/
|
| 268 |
+
venv/
|
| 269 |
+
ENV/
|
| 270 |
+
env.bak/
|
| 271 |
+
venv.bak/
|
| 272 |
+
|
| 273 |
+
# Spyder project settings
|
| 274 |
+
.spyderproject
|
| 275 |
+
.spyproject
|
| 276 |
+
|
| 277 |
+
# Rope project settings
|
| 278 |
+
.ropeproject
|
| 279 |
+
|
| 280 |
+
# mkdocs documentation
|
| 281 |
+
/site
|
| 282 |
+
|
| 283 |
+
# mypy
|
| 284 |
+
.mypy_cache/
|
| 285 |
+
.dmypy.json
|
| 286 |
+
dmypy.json
|
| 287 |
+
|
| 288 |
+
# Pyre type checker
|
| 289 |
+
.pyre/
|
| 290 |
+
|
| 291 |
+
# pytype static type analyzer
|
| 292 |
+
.pytype/
|
| 293 |
+
|
| 294 |
+
# Cython debug symbols
|
| 295 |
+
cython_debug/
|
| 296 |
+
|
| 297 |
+
# PyCharm
|
| 298 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 299 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 300 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 301 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 302 |
+
#.idea/
|
| 303 |
+
|
| 304 |
+
### Python Patch ###
|
| 305 |
+
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
| 306 |
+
poetry.toml
|
| 307 |
+
|
| 308 |
+
# ruff
|
| 309 |
+
.ruff_cache/
|
| 310 |
+
|
| 311 |
+
# LSP config files
|
| 312 |
+
pyrightconfig.json
|
| 313 |
+
|
| 314 |
+
### VisualStudioCode ###
|
| 315 |
+
.vscode/*
|
| 316 |
+
!.vscode/settings.json
|
| 317 |
+
!.vscode/tasks.json
|
| 318 |
+
!.vscode/launch.json
|
| 319 |
+
!.vscode/extensions.json
|
| 320 |
+
!.vscode/*.code-snippets
|
| 321 |
+
|
| 322 |
+
# Local History for Visual Studio Code
|
| 323 |
+
.history/
|
| 324 |
+
|
| 325 |
+
# Built Visual Studio Code Extensions
|
| 326 |
+
*.vsix
|
| 327 |
+
|
| 328 |
+
### VisualStudioCode Patch ###
|
| 329 |
+
# Ignore all local history of files
|
| 330 |
+
.history
|
| 331 |
+
.ionide
|
| 332 |
+
|
| 333 |
+
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,node,python#
|
| 334 |
+
Hugging Face Spaces
|
| 335 |
+
.space/
|
| 336 |
+
*.db
|
| 337 |
+
*.sqlite
|
| 338 |
+
media/
|
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.12 as base image
|
| 2 |
+
FROM python:3.12
|
| 3 |
+
|
| 4 |
+
# Install system packages (Node.js) as root before switching users
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
curl \
|
| 7 |
+
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
| 8 |
+
&& apt-get install -y nodejs \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Create user and switch to it
|
| 12 |
+
RUN useradd -m -u 1000 user
|
| 13 |
+
USER user
|
| 14 |
+
|
| 15 |
+
ENV HOME=/home/user \
|
| 16 |
+
PATH=/home/user/.local/bin:$PATH
|
| 17 |
+
|
| 18 |
+
# Set working directory
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
|
| 21 |
+
# Copy project files
|
| 22 |
+
COPY --chown=user . .
|
| 23 |
+
|
| 24 |
+
# Install Python dependencies
|
| 25 |
+
WORKDIR /app/backend
|
| 26 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 27 |
+
|
| 28 |
+
# Install Node.js dependencies and build frontend
|
| 29 |
+
WORKDIR /app/frontend
|
| 30 |
+
RUN npm install && npm run build
|
| 31 |
+
|
| 32 |
+
# Copy built frontend files to backend static directory
|
| 33 |
+
RUN mkdir -p /app/backend/static && cp -r dist/* /app/backend/static/
|
| 34 |
+
|
| 35 |
+
# Set working directory to backend for running the app
|
| 36 |
+
WORKDIR /app/backend
|
| 37 |
+
|
| 38 |
+
# Expose port
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
# Start the application directly
|
| 42 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: QuietRoom
|
| 3 |
+
emoji: 📔
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
license: gpl-3.0
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# QuietRoom
|
| 12 |
+
|
| 13 |
+
A privacy-first, multimodal journaling webapp that helps you capture and reflect on your daily experiences through photos, videos, audio recordings, text, and location data. QuietRoom leverages AI models to provide intelligent insights while maintaining your privacy.
|
| 14 |
+
|
| 15 |
+
## Disclaimer
|
| 16 |
+
QuietRoom has been developed exclusively as a proof-of-concept for the "Google - The Gemma 3n Impact Challenge". It is not intended for use as a medical or therapeutic tool.
|
backend/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Backend directory placeholder
|
backend/.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12
|
backend/README.md
ADDED
|
File without changes
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/ai_manager.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Model Manager for LiteLLM integration."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Dict, Any, Optional, List
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
import json_repair
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
import litellm
|
| 14 |
+
from litellm import completion
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
class AIModelManager:
|
| 19 |
+
"""Manages AI model configuration and processing through LiteLLM."""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.current_model: Optional[str] = None
|
| 23 |
+
self.api_key: Optional[str] = None
|
| 24 |
+
self.prompts: Dict[str, Any] = {}
|
| 25 |
+
self.load_prompts()
|
| 26 |
+
|
| 27 |
+
# Configure LiteLLM logging
|
| 28 |
+
if litellm:
|
| 29 |
+
litellm.set_verbose = False
|
| 30 |
+
|
| 31 |
+
def load_prompts(self) -> None:
|
| 32 |
+
"""Load prompt templates from prompts.json file."""
|
| 33 |
+
try:
|
| 34 |
+
prompts_path = Path(__file__).parent.parent / "prompts.json"
|
| 35 |
+
with open(prompts_path, 'r', encoding='utf-8') as f:
|
| 36 |
+
self.prompts = json.load(f)
|
| 37 |
+
logger.info("Prompts loaded successfully")
|
| 38 |
+
except FileNotFoundError:
|
| 39 |
+
logger.error("prompts.json file not found")
|
| 40 |
+
self.prompts = {}
|
| 41 |
+
except json.JSONDecodeError as e:
|
| 42 |
+
logger.error("Error parsing prompts.json: %s", e)
|
| 43 |
+
self.prompts = {}
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error("Unexpected error loading prompts: %s", e)
|
| 46 |
+
self.prompts = {}
|
| 47 |
+
|
| 48 |
+
def configure_model(self, model_name: str, api_key: Optional[str] = None) -> bool:
|
| 49 |
+
"""Configure the AI model for use with LiteLLM."""
|
| 50 |
+
if not litellm:
|
| 51 |
+
logger.error("LiteLLM not available - please install litellm package")
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
self.current_model = model_name
|
| 56 |
+
self.api_key = api_key
|
| 57 |
+
|
| 58 |
+
# Set API key in environment if provided
|
| 59 |
+
if api_key:
|
| 60 |
+
if model_name.startswith("gemini/"):
|
| 61 |
+
os.environ["GEMINI_API_KEY"] = api_key
|
| 62 |
+
elif model_name.startswith("openai/"):
|
| 63 |
+
os.environ["OPENAI_API_KEY"] = api_key
|
| 64 |
+
# Add more model providers as needed
|
| 65 |
+
|
| 66 |
+
# Test the model configuration
|
| 67 |
+
test_response = self._make_completion(
|
| 68 |
+
messages=[{"role": "user", "content": "This is a test. Respond with RECEIVED"}],
|
| 69 |
+
max_tokens=10
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
if test_response:
|
| 73 |
+
logger.info("Model %s configured successfully", model_name)
|
| 74 |
+
return True
|
| 75 |
+
|
| 76 |
+
logger.error("Failed to configure model %s", model_name)
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.error("Error configuring model %s: %s", model_name, e)
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
def _make_completion(self, messages: List[Dict], max_tokens: int = 1000, temperature: float = 0.7, return_json_object = False) -> Optional[str]:
|
| 84 |
+
"""Make a completion request using LiteLLM."""
|
| 85 |
+
if not litellm or not self.current_model:
|
| 86 |
+
logger.error("LiteLLM not configured or model not set")
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
response = completion(
|
| 91 |
+
model=self.current_model,
|
| 92 |
+
messages=messages,
|
| 93 |
+
api_base="http://localhost:11434" if self.current_model.startswith("ollama/") else None,
|
| 94 |
+
# max_tokens=max_tokens,
|
| 95 |
+
# temperature=temperature
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
if response and response.choices:
|
| 99 |
+
logger.info(response.choices[0].message.content)
|
| 100 |
+
if return_json_object:
|
| 101 |
+
return json_repair.loads(response.choices[0].message.content)
|
| 102 |
+
else:
|
| 103 |
+
return response.choices[0].message.content
|
| 104 |
+
|
| 105 |
+
logger.error("No response content received from model")
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error("Error making completion request: %s", e)
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
def analyze_content(self, content: str, content_type: str, db_session=None) -> Dict[str, Any]:
|
| 113 |
+
"""Analyze content using the configured AI model."""
|
| 114 |
+
if not self.current_model:
|
| 115 |
+
return {
|
| 116 |
+
"success": False,
|
| 117 |
+
"error": "No AI model configured",
|
| 118 |
+
"analysis": {}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
# Select appropriate prompt based on content type
|
| 123 |
+
if content_type == "text":
|
| 124 |
+
prompt_key = "mood_analysis"
|
| 125 |
+
elif content_type == "image":
|
| 126 |
+
prompt_key = "image_analysis"
|
| 127 |
+
elif content_type == "audio":
|
| 128 |
+
prompt_key = "audio_transcription"
|
| 129 |
+
else:
|
| 130 |
+
return {
|
| 131 |
+
"success": False,
|
| 132 |
+
"error": f"Unsupported content type: {content_type}",
|
| 133 |
+
"analysis": {}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if prompt_key not in self.prompts:
|
| 137 |
+
return {
|
| 138 |
+
"success": False,
|
| 139 |
+
"error": f"Prompt template not found for {content_type}",
|
| 140 |
+
"analysis": {}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
prompt_template = self.prompts[prompt_key]
|
| 144 |
+
|
| 145 |
+
# Add personal context to system prompt if available
|
| 146 |
+
system_content = prompt_template["system"]
|
| 147 |
+
if db_session:
|
| 148 |
+
from .crud import UserConfigCRUD
|
| 149 |
+
personal_info = UserConfigCRUD.get(db_session, "personal_info")
|
| 150 |
+
cultural_context = UserConfigCRUD.get(db_session, "cultural_context")
|
| 151 |
+
|
| 152 |
+
if personal_info:
|
| 153 |
+
system_content += f"\n\nPersonal information about the user:\n{personal_info}\n"
|
| 154 |
+
if cultural_context:
|
| 155 |
+
system_content += f"\nCultural context:\n{cultural_context}\n"
|
| 156 |
+
|
| 157 |
+
# Prepare messages based on content type
|
| 158 |
+
if content_type == "image":
|
| 159 |
+
# For image analysis, use multimodal format
|
| 160 |
+
messages = [
|
| 161 |
+
{"role": "system", "content": system_content},
|
| 162 |
+
{
|
| 163 |
+
"role": "user",
|
| 164 |
+
"content": [
|
| 165 |
+
{"type": "text", "text": "Analyze this image in the context of a personal journal entry. Focus on mood, setting, people, activities, and emotional context."},
|
| 166 |
+
{
|
| 167 |
+
"type": "image_url",
|
| 168 |
+
"image_url": {
|
| 169 |
+
"url": f"data:image/jpeg;base64,{content}"
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
]
|
| 173 |
+
}
|
| 174 |
+
]
|
| 175 |
+
elif content_type == "audio":
|
| 176 |
+
# For audio analysis, use multimodal format
|
| 177 |
+
messages = [
|
| 178 |
+
{"role": "system", "content": system_content},
|
| 179 |
+
{
|
| 180 |
+
"role": "user",
|
| 181 |
+
"content": [
|
| 182 |
+
{"type": "text", "text": "Transcribe and analyze this audio recording for a personal journal entry. Maintain natural speech patterns and emotional tone."},
|
| 183 |
+
{
|
| 184 |
+
"type": "input_audio",
|
| 185 |
+
"input_audio": {"data": content, "format": "wav"}
|
| 186 |
+
}
|
| 187 |
+
]
|
| 188 |
+
}
|
| 189 |
+
]
|
| 190 |
+
else:
|
| 191 |
+
# For text analysis, use standard format
|
| 192 |
+
messages = [
|
| 193 |
+
{"role": "system", "content": system_content},
|
| 194 |
+
{"role": "user", "content": prompt_template["user"].format(content=content)}
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
# Make completion request with JSON output for structured responses
|
| 198 |
+
response = self._make_completion(messages, return_json_object=True)
|
| 199 |
+
|
| 200 |
+
if response:
|
| 201 |
+
return {
|
| 202 |
+
"success": True,
|
| 203 |
+
"error": None,
|
| 204 |
+
"analysis": response
|
| 205 |
+
}
|
| 206 |
+
else:
|
| 207 |
+
return {
|
| 208 |
+
"success": False,
|
| 209 |
+
"error": "No response from AI model",
|
| 210 |
+
"analysis": {}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.error("Error analyzing content: %s", e)
|
| 215 |
+
return {
|
| 216 |
+
"success": False,
|
| 217 |
+
"error": str(e),
|
| 218 |
+
"analysis": {}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
def generate_chat_response(self, message: str, context_entries: Optional[List[str]] = None, conversation_history: Optional[List[Dict[str, str]]] = None, db_session=None) -> Dict[str, Any]:
|
| 222 |
+
"""Generate a chat response using the configured AI model with conversation context."""
|
| 223 |
+
if not self.current_model:
|
| 224 |
+
return {
|
| 225 |
+
"success": False,
|
| 226 |
+
"error": "No AI model configured",
|
| 227 |
+
"response": ""
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
if "chat_system" not in self.prompts:
|
| 232 |
+
return {
|
| 233 |
+
"success": False,
|
| 234 |
+
"error": "Chat prompt template not found",
|
| 235 |
+
"response": ""
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
prompt_template = self.prompts["chat_system"]
|
| 239 |
+
|
| 240 |
+
# Prepare context from entries if provided
|
| 241 |
+
context = ""
|
| 242 |
+
if context_entries:
|
| 243 |
+
context = f"\n\nRelevant journal entries for context:\n{chr(10).join(context_entries)}\n"
|
| 244 |
+
|
| 245 |
+
# Add personal information and cultural context if available
|
| 246 |
+
personal_context = ""
|
| 247 |
+
if db_session:
|
| 248 |
+
from .crud import UserConfigCRUD
|
| 249 |
+
personal_info = UserConfigCRUD.get(db_session, "personal_info")
|
| 250 |
+
cultural_context = UserConfigCRUD.get(db_session, "cultural_context")
|
| 251 |
+
|
| 252 |
+
if personal_info:
|
| 253 |
+
personal_context += f"\n\nPersonal information about the user:\n{personal_info}\n"
|
| 254 |
+
if cultural_context:
|
| 255 |
+
personal_context += f"\nCultural context:\n{cultural_context}\n"
|
| 256 |
+
|
| 257 |
+
# Prepare messages with conversation history
|
| 258 |
+
messages = [
|
| 259 |
+
{"role": "system", "content": prompt_template["system"] + context + personal_context}
|
| 260 |
+
]
|
| 261 |
+
|
| 262 |
+
# Add conversation history (last 10 messages to maintain context while avoiding token limits)
|
| 263 |
+
if conversation_history:
|
| 264 |
+
recent_history = conversation_history[-10:] # Keep last 10 messages
|
| 265 |
+
for hist_msg in recent_history:
|
| 266 |
+
messages.append({
|
| 267 |
+
"role": hist_msg["role"],
|
| 268 |
+
"content": hist_msg["content"]
|
| 269 |
+
})
|
| 270 |
+
|
| 271 |
+
# Add current user message
|
| 272 |
+
messages.append({
|
| 273 |
+
"role": "user",
|
| 274 |
+
"content": message
|
| 275 |
+
})
|
| 276 |
+
|
| 277 |
+
# Make completion request
|
| 278 |
+
response = self._make_completion(messages, max_tokens=500)
|
| 279 |
+
|
| 280 |
+
if response:
|
| 281 |
+
return {
|
| 282 |
+
"success": True,
|
| 283 |
+
"error": None,
|
| 284 |
+
"response": response
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
return {
|
| 288 |
+
"success": False,
|
| 289 |
+
"error": "No response from AI model",
|
| 290 |
+
"response": ""
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
except Exception as e:
|
| 294 |
+
logger.error("Error generating chat response: %s", e)
|
| 295 |
+
return {
|
| 296 |
+
"success": False,
|
| 297 |
+
"error": str(e),
|
| 298 |
+
"response": ""
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
def generate_insights(self, entries: List[str]) -> Dict[str, Any]:
|
| 302 |
+
"""Generate insights from multiple journal entries."""
|
| 303 |
+
if not self.current_model:
|
| 304 |
+
return {
|
| 305 |
+
"success": False,
|
| 306 |
+
"error": "No AI model configured",
|
| 307 |
+
"insights": {}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
try:
|
| 311 |
+
if "insights_generation" not in self.prompts:
|
| 312 |
+
return {
|
| 313 |
+
"success": False,
|
| 314 |
+
"error": "Insights prompt template not found",
|
| 315 |
+
"insights": {}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
prompt_template = self.prompts["insights_generation"]
|
| 319 |
+
entries_text = "\n\n---\n\n".join(entries)
|
| 320 |
+
|
| 321 |
+
# Prepare messages
|
| 322 |
+
messages = [
|
| 323 |
+
{"role": "system", "content": prompt_template["system"]},
|
| 324 |
+
{"role": "user", "content": prompt_template["user"].format(entries=entries_text)}
|
| 325 |
+
]
|
| 326 |
+
|
| 327 |
+
# Make completion request with JSON output
|
| 328 |
+
response = self._make_completion(messages, max_tokens=800, return_json_object=True)
|
| 329 |
+
|
| 330 |
+
if response:
|
| 331 |
+
return {
|
| 332 |
+
"success": True,
|
| 333 |
+
"error": None,
|
| 334 |
+
"insights": response
|
| 335 |
+
}
|
| 336 |
+
else:
|
| 337 |
+
return {
|
| 338 |
+
"success": False,
|
| 339 |
+
"error": "No response from AI model",
|
| 340 |
+
"insights": {}
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
except Exception as e:
|
| 344 |
+
logger.error("Error generating insights: %s", e)
|
| 345 |
+
return {
|
| 346 |
+
"success": False,
|
| 347 |
+
"error": str(e),
|
| 348 |
+
"insights": {}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
def test_model_connection(self) -> Dict[str, Any]:
|
| 352 |
+
"""Test the current model configuration."""
|
| 353 |
+
if not self.current_model:
|
| 354 |
+
return {
|
| 355 |
+
"success": False,
|
| 356 |
+
"error": "No model configured",
|
| 357 |
+
"model": None
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
try:
|
| 361 |
+
test_response = self._make_completion(
|
| 362 |
+
messages=[{"role": "user", "content": "Respond with 'OK' if you can hear me."}],
|
| 363 |
+
max_tokens=10
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
if test_response:
|
| 367 |
+
return {
|
| 368 |
+
"success": True,
|
| 369 |
+
"error": None,
|
| 370 |
+
"model": self.current_model,
|
| 371 |
+
"response": test_response
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
return {
|
| 375 |
+
"success": False,
|
| 376 |
+
"error": "No response from model",
|
| 377 |
+
"model": self.current_model
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
except Exception as e:
|
| 381 |
+
return {
|
| 382 |
+
"success": False,
|
| 383 |
+
"error": str(e),
|
| 384 |
+
"model": self.current_model
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
def get_supported_models(self) -> List[str]:
|
| 388 |
+
"""Get list of supported AI models."""
|
| 389 |
+
return [
|
| 390 |
+
"gemini/gemma-3n-e2b-it",
|
| 391 |
+
"gemini/gemma-3n-e4b-it",
|
| 392 |
+
"gemini/gemini-2.5-flash",
|
| 393 |
+
"gemini/gemini-2.5-flash-lite",
|
| 394 |
+
"ollama/gemma3n:e4b",
|
| 395 |
+
"ollama/gemma3n:e2b",
|
| 396 |
+
]
|
| 397 |
+
|
| 398 |
+
def get_model_info(self) -> Dict[str, Any]:
|
| 399 |
+
"""Get information about the current model configuration."""
|
| 400 |
+
return {
|
| 401 |
+
"current_model": self.current_model,
|
| 402 |
+
"has_api_key": bool(self.api_key),
|
| 403 |
+
"supported_models": self.get_supported_models(),
|
| 404 |
+
"litellm_available": litellm is not None,
|
| 405 |
+
"environment_api_keys": {
|
| 406 |
+
"gemini": bool(os.environ.get("GEMINI_API_KEY")),
|
| 407 |
+
"openai": bool(os.environ.get("OPENAI_API_KEY"))
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
def summarize_conversation(self, conversation: List[Dict[str, str]], db_session=None) -> Dict[str, Any]:
|
| 412 |
+
"""Generate a journal entry summary from a chat conversation."""
|
| 413 |
+
if not self.current_model:
|
| 414 |
+
return {
|
| 415 |
+
"success": False,
|
| 416 |
+
"error": "No AI model configured",
|
| 417 |
+
"summary": {}
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
try:
|
| 421 |
+
if "conversation_summary" not in self.prompts:
|
| 422 |
+
return {
|
| 423 |
+
"success": False,
|
| 424 |
+
"error": "Conversation summary prompt template not found",
|
| 425 |
+
"summary": {}
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
prompt_template = self.prompts["conversation_summary"]
|
| 429 |
+
|
| 430 |
+
# Format conversation for the prompt
|
| 431 |
+
conversation_text = ""
|
| 432 |
+
for msg in conversation:
|
| 433 |
+
role = "User" if msg["role"] == "user" else "Assistant"
|
| 434 |
+
conversation_text += f"{role}: {msg['content']}\n\n"
|
| 435 |
+
|
| 436 |
+
# Add personal context if available
|
| 437 |
+
personal_context = ""
|
| 438 |
+
if db_session:
|
| 439 |
+
from .crud import UserConfigCRUD
|
| 440 |
+
personal_info = UserConfigCRUD.get(db_session, "personal_info")
|
| 441 |
+
cultural_context = UserConfigCRUD.get(db_session, "cultural_context")
|
| 442 |
+
|
| 443 |
+
if personal_info:
|
| 444 |
+
personal_context += f"\n\nPersonal information about the user:\n{personal_info}\n"
|
| 445 |
+
if cultural_context:
|
| 446 |
+
personal_context += f"\nCultural context:\n{cultural_context}\n"
|
| 447 |
+
|
| 448 |
+
# Prepare messages
|
| 449 |
+
messages = [
|
| 450 |
+
{"role": "system", "content": prompt_template["system"] + personal_context},
|
| 451 |
+
{"role": "user", "content": prompt_template["user"].format(conversation=conversation_text)}
|
| 452 |
+
]
|
| 453 |
+
|
| 454 |
+
# Make completion request with JSON output
|
| 455 |
+
response = self._make_completion(messages, max_tokens=800, return_json_object=True)
|
| 456 |
+
|
| 457 |
+
if response:
|
| 458 |
+
return {
|
| 459 |
+
"success": True,
|
| 460 |
+
"error": None,
|
| 461 |
+
"summary": response
|
| 462 |
+
}
|
| 463 |
+
else:
|
| 464 |
+
return {
|
| 465 |
+
"success": False,
|
| 466 |
+
"error": "No response from AI model",
|
| 467 |
+
"summary": {}
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
except Exception as e:
|
| 471 |
+
logger.error("Error summarizing conversation: %s", e)
|
| 472 |
+
return {
|
| 473 |
+
"success": False,
|
| 474 |
+
"error": str(e),
|
| 475 |
+
"summary": {}
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
def detect_crisis(self, content: str, db_session=None) -> Dict[str, Any]:
|
| 479 |
+
"""Detect potential crisis or emotional distress in journal content."""
|
| 480 |
+
if not self.current_model:
|
| 481 |
+
return {
|
| 482 |
+
"success": False,
|
| 483 |
+
"error": "No AI model configured",
|
| 484 |
+
"crisis_detected": False
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
try:
|
| 488 |
+
if "crisis_detection" not in self.prompts:
|
| 489 |
+
return {
|
| 490 |
+
"success": False,
|
| 491 |
+
"error": "Crisis detection prompt template not found",
|
| 492 |
+
"crisis_detected": False
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
prompt_template = self.prompts["crisis_detection"]
|
| 496 |
+
|
| 497 |
+
# Prepare messages for crisis detection
|
| 498 |
+
messages = [
|
| 499 |
+
{"role": "system", "content": prompt_template["system"]},
|
| 500 |
+
{"role": "user", "content": prompt_template["user"].format(content=content)}
|
| 501 |
+
]
|
| 502 |
+
|
| 503 |
+
# Make completion request with JSON output
|
| 504 |
+
response = self._make_completion(messages, max_tokens=200, return_json_object=True)
|
| 505 |
+
|
| 506 |
+
if response:
|
| 507 |
+
return {
|
| 508 |
+
"success": True,
|
| 509 |
+
"error": None,
|
| 510 |
+
"crisis_detected": response.get("critical", False),
|
| 511 |
+
"severity": response.get("severity", "low"),
|
| 512 |
+
"reason": response.get("reason", "")
|
| 513 |
+
}
|
| 514 |
+
else:
|
| 515 |
+
return {
|
| 516 |
+
"success": False,
|
| 517 |
+
"error": "No response from AI model",
|
| 518 |
+
"crisis_detected": False
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
except Exception as e:
|
| 522 |
+
logger.error("Error in crisis detection: %s", e)
|
| 523 |
+
return {
|
| 524 |
+
"success": False,
|
| 525 |
+
"error": str(e),
|
| 526 |
+
"crisis_detected": False
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
def analyze_psychological_image(self, image_data: str, context_text: str = "", db_session=None) -> Dict[str, Any]:
|
| 530 |
+
"""Analyze image from a psychological perspective for therapeutic journaling."""
|
| 531 |
+
if not self.current_model:
|
| 532 |
+
return {
|
| 533 |
+
"success": False,
|
| 534 |
+
"error": "No AI model configured",
|
| 535 |
+
"analysis": {}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
try:
|
| 539 |
+
if "image_analysis" not in self.prompts:
|
| 540 |
+
return {
|
| 541 |
+
"success": False,
|
| 542 |
+
"error": "Image analysis prompt template not found",
|
| 543 |
+
"analysis": {}
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
prompt_template = self.prompts["image_analysis"]
|
| 547 |
+
|
| 548 |
+
# Add personal context if available
|
| 549 |
+
personal_context = ""
|
| 550 |
+
if db_session:
|
| 551 |
+
from .crud import UserConfigCRUD
|
| 552 |
+
personal_info = UserConfigCRUD.get(db_session, "personal_info")
|
| 553 |
+
cultural_context = UserConfigCRUD.get(db_session, "cultural_context")
|
| 554 |
+
|
| 555 |
+
if personal_info:
|
| 556 |
+
personal_context += f"\n\nPersonal information about the user:\n{personal_info}\n"
|
| 557 |
+
if cultural_context:
|
| 558 |
+
personal_context += f"\nCultural context:\n{cultural_context}\n"
|
| 559 |
+
|
| 560 |
+
# Add text context if provided
|
| 561 |
+
if context_text:
|
| 562 |
+
personal_context += f"\nJournal entry context: {context_text}\n"
|
| 563 |
+
|
| 564 |
+
# Prepare multimodal message
|
| 565 |
+
messages = [
|
| 566 |
+
{"role": "system", "content": prompt_template["system"] + personal_context},
|
| 567 |
+
{
|
| 568 |
+
"role": "user",
|
| 569 |
+
"content": [
|
| 570 |
+
{"type": "text", "text": prompt_template["user"]},
|
| 571 |
+
{
|
| 572 |
+
"type": "image_url",
|
| 573 |
+
"image_url": {
|
| 574 |
+
"url": f"data:image/jpeg;base64,{image_data}"
|
| 575 |
+
}
|
| 576 |
+
}
|
| 577 |
+
]
|
| 578 |
+
}
|
| 579 |
+
]
|
| 580 |
+
|
| 581 |
+
# Make completion request with JSON output
|
| 582 |
+
response = self._make_completion(messages, max_tokens=800, return_json_object=True)
|
| 583 |
+
|
| 584 |
+
if response:
|
| 585 |
+
return {
|
| 586 |
+
"success": True,
|
| 587 |
+
"error": None,
|
| 588 |
+
"analysis": response
|
| 589 |
+
}
|
| 590 |
+
else:
|
| 591 |
+
return {
|
| 592 |
+
"success": False,
|
| 593 |
+
"error": "No response from AI model",
|
| 594 |
+
"analysis": {}
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
except Exception as e:
|
| 598 |
+
logger.error("Error in psychological image analysis: %s", e)
|
| 599 |
+
return {
|
| 600 |
+
"success": False,
|
| 601 |
+
"error": str(e),
|
| 602 |
+
"analysis": {}
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
def generate_motivational_support(self, content: str, db_session=None) -> Dict[str, Any]:
|
| 606 |
+
"""Generate motivational support and suggestions for users in distress."""
|
| 607 |
+
if not self.current_model:
|
| 608 |
+
return {
|
| 609 |
+
"success": False,
|
| 610 |
+
"error": "No AI model configured",
|
| 611 |
+
"support": {}
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
try:
|
| 615 |
+
if "motivational_support" not in self.prompts:
|
| 616 |
+
return {
|
| 617 |
+
"success": False,
|
| 618 |
+
"error": "Motivational support prompt template not found",
|
| 619 |
+
"support": {}
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
prompt_template = self.prompts["motivational_support"]
|
| 623 |
+
|
| 624 |
+
# Add personal context if available
|
| 625 |
+
personal_context = ""
|
| 626 |
+
if db_session:
|
| 627 |
+
from .crud import UserConfigCRUD
|
| 628 |
+
personal_info = UserConfigCRUD.get(db_session, "personal_info")
|
| 629 |
+
cultural_context = UserConfigCRUD.get(db_session, "cultural_context")
|
| 630 |
+
|
| 631 |
+
if personal_info:
|
| 632 |
+
personal_context += f"\n\nPersonal information about the user:\n{personal_info}\n"
|
| 633 |
+
if cultural_context:
|
| 634 |
+
personal_context += f"\nCultural context:\n{cultural_context}\n"
|
| 635 |
+
|
| 636 |
+
# Prepare messages
|
| 637 |
+
messages = [
|
| 638 |
+
{"role": "system", "content": prompt_template["system"] + personal_context},
|
| 639 |
+
{"role": "user", "content": prompt_template["user"].format(content=content)}
|
| 640 |
+
]
|
| 641 |
+
|
| 642 |
+
# Make completion request with JSON output
|
| 643 |
+
response = self._make_completion(messages, max_tokens=600, return_json_object=True)
|
| 644 |
+
|
| 645 |
+
if response:
|
| 646 |
+
return {
|
| 647 |
+
"success": True,
|
| 648 |
+
"error": None,
|
| 649 |
+
"support": response
|
| 650 |
+
}
|
| 651 |
+
else:
|
| 652 |
+
return {
|
| 653 |
+
"success": False,
|
| 654 |
+
"error": "No response from AI model",
|
| 655 |
+
"support": {}
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
except Exception as e:
|
| 659 |
+
logger.error("Error generating motivational support: %s", e)
|
| 660 |
+
return {
|
| 661 |
+
"success": False,
|
| 662 |
+
"error": str(e),
|
| 663 |
+
"support": {}
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
def initialize_from_config(self, model_name: Optional[str], api_key: Optional[str]) -> bool:
|
| 667 |
+
"""Initialize AI manager with stored configuration."""
|
| 668 |
+
if model_name:
|
| 669 |
+
return self.configure_model(model_name, api_key)
|
| 670 |
+
return False
|
| 671 |
+
|
| 672 |
+
# Global AI manager instance
|
| 673 |
+
ai_manager = AIModelManager()
|
backend/app/crud.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CRUD operations for QuietRoom models."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from sqlalchemy import desc, and_, or_
|
| 5 |
+
from typing import List, Optional, Dict, Any
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
from . import models
|
| 10 |
+
|
| 11 |
+
class EntryCRUD:
|
| 12 |
+
"""CRUD operations for journal entries."""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def create(db: Session, entry_data: Dict[str, Any]) -> models.Entry:
|
| 16 |
+
"""Create a new journal entry."""
|
| 17 |
+
db_entry = models.Entry(**entry_data)
|
| 18 |
+
db.add(db_entry)
|
| 19 |
+
db.commit()
|
| 20 |
+
db.refresh(db_entry)
|
| 21 |
+
return db_entry
|
| 22 |
+
|
| 23 |
+
@staticmethod
|
| 24 |
+
def get(db: Session, entry_id: str) -> Optional[models.Entry]:
|
| 25 |
+
"""Get a journal entry by ID."""
|
| 26 |
+
return db.query(models.Entry).filter(models.Entry.id == entry_id).first()
|
| 27 |
+
|
| 28 |
+
@staticmethod
|
| 29 |
+
def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[models.Entry]:
|
| 30 |
+
"""Get all journal entries with pagination."""
|
| 31 |
+
return db.query(models.Entry).order_by(desc(models.Entry.created_at)).offset(skip).limit(limit).all()
|
| 32 |
+
|
| 33 |
+
@staticmethod
|
| 34 |
+
def update(db: Session, entry_id: str, entry_data: Dict[str, Any]) -> Optional[models.Entry]:
|
| 35 |
+
"""Update a journal entry."""
|
| 36 |
+
db_entry = db.query(models.Entry).filter(models.Entry.id == entry_id).first()
|
| 37 |
+
if db_entry:
|
| 38 |
+
for key, value in entry_data.items():
|
| 39 |
+
setattr(db_entry, key, value)
|
| 40 |
+
db_entry.updated_at = datetime.now(timezone.utc)
|
| 41 |
+
db.commit()
|
| 42 |
+
db.refresh(db_entry)
|
| 43 |
+
return db_entry
|
| 44 |
+
|
| 45 |
+
@staticmethod
|
| 46 |
+
def delete(db: Session, entry_id: str) -> bool:
|
| 47 |
+
"""Delete a journal entry."""
|
| 48 |
+
db_entry = db.query(models.Entry).filter(models.Entry.id == entry_id).first()
|
| 49 |
+
if db_entry:
|
| 50 |
+
db.delete(db_entry)
|
| 51 |
+
db.commit()
|
| 52 |
+
return True
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
def search(db: Session, query: str, skip: int = 0, limit: int = 100) -> List[models.Entry]:
|
| 57 |
+
"""Search entries by content or title."""
|
| 58 |
+
return db.query(models.Entry).filter(
|
| 59 |
+
or_(
|
| 60 |
+
models.Entry.content.contains(query),
|
| 61 |
+
models.Entry.title.contains(query)
|
| 62 |
+
)
|
| 63 |
+
).order_by(desc(models.Entry.created_at)).offset(skip).limit(limit).all()
|
| 64 |
+
|
| 65 |
+
class MediaFileCRUD:
|
| 66 |
+
"""CRUD operations for media files."""
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
def create(db: Session, media_data: Dict[str, Any]) -> models.MediaFile:
|
| 70 |
+
"""Create a new media file record."""
|
| 71 |
+
db_media = models.MediaFile(**media_data)
|
| 72 |
+
db.add(db_media)
|
| 73 |
+
db.commit()
|
| 74 |
+
db.refresh(db_media)
|
| 75 |
+
return db_media
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
def get(db: Session, media_id: str) -> Optional[models.MediaFile]:
|
| 79 |
+
"""Get a media file by ID."""
|
| 80 |
+
return db.query(models.MediaFile).filter(models.MediaFile.id == media_id).first()
|
| 81 |
+
|
| 82 |
+
@staticmethod
|
| 83 |
+
def get_by_entry(db: Session, entry_id: str) -> List[models.MediaFile]:
|
| 84 |
+
"""Get all media files for an entry."""
|
| 85 |
+
return db.query(models.MediaFile).filter(models.MediaFile.entry_id == entry_id).all()
|
| 86 |
+
|
| 87 |
+
@staticmethod
|
| 88 |
+
def delete(db: Session, media_id: str) -> bool:
|
| 89 |
+
"""Delete a media file record."""
|
| 90 |
+
db_media = db.query(models.MediaFile).filter(models.MediaFile.id == media_id).first()
|
| 91 |
+
if db_media:
|
| 92 |
+
db.delete(db_media)
|
| 93 |
+
db.commit()
|
| 94 |
+
return True
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
class TagCRUD:
|
| 98 |
+
"""CRUD operations for tags."""
|
| 99 |
+
|
| 100 |
+
@staticmethod
|
| 101 |
+
def create(db: Session, tag_data: Dict[str, Any]) -> models.Tag:
|
| 102 |
+
"""Create a new tag."""
|
| 103 |
+
db_tag = models.Tag(**tag_data)
|
| 104 |
+
db.add(db_tag)
|
| 105 |
+
db.commit()
|
| 106 |
+
db.refresh(db_tag)
|
| 107 |
+
return db_tag
|
| 108 |
+
|
| 109 |
+
@staticmethod
|
| 110 |
+
def get(db: Session, tag_id: str) -> Optional[models.Tag]:
|
| 111 |
+
"""Get a tag by ID."""
|
| 112 |
+
return db.query(models.Tag).filter(models.Tag.id == tag_id).first()
|
| 113 |
+
|
| 114 |
+
@staticmethod
|
| 115 |
+
def get_by_name(db: Session, name: str) -> Optional[models.Tag]:
|
| 116 |
+
"""Get a tag by name."""
|
| 117 |
+
return db.query(models.Tag).filter(models.Tag.name == name).first()
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
def get_all(db: Session) -> List[models.Tag]:
|
| 121 |
+
"""Get all tags."""
|
| 122 |
+
return db.query(models.Tag).order_by(models.Tag.name).all()
|
| 123 |
+
|
| 124 |
+
@staticmethod
|
| 125 |
+
def delete(db: Session, tag_id: str) -> bool:
|
| 126 |
+
"""Delete a tag."""
|
| 127 |
+
db_tag = db.query(models.Tag).filter(models.Tag.id == tag_id).first()
|
| 128 |
+
if db_tag:
|
| 129 |
+
db.delete(db_tag)
|
| 130 |
+
db.commit()
|
| 131 |
+
return True
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
class ChatMessageCRUD:
|
| 135 |
+
"""CRUD operations for chat messages."""
|
| 136 |
+
|
| 137 |
+
@staticmethod
|
| 138 |
+
def create(db: Session, message_data: Dict[str, Any]) -> models.ChatMessage:
|
| 139 |
+
"""Create a new chat message."""
|
| 140 |
+
db_message = models.ChatMessage(**message_data)
|
| 141 |
+
db.add(db_message)
|
| 142 |
+
db.commit()
|
| 143 |
+
db.refresh(db_message)
|
| 144 |
+
return db_message
|
| 145 |
+
|
| 146 |
+
@staticmethod
|
| 147 |
+
def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[models.ChatMessage]:
|
| 148 |
+
"""Get all chat messages with pagination."""
|
| 149 |
+
return db.query(models.ChatMessage).order_by(models.ChatMessage.created_at).offset(skip).limit(limit).all()
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def delete_all(db: Session) -> bool:
|
| 153 |
+
"""Delete all chat messages."""
|
| 154 |
+
db.query(models.ChatMessage).delete()
|
| 155 |
+
db.commit()
|
| 156 |
+
return True
|
| 157 |
+
|
| 158 |
+
class UserConfigCRUD:
|
| 159 |
+
"""CRUD operations for user configuration."""
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
def set(db: Session, key: str, value: str) -> models.UserConfig:
|
| 163 |
+
"""Set a configuration value."""
|
| 164 |
+
db_config = db.query(models.UserConfig).filter(models.UserConfig.key == key).first()
|
| 165 |
+
if db_config:
|
| 166 |
+
db_config.value = value
|
| 167 |
+
db_config.updated_at = datetime.now(timezone.utc)
|
| 168 |
+
else:
|
| 169 |
+
db_config = models.UserConfig(key=key, value=value)
|
| 170 |
+
db.add(db_config)
|
| 171 |
+
db.commit()
|
| 172 |
+
db.refresh(db_config)
|
| 173 |
+
return db_config
|
| 174 |
+
|
| 175 |
+
@staticmethod
|
| 176 |
+
def get(db: Session, key: str) -> Optional[str]:
|
| 177 |
+
"""Get a configuration value."""
|
| 178 |
+
db_config = db.query(models.UserConfig).filter(models.UserConfig.key == key).first()
|
| 179 |
+
return db_config.value if db_config else None
|
| 180 |
+
|
| 181 |
+
@staticmethod
|
| 182 |
+
def get_all(db: Session) -> Dict[str, str]:
|
| 183 |
+
"""Get all configuration values."""
|
| 184 |
+
configs = db.query(models.UserConfig).all()
|
| 185 |
+
return {config.key: config.value for config in configs}
|
| 186 |
+
|
| 187 |
+
@staticmethod
|
| 188 |
+
def delete(db: Session, key: str) -> bool:
|
| 189 |
+
"""Delete a configuration value."""
|
| 190 |
+
db_config = db.query(models.UserConfig).filter(models.UserConfig.key == key).first()
|
| 191 |
+
if db_config:
|
| 192 |
+
db.delete(db_config)
|
| 193 |
+
db.commit()
|
| 194 |
+
return True
|
| 195 |
+
return False
|
backend/app/database.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database configuration and session management."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import create_engine
|
| 4 |
+
from sqlalchemy.orm import declarative_base
|
| 5 |
+
from sqlalchemy.orm import sessionmaker
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# Database URL - using SQLite for local storage
|
| 9 |
+
# For Hugging Face Spaces, use persistent /data directory if available, fallback to /tmp
|
| 10 |
+
if os.getenv("SPACE_ID"): # Hugging Face Spaces environment
|
| 11 |
+
if os.path.exists("/data"):
|
| 12 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/quietroom_tmp.db")
|
| 13 |
+
else:
|
| 14 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////tmp/quietroom_tmp.db")
|
| 15 |
+
else:
|
| 16 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./quietroom_tmp.db")
|
| 17 |
+
|
| 18 |
+
# Create engine
|
| 19 |
+
engine = create_engine(
|
| 20 |
+
DATABASE_URL,
|
| 21 |
+
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Create session factory
|
| 25 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 26 |
+
|
| 27 |
+
# Create base class for models
|
| 28 |
+
Base = declarative_base()
|
| 29 |
+
|
| 30 |
+
def get_db():
|
| 31 |
+
"""Dependency to get database session."""
|
| 32 |
+
db = SessionLocal()
|
| 33 |
+
try:
|
| 34 |
+
yield db
|
| 35 |
+
finally:
|
| 36 |
+
db.close()
|
| 37 |
+
|
| 38 |
+
def get_media_directory():
|
| 39 |
+
"""Get the appropriate media directory based on environment."""
|
| 40 |
+
if os.getenv("SPACE_ID"): # Hugging Face Spaces environment
|
| 41 |
+
if os.path.exists("/data"):
|
| 42 |
+
return "/data/media"
|
| 43 |
+
else:
|
| 44 |
+
return "/tmp/media"
|
| 45 |
+
else:
|
| 46 |
+
return "media"
|
| 47 |
+
|
| 48 |
+
def init_db():
|
| 49 |
+
"""Initialize database tables."""
|
| 50 |
+
Base.metadata.create_all(bind=engine)
|
backend/app/init_db.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database initialization script."""
|
| 2 |
+
|
| 3 |
+
from .database import init_db, engine
|
| 4 |
+
from .models import Base
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logging.basicConfig(level=logging.INFO)
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
def create_tables():
|
| 11 |
+
"""Create all database tables."""
|
| 12 |
+
try:
|
| 13 |
+
logger.info("Creating database tables...")
|
| 14 |
+
Base.metadata.create_all(bind=engine)
|
| 15 |
+
logger.info("Database tables created successfully!")
|
| 16 |
+
except Exception as e:
|
| 17 |
+
logger.error(f"Error creating database tables: {e}")
|
| 18 |
+
raise
|
| 19 |
+
|
| 20 |
+
if __name__ == "__main__":
|
| 21 |
+
create_tables()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main FastAPI application."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
import json
|
| 7 |
+
import base64
|
| 8 |
+
from contextlib import asynccontextmanager
|
| 9 |
+
from typing import List, Optional, Dict, Any
|
| 10 |
+
|
| 11 |
+
from fastapi import FastAPI, HTTPException, Depends, status, File, UploadFile
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 14 |
+
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
from sqlalchemy.orm import Session
|
| 16 |
+
|
| 17 |
+
from .database import get_db, init_db, SessionLocal, get_media_directory
|
| 18 |
+
from .crud import EntryCRUD, MediaFileCRUD, UserConfigCRUD
|
| 19 |
+
from .ai_manager import ai_manager
|
| 20 |
+
from . import schemas, models
|
| 21 |
+
|
| 22 |
+
# Configure logging
|
| 23 |
+
logging.basicConfig(level=logging.INFO)
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
@asynccontextmanager
|
| 27 |
+
async def lifespan(app: FastAPI):
|
| 28 |
+
"""Application lifespan manager."""
|
| 29 |
+
# Startup
|
| 30 |
+
try:
|
| 31 |
+
init_db()
|
| 32 |
+
logger.info("Database initialized successfully")
|
| 33 |
+
|
| 34 |
+
# Initialize AI manager with stored configuration
|
| 35 |
+
db = SessionLocal()
|
| 36 |
+
try:
|
| 37 |
+
model_name = UserConfigCRUD.get(db, "ai_model")
|
| 38 |
+
api_key = UserConfigCRUD.get(db, "ai_api_key")
|
| 39 |
+
|
| 40 |
+
# Set default model if none is configured
|
| 41 |
+
if not model_name:
|
| 42 |
+
default_model = "gemini/gemma-3n-e4b-it"
|
| 43 |
+
UserConfigCRUD.set(db, "ai_model", default_model)
|
| 44 |
+
model_name = default_model
|
| 45 |
+
logger.info("Set default AI model to %s", default_model)
|
| 46 |
+
|
| 47 |
+
# Override current model to use the desired default
|
| 48 |
+
if model_name == "ollama/gemma3n:e4b":
|
| 49 |
+
default_model = "gemini/gemma-3n-e4b-it"
|
| 50 |
+
UserConfigCRUD.set(db, "ai_model", default_model)
|
| 51 |
+
model_name = default_model
|
| 52 |
+
logger.info("Updated AI model from Ollama to %s", default_model)
|
| 53 |
+
|
| 54 |
+
if model_name:
|
| 55 |
+
ai_manager.initialize_from_config(model_name, api_key)
|
| 56 |
+
logger.info("AI manager initialized with stored configuration")
|
| 57 |
+
finally:
|
| 58 |
+
db.close()
|
| 59 |
+
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error("Failed to initialize application: %s", e)
|
| 62 |
+
raise
|
| 63 |
+
|
| 64 |
+
yield
|
| 65 |
+
|
| 66 |
+
# Shutdown (if needed)
|
| 67 |
+
logger.info("Application shutting down")
|
| 68 |
+
|
| 69 |
+
# Create FastAPI app
|
| 70 |
+
app = FastAPI(
|
| 71 |
+
title="QuietRoom API",
|
| 72 |
+
description="Privacy-first multimodal journaling app API",
|
| 73 |
+
version="0.1.0",
|
| 74 |
+
lifespan=lifespan
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Configure CORS
|
| 78 |
+
app.add_middleware(
|
| 79 |
+
CORSMiddleware,
|
| 80 |
+
allow_origins=["*"], # Allow all origins for Hugging Face Spaces deployment
|
| 81 |
+
allow_credentials=True,
|
| 82 |
+
allow_methods=["*"],
|
| 83 |
+
allow_headers=["*"],
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Mount static files (for serving the built frontend)
|
| 87 |
+
# Note: This should be at the end of the file, after all API routes are defined
|
| 88 |
+
# if os.path.exists("static"):
|
| 89 |
+
# app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# Global exception handler
|
| 94 |
+
@app.exception_handler(Exception)
|
| 95 |
+
async def global_exception_handler(request, exc):
|
| 96 |
+
"""Global exception handler for unhandled errors."""
|
| 97 |
+
logger.error("Unhandled error: %s", exc)
|
| 98 |
+
return JSONResponse(
|
| 99 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 100 |
+
content={
|
| 101 |
+
"error": {
|
| 102 |
+
"code": "INTERNAL_SERVER_ERROR",
|
| 103 |
+
"message": "An internal server error occurred",
|
| 104 |
+
"details": str(exc) if app.debug else "Contact support if the problem persists",
|
| 105 |
+
"timestamp": "2025-01-31T10:30:00Z"
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Health check endpoint
|
| 111 |
+
@app.get("/health")
|
| 112 |
+
async def health_check():
|
| 113 |
+
"""Health check endpoint."""
|
| 114 |
+
return {"status": "healthy", "service": "quietroom-api"}
|
| 115 |
+
|
| 116 |
+
# Entry endpoints
|
| 117 |
+
@app.post("/api/entries", response_model=schemas.EntryResponse)
|
| 118 |
+
async def create_entry(entry: schemas.EntryCreate, db: Session = Depends(get_db)):
|
| 119 |
+
"""Create a new journal entry."""
|
| 120 |
+
try:
|
| 121 |
+
db_entry = EntryCRUD.create(db, entry.model_dump())
|
| 122 |
+
return db_entry
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.error("Error creating entry: %s", e)
|
| 125 |
+
raise HTTPException(
|
| 126 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 127 |
+
detail="Failed to create entry"
|
| 128 |
+
) from e
|
| 129 |
+
|
| 130 |
+
@app.get("/api/entries", response_model=List[schemas.EntryResponse])
|
| 131 |
+
async def get_entries(
|
| 132 |
+
skip: int = 0,
|
| 133 |
+
limit: int = 100,
|
| 134 |
+
search: Optional[str] = None,
|
| 135 |
+
db: Session = Depends(get_db)
|
| 136 |
+
):
|
| 137 |
+
"""Get journal entries with optional search and pagination."""
|
| 138 |
+
try:
|
| 139 |
+
if search:
|
| 140 |
+
entries = EntryCRUD.search(db, search, skip, limit)
|
| 141 |
+
else:
|
| 142 |
+
entries = EntryCRUD.get_all(db, skip, limit)
|
| 143 |
+
return entries
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.error("Error retrieving entries: %s", e)
|
| 146 |
+
raise HTTPException(
|
| 147 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 148 |
+
detail="Failed to retrieve entries"
|
| 149 |
+
) from e
|
| 150 |
+
|
| 151 |
+
@app.get("/api/entries/statistics", response_model=schemas.AppStatistics)
|
| 152 |
+
async def get_statistics(db: Session = Depends(get_db)):
|
| 153 |
+
"""Get app statistics."""
|
| 154 |
+
try:
|
| 155 |
+
from sqlalchemy import func, extract
|
| 156 |
+
from datetime import datetime, timedelta
|
| 157 |
+
|
| 158 |
+
# Get basic counts
|
| 159 |
+
total_entries = db.query(models.Entry).count()
|
| 160 |
+
|
| 161 |
+
# Calculate total words (approximate)
|
| 162 |
+
entries = db.query(models.Entry).all()
|
| 163 |
+
total_words = sum(len(entry.content.split()) if entry.content else 0 for entry in entries)
|
| 164 |
+
|
| 165 |
+
# Get entries this month
|
| 166 |
+
current_month = datetime.now().month
|
| 167 |
+
current_year = datetime.now().year
|
| 168 |
+
entries_this_month = db.query(models.Entry).filter(
|
| 169 |
+
extract('month', models.Entry.created_at) == current_month,
|
| 170 |
+
extract('year', models.Entry.created_at) == current_year
|
| 171 |
+
).count()
|
| 172 |
+
|
| 173 |
+
# Get entries this year
|
| 174 |
+
entries_this_year = db.query(models.Entry).filter(
|
| 175 |
+
extract('year', models.Entry.created_at) == current_year
|
| 176 |
+
).count()
|
| 177 |
+
|
| 178 |
+
# Calculate average mood (if available)
|
| 179 |
+
mood_scores = db.query(models.Entry.ai_mood_score).filter(
|
| 180 |
+
models.Entry.ai_mood_score.isnot(None)
|
| 181 |
+
).all()
|
| 182 |
+
average_mood = sum(score[0] for score in mood_scores) / len(mood_scores) if mood_scores else 0
|
| 183 |
+
|
| 184 |
+
# Get most common themes (simplified)
|
| 185 |
+
themes_data = db.query(models.Entry.ai_themes).filter(
|
| 186 |
+
models.Entry.ai_themes.isnot(None)
|
| 187 |
+
).all()
|
| 188 |
+
|
| 189 |
+
all_themes = []
|
| 190 |
+
for theme_data in themes_data:
|
| 191 |
+
if theme_data[0]:
|
| 192 |
+
try:
|
| 193 |
+
themes = json.loads(theme_data[0])
|
| 194 |
+
if isinstance(themes, list):
|
| 195 |
+
all_themes.extend(themes)
|
| 196 |
+
except:
|
| 197 |
+
pass
|
| 198 |
+
|
| 199 |
+
# Count theme frequency
|
| 200 |
+
theme_counts = {}
|
| 201 |
+
for theme in all_themes:
|
| 202 |
+
theme_counts[theme] = theme_counts.get(theme, 0) + 1
|
| 203 |
+
|
| 204 |
+
most_common_themes = sorted(theme_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
| 205 |
+
most_common_themes = [theme[0] for theme in most_common_themes]
|
| 206 |
+
|
| 207 |
+
return schemas.AppStatistics(
|
| 208 |
+
total_entries=total_entries,
|
| 209 |
+
total_words=total_words,
|
| 210 |
+
entries_this_month=entries_this_month,
|
| 211 |
+
entries_this_year=entries_this_year,
|
| 212 |
+
current_streak=0, # TODO: Implement streak calculation
|
| 213 |
+
longest_streak=0, # TODO: Implement streak calculation
|
| 214 |
+
average_mood=average_mood,
|
| 215 |
+
most_common_themes=most_common_themes
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
except Exception as e:
|
| 219 |
+
logger.error("Error getting statistics: %s", e)
|
| 220 |
+
raise HTTPException(
|
| 221 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 222 |
+
detail="Failed to get statistics"
|
| 223 |
+
) from e
|
| 224 |
+
|
| 225 |
+
@app.get("/api/entries/{entry_id}", response_model=schemas.EntryResponse)
|
| 226 |
+
async def get_entry(entry_id: str, db: Session = Depends(get_db)):
|
| 227 |
+
"""Get a specific journal entry."""
|
| 228 |
+
try:
|
| 229 |
+
entry = EntryCRUD.get(db, entry_id)
|
| 230 |
+
if not entry:
|
| 231 |
+
raise HTTPException(
|
| 232 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 233 |
+
detail="Entry not found"
|
| 234 |
+
)
|
| 235 |
+
return entry
|
| 236 |
+
except HTTPException:
|
| 237 |
+
raise
|
| 238 |
+
except Exception as e:
|
| 239 |
+
logger.error("Error retrieving entry %s: %s", entry_id, e)
|
| 240 |
+
raise HTTPException(
|
| 241 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 242 |
+
detail="Failed to retrieve entry"
|
| 243 |
+
) from e
|
| 244 |
+
|
| 245 |
+
@app.put("/api/entries/{entry_id}", response_model=schemas.EntryResponse)
|
| 246 |
+
async def update_entry(
|
| 247 |
+
entry_id: str,
|
| 248 |
+
entry_update: schemas.EntryUpdate,
|
| 249 |
+
db: Session = Depends(get_db)
|
| 250 |
+
):
|
| 251 |
+
"""Update a journal entry."""
|
| 252 |
+
try:
|
| 253 |
+
# Filter out None values
|
| 254 |
+
update_data = {k: v for k, v in entry_update.model_dump().items() if v is not None}
|
| 255 |
+
|
| 256 |
+
updated_entry = EntryCRUD.update(db, entry_id, update_data)
|
| 257 |
+
if not updated_entry:
|
| 258 |
+
raise HTTPException(
|
| 259 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 260 |
+
detail="Entry not found"
|
| 261 |
+
)
|
| 262 |
+
return updated_entry
|
| 263 |
+
except HTTPException:
|
| 264 |
+
raise
|
| 265 |
+
except Exception as e:
|
| 266 |
+
logger.error("Error updating entry %s: %s", entry_id, e)
|
| 267 |
+
raise HTTPException(
|
| 268 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 269 |
+
detail="Failed to update entry"
|
| 270 |
+
) from e
|
| 271 |
+
|
| 272 |
+
@app.delete("/api/entries/{entry_id}")
|
| 273 |
+
async def delete_entry(entry_id: str, db: Session = Depends(get_db)):
|
| 274 |
+
"""Delete a journal entry."""
|
| 275 |
+
try:
|
| 276 |
+
success = EntryCRUD.delete(db, entry_id)
|
| 277 |
+
if not success:
|
| 278 |
+
raise HTTPException(
|
| 279 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 280 |
+
detail="Entry not found"
|
| 281 |
+
)
|
| 282 |
+
return {"message": "Entry deleted successfully"}
|
| 283 |
+
except HTTPException:
|
| 284 |
+
raise
|
| 285 |
+
except Exception as e:
|
| 286 |
+
logger.error("Error deleting entry %s: %s", entry_id, e)
|
| 287 |
+
raise HTTPException(
|
| 288 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 289 |
+
detail="Failed to delete entry"
|
| 290 |
+
) from e
|
| 291 |
+
|
| 292 |
+
# Media file endpoints
|
| 293 |
+
@app.post("/api/entries/{entry_id}/media", response_model=schemas.MediaFileResponse)
|
| 294 |
+
async def upload_media(
|
| 295 |
+
entry_id: str,
|
| 296 |
+
file: UploadFile = File(...),
|
| 297 |
+
db: Session = Depends(get_db)
|
| 298 |
+
):
|
| 299 |
+
"""Upload media files for an entry."""
|
| 300 |
+
try:
|
| 301 |
+
# Check if entry exists
|
| 302 |
+
entry = EntryCRUD.get(db, entry_id)
|
| 303 |
+
if not entry:
|
| 304 |
+
raise HTTPException(
|
| 305 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 306 |
+
detail="Entry not found"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
# Validate file type
|
| 310 |
+
allowed_types = {
|
| 311 |
+
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
| 312 |
+
'video/mp4', 'video/avi', 'video/mov', 'video/webm',
|
| 313 |
+
'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a'
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
if file.content_type not in allowed_types:
|
| 317 |
+
raise HTTPException(
|
| 318 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 319 |
+
detail=f"File type {file.content_type} not allowed"
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# Validate file size (max 50MB)
|
| 323 |
+
max_size = 50 * 1024 * 1024 # 50MB
|
| 324 |
+
file_content = await file.read()
|
| 325 |
+
if len(file_content) > max_size:
|
| 326 |
+
raise HTTPException(
|
| 327 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 328 |
+
detail="File size exceeds 50MB limit"
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
# Generate unique filename
|
| 332 |
+
file_extension = file.filename.split('.')[-1] if '.' in file.filename else ''
|
| 333 |
+
unique_filename = f"{uuid.uuid4()}.{file_extension}"
|
| 334 |
+
media_dir = get_media_directory()
|
| 335 |
+
file_path = os.path.join(media_dir, "uploads", unique_filename)
|
| 336 |
+
|
| 337 |
+
# Ensure directory exists
|
| 338 |
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
| 339 |
+
|
| 340 |
+
# Save file to disk
|
| 341 |
+
with open(file_path, "wb") as buffer:
|
| 342 |
+
buffer.write(file_content)
|
| 343 |
+
|
| 344 |
+
# Determine file type category
|
| 345 |
+
if file.content_type.startswith('image/'):
|
| 346 |
+
file_type = 'image'
|
| 347 |
+
elif file.content_type.startswith('video/'):
|
| 348 |
+
file_type = 'video'
|
| 349 |
+
elif file.content_type.startswith('audio/'):
|
| 350 |
+
file_type = 'audio'
|
| 351 |
+
else:
|
| 352 |
+
file_type = 'unknown'
|
| 353 |
+
|
| 354 |
+
# Create media file record
|
| 355 |
+
media_data = {
|
| 356 |
+
"entry_id": entry_id,
|
| 357 |
+
"filename": unique_filename,
|
| 358 |
+
"file_type": file_type,
|
| 359 |
+
"file_size": len(file_content)
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
db_media = MediaFileCRUD.create(db, media_data)
|
| 363 |
+
return db_media
|
| 364 |
+
|
| 365 |
+
except HTTPException:
|
| 366 |
+
raise
|
| 367 |
+
except Exception as e:
|
| 368 |
+
logger.error("Error uploading media: %s", e)
|
| 369 |
+
raise HTTPException(
|
| 370 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 371 |
+
detail="Failed to upload media file"
|
| 372 |
+
) from e
|
| 373 |
+
|
| 374 |
+
@app.delete("/api/entries/{entry_id}/media/{media_id}")
|
| 375 |
+
async def delete_media(entry_id: str, media_id: str, db: Session = Depends(get_db)):
|
| 376 |
+
"""Delete a media file."""
|
| 377 |
+
try:
|
| 378 |
+
# Check if entry exists
|
| 379 |
+
entry = EntryCRUD.get(db, entry_id)
|
| 380 |
+
if not entry:
|
| 381 |
+
raise HTTPException(
|
| 382 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 383 |
+
detail="Entry not found"
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
# Get media file record
|
| 387 |
+
media_file = MediaFileCRUD.get(db, media_id)
|
| 388 |
+
if not media_file:
|
| 389 |
+
raise HTTPException(
|
| 390 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 391 |
+
detail="Media file not found"
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
# Check if media file belongs to the entry
|
| 395 |
+
if media_file.entry_id != entry_id:
|
| 396 |
+
raise HTTPException(
|
| 397 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 398 |
+
detail="Media file does not belong to this entry"
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
# Delete file from disk
|
| 402 |
+
media_dir = get_media_directory()
|
| 403 |
+
file_path = os.path.join(media_dir, "uploads", media_file.filename)
|
| 404 |
+
if os.path.exists(file_path):
|
| 405 |
+
os.remove(file_path)
|
| 406 |
+
|
| 407 |
+
# Delete database record
|
| 408 |
+
success = MediaFileCRUD.delete(db, media_id)
|
| 409 |
+
if not success:
|
| 410 |
+
raise HTTPException(
|
| 411 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 412 |
+
detail="Failed to delete media file record"
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
return {"message": "Media file deleted successfully"}
|
| 416 |
+
|
| 417 |
+
except HTTPException:
|
| 418 |
+
raise
|
| 419 |
+
except Exception as e:
|
| 420 |
+
logger.error("Error deleting media: %s", e)
|
| 421 |
+
raise HTTPException(
|
| 422 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 423 |
+
detail="Failed to delete media file"
|
| 424 |
+
) from e
|
| 425 |
+
|
| 426 |
+
@app.get("/api/entries/{entry_id}/media", response_model=List[schemas.MediaFileResponse])
|
| 427 |
+
async def get_entry_media(entry_id: str, db: Session = Depends(get_db)):
|
| 428 |
+
"""Get all media files for an entry."""
|
| 429 |
+
try:
|
| 430 |
+
# Check if entry exists
|
| 431 |
+
entry = EntryCRUD.get(db, entry_id)
|
| 432 |
+
if not entry:
|
| 433 |
+
raise HTTPException(
|
| 434 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 435 |
+
detail="Entry not found"
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
# Get media files
|
| 439 |
+
media_files = MediaFileCRUD.get_by_entry(db, entry_id)
|
| 440 |
+
return media_files
|
| 441 |
+
|
| 442 |
+
except HTTPException:
|
| 443 |
+
raise
|
| 444 |
+
except Exception as e:
|
| 445 |
+
logger.error("Error retrieving media files: %s", e)
|
| 446 |
+
raise HTTPException(
|
| 447 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 448 |
+
detail="Failed to retrieve media files"
|
| 449 |
+
) from e
|
| 450 |
+
|
| 451 |
+
@app.get("/api/media/{filename}")
|
| 452 |
+
async def serve_media(filename: str, db: Session = Depends(get_db)):
|
| 453 |
+
"""Serve media files."""
|
| 454 |
+
try:
|
| 455 |
+
# Check if media file exists in database
|
| 456 |
+
media_file = db.query(models.MediaFile).filter(models.MediaFile.filename == filename).first()
|
| 457 |
+
if not media_file:
|
| 458 |
+
raise HTTPException(
|
| 459 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 460 |
+
detail="Media file not found"
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
# Check if file exists on disk
|
| 464 |
+
media_dir = get_media_directory()
|
| 465 |
+
file_path = os.path.join(media_dir, "uploads", filename)
|
| 466 |
+
if not os.path.exists(file_path):
|
| 467 |
+
raise HTTPException(
|
| 468 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 469 |
+
detail="Media file not found on disk"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
return FileResponse(file_path)
|
| 473 |
+
|
| 474 |
+
except HTTPException:
|
| 475 |
+
raise
|
| 476 |
+
except Exception as e:
|
| 477 |
+
logger.error("Error serving media file: %s", e)
|
| 478 |
+
raise HTTPException(
|
| 479 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 480 |
+
detail="Failed to serve media file"
|
| 481 |
+
) from e
|
| 482 |
+
|
| 483 |
+
# AI processing endpoints
|
| 484 |
+
@app.post("/api/ai/analyze-text", response_model=schemas.AIAnalysisResponse)
|
| 485 |
+
async def analyze_text(request: schemas.AIAnalysisRequest, db: Session = Depends(get_db)):
|
| 486 |
+
"""Analyze text content for mood and themes."""
|
| 487 |
+
try:
|
| 488 |
+
result = ai_manager.analyze_content(request.content, "text", db_session=db)
|
| 489 |
+
return schemas.AIAnalysisResponse(**result)
|
| 490 |
+
except Exception as e:
|
| 491 |
+
logger.error("Error in text analysis: %s", e)
|
| 492 |
+
return schemas.AIAnalysisResponse(
|
| 493 |
+
analysis={},
|
| 494 |
+
success=False,
|
| 495 |
+
error=str(e)
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
@app.post("/api/ai/analyze-image", response_model=schemas.AIAnalysisResponse)
|
| 499 |
+
async def analyze_image(request: schemas.AIAnalysisRequest, db: Session = Depends(get_db)):
|
| 500 |
+
"""Analyze image content."""
|
| 501 |
+
try:
|
| 502 |
+
result = ai_manager.analyze_content(request.content, "image", db_session=db)
|
| 503 |
+
return schemas.AIAnalysisResponse(**result)
|
| 504 |
+
except Exception as e:
|
| 505 |
+
logger.error("Error in image analysis: %s", e)
|
| 506 |
+
return schemas.AIAnalysisResponse(
|
| 507 |
+
analysis={},
|
| 508 |
+
success=False,
|
| 509 |
+
error=str(e)
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
@app.post("/api/ai/analyze-psychological-image", response_model=schemas.AIAnalysisResponse)
|
| 513 |
+
async def analyze_psychological_image(
|
| 514 |
+
request: schemas.PsychologicalImageAnalysisRequest,
|
| 515 |
+
db: Session = Depends(get_db)
|
| 516 |
+
):
|
| 517 |
+
"""Analyze image from a psychological perspective for therapeutic journaling."""
|
| 518 |
+
try:
|
| 519 |
+
result = ai_manager.analyze_psychological_image(
|
| 520 |
+
request.image_data,
|
| 521 |
+
request.context_text or "",
|
| 522 |
+
db_session=db
|
| 523 |
+
)
|
| 524 |
+
return schemas.AIAnalysisResponse(**result)
|
| 525 |
+
except Exception as e:
|
| 526 |
+
logger.error("Error in psychological image analysis: %s", e)
|
| 527 |
+
return schemas.AIAnalysisResponse(
|
| 528 |
+
analysis={},
|
| 529 |
+
success=False,
|
| 530 |
+
error=str(e)
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
@app.post("/api/ai/transcribe-audio", response_model=schemas.AIAnalysisResponse)
|
| 534 |
+
async def transcribe_audio(
|
| 535 |
+
file: UploadFile = File(...),
|
| 536 |
+
db: Session = Depends(get_db)
|
| 537 |
+
):
|
| 538 |
+
"""Transcribe audio file to text and analyze it."""
|
| 539 |
+
try:
|
| 540 |
+
# Validate file type
|
| 541 |
+
allowed_audio_types = {
|
| 542 |
+
'audio/wav', 'audio/mp3', 'audio/m4a', 'audio/ogg', 'audio/webm'
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
if file.content_type not in allowed_audio_types:
|
| 546 |
+
raise HTTPException(
|
| 547 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 548 |
+
detail=f"Audio file type {file.content_type} not supported"
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
# Read and encode audio file
|
| 552 |
+
audio_content = await file.read()
|
| 553 |
+
encoded_audio = base64.b64encode(audio_content).decode('utf-8')
|
| 554 |
+
|
| 555 |
+
# Process with AI manager
|
| 556 |
+
result = ai_manager.analyze_content(encoded_audio, "audio", db_session=db)
|
| 557 |
+
return schemas.AIAnalysisResponse(**result)
|
| 558 |
+
|
| 559 |
+
except HTTPException:
|
| 560 |
+
raise
|
| 561 |
+
except Exception as e:
|
| 562 |
+
logger.error("Error in audio transcription: %s", e)
|
| 563 |
+
return schemas.AIAnalysisResponse(
|
| 564 |
+
analysis={},
|
| 565 |
+
success=False,
|
| 566 |
+
error=str(e)
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
+
@app.post("/api/ai/chat", response_model=schemas.ChatResponse)
|
| 570 |
+
async def chat(request: schemas.ChatRequest, db: Session = Depends(get_db)):
|
| 571 |
+
"""Chat with AI assistant."""
|
| 572 |
+
try:
|
| 573 |
+
result = ai_manager.generate_chat_response(
|
| 574 |
+
request.message,
|
| 575 |
+
request.context_entries,
|
| 576 |
+
request.conversation_history,
|
| 577 |
+
db_session=db
|
| 578 |
+
)
|
| 579 |
+
return schemas.ChatResponse(**result)
|
| 580 |
+
except Exception as e:
|
| 581 |
+
logger.error("Error in chat: %s", e)
|
| 582 |
+
return schemas.ChatResponse(
|
| 583 |
+
response="",
|
| 584 |
+
success=False,
|
| 585 |
+
error=str(e)
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
@app.post("/api/ai/summarize-conversation", response_model=schemas.ConversationSummaryResponse)
|
| 589 |
+
async def summarize_conversation(request: schemas.ConversationSummaryRequest, db: Session = Depends(get_db)):
|
| 590 |
+
"""Generate a journal entry summary from a chat conversation."""
|
| 591 |
+
try:
|
| 592 |
+
result = ai_manager.summarize_conversation(request.conversation, db_session=db)
|
| 593 |
+
return schemas.ConversationSummaryResponse(**result)
|
| 594 |
+
except Exception as e:
|
| 595 |
+
logger.error("Error in conversation summary: %s", e)
|
| 596 |
+
return schemas.ConversationSummaryResponse(
|
| 597 |
+
summary={},
|
| 598 |
+
success=False,
|
| 599 |
+
error=str(e)
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
@app.post("/api/ai/detect-crisis", response_model=schemas.CrisisDetectionResponse)
|
| 603 |
+
async def detect_crisis(request: schemas.AIAnalysisRequest, db: Session = Depends(get_db)):
|
| 604 |
+
"""Detect potential crisis or emotional distress in content."""
|
| 605 |
+
try:
|
| 606 |
+
result = ai_manager.detect_crisis(request.content, db_session=db)
|
| 607 |
+
return schemas.CrisisDetectionResponse(**result)
|
| 608 |
+
except Exception as e:
|
| 609 |
+
logger.error("Error in crisis detection: %s", e)
|
| 610 |
+
return schemas.CrisisDetectionResponse(
|
| 611 |
+
crisis_detected=False,
|
| 612 |
+
severity="low",
|
| 613 |
+
reason="",
|
| 614 |
+
success=False,
|
| 615 |
+
error=str(e)
|
| 616 |
+
)
|
| 617 |
+
|
| 618 |
+
@app.post("/api/ai/motivational-support", response_model=schemas.MotivationalSupportResponse)
|
| 619 |
+
async def get_motivational_support(request: schemas.AIAnalysisRequest, db: Session = Depends(get_db)):
|
| 620 |
+
"""Generate motivational support and suggestions for users in distress."""
|
| 621 |
+
try:
|
| 622 |
+
result = ai_manager.generate_motivational_support(request.content, db_session=db)
|
| 623 |
+
return schemas.MotivationalSupportResponse(**result)
|
| 624 |
+
except Exception as e:
|
| 625 |
+
logger.error("Error generating motivational support: %s", e)
|
| 626 |
+
return schemas.MotivationalSupportResponse(
|
| 627 |
+
support={},
|
| 628 |
+
success=False,
|
| 629 |
+
error=str(e)
|
| 630 |
+
)
|
| 631 |
+
|
| 632 |
+
@app.post("/api/ai/generate-insights", response_model=schemas.InsightsResponse)
|
| 633 |
+
async def generate_insights(request: schemas.InsightsRequest, db: Session = Depends(get_db)):
|
| 634 |
+
"""Generate AI insights from journal entries."""
|
| 635 |
+
try:
|
| 636 |
+
from datetime import datetime
|
| 637 |
+
|
| 638 |
+
# Get entries based on request parameters
|
| 639 |
+
query = db.query(models.Entry)
|
| 640 |
+
|
| 641 |
+
if request.entry_ids:
|
| 642 |
+
query = query.filter(models.Entry.id.in_(request.entry_ids))
|
| 643 |
+
elif request.date_range:
|
| 644 |
+
# Parse dates and make them timezone-aware (start of day and end of day)
|
| 645 |
+
start_date = datetime.fromisoformat(request.date_range["start"]).replace(hour=0, minute=0, second=0, microsecond=0)
|
| 646 |
+
end_date = datetime.fromisoformat(request.date_range["end"]).replace(hour=23, minute=59, second=59, microsecond=999999)
|
| 647 |
+
|
| 648 |
+
# Make dates timezone-aware (UTC)
|
| 649 |
+
from datetime import timezone
|
| 650 |
+
start_date = start_date.replace(tzinfo=timezone.utc)
|
| 651 |
+
end_date = end_date.replace(tzinfo=timezone.utc)
|
| 652 |
+
|
| 653 |
+
logger.info(f"Filtering entries between {start_date} and {end_date}")
|
| 654 |
+
query = query.filter(
|
| 655 |
+
models.Entry.created_at >= start_date,
|
| 656 |
+
models.Entry.created_at <= end_date
|
| 657 |
+
)
|
| 658 |
+
|
| 659 |
+
entries = query.all()
|
| 660 |
+
|
| 661 |
+
if not entries:
|
| 662 |
+
return schemas.InsightsResponse(
|
| 663 |
+
mood_trends=[],
|
| 664 |
+
common_themes=[],
|
| 665 |
+
emotional_patterns=[],
|
| 666 |
+
summary="No entries found for the specified criteria."
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
# Analyze mood trends
|
| 670 |
+
mood_trends = []
|
| 671 |
+
entries_by_date = {}
|
| 672 |
+
|
| 673 |
+
for entry in entries:
|
| 674 |
+
date_str = entry.created_at.strftime('%Y-%m-%d')
|
| 675 |
+
if date_str not in entries_by_date:
|
| 676 |
+
entries_by_date[date_str] = []
|
| 677 |
+
entries_by_date[date_str].append(entry)
|
| 678 |
+
|
| 679 |
+
for date_str, date_entries in entries_by_date.items():
|
| 680 |
+
mood_scores = [e.ai_mood_score for e in date_entries if e.ai_mood_score]
|
| 681 |
+
avg_mood = sum(mood_scores) / len(mood_scores) if mood_scores else 5.0
|
| 682 |
+
|
| 683 |
+
mood_trends.append({
|
| 684 |
+
"date": date_str,
|
| 685 |
+
"average_mood": avg_mood,
|
| 686 |
+
"entry_count": len(date_entries)
|
| 687 |
+
})
|
| 688 |
+
|
| 689 |
+
# Analyze common themes
|
| 690 |
+
all_themes = []
|
| 691 |
+
for entry in entries:
|
| 692 |
+
if entry.ai_themes:
|
| 693 |
+
try:
|
| 694 |
+
themes = json.loads(entry.ai_themes)
|
| 695 |
+
if isinstance(themes, list):
|
| 696 |
+
all_themes.extend(themes)
|
| 697 |
+
except:
|
| 698 |
+
pass
|
| 699 |
+
|
| 700 |
+
theme_counts = {}
|
| 701 |
+
for theme in all_themes:
|
| 702 |
+
theme_counts[theme] = theme_counts.get(theme, 0) + 1
|
| 703 |
+
|
| 704 |
+
common_themes = [
|
| 705 |
+
{"theme": theme, "frequency": count}
|
| 706 |
+
for theme, count in sorted(theme_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
| 707 |
+
]
|
| 708 |
+
|
| 709 |
+
# Analyze emotional patterns
|
| 710 |
+
mood_counts = {"positive": 0, "neutral": 0, "negative": 0}
|
| 711 |
+
total_with_mood = 0
|
| 712 |
+
|
| 713 |
+
for entry in entries:
|
| 714 |
+
if entry.ai_mood_score:
|
| 715 |
+
total_with_mood += 1
|
| 716 |
+
if entry.ai_mood_score >= 7:
|
| 717 |
+
mood_counts["positive"] += 1
|
| 718 |
+
elif entry.ai_mood_score >= 4:
|
| 719 |
+
mood_counts["neutral"] += 1
|
| 720 |
+
else:
|
| 721 |
+
mood_counts["negative"] += 1
|
| 722 |
+
|
| 723 |
+
emotional_patterns = []
|
| 724 |
+
if total_with_mood > 0:
|
| 725 |
+
for emotion, count in mood_counts.items():
|
| 726 |
+
percentage = (count / total_with_mood) * 100
|
| 727 |
+
emotional_patterns.append({
|
| 728 |
+
"emotion": emotion,
|
| 729 |
+
"percentage": percentage
|
| 730 |
+
})
|
| 731 |
+
|
| 732 |
+
# Generate AI-powered insights for personal growth
|
| 733 |
+
entry_contents = [entry.content for entry in entries]
|
| 734 |
+
logger.info(f"Generating AI insights for {len(entry_contents)} entries")
|
| 735 |
+
|
| 736 |
+
ai_insights_result = ai_manager.generate_insights(entry_contents)
|
| 737 |
+
logger.info(f"AI insights result: {ai_insights_result}")
|
| 738 |
+
|
| 739 |
+
# Extract AI insights if successful
|
| 740 |
+
ai_insights = {}
|
| 741 |
+
if ai_insights_result.get("success") and ai_insights_result.get("insights"):
|
| 742 |
+
ai_insights = ai_insights_result["insights"]
|
| 743 |
+
logger.info(f"Successfully extracted AI insights: {list(ai_insights.keys())}")
|
| 744 |
+
else:
|
| 745 |
+
logger.warning(f"AI insights generation failed: {ai_insights_result.get('error', 'Unknown error')}")
|
| 746 |
+
# Provide fallback insights when AI fails
|
| 747 |
+
ai_insights = {
|
| 748 |
+
"growth_areas": ["Continue journaling to unlock AI-powered insights"],
|
| 749 |
+
"actionable_outcomes": [],
|
| 750 |
+
"strengths_identified": ["Consistent journaling practice"],
|
| 751 |
+
"progress_indicators": [],
|
| 752 |
+
"growth_summary": "Keep writing to unlock personalized growth insights powered by AI analysis."
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
# Generate summary
|
| 756 |
+
summary = f"Analyzed {len(entries)} entries. "
|
| 757 |
+
if emotional_patterns:
|
| 758 |
+
dominant_emotion = max(emotional_patterns, key=lambda x: x["percentage"])
|
| 759 |
+
summary += f"Your dominant emotional pattern is {dominant_emotion['emotion']} ({dominant_emotion['percentage']:.1f}%). "
|
| 760 |
+
|
| 761 |
+
if common_themes:
|
| 762 |
+
top_theme = common_themes[0]
|
| 763 |
+
summary += f"Your most common theme is '{top_theme['theme']}' appearing {top_theme['frequency']} times."
|
| 764 |
+
|
| 765 |
+
# Process actionable outcomes from AI insights
|
| 766 |
+
actionable_outcomes = []
|
| 767 |
+
if ai_insights.get("actionable_outcomes"):
|
| 768 |
+
for outcome in ai_insights["actionable_outcomes"]:
|
| 769 |
+
actionable_outcomes.append(schemas.ActionableOutcome(
|
| 770 |
+
title=outcome.get("title", ""),
|
| 771 |
+
description=outcome.get("description", ""),
|
| 772 |
+
timeframe=outcome.get("timeframe", "weekly"),
|
| 773 |
+
category=outcome.get("category", "general")
|
| 774 |
+
))
|
| 775 |
+
|
| 776 |
+
return schemas.InsightsResponse(
|
| 777 |
+
mood_trends=mood_trends,
|
| 778 |
+
common_themes=common_themes,
|
| 779 |
+
emotional_patterns=emotional_patterns,
|
| 780 |
+
summary=summary,
|
| 781 |
+
growth_areas=ai_insights.get("growth_areas", []),
|
| 782 |
+
actionable_outcomes=actionable_outcomes,
|
| 783 |
+
strengths_identified=ai_insights.get("strengths_identified", []),
|
| 784 |
+
progress_indicators=ai_insights.get("progress_indicators", []),
|
| 785 |
+
growth_summary=ai_insights.get("growth_summary", "")
|
| 786 |
+
)
|
| 787 |
+
|
| 788 |
+
except Exception as e:
|
| 789 |
+
logger.error("Error generating insights: %s", e)
|
| 790 |
+
raise HTTPException(
|
| 791 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 792 |
+
detail="Failed to generate insights"
|
| 793 |
+
) from e
|
| 794 |
+
|
| 795 |
+
# Configuration endpoints
|
| 796 |
+
@app.get("/api/config", response_model=schemas.ConfigResponse)
|
| 797 |
+
async def get_config(db: Session = Depends(get_db)):
|
| 798 |
+
"""Get user configuration settings."""
|
| 799 |
+
try:
|
| 800 |
+
config = UserConfigCRUD.get_all(db)
|
| 801 |
+
return schemas.ConfigResponse(config=config)
|
| 802 |
+
except Exception as e:
|
| 803 |
+
logger.error("Error retrieving config: %s", e)
|
| 804 |
+
raise HTTPException(
|
| 805 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 806 |
+
detail="Failed to retrieve configuration"
|
| 807 |
+
) from e
|
| 808 |
+
|
| 809 |
+
@app.put("/api/config", response_model=schemas.UserConfigResponse)
|
| 810 |
+
async def update_config(
|
| 811 |
+
config_update: schemas.UserConfigUpdate,
|
| 812 |
+
db: Session = Depends(get_db)
|
| 813 |
+
):
|
| 814 |
+
"""Update user configuration settings."""
|
| 815 |
+
try:
|
| 816 |
+
# Handle special configuration keys
|
| 817 |
+
if config_update.key == "ai_model":
|
| 818 |
+
# Store the model name in config
|
| 819 |
+
config = UserConfigCRUD.set(db, config_update.key, config_update.value)
|
| 820 |
+
|
| 821 |
+
# Also try to configure the AI manager if API key is available
|
| 822 |
+
api_key = UserConfigCRUD.get(db, "ai_api_key")
|
| 823 |
+
if api_key:
|
| 824 |
+
ai_manager.configure_model(config_update.value, api_key)
|
| 825 |
+
|
| 826 |
+
return config
|
| 827 |
+
elif config_update.key == "ai_api_key":
|
| 828 |
+
# Store the API key in config
|
| 829 |
+
config = UserConfigCRUD.set(db, config_update.key, config_update.value)
|
| 830 |
+
|
| 831 |
+
# Also try to configure the AI manager if model is available
|
| 832 |
+
model_name = UserConfigCRUD.get(db, "ai_model")
|
| 833 |
+
if model_name:
|
| 834 |
+
ai_manager.configure_model(model_name, config_update.value)
|
| 835 |
+
|
| 836 |
+
return config
|
| 837 |
+
else:
|
| 838 |
+
# Regular configuration update
|
| 839 |
+
config = UserConfigCRUD.set(db, config_update.key, config_update.value)
|
| 840 |
+
return config
|
| 841 |
+
except Exception as e:
|
| 842 |
+
logger.error("Error updating config: %s", e)
|
| 843 |
+
raise HTTPException(
|
| 844 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 845 |
+
detail="Failed to update configuration"
|
| 846 |
+
) from e
|
| 847 |
+
|
| 848 |
+
@app.post("/api/config/test-model", response_model=schemas.ModelTestResponse)
|
| 849 |
+
async def test_model(db: Session = Depends(get_db)):
|
| 850 |
+
"""Test AI model connection."""
|
| 851 |
+
try:
|
| 852 |
+
# Get current model configuration from database
|
| 853 |
+
model_name = UserConfigCRUD.get(db, "ai_model")
|
| 854 |
+
api_key = UserConfigCRUD.get(db, "ai_api_key")
|
| 855 |
+
|
| 856 |
+
if not model_name:
|
| 857 |
+
return schemas.ModelTestResponse(
|
| 858 |
+
success=False,
|
| 859 |
+
error="No AI model configured",
|
| 860 |
+
model=None
|
| 861 |
+
)
|
| 862 |
+
|
| 863 |
+
# Configure the model if not already configured
|
| 864 |
+
if ai_manager.current_model != model_name:
|
| 865 |
+
success = ai_manager.configure_model(model_name, api_key)
|
| 866 |
+
if not success:
|
| 867 |
+
return schemas.ModelTestResponse(
|
| 868 |
+
success=False,
|
| 869 |
+
error=f"Failed to configure model {model_name}",
|
| 870 |
+
model=model_name
|
| 871 |
+
)
|
| 872 |
+
|
| 873 |
+
# Test the model connection
|
| 874 |
+
result = ai_manager.test_model_connection()
|
| 875 |
+
return schemas.ModelTestResponse(**result)
|
| 876 |
+
except Exception as e:
|
| 877 |
+
logger.error("Error testing model: %s", e)
|
| 878 |
+
return schemas.ModelTestResponse(
|
| 879 |
+
success=False,
|
| 880 |
+
error=str(e),
|
| 881 |
+
model=ai_manager.current_model
|
| 882 |
+
)
|
| 883 |
+
|
| 884 |
+
@app.post("/api/config/configure-model", response_model=schemas.ModelTestResponse)
|
| 885 |
+
async def configure_model(
|
| 886 |
+
model_config: schemas.ModelConfigRequest,
|
| 887 |
+
db: Session = Depends(get_db)
|
| 888 |
+
):
|
| 889 |
+
"""Configure AI model with API key."""
|
| 890 |
+
try:
|
| 891 |
+
# Store configuration in database
|
| 892 |
+
UserConfigCRUD.set(db, "ai_model", model_config.model_name)
|
| 893 |
+
if model_config.api_key:
|
| 894 |
+
UserConfigCRUD.set(db, "ai_api_key", model_config.api_key)
|
| 895 |
+
|
| 896 |
+
# Configure the AI manager
|
| 897 |
+
success = ai_manager.configure_model(model_config.model_name, model_config.api_key)
|
| 898 |
+
|
| 899 |
+
if success:
|
| 900 |
+
return schemas.ModelTestResponse(
|
| 901 |
+
success=True,
|
| 902 |
+
model=model_config.model_name,
|
| 903 |
+
response=f"Model {model_config.model_name} configured successfully"
|
| 904 |
+
)
|
| 905 |
+
|
| 906 |
+
return schemas.ModelTestResponse(
|
| 907 |
+
success=False,
|
| 908 |
+
error=f"Failed to configure model {model_config.model_name}",
|
| 909 |
+
model=model_config.model_name
|
| 910 |
+
)
|
| 911 |
+
except Exception as e:
|
| 912 |
+
logger.error("Error configuring model: %s", e)
|
| 913 |
+
return schemas.ModelTestResponse(
|
| 914 |
+
success=False,
|
| 915 |
+
error=str(e),
|
| 916 |
+
model=model_config.model_name
|
| 917 |
+
)
|
| 918 |
+
|
| 919 |
+
@app.get("/api/config/models", response_model=schemas.ModelInfoResponse)
|
| 920 |
+
async def get_model_info():
|
| 921 |
+
"""Get information about available AI models and current configuration."""
|
| 922 |
+
try:
|
| 923 |
+
model_info = ai_manager.get_model_info()
|
| 924 |
+
return schemas.ModelInfoResponse(
|
| 925 |
+
success=True,
|
| 926 |
+
data=model_info
|
| 927 |
+
)
|
| 928 |
+
except Exception as e:
|
| 929 |
+
logger.error("Error getting model info: %s", e)
|
| 930 |
+
return schemas.ModelInfoResponse(
|
| 931 |
+
success=False,
|
| 932 |
+
error=str(e),
|
| 933 |
+
data={}
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
@app.post("/api/ai/test-insights")
|
| 937 |
+
async def test_ai_insights(request: Dict[str, Any], db: Session = Depends(get_db)):
|
| 938 |
+
"""Test AI manager insights generation directly."""
|
| 939 |
+
try:
|
| 940 |
+
entries = request.get("entries", [])
|
| 941 |
+
if not entries:
|
| 942 |
+
return {"success": False, "error": "No entries provided"}
|
| 943 |
+
|
| 944 |
+
# Use AI manager's generate_insights method directly
|
| 945 |
+
result = ai_manager.generate_insights(entries)
|
| 946 |
+
return result
|
| 947 |
+
except Exception as e:
|
| 948 |
+
logger.error("Error in AI insights test: %s", e)
|
| 949 |
+
return {"success": False, "error": str(e)}
|
| 950 |
+
|
| 951 |
+
if __name__ == "__main__":
|
| 952 |
+
import uvicorn
|
| 953 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
| 954 |
+
# Mount static files (for serving the built frontend)
|
| 955 |
+
# This must be at the end, after all API routes are defined
|
| 956 |
+
if os.path.exists("static"):
|
| 957 |
+
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
backend/app/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy models for QuietRoom journal app."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import Column, String, Text, Integer, REAL, DateTime, ForeignKey, Table
|
| 4 |
+
from sqlalchemy.orm import relationship
|
| 5 |
+
from sqlalchemy.sql import func
|
| 6 |
+
from .database import Base
|
| 7 |
+
import uuid
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
# Junction table for entry-tags many-to-many relationship
|
| 11 |
+
entry_tags = Table(
|
| 12 |
+
'entry_tags',
|
| 13 |
+
Base.metadata,
|
| 14 |
+
Column('entry_id', String, ForeignKey('entries.id', ondelete='CASCADE'), primary_key=True),
|
| 15 |
+
Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True)
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
class Entry(Base):
|
| 19 |
+
"""Journal entry model."""
|
| 20 |
+
__tablename__ = 'entries'
|
| 21 |
+
|
| 22 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 23 |
+
created_at = Column(DateTime, default=func.now())
|
| 24 |
+
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
| 25 |
+
title = Column(String)
|
| 26 |
+
content = Column(Text, nullable=False)
|
| 27 |
+
mood = Column(String)
|
| 28 |
+
location_name = Column(String)
|
| 29 |
+
location_lat = Column(REAL)
|
| 30 |
+
location_lng = Column(REAL)
|
| 31 |
+
ai_summary = Column(Text)
|
| 32 |
+
ai_mood_score = Column(REAL)
|
| 33 |
+
ai_themes = Column(Text) # JSON array of themes stored as text
|
| 34 |
+
|
| 35 |
+
# Relationships
|
| 36 |
+
media_files = relationship("MediaFile", back_populates="entry", cascade="all, delete-orphan")
|
| 37 |
+
tags = relationship("Tag", secondary=entry_tags, back_populates="entries")
|
| 38 |
+
|
| 39 |
+
class MediaFile(Base):
|
| 40 |
+
"""Media file model for photos, videos, audio."""
|
| 41 |
+
__tablename__ = 'media_files'
|
| 42 |
+
|
| 43 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 44 |
+
entry_id = Column(String, ForeignKey('entries.id', ondelete='CASCADE'), nullable=False)
|
| 45 |
+
filename = Column(String, nullable=False)
|
| 46 |
+
file_type = Column(String, nullable=False) # 'image', 'video', 'audio'
|
| 47 |
+
file_size = Column(Integer)
|
| 48 |
+
ai_description = Column(Text)
|
| 49 |
+
created_at = Column(DateTime, default=func.now())
|
| 50 |
+
|
| 51 |
+
# Relationships
|
| 52 |
+
entry = relationship("Entry", back_populates="media_files")
|
| 53 |
+
|
| 54 |
+
class Tag(Base):
|
| 55 |
+
"""Tag model for categorizing entries."""
|
| 56 |
+
__tablename__ = 'tags'
|
| 57 |
+
|
| 58 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 59 |
+
name = Column(String, unique=True, nullable=False)
|
| 60 |
+
color = Column(String)
|
| 61 |
+
|
| 62 |
+
# Relationships
|
| 63 |
+
entries = relationship("Entry", secondary=entry_tags, back_populates="tags")
|
| 64 |
+
|
| 65 |
+
class ChatMessage(Base):
|
| 66 |
+
"""Chat message model for AI conversations."""
|
| 67 |
+
__tablename__ = 'chat_messages'
|
| 68 |
+
|
| 69 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 70 |
+
role = Column(String, nullable=False) # 'user' or 'assistant'
|
| 71 |
+
content = Column(Text, nullable=False)
|
| 72 |
+
related_entries = Column(Text) # JSON array of entry IDs stored as text
|
| 73 |
+
created_at = Column(DateTime, default=func.now())
|
| 74 |
+
|
| 75 |
+
class UserConfig(Base):
|
| 76 |
+
"""User configuration model for app settings."""
|
| 77 |
+
__tablename__ = 'user_config'
|
| 78 |
+
|
| 79 |
+
key = Column(String, primary_key=True)
|
| 80 |
+
value = Column(Text, nullable=False)
|
| 81 |
+
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
backend/app/schemas.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for request/response validation."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field, ConfigDict
|
| 7 |
+
|
| 8 |
+
# Entry schemas
|
| 9 |
+
class EntryBase(BaseModel):
|
| 10 |
+
title: Optional[str] = None
|
| 11 |
+
content: str
|
| 12 |
+
mood: Optional[str] = None
|
| 13 |
+
location_name: Optional[str] = None
|
| 14 |
+
location_lat: Optional[float] = None
|
| 15 |
+
location_lng: Optional[float] = None
|
| 16 |
+
|
| 17 |
+
class EntryCreate(EntryBase):
|
| 18 |
+
ai_summary: Optional[str] = None
|
| 19 |
+
ai_mood_score: Optional[float] = None
|
| 20 |
+
ai_themes: Optional[str] = None # JSON string
|
| 21 |
+
|
| 22 |
+
class EntryUpdate(BaseModel):
|
| 23 |
+
title: Optional[str] = None
|
| 24 |
+
content: Optional[str] = None
|
| 25 |
+
mood: Optional[str] = None
|
| 26 |
+
location_name: Optional[str] = None
|
| 27 |
+
location_lat: Optional[float] = None
|
| 28 |
+
location_lng: Optional[float] = None
|
| 29 |
+
ai_summary: Optional[str] = None
|
| 30 |
+
ai_mood_score: Optional[float] = None
|
| 31 |
+
ai_themes: Optional[str] = None # JSON string
|
| 32 |
+
|
| 33 |
+
class EntryResponse(EntryBase):
|
| 34 |
+
id: str
|
| 35 |
+
created_at: datetime
|
| 36 |
+
updated_at: datetime
|
| 37 |
+
ai_summary: Optional[str] = None
|
| 38 |
+
ai_mood_score: Optional[float] = None
|
| 39 |
+
ai_themes: Optional[str] = None # JSON string
|
| 40 |
+
|
| 41 |
+
model_config = ConfigDict(from_attributes=True)
|
| 42 |
+
|
| 43 |
+
# Media file schemas
|
| 44 |
+
class MediaFileBase(BaseModel):
|
| 45 |
+
filename: str
|
| 46 |
+
file_type: str
|
| 47 |
+
file_size: Optional[int] = None
|
| 48 |
+
|
| 49 |
+
class MediaFileCreate(MediaFileBase):
|
| 50 |
+
entry_id: str
|
| 51 |
+
|
| 52 |
+
class MediaFileResponse(MediaFileBase):
|
| 53 |
+
id: str
|
| 54 |
+
entry_id: str
|
| 55 |
+
ai_description: Optional[str] = None
|
| 56 |
+
created_at: datetime
|
| 57 |
+
|
| 58 |
+
model_config = ConfigDict(from_attributes=True)
|
| 59 |
+
|
| 60 |
+
# Tag schemas
|
| 61 |
+
class TagBase(BaseModel):
|
| 62 |
+
name: str
|
| 63 |
+
color: Optional[str] = None
|
| 64 |
+
|
| 65 |
+
class TagCreate(TagBase):
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
class TagResponse(TagBase):
|
| 69 |
+
id: str
|
| 70 |
+
|
| 71 |
+
model_config = ConfigDict(from_attributes=True)
|
| 72 |
+
|
| 73 |
+
# Chat message schemas
|
| 74 |
+
class ChatMessageBase(BaseModel):
|
| 75 |
+
role: str = Field(..., pattern="^(user|assistant)$")
|
| 76 |
+
content: str
|
| 77 |
+
|
| 78 |
+
class ChatMessageCreate(ChatMessageBase):
|
| 79 |
+
related_entries: Optional[List[str]] = None
|
| 80 |
+
|
| 81 |
+
class ChatMessageResponse(ChatMessageBase):
|
| 82 |
+
id: str
|
| 83 |
+
related_entries: Optional[str] = None # JSON string
|
| 84 |
+
created_at: datetime
|
| 85 |
+
|
| 86 |
+
model_config = ConfigDict(from_attributes=True)
|
| 87 |
+
|
| 88 |
+
# User config schemas
|
| 89 |
+
class UserConfigUpdate(BaseModel):
|
| 90 |
+
key: str
|
| 91 |
+
value: str
|
| 92 |
+
|
| 93 |
+
class UserConfigResponse(BaseModel):
|
| 94 |
+
key: str
|
| 95 |
+
value: str
|
| 96 |
+
updated_at: datetime
|
| 97 |
+
|
| 98 |
+
model_config = ConfigDict(from_attributes=True)
|
| 99 |
+
|
| 100 |
+
class ModelConfigRequest(BaseModel):
|
| 101 |
+
model_name: str
|
| 102 |
+
api_key: Optional[str] = None
|
| 103 |
+
|
| 104 |
+
class ModelTestResponse(BaseModel):
|
| 105 |
+
success: bool
|
| 106 |
+
model: Optional[str] = None
|
| 107 |
+
response: Optional[str] = None
|
| 108 |
+
error: Optional[str] = None
|
| 109 |
+
|
| 110 |
+
class ConfigResponse(BaseModel):
|
| 111 |
+
"""Response for configuration data."""
|
| 112 |
+
config: Dict[str, str]
|
| 113 |
+
success: bool = True
|
| 114 |
+
error: Optional[str] = None
|
| 115 |
+
|
| 116 |
+
class ModelInfoResponse(BaseModel):
|
| 117 |
+
"""Response for model information."""
|
| 118 |
+
success: bool
|
| 119 |
+
data: Dict[str, Any] = {}
|
| 120 |
+
error: Optional[str] = None
|
| 121 |
+
|
| 122 |
+
# AI processing schemas
|
| 123 |
+
class AIAnalysisRequest(BaseModel):
|
| 124 |
+
content: str
|
| 125 |
+
content_type: str = Field(..., pattern="^(text|image|audio)$")
|
| 126 |
+
|
| 127 |
+
class AIAnalysisResponse(BaseModel):
|
| 128 |
+
analysis: Dict[str, Any]
|
| 129 |
+
success: bool
|
| 130 |
+
error: Optional[str] = None
|
| 131 |
+
|
| 132 |
+
class ChatRequest(BaseModel):
|
| 133 |
+
message: str
|
| 134 |
+
context_entries: Optional[List[str]] = None
|
| 135 |
+
conversation_history: Optional[List[Dict[str, str]]] = None
|
| 136 |
+
|
| 137 |
+
class ChatResponse(BaseModel):
|
| 138 |
+
response: str
|
| 139 |
+
success: bool
|
| 140 |
+
error: Optional[str] = None
|
| 141 |
+
|
| 142 |
+
class ConversationSummaryRequest(BaseModel):
|
| 143 |
+
conversation: List[Dict[str, str]]
|
| 144 |
+
|
| 145 |
+
class ConversationSummaryResponse(BaseModel):
|
| 146 |
+
summary: Dict[str, Any]
|
| 147 |
+
success: bool
|
| 148 |
+
error: Optional[str] = None
|
| 149 |
+
|
| 150 |
+
# Statistics schemas
|
| 151 |
+
class AppStatistics(BaseModel):
|
| 152 |
+
total_entries: int
|
| 153 |
+
total_words: int
|
| 154 |
+
entries_this_month: int
|
| 155 |
+
entries_this_year: int
|
| 156 |
+
current_streak: int
|
| 157 |
+
longest_streak: int
|
| 158 |
+
average_mood: float
|
| 159 |
+
most_common_themes: List[str]
|
| 160 |
+
|
| 161 |
+
# Insights schemas
|
| 162 |
+
class MoodTrend(BaseModel):
|
| 163 |
+
date: str
|
| 164 |
+
average_mood: float
|
| 165 |
+
entry_count: int
|
| 166 |
+
|
| 167 |
+
class CommonTheme(BaseModel):
|
| 168 |
+
theme: str
|
| 169 |
+
frequency: int
|
| 170 |
+
|
| 171 |
+
class EmotionalPattern(BaseModel):
|
| 172 |
+
emotion: str
|
| 173 |
+
percentage: float
|
| 174 |
+
|
| 175 |
+
class ActionableOutcome(BaseModel):
|
| 176 |
+
title: str
|
| 177 |
+
description: str
|
| 178 |
+
timeframe: str
|
| 179 |
+
category: str
|
| 180 |
+
|
| 181 |
+
class InsightsRequest(BaseModel):
|
| 182 |
+
entry_ids: Optional[List[str]] = None
|
| 183 |
+
date_range: Optional[Dict[str, str]] = None
|
| 184 |
+
|
| 185 |
+
class InsightsResponse(BaseModel):
|
| 186 |
+
mood_trends: List[MoodTrend]
|
| 187 |
+
common_themes: List[CommonTheme]
|
| 188 |
+
emotional_patterns: List[EmotionalPattern]
|
| 189 |
+
summary: str
|
| 190 |
+
# New fields for enhanced personal growth insights
|
| 191 |
+
growth_areas: Optional[List[str]] = []
|
| 192 |
+
actionable_outcomes: Optional[List[ActionableOutcome]] = []
|
| 193 |
+
strengths_identified: Optional[List[str]] = []
|
| 194 |
+
progress_indicators: Optional[List[str]] = []
|
| 195 |
+
growth_summary: Optional[str] = ""
|
| 196 |
+
|
| 197 |
+
# Crisis detection schemas
|
| 198 |
+
class CrisisDetectionResponse(BaseModel):
|
| 199 |
+
crisis_detected: bool
|
| 200 |
+
severity: str
|
| 201 |
+
reason: str
|
| 202 |
+
success: bool
|
| 203 |
+
error: Optional[str] = None
|
| 204 |
+
|
| 205 |
+
class MotivationalSupportResponse(BaseModel):
|
| 206 |
+
support: Dict[str, Any]
|
| 207 |
+
success: bool
|
| 208 |
+
error: Optional[str] = None
|
| 209 |
+
|
| 210 |
+
class PsychologicalImageAnalysisRequest(BaseModel):
|
| 211 |
+
image_data: str # base64 encoded image
|
| 212 |
+
context_text: Optional[str] = None # optional journal entry text for context
|
| 213 |
+
|
| 214 |
+
# Error response schema
|
| 215 |
+
class ErrorResponse(BaseModel):
|
| 216 |
+
error: Dict[str, Any]
|
backend/prompts.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mood_analysis": {
|
| 3 |
+
"system": "You are an AI assistant that analyzes the emotional tone and mood of journal entries. Provide objective, helpful insights while being supportive and non-judgmental. Act as a thoughtful psychological companion who asks meaningful follow-up questions to stimulate deeper self-reflection using techniques from cognitive behavioral therapy, mindfulness, and positive psychology. You must respond with valid JSON only.",
|
| 4 |
+
"user": "Analyze the emotional tone and mood of the following journal entry. Provide a mood score from 1-10 (1=very negative, 10=very positive), identify key emotional themes, and generate 2-3 thoughtful follow-up questions that would help the user explore their feelings and experiences more deeply.\n\nFor follow-up questions, choose from these psychological techniques based on what would be most helpful:\n\n**Cognitive Exploration:**\n- \"What thoughts were going through your mind when you felt [emotion]?\"\n- \"What assumptions might you be making about this situation?\"\n- \"How might someone you admire view this situation?\"\n\n**Pattern Recognition:**\n- \"When have you felt similar emotions before? What patterns do you notice?\"\n- \"What triggers tend to bring up these feelings for you?\"\n- \"How do you typically respond when you feel this way?\"\n\n**Values & Growth:**\n- \"How does this experience connect to what matters most to you?\"\n- \"What would you tell a friend going through something similar?\"\n- \"What might this experience be teaching you about yourself?\"\n\n**Gratitude & Reframing:**\n- \"Despite the challenges, what are you grateful for in this situation?\"\n- \"What strengths did you show in handling this?\"\n- \"How might you view this differently in a year from now?\"\n\n**Mindfulness & Self-Compassion:**\n- \"What physical sensations do you notice when you think about this?\"\n- \"How can you be kinder to yourself about this experience?\"\n- \"What would it feel like to fully accept this emotion without judgment?\"\n\nEntry: {content}\n\nRespond with valid JSON in this exact format:\n{{\n \"mood_score\": <number between 1-10>,\n \"primary_emotion\": \"<single primary emotion>\",\n \"themes\": [\"<theme1>\", \"<theme2>\", \"<theme3>\"],\n \"summary\": \"<brief emotional summary in 1-2 sentences>\",\n \"follow_up_questions\": [\"<thoughtful question 1>\", \"<thoughtful question 2>\", \"<thoughtful question 3>\"]\n}}"
|
| 5 |
+
},
|
| 6 |
+
"image_analysis": {
|
| 7 |
+
"system": "You are a psychologically-informed AI assistant that analyzes images in the context of personal mental health journaling. You understand visual psychology, emotional expression, environmental psychology, and therapeutic photo analysis. Focus on psychological insights that can help users understand their emotional patterns, relationships, and mental state. You must respond with valid JSON only.",
|
| 8 |
+
"user": "Analyze this image from a psychological perspective for a personal journal entry. Consider:\n\n**Visual Psychology Elements:**\n- Facial expressions and body language (if people are present)\n- Color psychology and emotional associations\n- Composition and visual balance\n- Lighting and its psychological impact\n\n**Environmental Psychology:**\n- Space organization and its reflection of mental state\n- Social vs. solitary settings\n- Natural vs. urban environments\n- Personal vs. public spaces\n\n**Therapeutic Insights:**\n- What emotions might this image represent or trigger?\n- What patterns or themes might this reveal about the user's life?\n- How might this image relate to personal growth or challenges?\n- What questions might help the user reflect deeper on this moment?\n\nRespond with valid JSON in this exact format:\n{{\n \"description\": \"<detailed psychological description of the image>\",\n \"emotional_indicators\": [\"<psychological indicator1>\", \"<indicator2>\", \"<indicator3>\"],\n \"psychological_themes\": [\"<theme1>\", \"<theme2>\", \"<theme3>\"],\n \"mood_assessment\": \"<overall mood/emotional tone of the image>\",\n \"environmental_context\": \"<psychological significance of the setting>\",\n \"reflection_questions\": [\"<therapeutic question1>\", \"<question2>\", \"<question3>\"],\n \"suggested_tags\": [\"<psychologically relevant tag1>\", \"<tag2>\", \"<tag3>\"],\n \"therapeutic_insights\": \"<brief insight about what this image might reveal about the user's mental/emotional state>\"\n}}"
|
| 9 |
+
},
|
| 10 |
+
"audio_transcription": {
|
| 11 |
+
"system": "You are an AI assistant that transcribes audio recordings for personal journal entries. Provide accurate transcriptions while maintaining the natural flow and emotional tone of speech. You must respond with valid JSON only.",
|
| 12 |
+
"user": "Transcribe the following audio recording for a personal journal entry. Maintain natural speech patterns and emotional tone.\n\nRespond with valid JSON in this exact format:\n{{\n \"transcription\": \"<accurate transcription of the audio>\",\n \"confidence\": <decimal number between 0.0-1.0>,\n \"detected_emotions\": [\"<emotion1>\", \"<emotion2>\", \"<emotion3>\"]\n}}"
|
| 13 |
+
},
|
| 14 |
+
"chat_system": {
|
| 15 |
+
"system": "You are a supportive AI companion for a personal journaling app called QuietRoom. Your role is to help users reflect on their experiences, provide gentle insights, and encourage personal growth.\n\nGuidelines:\n- Be empathetic and non-judgmental\n- Ask thoughtful follow-up questions\n- Avoid giving medical or professional advice\n- Focus on self-reflection and personal insights\n- Maintain a warm, supportive tone\n- Encourage healthy coping strategies and mindfulness\n- Respect user privacy and confidentiality",
|
| 16 |
+
"user": "{message}"
|
| 17 |
+
},
|
| 18 |
+
"insights_generation": {
|
| 19 |
+
"system": "You are an AI assistant that generates meaningful insights from journal entries to help users understand patterns in their thoughts, emotions, and experiences. Focus on actionable personal growth outcomes and specific recommendations. You must respond with valid JSON only.",
|
| 20 |
+
"user": "Based on the following journal entries, generate insights about emotional patterns, personal growth opportunities, and actionable outcomes. Focus on constructive observations that promote self-awareness and provide specific, actionable recommendations for personal development.\n\nEntries: {entries}\n\nRespond with valid JSON in this exact format:\n{{\n \"patterns\": [\"<pattern1>\", \"<pattern2>\", \"<pattern3>\"],\n \"growth_areas\": [\"<specific area for improvement>\", \"<another growth area>\", \"<third growth area>\"],\n \"actionable_outcomes\": [\n {{\n \"title\": \"<specific action title>\",\n \"description\": \"<what to do and why>\",\n \"timeframe\": \"<daily/weekly/monthly>\",\n \"category\": \"<emotional/behavioral/mindset/relationship>\"\n }},\n {{\n \"title\": \"<specific action title>\",\n \"description\": \"<what to do and why>\",\n \"timeframe\": \"<daily/weekly/monthly>\",\n \"category\": \"<emotional/behavioral/mindset/relationship>\"\n }},\n {{\n \"title\": \"<specific action title>\",\n \"description\": \"<what to do and why>\",\n \"timeframe\": \"<daily/weekly/monthly>\",\n \"category\": \"<emotional/behavioral/mindset/relationship>\"\n }}\n ],\n \"strengths_identified\": [\"<strength1>\", \"<strength2>\", \"<strength3>\"],\n \"progress_indicators\": [\"<measurable indicator1>\", \"<measurable indicator2>\"],\n \"overall_trend\": \"<positive or neutral or concerning>\",\n \"growth_summary\": \"<personalized summary focusing on growth potential and next steps>\"\n}}"
|
| 21 |
+
},
|
| 22 |
+
"conversation_summary": {
|
| 23 |
+
"system": "You are an AI assistant that creates meaningful journal entries from chat conversations. Transform the conversation into a reflective journal entry that captures the key topics, emotions, and insights discussed. You must respond with valid JSON only.",
|
| 24 |
+
"user": "Based on the following conversation, create a journal entry that summarizes the key topics, emotions, and insights. Focus on the user's thoughts, feelings, and any meaningful reflections that emerged during the chat.\n\nConversation: {conversation}\n\nRespond with valid JSON in this exact format:\n{{\n \"title\": \"<meaningful title for the entry>\",\n \"content\": \"<journal entry content that captures the essence of the conversation>\",\n \"mood_score\": <number between 1-10>,\n \"tags\": [\"<tag1>\", \"<tag2>\", \"<tag3>\"]\n}}"
|
| 25 |
+
},
|
| 26 |
+
"crisis_detection": {
|
| 27 |
+
"system": "You are a safety-focused AI assistant that analyzes journal entries for signs of emotional distress or crisis. Your role is to identify when users might benefit from additional support and encouragement. You must respond with valid JSON only.",
|
| 28 |
+
"user": "CRITICAL SAFETY INSTRUCTION:\nAnalyze the following journal entry text for signs of emotional distress, crisis, or extremely low mood that might indicate the user needs additional support and encouragement.\n\nIf the text contains clear indicators of:\n- Self-harm thoughts or intentions\n- Suicidal ideation\n- Severe depression or hopelessness\n- Crisis situations requiring immediate support\n- Extremely negative mood (mood score would be 1-3)\n\nRespond with: {{\"critical\": true, \"severity\": \"high\", \"reason\": \"<brief explanation>\"}}\n\nIf the text shows moderate distress or low mood (mood score 4-5) that could benefit from encouragement:\nRespond with: {{\"critical\": false, \"severity\": \"moderate\", \"reason\": \"<brief explanation>\"}}\n\nFor all other content (mood score 6-10):\nRespond with: {{\"critical\": false, \"severity\": \"low\", \"reason\": \"<brief explanation>\"}}\n\nText: \"{content}\""
|
| 29 |
+
},
|
| 30 |
+
"motivational_support": {
|
| 31 |
+
"system": "You are a compassionate AI companion that provides gentle encouragement and practical suggestions for users experiencing difficult times. Focus on hope, self-care, and positive next steps. You must respond with valid JSON only.",
|
| 32 |
+
"user": "The user is having a difficult time based on their journal entry. Provide compassionate support with motivational phrases and practical suggestions to help them end their day better and start tomorrow positively.\n\nEntry: {content}\n\nRespond with valid JSON in this exact format:\n{{\n \"motivational_phrases\": [\"<encouraging phrase 1>\", \"<encouraging phrase 2>\", \"<encouraging phrase 3>\"],\n \"evening_suggestions\": [\"<practical evening activity 1>\", \"<practical evening activity 2>\", \"<practical evening activity 3>\"],\n \"tomorrow_suggestions\": [\"<positive morning activity 1>\", \"<positive morning activity 2>\", \"<positive morning activity 3>\"],\n \"affirmation\": \"<personal affirmation based on their situation>\",\n \"gentle_reminder\": \"<gentle reminder about self-compassion and hope>\"\n}}"
|
| 33 |
+
}
|
| 34 |
+
}
|
backend/pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"dotenv>=0.9.9",
|
| 9 |
+
"fastapi>=0.116.1",
|
| 10 |
+
"json-repair>=0.48.0",
|
| 11 |
+
"litellm>=1.74.9.post1",
|
| 12 |
+
"ollama>=0.5.1",
|
| 13 |
+
"pillow>=11.3.0",
|
| 14 |
+
"pydantic>=2.11.7",
|
| 15 |
+
"python-dotenv>=1.1.1",
|
| 16 |
+
"python-multipart>=0.0.20",
|
| 17 |
+
"selenium>=4.34.2",
|
| 18 |
+
"sqlalchemy>=2.0.42",
|
| 19 |
+
"uvicorn>=0.35.0",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
[dependency-groups]
|
| 23 |
+
dev = [
|
| 24 |
+
"pytest>=8.4.1",
|
| 25 |
+
]
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiohappyeyeballs==2.6.1
|
| 2 |
+
aiohttp==3.12.15
|
| 3 |
+
aiosignal==1.4.0
|
| 4 |
+
annotated-types==0.7.0
|
| 5 |
+
anyio==4.9.0
|
| 6 |
+
attrs==25.3.0
|
| 7 |
+
certifi==2025.7.14
|
| 8 |
+
charset-normalizer==3.4.2
|
| 9 |
+
click==8.2.1
|
| 10 |
+
distro==1.9.0
|
| 11 |
+
dotenv==0.9.9
|
| 12 |
+
fastapi==0.116.1
|
| 13 |
+
filelock==3.18.0
|
| 14 |
+
frozenlist==1.7.0
|
| 15 |
+
fsspec==2025.7.0
|
| 16 |
+
greenlet==3.2.3
|
| 17 |
+
h11==0.16.0
|
| 18 |
+
hf-xet==1.1.5
|
| 19 |
+
httpcore==1.0.9
|
| 20 |
+
httpx==0.28.1
|
| 21 |
+
huggingface-hub==0.34.3
|
| 22 |
+
idna==3.10
|
| 23 |
+
importlib-metadata==8.7.0
|
| 24 |
+
iniconfig==2.1.0
|
| 25 |
+
jinja2==3.1.6
|
| 26 |
+
jiter==0.10.0
|
| 27 |
+
json-repair==0.48.0
|
| 28 |
+
jsonschema==4.25.0
|
| 29 |
+
jsonschema-specifications==2025.4.1
|
| 30 |
+
litellm==1.74.9.post1
|
| 31 |
+
markupsafe==3.0.2
|
| 32 |
+
multidict==6.6.3
|
| 33 |
+
ollama==0.5.1
|
| 34 |
+
openai==1.98.0
|
| 35 |
+
outcome==1.3.0.post0
|
| 36 |
+
packaging==25.0
|
| 37 |
+
pillow==11.3.0
|
| 38 |
+
pluggy==1.6.0
|
| 39 |
+
propcache==0.3.2
|
| 40 |
+
pydantic==2.11.7
|
| 41 |
+
pydantic-core==2.33.2
|
| 42 |
+
pygments==2.19.2
|
| 43 |
+
pysocks==1.7.1
|
| 44 |
+
pytest==8.4.1
|
| 45 |
+
python-dotenv==1.1.1
|
| 46 |
+
python-multipart==0.0.20
|
| 47 |
+
pyyaml==6.0.2
|
| 48 |
+
referencing==0.36.2
|
| 49 |
+
regex==2025.7.34
|
| 50 |
+
requests==2.32.4
|
| 51 |
+
rpds-py==0.26.0
|
| 52 |
+
selenium==4.34.2
|
| 53 |
+
sniffio==1.3.1
|
| 54 |
+
sortedcontainers==2.4.0
|
| 55 |
+
sqlalchemy==2.0.42
|
| 56 |
+
starlette==0.47.2
|
| 57 |
+
tiktoken==0.9.0
|
| 58 |
+
tokenizers==0.21.4
|
| 59 |
+
tqdm==4.67.1
|
| 60 |
+
trio==0.30.0
|
| 61 |
+
trio-websocket==0.12.2
|
| 62 |
+
typing-extensions==4.14.1
|
| 63 |
+
typing-inspection==0.4.1
|
| 64 |
+
urllib3==2.5.0
|
| 65 |
+
uvicorn==0.35.0
|
| 66 |
+
websocket-client==1.8.0
|
| 67 |
+
wsproto==1.2.0
|
| 68 |
+
yarl==1.20.1
|
| 69 |
+
zipp==3.23.0
|
backend/uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Frontend directory placeholder
|
frontend/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## Expanding the ESLint configuration
|
| 11 |
+
|
| 12 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 13 |
+
|
| 14 |
+
```js
|
| 15 |
+
export default tseslint.config([
|
| 16 |
+
globalIgnores(['dist']),
|
| 17 |
+
{
|
| 18 |
+
files: ['**/*.{ts,tsx}'],
|
| 19 |
+
extends: [
|
| 20 |
+
// Other configs...
|
| 21 |
+
|
| 22 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 23 |
+
...tseslint.configs.recommendedTypeChecked,
|
| 24 |
+
// Alternatively, use this for stricter rules
|
| 25 |
+
...tseslint.configs.strictTypeChecked,
|
| 26 |
+
// Optionally, add this for stylistic rules
|
| 27 |
+
...tseslint.configs.stylisticTypeChecked,
|
| 28 |
+
|
| 29 |
+
// Other configs...
|
| 30 |
+
],
|
| 31 |
+
languageOptions: {
|
| 32 |
+
parserOptions: {
|
| 33 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 34 |
+
tsconfigRootDir: import.meta.dirname,
|
| 35 |
+
},
|
| 36 |
+
// other options...
|
| 37 |
+
},
|
| 38 |
+
},
|
| 39 |
+
])
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 43 |
+
|
| 44 |
+
```js
|
| 45 |
+
// eslint.config.js
|
| 46 |
+
import reactX from 'eslint-plugin-react-x'
|
| 47 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 48 |
+
|
| 49 |
+
export default tseslint.config([
|
| 50 |
+
globalIgnores(['dist']),
|
| 51 |
+
{
|
| 52 |
+
files: ['**/*.{ts,tsx}'],
|
| 53 |
+
extends: [
|
| 54 |
+
// Other configs...
|
| 55 |
+
// Enable lint rules for React
|
| 56 |
+
reactX.configs['recommended-typescript'],
|
| 57 |
+
// Enable lint rules for React DOM
|
| 58 |
+
reactDom.configs.recommended,
|
| 59 |
+
],
|
| 60 |
+
languageOptions: {
|
| 61 |
+
parserOptions: {
|
| 62 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 63 |
+
tsconfigRootDir: import.meta.dirname,
|
| 64 |
+
},
|
| 65 |
+
// other options...
|
| 66 |
+
},
|
| 67 |
+
},
|
| 68 |
+
])
|
| 69 |
+
```
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default tseslint.config([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs['recommended-latest'],
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>QuietRoom</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@heroicons/react": "^2.2.0",
|
| 14 |
+
"@tailwindcss/vite": "^4.1.11",
|
| 15 |
+
"@tanstack/react-query": "^5.83.1",
|
| 16 |
+
"axios": "^1.11.0",
|
| 17 |
+
"react": "^19.1.0",
|
| 18 |
+
"react-dom": "^19.1.0",
|
| 19 |
+
"react-hook-form": "^7.61.1",
|
| 20 |
+
"react-markdown": "^10.1.0",
|
| 21 |
+
"react-router-dom": "^7.7.1",
|
| 22 |
+
"rehype-highlight": "^7.0.2",
|
| 23 |
+
"remark-gfm": "^4.0.1"
|
| 24 |
+
},
|
| 25 |
+
"devDependencies": {
|
| 26 |
+
"@eslint/js": "^9.30.1",
|
| 27 |
+
"@tailwindcss/forms": "^0.5.10",
|
| 28 |
+
"@tanstack/react-query-devtools": "^5.84.0",
|
| 29 |
+
"@types/react": "^19.1.8",
|
| 30 |
+
"@types/react-dom": "^19.1.6",
|
| 31 |
+
"@vitejs/plugin-react": "^4.6.0",
|
| 32 |
+
"autoprefixer": "^10.4.21",
|
| 33 |
+
"eslint": "^9.30.1",
|
| 34 |
+
"eslint-plugin-react-hooks": "^5.2.0",
|
| 35 |
+
"eslint-plugin-react-refresh": "^0.4.20",
|
| 36 |
+
"globals": "^16.3.0",
|
| 37 |
+
"postcss": "^8.5.6",
|
| 38 |
+
"tailwindcss": "^4.1.11",
|
| 39 |
+
"typescript": "~5.8.3",
|
| 40 |
+
"typescript-eslint": "^8.35.1",
|
| 41 |
+
"vite": "^7.0.4"
|
| 42 |
+
}
|
| 43 |
+
}
|
frontend/public/quiet_room_extended.png
ADDED
|
Git LFS Details
|
frontend/public/vite.svg
ADDED
|
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
| 2 |
+
import { ThemeProvider } from './contexts/ThemeContext';
|
| 3 |
+
import { PreferencesProvider } from './contexts/PreferencesContext';
|
| 4 |
+
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
| 5 |
+
import { DevelopmentBanner } from './components/common/DevelopmentBanner';
|
| 6 |
+
import Layout from './components/layout/Layout';
|
| 7 |
+
import Home from './pages/Home';
|
| 8 |
+
import Journal from './pages/Journal';
|
| 9 |
+
import Timeline from './pages/Timeline';
|
| 10 |
+
import EntryDetail from './pages/EntryDetail';
|
| 11 |
+
import Chat from './pages/Chat';
|
| 12 |
+
import Settings from './pages/Settings';
|
| 13 |
+
|
| 14 |
+
function App() {
|
| 15 |
+
return (
|
| 16 |
+
<ErrorBoundary>
|
| 17 |
+
<ThemeProvider>
|
| 18 |
+
<PreferencesProvider>
|
| 19 |
+
<DevelopmentBanner />
|
| 20 |
+
<Router>
|
| 21 |
+
<Routes>
|
| 22 |
+
<Route path="/" element={<Layout />}>
|
| 23 |
+
<Route index element={<Home />} />
|
| 24 |
+
<Route path="journal" element={<Journal />} />
|
| 25 |
+
<Route path="journal/edit/:id" element={<Journal />} />
|
| 26 |
+
<Route path="timeline" element={<Timeline />} />
|
| 27 |
+
<Route path="entry/:id" element={<EntryDetail />} />
|
| 28 |
+
<Route path="chat" element={<Chat />} />
|
| 29 |
+
<Route path="settings" element={<Settings />} />
|
| 30 |
+
</Route>
|
| 31 |
+
</Routes>
|
| 32 |
+
</Router>
|
| 33 |
+
</PreferencesProvider>
|
| 34 |
+
</ThemeProvider>
|
| 35 |
+
</ErrorBoundary>
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export default App;
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/components/chat/MessageBubble.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ChatMessage } from '../../types/chat';
|
| 2 |
+
import MarkdownRenderer from '../common/MarkdownRenderer';
|
| 3 |
+
|
| 4 |
+
interface MessageBubbleProps {
|
| 5 |
+
message: ChatMessage;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
const MessageBubble = ({ message }: MessageBubbleProps) => {
|
| 9 |
+
const isUser = message.role === 'user';
|
| 10 |
+
|
| 11 |
+
return (
|
| 12 |
+
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
|
| 13 |
+
|
| 14 |
+
<div
|
| 15 |
+
className={`max-w-[70%] rounded-lg px-4 py-3 shadow-sm ${
|
| 16 |
+
isUser
|
| 17 |
+
? 'bg-blue-600 text-white ml-auto'
|
| 18 |
+
: 'bg-white text-gray-900 border border-gray-200'
|
| 19 |
+
}`}
|
| 20 |
+
>
|
| 21 |
+
<div className="leading-relaxed">
|
| 22 |
+
{isUser ? (
|
| 23 |
+
<div className="whitespace-pre-wrap break-words">
|
| 24 |
+
{message.content}
|
| 25 |
+
</div>
|
| 26 |
+
) : (
|
| 27 |
+
<MarkdownRenderer
|
| 28 |
+
content={message.content}
|
| 29 |
+
className="prose-invert text-inherit"
|
| 30 |
+
/>
|
| 31 |
+
)}
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
);
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
export default MessageBubble;
|
frontend/src/components/chat/TypingIndicator.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const TypingIndicator = () => {
|
| 2 |
+
return (
|
| 3 |
+
<div className="flex justify-start mb-4">
|
| 4 |
+
<div className="bg-white rounded-lg px-4 py-3 shadow-sm border border-gray-200">
|
| 5 |
+
<div className="flex items-center space-x-2">
|
| 6 |
+
<div className="flex space-x-1">
|
| 7 |
+
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
| 8 |
+
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
| 9 |
+
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
| 10 |
+
</div>
|
| 11 |
+
<span className="text-sm text-gray-500">QuietRoom is writing...</span>
|
| 12 |
+
</div>
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
);
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export default TypingIndicator;
|
frontend/src/components/common/DevelopmentBanner.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
| 3 |
+
|
| 4 |
+
export function DevelopmentBanner() {
|
| 5 |
+
const [isVisible, setIsVisible] = useState(false);
|
| 6 |
+
const [backendStatus, setBackendStatus] = useState<'checking' | 'online' | 'offline'>('checking');
|
| 7 |
+
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
// Only show in development mode
|
| 10 |
+
if (import.meta.env.DEV) {
|
| 11 |
+
checkBackendStatus();
|
| 12 |
+
}
|
| 13 |
+
}, []);
|
| 14 |
+
|
| 15 |
+
const checkBackendStatus = async () => {
|
| 16 |
+
try {
|
| 17 |
+
const response = await fetch('/api/config', {
|
| 18 |
+
method: 'GET',
|
| 19 |
+
signal: AbortSignal.timeout(3000) // 3 second timeout
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
if (response.ok) {
|
| 23 |
+
setBackendStatus('online');
|
| 24 |
+
} else {
|
| 25 |
+
setBackendStatus('offline');
|
| 26 |
+
setIsVisible(true);
|
| 27 |
+
}
|
| 28 |
+
} catch (error) {
|
| 29 |
+
setBackendStatus('offline');
|
| 30 |
+
setIsVisible(true);
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
if (!import.meta.env.DEV || !isVisible || backendStatus !== 'offline') {
|
| 35 |
+
return null;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="bg-yellow-50/20 border-b border-yellow-200">
|
| 40 |
+
<div className="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
|
| 41 |
+
<div className="flex items-center justify-between flex-wrap">
|
| 42 |
+
<div className="w-0 flex-1 flex items-center">
|
| 43 |
+
<span className="flex p-2 rounded-lg bg-yellow-400">
|
| 44 |
+
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-800" />
|
| 45 |
+
</span>
|
| 46 |
+
<p className="ml-3 font-medium text-yellow-800 truncate">
|
| 47 |
+
<span className="md:hidden">Backend offline - using demo mode</span>
|
| 48 |
+
<span className="hidden md:inline">
|
| 49 |
+
Backend server is not available. The app is running in demo mode with placeholder data.
|
| 50 |
+
</span>
|
| 51 |
+
</p>
|
| 52 |
+
</div>
|
| 53 |
+
<div className="order-2 flex-shrink-0 sm:order-3 sm:ml-3">
|
| 54 |
+
<button
|
| 55 |
+
type="button"
|
| 56 |
+
onClick={() => setIsVisible(false)}
|
| 57 |
+
className="-mr-1 flex p-2 rounded-md hover:bg-yellow-100 focus:outline-none focus:ring-2 focus:ring-yellow-600"
|
| 58 |
+
>
|
| 59 |
+
<span className="sr-only">Dismiss</span>
|
| 60 |
+
<XMarkIcon className="h-5 w-5 text-yellow-800" />
|
| 61 |
+
</button>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
frontend/src/components/common/Disclaimer.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const Disclaimer = () => {
|
| 2 |
+
return (
|
| 3 |
+
<div className="mt-12 pt-8 border-t border-gray-200">
|
| 4 |
+
<p className="text-xs text-gray-500 text-center max-w-4xl mx-auto leading-relaxed">
|
| 5 |
+
Developed exclusively as a proof-of-concept for the "Google - The Gemma 3n Impact Challenge".
|
| 6 |
+
It is not intended for use as a medical or therapeutic tool.
|
| 7 |
+
</p>
|
| 8 |
+
</div>
|
| 9 |
+
);
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export default Disclaimer;
|
frontend/src/components/common/ErrorBoundary.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
| 3 |
+
|
| 4 |
+
interface ErrorBoundaryState {
|
| 5 |
+
hasError: boolean;
|
| 6 |
+
error?: Error;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface ErrorBoundaryProps {
|
| 10 |
+
children: React.ReactNode;
|
| 11 |
+
fallback?: React.ComponentType<{ error: Error; resetError: () => void }>;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
| 15 |
+
constructor(props: ErrorBoundaryProps) {
|
| 16 |
+
super(props);
|
| 17 |
+
this.state = { hasError: false };
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
| 21 |
+
return { hasError: true, error };
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
| 25 |
+
console.error('Error caught by boundary:', error, errorInfo);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
resetError = () => {
|
| 29 |
+
this.setState({ hasError: false, error: undefined });
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
render() {
|
| 33 |
+
if (this.state.hasError && this.state.error) {
|
| 34 |
+
if (this.props.fallback) {
|
| 35 |
+
const FallbackComponent = this.props.fallback;
|
| 36 |
+
return <FallbackComponent error={this.state.error} resetError={this.resetError} />;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return <DefaultErrorFallback error={this.state.error} resetError={this.resetError} />;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return this.props.children;
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Default error fallback component
|
| 47 |
+
function DefaultErrorFallback({ error, resetError }: { error: Error; resetError: () => void }) {
|
| 48 |
+
return (
|
| 49 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
| 50 |
+
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
|
| 51 |
+
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full">
|
| 52 |
+
<ExclamationTriangleIcon className="w-6 h-6 text-red-600" />
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="mt-4 text-center">
|
| 56 |
+
<h3 className="text-lg font-medium text-gray-900">
|
| 57 |
+
Something went wrong
|
| 58 |
+
</h3>
|
| 59 |
+
<p className="mt-2 text-sm text-gray-500">
|
| 60 |
+
{error.message || 'An unexpected error occurred'}
|
| 61 |
+
</p>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div className="mt-6">
|
| 65 |
+
<button
|
| 66 |
+
onClick={resetError}
|
| 67 |
+
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
| 68 |
+
>
|
| 69 |
+
Try again
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
{import.meta.env.DEV && (
|
| 74 |
+
<details className="mt-4">
|
| 75 |
+
<summary className="text-sm text-gray-500 cursor-pointer">
|
| 76 |
+
Error details (dev only)
|
| 77 |
+
</summary>
|
| 78 |
+
<pre className="mt-2 text-xs text-gray-600 bg-gray-100 p-2 rounded overflow-auto">
|
| 79 |
+
{error.stack}
|
| 80 |
+
</pre>
|
| 81 |
+
</details>
|
| 82 |
+
)}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
}
|
frontend/src/components/common/ErrorDisplay.tsx
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { ExclamationTriangleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
| 3 |
+
|
| 4 |
+
interface ErrorDisplayProps {
|
| 5 |
+
error: Error | null;
|
| 6 |
+
title?: string;
|
| 7 |
+
onRetry?: () => void;
|
| 8 |
+
onDismiss?: () => void;
|
| 9 |
+
className?: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function ErrorDisplay({
|
| 13 |
+
error,
|
| 14 |
+
title = 'Error',
|
| 15 |
+
onRetry,
|
| 16 |
+
onDismiss,
|
| 17 |
+
className = ''
|
| 18 |
+
}: ErrorDisplayProps) {
|
| 19 |
+
if (!error) return null;
|
| 20 |
+
|
| 21 |
+
const isNetworkError = error.message.includes('Network error') || error.message.includes('fetch');
|
| 22 |
+
const isServerError = (error as any).status >= 500;
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className={`bg-red-50/20 border border-red-200 rounded-md p-4 ${className}`}>
|
| 26 |
+
<div className="flex">
|
| 27 |
+
<div className="flex-shrink-0">
|
| 28 |
+
{isNetworkError ? (
|
| 29 |
+
<XCircleIcon className="h-5 w-5 text-red-400" />
|
| 30 |
+
) : (
|
| 31 |
+
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
|
| 32 |
+
)}
|
| 33 |
+
</div>
|
| 34 |
+
<div className="ml-3 flex-1">
|
| 35 |
+
<h3 className="text-sm font-medium text-red-800">
|
| 36 |
+
{title}
|
| 37 |
+
</h3>
|
| 38 |
+
<div className="mt-2 text-sm text-red-700">
|
| 39 |
+
<p>{error.message}</p>
|
| 40 |
+
{isNetworkError && (
|
| 41 |
+
<p className="mt-1 text-xs">
|
| 42 |
+
Please check your internet connection and try again.
|
| 43 |
+
</p>
|
| 44 |
+
)}
|
| 45 |
+
{isServerError && (
|
| 46 |
+
<p className="mt-1 text-xs">
|
| 47 |
+
The server is experiencing issues. Please try again later.
|
| 48 |
+
</p>
|
| 49 |
+
)}
|
| 50 |
+
</div>
|
| 51 |
+
{(onRetry || onDismiss) && (
|
| 52 |
+
<div className="mt-4 flex space-x-2">
|
| 53 |
+
{onRetry && (
|
| 54 |
+
<button
|
| 55 |
+
type="button"
|
| 56 |
+
onClick={onRetry}
|
| 57 |
+
className="bg-red-100 px-3 py-1.5 rounded-md text-sm font-medium text-red-800 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
| 58 |
+
>
|
| 59 |
+
Try again
|
| 60 |
+
</button>
|
| 61 |
+
)}
|
| 62 |
+
{onDismiss && (
|
| 63 |
+
<button
|
| 64 |
+
type="button"
|
| 65 |
+
onClick={onDismiss}
|
| 66 |
+
className="bg-transparent px-3 py-1.5 rounded-md text-sm font-medium text-red-600 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
| 67 |
+
>
|
| 68 |
+
Dismiss
|
| 69 |
+
</button>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
)}
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
interface InlineErrorProps {
|
| 80 |
+
error: Error | null;
|
| 81 |
+
className?: string;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export function InlineError({ error, className = '' }: InlineErrorProps) {
|
| 85 |
+
if (!error) return null;
|
| 86 |
+
|
| 87 |
+
return (
|
| 88 |
+
<div className={`flex items-center text-sm text-red-600 ${className}`}>
|
| 89 |
+
<ExclamationTriangleIcon className="h-4 w-4 mr-1 flex-shrink-0" />
|
| 90 |
+
<span>{error.message}</span>
|
| 91 |
+
</div>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
interface ErrorToastProps {
|
| 96 |
+
error: Error | null;
|
| 97 |
+
isVisible: boolean;
|
| 98 |
+
onDismiss: () => void;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export function ErrorToast({ error, isVisible, onDismiss }: ErrorToastProps) {
|
| 102 |
+
React.useEffect(() => {
|
| 103 |
+
if (isVisible && error) {
|
| 104 |
+
const timer = setTimeout(() => {
|
| 105 |
+
onDismiss();
|
| 106 |
+
}, 5000); // Auto-dismiss after 5 seconds
|
| 107 |
+
|
| 108 |
+
return () => clearTimeout(timer);
|
| 109 |
+
}
|
| 110 |
+
}, [isVisible, error, onDismiss]);
|
| 111 |
+
|
| 112 |
+
if (!isVisible || !error) return null;
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div className="fixed top-4 right-4 z-50 max-w-sm w-full">
|
| 116 |
+
<div className="bg-red-500 text-white p-4 rounded-lg shadow-lg">
|
| 117 |
+
<div className="flex items-start">
|
| 118 |
+
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 mr-3 flex-shrink-0" />
|
| 119 |
+
<div className="flex-1">
|
| 120 |
+
<p className="text-sm font-medium">Error</p>
|
| 121 |
+
<p className="text-sm opacity-90">{error.message}</p>
|
| 122 |
+
</div>
|
| 123 |
+
<button
|
| 124 |
+
onClick={onDismiss}
|
| 125 |
+
className="ml-3 flex-shrink-0 text-white hover:text-gray-200"
|
| 126 |
+
>
|
| 127 |
+
<XCircleIcon className="h-5 w-5" />
|
| 128 |
+
</button>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
interface SuccessToastProps {
|
| 136 |
+
message: string;
|
| 137 |
+
isVisible: boolean;
|
| 138 |
+
onDismiss: () => void;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export function SuccessToast({ message, isVisible, onDismiss }: SuccessToastProps) {
|
| 142 |
+
React.useEffect(() => {
|
| 143 |
+
if (isVisible) {
|
| 144 |
+
const timer = setTimeout(() => {
|
| 145 |
+
onDismiss();
|
| 146 |
+
}, 3000); // Auto-dismiss after 3 seconds
|
| 147 |
+
|
| 148 |
+
return () => clearTimeout(timer);
|
| 149 |
+
}
|
| 150 |
+
}, [isVisible, onDismiss]);
|
| 151 |
+
|
| 152 |
+
if (!isVisible) return null;
|
| 153 |
+
|
| 154 |
+
return (
|
| 155 |
+
<div className="fixed top-4 right-4 z-50 max-w-sm w-full">
|
| 156 |
+
<div className="bg-green-500 text-white p-4 rounded-lg shadow-lg">
|
| 157 |
+
<div className="flex items-start">
|
| 158 |
+
<svg className="h-5 w-5 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 159 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
| 160 |
+
</svg>
|
| 161 |
+
<div className="flex-1">
|
| 162 |
+
<p className="text-sm font-medium">Success</p>
|
| 163 |
+
<p className="text-sm opacity-90">{message}</p>
|
| 164 |
+
</div>
|
| 165 |
+
<button
|
| 166 |
+
onClick={onDismiss}
|
| 167 |
+
className="ml-3 flex-shrink-0 text-white hover:text-gray-200"
|
| 168 |
+
>
|
| 169 |
+
<XCircleIcon className="h-5 w-5" />
|
| 170 |
+
</button>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
);
|
| 175 |
+
}
|
frontend/src/components/common/LoadingSpinner.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
interface LoadingSpinnerProps {
|
| 4 |
+
size?: 'sm' | 'md' | 'lg';
|
| 5 |
+
className?: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
|
| 9 |
+
const sizeClasses = {
|
| 10 |
+
sm: 'w-4 h-4',
|
| 11 |
+
md: 'w-6 h-6',
|
| 12 |
+
lg: 'w-8 h-8',
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className={`animate-spin ${sizeClasses[size]} ${className}`}>
|
| 17 |
+
<svg
|
| 18 |
+
className="w-full h-full text-blue-600"
|
| 19 |
+
fill="none"
|
| 20 |
+
viewBox="0 0 24 24"
|
| 21 |
+
>
|
| 22 |
+
<circle
|
| 23 |
+
className="opacity-25"
|
| 24 |
+
cx="12"
|
| 25 |
+
cy="12"
|
| 26 |
+
r="10"
|
| 27 |
+
stroke="currentColor"
|
| 28 |
+
strokeWidth="4"
|
| 29 |
+
/>
|
| 30 |
+
<path
|
| 31 |
+
className="opacity-75"
|
| 32 |
+
fill="currentColor"
|
| 33 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
| 34 |
+
/>
|
| 35 |
+
</svg>
|
| 36 |
+
</div>
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
interface LoadingStateProps {
|
| 41 |
+
message?: string;
|
| 42 |
+
className?: string;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function LoadingState({ message = 'Loading...', className = '' }: LoadingStateProps) {
|
| 46 |
+
return (
|
| 47 |
+
<div className={`flex flex-col items-center justify-center p-8 ${className}`}>
|
| 48 |
+
<LoadingSpinner size="lg" />
|
| 49 |
+
<p className="mt-4 text-sm text-gray-500">{message}</p>
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
interface LoadingOverlayProps {
|
| 55 |
+
isVisible: boolean;
|
| 56 |
+
message?: string;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export function LoadingOverlay({ isVisible, message = 'Loading...' }: LoadingOverlayProps) {
|
| 60 |
+
if (!isVisible) return null;
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
| 64 |
+
<div className="bg-white rounded-lg p-6 shadow-xl">
|
| 65 |
+
<div className="flex items-center space-x-3">
|
| 66 |
+
<LoadingSpinner />
|
| 67 |
+
<span className="text-gray-900">{message}</span>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
}
|
frontend/src/components/common/MarkdownRenderer.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ReactMarkdown from 'react-markdown';
|
| 2 |
+
import remarkGfm from 'remark-gfm';
|
| 3 |
+
import rehypeHighlight from 'rehype-highlight';
|
| 4 |
+
|
| 5 |
+
interface MarkdownRendererProps {
|
| 6 |
+
content: string;
|
| 7 |
+
className?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const MarkdownRenderer = ({ content, className = '' }: MarkdownRendererProps) => {
|
| 11 |
+
return (
|
| 12 |
+
<div className={`prose prose-sm max-w-none prose-gray ${className}`}>
|
| 13 |
+
<ReactMarkdown
|
| 14 |
+
remarkPlugins={[remarkGfm]}
|
| 15 |
+
rehypePlugins={[rehypeHighlight]}
|
| 16 |
+
components={{
|
| 17 |
+
// Customize code blocks
|
| 18 |
+
code: ({ inline, className, children, ...props }: any) => {
|
| 19 |
+
const match = /language-(\w+)/.exec(className || '');
|
| 20 |
+
return !inline && match ? (
|
| 21 |
+
<pre className="bg-gray-100 rounded-md p-3 overflow-x-auto">
|
| 22 |
+
<code className={className} {...props}>
|
| 23 |
+
{children}
|
| 24 |
+
</code>
|
| 25 |
+
</pre>
|
| 26 |
+
) : (
|
| 27 |
+
<code
|
| 28 |
+
className="bg-gray-100 px-1 py-0.5 rounded text-sm"
|
| 29 |
+
{...props}
|
| 30 |
+
>
|
| 31 |
+
{children}
|
| 32 |
+
</code>
|
| 33 |
+
);
|
| 34 |
+
},
|
| 35 |
+
// Customize links
|
| 36 |
+
a: ({ children, href, ...props }: any) => (
|
| 37 |
+
<a
|
| 38 |
+
href={href}
|
| 39 |
+
target="_blank"
|
| 40 |
+
rel="noopener noreferrer"
|
| 41 |
+
className="text-blue-600 hover:underline"
|
| 42 |
+
{...props}
|
| 43 |
+
>
|
| 44 |
+
{children}
|
| 45 |
+
</a>
|
| 46 |
+
),
|
| 47 |
+
// Customize blockquotes
|
| 48 |
+
blockquote: ({ children, ...props }: any) => (
|
| 49 |
+
<blockquote
|
| 50 |
+
className="border-l-4 border-gray-300 pl-4 italic text-gray-700"
|
| 51 |
+
{...props}
|
| 52 |
+
>
|
| 53 |
+
{children}
|
| 54 |
+
</blockquote>
|
| 55 |
+
),
|
| 56 |
+
// Customize lists
|
| 57 |
+
ul: ({ children, ...props }: any) => (
|
| 58 |
+
<ul className="list-disc list-inside space-y-1" {...props}>
|
| 59 |
+
{children}
|
| 60 |
+
</ul>
|
| 61 |
+
),
|
| 62 |
+
ol: ({ children, ...props }: any) => (
|
| 63 |
+
<ol className="list-decimal list-inside space-y-1" {...props}>
|
| 64 |
+
{children}
|
| 65 |
+
</ol>
|
| 66 |
+
),
|
| 67 |
+
}}
|
| 68 |
+
>
|
| 69 |
+
{content}
|
| 70 |
+
</ReactMarkdown>
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
export default MarkdownRenderer;
|
frontend/src/components/common/ModelCapabilityError.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
| 2 |
+
|
| 3 |
+
interface ModelCapabilityErrorProps {
|
| 4 |
+
error: string;
|
| 5 |
+
feature: 'image' | 'audio' | 'multimodal';
|
| 6 |
+
currentModel: string;
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
onOpenSettings?: () => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const ModelCapabilityError = ({
|
| 12 |
+
error,
|
| 13 |
+
feature,
|
| 14 |
+
currentModel,
|
| 15 |
+
onClose,
|
| 16 |
+
onOpenSettings
|
| 17 |
+
}: ModelCapabilityErrorProps) => {
|
| 18 |
+
const getFeatureName = () => {
|
| 19 |
+
switch (feature) {
|
| 20 |
+
case 'image': return 'Image Analysis';
|
| 21 |
+
case 'audio': return 'Audio Processing';
|
| 22 |
+
case 'multimodal': return 'Multimodal Processing';
|
| 23 |
+
default: return 'This Feature';
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const getRecommendedModels = () => {
|
| 28 |
+
switch (feature) {
|
| 29 |
+
case 'image':
|
| 30 |
+
case 'audio':
|
| 31 |
+
case 'multimodal':
|
| 32 |
+
return [
|
| 33 |
+
'gemini/gemini-2.5-flash',
|
| 34 |
+
'gemini/gemini-2.5-flash-lite',
|
| 35 |
+
'openai/gpt-4o',
|
| 36 |
+
'openai/gpt-4o-mini'
|
| 37 |
+
];
|
| 38 |
+
default:
|
| 39 |
+
return [];
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
| 45 |
+
<div className="bg-white rounded-lg max-w-md w-full">
|
| 46 |
+
<div className="p-6">
|
| 47 |
+
{/* Header */}
|
| 48 |
+
<div className="flex items-center justify-between mb-4">
|
| 49 |
+
<div className="flex items-center space-x-3">
|
| 50 |
+
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
| 51 |
+
<ExclamationTriangleIcon className="w-6 h-6 text-yellow-600" />
|
| 52 |
+
</div>
|
| 53 |
+
<div>
|
| 54 |
+
<h2 className="text-lg font-semibold text-gray-900">
|
| 55 |
+
{getFeatureName()} Not Supported
|
| 56 |
+
</h2>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
<button
|
| 60 |
+
onClick={onClose}
|
| 61 |
+
className="text-gray-400 hover:text-gray-600 transition-colors"
|
| 62 |
+
>
|
| 63 |
+
<XMarkIcon className="w-6 h-6" />
|
| 64 |
+
</button>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
{/* Content */}
|
| 68 |
+
<div className="space-y-4">
|
| 69 |
+
<div className="bg-yellow-50 rounded-lg p-4">
|
| 70 |
+
<p className="text-sm text-yellow-800">
|
| 71 |
+
<strong>Current Model:</strong> {currentModel}
|
| 72 |
+
</p>
|
| 73 |
+
<p className="text-sm text-yellow-700 mt-2">
|
| 74 |
+
This model doesn't support {feature} processing. The feature requires a multimodal-capable model.
|
| 75 |
+
</p>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div>
|
| 79 |
+
<h3 className="font-medium text-gray-900 mb-2">Recommended Models:</h3>
|
| 80 |
+
<div className="space-y-1">
|
| 81 |
+
{getRecommendedModels().map((model, index) => (
|
| 82 |
+
<div key={index} className="flex items-center space-x-2 text-sm">
|
| 83 |
+
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
|
| 84 |
+
<span className="text-gray-700">{model}</span>
|
| 85 |
+
</div>
|
| 86 |
+
))}
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div className="bg-blue-50 rounded-lg p-4">
|
| 91 |
+
<p className="text-sm text-blue-800">
|
| 92 |
+
<strong>💡 Tip:</strong> Switch to a multimodal model in Settings to enable {feature.toLowerCase()} analysis.
|
| 93 |
+
</p>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
{/* Technical Error Details (Collapsible) */}
|
| 97 |
+
<details className="text-sm">
|
| 98 |
+
<summary className="cursor-pointer text-gray-600 hover:text-gray-800">
|
| 99 |
+
Technical Details
|
| 100 |
+
</summary>
|
| 101 |
+
<div className="mt-2 p-3 bg-gray-50 rounded text-xs text-gray-600 font-mono">
|
| 102 |
+
{error}
|
| 103 |
+
</div>
|
| 104 |
+
</details>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Actions */}
|
| 108 |
+
<div className="flex space-x-3 mt-6">
|
| 109 |
+
<button
|
| 110 |
+
onClick={onClose}
|
| 111 |
+
className="flex-1 px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
| 112 |
+
>
|
| 113 |
+
Close
|
| 114 |
+
</button>
|
| 115 |
+
{onOpenSettings && (
|
| 116 |
+
<button
|
| 117 |
+
onClick={() => {
|
| 118 |
+
onOpenSettings();
|
| 119 |
+
onClose();
|
| 120 |
+
}}
|
| 121 |
+
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
| 122 |
+
>
|
| 123 |
+
Open Settings
|
| 124 |
+
</button>
|
| 125 |
+
)}
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
export default ModelCapabilityError;
|
frontend/src/components/home/AppOverview.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ShieldCheckIcon, DevicePhoneMobileIcon, CpuChipIcon } from '@heroicons/react/24/outline';
|
| 2 |
+
|
| 3 |
+
const AppOverview = () => {
|
| 4 |
+
return (
|
| 5 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-xl p-8 smooth-hover">
|
| 6 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 7 |
+
Privacy-First Journaling
|
| 8 |
+
</h2>
|
| 9 |
+
<div className="space-y-6">
|
| 10 |
+
<div className="group flex items-start space-x-4 p-4 rounded-xl hover:bg-green-50/50/10 transition-all duration-300">
|
| 11 |
+
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-r from-green-400 to-emerald-500 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
| 12 |
+
<ShieldCheckIcon className="h-6 w-6 text-white" />
|
| 13 |
+
</div>
|
| 14 |
+
<div className="flex-1">
|
| 15 |
+
<h3 className="font-semibold text-gray-900 mb-2">Your Data Stays Local</h3>
|
| 16 |
+
<p className="text-gray-600 leading-relaxed">
|
| 17 |
+
All journal entries, media files, and AI analysis happen on your device.
|
| 18 |
+
No cloud storage, no data mining.
|
| 19 |
+
</p>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<div className="group flex items-start space-x-4 p-4 rounded-xl hover:bg-blue-50/50/10 transition-all duration-300">
|
| 24 |
+
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-r from-blue-400 to-cyan-500 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
| 25 |
+
<DevicePhoneMobileIcon className="h-6 w-6 text-white" />
|
| 26 |
+
</div>
|
| 27 |
+
<div className="flex-1">
|
| 28 |
+
<h3 className="font-semibold text-gray-900 mb-2">Multimodal Capture</h3>
|
| 29 |
+
<p className="text-gray-600 leading-relaxed">
|
| 30 |
+
Add text, photos, videos, audio recordings, and location data to your entries.
|
| 31 |
+
</p>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div className="group flex items-start space-x-4 p-4 rounded-xl hover:bg-purple-50/50/10 transition-all duration-300">
|
| 36 |
+
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-r from-purple-400 to-pink-500 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
| 37 |
+
<CpuChipIcon className="h-6 w-6 text-white" />
|
| 38 |
+
</div>
|
| 39 |
+
<div className="flex-1">
|
| 40 |
+
<h3 className="font-semibold text-gray-900 mb-2">AI-Powered Insights</h3>
|
| 41 |
+
<p className="text-gray-600 leading-relaxed">
|
| 42 |
+
Choose your Gemma3n model for mood analysis and insights.
|
| 43 |
+
</p>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
export default AppOverview;
|
frontend/src/components/home/InsightsSummary.tsx
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
LightBulbIcon,
|
| 3 |
+
HeartIcon,
|
| 4 |
+
ArrowTrendingUpIcon,
|
| 5 |
+
SparklesIcon
|
| 6 |
+
} from '@heroicons/react/24/outline';
|
| 7 |
+
import { useInsights } from '../../hooks/useAI';
|
| 8 |
+
import { LoadingState } from '../common/LoadingSpinner';
|
| 9 |
+
import { ErrorDisplay } from '../common/ErrorDisplay';
|
| 10 |
+
|
| 11 |
+
interface Insight {
|
| 12 |
+
type: 'mood' | 'pattern' | 'growth' | 'suggestion';
|
| 13 |
+
title: string;
|
| 14 |
+
description: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const InsightsSummary = () => {
|
| 18 |
+
// Get insights for the last 30 days
|
| 19 |
+
const thirtyDaysAgo = new Date();
|
| 20 |
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
| 21 |
+
|
| 22 |
+
const { data: insightsData, isLoading, error, refetch } = useInsights({
|
| 23 |
+
date_range: {
|
| 24 |
+
start: thirtyDaysAgo.toISOString().split('T')[0],
|
| 25 |
+
end: new Date().toISOString().split('T')[0],
|
| 26 |
+
},
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
// Transform API data into display format - focus on patterns and themes
|
| 30 |
+
const insights: Insight[] = insightsData ? [
|
| 31 |
+
...(insightsData.emotional_patterns || []).slice(0, 3).map(pattern => ({
|
| 32 |
+
type: 'mood' as const,
|
| 33 |
+
title: `${pattern.emotion} emotions`,
|
| 34 |
+
description: `You've expressed ${pattern.emotion} emotions in ${pattern.percentage.toFixed(1)}% of your recent entries.`,
|
| 35 |
+
})),
|
| 36 |
+
...(insightsData.common_themes || []).slice(0, 3).map(theme => ({
|
| 37 |
+
type: 'pattern' as const,
|
| 38 |
+
title: `${theme.theme} theme`,
|
| 39 |
+
description: `This theme appeared ${theme.frequency} times in your recent entries.`,
|
| 40 |
+
})),
|
| 41 |
+
] : [];
|
| 42 |
+
|
| 43 |
+
// Debug logging
|
| 44 |
+
console.log('InsightsSummary Debug:', {
|
| 45 |
+
insightsData,
|
| 46 |
+
insights,
|
| 47 |
+
isLoading,
|
| 48 |
+
error: error ? {
|
| 49 |
+
message: error.message,
|
| 50 |
+
stack: error.stack,
|
| 51 |
+
name: error.name
|
| 52 |
+
} : null,
|
| 53 |
+
dateRange: {
|
| 54 |
+
start: thirtyDaysAgo.toISOString().split('T')[0],
|
| 55 |
+
end: new Date().toISOString().split('T')[0],
|
| 56 |
+
}
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
const getInsightIcon = (type: Insight['type']) => {
|
| 60 |
+
switch (type) {
|
| 61 |
+
case 'mood':
|
| 62 |
+
return HeartIcon;
|
| 63 |
+
case 'pattern':
|
| 64 |
+
return ArrowTrendingUpIcon;
|
| 65 |
+
case 'growth':
|
| 66 |
+
return SparklesIcon;
|
| 67 |
+
default:
|
| 68 |
+
return LightBulbIcon;
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if (isLoading) {
|
| 75 |
+
return (
|
| 76 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg p-8">
|
| 77 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 78 |
+
Patterns & Themes
|
| 79 |
+
</h2>
|
| 80 |
+
<LoadingState message="Generating insights..." />
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (error) {
|
| 86 |
+
return (
|
| 87 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg p-8">
|
| 88 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 89 |
+
Patterns & Themes
|
| 90 |
+
</h2>
|
| 91 |
+
<ErrorDisplay
|
| 92 |
+
error={error}
|
| 93 |
+
title="Failed to load insights"
|
| 94 |
+
onRetry={() => refetch()}
|
| 95 |
+
/>
|
| 96 |
+
</div>
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return (
|
| 101 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-xl p-8 smooth-hover">
|
| 102 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 103 |
+
Patterns & Themes
|
| 104 |
+
</h2>
|
| 105 |
+
|
| 106 |
+
{insights.length === 0 ? (
|
| 107 |
+
<div className="text-center py-12">
|
| 108 |
+
<div className="w-16 h-16 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-float">
|
| 109 |
+
<LightBulbIcon className="h-8 w-8 text-white" />
|
| 110 |
+
</div>
|
| 111 |
+
<p className="text-gray-600 mb-2 text-lg font-medium">
|
| 112 |
+
No insights yet
|
| 113 |
+
</p>
|
| 114 |
+
<p className="text-gray-500 max-w-sm mx-auto leading-relaxed">
|
| 115 |
+
Create some journal entries to see AI-generated insights about your patterns and growth.
|
| 116 |
+
</p>
|
| 117 |
+
</div>
|
| 118 |
+
) : (
|
| 119 |
+
<div className="space-y-6">
|
| 120 |
+
{insights.map((insight, index) => {
|
| 121 |
+
const IconComponent = getInsightIcon(insight.type);
|
| 122 |
+
|
| 123 |
+
return (
|
| 124 |
+
<div key={index} className="group flex items-start space-x-4 p-4 rounded-xl hover:bg-gray-50/50/30 transition-all duration-300">
|
| 125 |
+
<div className={`w-10 h-10 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform duration-300 ${
|
| 126 |
+
insight.type === 'mood' ? 'bg-gradient-to-r from-pink-400 to-rose-500' :
|
| 127 |
+
insight.type === 'pattern' ? 'bg-gradient-to-r from-blue-400 to-indigo-500' :
|
| 128 |
+
insight.type === 'growth' ? 'bg-gradient-to-r from-green-400 to-emerald-500' :
|
| 129 |
+
'bg-gradient-to-r from-yellow-400 to-orange-500'
|
| 130 |
+
}`}>
|
| 131 |
+
<IconComponent className="h-5 w-5 text-white" />
|
| 132 |
+
</div>
|
| 133 |
+
<div className="flex-1 min-w-0">
|
| 134 |
+
<h3 className="font-semibold text-gray-900 mb-2">
|
| 135 |
+
{insight.title}
|
| 136 |
+
</h3>
|
| 137 |
+
<p className="text-gray-600 leading-relaxed">
|
| 138 |
+
{insight.description}
|
| 139 |
+
</p>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
);
|
| 143 |
+
})}
|
| 144 |
+
</div>
|
| 145 |
+
)}
|
| 146 |
+
</div>
|
| 147 |
+
);
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
export default InsightsSummary;
|
frontend/src/components/home/PersonalGrowth.tsx
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
SparklesIcon,
|
| 3 |
+
TrophyIcon,
|
| 4 |
+
ArrowTrendingUpIcon,
|
| 5 |
+
CheckCircleIcon,
|
| 6 |
+
ClockIcon,
|
| 7 |
+
HeartIcon,
|
| 8 |
+
LightBulbIcon,
|
| 9 |
+
UsersIcon,
|
| 10 |
+
EyeIcon
|
| 11 |
+
} from '@heroicons/react/24/outline';
|
| 12 |
+
import { useInsights } from '../../hooks/useAI';
|
| 13 |
+
import { LoadingState } from '../common/LoadingSpinner';
|
| 14 |
+
import { ErrorDisplay } from '../common/ErrorDisplay';
|
| 15 |
+
import type { ActionableOutcome } from '../../types/api';
|
| 16 |
+
|
| 17 |
+
const PersonalGrowth = () => {
|
| 18 |
+
// Get insights for the last 30 days
|
| 19 |
+
const thirtyDaysAgo = new Date();
|
| 20 |
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
| 21 |
+
|
| 22 |
+
const { data: insightsData, isLoading, error, refetch } = useInsights({
|
| 23 |
+
date_range: {
|
| 24 |
+
start: thirtyDaysAgo.toISOString().split('T')[0],
|
| 25 |
+
end: new Date().toISOString().split('T')[0],
|
| 26 |
+
},
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
const getCategoryIcon = (category: string) => {
|
| 30 |
+
switch (category.toLowerCase()) {
|
| 31 |
+
case 'emotional':
|
| 32 |
+
return HeartIcon;
|
| 33 |
+
case 'behavioral':
|
| 34 |
+
return ArrowTrendingUpIcon;
|
| 35 |
+
case 'mindset':
|
| 36 |
+
return LightBulbIcon;
|
| 37 |
+
case 'relationship':
|
| 38 |
+
return UsersIcon;
|
| 39 |
+
default:
|
| 40 |
+
return SparklesIcon;
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const getCategoryColor = (category: string) => {
|
| 45 |
+
switch (category.toLowerCase()) {
|
| 46 |
+
case 'emotional':
|
| 47 |
+
return 'from-pink-400 to-rose-500';
|
| 48 |
+
case 'behavioral':
|
| 49 |
+
return 'from-blue-400 to-indigo-500';
|
| 50 |
+
case 'mindset':
|
| 51 |
+
return 'from-purple-400 to-violet-500';
|
| 52 |
+
case 'relationship':
|
| 53 |
+
return 'from-green-400 to-emerald-500';
|
| 54 |
+
default:
|
| 55 |
+
return 'from-yellow-400 to-orange-500';
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const getTimeframeIcon = () => {
|
| 60 |
+
return ClockIcon;
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
if (isLoading) {
|
| 64 |
+
return (
|
| 65 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg p-8">
|
| 66 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent mb-6">
|
| 67 |
+
Personal Growth
|
| 68 |
+
</h2>
|
| 69 |
+
<LoadingState message="Analyzing your growth opportunities..." />
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (error) {
|
| 75 |
+
return (
|
| 76 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg p-8">
|
| 77 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent mb-6">
|
| 78 |
+
Personal Growth
|
| 79 |
+
</h2>
|
| 80 |
+
<ErrorDisplay
|
| 81 |
+
error={error}
|
| 82 |
+
title="Failed to load growth insights"
|
| 83 |
+
onRetry={() => refetch()}
|
| 84 |
+
/>
|
| 85 |
+
</div>
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const hasGrowthData = insightsData?.actionable_outcomes?.length ||
|
| 90 |
+
insightsData?.strengths_identified?.length ||
|
| 91 |
+
insightsData?.growth_areas?.length;
|
| 92 |
+
|
| 93 |
+
// Debug logging
|
| 94 |
+
console.log('PersonalGrowth Debug:', {
|
| 95 |
+
insightsData,
|
| 96 |
+
hasGrowthData,
|
| 97 |
+
actionableOutcomes: insightsData?.actionable_outcomes,
|
| 98 |
+
strengths: insightsData?.strengths_identified,
|
| 99 |
+
growthAreas: insightsData?.growth_areas,
|
| 100 |
+
isLoading,
|
| 101 |
+
error: error ? {
|
| 102 |
+
message: (error as Error).message,
|
| 103 |
+
stack: (error as Error).stack,
|
| 104 |
+
name: (error as Error).name
|
| 105 |
+
} : null,
|
| 106 |
+
dateRange: {
|
| 107 |
+
start: thirtyDaysAgo.toISOString().split('T')[0],
|
| 108 |
+
end: new Date().toISOString().split('T')[0],
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-xl p-8 smooth-hover">
|
| 114 |
+
<div className="flex items-center justify-between mb-6">
|
| 115 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent">
|
| 116 |
+
Personal Growth
|
| 117 |
+
</h2>
|
| 118 |
+
<div className="w-10 h-10 bg-gradient-to-r from-green-400 to-emerald-500 rounded-xl flex items-center justify-center">
|
| 119 |
+
<TrophyIcon className="h-5 w-5 text-white" />
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{!hasGrowthData ? (
|
| 124 |
+
<div className="text-center py-12">
|
| 125 |
+
<div className="w-16 h-16 bg-gradient-to-r from-green-400 to-emerald-500 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-float">
|
| 126 |
+
<SparklesIcon className="h-8 w-8 text-white" />
|
| 127 |
+
</div>
|
| 128 |
+
<p className="text-gray-600 mb-2 text-lg font-medium">
|
| 129 |
+
Growth insights coming soon
|
| 130 |
+
</p>
|
| 131 |
+
<p className="text-gray-500 max-w-sm mx-auto leading-relaxed">
|
| 132 |
+
Continue journaling to unlock personalized growth recommendations and actionable outcomes.
|
| 133 |
+
</p>
|
| 134 |
+
</div>
|
| 135 |
+
) : (
|
| 136 |
+
<div className="space-y-8">
|
| 137 |
+
{/* Actionable Outcomes */}
|
| 138 |
+
{insightsData?.actionable_outcomes && insightsData.actionable_outcomes.length > 0 && (
|
| 139 |
+
<div>
|
| 140 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
| 141 |
+
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
|
| 142 |
+
Action Items for Growth
|
| 143 |
+
</h3>
|
| 144 |
+
<div className="space-y-4">
|
| 145 |
+
{insightsData.actionable_outcomes.map((outcome: ActionableOutcome, index: number) => {
|
| 146 |
+
const CategoryIcon = getCategoryIcon(outcome.category);
|
| 147 |
+
const TimeframeIcon = getTimeframeIcon();
|
| 148 |
+
|
| 149 |
+
return (
|
| 150 |
+
<div key={index} className="group p-4 rounded-xl border border-gray-200 hover:border-green-300 hover:bg-green-50/30/10 transition-all duration-300">
|
| 151 |
+
<div className="flex items-start space-x-4">
|
| 152 |
+
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-gradient-to-r ${getCategoryColor(outcome.category)} group-hover:scale-110 transition-transform duration-300`}>
|
| 153 |
+
<CategoryIcon className="h-5 w-5 text-white" />
|
| 154 |
+
</div>
|
| 155 |
+
<div className="flex-1 min-w-0">
|
| 156 |
+
<div className="flex items-center justify-between mb-2">
|
| 157 |
+
<h4 className="font-semibold text-gray-900">
|
| 158 |
+
{outcome.title}
|
| 159 |
+
</h4>
|
| 160 |
+
<div className="flex items-center text-sm text-gray-500">
|
| 161 |
+
<TimeframeIcon className="h-4 w-4 mr-1" />
|
| 162 |
+
{outcome.timeframe}
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
<p className="text-gray-600 leading-relaxed mb-2">
|
| 166 |
+
{outcome.description}
|
| 167 |
+
</p>
|
| 168 |
+
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 capitalize">
|
| 169 |
+
{outcome.category}
|
| 170 |
+
</span>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
);
|
| 175 |
+
})}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
)}
|
| 179 |
+
|
| 180 |
+
{/* Strengths Identified */}
|
| 181 |
+
{insightsData?.strengths_identified && insightsData.strengths_identified.length > 0 && (
|
| 182 |
+
<div>
|
| 183 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
| 184 |
+
<TrophyIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
| 185 |
+
Your Strengths
|
| 186 |
+
</h3>
|
| 187 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 188 |
+
{insightsData.strengths_identified.map((strength: string, index: number) => (
|
| 189 |
+
<div key={index} className="flex items-center p-3 rounded-lg bg-yellow-50/20 border border-yellow-200">
|
| 190 |
+
<div className="w-8 h-8 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-lg flex items-center justify-center mr-3">
|
| 191 |
+
<SparklesIcon className="h-4 w-4 text-white" />
|
| 192 |
+
</div>
|
| 193 |
+
<span className="text-gray-800 font-medium">
|
| 194 |
+
{strength}
|
| 195 |
+
</span>
|
| 196 |
+
</div>
|
| 197 |
+
))}
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
|
| 202 |
+
{/* Growth Areas */}
|
| 203 |
+
{insightsData?.growth_areas && insightsData.growth_areas.length > 0 && (
|
| 204 |
+
<div>
|
| 205 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
| 206 |
+
<ArrowTrendingUpIcon className="h-5 w-5 text-blue-500 mr-2" />
|
| 207 |
+
Areas for Development
|
| 208 |
+
</h3>
|
| 209 |
+
<div className="space-y-2">
|
| 210 |
+
{insightsData.growth_areas.map((area: string, index: number) => (
|
| 211 |
+
<div key={index} className="flex items-center p-3 rounded-lg bg-blue-50/20 border border-blue-200">
|
| 212 |
+
<div className="w-8 h-8 bg-gradient-to-r from-blue-400 to-indigo-500 rounded-lg flex items-center justify-center mr-3">
|
| 213 |
+
<EyeIcon className="h-4 w-4 text-white" />
|
| 214 |
+
</div>
|
| 215 |
+
<span className="text-gray-800">
|
| 216 |
+
{area}
|
| 217 |
+
</span>
|
| 218 |
+
</div>
|
| 219 |
+
))}
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
)}
|
| 223 |
+
|
| 224 |
+
{/* Growth Summary */}
|
| 225 |
+
{insightsData?.growth_summary && (
|
| 226 |
+
<div className="p-4 rounded-xl bg-gradient-to-r from-green-50 to-emerald-50/20/20 border border-green-200">
|
| 227 |
+
<h3 className="text-lg font-semibold text-green-800 mb-2">
|
| 228 |
+
Your Growth Journey
|
| 229 |
+
</h3>
|
| 230 |
+
<p className="text-green-700 leading-relaxed">
|
| 231 |
+
{insightsData.growth_summary}
|
| 232 |
+
</p>
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
</div>
|
| 238 |
+
);
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
export default PersonalGrowth;
|
frontend/src/components/home/QuickActions.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useNavigate } from 'react-router-dom';
|
| 2 |
+
import {
|
| 3 |
+
PlusIcon,
|
| 4 |
+
ChatBubbleLeftRightIcon,
|
| 5 |
+
ClockIcon,
|
| 6 |
+
Cog6ToothIcon
|
| 7 |
+
} from '@heroicons/react/24/outline';
|
| 8 |
+
|
| 9 |
+
const QuickActions = () => {
|
| 10 |
+
const navigate = useNavigate();
|
| 11 |
+
|
| 12 |
+
const actions = [
|
| 13 |
+
{
|
| 14 |
+
id: 'new-entry',
|
| 15 |
+
label: 'New Entry',
|
| 16 |
+
description: 'Create a new journal entry',
|
| 17 |
+
icon: PlusIcon,
|
| 18 |
+
onClick: () => navigate('/journal'),
|
| 19 |
+
primary: true
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
id: 'start-chat',
|
| 23 |
+
label: 'Start Chat',
|
| 24 |
+
description: 'Chat with your AI companion',
|
| 25 |
+
icon: ChatBubbleLeftRightIcon,
|
| 26 |
+
onClick: () => navigate('/chat'),
|
| 27 |
+
primary: false
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
id: 'view-timeline',
|
| 31 |
+
label: 'View Timeline',
|
| 32 |
+
description: 'Browse your past entries',
|
| 33 |
+
icon: ClockIcon,
|
| 34 |
+
onClick: () => navigate('/timeline'),
|
| 35 |
+
primary: false
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
id: 'settings',
|
| 39 |
+
label: 'Settings',
|
| 40 |
+
description: 'Configure your AI model',
|
| 41 |
+
icon: Cog6ToothIcon,
|
| 42 |
+
onClick: () => navigate('/settings'),
|
| 43 |
+
primary: false
|
| 44 |
+
}
|
| 45 |
+
];
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-xl p-8 smooth-hover">
|
| 49 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 50 |
+
Quick Actions
|
| 51 |
+
</h2>
|
| 52 |
+
<div className="space-y-4">
|
| 53 |
+
{actions.map((action, index) => (
|
| 54 |
+
<button
|
| 55 |
+
key={action.id}
|
| 56 |
+
onClick={action.onClick}
|
| 57 |
+
className={`group w-full flex items-center space-x-4 px-6 py-4 rounded-xl text-left transition-all duration-300 transform hover:scale-105 ${
|
| 58 |
+
action.primary
|
| 59 |
+
? 'bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white shadow-lg hover:shadow-xl'
|
| 60 |
+
: 'bg-gray-50/70 hover:bg-gray-100/70/50/70 text-gray-900 hover:shadow-md'
|
| 61 |
+
}`}
|
| 62 |
+
style={{ animationDelay: `${index * 100}ms` }}
|
| 63 |
+
>
|
| 64 |
+
<div className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center transition-transform duration-300 group-hover:rotate-6 ${
|
| 65 |
+
action.primary
|
| 66 |
+
? 'bg-white/20'
|
| 67 |
+
: 'bg-gradient-to-r from-blue-400 to-purple-500'
|
| 68 |
+
}`}>
|
| 69 |
+
<action.icon className={`h-5 w-5 ${action.primary ? 'text-white' : 'text-white'}`} />
|
| 70 |
+
</div>
|
| 71 |
+
<div className="flex-1 min-w-0">
|
| 72 |
+
<div className="font-semibold text-lg">{action.label}</div>
|
| 73 |
+
<div className={`text-sm ${
|
| 74 |
+
action.primary
|
| 75 |
+
? 'text-blue-100'
|
| 76 |
+
: 'text-gray-500'
|
| 77 |
+
}`}>
|
| 78 |
+
{action.description}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</button>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div className="mt-6 p-4 bg-gradient-to-r from-blue-50/50 to-purple-50/50/30/20 rounded-xl border border-blue-100/50/20">
|
| 86 |
+
<p className="text-sm text-gray-600 flex items-center space-x-2">
|
| 87 |
+
<span className="text-lg">💡</span>
|
| 88 |
+
<span>Tip: Use keyboard shortcuts - Press 'N' for new entry or 'C' for chat</span>
|
| 89 |
+
</p>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
);
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
export default QuickActions;
|
frontend/src/components/home/Statistics.tsx
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
DocumentTextIcon,
|
| 3 |
+
PencilSquareIcon,
|
| 4 |
+
ChartBarIcon,
|
| 5 |
+
CalendarDaysIcon
|
| 6 |
+
} from '@heroicons/react/24/outline';
|
| 7 |
+
import { useStatistics } from '../../hooks/useJournal';
|
| 8 |
+
import { LoadingState } from '../common/LoadingSpinner';
|
| 9 |
+
import { ErrorDisplay } from '../common/ErrorDisplay';
|
| 10 |
+
|
| 11 |
+
const Statistics = () => {
|
| 12 |
+
const { data: stats, isLoading, error, refetch } = useStatistics();
|
| 13 |
+
|
| 14 |
+
const statItems = stats ? [
|
| 15 |
+
{
|
| 16 |
+
label: 'Entries this year',
|
| 17 |
+
value: stats.entries_this_year || 0,
|
| 18 |
+
icon: CalendarDaysIcon,
|
| 19 |
+
color: 'text-blue-600'
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
label: 'Words written',
|
| 23 |
+
value: (stats.total_words || 0).toLocaleString(),
|
| 24 |
+
icon: PencilSquareIcon,
|
| 25 |
+
color: 'text-green-600'
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
label: 'Current streak',
|
| 29 |
+
value: `${stats.current_streak || 0} days`,
|
| 30 |
+
icon: ChartBarIcon,
|
| 31 |
+
color: 'text-orange-600'
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
label: 'Total entries',
|
| 35 |
+
value: stats.total_entries || 0,
|
| 36 |
+
icon: DocumentTextIcon,
|
| 37 |
+
color: 'text-purple-600'
|
| 38 |
+
}
|
| 39 |
+
] : [];
|
| 40 |
+
|
| 41 |
+
if (isLoading) {
|
| 42 |
+
return (
|
| 43 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg p-8">
|
| 44 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 45 |
+
Your Progress
|
| 46 |
+
</h2>
|
| 47 |
+
<LoadingState message="Loading statistics..." />
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (error) {
|
| 53 |
+
return (
|
| 54 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg p-8">
|
| 55 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 56 |
+
Your Progress
|
| 57 |
+
</h2>
|
| 58 |
+
<ErrorDisplay
|
| 59 |
+
error={error}
|
| 60 |
+
title="Failed to load statistics"
|
| 61 |
+
onRetry={() => refetch()}
|
| 62 |
+
/>
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (!stats) {
|
| 68 |
+
return (
|
| 69 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg p-8">
|
| 70 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 71 |
+
Your Progress
|
| 72 |
+
</h2>
|
| 73 |
+
<div className="text-center py-8">
|
| 74 |
+
<p className="text-gray-500">
|
| 75 |
+
No statistics available
|
| 76 |
+
</p>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return (
|
| 83 |
+
<div className="bg-white/70/70 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-xl p-8 smooth-hover">
|
| 84 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-6">
|
| 85 |
+
Your Progress
|
| 86 |
+
</h2>
|
| 87 |
+
<div className="space-y-6">
|
| 88 |
+
{statItems.map((item, index) => (
|
| 89 |
+
<div key={index} className="group flex items-center justify-between p-4 rounded-xl hover:bg-gray-50/50/30 transition-all duration-300">
|
| 90 |
+
<div className="flex items-center space-x-4">
|
| 91 |
+
<div className={`w-10 h-10 rounded-lg bg-gradient-to-r ${
|
| 92 |
+
index === 0 ? 'from-blue-400 to-blue-500' :
|
| 93 |
+
index === 1 ? 'from-green-400 to-green-500' :
|
| 94 |
+
index === 2 ? 'from-orange-400 to-orange-500' :
|
| 95 |
+
'from-purple-400 to-purple-500'
|
| 96 |
+
} flex items-center justify-center group-hover:scale-110 transition-transform duration-300`}>
|
| 97 |
+
<item.icon className="h-5 w-5 text-white" />
|
| 98 |
+
</div>
|
| 99 |
+
<span className="text-gray-700 font-medium">{item.label}</span>
|
| 100 |
+
</div>
|
| 101 |
+
<span className="font-bold text-xl text-gray-900">
|
| 102 |
+
{item.value}
|
| 103 |
+
</span>
|
| 104 |
+
</div>
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{stats && (stats.total_entries || 0) === 0 && (
|
| 109 |
+
<div className="mt-6 p-4 bg-gradient-to-r from-blue-50/50 to-purple-50/50/20/20 rounded-xl border border-blue-100/50/20">
|
| 110 |
+
<p className="text-sm text-blue-700 flex items-center space-x-2">
|
| 111 |
+
<span className="text-lg">🚀</span>
|
| 112 |
+
<span>Start your journaling journey by creating your first entry!</span>
|
| 113 |
+
</p>
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
+
</div>
|
| 117 |
+
);
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
export default Statistics;
|
frontend/src/components/home/WelcomeScreen.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useNavigate } from 'react-router-dom';
|
| 2 |
+
import {
|
| 3 |
+
PencilIcon,
|
| 4 |
+
ClockIcon,
|
| 5 |
+
ShieldCheckIcon,
|
| 6 |
+
CpuChipIcon,
|
| 7 |
+
MicrophoneIcon
|
| 8 |
+
} from '@heroicons/react/24/outline';
|
| 9 |
+
import Disclaimer from '../common/Disclaimer';
|
| 10 |
+
|
| 11 |
+
const WelcomeScreen = () => {
|
| 12 |
+
const navigate = useNavigate();
|
| 13 |
+
|
| 14 |
+
const features = [
|
| 15 |
+
{
|
| 16 |
+
icon: ShieldCheckIcon,
|
| 17 |
+
title: 'Privacy First',
|
| 18 |
+
description: 'All AI processing happens on your device. Your thoughts stay private and never leave your computer.',
|
| 19 |
+
color: 'from-green-400 to-emerald-500'
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
icon: CpuChipIcon,
|
| 23 |
+
title: 'AI Insights',
|
| 24 |
+
description: 'Get personalized insights and emotional analysis powered by advanced AI models running locally.',
|
| 25 |
+
color: 'from-purple-400 to-pink-500'
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
icon: MicrophoneIcon,
|
| 29 |
+
title: 'Multimodal',
|
| 30 |
+
description: 'Write, speak, or capture photos. Express yourself in the most natural way possible.',
|
| 31 |
+
color: 'from-blue-400 to-cyan-500'
|
| 32 |
+
}
|
| 33 |
+
];
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="max-w-4xl mx-auto">
|
| 37 |
+
{/* Hero Section */}
|
| 38 |
+
<div className="text-center mb-16 animate-fade-in">
|
| 39 |
+
<div className="mb-8">
|
| 40 |
+
<h1 className="text-5xl md:text-6xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent mb-6 animate-slide-up">
|
| 41 |
+
Welcome to QuietRoom
|
| 42 |
+
</h1>
|
| 43 |
+
<p className="text-xl md:text-2xl text-gray-600 max-w-3xl mx-auto leading-relaxed animate-slide-up animation-delay-200">
|
| 44 |
+
Your privacy-first personal journaling companion with on-device AI processing.
|
| 45 |
+
</p>
|
| 46 |
+
<p className="text-lg text-gray-500 mt-4 animate-slide-up animation-delay-400">
|
| 47 |
+
Capture your thoughts, emotions, and memories with complete privacy.
|
| 48 |
+
</p>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
{/* Action Buttons */}
|
| 52 |
+
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-16 animate-slide-up animation-delay-600">
|
| 53 |
+
<button
|
| 54 |
+
onClick={() => navigate('/journal')}
|
| 55 |
+
className="group flex items-center space-x-3 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-8 py-4 rounded-full font-semibold text-lg shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-300"
|
| 56 |
+
>
|
| 57 |
+
<PencilIcon className="h-6 w-6 group-hover:rotate-12 transition-transform duration-300" />
|
| 58 |
+
<span>Start Writing</span>
|
| 59 |
+
</button>
|
| 60 |
+
|
| 61 |
+
<button
|
| 62 |
+
onClick={() => navigate('/timeline')}
|
| 63 |
+
className="flex items-center space-x-3 bg-white text-gray-700 px-8 py-4 rounded-full font-semibold text-lg border-2 border-gray-200 hover:border-purple-300 hover:shadow-lg transform hover:scale-105 transition-all duration-300"
|
| 64 |
+
>
|
| 65 |
+
<ClockIcon className="h-6 w-6" />
|
| 66 |
+
<span>View Timeline</span>
|
| 67 |
+
</button>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{/* Features Grid */}
|
| 72 |
+
<div className="grid md:grid-cols-3 gap-8 mb-16">
|
| 73 |
+
{features.map((feature, index) => (
|
| 74 |
+
<div
|
| 75 |
+
key={feature.title}
|
| 76 |
+
className={`group bg-white/70/70 backdrop-blur-sm rounded-2xl p-8 text-center hover:shadow-xl transform hover:scale-105 transition-all duration-500 animate-slide-up`}
|
| 77 |
+
style={{ animationDelay: `${800 + index * 200}ms` }}
|
| 78 |
+
>
|
| 79 |
+
<div className={`inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-r ${feature.color} mb-6 group-hover:rotate-6 transition-transform duration-300`}>
|
| 80 |
+
<feature.icon className="h-8 w-8 text-white" />
|
| 81 |
+
</div>
|
| 82 |
+
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
| 83 |
+
{feature.title}
|
| 84 |
+
</h3>
|
| 85 |
+
<p className="text-gray-600 leading-relaxed">
|
| 86 |
+
{feature.description}
|
| 87 |
+
</p>
|
| 88 |
+
</div>
|
| 89 |
+
))}
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
{/* Call to Action */}
|
| 93 |
+
{/* <div className="bg-gradient-to-r from-blue-50 to-purple-50/50/20 rounded-3xl p-8 md:p-12 text-center animate-slide-up animation-delay-1400">
|
| 94 |
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
| 95 |
+
Ready to start your journey?
|
| 96 |
+
</h2>
|
| 97 |
+
<p className="text-lg text-gray-600 mb-8 max-w-2xl mx-auto">
|
| 98 |
+
Join thousands of users who have discovered the power of AI-assisted journaling. Your mental health and personal growth journey starts here.
|
| 99 |
+
</p>
|
| 100 |
+
<button
|
| 101 |
+
onClick={() => navigate('/journal')}
|
| 102 |
+
className="group inline-flex items-center space-x-3 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-10 py-5 rounded-full font-bold text-xl shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-300"
|
| 103 |
+
>
|
| 104 |
+
<PencilIcon className="h-7 w-7 group-hover:rotate-12 transition-transform duration-300" />
|
| 105 |
+
<span>Create Your First Entry</span>
|
| 106 |
+
</button>
|
| 107 |
+
</div> */}
|
| 108 |
+
|
| 109 |
+
<Disclaimer />
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
export default WelcomeScreen;
|
frontend/src/components/journal/AIAnalysisPanel.tsx
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
SparklesIcon,
|
| 4 |
+
LightBulbIcon,
|
| 5 |
+
ExclamationTriangleIcon,
|
| 6 |
+
ChatBubbleLeftRightIcon
|
| 7 |
+
} from '@heroicons/react/24/outline';
|
| 8 |
+
|
| 9 |
+
interface AIAnalysis {
|
| 10 |
+
mood?: {
|
| 11 |
+
score: number;
|
| 12 |
+
primary_emotion: string;
|
| 13 |
+
themes: string[];
|
| 14 |
+
summary: string;
|
| 15 |
+
follow_up_questions?: string[];
|
| 16 |
+
};
|
| 17 |
+
suggestions?: string[];
|
| 18 |
+
loading: boolean;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface AIAnalysisPanelProps {
|
| 22 |
+
analysis: AIAnalysis;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const AIAnalysisPanel = ({ analysis }: AIAnalysisPanelProps) => {
|
| 26 |
+
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
| 27 |
+
|
| 28 |
+
const getMoodColor = (score: number) => {
|
| 29 |
+
if (score >= 8) return 'text-green-600';
|
| 30 |
+
if (score >= 6) return 'text-blue-600';
|
| 31 |
+
if (score >= 4) return 'text-yellow-600';
|
| 32 |
+
return 'text-red-600';
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const getMoodEmoji = (score: number) => {
|
| 36 |
+
if (score >= 8) return '😊';
|
| 37 |
+
if (score >= 6) return '🙂';
|
| 38 |
+
if (score >= 4) return '😐';
|
| 39 |
+
return '😔';
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const handleQuestionClick = async (question: string, index: number) => {
|
| 43 |
+
try {
|
| 44 |
+
await navigator.clipboard.writeText(question);
|
| 45 |
+
setCopiedIndex(index);
|
| 46 |
+
setTimeout(() => setCopiedIndex(null), 2000);
|
| 47 |
+
} catch (err) {
|
| 48 |
+
console.error('Failed to copy question:', err);
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="space-y-6">
|
| 54 |
+
{/* AI Analysis Card */}
|
| 55 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 56 |
+
<div className="flex items-center space-x-2 mb-6">
|
| 57 |
+
<SparklesIcon className="h-5 w-5 text-blue-500" />
|
| 58 |
+
<h3 className="text-lg font-medium text-gray-900">
|
| 59 |
+
Insights
|
| 60 |
+
</h3>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{analysis.loading ? (
|
| 64 |
+
<div className="space-y-4">
|
| 65 |
+
<div className="animate-pulse">
|
| 66 |
+
<div className="h-3 bg-gray-200 rounded w-3/4 mb-3"></div>
|
| 67 |
+
<div className="h-3 bg-gray-200 rounded w-1/2 mb-4"></div>
|
| 68 |
+
<div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
|
| 69 |
+
<div className="h-3 bg-gray-200 rounded w-4/5"></div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
) : analysis.mood ? (
|
| 73 |
+
<div className="space-y-5">
|
| 74 |
+
{/* Mood Score */}
|
| 75 |
+
<div className="text-center">
|
| 76 |
+
<div className="text-3xl mb-2">{getMoodEmoji(analysis.mood.score)}</div>
|
| 77 |
+
<div className={`text-lg font-medium ${getMoodColor(analysis.mood.score)} capitalize`}>
|
| 78 |
+
{analysis.mood.primary_emotion}
|
| 79 |
+
</div>
|
| 80 |
+
<div className="text-sm text-gray-500">
|
| 81 |
+
{analysis.mood.score}/10
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* Themes */}
|
| 86 |
+
{analysis.mood.themes.length > 0 && (
|
| 87 |
+
<div>
|
| 88 |
+
<h4 className="text-sm font-medium text-gray-600 mb-3">
|
| 89 |
+
Key themes
|
| 90 |
+
</h4>
|
| 91 |
+
<div className="flex flex-wrap gap-2">
|
| 92 |
+
{analysis.mood.themes.map((theme, index) => (
|
| 93 |
+
<span
|
| 94 |
+
key={index}
|
| 95 |
+
className="px-3 py-1.5 bg-gray-100 text-gray-700 text-sm rounded-full capitalize"
|
| 96 |
+
>
|
| 97 |
+
{theme}
|
| 98 |
+
</span>
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
)}
|
| 103 |
+
|
| 104 |
+
{/* Summary */}
|
| 105 |
+
<div>
|
| 106 |
+
<h4 className="text-sm font-medium text-gray-600 mb-3">
|
| 107 |
+
Summary
|
| 108 |
+
</h4>
|
| 109 |
+
<p className="text-sm text-gray-700 leading-relaxed">
|
| 110 |
+
{analysis.mood.summary}
|
| 111 |
+
</p>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
{/* Follow-up Questions */}
|
| 115 |
+
{analysis.mood.follow_up_questions && analysis.mood.follow_up_questions.length > 0 && (
|
| 116 |
+
<div>
|
| 117 |
+
<h4 className="text-sm font-medium text-gray-600 mb-3 flex items-center space-x-2">
|
| 118 |
+
<ChatBubbleLeftRightIcon className="h-4 w-4" />
|
| 119 |
+
<span>Questions for deeper reflection</span>
|
| 120 |
+
</h4>
|
| 121 |
+
<div className="space-y-3">
|
| 122 |
+
{analysis.mood.follow_up_questions.map((question, index) => (
|
| 123 |
+
<div
|
| 124 |
+
key={index}
|
| 125 |
+
className="group p-4 bg-gradient-to-r from-purple-50 to-indigo-50/20/20 rounded-lg border border-purple-200 hover:shadow-sm transition-all duration-200 cursor-pointer"
|
| 126 |
+
onClick={() => handleQuestionClick(question, index)}
|
| 127 |
+
>
|
| 128 |
+
<div className="flex items-start space-x-3">
|
| 129 |
+
<div className="flex-shrink-0 w-6 h-6 bg-purple-100 rounded-full flex items-center justify-center text-xs font-medium text-purple-600">
|
| 130 |
+
{['🤔', '💭', '🌱'][index] || (index + 1)}
|
| 131 |
+
</div>
|
| 132 |
+
<div className="flex-1">
|
| 133 |
+
<p className="text-sm text-gray-700 leading-relaxed">
|
| 134 |
+
{question}
|
| 135 |
+
</p>
|
| 136 |
+
<div className="mt-2 transition-opacity duration-200">
|
| 137 |
+
{copiedIndex === index ? (
|
| 138 |
+
<span className="text-xs text-green-600 font-medium">
|
| 139 |
+
✓ Copied! Consider journaling about this
|
| 140 |
+
</span>
|
| 141 |
+
) : (
|
| 142 |
+
<span className="text-xs text-purple-600 font-medium opacity-0 group-hover:opacity-100">
|
| 143 |
+
Click to copy • Consider journaling about this
|
| 144 |
+
</span>
|
| 145 |
+
)}
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
))}
|
| 151 |
+
</div>
|
| 152 |
+
<div className="mt-4 p-3 bg-amber-50/20 rounded-lg border border-amber-200">
|
| 153 |
+
<div className="flex items-start space-x-2">
|
| 154 |
+
<span className="text-amber-600 text-sm">💡</span>
|
| 155 |
+
<div className="text-xs text-amber-700">
|
| 156 |
+
<strong>Reflection tip:</strong> These questions are designed to help you explore your thoughts and feelings more deeply. Consider writing about one that resonates with you in your next entry.
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
) : (
|
| 164 |
+
<div className="text-center py-8">
|
| 165 |
+
<SparklesIcon className="h-8 w-8 text-gray-300 mx-auto mb-3" />
|
| 166 |
+
<p className="text-sm text-gray-400">
|
| 167 |
+
Keep writing to see insights
|
| 168 |
+
</p>
|
| 169 |
+
</div>
|
| 170 |
+
)}
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
{/* Suggestions Card */}
|
| 174 |
+
{analysis.suggestions && analysis.suggestions.length > 0 && (
|
| 175 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 176 |
+
<div className="flex items-center space-x-2 mb-4">
|
| 177 |
+
<LightBulbIcon className="h-5 w-5 text-amber-500" />
|
| 178 |
+
<h3 className="text-lg font-medium text-gray-900">
|
| 179 |
+
Suggestions
|
| 180 |
+
</h3>
|
| 181 |
+
</div>
|
| 182 |
+
<div className="space-y-3">
|
| 183 |
+
{analysis.suggestions.map((suggestion, index) => (
|
| 184 |
+
<div
|
| 185 |
+
key={index}
|
| 186 |
+
className="p-3 bg-amber-50/20 rounded-lg"
|
| 187 |
+
>
|
| 188 |
+
<p className="text-sm text-gray-700">
|
| 189 |
+
{suggestion}
|
| 190 |
+
</p>
|
| 191 |
+
</div>
|
| 192 |
+
))}
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
+
{/* Privacy Notice */}
|
| 198 |
+
<div className="bg-blue-50/20 rounded-lg p-4">
|
| 199 |
+
<div className="flex items-start space-x-2">
|
| 200 |
+
<ExclamationTriangleIcon className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
| 201 |
+
<div>
|
| 202 |
+
<h4 className="text-sm font-medium text-blue-800 mb-1">
|
| 203 |
+
Private & Secure
|
| 204 |
+
</h4>
|
| 205 |
+
<p className="text-xs text-blue-700">
|
| 206 |
+
Your entries stay on your device. AI analysis uses your chosen model.
|
| 207 |
+
</p>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
);
|
| 213 |
+
};
|
| 214 |
+
|
| 215 |
+
export default AIAnalysisPanel;
|
frontend/src/components/journal/AudioInput.tsx
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { MicrophoneIcon, StopIcon, PlayIcon, PauseIcon } from '@heroicons/react/24/outline';
|
| 4 |
+
import { useTranscribeAudioFile } from '../../hooks/useAI';
|
| 5 |
+
import ModelCapabilityError from '../common/ModelCapabilityError';
|
| 6 |
+
import { isModelCapabilityError, type ModelCapabilityError as ModelCapabilityErrorType } from '../../types/errors';
|
| 7 |
+
|
| 8 |
+
interface AudioInputProps {
|
| 9 |
+
onTranscription: (transcription: string) => void;
|
| 10 |
+
disabled?: boolean;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const AudioInput = ({ onTranscription, disabled = false }: AudioInputProps) => {
|
| 14 |
+
const navigate = useNavigate();
|
| 15 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 16 |
+
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
| 17 |
+
const [isPlaying, setIsPlaying] = useState(false);
|
| 18 |
+
const [recordingTime, setRecordingTime] = useState(0);
|
| 19 |
+
const [modelError, setModelError] = useState<ModelCapabilityErrorType | null>(null);
|
| 20 |
+
|
| 21 |
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
| 22 |
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
| 23 |
+
const intervalRef = useRef<number | null>(null);
|
| 24 |
+
|
| 25 |
+
const transcribeAudio = useTranscribeAudioFile();
|
| 26 |
+
|
| 27 |
+
const startRecording = async () => {
|
| 28 |
+
try {
|
| 29 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 30 |
+
const mediaRecorder = new MediaRecorder(stream);
|
| 31 |
+
mediaRecorderRef.current = mediaRecorder;
|
| 32 |
+
|
| 33 |
+
const chunks: BlobPart[] = [];
|
| 34 |
+
mediaRecorder.ondataavailable = (event) => {
|
| 35 |
+
chunks.push(event.data);
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
mediaRecorder.onstop = () => {
|
| 39 |
+
const blob = new Blob(chunks, { type: 'audio/wav' });
|
| 40 |
+
setAudioBlob(blob);
|
| 41 |
+
stream.getTracks().forEach(track => track.stop());
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
mediaRecorder.start();
|
| 45 |
+
setIsRecording(true);
|
| 46 |
+
setRecordingTime(0);
|
| 47 |
+
|
| 48 |
+
// Start timer
|
| 49 |
+
intervalRef.current = setInterval(() => {
|
| 50 |
+
setRecordingTime(prev => prev + 1);
|
| 51 |
+
}, 1000);
|
| 52 |
+
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.error('Error starting recording:', error);
|
| 55 |
+
alert('Could not access microphone. Please check permissions.');
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const stopRecording = () => {
|
| 60 |
+
if (mediaRecorderRef.current && isRecording) {
|
| 61 |
+
mediaRecorderRef.current.stop();
|
| 62 |
+
setIsRecording(false);
|
| 63 |
+
|
| 64 |
+
if (intervalRef.current) {
|
| 65 |
+
clearInterval(intervalRef.current);
|
| 66 |
+
intervalRef.current = null;
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const playAudio = () => {
|
| 72 |
+
if (audioBlob && audioRef.current) {
|
| 73 |
+
if (isPlaying) {
|
| 74 |
+
audioRef.current.pause();
|
| 75 |
+
setIsPlaying(false);
|
| 76 |
+
} else {
|
| 77 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
| 78 |
+
audioRef.current.src = audioUrl;
|
| 79 |
+
audioRef.current.play();
|
| 80 |
+
setIsPlaying(true);
|
| 81 |
+
|
| 82 |
+
audioRef.current.onended = () => {
|
| 83 |
+
setIsPlaying(false);
|
| 84 |
+
URL.revokeObjectURL(audioUrl);
|
| 85 |
+
};
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const transcribeRecording = async () => {
|
| 91 |
+
if (!audioBlob) return;
|
| 92 |
+
|
| 93 |
+
try {
|
| 94 |
+
const audioFile = new File([audioBlob], 'recording.wav', { type: 'audio/wav' });
|
| 95 |
+
const result = await transcribeAudio.mutateAsync(audioFile);
|
| 96 |
+
|
| 97 |
+
if (result.transcription) {
|
| 98 |
+
onTranscription(result.transcription);
|
| 99 |
+
// Clear the recording after successful transcription
|
| 100 |
+
setAudioBlob(null);
|
| 101 |
+
setRecordingTime(0);
|
| 102 |
+
}
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.error('Error transcribing audio:', error);
|
| 105 |
+
|
| 106 |
+
// Check if this is a model capability error
|
| 107 |
+
if (isModelCapabilityError(error)) {
|
| 108 |
+
setModelError(error);
|
| 109 |
+
} else {
|
| 110 |
+
alert('Failed to transcribe audio. Please try again.');
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const discardRecording = () => {
|
| 116 |
+
setAudioBlob(null);
|
| 117 |
+
setRecordingTime(0);
|
| 118 |
+
setIsPlaying(false);
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const formatTime = (seconds: number) => {
|
| 122 |
+
const mins = Math.floor(seconds / 60);
|
| 123 |
+
const secs = seconds % 60;
|
| 124 |
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
return (
|
| 128 |
+
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
| 129 |
+
<div className="flex items-center justify-between mb-3">
|
| 130 |
+
<h3 className="text-sm font-medium text-gray-700">Voice Recording</h3>
|
| 131 |
+
{recordingTime > 0 && (
|
| 132 |
+
<span className="text-sm text-gray-500">{formatTime(recordingTime)}</span>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{!audioBlob ? (
|
| 137 |
+
<div className="flex items-center space-x-3">
|
| 138 |
+
<button
|
| 139 |
+
onClick={isRecording ? stopRecording : startRecording}
|
| 140 |
+
disabled={disabled}
|
| 141 |
+
className={`flex items-center space-x-2 px-4 py-2 rounded-md transition-colors ${
|
| 142 |
+
isRecording
|
| 143 |
+
? 'bg-red-600 text-white hover:bg-red-700'
|
| 144 |
+
: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400'
|
| 145 |
+
}`}
|
| 146 |
+
>
|
| 147 |
+
{isRecording ? (
|
| 148 |
+
<>
|
| 149 |
+
<StopIcon className="w-4 h-4" />
|
| 150 |
+
<span>Stop Recording</span>
|
| 151 |
+
</>
|
| 152 |
+
) : (
|
| 153 |
+
<>
|
| 154 |
+
<MicrophoneIcon className="w-4 h-4" />
|
| 155 |
+
<span>Start Recording</span>
|
| 156 |
+
</>
|
| 157 |
+
)}
|
| 158 |
+
</button>
|
| 159 |
+
|
| 160 |
+
{isRecording && (
|
| 161 |
+
<div className="flex items-center space-x-2">
|
| 162 |
+
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
| 163 |
+
<span className="text-sm text-gray-600">Recording...</span>
|
| 164 |
+
</div>
|
| 165 |
+
)}
|
| 166 |
+
</div>
|
| 167 |
+
) : (
|
| 168 |
+
<div className="space-y-3">
|
| 169 |
+
<div className="flex items-center space-x-3">
|
| 170 |
+
<button
|
| 171 |
+
onClick={playAudio}
|
| 172 |
+
className="flex items-center space-x-2 px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors"
|
| 173 |
+
>
|
| 174 |
+
{isPlaying ? (
|
| 175 |
+
<>
|
| 176 |
+
<PauseIcon className="w-4 h-4" />
|
| 177 |
+
<span>Pause</span>
|
| 178 |
+
</>
|
| 179 |
+
) : (
|
| 180 |
+
<>
|
| 181 |
+
<PlayIcon className="w-4 h-4" />
|
| 182 |
+
<span>Play</span>
|
| 183 |
+
</>
|
| 184 |
+
)}
|
| 185 |
+
</button>
|
| 186 |
+
|
| 187 |
+
<span className="text-sm text-gray-600">
|
| 188 |
+
Recording ready ({formatTime(recordingTime)})
|
| 189 |
+
</span>
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
<div className="flex items-center space-x-2">
|
| 193 |
+
<button
|
| 194 |
+
onClick={transcribeRecording}
|
| 195 |
+
disabled={transcribeAudio.isPending}
|
| 196 |
+
className="flex items-center space-x-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 transition-colors"
|
| 197 |
+
>
|
| 198 |
+
{transcribeAudio.isPending ? (
|
| 199 |
+
<>
|
| 200 |
+
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
| 201 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 202 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 203 |
+
</svg>
|
| 204 |
+
<span>Transcribing...</span>
|
| 205 |
+
</>
|
| 206 |
+
) : (
|
| 207 |
+
<span>Add to Entry</span>
|
| 208 |
+
)}
|
| 209 |
+
</button>
|
| 210 |
+
|
| 211 |
+
<button
|
| 212 |
+
onClick={discardRecording}
|
| 213 |
+
className="px-3 py-2 text-gray-600 hover:text-gray-800 transition-colors"
|
| 214 |
+
>
|
| 215 |
+
Discard
|
| 216 |
+
</button>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
{transcribeAudio.error && (
|
| 220 |
+
<p className="text-sm text-red-600">
|
| 221 |
+
Failed to transcribe audio. Please try again.
|
| 222 |
+
</p>
|
| 223 |
+
)}
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
<audio ref={audioRef} className="hidden" />
|
| 228 |
+
|
| 229 |
+
{/* Model Capability Error Modal */}
|
| 230 |
+
{modelError && (
|
| 231 |
+
<ModelCapabilityError
|
| 232 |
+
error={modelError.originalError}
|
| 233 |
+
feature={modelError.feature}
|
| 234 |
+
currentModel={modelError.currentModel}
|
| 235 |
+
onClose={() => setModelError(null)}
|
| 236 |
+
onOpenSettings={() => navigate('/settings')}
|
| 237 |
+
/>
|
| 238 |
+
)}
|
| 239 |
+
</div>
|
| 240 |
+
);
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
export default AudioInput;
|
frontend/src/components/journal/CrisisSupport.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { HeartIcon, SunIcon, MoonIcon } from '@heroicons/react/24/outline';
|
| 3 |
+
import type { MotivationalSupportResponse } from '../../types/api';
|
| 4 |
+
|
| 5 |
+
interface CrisisSupportProps {
|
| 6 |
+
support: MotivationalSupportResponse['support'];
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const CrisisSupport = ({ support, onClose }: CrisisSupportProps) => {
|
| 11 |
+
const [activeTab, setActiveTab] = useState<'motivation' | 'evening' | 'tomorrow'>('motivation');
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
| 15 |
+
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
| 16 |
+
<div className="p-6">
|
| 17 |
+
{/* Header */}
|
| 18 |
+
<div className="flex items-center justify-between mb-6">
|
| 19 |
+
<div className="flex items-center space-x-3">
|
| 20 |
+
<div className="w-10 h-10 bg-pink-100 rounded-full flex items-center justify-center">
|
| 21 |
+
<HeartIcon className="w-6 h-6 text-pink-600" />
|
| 22 |
+
</div>
|
| 23 |
+
<div>
|
| 24 |
+
<h2 className="text-xl font-semibold text-gray-900">
|
| 25 |
+
You're Not Alone
|
| 26 |
+
</h2>
|
| 27 |
+
<p className="text-gray-600">Here's some gentle support for you</p>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
<button
|
| 31 |
+
onClick={onClose}
|
| 32 |
+
className="text-gray-400 hover:text-gray-600 transition-colors"
|
| 33 |
+
>
|
| 34 |
+
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 35 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 36 |
+
</svg>
|
| 37 |
+
</button>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
{/* Affirmation */}
|
| 41 |
+
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4 mb-6">
|
| 42 |
+
<p className="text-lg font-medium text-gray-800 text-center">
|
| 43 |
+
"{support.affirmation}"
|
| 44 |
+
</p>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
{/* Tabs */}
|
| 48 |
+
<div className="flex space-x-1 mb-6 bg-gray-100 rounded-lg p-1">
|
| 49 |
+
<button
|
| 50 |
+
onClick={() => setActiveTab('motivation')}
|
| 51 |
+
className={`flex-1 flex items-center justify-center space-x-2 py-2 px-4 rounded-md transition-colors ${
|
| 52 |
+
activeTab === 'motivation'
|
| 53 |
+
? 'bg-white text-blue-600 shadow-sm'
|
| 54 |
+
: 'text-gray-600 hover:text-gray-800'
|
| 55 |
+
}`}
|
| 56 |
+
>
|
| 57 |
+
<HeartIcon className="w-4 h-4" />
|
| 58 |
+
<span>Encouragement</span>
|
| 59 |
+
</button>
|
| 60 |
+
<button
|
| 61 |
+
onClick={() => setActiveTab('evening')}
|
| 62 |
+
className={`flex-1 flex items-center justify-center space-x-2 py-2 px-4 rounded-md transition-colors ${
|
| 63 |
+
activeTab === 'evening'
|
| 64 |
+
? 'bg-white text-blue-600 shadow-sm'
|
| 65 |
+
: 'text-gray-600 hover:text-gray-800'
|
| 66 |
+
}`}
|
| 67 |
+
>
|
| 68 |
+
<MoonIcon className="w-4 h-4" />
|
| 69 |
+
<span>Tonight</span>
|
| 70 |
+
</button>
|
| 71 |
+
<button
|
| 72 |
+
onClick={() => setActiveTab('tomorrow')}
|
| 73 |
+
className={`flex-1 flex items-center justify-center space-x-2 py-2 px-4 rounded-md transition-colors ${
|
| 74 |
+
activeTab === 'tomorrow'
|
| 75 |
+
? 'bg-white text-blue-600 shadow-sm'
|
| 76 |
+
: 'text-gray-600 hover:text-gray-800'
|
| 77 |
+
}`}
|
| 78 |
+
>
|
| 79 |
+
<SunIcon className="w-4 h-4" />
|
| 80 |
+
<span>Tomorrow</span>
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* Content */}
|
| 85 |
+
<div className="space-y-4">
|
| 86 |
+
{activeTab === 'motivation' && (
|
| 87 |
+
<div>
|
| 88 |
+
<h3 className="font-semibold text-gray-900 mb-3">Gentle Reminders</h3>
|
| 89 |
+
<div className="space-y-3">
|
| 90 |
+
{support.motivational_phrases.map((phrase, index) => (
|
| 91 |
+
<div key={index} className="flex items-start space-x-3">
|
| 92 |
+
<div className="w-2 h-2 bg-blue-400 rounded-full mt-2 flex-shrink-0"></div>
|
| 93 |
+
<p className="text-gray-700">{phrase}</p>
|
| 94 |
+
</div>
|
| 95 |
+
))}
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
)}
|
| 99 |
+
|
| 100 |
+
{activeTab === 'evening' && (
|
| 101 |
+
<div>
|
| 102 |
+
<h3 className="font-semibold text-gray-900 mb-3">For This Evening</h3>
|
| 103 |
+
<div className="space-y-3">
|
| 104 |
+
{support.evening_suggestions.map((suggestion, index) => (
|
| 105 |
+
<div key={index} className="flex items-start space-x-3">
|
| 106 |
+
<div className="w-2 h-2 bg-purple-400 rounded-full mt-2 flex-shrink-0"></div>
|
| 107 |
+
<p className="text-gray-700">{suggestion}</p>
|
| 108 |
+
</div>
|
| 109 |
+
))}
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
)}
|
| 113 |
+
|
| 114 |
+
{activeTab === 'tomorrow' && (
|
| 115 |
+
<div>
|
| 116 |
+
<h3 className="font-semibold text-gray-900 mb-3">Tomorrow's Fresh Start</h3>
|
| 117 |
+
<div className="space-y-3">
|
| 118 |
+
{support.tomorrow_suggestions.map((suggestion, index) => (
|
| 119 |
+
<div key={index} className="flex items-start space-x-3">
|
| 120 |
+
<div className="w-2 h-2 bg-green-400 rounded-full mt-2 flex-shrink-0"></div>
|
| 121 |
+
<p className="text-gray-700">{suggestion}</p>
|
| 122 |
+
</div>
|
| 123 |
+
))}
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
)}
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
{/* Gentle reminder */}
|
| 130 |
+
<div className="mt-6 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
| 131 |
+
<p className="text-sm text-yellow-800">
|
| 132 |
+
<strong>Remember:</strong> {support.gentle_reminder}
|
| 133 |
+
</p>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* Action buttons */}
|
| 137 |
+
<div className="flex space-x-3 mt-6">
|
| 138 |
+
<button
|
| 139 |
+
onClick={onClose}
|
| 140 |
+
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
|
| 141 |
+
>
|
| 142 |
+
Thank you
|
| 143 |
+
</button>
|
| 144 |
+
<button
|
| 145 |
+
onClick={() => window.open('tel:988', '_blank')}
|
| 146 |
+
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 transition-colors"
|
| 147 |
+
>
|
| 148 |
+
Crisis Hotline: 988
|
| 149 |
+
</button>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
);
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
export default CrisisSupport;
|
frontend/src/components/journal/EntryDetail.tsx
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { useNavigate, useParams } from 'react-router-dom';
|
| 3 |
+
import {
|
| 4 |
+
CalendarIcon,
|
| 5 |
+
MapPinIcon,
|
| 6 |
+
TagIcon,
|
| 7 |
+
PhotoIcon,
|
| 8 |
+
VideoCameraIcon,
|
| 9 |
+
MicrophoneIcon,
|
| 10 |
+
HeartIcon,
|
| 11 |
+
PencilIcon,
|
| 12 |
+
TrashIcon,
|
| 13 |
+
ArrowLeftIcon
|
| 14 |
+
} from '@heroicons/react/24/outline';
|
| 15 |
+
import FormattedTextPreview from './FormattedTextPreview';
|
| 16 |
+
import { useEntry, useDeleteEntry } from '../../hooks/useJournal';
|
| 17 |
+
import { ErrorDisplay } from '../common/ErrorDisplay';
|
| 18 |
+
|
| 19 |
+
interface JournalEntry {
|
| 20 |
+
id: string;
|
| 21 |
+
title?: string;
|
| 22 |
+
content: string;
|
| 23 |
+
created_at: string;
|
| 24 |
+
updated_at?: string;
|
| 25 |
+
mood?: string;
|
| 26 |
+
mood_score?: number;
|
| 27 |
+
location?: {
|
| 28 |
+
name: string;
|
| 29 |
+
lat: number;
|
| 30 |
+
lng: number;
|
| 31 |
+
};
|
| 32 |
+
tags: string[];
|
| 33 |
+
media: Array<{
|
| 34 |
+
id: string;
|
| 35 |
+
type: 'image' | 'video' | 'audio';
|
| 36 |
+
filename: string;
|
| 37 |
+
url: string;
|
| 38 |
+
thumbnail_url?: string;
|
| 39 |
+
}>;
|
| 40 |
+
ai_summary?: string;
|
| 41 |
+
word_count: number;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const EntryDetail = () => {
|
| 45 |
+
const navigate = useNavigate();
|
| 46 |
+
const { id } = useParams<{ id: string }>();
|
| 47 |
+
const [imageLoadErrors, setImageLoadErrors] = useState<Set<string>>(new Set());
|
| 48 |
+
|
| 49 |
+
const { data: entryData, isLoading, error } = useEntry(id || '');
|
| 50 |
+
const deleteEntryMutation = useDeleteEntry();
|
| 51 |
+
|
| 52 |
+
// Transform API data to component format
|
| 53 |
+
const entry: JournalEntry | null = entryData ? {
|
| 54 |
+
id: entryData.id,
|
| 55 |
+
title: entryData.title || '',
|
| 56 |
+
content: entryData.content,
|
| 57 |
+
created_at: entryData.created_at,
|
| 58 |
+
updated_at: entryData.updated_at,
|
| 59 |
+
mood: entryData.mood,
|
| 60 |
+
mood_score: entryData.ai_mood_score,
|
| 61 |
+
location: entryData.location_name ? {
|
| 62 |
+
name: entryData.location_name,
|
| 63 |
+
lat: entryData.location_lat || 0,
|
| 64 |
+
lng: entryData.location_lng || 0
|
| 65 |
+
} : undefined,
|
| 66 |
+
tags: entryData.tags?.map(tag => tag.name) || [],
|
| 67 |
+
media: entryData.media_files?.map(mediaFile => ({
|
| 68 |
+
id: mediaFile.id,
|
| 69 |
+
type: mediaFile.file_type,
|
| 70 |
+
filename: mediaFile.filename,
|
| 71 |
+
url: mediaFile.url,
|
| 72 |
+
thumbnail_url: mediaFile.url // Use the same URL for thumbnail for now
|
| 73 |
+
})) || [],
|
| 74 |
+
ai_summary: entryData.ai_summary,
|
| 75 |
+
word_count: entryData.content ? entryData.content.split(' ').length : 0
|
| 76 |
+
} : null;
|
| 77 |
+
|
| 78 |
+
const formatDate = (dateString: string) => {
|
| 79 |
+
const date = new Date(dateString);
|
| 80 |
+
return date.toLocaleDateString('en-US', {
|
| 81 |
+
weekday: 'long',
|
| 82 |
+
year: 'numeric',
|
| 83 |
+
month: 'long',
|
| 84 |
+
day: 'numeric',
|
| 85 |
+
hour: '2-digit',
|
| 86 |
+
minute: '2-digit'
|
| 87 |
+
});
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const getMoodColor = (score?: number) => {
|
| 91 |
+
if (!score) return 'text-gray-400';
|
| 92 |
+
if (score >= 8) return 'text-green-500';
|
| 93 |
+
if (score >= 6) return 'text-blue-500';
|
| 94 |
+
if (score >= 4) return 'text-yellow-500';
|
| 95 |
+
return 'text-red-500';
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const getMoodEmoji = (score?: number) => {
|
| 99 |
+
if (!score) return '😐';
|
| 100 |
+
if (score >= 8) return '😊';
|
| 101 |
+
if (score >= 6) return '🙂';
|
| 102 |
+
if (score >= 4) return '😐';
|
| 103 |
+
return '😔';
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const getMediaIcon = (type: string) => {
|
| 107 |
+
switch (type) {
|
| 108 |
+
case 'image': return PhotoIcon;
|
| 109 |
+
case 'video': return VideoCameraIcon;
|
| 110 |
+
case 'audio': return MicrophoneIcon;
|
| 111 |
+
default: return PhotoIcon;
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const handleImageError = (mediaId: string) => {
|
| 116 |
+
setImageLoadErrors(prev => new Set(prev).add(mediaId));
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const handleEdit = () => {
|
| 120 |
+
navigate(`/journal/edit/${entry?.id}`);
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const handleDelete = async () => {
|
| 124 |
+
if (!entry) return;
|
| 125 |
+
|
| 126 |
+
if (confirm('Are you sure you want to delete this entry?')) {
|
| 127 |
+
try {
|
| 128 |
+
await deleteEntryMutation.mutateAsync(entry.id);
|
| 129 |
+
navigate('/timeline');
|
| 130 |
+
} catch (error) {
|
| 131 |
+
console.error('Failed to delete entry:', error);
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const handleBack = () => {
|
| 137 |
+
navigate('/timeline');
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
if (isLoading) {
|
| 141 |
+
return (
|
| 142 |
+
<div className="max-w-4xl mx-auto">
|
| 143 |
+
<div className="animate-pulse space-y-6">
|
| 144 |
+
<div className="h-8 bg-gray-200 rounded w-48"></div>
|
| 145 |
+
<div className="h-32 bg-gray-200 rounded"></div>
|
| 146 |
+
<div className="h-64 bg-gray-200 rounded"></div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if (error || !entry) {
|
| 153 |
+
return (
|
| 154 |
+
<div className="max-w-4xl mx-auto">
|
| 155 |
+
<ErrorDisplay
|
| 156 |
+
error={error || new Error('Entry not found')}
|
| 157 |
+
title="Failed to load entry"
|
| 158 |
+
onRetry={() => window.location.reload()}
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
return (
|
| 165 |
+
<div className="max-w-4xl mx-auto">
|
| 166 |
+
{/* Header */}
|
| 167 |
+
<div className="flex items-center justify-between mb-6">
|
| 168 |
+
<button
|
| 169 |
+
onClick={handleBack}
|
| 170 |
+
className="flex items-center space-x-2 text-gray-600 hover:text-gray-900 transition-colors"
|
| 171 |
+
>
|
| 172 |
+
<ArrowLeftIcon className="h-5 w-5" />
|
| 173 |
+
<span>Back to Timeline</span>
|
| 174 |
+
</button>
|
| 175 |
+
|
| 176 |
+
<div className="flex items-center space-x-2">
|
| 177 |
+
<button
|
| 178 |
+
onClick={handleEdit}
|
| 179 |
+
className="flex items-center space-x-2 px-3 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
| 180 |
+
>
|
| 181 |
+
<PencilIcon className="h-4 w-4" />
|
| 182 |
+
<span>Edit</span>
|
| 183 |
+
</button>
|
| 184 |
+
<button
|
| 185 |
+
onClick={handleDelete}
|
| 186 |
+
disabled={deleteEntryMutation.isPending}
|
| 187 |
+
className="flex items-center space-x-2 px-3 py-2 text-red-600 bg-red-50/20 rounded-md hover:bg-red-100/30 transition-colors disabled:opacity-50"
|
| 188 |
+
>
|
| 189 |
+
<TrashIcon className="h-4 w-4" />
|
| 190 |
+
<span>{deleteEntryMutation.isPending ? 'Deleting...' : 'Delete'}</span>
|
| 191 |
+
</button>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* Entry Content */}
|
| 196 |
+
<div className="bg-white rounded-lg shadow-md">
|
| 197 |
+
{/* Entry Header */}
|
| 198 |
+
<div className="p-6 border-b border-gray-200">
|
| 199 |
+
{entry.title && (
|
| 200 |
+
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
| 201 |
+
{entry.title}
|
| 202 |
+
</h1>
|
| 203 |
+
)}
|
| 204 |
+
|
| 205 |
+
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
|
| 206 |
+
<div className="flex items-center space-x-1">
|
| 207 |
+
<CalendarIcon className="h-4 w-4" />
|
| 208 |
+
<span>{formatDate(entry.created_at)}</span>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{entry.mood_score && (
|
| 212 |
+
<div className="flex items-center space-x-1">
|
| 213 |
+
<HeartIcon className={`h-4 w-4 ${getMoodColor(entry.mood_score)}`} />
|
| 214 |
+
<span>{getMoodEmoji(entry.mood_score)}</span>
|
| 215 |
+
<span className={getMoodColor(entry.mood_score)}>
|
| 216 |
+
{entry.mood || 'Unknown mood'}
|
| 217 |
+
</span>
|
| 218 |
+
</div>
|
| 219 |
+
)}
|
| 220 |
+
|
| 221 |
+
<span>{entry.word_count} words</span>
|
| 222 |
+
|
| 223 |
+
{entry.location && (
|
| 224 |
+
<div className="flex items-center space-x-1">
|
| 225 |
+
<MapPinIcon className="h-4 w-4" />
|
| 226 |
+
<span>{entry.location.name}</span>
|
| 227 |
+
</div>
|
| 228 |
+
)}
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
{/* Entry Content */}
|
| 233 |
+
<div className="p-6">
|
| 234 |
+
<FormattedTextPreview
|
| 235 |
+
content={entry.content}
|
| 236 |
+
className="text-gray-700 leading-relaxed"
|
| 237 |
+
/>
|
| 238 |
+
|
| 239 |
+
{entry.ai_summary && (
|
| 240 |
+
<div className="mt-6 p-4 bg-blue-50/20 rounded-lg">
|
| 241 |
+
<h3 className="text-sm font-medium text-blue-800 mb-2">
|
| 242 |
+
AI Summary
|
| 243 |
+
</h3>
|
| 244 |
+
<p className="text-sm text-blue-700">
|
| 245 |
+
{entry.ai_summary}
|
| 246 |
+
</p>
|
| 247 |
+
</div>
|
| 248 |
+
)}
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{/* Media Section */}
|
| 252 |
+
{entry.media.length > 0 && (
|
| 253 |
+
<div className="p-6 border-t border-gray-200">
|
| 254 |
+
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
| 255 |
+
Media ({entry.media.length})
|
| 256 |
+
</h3>
|
| 257 |
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
| 258 |
+
{entry.media.map((media) => {
|
| 259 |
+
const IconComponent = getMediaIcon(media.type);
|
| 260 |
+
const hasError = imageLoadErrors.has(media.id);
|
| 261 |
+
|
| 262 |
+
return (
|
| 263 |
+
<div key={media.id} className="relative group">
|
| 264 |
+
{media.type === 'image' && !hasError ? (
|
| 265 |
+
<img
|
| 266 |
+
src={media.url}
|
| 267 |
+
alt={media.filename}
|
| 268 |
+
className="w-full h-32 object-cover rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
| 269 |
+
onError={() => handleImageError(media.id)}
|
| 270 |
+
onClick={() => window.open(media.url, '_blank')}
|
| 271 |
+
/>
|
| 272 |
+
) : (
|
| 273 |
+
<div className="w-full h-32 bg-gray-100 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:bg-gray-200 transition-colors">
|
| 274 |
+
<IconComponent className="h-8 w-8 text-gray-400 mb-2" />
|
| 275 |
+
<span className="text-xs text-gray-500 text-center px-2">
|
| 276 |
+
{media.filename}
|
| 277 |
+
</span>
|
| 278 |
+
</div>
|
| 279 |
+
)}
|
| 280 |
+
</div>
|
| 281 |
+
);
|
| 282 |
+
})}
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
)}
|
| 286 |
+
|
| 287 |
+
{/* Tags Section */}
|
| 288 |
+
{entry.tags.length > 0 && (
|
| 289 |
+
<div className="p-6 border-t border-gray-200">
|
| 290 |
+
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
| 291 |
+
Tags
|
| 292 |
+
</h3>
|
| 293 |
+
<div className="flex flex-wrap gap-2">
|
| 294 |
+
{entry.tags.map((tag, index) => (
|
| 295 |
+
<span
|
| 296 |
+
key={index}
|
| 297 |
+
className="inline-flex items-center space-x-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
|
| 298 |
+
>
|
| 299 |
+
<TagIcon className="h-3 w-3" />
|
| 300 |
+
<span>#{tag}</span>
|
| 301 |
+
</span>
|
| 302 |
+
))}
|
| 303 |
+
</div>
|
| 304 |
+
</div>
|
| 305 |
+
)}
|
| 306 |
+
|
| 307 |
+
{/* Footer with metadata */}
|
| 308 |
+
{entry.updated_at && entry.updated_at !== entry.created_at && (
|
| 309 |
+
<div className="p-6 border-t border-gray-200 bg-gray-50/50">
|
| 310 |
+
<p className="text-xs text-gray-500">
|
| 311 |
+
Last updated: {formatDate(entry.updated_at)}
|
| 312 |
+
</p>
|
| 313 |
+
</div>
|
| 314 |
+
)}
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
);
|
| 318 |
+
};
|
| 319 |
+
|
| 320 |
+
export default EntryDetail;
|
frontend/src/components/journal/FormattedTextPreview.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface FormattedTextPreviewProps {
|
| 2 |
+
content: string;
|
| 3 |
+
className?: string;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
const FormattedTextPreview = ({ content, className = '' }: FormattedTextPreviewProps) => {
|
| 7 |
+
const formatText = (text: string): string => {
|
| 8 |
+
// Convert markdown-style formatting to HTML
|
| 9 |
+
let formatted = text
|
| 10 |
+
// Bold text: **text** -> <strong>text</strong>
|
| 11 |
+
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
| 12 |
+
// Italic text: *text* -> <em>text</em>
|
| 13 |
+
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
| 14 |
+
// Bullet points: • text -> <li>text</li>
|
| 15 |
+
.replace(/^• (.+)$/gm, '<li>$1</li>')
|
| 16 |
+
// Numbered lists: 1. text -> <li>text</li>
|
| 17 |
+
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
|
| 18 |
+
// Line breaks
|
| 19 |
+
.replace(/\n/g, '<br>');
|
| 20 |
+
|
| 21 |
+
// Wrap consecutive <li> elements in <ul> or <ol>
|
| 22 |
+
formatted = formatted
|
| 23 |
+
// Wrap bullet list items
|
| 24 |
+
.replace(/(<li>.*?<\/li>)(\s*<br>\s*<li>.*?<\/li>)*/g, (match) => {
|
| 25 |
+
// Check if this is a numbered list (contains digits at start of original lines)
|
| 26 |
+
const originalLines = match.split('<br>').filter(line => line.includes('<li>'));
|
| 27 |
+
const isNumbered = originalLines.some(line => {
|
| 28 |
+
const textContent = line.replace(/<[^>]*>/g, '');
|
| 29 |
+
return /^\d+\./.test(textContent.trim());
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
if (isNumbered) {
|
| 33 |
+
return `<ol>${match.replace(/<br>/g, '')}</ol>`;
|
| 34 |
+
} else {
|
| 35 |
+
return `<ul>${match.replace(/<br>/g, '')}</ul>`;
|
| 36 |
+
}
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
return formatted;
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
if (!content.trim()) {
|
| 43 |
+
return (
|
| 44 |
+
<div className={`text-gray-500 italic ${className}`}>
|
| 45 |
+
No content yet...
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<div
|
| 52 |
+
className={`prose max-w-none ${className}`}
|
| 53 |
+
dangerouslySetInnerHTML={{
|
| 54 |
+
__html: formatText(content)
|
| 55 |
+
}}
|
| 56 |
+
style={{
|
| 57 |
+
// Custom styles for the formatted content
|
| 58 |
+
lineHeight: '1.6',
|
| 59 |
+
}}
|
| 60 |
+
/>
|
| 61 |
+
);
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
export default FormattedTextPreview;
|