Francesco Laiti commited on
Commit
9793f25
·
0 Parent(s):

Initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .gitignore +338 -0
  3. Dockerfile +42 -0
  4. README.md +16 -0
  5. backend/.gitkeep +1 -0
  6. backend/.python-version +1 -0
  7. backend/README.md +0 -0
  8. backend/app/__init__.py +0 -0
  9. backend/app/ai_manager.py +673 -0
  10. backend/app/crud.py +195 -0
  11. backend/app/database.py +50 -0
  12. backend/app/init_db.py +21 -0
  13. backend/app/main.py +957 -0
  14. backend/app/models.py +81 -0
  15. backend/app/schemas.py +216 -0
  16. backend/prompts.json +34 -0
  17. backend/pyproject.toml +25 -0
  18. backend/requirements.txt +69 -0
  19. backend/uv.lock +0 -0
  20. frontend/.gitignore +24 -0
  21. frontend/.gitkeep +1 -0
  22. frontend/README.md +69 -0
  23. frontend/eslint.config.js +23 -0
  24. frontend/index.html +13 -0
  25. frontend/package-lock.json +0 -0
  26. frontend/package.json +43 -0
  27. frontend/public/quiet_room_extended.png +3 -0
  28. frontend/public/vite.svg +1 -0
  29. frontend/src/App.tsx +39 -0
  30. frontend/src/assets/react.svg +1 -0
  31. frontend/src/components/chat/MessageBubble.tsx +40 -0
  32. frontend/src/components/chat/TypingIndicator.tsx +18 -0
  33. frontend/src/components/common/DevelopmentBanner.tsx +67 -0
  34. frontend/src/components/common/Disclaimer.tsx +12 -0
  35. frontend/src/components/common/ErrorBoundary.tsx +86 -0
  36. frontend/src/components/common/ErrorDisplay.tsx +175 -0
  37. frontend/src/components/common/LoadingSpinner.tsx +72 -0
  38. frontend/src/components/common/MarkdownRenderer.tsx +75 -0
  39. frontend/src/components/common/ModelCapabilityError.tsx +133 -0
  40. frontend/src/components/home/AppOverview.tsx +51 -0
  41. frontend/src/components/home/InsightsSummary.tsx +150 -0
  42. frontend/src/components/home/PersonalGrowth.tsx +241 -0
  43. frontend/src/components/home/QuickActions.tsx +95 -0
  44. frontend/src/components/home/Statistics.tsx +120 -0
  45. frontend/src/components/home/WelcomeScreen.tsx +114 -0
  46. frontend/src/components/journal/AIAnalysisPanel.tsx +215 -0
  47. frontend/src/components/journal/AudioInput.tsx +243 -0
  48. frontend/src/components/journal/CrisisSupport.tsx +157 -0
  49. frontend/src/components/journal/EntryDetail.tsx +320 -0
  50. 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

  • SHA256: 5b59b8ceea488273c1a9b9ae6da3b4abae1308f44591fc7bc0e5f0872d5eaeb0
  • Pointer size: 131 Bytes
  • Size of remote file: 255 kB
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;