From 3a85ce94e096164e154b887ae26ad052c97fb9e8 Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Mon, 8 Dec 2025 18:30:20 +0530 Subject: [PATCH 01/11] v1 commit --- apps/interactive-journal/.gitignore | 42 + apps/interactive-journal/README.md | 39 + apps/interactive-journal/backend/.env.example | 11 + .../backend/app/__init__.py | 1 + .../interactive-journal/backend/app/config.py | 21 + apps/interactive-journal/backend/app/main.py | 46 + .../backend/app/models/__init__.py | 1 + .../backend/app/models/schemas.py | 5 + .../backend/app/routers/__init__.py | 1 + .../backend/app/routers/chats.py | 82 + .../backend/app/services/__init__.py | 1 + .../backend/app/services/database.py | 28 + .../backend/app/services/openai_service.py | 31 + .../backend/requirements.txt | 8 + apps/interactive-journal/frontend/.gitignore | 24 + apps/interactive-journal/frontend/README.md | 16 + .../frontend/eslint.config.js | 29 + apps/interactive-journal/frontend/index.html | 13 + .../frontend/package-lock.json | 2872 +++++++++++++++++ .../interactive-journal/frontend/package.json | 27 + .../frontend/public/vite.svg | 1 + apps/interactive-journal/frontend/src/App.css | 358 ++ apps/interactive-journal/frontend/src/App.jsx | 91 + .../frontend/src/assets/react.svg | 1 + .../frontend/src/components/Chat.jsx | 67 + .../frontend/src/components/Sidebar.jsx | 93 + .../frontend/src/index.css | 24 + .../interactive-journal/frontend/src/main.jsx | 10 + .../frontend/vite.config.js | 7 + 29 files changed, 3950 insertions(+) create mode 100644 apps/interactive-journal/.gitignore create mode 100644 apps/interactive-journal/README.md create mode 100644 apps/interactive-journal/backend/.env.example create mode 100644 apps/interactive-journal/backend/app/__init__.py create mode 100644 apps/interactive-journal/backend/app/config.py create mode 100644 apps/interactive-journal/backend/app/main.py create mode 100644 apps/interactive-journal/backend/app/models/__init__.py create mode 100644 apps/interactive-journal/backend/app/models/schemas.py create mode 100644 apps/interactive-journal/backend/app/routers/__init__.py create mode 100644 apps/interactive-journal/backend/app/routers/chats.py create mode 100644 apps/interactive-journal/backend/app/services/__init__.py create mode 100644 apps/interactive-journal/backend/app/services/database.py create mode 100644 apps/interactive-journal/backend/app/services/openai_service.py create mode 100644 apps/interactive-journal/backend/requirements.txt create mode 100644 apps/interactive-journal/frontend/.gitignore create mode 100644 apps/interactive-journal/frontend/README.md create mode 100644 apps/interactive-journal/frontend/eslint.config.js create mode 100644 apps/interactive-journal/frontend/index.html create mode 100644 apps/interactive-journal/frontend/package-lock.json create mode 100644 apps/interactive-journal/frontend/package.json create mode 100644 apps/interactive-journal/frontend/public/vite.svg create mode 100644 apps/interactive-journal/frontend/src/App.css create mode 100644 apps/interactive-journal/frontend/src/App.jsx create mode 100644 apps/interactive-journal/frontend/src/assets/react.svg create mode 100644 apps/interactive-journal/frontend/src/components/Chat.jsx create mode 100644 apps/interactive-journal/frontend/src/components/Sidebar.jsx create mode 100644 apps/interactive-journal/frontend/src/index.css create mode 100644 apps/interactive-journal/frontend/src/main.jsx create mode 100644 apps/interactive-journal/frontend/vite.config.js diff --git a/apps/interactive-journal/.gitignore b/apps/interactive-journal/.gitignore new file mode 100644 index 00000000..6e4100cd --- /dev/null +++ b/apps/interactive-journal/.gitignore @@ -0,0 +1,42 @@ +# Environment files +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +env/ +.eggs/ +*.egg-info/ +.pytest_cache/ + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ +*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ diff --git a/apps/interactive-journal/README.md b/apps/interactive-journal/README.md new file mode 100644 index 00000000..4834fd39 --- /dev/null +++ b/apps/interactive-journal/README.md @@ -0,0 +1,39 @@ +## Setup + +### Backend + +```bash +cd backend + +# Create virtual environment +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Create .env file +cp .env.example .env +# Edit .env with your values: +# MONGODB_URI=your-mongodb-connection-string +# OPENAI_API_KEY=your-openai-api-key + +# Run the server +uvicorn app.main:app --reload +``` + +Backend runs at http://localhost:8000 + +### Frontend + +```bash +cd frontend + +# Install dependencies +npm install + +# Run the dev server +npm run dev +``` + +Frontend runs at http://localhost:5173 diff --git a/apps/interactive-journal/backend/.env.example b/apps/interactive-journal/backend/.env.example new file mode 100644 index 00000000..9ead3765 --- /dev/null +++ b/apps/interactive-journal/backend/.env.example @@ -0,0 +1,11 @@ +# MongoDB Atlas connection string +MONGODB_URI=mongodb+srv://:@.mongodb.net/?retryWrites=true&w=majority + +# Database name +DATABASE_NAME=memoir + +# OpenAI API key +OPENAI_API_KEY=sk-your-api-key-here + +# OpenAI model to use +OPENAI_MODEL=gpt-4o-mini diff --git a/apps/interactive-journal/backend/app/__init__.py b/apps/interactive-journal/backend/app/__init__.py new file mode 100644 index 00000000..3db73c29 --- /dev/null +++ b/apps/interactive-journal/backend/app/__init__.py @@ -0,0 +1 @@ +# Memoir - Interactive Journaling App diff --git a/apps/interactive-journal/backend/app/config.py b/apps/interactive-journal/backend/app/config.py new file mode 100644 index 00000000..e796229e --- /dev/null +++ b/apps/interactive-journal/backend/app/config.py @@ -0,0 +1,21 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # MongoDB + mongodb_uri: str = "mongodb://localhost:27017" + database_name: str = "memoir" + + # OpenAI + openai_api_key: str = "" + openai_model: str = "gpt-4o-mini" + + class Config: + env_file = ".env" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/apps/interactive-journal/backend/app/main.py b/apps/interactive-journal/backend/app/main.py new file mode 100644 index 00000000..5aff4a10 --- /dev/null +++ b/apps/interactive-journal/backend/app/main.py @@ -0,0 +1,46 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routers import chats +from app.services.database import close_db, connect_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + connect_db() + yield + # Shutdown + close_db() + + +app = FastAPI( + title="Memoir", + description="AI-powered interactive journaling application", + version="1.0.0", + lifespan=lifespan, +) + +# CORS middleware for frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], # Vite default port + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(chats.router, prefix="/api/chats", tags=["chats"]) + + +@app.get("/") +def root(): + return {"message": "Welcome to Memoir API"} + + +@app.get("/health") +def health_check(): + return {"status": "healthy"} diff --git a/apps/interactive-journal/backend/app/models/__init__.py b/apps/interactive-journal/backend/app/models/__init__.py new file mode 100644 index 00000000..e2313c5f --- /dev/null +++ b/apps/interactive-journal/backend/app/models/__init__.py @@ -0,0 +1 @@ +# Models module diff --git a/apps/interactive-journal/backend/app/models/schemas.py b/apps/interactive-journal/backend/app/models/schemas.py new file mode 100644 index 00000000..1ac827af --- /dev/null +++ b/apps/interactive-journal/backend/app/models/schemas.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class Message(BaseModel): + content: str diff --git a/apps/interactive-journal/backend/app/routers/__init__.py b/apps/interactive-journal/backend/app/routers/__init__.py new file mode 100644 index 00000000..22da696d --- /dev/null +++ b/apps/interactive-journal/backend/app/routers/__init__.py @@ -0,0 +1 @@ +# Routers module diff --git a/apps/interactive-journal/backend/app/routers/chats.py b/apps/interactive-journal/backend/app/routers/chats.py new file mode 100644 index 00000000..5e901b06 --- /dev/null +++ b/apps/interactive-journal/backend/app/routers/chats.py @@ -0,0 +1,82 @@ +from datetime import datetime + +from bson import ObjectId +from fastapi import APIRouter + +from app.models.schemas import Message +from app.services.database import get_database +from app.services.openai_service import generate_response + +router = APIRouter() + +USER_ID = "Apoorva" + + +@router.post("/") +def create_chat(): + db = get_database() + chat_data = { + "user_id": USER_ID, + "title": datetime.utcnow().strftime("%d/%m/%Y"), + "created_at": datetime.utcnow(), + } + result = db.chats.insert_one(chat_data) + return {"_id": str(result.inserted_id)} + + +@router.get("/") +def get_chats(): + db = get_database() + chats = list(db.chats.find({"user_id": USER_ID}).sort("created_at", -1)) + for chat in chats: + chat["_id"] = str(chat["_id"]) + return chats + + +@router.get("/{chat_id}/messages") +def get_messages(chat_id: str): + db = get_database() + messages = list(db.messages.find({"chat_id": chat_id}).sort("created_at", 1)) + for msg in messages: + msg["_id"] = str(msg["_id"]) + return messages + + +@router.post("/{chat_id}/messages") +def send_message(chat_id: str, message: Message): + db = get_database() + + # Save user message + user_msg = { + "chat_id": chat_id, + "role": "user", + "content": message.content, + "created_at": datetime.utcnow(), + } + db.messages.insert_one(user_msg) + + # Get conversation history + history = list(db.messages.find({"chat_id": chat_id}).sort("created_at", 1)) + conversation = [{"role": msg["role"], "content": msg["content"]} for msg in history] + + # Generate AI response + ai_content = generate_response(conversation) + + # Save AI response + ai_msg = { + "chat_id": chat_id, + "role": "assistant", + "content": ai_content, + "created_at": datetime.utcnow(), + } + db.messages.insert_one(ai_msg) + + return {"response": ai_content} + + +@router.delete("/{chat_id}") +def delete_chat(chat_id: str): + db = get_database() + db.chats.delete_one({"_id": ObjectId(chat_id)}) + db.messages.delete_many({"chat_id": chat_id}) + return {"deleted": True} diff --git a/apps/interactive-journal/backend/app/services/__init__.py b/apps/interactive-journal/backend/app/services/__init__.py new file mode 100644 index 00000000..0557eb63 --- /dev/null +++ b/apps/interactive-journal/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Services module diff --git a/apps/interactive-journal/backend/app/services/database.py b/apps/interactive-journal/backend/app/services/database.py new file mode 100644 index 00000000..fa98cd2a --- /dev/null +++ b/apps/interactive-journal/backend/app/services/database.py @@ -0,0 +1,28 @@ +from pymongo import MongoClient + +from app.config import get_settings + +settings = get_settings() + +client: MongoClient = None +db = None + + +def connect_db(): + global client, db + client = MongoClient(settings.mongodb_uri) + db = client[settings.database_name] + # Test connection + client.admin.command("ping") + print(f"Connected to MongoDB: {settings.database_name}") + + +def close_db(): + global client + if client: + client.close() + print("MongoDB connection closed") + + +def get_database(): + return db diff --git a/apps/interactive-journal/backend/app/services/openai_service.py b/apps/interactive-journal/backend/app/services/openai_service.py new file mode 100644 index 00000000..eabf5320 --- /dev/null +++ b/apps/interactive-journal/backend/app/services/openai_service.py @@ -0,0 +1,31 @@ +from openai import OpenAI + +from app.config import get_settings + +settings = get_settings() +client = OpenAI(api_key=settings.openai_api_key) + +JOURNAL_SYSTEM_PROMPT = """You are a thoughtful and empathetic AI journaling companion called Memoir. +Your role is to help users reflect on their thoughts, feelings, and experiences through conversation. + +Guidelines: +- Ask thoughtful follow-up questions to help users explore their thoughts deeper +- Be supportive and non-judgmental +- Help users identify patterns and insights in their reflections +- Keep responses concise but meaningful +- Encourage self-reflection without being preachy +- If users share something difficult, acknowledge their feelings first + +Remember: You're a journaling companion, not a therapist. Focus on reflection and exploration.""" + + +def generate_response(messages: list[dict]) -> str: + """Generate a response using OpenAI's chat completion.""" + response = client.chat.completions.create( + model=settings.openai_model, + messages=[{"role": "system", "content": JOURNAL_SYSTEM_PROMPT}, *messages], + temperature=0.7, + max_tokens=500, + ) + + return response.choices[0].message.content diff --git a/apps/interactive-journal/backend/requirements.txt b/apps/interactive-journal/backend/requirements.txt new file mode 100644 index 00000000..59f7c6df --- /dev/null +++ b/apps/interactive-journal/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pymongo==4.10.1 +openai==1.82.0 +python-dotenv==1.0.1 +pydantic==2.10.6 +pydantic-settings==2.7.1 +python-multipart==0.0.20 diff --git a/apps/interactive-journal/frontend/.gitignore b/apps/interactive-journal/frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/apps/interactive-journal/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/interactive-journal/frontend/README.md b/apps/interactive-journal/frontend/README.md new file mode 100644 index 00000000..18bc70eb --- /dev/null +++ b/apps/interactive-journal/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@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 + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/apps/interactive-journal/frontend/eslint.config.js b/apps/interactive-journal/frontend/eslint.config.js new file mode 100644 index 00000000..4fa125da --- /dev/null +++ b/apps/interactive-journal/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/apps/interactive-journal/frontend/index.html b/apps/interactive-journal/frontend/index.html new file mode 100644 index 00000000..c20fbd3a --- /dev/null +++ b/apps/interactive-journal/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/apps/interactive-journal/frontend/package-lock.json b/apps/interactive-journal/frontend/package-lock.json new file mode 100644 index 00000000..bdafea8b --- /dev/null +++ b/apps/interactive-journal/frontend/package-lock.json @@ -0,0 +1,2872 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^7.2.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", + "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/apps/interactive-journal/frontend/package.json b/apps/interactive-journal/frontend/package.json new file mode 100644 index 00000000..1d89f066 --- /dev/null +++ b/apps/interactive-journal/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^7.2.4" + } +} diff --git a/apps/interactive-journal/frontend/public/vite.svg b/apps/interactive-journal/frontend/public/vite.svg new file mode 100644 index 00000000..ee9fadaf --- /dev/null +++ b/apps/interactive-journal/frontend/public/vite.svg @@ -0,0 +1 @@ + diff --git a/apps/interactive-journal/frontend/src/App.css b/apps/interactive-journal/frontend/src/App.css new file mode 100644 index 00000000..e6094032 --- /dev/null +++ b/apps/interactive-journal/frontend/src/App.css @@ -0,0 +1,358 @@ +.app { + display: flex; + height: 100vh; + position: relative; + overflow: hidden; + background: #fafafa; +} + +/* Mesh gradient background */ +.app::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: + radial-gradient(at 0% 0%, rgba(6, 182, 212, 0.4) 0%, transparent 50%), + radial-gradient(at 50% 0%, rgba(168, 85, 247, 0.35) 0%, transparent 45%), + radial-gradient(at 100% 0%, rgba(251, 146, 60, 0.5) 0%, transparent 50%), + radial-gradient(at 0% 50%, rgba(34, 197, 94, 0.3) 0%, transparent 40%), + radial-gradient(at 50% 50%, rgba(236, 72, 153, 0.25) 0%, transparent 50%), + radial-gradient(at 100% 50%, rgba(234, 179, 8, 0.4) 0%, transparent 45%), + radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.35) 0%, transparent 50%), + radial-gradient(at 50% 100%, rgba(20, 184, 166, 0.3) 0%, transparent 45%), + radial-gradient(at 100% 100%, rgba(239, 68, 68, 0.35) 0%, transparent 50%); + animation: meshMove 30s ease-in-out infinite alternate; + z-index: 0; + pointer-events: none; +} + +@keyframes meshMove { + 0% { + filter: blur(60px) saturate(150%); + transform: scale(1) rotate(0deg); + } + 50% { + filter: blur(80px) saturate(130%); + transform: scale(1.1) rotate(2deg); + } + 100% { + filter: blur(60px) saturate(150%); + transform: scale(1) rotate(-2deg); + } +} + +/* Sidebar */ +.sidebar { + width: 280px; + background: transparent; + display: flex; + flex-direction: column; + position: relative; + z-index: 10; + padding: 24px 20px; +} + +.sidebar-header { + padding: 8px 4px 32px; +} + +.logo { + font-family: 'Playfair Display', Georgia, serif; + font-size: 2rem; + font-weight: 600; + font-style: italic; + color: #1a1a1a; + letter-spacing: -0.02em; +} + +.sidebar-section { + margin-bottom: 24px; +} + +.sidebar-section:last-child { + flex: 1; + display: flex; + flex-direction: column; + overflow: visible; +} + +.section-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6b7280; + padding: 0 4px 12px; +} + +.new-chat-btn { + width: 100%; + padding: 12px 16px; + background: transparent; + color: #374151; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 10px; + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 10px; +} + +.new-chat-icon { + font-size: 18px; + font-weight: 400; + color: #6b7280; +} + +.new-chat-btn:hover { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.25); +} + +.empty-state { + padding: 16px 4px; + color: #9ca3af; + font-size: 13px; +} + +.chat-list { + flex: 1; + overflow-y: auto; +} + +.chat-item { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + font-family: inherit; + font-size: 14px; + font-weight: 400; + color: #374151; + transition: all 0.15s ease; + margin-bottom: 2px; + display: flex; + align-items: center; + justify-content: space-between; + position: relative; +} + +.chat-item:hover { + background: rgba(0, 0, 0, 0.05); +} + +.chat-item.active { + background: rgba(0, 0, 0, 0.08); + font-weight: 500; + color: #1a1a1a; +} + +.chat-item.menu-open { + background: rgba(0, 0, 0, 0.06); +} + +.chat-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-btn { + opacity: 0; + background: none; + border: none; + padding: 4px 6px; + cursor: pointer; + font-size: 14px; + color: #9ca3af; + border-radius: 4px; + transition: all 0.15s ease; + line-height: 1; +} + +.chat-item:hover .menu-btn, +.menu-btn.open { + opacity: 1; +} + +.menu-btn:hover, +.menu-btn.open { + color: #374151; + background: rgba(0, 0, 0, 0.08); +} + +.dropdown-menu { + position: fixed; + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + min-width: 120px; + z-index: 9999; + overflow: hidden; + border: 1px solid rgba(0, 0, 0, 0.08); +} + +.dropdown-item { + width: 100%; + padding: 10px 14px; + border: none; + background: none; + font-family: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + transition: background 0.15s ease; + display: block; +} + +.dropdown-item:hover { + background: #f3f4f6; +} + +.dropdown-item.delete { + color: #dc2626; +} + +.dropdown-item.delete:hover { + background: #fef2f2; +} + +/* Chat area */ +.chat { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + z-index: 1; + margin: 24px 24px 24px 0; + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(30px); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.06); +} + +.chat-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: #6b7280; + font-size: 17px; + font-weight: 500; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 0; + display: flex; + flex-direction: column; +} + +.message { + width: 100%; + padding: 24px 48px; + font-size: 15px; + line-height: 1.75; + border-bottom: 1px solid rgba(0, 0, 0, 0.04); +} + +.message.user { + background: transparent; + color: #1f2937; +} + +.message.assistant { + background: rgba(139, 92, 246, 0.04); + color: #1f2937; +} + +.message-content { + max-width: 720px; + margin: 0 auto; +} + +.message-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; + color: #6b7280; +} + +.message.user .message-label { + color: #8b5cf6; +} + +.message.assistant .message-label { + color: #ec4899; +} + +/* Chat input */ +.chat-input { + padding: 24px 48px 32px; + max-width: 816px; + margin: 0 auto; + width: 100%; +} + +.chat-input-wrapper { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 8px 8px 20px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 24px; + background: rgba(255, 255, 255, 0.9); + transition: all 0.3s ease; +} + +.chat-input-wrapper:focus-within { + border-color: rgba(139, 92, 246, 0.5); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.08); +} + +.chat-input input { + flex: 1; + padding: 12px 0; + border: none; + font-family: inherit; + font-size: 15px; + outline: none; + background: transparent; +} + +.chat-input input::placeholder { + color: #9ca3af; +} + +.send-btn { + padding: 10px; + background: transparent; + color: #374151; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.send-btn:hover { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.25); +} + +.send-btn svg { + display: block; +} diff --git a/apps/interactive-journal/frontend/src/App.jsx b/apps/interactive-journal/frontend/src/App.jsx new file mode 100644 index 00000000..fe273661 --- /dev/null +++ b/apps/interactive-journal/frontend/src/App.jsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from 'react' +import Sidebar from './components/Sidebar' +import Chat from './components/Chat' +import './App.css' + +const API_URL = 'http://localhost:8000/api' + +function App() { + const [chats, setChats] = useState([]) + const [activeChat, setActiveChat] = useState(null) + const [messages, setMessages] = useState([]) + + useEffect(() => { + fetchChats() + }, []) + + useEffect(() => { + if (activeChat) { + fetchMessages(activeChat) + } + }, [activeChat]) + + const fetchChats = async () => { + const res = await fetch(`${API_URL}/chats/`) + const data = await res.json() + setChats(data) + } + + const fetchMessages = async (chatId) => { + const res = await fetch(`${API_URL}/chats/${chatId}/messages`) + const data = await res.json() + setMessages(data) + } + + const createChat = async () => { + const res = await fetch(`${API_URL}/chats/`, { method: 'POST' }) + const data = await res.json() + await fetchChats() + setActiveChat(data._id) + setMessages([]) + } + + const deleteChat = async (chatId) => { + await fetch(`${API_URL}/chats/${chatId}`, { method: 'DELETE' }) + await fetchChats() + if (activeChat === chatId) { + setActiveChat(null) + setMessages([]) + } + } + + const sendMessage = async (content) => { + // Show user message immediately + const userMsg = { + _id: Date.now().toString(), + role: 'user', + content + } + setMessages(prev => [...prev, userMsg]) + + // Get AI response + const res = await fetch(`${API_URL}/chats/${activeChat}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }) + }) + const data = await res.json() + + // Add AI response + setMessages(prev => [...prev, { _id: Date.now().toString() + '-ai', role: 'assistant', content: data.response }]) + } + + return ( +
+ + +
+ ) +} + +export default App diff --git a/apps/interactive-journal/frontend/src/assets/react.svg b/apps/interactive-journal/frontend/src/assets/react.svg new file mode 100644 index 00000000..8e0e0f15 --- /dev/null +++ b/apps/interactive-journal/frontend/src/assets/react.svg @@ -0,0 +1 @@ + diff --git a/apps/interactive-journal/frontend/src/components/Chat.jsx b/apps/interactive-journal/frontend/src/components/Chat.jsx new file mode 100644 index 00000000..4cbb8bb5 --- /dev/null +++ b/apps/interactive-journal/frontend/src/components/Chat.jsx @@ -0,0 +1,67 @@ +import { useState, useRef, useEffect } from 'react' + +function Chat({ messages, onSendMessage, hasActiveChat }) { + const [input, setInput] = useState('') + const messagesEndRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSubmit = (e) => { + e.preventDefault() + if (input.trim() && hasActiveChat) { + onSendMessage(input) + setInput('') + } + } + + if (!hasActiveChat) { + return ( +
+
+

Select an entry or create a new one to start journaling

+
+
+ ) + } + + return ( +
+
+ {messages.map((msg) => ( +
+
+
+ {msg.role === 'user' ? 'You' : 'Memoir'} +
+ {msg.content} +
+
+ ))} +
+
+
+
+ setInput(e.target.value)} + placeholder="What's on your mind?" + /> + +
+
+
+ ) +} + +export default Chat diff --git a/apps/interactive-journal/frontend/src/components/Sidebar.jsx b/apps/interactive-journal/frontend/src/components/Sidebar.jsx new file mode 100644 index 00000000..f0c6bd75 --- /dev/null +++ b/apps/interactive-journal/frontend/src/components/Sidebar.jsx @@ -0,0 +1,93 @@ +import { useState, useEffect } from 'react' + +function Sidebar({ chats, activeChat, onSelectChat, onNewChat, onDeleteChat }) { + const [openMenu, setOpenMenu] = useState(null) + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }) + + const handleMenuClick = (e, chatId) => { + e.stopPropagation() + if (openMenu === chatId) { + setOpenMenu(null) + } else { + const rect = e.currentTarget.getBoundingClientRect() + setMenuPosition({ + top: rect.bottom + 4, + left: rect.left + }) + setOpenMenu(chatId) + } + } + + const handleDelete = (e, chatId) => { + e.stopPropagation() + onDeleteChat(chatId) + setOpenMenu(null) + } + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = () => setOpenMenu(null) + if (openMenu) { + document.addEventListener('click', handleClickOutside) + return () => document.removeEventListener('click', handleClickOutside) + } + }, [openMenu]) + + return ( +
+
+

Memoir

+
+ +
+ +
+ +
+
Previous Entries
+
+ {chats.length === 0 ? ( +
No entries yet
+ ) : ( + chats.map((chat) => ( +
onSelectChat(chat._id)} + > + {chat.title} + +
+ )) + )} +
+
+ + {/* Fixed position dropdown */} + {openMenu && ( +
e.stopPropagation()} + > + +
+ )} +
+ ) +} + +export default Sidebar diff --git a/apps/interactive-journal/frontend/src/index.css b/apps/interactive-journal/frontend/src/index.css new file mode 100644 index 00000000..08c06441 --- /dev/null +++ b/apps/interactive-journal/frontend/src/index.css @@ -0,0 +1,24 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@1,600&display=swap'); + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.6; + font-weight: 400; + color: #1a1a1a; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + min-height: 100vh; +} + +#root { + height: 100vh; +} diff --git a/apps/interactive-journal/frontend/src/main.jsx b/apps/interactive-journal/frontend/src/main.jsx new file mode 100644 index 00000000..b9a1a6de --- /dev/null +++ b/apps/interactive-journal/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/apps/interactive-journal/frontend/vite.config.js b/apps/interactive-journal/frontend/vite.config.js new file mode 100644 index 00000000..8b0f57b9 --- /dev/null +++ b/apps/interactive-journal/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) From 15b7292156f63467ec22f8de0e1aa5cf3e6369af Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Tue, 9 Dec 2025 18:29:52 +0530 Subject: [PATCH 02/11] Added app version v2 --- apps/interactive-journal/README.md | 1 + apps/interactive-journal/backend/.env.example | 7 +- .../interactive-journal/backend/app/config.py | 21 - apps/interactive-journal/backend/app/main.py | 17 +- .../backend/app/models/__init__.py | 1 - .../backend/app/models/schemas.py | 5 - .../backend/app/routers/chats.py | 82 ---- .../backend/app/routers/entries.py | 232 ++++++++++ .../backend/app/services/database.py | 55 ++- .../backend/app/services/openai_service.py | 74 +++- .../backend/app/services/voyage_service.py | 59 +++ .../backend/requirements.txt | 5 +- apps/interactive-journal/frontend/README.md | 16 - apps/interactive-journal/frontend/src/App.css | 412 ++++++++++++++++-- apps/interactive-journal/frontend/src/App.jsx | 146 +++++-- .../frontend/src/components/Chat.jsx | 67 --- .../frontend/src/components/Entry.jsx | 226 ++++++++++ .../frontend/src/components/Sidebar.jsx | 90 +++- .../frontend/src/index.css | 2 +- ruff.toml | 1 + 20 files changed, 1221 insertions(+), 298 deletions(-) delete mode 100644 apps/interactive-journal/backend/app/config.py delete mode 100644 apps/interactive-journal/backend/app/models/__init__.py delete mode 100644 apps/interactive-journal/backend/app/models/schemas.py delete mode 100644 apps/interactive-journal/backend/app/routers/chats.py create mode 100644 apps/interactive-journal/backend/app/routers/entries.py create mode 100644 apps/interactive-journal/backend/app/services/voyage_service.py delete mode 100644 apps/interactive-journal/frontend/README.md delete mode 100644 apps/interactive-journal/frontend/src/components/Chat.jsx create mode 100644 apps/interactive-journal/frontend/src/components/Entry.jsx diff --git a/apps/interactive-journal/README.md b/apps/interactive-journal/README.md index 4834fd39..7f1bb17e 100644 --- a/apps/interactive-journal/README.md +++ b/apps/interactive-journal/README.md @@ -17,6 +17,7 @@ cp .env.example .env # Edit .env with your values: # MONGODB_URI=your-mongodb-connection-string # OPENAI_API_KEY=your-openai-api-key +# VOYAGE_API_KEY=your-voyage-api-key (for V2 search) # Run the server uvicorn app.main:app --reload diff --git a/apps/interactive-journal/backend/.env.example b/apps/interactive-journal/backend/.env.example index 9ead3765..7f11dcab 100644 --- a/apps/interactive-journal/backend/.env.example +++ b/apps/interactive-journal/backend/.env.example @@ -1,11 +1,14 @@ # MongoDB Atlas connection string -MONGODB_URI=mongodb+srv://:@.mongodb.net/?retryWrites=true&w=majority +MONGODB_URI=your-mongodb-connection-string-here # Database name DATABASE_NAME=memoir # OpenAI API key -OPENAI_API_KEY=sk-your-api-key-here +OPENAI_API_KEY=your-api-key-here # OpenAI model to use OPENAI_MODEL=gpt-4o-mini + +# Voyage AI API key (for V2 semantic search) +VOYAGE_API_KEY=your-voyage-api-key-here diff --git a/apps/interactive-journal/backend/app/config.py b/apps/interactive-journal/backend/app/config.py deleted file mode 100644 index e796229e..00000000 --- a/apps/interactive-journal/backend/app/config.py +++ /dev/null @@ -1,21 +0,0 @@ -from functools import lru_cache - -from pydantic_settings import BaseSettings - - -class Settings(BaseSettings): - # MongoDB - mongodb_uri: str = "mongodb://localhost:27017" - database_name: str = "memoir" - - # OpenAI - openai_api_key: str = "" - openai_model: str = "gpt-4o-mini" - - class Config: - env_file = ".env" - - -@lru_cache -def get_settings() -> Settings: - return Settings() diff --git a/apps/interactive-journal/backend/app/main.py b/apps/interactive-journal/backend/app/main.py index 5aff4a10..62f00f94 100644 --- a/apps/interactive-journal/backend/app/main.py +++ b/apps/interactive-journal/backend/app/main.py @@ -1,18 +1,31 @@ +from dotenv import load_dotenv + +load_dotenv() + +import logging from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.routers import chats +from app.routers import entries from app.services.database import close_db, connect_db +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + @asynccontextmanager async def lifespan(app: FastAPI): # Startup + logger.info("Starting Memoir API...") connect_db() + logger.info("Memoir API started successfully") yield # Shutdown + logger.info("Shutting down Memoir API...") close_db() @@ -33,7 +46,7 @@ async def lifespan(app: FastAPI): ) # Include routers -app.include_router(chats.router, prefix="/api/chats", tags=["chats"]) +app.include_router(entries.router, prefix="/api/entries", tags=["entries"]) @app.get("/") diff --git a/apps/interactive-journal/backend/app/models/__init__.py b/apps/interactive-journal/backend/app/models/__init__.py deleted file mode 100644 index e2313c5f..00000000 --- a/apps/interactive-journal/backend/app/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Models module diff --git a/apps/interactive-journal/backend/app/models/schemas.py b/apps/interactive-journal/backend/app/models/schemas.py deleted file mode 100644 index 1ac827af..00000000 --- a/apps/interactive-journal/backend/app/models/schemas.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseModel - - -class Message(BaseModel): - content: str diff --git a/apps/interactive-journal/backend/app/routers/chats.py b/apps/interactive-journal/backend/app/routers/chats.py deleted file mode 100644 index 5e901b06..00000000 --- a/apps/interactive-journal/backend/app/routers/chats.py +++ /dev/null @@ -1,82 +0,0 @@ -from datetime import datetime - -from bson import ObjectId -from fastapi import APIRouter - -from app.models.schemas import Message -from app.services.database import get_database -from app.services.openai_service import generate_response - -router = APIRouter() - -USER_ID = "Apoorva" - - -@router.post("/") -def create_chat(): - db = get_database() - chat_data = { - "user_id": USER_ID, - "title": datetime.utcnow().strftime("%d/%m/%Y"), - "created_at": datetime.utcnow(), - } - result = db.chats.insert_one(chat_data) - return {"_id": str(result.inserted_id)} - - -@router.get("/") -def get_chats(): - db = get_database() - chats = list(db.chats.find({"user_id": USER_ID}).sort("created_at", -1)) - for chat in chats: - chat["_id"] = str(chat["_id"]) - return chats - - -@router.get("/{chat_id}/messages") -def get_messages(chat_id: str): - db = get_database() - messages = list(db.messages.find({"chat_id": chat_id}).sort("created_at", 1)) - for msg in messages: - msg["_id"] = str(msg["_id"]) - return messages - - -@router.post("/{chat_id}/messages") -def send_message(chat_id: str, message: Message): - db = get_database() - - # Save user message - user_msg = { - "chat_id": chat_id, - "role": "user", - "content": message.content, - "created_at": datetime.utcnow(), - } - db.messages.insert_one(user_msg) - - # Get conversation history - history = list(db.messages.find({"chat_id": chat_id}).sort("created_at", 1)) - conversation = [{"role": msg["role"], "content": msg["content"]} for msg in history] - - # Generate AI response - ai_content = generate_response(conversation) - - # Save AI response - ai_msg = { - "chat_id": chat_id, - "role": "assistant", - "content": ai_content, - "created_at": datetime.utcnow(), - } - db.messages.insert_one(ai_msg) - - return {"response": ai_content} - - -@router.delete("/{chat_id}") -def delete_chat(chat_id: str): - db = get_database() - db.chats.delete_one({"_id": ObjectId(chat_id)}) - db.messages.delete_many({"chat_id": chat_id}) - return {"deleted": True} diff --git a/apps/interactive-journal/backend/app/routers/entries.py b/apps/interactive-journal/backend/app/routers/entries.py new file mode 100644 index 00000000..05d4fd9b --- /dev/null +++ b/apps/interactive-journal/backend/app/routers/entries.py @@ -0,0 +1,232 @@ +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from bson import ObjectId +from fastapi import APIRouter, File, Form, UploadFile + +from app.services.database import create_vector_index, get_database +from app.services.openai_service import extract_memories, generate_response +from app.services.voyage_service import get_embedding, get_text_embedding + +logger = logging.getLogger(__name__) + +# Store images in frontend's public folder for direct access +UPLOADS_DIR = ( + Path(__file__).parent.parent.parent.parent / "frontend" / "public" / "uploads" +) +UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + + +router = APIRouter() + +USER_ID = "Apoorva" + + +def utc_now(): + return datetime.now(timezone.utc) + + +@router.post("/") +def create_entry(version: int = Form(1)): + db = get_database() + now = utc_now() + entry_data = { + "user_id": USER_ID, + "title": now.strftime("%d/%m/%Y"), + "version": version, + "created_at": now, + } + result = db.entries.insert_one(entry_data) + logger.info(f"Created entry {result.inserted_id} for user {USER_ID}") + return {"_id": str(result.inserted_id)} + + +@router.get("/") +def get_entries(version: int = 1): + db = get_database() + query = {"user_id": USER_ID, "version": version} + entries = list(db.entries.find(query).sort("created_at", -1)) + for entry in entries: + entry["_id"] = str(entry["_id"]) + return entries + + +@router.post("/init-v2") +def init_v2(): + """Initialize V2 features by creating the vector search index.""" + success = create_vector_index() + return {"success": success} + + +@router.get("/search") +def search_entries(q: str): + """Search through entries using MongoDB Atlas Vector Search.""" + db = get_database() + logger.info(f"Searching entries with query: {q[:50]}...") + + query_embedding = get_embedding(q, mode="text", input_type="query") + + pipeline = [ + { + "$vectorSearch": { + "index": "vector_index", + "path": "embedding", + "queryVector": query_embedding, + "numCandidates": 100, + "limit": 10, + } + }, + { + "$project": { + "_id": 1, + "entry_id": 1, + "content": 1, + "image": 1, + "role": 1, + "created_at": 1, + "score": {"$meta": "vectorSearchScore"}, + } + }, + ] + + results = list(db.messages.aggregate(pipeline)) + for result in results: + result["_id"] = str(result["_id"]) + + logger.info(f"Search returned {len(results)} results") + return results + + +@router.get("/{entry_id}/messages") +def get_messages(entry_id: str): + db = get_database() + messages = list(db.messages.find({"entry_id": entry_id}).sort("created_at", 1)) + for msg in messages: + msg["_id"] = str(msg["_id"]) + msg.pop("embedding", None) + return messages + + +@router.post("/{entry_id}/messages") +def send_message( + entry_id: str, + content: Optional[str] = Form(None), + images: list[UploadFile] = File([]), + version: int = Form(1), +): + db = get_database() + is_v2 = version == 2 + logger.info(f"Processing message for entry {entry_id} (v{version})") + + # Save text message if provided + if content: + embedding = get_embedding(content, mode="text", input_type="document") + text_msg = { + "entry_id": entry_id, + "role": "user", + "content": content, + "embedding": embedding, + "created_at": utc_now(), + } + db.messages.insert_one(text_msg) + logger.info(f"Saved text message for entry {entry_id}") + + # Save each image as a separate message + for image_file in images: + filename = f"{uuid.uuid4()}{Path(image_file.filename).suffix or '.jpg'}" + image_path = UPLOADS_DIR / filename + with open(image_path, "wb") as f: + f.write(image_file.file.read()) + + embedding = get_embedding(image_path, mode="image", input_type="document") + image_msg = { + "entry_id": entry_id, + "role": "user", + "image": filename, + "embedding": embedding, + "created_at": utc_now(), + } + db.messages.insert_one(image_msg) + logger.info(f"Saved image {filename} for entry {entry_id}") + + # V2 only: Extract and save memories, then retrieve relevant ones + retrieved_memories = [] + if is_v2 and content: + # Extract memories from user message + memories = extract_memories(content) + if memories: + memory_docs = [] + for memory_content in memories: + memory_embedding = get_text_embedding( + memory_content, input_type="document" + ) + memory_docs.append( + { + "user_id": USER_ID, + "entry_id": entry_id, + "content": memory_content, + "embedding": memory_embedding, + "created_at": utc_now(), + } + ) + db.memories.insert_many(memory_docs) + logger.info(f"Extracted and saved {len(memories)} memories: {memories}") + + # Retrieve relevant memories via vector search + query_embedding = get_text_embedding(content, input_type="query") + pipeline = [ + { + "$vectorSearch": { + "index": "vector_index", + "path": "embedding", + "queryVector": query_embedding, + "numCandidates": 100, + "limit": 10, + "filter": {"user_id": USER_ID}, + } + }, + {"$project": {"content": 1, "score": {"$meta": "vectorSearchScore"}}}, + ] + results = list(db.memories.aggregate(pipeline)) + retrieved_memories = [r["content"] for r in results] + logger.info(f"Retrieved {len(retrieved_memories)} memories for context") + + # Get conversation history (text only for AI context) + history = list(db.messages.find({"entry_id": entry_id}).sort("created_at", 1)) + conversation = [] + for msg in history: + if msg.get("content"): + conversation.append({"role": msg["role"], "content": msg["content"]}) + elif msg.get("image") and msg["role"] == "user": + conversation.append({"role": "user", "content": "[User shared an image]"}) + + # Generate AI response with retrieved memories + ai_content = generate_response(conversation, memories=retrieved_memories) + + # Save AI response + ai_msg = { + "entry_id": entry_id, + "role": "assistant", + "content": ai_content, + "created_at": utc_now(), + } + db.messages.insert_one(ai_msg) + logger.info(f"Generated and saved AI response for entry {entry_id}") + + return {"response": ai_content} + + +@router.delete("/{entry_id}") +def delete_entry(entry_id: str): + db = get_database() + db.entries.delete_one({"_id": ObjectId(entry_id)}) + messages_deleted = db.messages.delete_many({"entry_id": entry_id}) + memories_deleted = db.memories.delete_many({"entry_id": entry_id}) + logger.info( + f"Deleted entry {entry_id}: {messages_deleted.deleted_count} messages, " + f"{memories_deleted.deleted_count} memories" + ) + return {"deleted": True} diff --git a/apps/interactive-journal/backend/app/services/database.py b/apps/interactive-journal/backend/app/services/database.py index fa98cd2a..a9793b1e 100644 --- a/apps/interactive-journal/backend/app/services/database.py +++ b/apps/interactive-journal/backend/app/services/database.py @@ -1,8 +1,10 @@ -from pymongo import MongoClient +import logging +import os -from app.config import get_settings +from pymongo import MongoClient +from pymongo.errors import OperationFailure -settings = get_settings() +logger = logging.getLogger(__name__) client: MongoClient = None db = None @@ -10,19 +12,56 @@ def connect_db(): global client, db - client = MongoClient(settings.mongodb_uri) - db = client[settings.database_name] - # Test connection + uri = os.getenv("MONGODB_URI", "mongodb://localhost:27017") + db_name = os.getenv("DATABASE_NAME", "memoir") + client = MongoClient(uri) + db = client[db_name] client.admin.command("ping") - print(f"Connected to MongoDB: {settings.database_name}") + logger.info(f"Connected to MongoDB: {db_name}") def close_db(): global client if client: client.close() - print("MongoDB connection closed") + logger.info("MongoDB connection closed") def get_database(): return db + + +def create_vector_index(): + """Create vector search indexes on messages and memories collections.""" + if db is None: + return False + + # Index config (same for both collections) + index_model = { + "name": "vector_index", + "type": "vectorSearch", + "definition": { + "fields": [ + { + "type": "vector", + "numDimensions": 1024, + "path": "embedding", + "similarity": "cosine", + } + ] + }, + } + + success = True + for collection in ["messages", "memories"]: + try: + logger.info(f"Creating vector_index on {collection}") + getattr(db, collection).create_search_index(model=index_model) + logger.info(f"Vector search index created on {collection}") + except OperationFailure: + logger.info(f"Vector search index on {collection} already exists") + except Exception as e: + logger.error(f"Error creating vector search index on {collection}: {e}") + success = False + + return success diff --git a/apps/interactive-journal/backend/app/services/openai_service.py b/apps/interactive-journal/backend/app/services/openai_service.py index eabf5320..f5ff4303 100644 --- a/apps/interactive-journal/backend/app/services/openai_service.py +++ b/apps/interactive-journal/backend/app/services/openai_service.py @@ -1,9 +1,13 @@ +import json +import logging +import os +from typing import Optional + from openai import OpenAI -from app.config import get_settings +logger = logging.getLogger(__name__) -settings = get_settings() -client = OpenAI(api_key=settings.openai_api_key) +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) JOURNAL_SYSTEM_PROMPT = """You are a thoughtful and empathetic AI journaling companion called Memoir. Your role is to help users reflect on their thoughts, feelings, and experiences through conversation. @@ -18,14 +22,72 @@ Remember: You're a journaling companion, not a therapist. Focus on reflection and exploration.""" +MEMORY_EXTRACTION_PROMPT = """You are a memory extraction system. Analyze the user's journal entry and extract meaningful memories, insights, and facts about the user. + +Extract information such as: +- Personal facts (relationships, work, hobbies, preferences) +- Emotional patterns and feelings +- Goals, aspirations, and plans +- Significant events or experiences +- Insights and realizations + +Return a JSON array of memory strings. Each memory should be a concise, standalone statement ending in a period. +If no meaningful memories can be extracted, return an empty array. + +Example output: +["User has a sister named Sarah.", "User feels anxious about their job interview next week.", "User enjoys morning walks."]""" -def generate_response(messages: list[dict]) -> str: + +def extract_memories(user_message: str) -> list[str]: + """Extract memories/insights from a user's journal entry.""" + model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + logger.info(f"Extracting memories using {model}") + + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": MEMORY_EXTRACTION_PROMPT}, + {"role": "user", "content": user_message}, + ], + temperature=0.3, + max_tokens=500, + response_format={"type": "json_object"}, + ) + + try: + result = json.loads(response.choices[0].message.content) + # Handle both {"memories": [...]} and direct array formats + if isinstance(result, list): + memories = result + else: + memories = result.get("memories", []) + logger.info(f"Extracted {len(memories)} memories") + return memories + except (json.JSONDecodeError, AttributeError) as e: + logger.error(f"Failed to parse memory extraction response: {e}") + return [] + + +def generate_response( + messages: list[dict], memories: Optional[list[str]] = None +) -> str: """Generate a response using OpenAI's chat completion.""" + model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + logger.info( + f"Generating response using {model} with {len(memories) if memories else 0} memories" + ) + + system_prompt = JOURNAL_SYSTEM_PROMPT + if memories: + memory_context = "\n".join(f"- {m}" for m in memories) + system_prompt += f"\n\nRelevant memories about this user:\n{memory_context}\n\nUse these memories to provide more personalized and contextual responses when relevant." + response = client.chat.completions.create( - model=settings.openai_model, - messages=[{"role": "system", "content": JOURNAL_SYSTEM_PROMPT}, *messages], + model=model, + messages=[{"role": "system", "content": system_prompt}, *messages], temperature=0.7, max_tokens=500, ) + logger.info("Response generated successfully") return response.choices[0].message.content diff --git a/apps/interactive-journal/backend/app/services/voyage_service.py b/apps/interactive-journal/backend/app/services/voyage_service.py new file mode 100644 index 00000000..547f5106 --- /dev/null +++ b/apps/interactive-journal/backend/app/services/voyage_service.py @@ -0,0 +1,59 @@ +import logging +import os +from pathlib import Path +from typing import List, Union + +import voyageai +from PIL import Image + +logger = logging.getLogger(__name__) + +vo = voyageai.Client(api_key=os.getenv("VOYAGE_API_KEY")) + + +def get_embedding(content: Union[str, Path], mode: str, input_type: str) -> List[float]: + """ + Generate embeddings using Voyage AI's multimodal model. + + Args: + content: Text string or path to image file + mode (str): Content mode ("image" or "text") + input_type (str): Type of input ("document" or "query") + + Returns: + List[float]: Embedding of the content as a list. + """ + logger.debug( + f"Generating multimodal embedding: mode={mode}, input_type={input_type}" + ) + + if mode == "image": + content = Image.open(content) + + result = vo.multimodal_embed( + inputs=[[content]], model="voyage-multimodal-3", input_type=input_type + ).embeddings[0] + + logger.debug(f"Generated embedding with {len(result)} dimensions") + return result + + +def get_text_embedding(text: str, input_type: str = "document") -> List[float]: + """ + Generate text embeddings using Voyage AI's voyage-3-large model. + + Args: + text: Text string to embed + input_type: Type of input ("document" or "query") + + Returns: + List[float]: Embedding of the text as a list. + """ + logger.debug(f"Generating text embedding: input_type={input_type}") + + result = vo.embed( + texts=[text], model="voyage-3-large", input_type=input_type + ).embeddings[0] + + logger.debug(f"Generated embedding with {len(result)} dimensions") + return result diff --git a/apps/interactive-journal/backend/requirements.txt b/apps/interactive-journal/backend/requirements.txt index 59f7c6df..faa669ed 100644 --- a/apps/interactive-journal/backend/requirements.txt +++ b/apps/interactive-journal/backend/requirements.txt @@ -3,6 +3,5 @@ uvicorn[standard]==0.34.0 pymongo==4.10.1 openai==1.82.0 python-dotenv==1.0.1 -pydantic==2.10.6 -pydantic-settings==2.7.1 -python-multipart==0.0.20 +voyageai==0.3.2 +Pillow==11.0.0 diff --git a/apps/interactive-journal/frontend/README.md b/apps/interactive-journal/frontend/README.md deleted file mode 100644 index 18bc70eb..00000000 --- a/apps/interactive-journal/frontend/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# React + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@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 - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/apps/interactive-journal/frontend/src/App.css b/apps/interactive-journal/frontend/src/App.css index e6094032..afff332a 100644 --- a/apps/interactive-journal/frontend/src/App.css +++ b/apps/interactive-journal/frontend/src/App.css @@ -56,16 +56,43 @@ } .sidebar-header { - padding: 8px 4px 32px; + padding: 8px 4px 24px; } -.logo { - font-family: 'Playfair Display', Georgia, serif; - font-size: 2rem; +.user-info { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 4px; + margin-top: auto; +} + +.user-avatar { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #8b5cf6, #ec4899); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; font-weight: 600; - font-style: italic; + font-size: 14px; +} + +.user-name { + font-size: 14px; + font-weight: 500; color: #1a1a1a; - letter-spacing: -0.02em; +} + +.logo { + font-family: 'Sacramento', cursive; + font-size: 2.5rem; + font-weight: 400; + color: #1a1a1a; + letter-spacing: 0.08em; + -webkit-text-stroke: 0.5px #1a1a1a; } .sidebar-section { @@ -88,7 +115,7 @@ padding: 0 4px 12px; } -.new-chat-btn { +.new-entry-btn { width: 100%; padding: 12px 16px; background: transparent; @@ -105,13 +132,13 @@ gap: 10px; } -.new-chat-icon { +.new-entry-icon { font-size: 18px; font-weight: 400; color: #6b7280; } -.new-chat-btn:hover { +.new-entry-btn:hover { background: rgba(0, 0, 0, 0.04); border-color: rgba(0, 0, 0, 0.25); } @@ -122,12 +149,12 @@ font-size: 13px; } -.chat-list { +.entry-list { flex: 1; overflow-y: auto; } -.chat-item { +.entry-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; @@ -143,21 +170,21 @@ position: relative; } -.chat-item:hover { +.entry-item:hover { background: rgba(0, 0, 0, 0.05); } -.chat-item.active { +.entry-item.active { background: rgba(0, 0, 0, 0.08); font-weight: 500; color: #1a1a1a; } -.chat-item.menu-open { +.entry-item.menu-open { background: rgba(0, 0, 0, 0.06); } -.chat-title { +.entry-title { flex: 1; overflow: hidden; text-overflow: ellipsis; @@ -177,7 +204,7 @@ line-height: 1; } -.chat-item:hover .menu-btn, +.entry-item:hover .menu-btn, .menu-btn.open { opacity: 1; } @@ -224,8 +251,8 @@ background: #fef2f2; } -/* Chat area */ -.chat { +/* Entry area */ +.entry { flex: 1; display: flex; flex-direction: column; @@ -239,7 +266,7 @@ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.06); } -.chat-empty { +.entry-empty { flex: 1; display: flex; align-items: center; @@ -249,6 +276,14 @@ font-weight: 500; } +.greeting { + font-size: 28px; + font-weight: 400; + color: #374151; + text-align: center; + margin-bottom: 32px; +} + .messages { flex: 1; overflow-y: auto; @@ -297,15 +332,15 @@ color: #ec4899; } -/* Chat input */ -.chat-input { +/* Entry input */ +.entry-input { padding: 24px 48px 32px; max-width: 816px; margin: 0 auto; width: 100%; } -.chat-input-wrapper { +.entry-input-wrapper { display: flex; align-items: center; gap: 12px; @@ -316,12 +351,12 @@ transition: all 0.3s ease; } -.chat-input-wrapper:focus-within { +.entry-input-wrapper:focus-within { border-color: rgba(139, 92, 246, 0.5); box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.08); } -.chat-input input { +.entry-input input { flex: 1; padding: 12px 0; border: none; @@ -331,7 +366,7 @@ background: transparent; } -.chat-input input::placeholder { +.entry-input input::placeholder { color: #9ca3af; } @@ -356,3 +391,330 @@ .send-btn svg { display: block; } + +/* Version toggle */ +.version-toggle { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; +} + +.version-label { + font-size: 12px; + font-weight: 500; + color: #9ca3af; + transition: color 0.2s ease; +} + +.version-label.active { + color: #1a1a1a; +} + +.toggle-switch { + width: 44px; + height: 24px; + background: rgba(0, 0, 0, 0.1); + border: none; + border-radius: 12px; + cursor: pointer; + position: relative; + transition: background 0.2s ease; + padding: 0; +} + +.toggle-switch.on { + background: #8b5cf6; +} + +.toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: transform 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.toggle-switch.on .toggle-knob { + transform: translateX(20px); +} + +/* Sidebar nav */ +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 4px; + margin-bottom: 16px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: transparent; + border: none; + border-radius: 8px; + font-family: inherit; + font-size: 14px; + font-weight: 400; + color: #374151; + cursor: pointer; + transition: all 0.15s ease; + text-align: left; +} + +.nav-item:hover { + background: rgba(0, 0, 0, 0.05); +} + +.nav-item.active { + background: rgba(0, 0, 0, 0.08); + font-weight: 500; +} + +.nav-item svg { + flex-shrink: 0; + color: #6b7280; +} + +/* Search form */ +.search-form { + position: relative; + margin-bottom: 12px; +} + +.search-input { + width: 100%; + padding: 10px 32px 10px 12px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + font-family: inherit; + font-size: 13px; + background: rgba(255, 255, 255, 0.6); + outline: none; + transition: all 0.2s ease; +} + +.search-input:focus { + border-color: rgba(139, 92, 246, 0.5); + box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.1); +} + +.search-input::placeholder { + color: #9ca3af; +} + +.search-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + font-size: 18px; + color: #9ca3af; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} + +.search-clear:hover { + color: #6b7280; +} + +.search-results { + margin-bottom: 8px; +} + +/* Photo upload button */ +.photo-btn { + padding: 10px; + background: transparent; + color: #6b7280; + border: none; + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.photo-btn:hover { + background: rgba(0, 0, 0, 0.04); + color: #374151; +} + +/* Main area search */ +.entry-search { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 48px; + max-width: 600px; + margin: 0 auto; + width: 100%; +} + +.search-title { + font-size: 24px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 24px; +} + +.search-form-main { + position: relative; + width: 100%; + margin-bottom: 24px; +} + +.search-input-main { + width: 100%; + padding: 16px 48px 16px 20px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 16px; + font-family: inherit; + font-size: 16px; + background: rgba(255, 255, 255, 0.9); + outline: none; + transition: all 0.2s ease; +} + +.search-input-main:focus { + border-color: rgba(139, 92, 246, 0.5); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); +} + +.search-input-main::placeholder { + color: #9ca3af; +} + +.search-clear-main { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + font-size: 24px; + color: #9ca3af; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} + +.search-clear-main:hover { + color: #6b7280; +} + +.search-results-main { + width: 100%; +} + +.no-results { + text-align: center; + color: #9ca3af; + font-size: 15px; +} + +.search-result-item { + padding: 16px 20px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 12px; + margin-bottom: 12px; + cursor: pointer; + transition: all 0.15s ease; +} + +.search-result-item:hover { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(139, 92, 246, 0.3); +} + +.result-date { + display: block; + font-size: 12px; + font-weight: 500; + color: #8b5cf6; + margin-bottom: 8px; +} + +.result-content { + font-size: 14px; + color: #374151; + line-height: 1.6; + margin: 0; +} + +.result-image { + max-width: 100%; + max-height: 150px; + border-radius: 8px; + object-fit: contain; +} + +/* Image previews in input */ +.image-preview-container { + display: flex; + gap: 12px; + padding: 12px 48px; + flex-wrap: wrap; +} + +.image-preview { + position: relative; + width: 80px; + height: 80px; + border-radius: 8px; + overflow: hidden; +} + +.image-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove-image-btn { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + color: white; + border: none; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.remove-image-btn:hover { + background: rgba(0, 0, 0, 0.8); +} + +/* Message text and images */ +.message-text { + margin: 0; +} + +.message-image { + max-width: 400px; + max-height: 300px; + border-radius: 12px; + object-fit: contain; + display: block; +} diff --git a/apps/interactive-journal/frontend/src/App.jsx b/apps/interactive-journal/frontend/src/App.jsx index fe273661..d983796b 100644 --- a/apps/interactive-journal/frontend/src/App.jsx +++ b/apps/interactive-journal/frontend/src/App.jsx @@ -1,68 +1,120 @@ import { useState, useEffect } from 'react' import Sidebar from './components/Sidebar' -import Chat from './components/Chat' +import Entry from './components/Entry' import './App.css' const API_URL = 'http://localhost:8000/api' function App() { - const [chats, setChats] = useState([]) - const [activeChat, setActiveChat] = useState(null) + const [entries, setEntries] = useState([]) + const [activeEntry, setActiveEntry] = useState(null) const [messages, setMessages] = useState([]) + const [isV2, setIsV2] = useState(false) + const [v2Initialized, setV2Initialized] = useState(false) + const [activeSection, setActiveSection] = useState(null) useEffect(() => { - fetchChats() - }, []) + fetchEntries() + }, [isV2]) useEffect(() => { - if (activeChat) { - fetchMessages(activeChat) + if (activeEntry) { + fetchMessages(activeEntry) } - }, [activeChat]) + }, [activeEntry]) - const fetchChats = async () => { - const res = await fetch(`${API_URL}/chats/`) + const fetchEntries = async () => { + const version = isV2 ? 2 : 1 + const res = await fetch(`${API_URL}/entries/?version=${version}`) const data = await res.json() - setChats(data) + setEntries(data) } - const fetchMessages = async (chatId) => { - const res = await fetch(`${API_URL}/chats/${chatId}/messages`) + const fetchMessages = async (entryId) => { + const res = await fetch(`${API_URL}/entries/${entryId}/messages`) const data = await res.json() setMessages(data) } - const createChat = async () => { - const res = await fetch(`${API_URL}/chats/`, { method: 'POST' }) + const createEntry = async () => { + const version = isV2 ? 2 : 1 + const formData = new FormData() + formData.append('version', version) + const res = await fetch(`${API_URL}/entries/`, { + method: 'POST', + body: formData + }) const data = await res.json() - await fetchChats() - setActiveChat(data._id) + await fetchEntries() + setActiveEntry(data._id) setMessages([]) + setActiveSection(null) } - const deleteChat = async (chatId) => { - await fetch(`${API_URL}/chats/${chatId}`, { method: 'DELETE' }) - await fetchChats() - if (activeChat === chatId) { - setActiveChat(null) + const deleteEntry = async (entryId) => { + await fetch(`${API_URL}/entries/${entryId}`, { method: 'DELETE' }) + await fetchEntries() + if (activeEntry === entryId) { + setActiveEntry(null) setMessages([]) } } - const sendMessage = async (content) => { - // Show user message immediately - const userMsg = { - _id: Date.now().toString(), - role: 'user', - content + const initV2 = async () => { + if (v2Initialized) return + try { + await fetch(`${API_URL}/entries/init-v2`, { method: 'POST' }) + setV2Initialized(true) + } catch (error) { + console.error('Failed to initialize V2:', error) + } + } + + const toggleVersion = async () => { + const newIsV2 = !isV2 + if (newIsV2 && !v2Initialized) { + await initV2() + } + setIsV2(newIsV2) + setActiveEntry(null) + setMessages([]) + } + + const sendMessage = async (content, images = []) => { + // Show user messages immediately (text and images separately) + const newMessages = [] + + if (content.trim()) { + newMessages.push({ + _id: Date.now().toString(), + role: 'user', + content + }) + } + + images.forEach((img, index) => { + newMessages.push({ + _id: Date.now().toString() + '-img-' + index, + role: 'user', + image: img.preview + }) + }) + + setMessages(prev => [...prev, ...newMessages]) + + // Send to backend using FormData + const formData = new FormData() + if (content) { + formData.append('content', content) } - setMessages(prev => [...prev, userMsg]) + images.forEach(img => { + formData.append('images', img.file) + }) + formData.append('version', isV2 ? 2 : 1) - // Get AI response - const res = await fetch(`${API_URL}/chats/${activeChat}/messages`, { + const res = await fetch(`${API_URL}/entries/${activeEntry}/messages`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content }) + body: formData }) const data = await res.json() @@ -73,16 +125,30 @@ function App() { return (
{ + setActiveEntry(entryId) + if (isV2) setActiveSection('entries') + }} + onNewEntry={createEntry} + onDeleteEntry={deleteEntry} + isV2={isV2} + onToggleVersion={toggleVersion} + activeSection={activeSection} + onSectionChange={(section) => { + setActiveSection(section) + setActiveEntry(null) + setMessages([]) + }} /> -
) diff --git a/apps/interactive-journal/frontend/src/components/Chat.jsx b/apps/interactive-journal/frontend/src/components/Chat.jsx deleted file mode 100644 index 4cbb8bb5..00000000 --- a/apps/interactive-journal/frontend/src/components/Chat.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState, useRef, useEffect } from 'react' - -function Chat({ messages, onSendMessage, hasActiveChat }) { - const [input, setInput] = useState('') - const messagesEndRef = useRef(null) - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - } - - useEffect(() => { - scrollToBottom() - }, [messages]) - - const handleSubmit = (e) => { - e.preventDefault() - if (input.trim() && hasActiveChat) { - onSendMessage(input) - setInput('') - } - } - - if (!hasActiveChat) { - return ( -
-
-

Select an entry or create a new one to start journaling

-
-
- ) - } - - return ( -
-
- {messages.map((msg) => ( -
-
-
- {msg.role === 'user' ? 'You' : 'Memoir'} -
- {msg.content} -
-
- ))} -
-
-
-
- setInput(e.target.value)} - placeholder="What's on your mind?" - /> - -
-
-
- ) -} - -export default Chat diff --git a/apps/interactive-journal/frontend/src/components/Entry.jsx b/apps/interactive-journal/frontend/src/components/Entry.jsx new file mode 100644 index 00000000..14df4cef --- /dev/null +++ b/apps/interactive-journal/frontend/src/components/Entry.jsx @@ -0,0 +1,226 @@ +import { useState, useRef, useEffect } from 'react' + +function Entry({ messages, onSendMessage, hasActiveEntry, isV2, activeSection, onSelectEntry }) { + const [input, setInput] = useState('') + const [selectedImages, setSelectedImages] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState(null) + const [isSearching, setIsSearching] = useState(false) + const messagesEndRef = useRef(null) + const fileInputRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSubmit = (e) => { + e.preventDefault() + if ((input.trim() || selectedImages.length > 0) && hasActiveEntry) { + onSendMessage(input, selectedImages) + setInput('') + setSelectedImages([]) + } + } + + const handlePhotoClick = () => { + fileInputRef.current?.click() + } + + const handleFileChange = (e) => { + const files = Array.from(e.target.files || []) + + files.forEach(file => { + setSelectedImages(prev => [...prev, { + file, + preview: URL.createObjectURL(file), + name: file.name + }]) + }) + + // Reset input so same file can be selected again + e.target.value = '' + } + + const removeImage = (index) => { + setSelectedImages(prev => prev.filter((_, i) => i !== index)) + } + + const handleSearch = async (e) => { + e.preventDefault() + if (!searchQuery.trim()) return + + setIsSearching(true) + try { + const res = await fetch(`http://localhost:8000/api/entries/search?q=${encodeURIComponent(searchQuery)}`) + const data = await res.json() + setSearchResults(data) + } catch (error) { + console.error('Search failed:', error) + } + setIsSearching(false) + } + + const clearSearch = () => { + setSearchQuery('') + setSearchResults(null) + } + + // Show search interface when V2 Entries tab is clicked and no entry selected + if (isV2 && activeSection === 'entries' && !hasActiveEntry) { + return ( +
+
+

Search your entries

+
+ setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+ {searchResults && ( +
+ {searchResults.length === 0 ? ( +

No matches found

+ ) : ( + searchResults.map((result) => ( +
onSelectEntry(result.entry_id)} + > + + {new Date(result.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + })} + + {result.image ? ( + Result + ) : ( +

{result.content}

+ )} +
+ )) + )} +
+ )} +
+
+ ) + } + + // Show insights placeholder when V2 Insights tab is active and no entry selected + if (isV2 && activeSection === 'insights' && !hasActiveEntry) { + return ( +
+
+

Insights coming soon...

+
+
+ ) + } + + if (!hasActiveEntry) { + return ( +
+
+

How was your day, Apoorva?

+
+
+ ) + } + + return ( +
+
+ {messages.map((msg) => ( +
+
+
+ {msg.role === 'user' ? 'You' : 'Memoir'} +
+ {msg.content &&

{msg.content}

} + {msg.image && ( + Uploaded + )} +
+
+ ))} +
+
+ + {/* Image previews */} + {selectedImages.length > 0 && ( +
+ {selectedImages.map((img, index) => ( +
+ {img.name} + +
+ ))} +
+ )} + +
+
+ {isV2 && ( + <> + + + + )} + setInput(e.target.value)} + placeholder="What's on your mind?" + /> + +
+
+
+ ) +} + +export default Entry diff --git a/apps/interactive-journal/frontend/src/components/Sidebar.jsx b/apps/interactive-journal/frontend/src/components/Sidebar.jsx index f0c6bd75..475bade9 100644 --- a/apps/interactive-journal/frontend/src/components/Sidebar.jsx +++ b/apps/interactive-journal/frontend/src/components/Sidebar.jsx @@ -1,12 +1,20 @@ import { useState, useEffect } from 'react' -function Sidebar({ chats, activeChat, onSelectChat, onNewChat, onDeleteChat }) { +function Sidebar({ entries, activeEntry, onSelectEntry, onNewEntry, onDeleteEntry, isV2, onToggleVersion, activeSection, onSectionChange }) { const [openMenu, setOpenMenu] = useState(null) const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }) - const handleMenuClick = (e, chatId) => { + // Filter entries to only show recent ones (last 7 days) in V2 + const recentEntries = entries.filter(entry => { + const entryDate = new Date(entry.created_at) + const oneWeekAgo = new Date() + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7) + return entryDate >= oneWeekAgo + }) + + const handleMenuClick = (e, entryId) => { e.stopPropagation() - if (openMenu === chatId) { + if (openMenu === entryId) { setOpenMenu(null) } else { const rect = e.currentTarget.getBoundingClientRect() @@ -14,13 +22,13 @@ function Sidebar({ chats, activeChat, onSelectChat, onNewChat, onDeleteChat }) { top: rect.bottom + 4, left: rect.left }) - setOpenMenu(chatId) + setOpenMenu(entryId) } } - const handleDelete = (e, chatId) => { + const handleDelete = (e, entryId) => { e.stopPropagation() - onDeleteChat(chatId) + onDeleteEntry(entryId) setOpenMenu(null) } @@ -33,35 +41,74 @@ function Sidebar({ chats, activeChat, onSelectChat, onNewChat, onDeleteChat }) { } }, [openMenu]) + const displayEntries = isV2 ? recentEntries : entries + + const formatDate = (dateString) => { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + } + return (

Memoir

+
+ V1 + + V2 +
-
+ {isV2 && ( +
+ + +
+ )} +
-
Previous Entries
-
- {chats.length === 0 ? ( -
No entries yet
+
Recent
+
+ {displayEntries.length === 0 ? ( +
No recent entries
) : ( - chats.map((chat) => ( + displayEntries.map((entry) => (
onSelectChat(chat._id)} + key={entry._id} + className={`entry-item ${activeEntry === entry._id ? 'active' : ''} ${openMenu === entry._id ? 'menu-open' : ''}`} + onClick={() => onSelectEntry(entry._id)} > - {chat.title} + {formatDate(entry.created_at)} @@ -86,6 +133,11 @@ function Sidebar({ chats, activeChat, onSelectChat, onNewChat, onDeleteChat }) {
)} + +
+ A + Apoorva +
) } diff --git a/apps/interactive-journal/frontend/src/index.css b/apps/interactive-journal/frontend/src/index.css index 08c06441..363ca514 100644 --- a/apps/interactive-journal/frontend/src/index.css +++ b/apps/interactive-journal/frontend/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@1,600&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Sacramento&display=swap'); * { box-sizing: border-box; diff --git a/ruff.toml b/ruff.toml index 411c7bd7..e73ef5a4 100644 --- a/ruff.toml +++ b/ruff.toml @@ -17,6 +17,7 @@ extend-select = [ "YTT", # flake8-2020 ] ignore = [ + "E402", # Module level import not at top of file, "F811", # Redefinition of unused, "F402", # Import shadowing, "B006", # Do not use mutable data structures for argument defaults, From be99ca0caaff438ce34a9908ee758faa24599333 Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Thu, 11 Dec 2025 13:09:41 +0530 Subject: [PATCH 03/11] Fixed search functionality, created generate prompt feature --- .../interactive-journal/backend/app/config.py | 25 +++ .../backend/app/routers/entries.py | 145 ++++++++++++------ .../backend/app/services/database.py | 76 ++++++--- .../backend/app/services/openai_service.py | 73 ++++----- .../backend/app/services/prompts.py | 35 +++++ .../backend/app/services/voyage_service.py | 31 ++-- apps/interactive-journal/frontend/src/App.css | 119 ++++++++++++++ apps/interactive-journal/frontend/src/App.jsx | 29 ++-- .../frontend/src/components/Entry.jsx | 37 ++++- .../frontend/src/components/Sidebar.jsx | 51 +++++- 10 files changed, 471 insertions(+), 150 deletions(-) create mode 100644 apps/interactive-journal/backend/app/config.py create mode 100644 apps/interactive-journal/backend/app/services/prompts.py diff --git a/apps/interactive-journal/backend/app/config.py b/apps/interactive-journal/backend/app/config.py new file mode 100644 index 00000000..43d25ca2 --- /dev/null +++ b/apps/interactive-journal/backend/app/config.py @@ -0,0 +1,25 @@ +import os + +# User config +USER_ID = "Apoorva" + +# MongoDB config +MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017") +DATABASE_NAME = os.getenv("DATABASE_NAME", "memoir") + +# OpenAI config +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + +# Voyage AI config +VOYAGE_API_KEY = os.getenv("VOYAGE_API_KEY") +VOYAGE_MULTIMODAL_MODEL = "voyage-multimodal-3" +VOYAGE_TEXT_MODEL = "voyage-3-large" + +# Vector search config +VECTOR_INDEX_NAME = "vector_index" +VECTOR_DIMENSIONS = 1024 +VECTOR_NUM_CANDIDATES = 100 + +# Image config +IMAGE_SIZE = (1024, 1024) diff --git a/apps/interactive-journal/backend/app/routers/entries.py b/apps/interactive-journal/backend/app/routers/entries.py index 05d4fd9b..d3157f0e 100644 --- a/apps/interactive-journal/backend/app/routers/entries.py +++ b/apps/interactive-journal/backend/app/routers/entries.py @@ -1,14 +1,19 @@ import logging import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta from pathlib import Path from typing import Optional from bson import ObjectId from fastapi import APIRouter, File, Form, UploadFile -from app.services.database import create_vector_index, get_database -from app.services.openai_service import extract_memories, generate_response +from app.config import USER_ID, VECTOR_INDEX_NAME, VECTOR_NUM_CANDIDATES +from app.services.database import get_database, vector_index_exists +from app.services.openai_service import ( + extract_memories, + generate_journal_prompt, + generate_response, +) from app.services.voyage_service import get_embedding, get_text_embedding logger = logging.getLogger(__name__) @@ -19,25 +24,18 @@ ) UPLOADS_DIR.mkdir(parents=True, exist_ok=True) - router = APIRouter() -USER_ID = "Apoorva" - - -def utc_now(): - return datetime.now(timezone.utc) - @router.post("/") -def create_entry(version: int = Form(1)): +def create_entry(version: int = Form(1), entry_date: str = Form(...)): db = get_database() - now = utc_now() + entry_dt = datetime.fromisoformat(entry_date) entry_data = { "user_id": USER_ID, - "title": now.strftime("%d/%m/%Y"), + "title": entry_dt.strftime("%d/%m/%Y"), "version": version, - "created_at": now, + "created_at": entry_dt, } result = db.entries.insert_one(entry_data) logger.info(f"Created entry {result.inserted_id} for user {USER_ID}") @@ -54,52 +52,77 @@ def get_entries(version: int = 1): return entries -@router.post("/init-v2") -def init_v2(): - """Initialize V2 features by creating the vector search index.""" - success = create_vector_index() - return {"success": success} - - @router.get("/search") def search_entries(q: str): - """Search through entries using MongoDB Atlas Vector Search.""" + """Search entries using vector search, grouped by entry.""" db = get_database() logger.info(f"Searching entries with query: {q[:50]}...") query_embedding = get_embedding(q, mode="text", input_type="query") - pipeline = [ { "$vectorSearch": { - "index": "vector_index", + "index": VECTOR_INDEX_NAME, "path": "embedding", "queryVector": query_embedding, - "numCandidates": 100, - "limit": 10, + "numCandidates": VECTOR_NUM_CANDIDATES, + "limit": 20, + "filter": {"user_id": USER_ID}, } }, { - "$project": { - "_id": 1, - "entry_id": 1, - "content": 1, - "image": 1, - "role": 1, - "created_at": 1, - "score": {"$meta": "vectorSearchScore"}, + "$group": { + "_id": "$entry_id", + "content": {"$first": "$content"}, + "image": {"$first": "$image"}, + "created_at": {"$first": "$created_at"}, } }, + {"$limit": 5}, ] results = list(db.messages.aggregate(pipeline)) for result in results: result["_id"] = str(result["_id"]) - logger.info(f"Search returned {len(results)} results") + logger.info(f"Search returned {len(results)} entries") return results +@router.post("/generate-prompt") +def generate_prompt(entry_id: str = Form(...), entry_date: str = Form(...)): + """Generate a journal prompt based on the last month's memories and save it.""" + db = get_database() + one_month_ago = datetime.now() - timedelta(days=30) + + memories = list( + db.memories.find( + {"user_id": USER_ID, "created_at": {"$gte": one_month_ago}}, + {"content": 1, "created_at": 1, "_id": 0}, + ) + ) + memory_contents = [ + f"Date: {m['created_at'].strftime('%Y-%m-%d')}, Memory: {m['content']}" + for m in memories + ] + logger.info(f"Found {len(memory_contents)} memories from the last month") + + prompt = generate_journal_prompt(memory_contents) + + # Save the prompt as an assistant message + msg_date = datetime.fromisoformat(entry_date) + prompt_msg = { + "entry_id": entry_id, + "role": "assistant", + "content": prompt, + "created_at": msg_date, + } + db.messages.insert_one(prompt_msg) + logger.info(f"Saved generated prompt for entry {entry_id}") + + return {"prompt": prompt} + + @router.get("/{entry_id}/messages") def get_messages(entry_id: str): db = get_database() @@ -116,22 +139,27 @@ def send_message( content: Optional[str] = Form(None), images: list[UploadFile] = File([]), version: int = Form(1), + entry_date: Optional[str] = Form(None), ): db = get_database() is_v2 = version == 2 logger.info(f"Processing message for entry {entry_id} (v{version})") + msg_date = datetime.fromisoformat(entry_date) + # Save text message if provided if content: embedding = get_embedding(content, mode="text", input_type="document") text_msg = { "entry_id": entry_id, + "user_id": USER_ID, "role": "user", "content": content, "embedding": embedding, - "created_at": utc_now(), + "created_at": msg_date, } db.messages.insert_one(text_msg) + vector_index_exists("messages") logger.info(f"Saved text message for entry {entry_id}") # Save each image as a separate message @@ -144,19 +172,29 @@ def send_message( embedding = get_embedding(image_path, mode="image", input_type="document") image_msg = { "entry_id": entry_id, + "user_id": USER_ID, "role": "user", "image": filename, "embedding": embedding, - "created_at": utc_now(), + "created_at": msg_date, } db.messages.insert_one(image_msg) + vector_index_exists("messages") logger.info(f"Saved image {filename} for entry {entry_id}") # V2 only: Extract and save memories, then retrieve relevant ones retrieved_memories = [] if is_v2 and content: - # Extract memories from user message - memories = extract_memories(content) + # Get last 3 messages for context when extracting memories + recent_msgs = list(db.messages.aggregate([ + {"$match": {"entry_id": entry_id, "content": {"$exists": True}}}, + {"$sort": {"created_at": -1}}, + {"$limit": 3}, + ])) + context_for_memories = "\n".join( + f"{msg['role'].capitalize()}: {msg['content']}" for msg in recent_msgs + ) + memories = extract_memories(context_for_memories) if memories: memory_docs = [] for memory_content in memories: @@ -169,10 +207,11 @@ def send_message( "entry_id": entry_id, "content": memory_content, "embedding": memory_embedding, - "created_at": utc_now(), + "created_at": msg_date, } ) db.memories.insert_many(memory_docs) + vector_index_exists("memories") logger.info(f"Extracted and saved {len(memories)} memories: {memories}") # Retrieve relevant memories via vector search @@ -180,10 +219,10 @@ def send_message( pipeline = [ { "$vectorSearch": { - "index": "vector_index", + "index": VECTOR_INDEX_NAME, "path": "embedding", "queryVector": query_embedding, - "numCandidates": 100, + "numCandidates": VECTOR_NUM_CANDIDATES, "limit": 10, "filter": {"user_id": USER_ID}, } @@ -194,14 +233,18 @@ def send_message( retrieved_memories = [r["content"] for r in results] logger.info(f"Retrieved {len(retrieved_memories)} memories for context") - # Get conversation history (text only for AI context) - history = list(db.messages.find({"entry_id": entry_id}).sort("created_at", 1)) - conversation = [] - for msg in history: - if msg.get("content"): - conversation.append({"role": msg["role"], "content": msg["content"]}) - elif msg.get("image") and msg["role"] == "user": - conversation.append({"role": "user", "content": "[User shared an image]"}) + # Get conversation history (only fetch needed fields) + history = list( + db.messages.find( + {"entry_id": entry_id}, + {"role": 1, "content": 1, "_id": 0} + ).sort("created_at", 1) + ) + conversation = [ + {"role": msg["role"], "content": msg["content"]} + for msg in history + if msg.get("content") + ] # Generate AI response with retrieved memories ai_content = generate_response(conversation, memories=retrieved_memories) @@ -211,7 +254,7 @@ def send_message( "entry_id": entry_id, "role": "assistant", "content": ai_content, - "created_at": utc_now(), + "created_at": msg_date, } db.messages.insert_one(ai_msg) logger.info(f"Generated and saved AI response for entry {entry_id}") diff --git a/apps/interactive-journal/backend/app/services/database.py b/apps/interactive-journal/backend/app/services/database.py index a9793b1e..26a3746a 100644 --- a/apps/interactive-journal/backend/app/services/database.py +++ b/apps/interactive-journal/backend/app/services/database.py @@ -1,9 +1,15 @@ import logging -import os from pymongo import MongoClient from pymongo.errors import OperationFailure +from app.config import ( + DATABASE_NAME, + MONGODB_URI, + VECTOR_DIMENSIONS, + VECTOR_INDEX_NAME, +) + logger = logging.getLogger(__name__) client: MongoClient = None @@ -12,12 +18,10 @@ def connect_db(): global client, db - uri = os.getenv("MONGODB_URI", "mongodb://localhost:27017") - db_name = os.getenv("DATABASE_NAME", "memoir") - client = MongoClient(uri) - db = client[db_name] + client = MongoClient(MONGODB_URI) + db = client[DATABASE_NAME] client.admin.command("ping") - logger.info(f"Connected to MongoDB: {db_name}") + logger.info(f"Connected to MongoDB: {DATABASE_NAME}") def close_db(): @@ -31,37 +35,59 @@ def get_database(): return db -def create_vector_index(): - """Create vector search indexes on messages and memories collections.""" +# Track which collections have had their vector index created this session +_index_created = set() + + +def vector_index_exists(collection_name: str) -> bool: + """Create vector search index on first write to a collection. + + The index includes a filter field for user_id to support filtered vector search. + """ if db is None: + logger.error("Database not connected") return False - # Index config (same for both collections) + if collection_name in _index_created: + return True + + collection = getattr(db, collection_name) + + # Check if index already exists + existing_indexes = list(collection.list_search_indexes()) + if any(idx.get("name") == VECTOR_INDEX_NAME for idx in existing_indexes): + logger.info(f"Vector index already exists on {collection_name}") + _index_created.add(collection_name) + return True + index_model = { - "name": "vector_index", + "name": VECTOR_INDEX_NAME, "type": "vectorSearch", "definition": { "fields": [ { "type": "vector", - "numDimensions": 1024, + "numDimensions": VECTOR_DIMENSIONS, "path": "embedding", "similarity": "cosine", - } + }, + { + "type": "filter", + "path": "user_id", + }, ] }, } - success = True - for collection in ["messages", "memories"]: - try: - logger.info(f"Creating vector_index on {collection}") - getattr(db, collection).create_search_index(model=index_model) - logger.info(f"Vector search index created on {collection}") - except OperationFailure: - logger.info(f"Vector search index on {collection} already exists") - except Exception as e: - logger.error(f"Error creating vector search index on {collection}: {e}") - success = False - - return success + try: + logger.info(f"Creating vector_index on {collection_name}") + collection.create_search_index(model=index_model) + logger.info(f"Vector search index created on {collection_name}") + _index_created.add(collection_name) + return True + except OperationFailure as e: + logger.error(f"OperationFailure creating index on {collection_name}: {e}") + return False + except Exception as e: + logger.error(f"Error creating vector search index on {collection_name}: {e}") + return False diff --git a/apps/interactive-journal/backend/app/services/openai_service.py b/apps/interactive-journal/backend/app/services/openai_service.py index f5ff4303..f816c72a 100644 --- a/apps/interactive-journal/backend/app/services/openai_service.py +++ b/apps/interactive-journal/backend/app/services/openai_service.py @@ -1,50 +1,28 @@ import json import logging -import os +from datetime import datetime from typing import Optional from openai import OpenAI -logger = logging.getLogger(__name__) - -client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) - -JOURNAL_SYSTEM_PROMPT = """You are a thoughtful and empathetic AI journaling companion called Memoir. -Your role is to help users reflect on their thoughts, feelings, and experiences through conversation. - -Guidelines: -- Ask thoughtful follow-up questions to help users explore their thoughts deeper -- Be supportive and non-judgmental -- Help users identify patterns and insights in their reflections -- Keep responses concise but meaningful -- Encourage self-reflection without being preachy -- If users share something difficult, acknowledge their feelings first - -Remember: You're a journaling companion, not a therapist. Focus on reflection and exploration.""" +from app.config import OPENAI_API_KEY, OPENAI_MODEL +from app.services.prompts import ( + JOURNAL_SYSTEM_PROMPT, + MEMORY_EXTRACTION_PROMPT, + PROMPT_GENERATOR, +) -MEMORY_EXTRACTION_PROMPT = """You are a memory extraction system. Analyze the user's journal entry and extract meaningful memories, insights, and facts about the user. - -Extract information such as: -- Personal facts (relationships, work, hobbies, preferences) -- Emotional patterns and feelings -- Goals, aspirations, and plans -- Significant events or experiences -- Insights and realizations - -Return a JSON array of memory strings. Each memory should be a concise, standalone statement ending in a period. -If no meaningful memories can be extracted, return an empty array. +logger = logging.getLogger(__name__) -Example output: -["User has a sister named Sarah.", "User feels anxious about their job interview next week.", "User enjoys morning walks."]""" +client = OpenAI(api_key=OPENAI_API_KEY) def extract_memories(user_message: str) -> list[str]: """Extract memories/insights from a user's journal entry.""" - model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") - logger.info(f"Extracting memories using {model}") + logger.info(f"Extracting memories using {OPENAI_MODEL}") response = client.chat.completions.create( - model=model, + model=OPENAI_MODEL, messages=[ {"role": "system", "content": MEMORY_EXTRACTION_PROMPT}, {"role": "user", "content": user_message}, @@ -72,9 +50,8 @@ def generate_response( messages: list[dict], memories: Optional[list[str]] = None ) -> str: """Generate a response using OpenAI's chat completion.""" - model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") logger.info( - f"Generating response using {model} with {len(memories) if memories else 0} memories" + f"Generating response using {OPENAI_MODEL} with {len(memories) if memories else 0} memories" ) system_prompt = JOURNAL_SYSTEM_PROMPT @@ -83,7 +60,7 @@ def generate_response( system_prompt += f"\n\nRelevant memories about this user:\n{memory_context}\n\nUse these memories to provide more personalized and contextual responses when relevant." response = client.chat.completions.create( - model=model, + model=OPENAI_MODEL, messages=[{"role": "system", "content": system_prompt}, *messages], temperature=0.7, max_tokens=500, @@ -91,3 +68,27 @@ def generate_response( logger.info("Response generated successfully") return response.choices[0].message.content + + +def generate_journal_prompt(memories: list[str]) -> str: + """Generate a reflective journal prompt based on past memories.""" + logger.info(f"Generating journal prompt from {len(memories)} memories") + + if not memories: + return "What's on your mind today?" + + today = datetime.now().strftime("%Y-%m-%d") + memory_context = "\n".join(f"- {m}" for m in memories) + user_content = f"Today's date: {today}\n\nMemories:\n{memory_context}" + + response = client.chat.completions.create( + model=OPENAI_MODEL, + messages=[ + {"role": "system", "content": PROMPT_GENERATOR}, + {"role": "user", "content": user_content}, + ], + temperature=0.8, + max_tokens=150, + ) + + return response.choices[0].message.content diff --git a/apps/interactive-journal/backend/app/services/prompts.py b/apps/interactive-journal/backend/app/services/prompts.py new file mode 100644 index 00000000..da39d58a --- /dev/null +++ b/apps/interactive-journal/backend/app/services/prompts.py @@ -0,0 +1,35 @@ +JOURNAL_SYSTEM_PROMPT = """You are a thoughtful and empathetic AI journaling companion called Memoir. +Your role is to help users reflect on their thoughts, feelings, and experiences through conversation. + +Guidelines: +- Ask thoughtful follow-up questions to help users explore their thoughts deeper +- Be supportive and non-judgmental +- Help users identify patterns and insights in their reflections +- Keep responses concise but meaningful +- Encourage self-reflection without being preachy +- If users share something difficult, acknowledge their feelings first + +Remember: You're a journaling companion, not a therapist. Focus on reflection and exploration.""" + +MEMORY_EXTRACTION_PROMPT = """You are a memory extraction system. Analyze the user's journal entry and extract meaningful memories, insights, and facts about the user. + +Extract information such as: +- Personal facts (relationships, work, hobbies, preferences) +- Emotional patterns and feelings +- Goals, aspirations, and plans +- Significant events or experiences +- Insights and realizations + +Return a JSON array of memory strings. Each memory should be a concise, standalone statement ending in a period. +If no meaningful memories can be extracted, return an empty array. + +Example output: +["User has a sister named Sarah.", "User feels anxious about their job interview next week.", "User enjoys morning walks."]""" + +PROMPT_GENERATOR = """Based on the user's past memories, generate a thoughtful journaling prompt that encourages deeper reflection. + +Each memory includes its date. Use this to frame your prompt appropriately (e.g., "Last week you mentioned..." or "A few weeks ago you wrote about..."). Today's date is provided below. + +Pick one memory that seems meaningful and ask an open-ended question about it. Keep the prompt concise (1-2 sentences). + +Return only the prompt, nothing else.""" diff --git a/apps/interactive-journal/backend/app/services/voyage_service.py b/apps/interactive-journal/backend/app/services/voyage_service.py index 547f5106..8e3c6408 100644 --- a/apps/interactive-journal/backend/app/services/voyage_service.py +++ b/apps/interactive-journal/backend/app/services/voyage_service.py @@ -1,17 +1,17 @@ import logging -import os from pathlib import Path -from typing import List, Union import voyageai from PIL import Image +from app.config import IMAGE_SIZE, VOYAGE_API_KEY, VOYAGE_MULTIMODAL_MODEL, VOYAGE_TEXT_MODEL + logger = logging.getLogger(__name__) -vo = voyageai.Client(api_key=os.getenv("VOYAGE_API_KEY")) +vo = voyageai.Client(api_key=VOYAGE_API_KEY) -def get_embedding(content: Union[str, Path], mode: str, input_type: str) -> List[float]: +def get_embedding(content: str | Path, mode: str, input_type: str) -> list[float]: """ Generate embeddings using Voyage AI's multimodal model. @@ -21,24 +21,23 @@ def get_embedding(content: Union[str, Path], mode: str, input_type: str) -> List input_type (str): Type of input ("document" or "query") Returns: - List[float]: Embedding of the content as a list. + list[float]: Embedding of the content as a list. """ - logger.debug( - f"Generating multimodal embedding: mode={mode}, input_type={input_type}" - ) + logger.info(f"Generating multimodal embedding: mode={mode}, input_type={input_type}") if mode == "image": - content = Image.open(content) + img = Image.open(content) + content = img.resize(IMAGE_SIZE, Image.Resampling.LANCZOS) result = vo.multimodal_embed( - inputs=[[content]], model="voyage-multimodal-3", input_type=input_type + inputs=[[content]], model=VOYAGE_MULTIMODAL_MODEL, input_type=input_type ).embeddings[0] - logger.debug(f"Generated embedding with {len(result)} dimensions") + logger.debug(f"Generated {len(result)}-dim embedding") return result -def get_text_embedding(text: str, input_type: str = "document") -> List[float]: +def get_text_embedding(text: str, input_type: str = "document") -> list[float]: """ Generate text embeddings using Voyage AI's voyage-3-large model. @@ -47,13 +46,13 @@ def get_text_embedding(text: str, input_type: str = "document") -> List[float]: input_type: Type of input ("document" or "query") Returns: - List[float]: Embedding of the text as a list. + list[float]: Embedding of the text as a list. """ - logger.debug(f"Generating text embedding: input_type={input_type}") + logger.info(f"Generating text embedding: input_type={input_type}") result = vo.embed( - texts=[text], model="voyage-3-large", input_type=input_type + texts=[text], model=VOYAGE_TEXT_MODEL, input_type=input_type ).embeddings[0] - logger.debug(f"Generated embedding with {len(result)} dimensions") + logger.debug(f"Generated {len(result)}-dim embedding") return result diff --git a/apps/interactive-journal/frontend/src/App.css b/apps/interactive-journal/frontend/src/App.css index afff332a..df798ad4 100644 --- a/apps/interactive-journal/frontend/src/App.css +++ b/apps/interactive-journal/frontend/src/App.css @@ -561,6 +561,7 @@ max-width: 600px; margin: 0 auto; width: 100%; + overflow: hidden; } .search-title { @@ -617,6 +618,8 @@ .search-results-main { width: 100%; + flex: 1; + overflow-y: auto; } .no-results { @@ -718,3 +721,119 @@ object-fit: contain; display: block; } + +/* Date picker modal */ +.date-picker-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.date-picker-modal { + background: white; + border-radius: 16px; + padding: 24px; + min-width: 300px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); +} + +.date-picker-modal h3 { + margin: 0 0 20px; + font-size: 18px; + font-weight: 600; + color: #1a1a1a; +} + +.date-input { + width: 100%; + padding: 12px 16px; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 10px; + font-family: inherit; + font-size: 15px; + outline: none; + transition: all 0.2s ease; +} + +.date-input:focus { + border-color: rgba(139, 92, 246, 0.5); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); +} + +.date-picker-actions { + display: flex; + gap: 12px; + margin-top: 20px; + justify-content: flex-end; +} + +.date-cancel-btn, +.date-confirm-btn { + padding: 10px 20px; + border-radius: 10px; + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.date-cancel-btn { + background: transparent; + border: 1px solid rgba(0, 0, 0, 0.15); + color: #374151; +} + +.date-cancel-btn:hover { + background: rgba(0, 0, 0, 0.04); +} + +.date-confirm-btn { + background: #8b5cf6; + border: none; + color: white; +} + +.date-confirm-btn:hover { + background: #7c3aed; +} + +/* Prompt generator */ +.prompt-generator { + display: flex; + justify-content: flex-end; + padding: 0 48px 12px; + max-width: 816px; + margin: 0 auto; + width: 100%; +} + +.generate-prompt-btn { + padding: 12px 16px; + background: transparent; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 10px; + color: #374151; + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.generate-prompt-btn:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.25); +} + +.generate-prompt-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/apps/interactive-journal/frontend/src/App.jsx b/apps/interactive-journal/frontend/src/App.jsx index d983796b..e935bf4c 100644 --- a/apps/interactive-journal/frontend/src/App.jsx +++ b/apps/interactive-journal/frontend/src/App.jsx @@ -10,7 +10,6 @@ function App() { const [activeEntry, setActiveEntry] = useState(null) const [messages, setMessages] = useState([]) const [isV2, setIsV2] = useState(false) - const [v2Initialized, setV2Initialized] = useState(false) const [activeSection, setActiveSection] = useState(null) useEffect(() => { @@ -36,10 +35,11 @@ function App() { setMessages(data) } - const createEntry = async () => { + const createEntry = async (entryDate) => { const version = isV2 ? 2 : 1 const formData = new FormData() formData.append('version', version) + formData.append('entry_date', entryDate) const res = await fetch(`${API_URL}/entries/`, { method: 'POST', body: formData @@ -60,22 +60,8 @@ function App() { } } - const initV2 = async () => { - if (v2Initialized) return - try { - await fetch(`${API_URL}/entries/init-v2`, { method: 'POST' }) - setV2Initialized(true) - } catch (error) { - console.error('Failed to initialize V2:', error) - } - } - - const toggleVersion = async () => { - const newIsV2 = !isV2 - if (newIsV2 && !v2Initialized) { - await initV2() - } - setIsV2(newIsV2) + const toggleVersion = () => { + setIsV2(!isV2) setActiveEntry(null) setMessages([]) } @@ -111,6 +97,10 @@ function App() { formData.append('images', img.file) }) formData.append('version', isV2 ? 2 : 1) + const activeEntryObj = entries.find(e => e._id === activeEntry) + if (activeEntryObj?.created_at) { + formData.append('entry_date', activeEntryObj.created_at) + } const res = await fetch(`${API_URL}/entries/${activeEntry}/messages`, { method: 'POST', @@ -146,6 +136,9 @@ function App() { messages={messages} onSendMessage={sendMessage} hasActiveEntry={!!activeEntry} + activeEntry={activeEntry} + entries={entries} + onRefreshMessages={() => activeEntry && fetchMessages(activeEntry)} isV2={isV2} activeSection={activeSection} onSelectEntry={setActiveEntry} diff --git a/apps/interactive-journal/frontend/src/components/Entry.jsx b/apps/interactive-journal/frontend/src/components/Entry.jsx index 14df4cef..253c5dbd 100644 --- a/apps/interactive-journal/frontend/src/components/Entry.jsx +++ b/apps/interactive-journal/frontend/src/components/Entry.jsx @@ -1,14 +1,34 @@ import { useState, useRef, useEffect } from 'react' -function Entry({ messages, onSendMessage, hasActiveEntry, isV2, activeSection, onSelectEntry }) { +function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, onRefreshMessages, isV2, activeSection, onSelectEntry }) { const [input, setInput] = useState('') const [selectedImages, setSelectedImages] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState(null) const [isSearching, setIsSearching] = useState(false) + const [isGeneratingPrompt, setIsGeneratingPrompt] = useState(false) const messagesEndRef = useRef(null) const fileInputRef = useRef(null) + const handleGeneratePrompt = async () => { + setIsGeneratingPrompt(true) + try { + const activeEntryObj = entries.find(e => e._id === activeEntry) + const formData = new FormData() + formData.append('entry_id', activeEntry) + formData.append('entry_date', activeEntryObj?.created_at || new Date().toISOString()) + + await fetch('http://localhost:8000/api/entries/generate-prompt', { + method: 'POST', + body: formData + }) + onRefreshMessages() + } catch (error) { + console.error('Failed to generate prompt:', error) + } + setIsGeneratingPrompt(false) + } + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } @@ -17,6 +37,7 @@ function Entry({ messages, onSendMessage, hasActiveEntry, isV2, activeSection, o scrollToBottom() }, [messages]) + const handleSubmit = (e) => { e.preventDefault() if ((input.trim() || selectedImages.length > 0) && hasActiveEntry) { @@ -98,7 +119,7 @@ function Entry({ messages, onSendMessage, hasActiveEntry, isV2, activeSection, o
onSelectEntry(result.entry_id)} + onClick={() => onSelectEntry(result._id)} > {new Date(result.created_at).toLocaleDateString('en-US', { @@ -180,6 +201,18 @@ function Entry({ messages, onSendMessage, hasActiveEntry, isV2, activeSection, o
)} + {isV2 && messages.length === 0 && ( +
+ +
+ )} +
{isV2 && ( diff --git a/apps/interactive-journal/frontend/src/components/Sidebar.jsx b/apps/interactive-journal/frontend/src/components/Sidebar.jsx index 475bade9..d3a3f0f9 100644 --- a/apps/interactive-journal/frontend/src/components/Sidebar.jsx +++ b/apps/interactive-journal/frontend/src/components/Sidebar.jsx @@ -3,6 +3,20 @@ import { useState, useEffect } from 'react' function Sidebar({ entries, activeEntry, onSelectEntry, onNewEntry, onDeleteEntry, isV2, onToggleVersion, activeSection, onSectionChange }) { const [openMenu, setOpenMenu] = useState(null) const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }) + const [showDatePicker, setShowDatePicker] = useState(false) + const [selectedDate, setSelectedDate] = useState('') + + const handleNewEntryClick = () => { + setSelectedDate(new Date().toISOString().split('T')[0]) + setShowDatePicker(true) + } + + const handleDateConfirm = () => { + if (selectedDate) { + onNewEntry(selectedDate) + setShowDatePicker(false) + } + } // Filter entries to only show recent ones (last 7 days) in V2 const recentEntries = entries.filter(entry => { @@ -41,7 +55,18 @@ function Sidebar({ entries, activeEntry, onSelectEntry, onNewEntry, onDeleteEntr } }, [openMenu]) - const displayEntries = isV2 ? recentEntries : entries + // Ensure active entry is always included, sorted chronologically + const getDisplayEntries = () => { + if (!isV2) return entries + const activeEntryObj = entries.find(e => e._id === activeEntry) + if (!activeEntryObj || recentEntries.find(e => e._id === activeEntry)) { + return recentEntries + } + return [...recentEntries, activeEntryObj].sort( + (a, b) => new Date(b.created_at) - new Date(a.created_at) + ) + } + const displayEntries = getDisplayEntries() const formatDate = (dateString) => { const date = new Date(dateString) @@ -70,12 +95,34 @@ function Sidebar({ entries, activeEntry, onSelectEntry, onNewEntry, onDeleteEntr
-
+ {showDatePicker && ( +
setShowDatePicker(false)}> +
e.stopPropagation()}> +

Select entry date

+ setSelectedDate(e.target.value)} + className="date-input" + /> +
+ + +
+
+
+ )} + {isV2 && (
+
+ + {isV2 && ( -
- )} + )} +
Recent
From 083c54f967f9ab966b885c13884ce74cae2a78fe Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Mon, 15 Dec 2025 14:01:29 +0530 Subject: [PATCH 05/11] Starting on insights --- .../backend/app/routers/helpers.py | 83 ++++++++++++++----- .../backend/app/routers/routes.py | 20 ++++- apps/interactive-journal/frontend/src/App.css | 34 ++++++++ .../frontend/src/components/Entry.jsx | 30 ++++++- 4 files changed, 140 insertions(+), 27 deletions(-) diff --git a/apps/interactive-journal/backend/app/routers/helpers.py b/apps/interactive-journal/backend/app/routers/helpers.py index 75cdcb34..8f6fceb7 100644 --- a/apps/interactive-journal/backend/app/routers/helpers.py +++ b/apps/interactive-journal/backend/app/routers/helpers.py @@ -1,6 +1,6 @@ import logging import uuid -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from fastapi import UploadFile @@ -59,11 +59,15 @@ def save_image_file(image_file: UploadFile) -> Path: def extract_and_save_memories(db, entry_id: str, msg_date: datetime) -> None: """Extract memories from recent messages and save them.""" - recent_msgs = list(db.messages.aggregate([ - {"$match": {"entry_id": entry_id, "content": {"$exists": True}}}, - {"$sort": {"created_at": -1}}, - {"$limit": 3}, - ])) + recent_msgs = list( + db.messages.aggregate( + [ + {"$match": {"entry_id": entry_id, "content": {"$exists": True}}}, + {"$sort": {"created_at": -1}}, + {"$limit": 3}, + ] + ) + ) context = "\n".join( f"{msg['role'].capitalize()}: {msg['content']}" for msg in recent_msgs ) @@ -75,9 +79,7 @@ def extract_and_save_memories(db, entry_id: str, msg_date: datetime) -> None: "user_id": USER_ID, "entry_id": entry_id, "content": memory_content, - "embedding": get_text_embedding( - memory_content, input_type="document" - ), + "embedding": get_text_embedding(memory_content, input_type="document"), "created_at": msg_date, } for memory_content in memories @@ -112,8 +114,7 @@ def get_conversation_history(db, entry_id: str) -> list[dict]: """Get conversation history for an entry.""" history = list( db.messages.find( - {"entry_id": entry_id}, - {"role": 1, "content": 1, "_id": 0} + {"entry_id": entry_id}, {"role": 1, "content": 1, "_id": 0} ).sort("created_at", 1) ) return [ @@ -123,14 +124,56 @@ def get_conversation_history(db, entry_id: str) -> list[dict]: ] -def save_assistant_message( - db, entry_id: str, content: str, msg_date: datetime -) -> None: +def save_assistant_message(db, entry_id: str, content: str, msg_date: datetime) -> None: """Save an assistant response message.""" - db.messages.insert_one({ - "entry_id": entry_id, - "role": "assistant", - "content": content, - "created_at": msg_date, - }) + db.messages.insert_one( + { + "entry_id": entry_id, + "role": "assistant", + "content": content, + "created_at": msg_date, + } + ) logger.info(f"Saved AI response for entry {entry_id}") + + +def get_total_entries(db, user_id: str) -> int: + """Get total entries count for past 30 days.""" + thirty_days_ago = datetime.now() - timedelta(days=30) + return db.entries.count_documents({ + "user_id": user_id, + "version": 2, + "created_at": {"$gte": thirty_days_ago}, + }) + + +def get_longest_streak(db, user_id: str) -> int: + """Get longest consecutive days streak in past 30 days.""" + thirty_days_ago = datetime.now() - timedelta(days=30) + + pipeline = [ + {"$match": { + "user_id": user_id, + "version": 2, + "created_at": {"$gte": thirty_days_ago}, + }}, + {"$project": { + "date": {"$dateTrunc": {"date": "$created_at", "unit": "day"}} + }}, + {"$group": {"_id": "$date"}}, + {"$sort": {"_id": 1}}, + ] + dates = [doc["_id"] for doc in db.entries.aggregate(pipeline)] + + if not dates: + return 0 + + longest = current = 1 + for i in range(1, len(dates)): + if (dates[i] - dates[i - 1]).days == 1: + current += 1 + longest = max(longest, current) + else: + current = 1 + + return longest diff --git a/apps/interactive-journal/backend/app/routers/routes.py b/apps/interactive-journal/backend/app/routers/routes.py index 8279fff2..e7ce7262 100644 --- a/apps/interactive-journal/backend/app/routers/routes.py +++ b/apps/interactive-journal/backend/app/routers/routes.py @@ -9,6 +9,8 @@ from app.routers.helpers import ( extract_and_save_memories, get_conversation_history, + get_longest_streak, + get_total_entries, retrieve_relevant_memories, save_assistant_message, save_image_file, @@ -183,14 +185,24 @@ def send_message( return {"response": ai_content} +@router.get("/insights") +def get_insights(): + """Get user insights: total entries and longest streak.""" + db = get_database() + return { + "total_entries": get_total_entries(db, USER_ID), + "longest_streak": get_longest_streak(db, USER_ID), + } + + @router.delete("/{entry_id}") def delete_entry(entry_id: str): db = get_database() db.entries.delete_one({"_id": ObjectId(entry_id)}) - messages_deleted = db.messages.delete_many({"entry_id": entry_id}) - memories_deleted = db.memories.delete_many({"entry_id": entry_id}) + messages = db.messages.delete_many({"entry_id": entry_id}) + memories = db.memories.delete_many({"entry_id": entry_id}) logger.info( - f"Deleted entry {entry_id}: {messages_deleted.deleted_count} messages, " - f"{memories_deleted.deleted_count} memories" + f"Deleted entry {entry_id}: " + f"{messages.deleted_count} messages, {memories.deleted_count} memories" ) return {"deleted": True} diff --git a/apps/interactive-journal/frontend/src/App.css b/apps/interactive-journal/frontend/src/App.css index 94e1b790..80fbfb56 100644 --- a/apps/interactive-journal/frontend/src/App.css +++ b/apps/interactive-journal/frontend/src/App.css @@ -850,3 +850,37 @@ opacity: 0.6; cursor: not-allowed; } + +/* Insights */ +.insights-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; + width: 100%; +} + +.insight-card { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(20px); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 16px; + padding: 32px 24px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.insight-value { + font-size: 48px; + font-weight: 600; + color: #1a1a1a; + line-height: 1; + margin-bottom: 8px; +} + +.insight-label { + font-size: 14px; + color: #6b7280; + font-weight: 500; +} diff --git a/apps/interactive-journal/frontend/src/components/Entry.jsx b/apps/interactive-journal/frontend/src/components/Entry.jsx index 279975fc..c62d5fbc 100644 --- a/apps/interactive-journal/frontend/src/components/Entry.jsx +++ b/apps/interactive-journal/frontend/src/components/Entry.jsx @@ -7,6 +7,7 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, const [searchResults, setSearchResults] = useState(null) const [isSearching, setIsSearching] = useState(false) const [isGeneratingPrompt, setIsGeneratingPrompt] = useState(false) + const [insights, setInsights] = useState(null) const messagesEndRef = useRef(null) const fileInputRef = useRef(null) @@ -37,6 +38,15 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, scrollToBottom() }, [messages]) + useEffect(() => { + if (isV2 && activeSection === 'insights') { + fetch('http://localhost:8000/api/entries/insights') + .then(res => res.json()) + .then(data => setInsights(data)) + .catch(err => console.error('Failed to fetch insights:', err)) + } + }, [isV2, activeSection]) + const handleSubmit = (e) => { e.preventDefault() @@ -149,12 +159,26 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, ) } - // Show insights placeholder when V2 Insights tab is active and no entry selected + // Show insights when V2 Insights tab is active and no entry selected if (isV2 && activeSection === 'insights' && !hasActiveEntry) { return (
-
-

Insights coming soon...

+
+

Your month in review

+ {insights ? ( +
+
+ {insights.total_entries} + Entries +
+
+ {insights.longest_streak} + Longest streak +
+
+ ) : ( +

Loading...

+ )}
) From e3e2f1798031bcfc98fac8428f09510f964fc833 Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Mon, 15 Dec 2025 16:22:44 +0530 Subject: [PATCH 06/11] Added analyze entry functionality --- .../backend/app/routers/helpers.py | 2 +- .../backend/app/routers/routes.py | 25 ++++++++++++- .../backend/app/services/openai.py | 27 ++++++++++++++ .../backend/app/services/prompts.py | 12 +++++++ apps/interactive-journal/frontend/src/App.css | 36 +++++++++++++++++++ .../frontend/src/components/Entry.jsx | 23 ++++++++++++ 6 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/interactive-journal/backend/app/routers/helpers.py b/apps/interactive-journal/backend/app/routers/helpers.py index 8f6fceb7..c812bb53 100644 --- a/apps/interactive-journal/backend/app/routers/helpers.py +++ b/apps/interactive-journal/backend/app/routers/helpers.py @@ -69,7 +69,7 @@ def extract_and_save_memories(db, entry_id: str, msg_date: datetime) -> None: ) ) context = "\n".join( - f"{msg['role'].capitalize()}: {msg['content']}" for msg in recent_msgs + f"{msg['role']}: {msg['content']}" for msg in recent_msgs ) memories = extract_memories(context) diff --git a/apps/interactive-journal/backend/app/routers/routes.py b/apps/interactive-journal/backend/app/routers/routes.py index e7ce7262..028095e4 100644 --- a/apps/interactive-journal/backend/app/routers/routes.py +++ b/apps/interactive-journal/backend/app/routers/routes.py @@ -17,7 +17,7 @@ save_user_message, ) from app.services.mongodb import get_database -from app.services.openai import generate_journal_prompt, generate_response +from app.services.openai import analyze_entry, generate_journal_prompt, generate_response from app.services.voyage import get_multimodal_embedding, get_text_embedding logger = logging.getLogger(__name__) @@ -185,6 +185,29 @@ def send_message( return {"response": ai_content} +@router.post("/{entry_id}/analyze") +def save_entry(entry_id: str): + """Analyze entry for sentiment and themes.""" + db = get_database() + conversation = get_conversation_history(db, entry_id) + + if not conversation: + return {"error": "No messages in entry"} + + analysis = analyze_entry(conversation) + + db.entries.update_one( + {"_id": ObjectId(entry_id)}, + {"$set": { + "sentiment": analysis["sentiment"], + "themes": analysis["themes"], + }} + ) + + logger.info(f"Analyzed entry {entry_id}: {analysis}") + return analysis + + @router.get("/insights") def get_insights(): """Get user insights: total entries and longest streak.""" diff --git a/apps/interactive-journal/backend/app/services/openai.py b/apps/interactive-journal/backend/app/services/openai.py index f816c72a..ea1280ba 100644 --- a/apps/interactive-journal/backend/app/services/openai.py +++ b/apps/interactive-journal/backend/app/services/openai.py @@ -7,6 +7,7 @@ from app.config import OPENAI_API_KEY, OPENAI_MODEL from app.services.prompts import ( + INSIGHTS_PROMPT, JOURNAL_SYSTEM_PROMPT, MEMORY_EXTRACTION_PROMPT, PROMPT_GENERATOR, @@ -92,3 +93,29 @@ def generate_journal_prompt(memories: list[str]) -> str: ) return response.choices[0].message.content + + +def analyze_entry(conversation: list[dict]) -> dict: + """Analyze a journal entry for sentiment and themes.""" + logger.info(f"Analyzing entry with {len(conversation)} messages") + + content = "\n".join(f"{msg['role']}: {msg['content']}" for msg in conversation) + + response = client.chat.completions.create( + model=OPENAI_MODEL, + messages=[ + {"role": "system", "content": INSIGHTS_PROMPT}, + {"role": "user", "content": content}, + ], + temperature=0.8, + max_tokens=200, + response_format={"type": "json_object"}, + ) + + try: + result = json.loads(response.choices[0].message.content) + logger.info(f"Entry analysis: {result}") + return result + except (json.JSONDecodeError, AttributeError) as e: + logger.error(f"Failed to parse entry analysis: {e}") + return {"sentiment": "neutral", "themes": []} diff --git a/apps/interactive-journal/backend/app/services/prompts.py b/apps/interactive-journal/backend/app/services/prompts.py index da39d58a..459aadfc 100644 --- a/apps/interactive-journal/backend/app/services/prompts.py +++ b/apps/interactive-journal/backend/app/services/prompts.py @@ -33,3 +33,15 @@ Pick one memory that seems meaningful and ask an open-ended question about it. Keep the prompt concise (1-2 sentences). Return only the prompt, nothing else.""" + +INSIGHTS_PROMPT = """Analyze this journal entry conversation and extract: +1. Overall sentiment (positive, negative, neutral, or mixed) +2. Key themes discussed (2-4 short themes) + +Return a JSON object with this structure: +{ + "sentiment": "positive" | "negative" | "neutral" | "mixed", + "themes": ["theme1", "theme2", ...] +} + +Keep themes concise (1-3 words each). Examples: "work stress", "family", "self-improvement", "gratitude".""" diff --git a/apps/interactive-journal/frontend/src/App.css b/apps/interactive-journal/frontend/src/App.css index 80fbfb56..a8dff9b4 100644 --- a/apps/interactive-journal/frontend/src/App.css +++ b/apps/interactive-journal/frontend/src/App.css @@ -884,3 +884,39 @@ color: #6b7280; font-weight: 500; } + +/* Save Entry */ +.save-entry { + display: flex; + justify-content: flex-end; + padding: 16px 48px; + max-width: 816px; + margin: 0 auto; + width: 100%; +} + +.save-btn { + padding: 12px 24px; + background: #8b5cf6; + border: none; + border-radius: 10px; + color: white; + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.save-btn:hover { + background: #7c3aed; +} + +.save-btn:active { + transform: scale(0.95); +} + +.save-btn.saved { + background: #bbf7d0; + color: #166534; +} diff --git a/apps/interactive-journal/frontend/src/components/Entry.jsx b/apps/interactive-journal/frontend/src/components/Entry.jsx index c62d5fbc..4180940d 100644 --- a/apps/interactive-journal/frontend/src/components/Entry.jsx +++ b/apps/interactive-journal/frontend/src/components/Entry.jsx @@ -8,6 +8,7 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, const [isSearching, setIsSearching] = useState(false) const [isGeneratingPrompt, setIsGeneratingPrompt] = useState(false) const [insights, setInsights] = useState(null) + const [saveStatus, setSaveStatus] = useState(null) const messagesEndRef = useRef(null) const fileInputRef = useRef(null) @@ -30,6 +31,18 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, setIsGeneratingPrompt(false) } + const handleSaveEntry = async () => { + try { + await fetch(`http://localhost:8000/api/entries/${activeEntry}/analyze`, { + method: 'POST' + }) + setSaveStatus('saved') + setTimeout(() => setSaveStatus(null), 2000) + } catch (error) { + console.error('Failed to save entry:', error) + } + } + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } @@ -210,6 +223,16 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries,
))} + {isV2 && messages.length > 0 && ( +
+ +
+ )}
From ce1effd6c7c2bed0cb42774c13d887b1f4302348 Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Wed, 17 Dec 2025 13:11:18 -0800 Subject: [PATCH 07/11] Adding Insights --- apps/interactive-journal/README.md | 4 +- apps/interactive-journal/backend/.env.example | 8 +- .../interactive-journal/backend/app/config.py | 6 +- apps/interactive-journal/backend/app/main.py | 2 +- .../backend/app/routers/helpers.py | 60 ++++++--- .../backend/app/routers/routes.py | 8 +- .../backend/app/services/anthropic.py | 123 ++++++++++++++++++ .../backend/app/services/openai.py | 121 ----------------- .../backend/requirements.txt | 2 +- apps/interactive-journal/frontend/src/App.css | 110 +++++++++++++++- .../frontend/src/components/Entry.jsx | 78 +++++++++-- 11 files changed, 363 insertions(+), 159 deletions(-) create mode 100644 apps/interactive-journal/backend/app/services/anthropic.py delete mode 100644 apps/interactive-journal/backend/app/services/openai.py diff --git a/apps/interactive-journal/README.md b/apps/interactive-journal/README.md index 7f1bb17e..83f3fd0d 100644 --- a/apps/interactive-journal/README.md +++ b/apps/interactive-journal/README.md @@ -16,8 +16,8 @@ pip install -r requirements.txt cp .env.example .env # Edit .env with your values: # MONGODB_URI=your-mongodb-connection-string -# OPENAI_API_KEY=your-openai-api-key -# VOYAGE_API_KEY=your-voyage-api-key (for V2 search) +# ANTHROPIC_API_KEY=your-anthropic-api-key +# VOYAGE_API_KEY=your-voyage-api-key # Run the server uvicorn app.main:app --reload diff --git a/apps/interactive-journal/backend/.env.example b/apps/interactive-journal/backend/.env.example index 7f11dcab..f210bd95 100644 --- a/apps/interactive-journal/backend/.env.example +++ b/apps/interactive-journal/backend/.env.example @@ -4,11 +4,11 @@ MONGODB_URI=your-mongodb-connection-string-here # Database name DATABASE_NAME=memoir -# OpenAI API key -OPENAI_API_KEY=your-api-key-here +# Anthropic API key +ANTHROPIC_API_KEY=your-anthropic-api-key-here -# OpenAI model to use -OPENAI_MODEL=gpt-4o-mini +# Anthropic model to use +ANTHROPIC_MODEL=claude-sonnet-4-5 # Voyage AI API key (for V2 semantic search) VOYAGE_API_KEY=your-voyage-api-key-here diff --git a/apps/interactive-journal/backend/app/config.py b/apps/interactive-journal/backend/app/config.py index 43d25ca2..9d27691c 100644 --- a/apps/interactive-journal/backend/app/config.py +++ b/apps/interactive-journal/backend/app/config.py @@ -7,9 +7,9 @@ MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017") DATABASE_NAME = os.getenv("DATABASE_NAME", "memoir") -# OpenAI config -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") +# Anthropic config +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") +ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5") # Voyage AI config VOYAGE_API_KEY = os.getenv("VOYAGE_API_KEY") diff --git a/apps/interactive-journal/backend/app/main.py b/apps/interactive-journal/backend/app/main.py index 6a1cadc7..ddd17b54 100644 --- a/apps/interactive-journal/backend/app/main.py +++ b/apps/interactive-journal/backend/app/main.py @@ -1,6 +1,6 @@ from dotenv import load_dotenv -load_dotenv() +load_dotenv(override=True) import logging from contextlib import asynccontextmanager diff --git a/apps/interactive-journal/backend/app/routers/helpers.py b/apps/interactive-journal/backend/app/routers/helpers.py index c812bb53..3792bf96 100644 --- a/apps/interactive-journal/backend/app/routers/helpers.py +++ b/apps/interactive-journal/backend/app/routers/helpers.py @@ -6,7 +6,7 @@ from fastapi import UploadFile from app.config import USER_ID, VECTOR_INDEX_NAME, VECTOR_NUM_CANDIDATES -from app.services.openai import extract_memories +from app.services.anthropic import extract_memories from app.services.voyage import get_multimodal_embedding, get_text_embedding logger = logging.getLogger(__name__) @@ -137,29 +137,26 @@ def save_assistant_message(db, entry_id: str, content: str, msg_date: datetime) logger.info(f"Saved AI response for entry {entry_id}") -def get_total_entries(db, user_id: str) -> int: - """Get total entries count for past 30 days.""" +def get_monthly_filter(user_id: str) -> dict: + """Get common filter for monthly v2 entries.""" thirty_days_ago = datetime.now() - timedelta(days=30) - return db.entries.count_documents({ + return { "user_id": user_id, "version": 2, "created_at": {"$gte": thirty_days_ago}, - }) + } + + +def get_total_entries(db, user_id: str) -> int: + """Get total entries count for past 30 days.""" + return db.entries.count_documents(get_monthly_filter(user_id)) def get_longest_streak(db, user_id: str) -> int: """Get longest consecutive days streak in past 30 days.""" - thirty_days_ago = datetime.now() - timedelta(days=30) - pipeline = [ - {"$match": { - "user_id": user_id, - "version": 2, - "created_at": {"$gte": thirty_days_ago}, - }}, - {"$project": { - "date": {"$dateTrunc": {"date": "$created_at", "unit": "day"}} - }}, + {"$match": get_monthly_filter(user_id)}, + {"$project": {"date": {"$dateTrunc": {"date": "$created_at", "unit": "day"}}}}, {"$group": {"_id": "$date"}}, {"$sort": {"_id": 1}}, ] @@ -177,3 +174,36 @@ def get_longest_streak(db, user_id: str) -> int: current = 1 return longest + + +def get_mood_distribution(db, user_id: str) -> dict: + """Get sentiment distribution for past 30 days.""" + filter = get_monthly_filter(user_id) + filter["sentiment"] = {"$exists": True} + pipeline = [ + {"$match": filter}, + {"$group": {"_id": "$sentiment", "count": {"$sum": 1}}}, + ] + results = list(db.entries.aggregate(pipeline)) + counts = {r["_id"]: r["count"] for r in results} + total = sum(counts.values()) or 1 + return { + "positive": round(counts.get("positive", 0) / total * 100), + "neutral": round(counts.get("neutral", 0) / total * 100), + "mixed": round(counts.get("mixed", 0) / total * 100), + "negative": round(counts.get("negative", 0) / total * 100), + } + + +def get_themes(db, user_id: str) -> list[dict]: + """Get all themes with counts for past 30 days.""" + filter = get_monthly_filter(user_id) + filter["themes"] = {"$exists": True} + pipeline = [ + {"$match": filter}, + {"$unwind": "$themes"}, + {"$group": {"_id": "$themes", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + results = list(db.entries.aggregate(pipeline)) + return [{"theme": r["_id"], "count": r["count"]} for r in results] diff --git a/apps/interactive-journal/backend/app/routers/routes.py b/apps/interactive-journal/backend/app/routers/routes.py index 028095e4..ba57d92b 100644 --- a/apps/interactive-journal/backend/app/routers/routes.py +++ b/apps/interactive-journal/backend/app/routers/routes.py @@ -10,6 +10,8 @@ extract_and_save_memories, get_conversation_history, get_longest_streak, + get_mood_distribution, + get_themes, get_total_entries, retrieve_relevant_memories, save_assistant_message, @@ -17,7 +19,7 @@ save_user_message, ) from app.services.mongodb import get_database -from app.services.openai import analyze_entry, generate_journal_prompt, generate_response +from app.services.anthropic import analyze_entry, generate_journal_prompt, generate_response from app.services.voyage import get_multimodal_embedding, get_text_embedding logger = logging.getLogger(__name__) @@ -210,11 +212,13 @@ def save_entry(entry_id: str): @router.get("/insights") def get_insights(): - """Get user insights: total entries and longest streak.""" + """Get user insights: stats, mood distribution, and themes.""" db = get_database() return { "total_entries": get_total_entries(db, USER_ID), "longest_streak": get_longest_streak(db, USER_ID), + "mood": get_mood_distribution(db, USER_ID), + "themes": get_themes(db, USER_ID), } diff --git a/apps/interactive-journal/backend/app/services/anthropic.py b/apps/interactive-journal/backend/app/services/anthropic.py new file mode 100644 index 00000000..7dc7dd1d --- /dev/null +++ b/apps/interactive-journal/backend/app/services/anthropic.py @@ -0,0 +1,123 @@ +import logging +from datetime import datetime +from typing import Literal, Optional + +import anthropic +from pydantic import BaseModel + +from app.config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL +from app.services.prompts import ( + INSIGHTS_PROMPT, + JOURNAL_SYSTEM_PROMPT, + MEMORY_EXTRACTION_PROMPT, + PROMPT_GENERATOR, +) + +logger = logging.getLogger(__name__) + +client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) + + +class MemoriesOutput(BaseModel): + memories: list[str] + + +class EntryAnalysis(BaseModel): + sentiment: Literal["positive", "negative", "neutral", "mixed"] + themes: list[str] + + +def extract_memories(user_message: str) -> list[str]: + """Extract memories/insights from a user's journal entry.""" + logger.info(f"Extracting memories using {ANTHROPIC_MODEL}") + + try: + response = client.beta.messages.parse( + model=ANTHROPIC_MODEL, + max_tokens=500, + temperature=0.8, + betas=["structured-outputs-2025-11-13"], + system=MEMORY_EXTRACTION_PROMPT, + messages=[{"role": "user", "content": user_message}], + output_format=MemoriesOutput, + ) + memories = response.parsed_output.memories + logger.info(f"Extracted {len(memories)} memories") + return memories + except Exception as e: + logger.error(f"Failed to extract memories: {e}") + return [] + + +def generate_response( + messages: list[dict], memories: Optional[list[str]] = None +) -> str: + """Generate a response using Anthropic's Claude.""" + logger.info( + f"Generating response using {ANTHROPIC_MODEL} with {len(memories) if memories else 0} memories" + ) + + system_prompt = JOURNAL_SYSTEM_PROMPT + if memories: + memory_context = "\n".join(f"- {m}" for m in memories) + system_prompt += f"\n\nRelevant memories about this user:\n{memory_context}\n\nUse these memories to provide more personalized and contextual responses when relevant." + + response = client.messages.create( + model=ANTHROPIC_MODEL, + max_tokens=500, + temperature=0.8, + system=system_prompt, + messages=messages, + ) + + logger.info("Response generated successfully") + return response.content[0].text + + +def generate_journal_prompt(memories: list[str]) -> str: + """Generate a reflective journal prompt based on past memories.""" + logger.info(f"Generating journal prompt from {len(memories)} memories") + + if not memories: + return "What's on your mind today?" + + today = datetime.now().strftime("%Y-%m-%d") + memory_context = "\n".join(f"- {m}" for m in memories) + user_content = f"Today's date: {today}\n\nMemories:\n{memory_context}" + + response = client.messages.create( + model=ANTHROPIC_MODEL, + max_tokens=150, + temperature=0.8, + system=PROMPT_GENERATOR, + messages=[{"role": "user", "content": user_content}], + ) + + return response.content[0].text + + +def analyze_entry(conversation: list[dict]) -> dict: + """Analyze a journal entry for sentiment and themes.""" + logger.info(f"Analyzing entry with {len(conversation)} messages") + + content = "\n".join(f"{msg['role']}: {msg['content']}" for msg in conversation) + + try: + response = client.beta.messages.parse( + model=ANTHROPIC_MODEL, + max_tokens=200, + temperature=0.8, + betas=["structured-outputs-2025-11-13"], + system=INSIGHTS_PROMPT, + messages=[{"role": "user", "content": content}], + output_format=EntryAnalysis, + ) + result = { + "sentiment": response.parsed_output.sentiment, + "themes": response.parsed_output.themes, + } + logger.info(f"Entry analysis: {result}") + return result + except Exception as e: + logger.error(f"Failed to analyze entry: {e}") + return {"sentiment": "neutral", "themes": []} diff --git a/apps/interactive-journal/backend/app/services/openai.py b/apps/interactive-journal/backend/app/services/openai.py deleted file mode 100644 index ea1280ba..00000000 --- a/apps/interactive-journal/backend/app/services/openai.py +++ /dev/null @@ -1,121 +0,0 @@ -import json -import logging -from datetime import datetime -from typing import Optional - -from openai import OpenAI - -from app.config import OPENAI_API_KEY, OPENAI_MODEL -from app.services.prompts import ( - INSIGHTS_PROMPT, - JOURNAL_SYSTEM_PROMPT, - MEMORY_EXTRACTION_PROMPT, - PROMPT_GENERATOR, -) - -logger = logging.getLogger(__name__) - -client = OpenAI(api_key=OPENAI_API_KEY) - - -def extract_memories(user_message: str) -> list[str]: - """Extract memories/insights from a user's journal entry.""" - logger.info(f"Extracting memories using {OPENAI_MODEL}") - - response = client.chat.completions.create( - model=OPENAI_MODEL, - messages=[ - {"role": "system", "content": MEMORY_EXTRACTION_PROMPT}, - {"role": "user", "content": user_message}, - ], - temperature=0.3, - max_tokens=500, - response_format={"type": "json_object"}, - ) - - try: - result = json.loads(response.choices[0].message.content) - # Handle both {"memories": [...]} and direct array formats - if isinstance(result, list): - memories = result - else: - memories = result.get("memories", []) - logger.info(f"Extracted {len(memories)} memories") - return memories - except (json.JSONDecodeError, AttributeError) as e: - logger.error(f"Failed to parse memory extraction response: {e}") - return [] - - -def generate_response( - messages: list[dict], memories: Optional[list[str]] = None -) -> str: - """Generate a response using OpenAI's chat completion.""" - logger.info( - f"Generating response using {OPENAI_MODEL} with {len(memories) if memories else 0} memories" - ) - - system_prompt = JOURNAL_SYSTEM_PROMPT - if memories: - memory_context = "\n".join(f"- {m}" for m in memories) - system_prompt += f"\n\nRelevant memories about this user:\n{memory_context}\n\nUse these memories to provide more personalized and contextual responses when relevant." - - response = client.chat.completions.create( - model=OPENAI_MODEL, - messages=[{"role": "system", "content": system_prompt}, *messages], - temperature=0.7, - max_tokens=500, - ) - - logger.info("Response generated successfully") - return response.choices[0].message.content - - -def generate_journal_prompt(memories: list[str]) -> str: - """Generate a reflective journal prompt based on past memories.""" - logger.info(f"Generating journal prompt from {len(memories)} memories") - - if not memories: - return "What's on your mind today?" - - today = datetime.now().strftime("%Y-%m-%d") - memory_context = "\n".join(f"- {m}" for m in memories) - user_content = f"Today's date: {today}\n\nMemories:\n{memory_context}" - - response = client.chat.completions.create( - model=OPENAI_MODEL, - messages=[ - {"role": "system", "content": PROMPT_GENERATOR}, - {"role": "user", "content": user_content}, - ], - temperature=0.8, - max_tokens=150, - ) - - return response.choices[0].message.content - - -def analyze_entry(conversation: list[dict]) -> dict: - """Analyze a journal entry for sentiment and themes.""" - logger.info(f"Analyzing entry with {len(conversation)} messages") - - content = "\n".join(f"{msg['role']}: {msg['content']}" for msg in conversation) - - response = client.chat.completions.create( - model=OPENAI_MODEL, - messages=[ - {"role": "system", "content": INSIGHTS_PROMPT}, - {"role": "user", "content": content}, - ], - temperature=0.8, - max_tokens=200, - response_format={"type": "json_object"}, - ) - - try: - result = json.loads(response.choices[0].message.content) - logger.info(f"Entry analysis: {result}") - return result - except (json.JSONDecodeError, AttributeError) as e: - logger.error(f"Failed to parse entry analysis: {e}") - return {"sentiment": "neutral", "themes": []} diff --git a/apps/interactive-journal/backend/requirements.txt b/apps/interactive-journal/backend/requirements.txt index faa669ed..e82439c9 100644 --- a/apps/interactive-journal/backend/requirements.txt +++ b/apps/interactive-journal/backend/requirements.txt @@ -1,7 +1,7 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 pymongo==4.10.1 -openai==1.82.0 +anthropic==0.75.0 python-dotenv==1.0.1 voyageai==0.3.2 Pillow==11.0.0 diff --git a/apps/interactive-journal/frontend/src/App.css b/apps/interactive-journal/frontend/src/App.css index a8dff9b4..24e63de2 100644 --- a/apps/interactive-journal/frontend/src/App.css +++ b/apps/interactive-journal/frontend/src/App.css @@ -561,7 +561,7 @@ max-width: 600px; margin: 0 auto; width: 100%; - overflow: hidden; + overflow-y: auto; } .search-title { @@ -920,3 +920,111 @@ background: #bbf7d0; color: #166534; } + +/* Insights Sections */ +.section-title { + font-size: 16px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 16px; +} + +.mood-section, +.themes-section { + width: 100%; + margin-top: 32px; +} + +/* Mood Bars */ +.mood-bars { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mood-row { + display: flex; + align-items: center; + gap: 12px; +} + +.mood-emoji { + font-size: 20px; + width: 28px; +} + +.mood-bar-track { + flex: 1; + height: 16px; + background: rgba(0, 0, 0, 0.05); + border-radius: 8px; + overflow: hidden; +} + +.mood-bar-fill { + height: 100%; + border-radius: 8px; + transition: width 0.3s ease; +} + +.mood-bar-fill.positive { + background: #22c55e; +} + +.mood-bar-fill.neutral { + background: #94a3b8; +} + +.mood-bar-fill.mixed { + background: #f59e0b; +} + +.mood-bar-fill.negative { + background: #ef4444; +} + +.mood-percent { + font-size: 14px; + color: #6b7280; + width: 40px; + text-align: right; +} + +/* Word Cloud */ +.word-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + justify-content: center; + align-items: baseline; + max-height: 300px; + overflow-y: auto; +} + +.theme-word { + color: #6b7280; + font-weight: 500; + transition: color 0.2s ease; + cursor: default; +} + +.theme-word:hover { + color: #7c3aed; +} + +/* Hidden Scrollbar */ +.entry-list, +.messages, +.entry-search, +.search-results-main, +.word-cloud { + scrollbar-width: none; +} + +.entry-list::-webkit-scrollbar, +.messages::-webkit-scrollbar, +.entry-search::-webkit-scrollbar, +.search-results-main::-webkit-scrollbar, +.word-cloud::-webkit-scrollbar { + display: none; +} diff --git a/apps/interactive-journal/frontend/src/components/Entry.jsx b/apps/interactive-journal/frontend/src/components/Entry.jsx index 4180940d..c07b822b 100644 --- a/apps/interactive-journal/frontend/src/components/Entry.jsx +++ b/apps/interactive-journal/frontend/src/components/Entry.jsx @@ -60,6 +60,11 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, } }, [isV2, activeSection]) + useEffect(() => { + setSearchQuery('') + setSearchResults(null) + }, [isV2]) + const handleSubmit = (e) => { e.preventDefault() @@ -179,16 +184,71 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries,

Your month in review

{insights ? ( -
-
- {insights.total_entries} - Entries -
-
- {insights.longest_streak} - Longest streak + <> +
+
+ {insights.total_entries} + Entries +
+
+ {insights.longest_streak} + Longest streak +
-
+ + {(insights.mood.positive > 0 || insights.mood.neutral > 0 || insights.mood.mixed > 0 || insights.mood.negative > 0) && ( +
+

Mood

+
+
+ 😊 +
+
+
+ {insights.mood.positive}% +
+
+ 😐 +
+
+
+ {insights.mood.neutral}% +
+
+ 🤔 +
+
+
+ {insights.mood.mixed}% +
+
+ 😔 +
+
+
+ {insights.mood.negative}% +
+
+
+ )} + + {insights.themes.length > 0 && ( +
+

Themes

+
+ {insights.themes.map((item, i) => ( + + {item.theme} + + ))} +
+
+ )} + ) : (

Loading...

)} From 36e9b12123f0a6af7231dd2bd8365fe46c88d8d0 Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Thu, 18 Dec 2025 10:34:53 -0800 Subject: [PATCH 08/11] Speeding up assistant responses, rearranging helpers for smoother demo --- .../interactive-journal/backend/app/config.py | 2 +- .../backend/app/routers/helpers.py | 95 +++++------ .../backend/app/routers/routes.py | 148 +++++++++--------- .../backend/app/services/anthropic.py | 69 ++++---- .../backend/requirements.txt | 2 +- apps/interactive-journal/frontend/src/App.jsx | 28 +++- .../frontend/src/components/Entry.jsx | 7 +- 7 files changed, 186 insertions(+), 165 deletions(-) diff --git a/apps/interactive-journal/backend/app/config.py b/apps/interactive-journal/backend/app/config.py index 9d27691c..72c40abb 100644 --- a/apps/interactive-journal/backend/app/config.py +++ b/apps/interactive-journal/backend/app/config.py @@ -13,7 +13,7 @@ # Voyage AI config VOYAGE_API_KEY = os.getenv("VOYAGE_API_KEY") -VOYAGE_MULTIMODAL_MODEL = "voyage-multimodal-3" +VOYAGE_MULTIMODAL_MODEL = "voyage-multimodal-3.5" VOYAGE_TEXT_MODEL = "voyage-3-large" # Vector search config diff --git a/apps/interactive-journal/backend/app/routers/helpers.py b/apps/interactive-journal/backend/app/routers/helpers.py index 3792bf96..6d288357 100644 --- a/apps/interactive-journal/backend/app/routers/helpers.py +++ b/apps/interactive-journal/backend/app/routers/helpers.py @@ -18,6 +18,20 @@ UPLOADS_DIR.mkdir(parents=True, exist_ok=True) +def get_conversation_history(db, entry_id: str) -> list[dict]: + """Get conversation history for an entry.""" + history = list( + db.messages.find( + {"entry_id": entry_id}, {"role": 1, "content": 1, "_id": 0} + ).sort("created_at", 1) + ) + return [ + {"role": msg["role"], "content": msg["content"]} + for msg in history + if msg.get("content") + ] + + def save_user_message( db, entry_id: str, content: str | Path, version: int, msg_date: datetime ) -> None: @@ -48,46 +62,6 @@ def save_user_message( logger.info(f"Saved message for entry {entry_id}") -def save_image_file(image_file: UploadFile) -> Path: - """Save uploaded image file and return the path.""" - filename = f"{uuid.uuid4()}{Path(image_file.filename).suffix or '.jpg'}" - image_path = UPLOADS_DIR / filename - with open(image_path, "wb") as f: - f.write(image_file.file.read()) - return image_path - - -def extract_and_save_memories(db, entry_id: str, msg_date: datetime) -> None: - """Extract memories from recent messages and save them.""" - recent_msgs = list( - db.messages.aggregate( - [ - {"$match": {"entry_id": entry_id, "content": {"$exists": True}}}, - {"$sort": {"created_at": -1}}, - {"$limit": 3}, - ] - ) - ) - context = "\n".join( - f"{msg['role']}: {msg['content']}" for msg in recent_msgs - ) - memories = extract_memories(context) - - if memories: - memory_docs = [ - { - "user_id": USER_ID, - "entry_id": entry_id, - "content": memory_content, - "embedding": get_text_embedding(memory_content, input_type="document"), - "created_at": msg_date, - } - for memory_content in memories - ] - db.memories.insert_many(memory_docs) - logger.info(f"Extracted and saved {len(memories)} memories: {memories}") - - def retrieve_relevant_memories(db, query: str) -> list[str]: """Retrieve relevant memories via vector search.""" query_embedding = get_text_embedding(query, input_type="query") @@ -110,18 +84,26 @@ def retrieve_relevant_memories(db, query: str) -> list[str]: return memories -def get_conversation_history(db, entry_id: str) -> list[dict]: - """Get conversation history for an entry.""" - history = list( - db.messages.find( - {"entry_id": entry_id}, {"role": 1, "content": 1, "_id": 0} - ).sort("created_at", 1) - ) - return [ - {"role": msg["role"], "content": msg["content"]} - for msg in history - if msg.get("content") - ] +def extract_and_save_memories( + db, entry_id: str, conversation: list[dict], entry_date: datetime +) -> None: + """Extract memories from conversation and save them.""" + context = "\n".join(f"{msg['role']}: {msg['content']}" for msg in conversation) + memories = extract_memories(context) + + if memories: + memory_docs = [ + { + "user_id": USER_ID, + "entry_id": entry_id, + "content": memory_content, + "embedding": get_text_embedding(memory_content, input_type="document"), + "created_at": entry_date, + } + for memory_content in memories + ] + db.memories.insert_many(memory_docs) + logger.info(f"Extracted and saved {len(memories)} memories: {memories}") def save_assistant_message(db, entry_id: str, content: str, msg_date: datetime) -> None: @@ -137,6 +119,15 @@ def save_assistant_message(db, entry_id: str, content: str, msg_date: datetime) logger.info(f"Saved AI response for entry {entry_id}") +def save_image_file(image_file: UploadFile) -> Path: + """Save uploaded image file and return the path.""" + filename = f"{uuid.uuid4()}{Path(image_file.filename).suffix or '.jpg'}" + image_path = UPLOADS_DIR / filename + with open(image_path, "wb") as f: + f.write(image_file.file.read()) + return image_path + + def get_monthly_filter(user_id: str) -> dict: """Get common filter for monthly v2 entries.""" thirty_days_ago = datetime.now() - timedelta(days=30) diff --git a/apps/interactive-journal/backend/app/routers/routes.py b/apps/interactive-journal/backend/app/routers/routes.py index ba57d92b..debbe495 100644 --- a/apps/interactive-journal/backend/app/routers/routes.py +++ b/apps/interactive-journal/backend/app/routers/routes.py @@ -19,7 +19,12 @@ save_user_message, ) from app.services.mongodb import get_database -from app.services.anthropic import analyze_entry, generate_journal_prompt, generate_response +from fastapi.responses import StreamingResponse +from app.services.anthropic import ( + analyze_entry, + generate_journal_prompt, + generate_response, +) from app.services.voyage import get_multimodal_embedding, get_text_embedding logger = logging.getLogger(__name__) @@ -42,14 +47,44 @@ def create_entry(version: int = Form(1), entry_date: str = Form(...)): return {"_id": str(result.inserted_id)} -@router.get("/") -def get_entries(version: int = 1): +@router.post("/{entry_id}/messages") +def send_message( + entry_id: str, + content: Optional[str] = Form(None), + images: list[UploadFile] = File([]), + version: int = Form(1), + entry_date: Optional[str] = Form(None), +): db = get_database() - query = {"user_id": USER_ID, "version": version} - entries = list(db.entries.find(query).sort("created_at", -1)) - for entry in entries: - entry["_id"] = str(entry["_id"]) - return entries + is_v2 = version == 2 + msg_date = datetime.fromisoformat(entry_date) + + # Save image files to disk before streaming (file handles close after) + image_paths = [save_image_file(image) for image in images] + + # Get conversation history and add current message + conversation = get_conversation_history(db, entry_id) + if content: + conversation.append({"role": "user", "content": content}) + + # Retrieve relevant memories for context (V2 only) + memories = retrieve_relevant_memories(db, content) if is_v2 and content else [] + + def respond_and_save(): + # Stream response to user + response_text = [] + for chunk in generate_response(conversation, memories=memories): + response_text.append(chunk) + yield chunk + + # Save messages to DB after response completes + if content: + save_user_message(db, entry_id, content, version, msg_date) + for path in image_paths: + save_user_message(db, entry_id, path, version, msg_date) + save_assistant_message(db, entry_id, "".join(response_text), msg_date) + + return StreamingResponse(respond_and_save(), media_type="text/plain") @router.get("/search") @@ -60,9 +95,7 @@ def search_entries(q: str, version: int = 1): # Use appropriate embedding based on version if version == 2: - query_embedding = get_multimodal_embedding( - q, mode="text", input_type="query" - ) + query_embedding = get_multimodal_embedding(q, mode="text", input_type="query") else: query_embedding = get_text_embedding(q, input_type="query") @@ -107,6 +140,38 @@ def search_entries(q: str, version: int = 1): return results +@router.post("/{entry_id}/analyze") +def save_entry(entry_id: str, entry_date: str = Form(...)): + """Analyze entry for sentiment/themes and extract memories.""" + db = get_database() + conversation = get_conversation_history(db, entry_id) + + if not conversation: + return {"error": "No messages in entry"} + + # Analyze sentiment and themes + analysis = analyze_entry(conversation) + db.entries.update_one( + {"_id": ObjectId(entry_id)}, + {"$set": {"sentiment": analysis["sentiment"], "themes": analysis["themes"]}}, + ) + + # Extract memories from full conversation + extract_and_save_memories( + db, entry_id, conversation, datetime.fromisoformat(entry_date) + ) + + +@router.get("/") +def get_entries(version: int = 1): + db = get_database() + query = {"user_id": USER_ID, "version": version} + entries = list(db.entries.find(query).sort("created_at", -1)) + for entry in entries: + entry["_id"] = str(entry["_id"]) + return entries + + @router.post("/generate-prompt") def generate_prompt(entry_id: str = Form(...), entry_date: str = Form(...)): """Generate a journal prompt based on the last month's memories.""" @@ -144,72 +209,13 @@ def generate_prompt(entry_id: str = Form(...), entry_date: str = Form(...)): @router.get("/{entry_id}/messages") def get_messages(entry_id: str): db = get_database() - messages = list( - db.messages.find({"entry_id": entry_id}).sort("created_at", 1) - ) + messages = list(db.messages.find({"entry_id": entry_id}).sort("created_at", 1)) for msg in messages: msg["_id"] = str(msg["_id"]) msg.pop("embedding", None) return messages -@router.post("/{entry_id}/messages") -def send_message( - entry_id: str, - content: Optional[str] = Form(None), - images: list[UploadFile] = File([]), - version: int = Form(1), - entry_date: Optional[str] = Form(None), -): - db = get_database() - is_v2 = version == 2 - msg_date = datetime.fromisoformat(entry_date) - logger.info(f"Processing message for entry {entry_id} (v{version})") - - # Save user messages - if content: - save_user_message(db, entry_id, content, version, msg_date) - for image_file in images: - image_path = save_image_file(image_file) - save_user_message(db, entry_id, image_path, version, msg_date) - - # V2 only: Extract and retrieve memories - retrieved_memories = [] - if is_v2 and content: - extract_and_save_memories(db, entry_id, msg_date) - retrieved_memories = retrieve_relevant_memories(db, content) - - # Generate and save AI response - conversation = get_conversation_history(db, entry_id) - ai_content = generate_response(conversation, memories=retrieved_memories) - save_assistant_message(db, entry_id, ai_content, msg_date) - - return {"response": ai_content} - - -@router.post("/{entry_id}/analyze") -def save_entry(entry_id: str): - """Analyze entry for sentiment and themes.""" - db = get_database() - conversation = get_conversation_history(db, entry_id) - - if not conversation: - return {"error": "No messages in entry"} - - analysis = analyze_entry(conversation) - - db.entries.update_one( - {"_id": ObjectId(entry_id)}, - {"$set": { - "sentiment": analysis["sentiment"], - "themes": analysis["themes"], - }} - ) - - logger.info(f"Analyzed entry {entry_id}: {analysis}") - return analysis - - @router.get("/insights") def get_insights(): """Get user insights: stats, mood distribution, and themes.""" diff --git a/apps/interactive-journal/backend/app/services/anthropic.py b/apps/interactive-journal/backend/app/services/anthropic.py index 7dc7dd1d..f38a1e47 100644 --- a/apps/interactive-journal/backend/app/services/anthropic.py +++ b/apps/interactive-journal/backend/app/services/anthropic.py @@ -49,10 +49,35 @@ def extract_memories(user_message: str) -> list[str]: return [] -def generate_response( - messages: list[dict], memories: Optional[list[str]] = None -) -> str: - """Generate a response using Anthropic's Claude.""" +def analyze_entry(conversation: list[dict]) -> dict: + """Analyze a journal entry for sentiment and themes.""" + logger.info(f"Analyzing entry with {len(conversation)} messages") + + content = "\n".join(f"{msg['role']}: {msg['content']}" for msg in conversation) + + try: + response = client.beta.messages.parse( + model=ANTHROPIC_MODEL, + max_tokens=200, + temperature=0.8, + betas=["structured-outputs-2025-11-13"], + system=INSIGHTS_PROMPT, + messages=[{"role": "user", "content": content}], + output_format=EntryAnalysis, + ) + result = { + "sentiment": response.parsed_output.sentiment, + "themes": response.parsed_output.themes, + } + logger.info(f"Entry analysis: {result}") + return result + except Exception as e: + logger.error(f"Failed to analyze entry: {e}") + return {"sentiment": "neutral", "themes": []} + + +def generate_response(messages: list[dict], memories: Optional[list[str]] = None): + """Generate a streaming response using Anthropic's Claude.""" logger.info( f"Generating response using {ANTHROPIC_MODEL} with {len(memories) if memories else 0} memories" ) @@ -62,16 +87,15 @@ def generate_response( memory_context = "\n".join(f"- {m}" for m in memories) system_prompt += f"\n\nRelevant memories about this user:\n{memory_context}\n\nUse these memories to provide more personalized and contextual responses when relevant." - response = client.messages.create( + with client.messages.stream( model=ANTHROPIC_MODEL, max_tokens=500, temperature=0.8, system=system_prompt, messages=messages, - ) - - logger.info("Response generated successfully") - return response.content[0].text + ) as stream: + for text in stream.text_stream: + yield text def generate_journal_prompt(memories: list[str]) -> str: @@ -94,30 +118,3 @@ def generate_journal_prompt(memories: list[str]) -> str: ) return response.content[0].text - - -def analyze_entry(conversation: list[dict]) -> dict: - """Analyze a journal entry for sentiment and themes.""" - logger.info(f"Analyzing entry with {len(conversation)} messages") - - content = "\n".join(f"{msg['role']}: {msg['content']}" for msg in conversation) - - try: - response = client.beta.messages.parse( - model=ANTHROPIC_MODEL, - max_tokens=200, - temperature=0.8, - betas=["structured-outputs-2025-11-13"], - system=INSIGHTS_PROMPT, - messages=[{"role": "user", "content": content}], - output_format=EntryAnalysis, - ) - result = { - "sentiment": response.parsed_output.sentiment, - "themes": response.parsed_output.themes, - } - logger.info(f"Entry analysis: {result}") - return result - except Exception as e: - logger.error(f"Failed to analyze entry: {e}") - return {"sentiment": "neutral", "themes": []} diff --git a/apps/interactive-journal/backend/requirements.txt b/apps/interactive-journal/backend/requirements.txt index e82439c9..4ae28151 100644 --- a/apps/interactive-journal/backend/requirements.txt +++ b/apps/interactive-journal/backend/requirements.txt @@ -3,5 +3,5 @@ uvicorn[standard]==0.34.0 pymongo==4.10.1 anthropic==0.75.0 python-dotenv==1.0.1 -voyageai==0.3.2 +voyageai==0.3.7 Pillow==11.0.0 diff --git a/apps/interactive-journal/frontend/src/App.jsx b/apps/interactive-journal/frontend/src/App.jsx index e935bf4c..9f538482 100644 --- a/apps/interactive-journal/frontend/src/App.jsx +++ b/apps/interactive-journal/frontend/src/App.jsx @@ -86,6 +86,7 @@ function App() { }) }) + // Show user messages immediately setMessages(prev => [...prev, ...newMessages]) // Send to backend using FormData @@ -106,10 +107,31 @@ function App() { method: 'POST', body: formData }) - const data = await res.json() - // Add AI response - setMessages(prev => [...prev, { _id: Date.now().toString() + '-ai', role: 'assistant', content: data.response }]) + // Read the streaming response + const reader = res.body.getReader() + const decoder = new TextDecoder() + const aiMessageId = Date.now().toString() + '-ai' + let fullResponse = '' + let messageAdded = false + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + fullResponse += chunk + + // Add AI message on first chunk, then update + if (!messageAdded) { + setMessages(prev => [...prev, { _id: aiMessageId, role: 'assistant', content: fullResponse }]) + messageAdded = true + } else { + setMessages(prev => prev.map(msg => + msg._id === aiMessageId ? { ...msg, content: fullResponse } : msg + )) + } + } } return ( diff --git a/apps/interactive-journal/frontend/src/components/Entry.jsx b/apps/interactive-journal/frontend/src/components/Entry.jsx index c07b822b..f62e12b2 100644 --- a/apps/interactive-journal/frontend/src/components/Entry.jsx +++ b/apps/interactive-journal/frontend/src/components/Entry.jsx @@ -33,8 +33,13 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, const handleSaveEntry = async () => { try { + const activeEntryObj = entries.find(e => e._id === activeEntry) + const formData = new FormData() + formData.append('entry_date', activeEntryObj?.created_at || new Date().toISOString()) + await fetch(`http://localhost:8000/api/entries/${activeEntry}/analyze`, { - method: 'POST' + method: 'POST', + body: formData }) setSaveStatus('saved') setTimeout(() => setSaveStatus(null), 2000) From 055631fd1b5ac02fa4551e1633944c3e5229c28f Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Fri, 19 Dec 2025 07:49:17 -0800 Subject: [PATCH 09/11] Nit --- .../backend/app/services/voyage.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/interactive-journal/backend/app/services/voyage.py b/apps/interactive-journal/backend/app/services/voyage.py index 091cd866..bf2fdcc0 100644 --- a/apps/interactive-journal/backend/app/services/voyage.py +++ b/apps/interactive-journal/backend/app/services/voyage.py @@ -4,16 +4,23 @@ import voyageai from PIL import Image -from app.config import IMAGE_SIZE, VOYAGE_API_KEY, VOYAGE_MULTIMODAL_MODEL, VOYAGE_TEXT_MODEL +from app.config import ( + IMAGE_SIZE, + VOYAGE_API_KEY, + VOYAGE_MULTIMODAL_MODEL, + VOYAGE_TEXT_MODEL, +) logger = logging.getLogger(__name__) vo = voyageai.Client(api_key=VOYAGE_API_KEY) -def get_multimodal_embedding(content: str | Path, mode: str, input_type: str) -> list[float]: +def get_multimodal_embedding( + content: str | Path, mode: str, input_type: str +) -> list[float]: """ - Generate embeddings using Voyage AI's multimodal model. + Generate embeddings using Voyage AI's voyage-multimodal-3.5 model. Args: content: Text string or path to image file @@ -23,7 +30,9 @@ def get_multimodal_embedding(content: str | Path, mode: str, input_type: str) -> Returns: list[float]: Embedding of the content as a list. """ - logger.info(f"Generating multimodal embedding: mode={mode}, input_type={input_type}") + logger.info( + f"Generating multimodal embedding: mode={mode}, input_type={input_type}" + ) if mode == "image": img = Image.open(content) From 4499ef5b6f301c8d397af73649f61daaf348e9a3 Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Fri, 19 Dec 2025 09:54:49 -0800 Subject: [PATCH 10/11] handling multimodal entries --- .../backend/app/routers/helpers.py | 97 ++++++++++++------- .../backend/app/routers/routes.py | 14 ++- .../frontend/src/components/Entry.jsx | 7 +- 3 files changed, 78 insertions(+), 40 deletions(-) diff --git a/apps/interactive-journal/backend/app/routers/helpers.py b/apps/interactive-journal/backend/app/routers/helpers.py index 6d288357..ea8d91d9 100644 --- a/apps/interactive-journal/backend/app/routers/helpers.py +++ b/apps/interactive-journal/backend/app/routers/helpers.py @@ -1,11 +1,14 @@ +import base64 import logging import uuid from datetime import datetime, timedelta +from io import BytesIO from pathlib import Path from fastapi import UploadFile +from PIL import Image -from app.config import USER_ID, VECTOR_INDEX_NAME, VECTOR_NUM_CANDIDATES +from app.config import IMAGE_SIZE, USER_ID, VECTOR_INDEX_NAME, VECTOR_NUM_CANDIDATES from app.services.anthropic import extract_memories from app.services.voyage import get_multimodal_embedding, get_text_embedding @@ -18,20 +21,6 @@ UPLOADS_DIR.mkdir(parents=True, exist_ok=True) -def get_conversation_history(db, entry_id: str) -> list[dict]: - """Get conversation history for an entry.""" - history = list( - db.messages.find( - {"entry_id": entry_id}, {"role": 1, "content": 1, "_id": 0} - ).sort("created_at", 1) - ) - return [ - {"role": msg["role"], "content": msg["content"]} - for msg in history - if msg.get("content") - ] - - def save_user_message( db, entry_id: str, content: str | Path, version: int, msg_date: datetime ) -> None: @@ -62,6 +51,28 @@ def save_user_message( logger.info(f"Saved message for entry {entry_id}") +def extract_and_save_memories( + db, entry_id: str, conversation: list[dict], entry_date: datetime +) -> None: + """Extract memories from conversation and save them.""" + context = "\n".join(f"{msg['role']}: {msg['content']}" for msg in conversation) + memories = extract_memories(context) + + if memories: + memory_docs = [ + { + "user_id": USER_ID, + "entry_id": entry_id, + "content": memory_content, + "embedding": get_text_embedding(memory_content, input_type="document"), + "created_at": entry_date, + } + for memory_content in memories + ] + db.memories.insert_many(memory_docs) + logger.info(f"Extracted and saved {len(memories)} memories: {memories}") + + def retrieve_relevant_memories(db, query: str) -> list[str]: """Retrieve relevant memories via vector search.""" query_embedding = get_text_embedding(query, input_type="query") @@ -84,26 +95,42 @@ def retrieve_relevant_memories(db, query: str) -> list[str]: return memories -def extract_and_save_memories( - db, entry_id: str, conversation: list[dict], entry_date: datetime -) -> None: - """Extract memories from conversation and save them.""" - context = "\n".join(f"{msg['role']}: {msg['content']}" for msg in conversation) - memories = extract_memories(context) +def get_conversation_history(db, entry_id: str, include_images: bool = True) -> list[dict]: + """Get conversation history for an entry.""" + history = list( + db.messages.find( + {"entry_id": entry_id}, {"role": 1, "content": 1, "image": 1, "_id": 0} + ).sort("created_at", 1) + ) - if memories: - memory_docs = [ - { - "user_id": USER_ID, - "entry_id": entry_id, - "content": memory_content, - "embedding": get_text_embedding(memory_content, input_type="document"), - "created_at": entry_date, - } - for memory_content in memories - ] - db.memories.insert_many(memory_docs) - logger.info(f"Extracted and saved {len(memories)} memories: {memories}") + messages = [] + for msg in history: + if msg.get("content"): + messages.append({"role": msg["role"], "content": msg["content"]}) + elif msg.get("image") and include_images: + image_path = UPLOADS_DIR / msg["image"] + if image_path.exists(): + messages.append( + { + "role": msg["role"], + "content": [image_to_base64(image_path)], + } + ) + return messages + + +def image_to_base64(image_path: Path) -> dict: + """Convert an image file to Claude's base64 format, resizing to fit limits.""" + with Image.open(image_path) as img: + img = img.resize(IMAGE_SIZE, Image.Resampling.LANCZOS) + buffer = BytesIO() + img.save(buffer, format="JPEG", quality=85) + data = base64.standard_b64encode(buffer.getvalue()).decode("utf-8") + + return { + "type": "image", + "source": {"type": "base64", "media_type": "image/jpeg", "data": data}, + } def save_assistant_message(db, entry_id: str, content: str, msg_date: datetime) -> None: @@ -120,7 +147,7 @@ def save_assistant_message(db, entry_id: str, content: str, msg_date: datetime) def save_image_file(image_file: UploadFile) -> Path: - """Save uploaded image file and return the path.""" + """Save uploaded image file.""" filename = f"{uuid.uuid4()}{Path(image_file.filename).suffix or '.jpg'}" image_path = UPLOADS_DIR / filename with open(image_path, "wb") as f: diff --git a/apps/interactive-journal/backend/app/routers/routes.py b/apps/interactive-journal/backend/app/routers/routes.py index debbe495..fdfa9979 100644 --- a/apps/interactive-journal/backend/app/routers/routes.py +++ b/apps/interactive-journal/backend/app/routers/routes.py @@ -13,6 +13,7 @@ get_mood_distribution, get_themes, get_total_entries, + image_to_base64, retrieve_relevant_memories, save_assistant_message, save_image_file, @@ -62,10 +63,17 @@ def send_message( # Save image files to disk before streaming (file handles close after) image_paths = [save_image_file(image) for image in images] + # Build current message (text, images, or both) + messages = [] + if content: + messages.append({"type": "text", "text": content}) + for path in image_paths: + messages.append(image_to_base64(path)) + # Get conversation history and add current message conversation = get_conversation_history(db, entry_id) - if content: - conversation.append({"role": "user", "content": content}) + if messages: + conversation.append({"role": "user", "content": messages}) # Retrieve relevant memories for context (V2 only) memories = retrieve_relevant_memories(db, content) if is_v2 and content else [] @@ -144,7 +152,7 @@ def search_entries(q: str, version: int = 1): def save_entry(entry_id: str, entry_date: str = Form(...)): """Analyze entry for sentiment/themes and extract memories.""" db = get_database() - conversation = get_conversation_history(db, entry_id) + conversation = get_conversation_history(db, entry_id, include_images=False) if not conversation: return {"error": "No messages in entry"} diff --git a/apps/interactive-journal/frontend/src/components/Entry.jsx b/apps/interactive-journal/frontend/src/components/Entry.jsx index f62e12b2..fbe61e09 100644 --- a/apps/interactive-journal/frontend/src/components/Entry.jsx +++ b/apps/interactive-journal/frontend/src/components/Entry.jsx @@ -32,6 +32,7 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, } const handleSaveEntry = async () => { + setSaveStatus('saving') try { const activeEntryObj = entries.find(e => e._id === activeEntry) const formData = new FormData() @@ -45,6 +46,7 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, setTimeout(() => setSaveStatus(null), 2000) } catch (error) { console.error('Failed to save entry:', error) + setSaveStatus(null) } } @@ -291,10 +293,11 @@ function Entry({ messages, onSendMessage, hasActiveEntry, activeEntry, entries, {isV2 && messages.length > 0 && (
)} From f12017c3e5954039163ab624daac05096b3c5a70 Mon Sep 17 00:00:00 2001 From: ajosh0504 Date: Mon, 22 Dec 2025 13:46:15 -0800 Subject: [PATCH 11/11] Pre commit checks pass --- apps/interactive-journal/backend/app/routers/helpers.py | 4 +++- apps/interactive-journal/backend/app/routers/routes.py | 4 ++-- apps/interactive-journal/backend/app/services/anthropic.py | 3 +-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/interactive-journal/backend/app/routers/helpers.py b/apps/interactive-journal/backend/app/routers/helpers.py index ea8d91d9..f4acd060 100644 --- a/apps/interactive-journal/backend/app/routers/helpers.py +++ b/apps/interactive-journal/backend/app/routers/helpers.py @@ -95,7 +95,9 @@ def retrieve_relevant_memories(db, query: str) -> list[str]: return memories -def get_conversation_history(db, entry_id: str, include_images: bool = True) -> list[dict]: +def get_conversation_history( + db, entry_id: str, include_images: bool = True +) -> list[dict]: """Get conversation history for an entry.""" history = list( db.messages.find( diff --git a/apps/interactive-journal/backend/app/routers/routes.py b/apps/interactive-journal/backend/app/routers/routes.py index fdfa9979..9aaa4271 100644 --- a/apps/interactive-journal/backend/app/routers/routes.py +++ b/apps/interactive-journal/backend/app/routers/routes.py @@ -4,6 +4,7 @@ from bson import ObjectId from fastapi import APIRouter, File, Form, UploadFile +from fastapi.responses import StreamingResponse from app.config import USER_ID, VECTOR_INDEX_NAME, VECTOR_NUM_CANDIDATES from app.routers.helpers import ( @@ -19,13 +20,12 @@ save_image_file, save_user_message, ) -from app.services.mongodb import get_database -from fastapi.responses import StreamingResponse from app.services.anthropic import ( analyze_entry, generate_journal_prompt, generate_response, ) +from app.services.mongodb import get_database from app.services.voyage import get_multimodal_embedding, get_text_embedding logger = logging.getLogger(__name__) diff --git a/apps/interactive-journal/backend/app/services/anthropic.py b/apps/interactive-journal/backend/app/services/anthropic.py index f38a1e47..3215f5d9 100644 --- a/apps/interactive-journal/backend/app/services/anthropic.py +++ b/apps/interactive-journal/backend/app/services/anthropic.py @@ -94,8 +94,7 @@ def generate_response(messages: list[dict], memories: Optional[list[str]] = None system=system_prompt, messages=messages, ) as stream: - for text in stream.text_stream: - yield text + yield from stream.text_stream def generate_journal_prompt(memories: list[str]) -> str: