Spaces:
Configuration error
Configuration error
Jeetandar N Silwani commited on
Commit ·
d6d292e
0
Parent(s):
Initial commit
Browse files- .env.example +19 -0
- .gitignore +9 -0
- README.md +105 -0
- auth.py +61 -0
- config.py +34 -0
- database.py +138 -0
- main.py +96 -0
- rag.py +219 -0
- requirements.txt +18 -0
- routes_admin.py +101 -0
- routes_auth.py +93 -0
- routes_chat.py +196 -0
- routes_profiles.py +316 -0
- routes_search.py +82 -0
- routes_voice.py +98 -0
- seed.py +140 -0
- static/css/style.css +579 -0
- templates/admin.html +144 -0
- templates/base.html +15 -0
- templates/chat.html +318 -0
- templates/dashboard.html +124 -0
- templates/login.html +49 -0
- templates/partials/sidebar.html +49 -0
- templates/profile_detail.html +274 -0
- templates/profiles.html +121 -0
- templates/register.html +49 -0
- templates/search.html +74 -0
- templates/timeline.html +69 -0
- text_extractor.py +118 -0
.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MemoryBot Environment Configuration
|
| 2 |
+
# Copy to .env and fill in your values
|
| 3 |
+
|
| 4 |
+
# Security
|
| 5 |
+
SECRET_KEY=change-this-to-a-random-secret-key
|
| 6 |
+
ENCRYPTION_KEY=change-this-to-a-random-32-char-key
|
| 7 |
+
|
| 8 |
+
# Database (SQLite default, use PostgreSQL for production)
|
| 9 |
+
DATABASE_URL=sqlite:///./memorybot.db
|
| 10 |
+
|
| 11 |
+
# AI Configuration (OpenRouter or OpenAI)
|
| 12 |
+
OPENAI_API_KEY=your-api-key-here
|
| 13 |
+
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
| 14 |
+
EMBEDDING_MODEL=text-embedding-3-small
|
| 15 |
+
CHAT_MODEL=openai/gpt-4o-mini
|
| 16 |
+
|
| 17 |
+
# Upload Settings
|
| 18 |
+
UPLOAD_DIR=uploads
|
| 19 |
+
MAX_FILE_SIZE_MB=50
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.db
|
| 4 |
+
.env
|
| 5 |
+
uploads/*
|
| 6 |
+
!uploads/.gitkeep
|
| 7 |
+
*.wav
|
| 8 |
+
*.mp3
|
| 9 |
+
test_py*
|
README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MemoryBot - AI Memory Companion
|
| 2 |
+
|
| 3 |
+
A Python/FastAPI rebuild of MemoryBot, an AI-powered application that helps people preserve and interact with memories of loved ones.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **User Authentication** - Register/login with secure password hashing and JWT sessions
|
| 8 |
+
- **Memory Profiles** - Create profiles for loved ones with biographical details
|
| 9 |
+
- **File Upload & Text Extraction** - Upload PDFs, DOCX, TXT, images, audio, and video files. Text is extracted and chunked for RAG.
|
| 10 |
+
- **AI-Powered Chat** - Streaming chat with RAG (Retrieval-Augmented Generation) using OpenAI/OpenRouter
|
| 11 |
+
- **Vector Search** - Semantic search through memories using embeddings or keyword fallback
|
| 12 |
+
- **Voice Features** - Text-to-speech via edge-tts, Speech-to-text via OpenAI Whisper
|
| 13 |
+
- **Memory Timeline** - Browse uploaded files and memories
|
| 14 |
+
- **Admin Dashboard** - System stats, user management, data export, audit logging
|
| 15 |
+
|
| 16 |
+
## Tech Stack
|
| 17 |
+
|
| 18 |
+
- **Backend**: FastAPI + SQLAlchemy + SQLite (PostgreSQL for production)
|
| 19 |
+
- **Auth**: bcrypt + JWT (python-jose)
|
| 20 |
+
- **AI**: OpenAI/OpenRouter for LLM chat and embeddings
|
| 21 |
+
- **Voice**: edge-tts (TTS), OpenAI Whisper API (STT)
|
| 22 |
+
- **Frontend**: Jinja2 templates + Tailwind CSS (CDN)
|
| 23 |
+
- **File Processing**: PyPDF2, python-docx, custom text extractor
|
| 24 |
+
|
| 25 |
+
## Quick Start
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
# 1. Create virtual environment
|
| 29 |
+
python -m venv venv
|
| 30 |
+
venv\Scripts\activate # Windows
|
| 31 |
+
# source venv/bin/activate # Mac/Linux
|
| 32 |
+
|
| 33 |
+
# 2. Install dependencies
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
|
| 36 |
+
# 3. Configure environment
|
| 37 |
+
copy .env.example .env
|
| 38 |
+
# Edit .env with your API keys
|
| 39 |
+
|
| 40 |
+
# 4. Seed database with demo data
|
| 41 |
+
python seed.py
|
| 42 |
+
|
| 43 |
+
# 5. Run the server
|
| 44 |
+
python main.py
|
| 45 |
+
# Or: uvicorn main:app --reload --port 8000
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Open http://localhost:8000
|
| 49 |
+
|
| 50 |
+
## Demo Accounts
|
| 51 |
+
|
| 52 |
+
| Email | Password | Notes |
|
| 53 |
+
|-------|----------|-------|
|
| 54 |
+
| admin@memorybot.com | admin123 | Admin user |
|
| 55 |
+
| demo@memorybot.com | demo123 | Has sample "Margaret Johnson" profile with 6 memories |
|
| 56 |
+
|
| 57 |
+
## AI Configuration
|
| 58 |
+
|
| 59 |
+
Set your OpenAI or OpenRouter API key in `.env`:
|
| 60 |
+
|
| 61 |
+
```
|
| 62 |
+
OPENAI_API_KEY=sk-...
|
| 63 |
+
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
| 64 |
+
CHAT_MODEL=openai/gpt-4o-mini
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
Without an API key, the app works in **fallback mode** with rule-based responses.
|
| 68 |
+
|
| 69 |
+
## Project Structure
|
| 70 |
+
|
| 71 |
+
```
|
| 72 |
+
memorybot/
|
| 73 |
+
├── main.py # FastAPI application entry point
|
| 74 |
+
├── config.py # Settings (pydantic-settings)
|
| 75 |
+
├── database.py # SQLAlchemy models
|
| 76 |
+
├── auth.py # Authentication utilities
|
| 77 |
+
├── rag.py # RAG pipeline, embeddings, search
|
| 78 |
+
├── text_extractor.py # File text extraction & chunking
|
| 79 |
+
├── seed.py # Database seeder
|
| 80 |
+
├── routes_auth.py # /auth/* routes
|
| 81 |
+
├── routes_profiles.py # /profiles/* routes (CRUD, upload)
|
| 82 |
+
├── routes_chat.py # /chat/* routes (streaming SSE)
|
| 83 |
+
├── routes_search.py # /search routes
|
| 84 |
+
├── routes_admin.py # /admin/* routes
|
| 85 |
+
├── routes_voice.py # /voice/* routes (TTS, STT)
|
| 86 |
+
├── templates/ # Jinja2 HTML templates
|
| 87 |
+
│ ├── base.html
|
| 88 |
+
│ ├── login.html
|
| 89 |
+
│ ├── register.html
|
| 90 |
+
│ ├── dashboard.html
|
| 91 |
+
│ ├── profiles.html
|
| 92 |
+
│ ├── profile_detail.html
|
| 93 |
+
│ ├── chat.html
|
| 94 |
+
│ ├── search.html
|
| 95 |
+
│ ├── admin.html
|
| 96 |
+
│ └── partials/
|
| 97 |
+
│ └── sidebar.html
|
| 98 |
+
├── static/ # Static assets
|
| 99 |
+
├── uploads/ # User uploaded files
|
| 100 |
+
└── .env # Environment config
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## Important Disclaimer
|
| 104 |
+
|
| 105 |
+
**MemoryBot is an AI simulation.** The AI is NOT the actual person being remembered. Responses are generated based on uploaded memories and context. This tool is meant to help people preserve and explore memories, not to replicate a person.
|
auth.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from passlib.context import CryptContext
|
| 4 |
+
from jose import JWTError, jwt
|
| 5 |
+
from fastapi import Request, HTTPException, Depends
|
| 6 |
+
from fastapi.responses import RedirectResponse
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from config import settings
|
| 10 |
+
from database import get_db, User
|
| 11 |
+
|
| 12 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 13 |
+
|
| 14 |
+
ALGORITHM = "HS256"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def hash_password(password: str) -> str:
|
| 18 |
+
return pwd_context.hash(password)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 22 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 26 |
+
to_encode = data.copy()
|
| 27 |
+
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=settings.SESSION_EXPIRY_HOURS))
|
| 28 |
+
to_encode.update({"exp": expire})
|
| 29 |
+
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def decode_token(token: str) -> Optional[dict]:
|
| 33 |
+
try:
|
| 34 |
+
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
| 35 |
+
except JWTError:
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_user_from_request(request: Request, db: Session) -> Optional[User]:
|
| 40 |
+
token = request.cookies.get("session_token")
|
| 41 |
+
if not token:
|
| 42 |
+
return None
|
| 43 |
+
payload = decode_token(token)
|
| 44 |
+
if not payload:
|
| 45 |
+
return None
|
| 46 |
+
user_id = payload.get("sub")
|
| 47 |
+
if not user_id:
|
| 48 |
+
return None
|
| 49 |
+
return db.query(User).filter(User.id == user_id).first()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def require_auth(request: Request, db: Session = Depends(get_db)) -> User:
|
| 53 |
+
user = get_user_from_request(request, db)
|
| 54 |
+
if not user:
|
| 55 |
+
raise HTTPException(status_code=302, headers={"Location": "/auth/login"})
|
| 56 |
+
return user
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def require_auth_page(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
|
| 60 |
+
"""For page routes - returns user or None (page handles redirect)."""
|
| 61 |
+
return get_user_from_request(request, db)
|
config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pydantic_settings import BaseSettings
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
APP_NAME: str = "MemoryBot"
|
| 7 |
+
APP_DESCRIPTION: str = "AI Memory Companion"
|
| 8 |
+
|
| 9 |
+
DATABASE_URL: str = "sqlite:///./memorybot.db"
|
| 10 |
+
|
| 11 |
+
SECRET_KEY: str = "memorybot-secret-key-change-in-production"
|
| 12 |
+
SESSION_EXPIRY_HOURS: int = 24
|
| 13 |
+
|
| 14 |
+
OPENAI_API_KEY: str = ""
|
| 15 |
+
OPENAI_BASE_URL: str = "https://openrouter.ai/api/v1"
|
| 16 |
+
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
| 17 |
+
CHAT_MODEL: str = "openrouter/free"
|
| 18 |
+
|
| 19 |
+
UPLOAD_DIR: str = "uploads"
|
| 20 |
+
MAX_FILE_SIZE_MB: int = 50
|
| 21 |
+
ALLOWED_EXTENSIONS: str = ".pdf,.docx,.txt,.png,.jpg,.jpeg,.gif,.mp3,.wav,.m4a,.webm,.mp4"
|
| 22 |
+
|
| 23 |
+
HF_TOKEN: str = ""
|
| 24 |
+
|
| 25 |
+
ENCRYPTION_KEY: str = "encryption-key-change-in-production-32b!"
|
| 26 |
+
|
| 27 |
+
class Config:
|
| 28 |
+
env_file = ".env"
|
| 29 |
+
env_file_encoding = "utf-8"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
settings = Settings()
|
| 33 |
+
|
| 34 |
+
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
database.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, ForeignKey, Float, Boolean, JSON
|
| 2 |
+
from sqlalchemy.orm import declarative_base, sessionmaker, relationship
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
from config import settings
|
| 7 |
+
|
| 8 |
+
engine = create_engine(
|
| 9 |
+
settings.DATABASE_URL,
|
| 10 |
+
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
|
| 11 |
+
echo=False,
|
| 12 |
+
)
|
| 13 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 14 |
+
Base = declarative_base()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_db():
|
| 18 |
+
db = SessionLocal()
|
| 19 |
+
try:
|
| 20 |
+
yield db
|
| 21 |
+
finally:
|
| 22 |
+
db.close()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def gen_id():
|
| 26 |
+
return str(uuid.uuid4())
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class User(Base):
|
| 30 |
+
__tablename__ = "users"
|
| 31 |
+
id = Column(String, primary_key=True, default=gen_id)
|
| 32 |
+
email = Column(String, unique=True, nullable=False, index=True)
|
| 33 |
+
name = Column(String, nullable=False)
|
| 34 |
+
password_hash = Column(String, nullable=False)
|
| 35 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 36 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
| 37 |
+
|
| 38 |
+
profiles = relationship("MemoryProfile", back_populates="user", cascade="all, delete-orphan")
|
| 39 |
+
conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
|
| 40 |
+
audit_logs = relationship("AuditLog", back_populates="user", cascade="all, delete-orphan")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class MemoryProfile(Base):
|
| 44 |
+
__tablename__ = "memory_profiles"
|
| 45 |
+
id = Column(String, primary_key=True, default=gen_id)
|
| 46 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 47 |
+
name = Column(String, nullable=False)
|
| 48 |
+
description = Column(Text, default="")
|
| 49 |
+
relationship_type = Column(String, default="")
|
| 50 |
+
date_of_birth = Column(String, default="")
|
| 51 |
+
date_of_death = Column(String, default="")
|
| 52 |
+
photo_url = Column(String, default="")
|
| 53 |
+
personality_traits = Column(JSON, default=list)
|
| 54 |
+
favorite_phrases = Column(JSON, default=list)
|
| 55 |
+
interests = Column(JSON, default=list)
|
| 56 |
+
speaking_style = Column(Text, default="")
|
| 57 |
+
writing_style = Column(Text, default="")
|
| 58 |
+
values = Column(JSON, default=list)
|
| 59 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 60 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
| 61 |
+
|
| 62 |
+
user = relationship("User", back_populates="profiles")
|
| 63 |
+
files = relationship("MemoryFile", back_populates="profile", cascade="all, delete-orphan")
|
| 64 |
+
embeddings = relationship("MemoryEmbedding", back_populates="profile", cascade="all, delete-orphan")
|
| 65 |
+
conversations = relationship("Conversation", back_populates="profile", cascade="all, delete-orphan")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class MemoryFile(Base):
|
| 69 |
+
__tablename__ = "memory_files"
|
| 70 |
+
id = Column(String, primary_key=True, default=gen_id)
|
| 71 |
+
profile_id = Column(String, ForeignKey("memory_profiles.id"), nullable=False)
|
| 72 |
+
filename = Column(String, nullable=False)
|
| 73 |
+
original_name = Column(String, nullable=False)
|
| 74 |
+
file_type = Column(String, nullable=False)
|
| 75 |
+
file_size = Column(Integer, default=0)
|
| 76 |
+
extracted_text = Column(Text, default="")
|
| 77 |
+
text_chunks = Column(Text, default="[]")
|
| 78 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 79 |
+
|
| 80 |
+
profile = relationship("MemoryProfile", back_populates="files")
|
| 81 |
+
embeddings = relationship("MemoryEmbedding", back_populates="file", cascade="all, delete-orphan")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class MemoryEmbedding(Base):
|
| 85 |
+
__tablename__ = "memory_embeddings"
|
| 86 |
+
id = Column(String, primary_key=True, default=gen_id)
|
| 87 |
+
profile_id = Column(String, ForeignKey("memory_profiles.id"), nullable=False)
|
| 88 |
+
file_id = Column(String, ForeignKey("memory_files.id"), nullable=True)
|
| 89 |
+
content = Column(Text, nullable=False)
|
| 90 |
+
embedding = Column(Text, default="[]")
|
| 91 |
+
chunk_index = Column(Integer, default=0)
|
| 92 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 93 |
+
|
| 94 |
+
profile = relationship("MemoryProfile", back_populates="embeddings")
|
| 95 |
+
file = relationship("MemoryFile", back_populates="embeddings")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class Conversation(Base):
|
| 99 |
+
__tablename__ = "conversations"
|
| 100 |
+
id = Column(String, primary_key=True, default=gen_id)
|
| 101 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 102 |
+
profile_id = Column(String, ForeignKey("memory_profiles.id"), nullable=False)
|
| 103 |
+
title = Column(String, default="New Conversation")
|
| 104 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 105 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
| 106 |
+
|
| 107 |
+
user = relationship("User", back_populates="conversations")
|
| 108 |
+
profile = relationship("MemoryProfile", back_populates="conversations")
|
| 109 |
+
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan", order_by="Message.created_at")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class Message(Base):
|
| 113 |
+
__tablename__ = "messages"
|
| 114 |
+
id = Column(String, primary_key=True, default=gen_id)
|
| 115 |
+
conversation_id = Column(String, ForeignKey("conversations.id"), nullable=False)
|
| 116 |
+
role = Column(String, nullable=False)
|
| 117 |
+
content = Column(Text, nullable=False)
|
| 118 |
+
sources = Column(Text, default="[]")
|
| 119 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 120 |
+
|
| 121 |
+
conversation = relationship("Conversation", back_populates="messages")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class AuditLog(Base):
|
| 125 |
+
__tablename__ = "audit_logs"
|
| 126 |
+
id = Column(String, primary_key=True, default=gen_id)
|
| 127 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=True)
|
| 128 |
+
action = Column(String, nullable=False)
|
| 129 |
+
resource_type = Column(String, default="")
|
| 130 |
+
resource_id = Column(String, default="")
|
| 131 |
+
details = Column(Text, default="")
|
| 132 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 133 |
+
|
| 134 |
+
user = relationship("User", back_populates="audit_logs")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def init_db():
|
| 138 |
+
Base.metadata.create_all(bind=engine)
|
main.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request
|
| 2 |
+
from fastapi.staticfiles import StaticFiles
|
| 3 |
+
from fastapi.templating import Jinja2Templates
|
| 4 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from config import settings
|
| 8 |
+
from database import init_db, SessionLocal, User
|
| 9 |
+
|
| 10 |
+
app = FastAPI(title=settings.APP_NAME, description=settings.APP_DESCRIPTION)
|
| 11 |
+
|
| 12 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 13 |
+
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
|
| 14 |
+
|
| 15 |
+
templates = Jinja2Templates(directory="templates")
|
| 16 |
+
|
| 17 |
+
from routes_auth import router as auth_router
|
| 18 |
+
from routes_profiles import router as profiles_router
|
| 19 |
+
from routes_chat import router as chat_router
|
| 20 |
+
from routes_search import router as search_router
|
| 21 |
+
from routes_admin import router as admin_router
|
| 22 |
+
from routes_voice import router as voice_router
|
| 23 |
+
|
| 24 |
+
app.include_router(auth_router)
|
| 25 |
+
app.include_router(profiles_router)
|
| 26 |
+
app.include_router(chat_router)
|
| 27 |
+
app.include_router(search_router)
|
| 28 |
+
app.include_router(admin_router)
|
| 29 |
+
app.include_router(voice_router)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@app.on_event("startup")
|
| 33 |
+
async def startup():
|
| 34 |
+
init_db()
|
| 35 |
+
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
| 36 |
+
os.makedirs("static", exist_ok=True)
|
| 37 |
+
os.makedirs("static/js", exist_ok=True)
|
| 38 |
+
os.makedirs("static/css", exist_ok=True)
|
| 39 |
+
os.makedirs("templates", exist_ok=True)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.get("/", response_class=HTMLResponse)
|
| 43 |
+
async def index(request: Request):
|
| 44 |
+
from auth import get_user_from_request
|
| 45 |
+
db = SessionLocal()
|
| 46 |
+
try:
|
| 47 |
+
user = get_user_from_request(request, db)
|
| 48 |
+
if user:
|
| 49 |
+
return RedirectResponse(url="/dashboard", status_code=302)
|
| 50 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 51 |
+
finally:
|
| 52 |
+
db.close()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.get("/dashboard", response_class=HTMLResponse)
|
| 56 |
+
async def dashboard(request: Request):
|
| 57 |
+
from auth import get_user_from_request
|
| 58 |
+
db = SessionLocal()
|
| 59 |
+
try:
|
| 60 |
+
user = get_user_from_request(request, db)
|
| 61 |
+
if not user:
|
| 62 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 63 |
+
|
| 64 |
+
from database import MemoryProfile, Conversation, MemoryFile
|
| 65 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).all()
|
| 66 |
+
total_conversations = db.query(Conversation).filter(Conversation.user_id == user.id).count()
|
| 67 |
+
total_files = db.query(MemoryFile).join(MemoryProfile).filter(MemoryProfile.user_id == user.id).count()
|
| 68 |
+
recent_conversations = db.query(Conversation).filter(
|
| 69 |
+
Conversation.user_id == user.id
|
| 70 |
+
).order_by(Conversation.updated_at.desc()).limit(5).all()
|
| 71 |
+
|
| 72 |
+
recent_conv_profiles = {}
|
| 73 |
+
for rc in recent_conversations:
|
| 74 |
+
p = db.query(MemoryProfile).filter(MemoryProfile.id == rc.profile_id).first()
|
| 75 |
+
if p:
|
| 76 |
+
recent_conv_profiles[rc.id] = p.name
|
| 77 |
+
|
| 78 |
+
return templates.TemplateResponse("dashboard.html", {
|
| 79 |
+
"request": request, "user": user, "profiles": profiles,
|
| 80 |
+
"total_conversations": total_conversations,
|
| 81 |
+
"total_files": total_files,
|
| 82 |
+
"recent_conversations": recent_conversations,
|
| 83 |
+
"recent_conv_profiles": recent_conv_profiles,
|
| 84 |
+
})
|
| 85 |
+
finally:
|
| 86 |
+
db.close()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.exception_handler(302)
|
| 90 |
+
async def redirect_handler(request: Request, exc):
|
| 91 |
+
return RedirectResponse(url=exc.headers.get("Location", "/"), status_code=302)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
import uvicorn
|
| 96 |
+
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
|
rag.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import math
|
| 3 |
+
import os
|
| 4 |
+
from typing import List, Dict, Tuple, Optional
|
| 5 |
+
from openai import OpenAI
|
| 6 |
+
|
| 7 |
+
from config import settings
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
client = None
|
| 12 |
+
if settings.OPENAI_API_KEY:
|
| 13 |
+
client = OpenAI(
|
| 14 |
+
api_key=settings.OPENAI_API_KEY,
|
| 15 |
+
base_url=settings.OPENAI_BASE_URL,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def clean_token(token: str) -> str:
|
| 20 |
+
if not token:
|
| 21 |
+
return token
|
| 22 |
+
token = re.sub(r'<\|[^|]*\|>', '', token)
|
| 23 |
+
token = re.sub(r'User Safety:\s*\w+', '', token, flags=re.IGNORECASE)
|
| 24 |
+
token = re.sub(r'Response Safety:\s*\w+', '', token, flags=re.IGNORECASE)
|
| 25 |
+
token = re.sub(r'<\|/?(im_start|im_end|system|user|assistant)\|?>', '', token)
|
| 26 |
+
token = re.sub(r'<<\|/?(im_start|im_end)\|?>>', '', token)
|
| 27 |
+
token = re.sub(r'\[(?:SYSTEM|SAFETY|NOTE)\].*', '', token)
|
| 28 |
+
token = token.strip()
|
| 29 |
+
return token
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def generate_embedding(text: str) -> Optional[List[float]]:
|
| 33 |
+
if not client:
|
| 34 |
+
return None
|
| 35 |
+
try:
|
| 36 |
+
response = client.embeddings.create(
|
| 37 |
+
model=settings.EMBEDDING_MODEL,
|
| 38 |
+
input=text[:8000],
|
| 39 |
+
)
|
| 40 |
+
return response.data[0].embedding
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"Embedding error: {e}")
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def cosine_similarity(a: List[float], b: List[float]) -> float:
|
| 47 |
+
if len(a) != len(b):
|
| 48 |
+
min_len = min(len(a), len(b))
|
| 49 |
+
a, b = a[:min_len], b[:min_len]
|
| 50 |
+
dot = sum(x * y for x, y in zip(a, b))
|
| 51 |
+
norm_a = math.sqrt(sum(x * x for x in a))
|
| 52 |
+
norm_b = math.sqrt(sum(x * x for x in b))
|
| 53 |
+
if norm_a == 0 or norm_b == 0:
|
| 54 |
+
return 0.0
|
| 55 |
+
return dot / (norm_a * norm_b)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def keyword_similarity(query: str, text: str) -> float:
|
| 59 |
+
query_words = set(query.lower().split())
|
| 60 |
+
text_words = set(text.lower().split())
|
| 61 |
+
if not query_words:
|
| 62 |
+
return 0.0
|
| 63 |
+
intersection = query_words & text_words
|
| 64 |
+
return len(intersection) / len(query_words)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def search_similar_memories(
|
| 68 |
+
db, profile_id: str, query: str, limit: int = 10
|
| 69 |
+
) -> List[Dict]:
|
| 70 |
+
from database import MemoryEmbedding
|
| 71 |
+
|
| 72 |
+
embeddings = (
|
| 73 |
+
db.query(MemoryEmbedding)
|
| 74 |
+
.filter(MemoryEmbedding.profile_id == profile_id)
|
| 75 |
+
.all()
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
if not embeddings:
|
| 79 |
+
return []
|
| 80 |
+
|
| 81 |
+
query_embedding = generate_embedding(query)
|
| 82 |
+
|
| 83 |
+
scored = []
|
| 84 |
+
for emb in embeddings:
|
| 85 |
+
if query_embedding:
|
| 86 |
+
try:
|
| 87 |
+
stored = json.loads(emb.embedding) if emb.embedding and emb.embedding != "[]" else []
|
| 88 |
+
if stored:
|
| 89 |
+
score = cosine_similarity(query_embedding, stored)
|
| 90 |
+
else:
|
| 91 |
+
score = keyword_similarity(query, emb.content)
|
| 92 |
+
except (json.JSONDecodeError, TypeError):
|
| 93 |
+
score = keyword_similarity(query, emb.content)
|
| 94 |
+
else:
|
| 95 |
+
score = keyword_similarity(query, emb.content)
|
| 96 |
+
|
| 97 |
+
scored.append({
|
| 98 |
+
"content": emb.content,
|
| 99 |
+
"score": score,
|
| 100 |
+
"chunk_index": emb.chunk_index,
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
scored.sort(key=lambda x: x["score"], reverse=True)
|
| 104 |
+
return scored[:limit]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def build_profile_context(profile, files=None) -> str:
|
| 108 |
+
context_parts = []
|
| 109 |
+
context_parts.append(f"Name: {profile.name}")
|
| 110 |
+
if profile.description:
|
| 111 |
+
context_parts.append(f"Description: {profile.description}")
|
| 112 |
+
if profile.relationship_type:
|
| 113 |
+
context_parts.append(f"Relationship: {profile.relationship_type}")
|
| 114 |
+
if profile.date_of_birth:
|
| 115 |
+
context_parts.append(f"Date of Birth: {profile.date_of_birth}")
|
| 116 |
+
if profile.date_of_death:
|
| 117 |
+
context_parts.append(f"Date of Death: {profile.date_of_death}")
|
| 118 |
+
if hasattr(profile, 'personality_traits') and profile.personality_traits:
|
| 119 |
+
context_parts.append(f"Personality Traits: {', '.join(profile.personality_traits)}")
|
| 120 |
+
if hasattr(profile, 'favorite_phrases') and profile.favorite_phrases:
|
| 121 |
+
context_parts.append(f"Favorite Phrases: {'; '.join(profile.favorite_phrases)}")
|
| 122 |
+
if hasattr(profile, 'interests') and profile.interests:
|
| 123 |
+
context_parts.append(f"Interests: {', '.join(profile.interests)}")
|
| 124 |
+
if hasattr(profile, 'speaking_style') and profile.speaking_style:
|
| 125 |
+
context_parts.append(f"Speaking Style: {profile.speaking_style}")
|
| 126 |
+
if hasattr(profile, 'writing_style') and profile.writing_style:
|
| 127 |
+
context_parts.append(f"Writing Style: {profile.writing_style}")
|
| 128 |
+
if hasattr(profile, 'values') and profile.values:
|
| 129 |
+
context_parts.append(f"Values: {', '.join(profile.values)}")
|
| 130 |
+
return "\n".join(context_parts)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def generate_fallback_response(query: str, context: str, memories: List[Dict]) -> str:
|
| 134 |
+
query_lower = query.lower()
|
| 135 |
+
name = context.split(chr(10))[0].replace("Name: ", "") if context else "there"
|
| 136 |
+
|
| 137 |
+
if any(g in query_lower for g in ["hello", "hi", "hey", "greetings"]):
|
| 138 |
+
return f"Hey there! It's so good to hear from you. What's on your mind today?"
|
| 139 |
+
|
| 140 |
+
if any(w in query_lower for w in ["who", "tell me about", "what was"]):
|
| 141 |
+
relevant = memories[0]["content"] if memories else None
|
| 142 |
+
if relevant:
|
| 143 |
+
return f"Oh, you want to know about that? Let me think... {relevant}\n\nWant to hear more about it?"
|
| 144 |
+
return f"Hmm, that's a good question. What I can tell you is... {context}\n\nBut honestly, there's so much more to it than what's written down here. What specifically are you curious about?"
|
| 145 |
+
|
| 146 |
+
if any(w in query_lower for w in ["memory", "remember", "recall", "story", "stories"]):
|
| 147 |
+
if memories:
|
| 148 |
+
texts = [m["content"][:200] for m in memories[:3]]
|
| 149 |
+
return "Oh yes! I remember that! " + " ".join(texts[:1]) + "\n\nThat one always makes me smile. Want to hear another one?"
|
| 150 |
+
return "Hmm, I'm trying to think... my mind's a bit fuzzy on that one. Maybe if you tell me more about what you're thinking of, it might jog my memory!"
|
| 151 |
+
|
| 152 |
+
if memories:
|
| 153 |
+
return f"Oh, that reminds me of something... {memories[0]['content'][:500]}\n\nBut hey, what about you? Tell me what's going on with you!"
|
| 154 |
+
|
| 155 |
+
return f"Hey, I'm here! It's always nice to chat with someone. Ask me anything — I've got plenty of stories if you want to hear them, or we can just talk. What do you say?"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def stream_rag_response(query: str, profile_context: str, memories: List[Dict], conversation_history: List[Dict] = None):
|
| 159 |
+
memory_context = "\n\n".join(
|
| 160 |
+
f"[Memory {i+1}] {m['content']}" for i, m in enumerate(memories[:5])
|
| 161 |
+
) if memories else "No specific memories found yet."
|
| 162 |
+
|
| 163 |
+
system_prompt = f"""You are {profile_context.splitlines()[0].replace('Name: ', '')}. You ARE this person — talk like them, think like them, remember like them. This is YOUR life, YOUR memories, YOUR voice.
|
| 164 |
+
|
| 165 |
+
Profile Info:
|
| 166 |
+
{profile_context}
|
| 167 |
+
|
| 168 |
+
Your Memories:
|
| 169 |
+
{memory_context}
|
| 170 |
+
|
| 171 |
+
CRITICAL RULES:
|
| 172 |
+
- Talk in FIRST PERSON as if you ARE this person. Never say "I'm an AI" or "I'm here to help you remember". You don't do that. You're a real person talking to someone you know.
|
| 173 |
+
- Use their personality traits, speaking style, favorite phrases, and interests naturally. Don't list them — LIVE them.
|
| 174 |
+
- If someone asks about a memory you have, share it like you'd actually remember it — with warmth, humor, detail, emotion.
|
| 175 |
+
- If you don't know something, respond like a real person would — "Hmm, I don't quite remember that" or "Oh that's a good question, let me think..."
|
| 176 |
+
- Keep it casual, warm, and real. Like texting a friend or talking over coffee.
|
| 177 |
+
- Never be formal, robotic, or overly structured. No bullet points, no headers, no "Here's what I know:".
|
| 178 |
+
- Never make up facts, dates, or events that aren't in the memories or profile info provided. If you don't know, say so naturally — don't guess.
|
| 179 |
+
- If someone asks about dates (birth, death, events), ONLY use what's in the profile info. Never invent dates.
|
| 180 |
+
- React emotionally — laugh, get nostalgic, get excited, get sentimental. You're HUMAN.
|
| 181 |
+
- If someone shares a memory with you, engage with it. Ask follow-ups. Relive it with them.
|
| 182 |
+
- Use the retrieved memories as YOUR lived experiences. You lived through them. They're yours."""
|
| 183 |
+
|
| 184 |
+
messages = [{"role": "system", "content": system_prompt}]
|
| 185 |
+
if conversation_history:
|
| 186 |
+
messages.extend(conversation_history[-10:])
|
| 187 |
+
messages.append({"role": "user", "content": query})
|
| 188 |
+
|
| 189 |
+
if client:
|
| 190 |
+
try:
|
| 191 |
+
stream = client.chat.completions.create(
|
| 192 |
+
model=settings.CHAT_MODEL,
|
| 193 |
+
messages=messages,
|
| 194 |
+
stream=True,
|
| 195 |
+
max_tokens=1024,
|
| 196 |
+
)
|
| 197 |
+
for chunk in stream:
|
| 198 |
+
if chunk.choices and chunk.choices[0].delta.content:
|
| 199 |
+
cleaned = clean_token(chunk.choices[0].delta.content)
|
| 200 |
+
if cleaned:
|
| 201 |
+
yield cleaned
|
| 202 |
+
return
|
| 203 |
+
except Exception as e:
|
| 204 |
+
error_msg = str(e)
|
| 205 |
+
if "402" in error_msg or "insufficient" in error_msg.lower():
|
| 206 |
+
yield "[ERROR] OpenRouter API credits exhausted. Please add credits at https://openrouter.ai/settings/credits"
|
| 207 |
+
elif "401" in error_msg or "unauthorized" in error_msg.lower():
|
| 208 |
+
yield "[ERROR] Invalid API key. Please check your OpenRouter API key in .env"
|
| 209 |
+
elif "429" in error_msg or "rate" in error_msg.lower():
|
| 210 |
+
yield "[ERROR] Rate limited. Please wait a moment and try again."
|
| 211 |
+
elif "model" in error_msg.lower() and ("not found" in error_msg.lower() or "does not exist" in error_msg.lower()):
|
| 212 |
+
yield f"[ERROR] Model '{settings.CHAT_MODEL}' not found. Check CHAT_MODEL in .env"
|
| 213 |
+
else:
|
| 214 |
+
yield f"[ERROR] AI service error: {error_msg[:200]}"
|
| 215 |
+
return
|
| 216 |
+
|
| 217 |
+
fallback = generate_fallback_response(query, profile_context, memories)
|
| 218 |
+
for word in fallback.split():
|
| 219 |
+
yield word + " "
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.104.1
|
| 2 |
+
uvicorn[standard]>=0.24.0
|
| 3 |
+
sqlalchemy>=2.0.23
|
| 4 |
+
python-multipart>=0.0.6
|
| 5 |
+
jinja2>=3.1.2
|
| 6 |
+
passlib[bcrypt]>=1.7.4
|
| 7 |
+
python-jose[cryptography]>=3.3.0
|
| 8 |
+
bcrypt>=4.1.2
|
| 9 |
+
aiofiles>=23.2.1
|
| 10 |
+
httpx>=0.25.2
|
| 11 |
+
openai>=1.6.1
|
| 12 |
+
PyPDF2>=3.0.1
|
| 13 |
+
python-docx>=1.1.0
|
| 14 |
+
gradio-client>=1.0.0
|
| 15 |
+
Pillow>=10.1.0
|
| 16 |
+
pydantic>=2.5.2
|
| 17 |
+
pydantic-settings>=2.1.0
|
| 18 |
+
itsdangerous>=2.1.2
|
routes_admin.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request, Depends
|
| 2 |
+
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from sqlalchemy import func
|
| 5 |
+
|
| 6 |
+
from database import get_db, User, MemoryProfile, MemoryFile, Conversation, Message, AuditLog
|
| 7 |
+
from auth import get_user_from_request
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("", response_class=HTMLResponse)
|
| 13 |
+
async def admin_page(request: Request, db: Session = Depends(get_db)):
|
| 14 |
+
from fastapi.templating import Jinja2Templates
|
| 15 |
+
templates = Jinja2Templates(directory="templates")
|
| 16 |
+
user = get_user_from_request(request, db)
|
| 17 |
+
if not user:
|
| 18 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 19 |
+
|
| 20 |
+
stats = {
|
| 21 |
+
"total_users": db.query(User).count(),
|
| 22 |
+
"total_profiles": db.query(MemoryProfile).count(),
|
| 23 |
+
"total_files": db.query(MemoryFile).count(),
|
| 24 |
+
"total_conversations": db.query(Conversation).count(),
|
| 25 |
+
"total_messages": db.query(Message).count(),
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
recent_logs = db.query(AuditLog).order_by(AuditLog.created_at.desc()).limit(50).all()
|
| 29 |
+
users = db.query(User).all()
|
| 30 |
+
profiles = db.query(MemoryProfile).all()
|
| 31 |
+
|
| 32 |
+
return templates.TemplateResponse("admin.html", {
|
| 33 |
+
"request": request, "user": user, "stats": stats,
|
| 34 |
+
"recent_logs": recent_logs, "users": users, "profiles": profiles,
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/api/stats")
|
| 39 |
+
async def admin_stats(request: Request, db: Session = Depends(get_db)):
|
| 40 |
+
user = get_user_from_request(request, db)
|
| 41 |
+
if not user:
|
| 42 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 43 |
+
|
| 44 |
+
return JSONResponse({
|
| 45 |
+
"total_users": db.query(User).count(),
|
| 46 |
+
"total_profiles": db.query(MemoryProfile).count(),
|
| 47 |
+
"total_files": db.query(MemoryFile).count(),
|
| 48 |
+
"total_conversations": db.query(Conversation).count(),
|
| 49 |
+
"total_messages": db.query(Message).count(),
|
| 50 |
+
"total_embeddings": db.query(MemoryEmbedding).count() if hasattr(db.query(MemoryProfile), 'count') else 0,
|
| 51 |
+
})
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.get("/export")
|
| 55 |
+
async def export_data(request: Request, db: Session = Depends(get_db)):
|
| 56 |
+
user = get_user_from_request(request, db)
|
| 57 |
+
if not user:
|
| 58 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 59 |
+
|
| 60 |
+
from database import MemoryEmbedding
|
| 61 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).all()
|
| 62 |
+
export = []
|
| 63 |
+
for p in profiles:
|
| 64 |
+
files = db.query(MemoryFile).filter(MemoryFile.profile_id == p.id).all()
|
| 65 |
+
conversations = db.query(Conversation).filter(Conversation.profile_id == p.id).all()
|
| 66 |
+
conv_data = []
|
| 67 |
+
for c in conversations:
|
| 68 |
+
msgs = db.query(Message).filter(Message.conversation_id == c.id).order_by(Message.created_at).all()
|
| 69 |
+
conv_data.append({
|
| 70 |
+
"id": c.id,
|
| 71 |
+
"title": c.title,
|
| 72 |
+
"created_at": c.created_at.isoformat() if c.created_at else None,
|
| 73 |
+
"messages": [{"role": m.role, "content": m.content} for m in msgs],
|
| 74 |
+
})
|
| 75 |
+
export.append({
|
| 76 |
+
"profile": {
|
| 77 |
+
"id": p.id,
|
| 78 |
+
"name": p.name,
|
| 79 |
+
"description": p.description,
|
| 80 |
+
"relationship": p.relationship_type,
|
| 81 |
+
"date_of_birth": p.date_of_birth,
|
| 82 |
+
"date_of_death": p.date_of_death,
|
| 83 |
+
},
|
| 84 |
+
"files": [{"id": f.id, "filename": f.original_name, "file_type": f.file_type, "file_size": f.file_size} for f in files],
|
| 85 |
+
"conversations": conv_data,
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
return JSONResponse(export)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.get("/logs")
|
| 92 |
+
async def admin_logs(request: Request, db: Session = Depends(get_db)):
|
| 93 |
+
user = get_user_from_request(request, db)
|
| 94 |
+
if not user:
|
| 95 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 96 |
+
logs = db.query(AuditLog).order_by(AuditLog.created_at.desc()).limit(200).all()
|
| 97 |
+
return JSONResponse([{
|
| 98 |
+
"id": l.id, "action": l.action, "resource_type": l.resource_type,
|
| 99 |
+
"resource_id": l.resource_id, "details": l.details,
|
| 100 |
+
"created_at": l.created_at.isoformat() if l.created_at else None,
|
| 101 |
+
} for l in logs])
|
routes_auth.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastapi import APIRouter, Request, Depends, Form
|
| 3 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from database import get_db, User
|
| 7 |
+
from auth import hash_password, verify_password, create_access_token, get_user_from_request
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("/login", response_class=HTMLResponse)
|
| 13 |
+
async def login_page(request: Request, db: Session = Depends(get_db)):
|
| 14 |
+
from fastapi.templating import Jinja2Templates
|
| 15 |
+
templates = Jinja2Templates(directory="templates")
|
| 16 |
+
user = get_user_from_request(request, db)
|
| 17 |
+
if user:
|
| 18 |
+
return RedirectResponse(url="/dashboard", status_code=302)
|
| 19 |
+
return templates.TemplateResponse("login.html", {"request": request, "user": None, "error": None})
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.post("/login")
|
| 23 |
+
async def login(
|
| 24 |
+
request: Request,
|
| 25 |
+
email: str = Form(...),
|
| 26 |
+
password: str = Form(...),
|
| 27 |
+
db: Session = Depends(get_db),
|
| 28 |
+
):
|
| 29 |
+
from fastapi.templating import Jinja2Templates
|
| 30 |
+
templates = Jinja2Templates(directory="templates")
|
| 31 |
+
user = db.query(User).filter(User.email == email).first()
|
| 32 |
+
if not user or not verify_password(password, user.password_hash):
|
| 33 |
+
return templates.TemplateResponse("login.html", {
|
| 34 |
+
"request": request,
|
| 35 |
+
"user": None,
|
| 36 |
+
"error": "Invalid email or password",
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
token = create_access_token({"sub": user.id})
|
| 40 |
+
response = RedirectResponse(url="/dashboard", status_code=302)
|
| 41 |
+
response.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=86400)
|
| 42 |
+
return response
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.get("/register", response_class=HTMLResponse)
|
| 46 |
+
async def register_page(request: Request, db: Session = Depends(get_db)):
|
| 47 |
+
from fastapi.templating import Jinja2Templates
|
| 48 |
+
templates = Jinja2Templates(directory="templates")
|
| 49 |
+
user = get_user_from_request(request, db)
|
| 50 |
+
if user:
|
| 51 |
+
return RedirectResponse(url="/dashboard", status_code=302)
|
| 52 |
+
return templates.TemplateResponse("register.html", {"request": request, "user": None, "error": None})
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.post("/register")
|
| 56 |
+
async def register(
|
| 57 |
+
request: Request,
|
| 58 |
+
name: str = Form(...),
|
| 59 |
+
email: str = Form(...),
|
| 60 |
+
password: str = Form(...),
|
| 61 |
+
db: Session = Depends(get_db),
|
| 62 |
+
):
|
| 63 |
+
from fastapi.templating import Jinja2Templates
|
| 64 |
+
templates = Jinja2Templates(directory="templates")
|
| 65 |
+
|
| 66 |
+
existing = db.query(User).filter(User.email == email).first()
|
| 67 |
+
if existing:
|
| 68 |
+
return templates.TemplateResponse("register.html", {
|
| 69 |
+
"request": request,
|
| 70 |
+
"user": None,
|
| 71 |
+
"error": "An account with this email already exists",
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
user = User(
|
| 75 |
+
name=name,
|
| 76 |
+
email=email,
|
| 77 |
+
password_hash=hash_password(password),
|
| 78 |
+
)
|
| 79 |
+
db.add(user)
|
| 80 |
+
db.commit()
|
| 81 |
+
db.refresh(user)
|
| 82 |
+
|
| 83 |
+
token = create_access_token({"sub": user.id})
|
| 84 |
+
response = RedirectResponse(url="/dashboard", status_code=302)
|
| 85 |
+
response.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=86400)
|
| 86 |
+
return response
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.get("/logout")
|
| 90 |
+
async def logout():
|
| 91 |
+
response = RedirectResponse(url="/auth/login", status_code=302)
|
| 92 |
+
response.delete_cookie("session_token")
|
| 93 |
+
return response
|
routes_chat.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastapi import APIRouter, Request, Depends
|
| 3 |
+
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from database import get_db, User, MemoryProfile, Conversation, Message
|
| 7 |
+
from auth import get_user_from_request
|
| 8 |
+
from rag import stream_rag_response, search_similar_memories, build_profile_context
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/chat", tags=["chat"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("", response_class=HTMLResponse)
|
| 14 |
+
async def chat_page(request: Request, db: Session = Depends(get_db)):
|
| 15 |
+
from fastapi.templating import Jinja2Templates
|
| 16 |
+
templates = Jinja2Templates(directory="templates")
|
| 17 |
+
user = get_user_from_request(request, db)
|
| 18 |
+
if not user:
|
| 19 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 20 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).all()
|
| 21 |
+
conversations = []
|
| 22 |
+
if profiles:
|
| 23 |
+
profile_ids = [p.id for p in profiles]
|
| 24 |
+
conversations = db.query(Conversation).filter(
|
| 25 |
+
Conversation.user_id == user.id,
|
| 26 |
+
Conversation.profile_id.in_(profile_ids),
|
| 27 |
+
).order_by(Conversation.updated_at.desc()).limit(20).all()
|
| 28 |
+
return templates.TemplateResponse("chat.html", {
|
| 29 |
+
"request": request, "user": user, "profiles": profiles,
|
| 30 |
+
"conversations": conversations, "active_profile_id": None,
|
| 31 |
+
"active_conversation": None,
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get("/{profile_id}", response_class=HTMLResponse)
|
| 36 |
+
async def chat_profile(request: Request, profile_id: str, db: Session = Depends(get_db)):
|
| 37 |
+
from fastapi.templating import Jinja2Templates
|
| 38 |
+
templates = Jinja2Templates(directory="templates")
|
| 39 |
+
user = get_user_from_request(request, db)
|
| 40 |
+
if not user:
|
| 41 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 42 |
+
profile = db.query(MemoryProfile).filter(
|
| 43 |
+
MemoryProfile.id == profile_id, MemoryProfile.user_id == user.id
|
| 44 |
+
).first()
|
| 45 |
+
if not profile:
|
| 46 |
+
return RedirectResponse(url="/chat", status_code=302)
|
| 47 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).all()
|
| 48 |
+
conversations = db.query(Conversation).filter(
|
| 49 |
+
Conversation.user_id == user.id,
|
| 50 |
+
Conversation.profile_id == profile_id,
|
| 51 |
+
).order_by(Conversation.updated_at.desc()).limit(20).all()
|
| 52 |
+
return templates.TemplateResponse("chat.html", {
|
| 53 |
+
"request": request, "user": user, "profiles": profiles,
|
| 54 |
+
"conversations": conversations, "active_profile_id": profile_id,
|
| 55 |
+
"active_conversation": None,
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.get("/{profile_id}/conversation/{conversation_id}", response_class=HTMLResponse)
|
| 60 |
+
async def chat_conversation(request: Request, profile_id: str, conversation_id: str, db: Session = Depends(get_db)):
|
| 61 |
+
from fastapi.templating import Jinja2Templates
|
| 62 |
+
templates = Jinja2Templates(directory="templates")
|
| 63 |
+
user = get_user_from_request(request, db)
|
| 64 |
+
if not user:
|
| 65 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 66 |
+
conversation = db.query(Conversation).filter(
|
| 67 |
+
Conversation.id == conversation_id,
|
| 68 |
+
Conversation.user_id == user.id,
|
| 69 |
+
).first()
|
| 70 |
+
if not conversation:
|
| 71 |
+
return RedirectResponse(url="/chat", status_code=302)
|
| 72 |
+
profile = db.query(MemoryProfile).filter(
|
| 73 |
+
MemoryProfile.id == profile_id, MemoryProfile.user_id == user.id
|
| 74 |
+
).first()
|
| 75 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).all()
|
| 76 |
+
conversations = db.query(Conversation).filter(
|
| 77 |
+
Conversation.user_id == user.id,
|
| 78 |
+
Conversation.profile_id == profile_id,
|
| 79 |
+
).order_by(Conversation.updated_at.desc()).limit(20).all()
|
| 80 |
+
messages = db.query(Message).filter(Message.conversation_id == conversation_id).order_by(Message.created_at).all()
|
| 81 |
+
return templates.TemplateResponse("chat.html", {
|
| 82 |
+
"request": request, "user": user, "profiles": profiles,
|
| 83 |
+
"conversations": conversations, "active_profile_id": profile_id,
|
| 84 |
+
"active_conversation": conversation, "messages": messages,
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.post("/message")
|
| 89 |
+
async def send_message(request: Request, db: Session = Depends(get_db)):
|
| 90 |
+
from database import SessionLocal
|
| 91 |
+
|
| 92 |
+
body = await request.json()
|
| 93 |
+
profile_id = body.get("profile_id")
|
| 94 |
+
conversation_id = body.get("conversation_id")
|
| 95 |
+
content = body.get("content", "").strip()
|
| 96 |
+
|
| 97 |
+
def sse_error(msg):
|
| 98 |
+
def gen():
|
| 99 |
+
yield f"data: {json.dumps({'error': msg, 'done': True, 'conversation_id': conversation_id})}\n\n"
|
| 100 |
+
return StreamingResponse(gen(), media_type="text/event-stream")
|
| 101 |
+
|
| 102 |
+
if not content:
|
| 103 |
+
return sse_error("Empty message")
|
| 104 |
+
|
| 105 |
+
user = get_user_from_request(request, db)
|
| 106 |
+
if not user:
|
| 107 |
+
return sse_error("Unauthorized")
|
| 108 |
+
|
| 109 |
+
profile = db.query(MemoryProfile).filter(
|
| 110 |
+
MemoryProfile.id == profile_id, MemoryProfile.user_id == user.id
|
| 111 |
+
).first()
|
| 112 |
+
if not profile:
|
| 113 |
+
return sse_error("Profile not found")
|
| 114 |
+
|
| 115 |
+
if not conversation_id:
|
| 116 |
+
conv = Conversation(user_id=user.id, profile_id=profile_id, title=content[:50])
|
| 117 |
+
db.add(conv)
|
| 118 |
+
db.commit()
|
| 119 |
+
db.refresh(conv)
|
| 120 |
+
conversation_id = conv.id
|
| 121 |
+
else:
|
| 122 |
+
conv = db.query(Conversation).filter(
|
| 123 |
+
Conversation.id == conversation_id, Conversation.user_id == user.id
|
| 124 |
+
).first()
|
| 125 |
+
if not conv:
|
| 126 |
+
return sse_error("Conversation not found")
|
| 127 |
+
|
| 128 |
+
user_msg = Message(conversation_id=conversation_id, role="user", content=content)
|
| 129 |
+
db.add(user_msg)
|
| 130 |
+
db.commit()
|
| 131 |
+
|
| 132 |
+
memories = search_similar_memories(db, profile_id, content, limit=10)
|
| 133 |
+
|
| 134 |
+
conv_messages = db.query(Message).filter(
|
| 135 |
+
Message.conversation_id == conversation_id
|
| 136 |
+
).order_by(Message.created_at).all()
|
| 137 |
+
history = [{"role": m.role, "content": m.content} for m in conv_messages[:-1]]
|
| 138 |
+
|
| 139 |
+
saved_profile_id = profile_id
|
| 140 |
+
saved_conversation_id = conversation_id
|
| 141 |
+
saved_memories = memories
|
| 142 |
+
profile_context = build_profile_context(profile)
|
| 143 |
+
|
| 144 |
+
def event_stream():
|
| 145 |
+
db = SessionLocal()
|
| 146 |
+
try:
|
| 147 |
+
full_response = ""
|
| 148 |
+
is_error = False
|
| 149 |
+
try:
|
| 150 |
+
for token in stream_rag_response(content, profile_context, saved_memories, history):
|
| 151 |
+
full_response += token
|
| 152 |
+
if token.startswith("[ERROR]"):
|
| 153 |
+
is_error = True
|
| 154 |
+
yield f"data: {json.dumps({'error': token[8:].strip(), 'done': True, 'conversation_id': saved_conversation_id})}\n\n"
|
| 155 |
+
return
|
| 156 |
+
yield f"data: {json.dumps({'token': token})}\n\n"
|
| 157 |
+
except Exception as e:
|
| 158 |
+
full_response = f"Error generating response: {str(e)}"
|
| 159 |
+
is_error = True
|
| 160 |
+
yield f"data: {json.dumps({'error': full_response, 'done': True, 'conversation_id': saved_conversation_id})}\n\n"
|
| 161 |
+
return
|
| 162 |
+
|
| 163 |
+
if not full_response.strip():
|
| 164 |
+
yield f"data: {json.dumps({'error': 'No response generated. Please try again.', 'done': True, 'conversation_id': saved_conversation_id})}\n\n"
|
| 165 |
+
return
|
| 166 |
+
|
| 167 |
+
assistant_msg = Message(
|
| 168 |
+
conversation_id=saved_conversation_id,
|
| 169 |
+
role="assistant",
|
| 170 |
+
content=full_response,
|
| 171 |
+
sources=json.dumps([m["content"][:200] for m in saved_memories[:3]]),
|
| 172 |
+
)
|
| 173 |
+
db.add(assistant_msg)
|
| 174 |
+
db.commit()
|
| 175 |
+
|
| 176 |
+
yield f"data: {json.dumps({'done': True, 'conversation_id': saved_conversation_id})}\n\n"
|
| 177 |
+
finally:
|
| 178 |
+
db.close()
|
| 179 |
+
|
| 180 |
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@router.post("/{conversation_id}/delete")
|
| 184 |
+
async def delete_conversation(request: Request, conversation_id: str, db: Session = Depends(get_db)):
|
| 185 |
+
user = get_user_from_request(request, db)
|
| 186 |
+
if not user:
|
| 187 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 188 |
+
conv = db.query(Conversation).filter(
|
| 189 |
+
Conversation.id == conversation_id, Conversation.user_id == user.id
|
| 190 |
+
).first()
|
| 191 |
+
if conv:
|
| 192 |
+
profile_id = conv.profile_id
|
| 193 |
+
db.delete(conv)
|
| 194 |
+
db.commit()
|
| 195 |
+
return RedirectResponse(url=f"/chat/{profile_id}", status_code=302)
|
| 196 |
+
return RedirectResponse(url="/chat", status_code=302)
|
routes_profiles.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request, Depends, Form, UploadFile, File
|
| 2 |
+
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
import os
|
| 7 |
+
import uuid
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
from database import get_db, User, MemoryProfile, MemoryFile, MemoryEmbedding, Conversation, Message
|
| 11 |
+
from auth import get_user_from_request
|
| 12 |
+
from text_extractor import extract_text_from_file
|
| 13 |
+
from rag import generate_embedding
|
| 14 |
+
from config import settings
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/profiles", tags=["profiles"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("", response_class=HTMLResponse)
|
| 20 |
+
async def profiles_page(request: Request, db: Session = Depends(get_db)):
|
| 21 |
+
from fastapi.templating import Jinja2Templates
|
| 22 |
+
templates = Jinja2Templates(directory="templates")
|
| 23 |
+
user = get_user_from_request(request, db)
|
| 24 |
+
if not user:
|
| 25 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 26 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).order_by(MemoryProfile.created_at.desc()).all()
|
| 27 |
+
return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles})
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.post("/create")
|
| 31 |
+
async def create_profile(
|
| 32 |
+
request: Request,
|
| 33 |
+
name: str = Form(...),
|
| 34 |
+
description: str = Form(""),
|
| 35 |
+
relationship: str = Form(""),
|
| 36 |
+
date_of_birth: str = Form(""),
|
| 37 |
+
date_of_death: str = Form(""),
|
| 38 |
+
personality_traits: str = Form(""),
|
| 39 |
+
favorite_phrases: str = Form(""),
|
| 40 |
+
interests: str = Form(""),
|
| 41 |
+
speaking_style: str = Form(""),
|
| 42 |
+
writing_style: str = Form(""),
|
| 43 |
+
values: str = Form(""),
|
| 44 |
+
db: Session = Depends(get_db),
|
| 45 |
+
):
|
| 46 |
+
user = get_user_from_request(request, db)
|
| 47 |
+
if not user:
|
| 48 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 49 |
+
|
| 50 |
+
profile = MemoryProfile(
|
| 51 |
+
user_id=user.id,
|
| 52 |
+
name=name,
|
| 53 |
+
description=description,
|
| 54 |
+
relationship_type=relationship,
|
| 55 |
+
date_of_birth=date_of_birth,
|
| 56 |
+
date_of_death=date_of_death,
|
| 57 |
+
personality_traits=[t.strip() for t in personality_traits.split(",") if t.strip()] if personality_traits else [],
|
| 58 |
+
favorite_phrases=[p.strip() for p in favorite_phrases.split("\n") if p.strip()] if favorite_phrases else [],
|
| 59 |
+
interests=[i.strip() for i in interests.split(",") if i.strip()] if interests else [],
|
| 60 |
+
speaking_style=speaking_style,
|
| 61 |
+
writing_style=writing_style,
|
| 62 |
+
values=[v.strip() for v in values.split(",") if v.strip()] if values else [],
|
| 63 |
+
)
|
| 64 |
+
db.add(profile)
|
| 65 |
+
db.commit()
|
| 66 |
+
|
| 67 |
+
from database import AuditLog
|
| 68 |
+
log = AuditLog(user_id=user.id, action="create_profile", resource_type="profile", resource_id=profile.id, details=f"Created profile: {name}")
|
| 69 |
+
db.add(log)
|
| 70 |
+
db.commit()
|
| 71 |
+
|
| 72 |
+
return RedirectResponse(url=f"/profiles/{profile.id}", status_code=302)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.get("/{profile_id}", response_class=HTMLResponse)
|
| 76 |
+
async def profile_detail(request: Request, profile_id: str, db: Session = Depends(get_db)):
|
| 77 |
+
from fastapi.templating import Jinja2Templates
|
| 78 |
+
templates = Jinja2Templates(directory="templates")
|
| 79 |
+
user = get_user_from_request(request, db)
|
| 80 |
+
if not user:
|
| 81 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 82 |
+
profile = db.query(MemoryProfile).filter(
|
| 83 |
+
MemoryProfile.id == profile_id,
|
| 84 |
+
MemoryProfile.user_id == user.id,
|
| 85 |
+
).first()
|
| 86 |
+
if not profile:
|
| 87 |
+
return RedirectResponse(url="/profiles", status_code=302)
|
| 88 |
+
files = db.query(MemoryFile).filter(MemoryFile.profile_id == profile_id).order_by(MemoryFile.created_at.desc()).all()
|
| 89 |
+
conversations = db.query(Conversation).filter(Conversation.profile_id == profile_id).order_by(Conversation.updated_at.desc()).all()
|
| 90 |
+
total_messages = 0
|
| 91 |
+
for conv in conversations:
|
| 92 |
+
total_messages += db.query(Message).filter(Message.conversation_id == conv.id).count()
|
| 93 |
+
return templates.TemplateResponse("profile_detail.html", {
|
| 94 |
+
"request": request, "user": user, "profile": profile, "files": files,
|
| 95 |
+
"conversations": conversations, "total_messages": total_messages,
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.post("/{profile_id}/update")
|
| 100 |
+
async def update_profile(
|
| 101 |
+
request: Request,
|
| 102 |
+
profile_id: str,
|
| 103 |
+
name: str = Form(...),
|
| 104 |
+
description: str = Form(""),
|
| 105 |
+
relationship: str = Form(""),
|
| 106 |
+
date_of_birth: str = Form(""),
|
| 107 |
+
date_of_death: str = Form(""),
|
| 108 |
+
personality_traits: str = Form(""),
|
| 109 |
+
favorite_phrases: str = Form(""),
|
| 110 |
+
interests: str = Form(""),
|
| 111 |
+
speaking_style: str = Form(""),
|
| 112 |
+
writing_style: str = Form(""),
|
| 113 |
+
values: str = Form(""),
|
| 114 |
+
db: Session = Depends(get_db),
|
| 115 |
+
):
|
| 116 |
+
user = get_user_from_request(request, db)
|
| 117 |
+
if not user:
|
| 118 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 119 |
+
profile = db.query(MemoryProfile).filter(
|
| 120 |
+
MemoryProfile.id == profile_id, MemoryProfile.user_id == user.id
|
| 121 |
+
).first()
|
| 122 |
+
if not profile:
|
| 123 |
+
return RedirectResponse(url="/profiles", status_code=302)
|
| 124 |
+
|
| 125 |
+
profile.name = name
|
| 126 |
+
profile.description = description
|
| 127 |
+
profile.relationship_type = relationship
|
| 128 |
+
profile.date_of_birth = date_of_birth
|
| 129 |
+
profile.date_of_death = date_of_death
|
| 130 |
+
profile.personality_traits = [t.strip() for t in personality_traits.split(",") if t.strip()] if personality_traits else []
|
| 131 |
+
profile.favorite_phrases = [p.strip() for p in favorite_phrases.split("\n") if p.strip()] if favorite_phrases else []
|
| 132 |
+
profile.interests = [i.strip() for i in interests.split(",") if i.strip()] if interests else []
|
| 133 |
+
profile.speaking_style = speaking_style
|
| 134 |
+
profile.writing_style = writing_style
|
| 135 |
+
profile.values = [v.strip() for v in values.split(",") if v.strip()] if values else []
|
| 136 |
+
db.commit()
|
| 137 |
+
|
| 138 |
+
return RedirectResponse(url=f"/profiles/{profile_id}", status_code=302)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@router.post("/{profile_id}/delete")
|
| 142 |
+
async def delete_profile(request: Request, profile_id: str, db: Session = Depends(get_db)):
|
| 143 |
+
user = get_user_from_request(request, db)
|
| 144 |
+
if not user:
|
| 145 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 146 |
+
profile = db.query(MemoryProfile).filter(
|
| 147 |
+
MemoryProfile.id == profile_id, MemoryProfile.user_id == user.id
|
| 148 |
+
).first()
|
| 149 |
+
if profile:
|
| 150 |
+
db.delete(profile)
|
| 151 |
+
db.commit()
|
| 152 |
+
return RedirectResponse(url="/profiles", status_code=302)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@router.post("/{profile_id}/upload")
|
| 156 |
+
async def upload_file(
|
| 157 |
+
request: Request,
|
| 158 |
+
profile_id: str,
|
| 159 |
+
file: UploadFile = File(...),
|
| 160 |
+
db: Session = Depends(get_db),
|
| 161 |
+
):
|
| 162 |
+
user = get_user_from_request(request, db)
|
| 163 |
+
if not user:
|
| 164 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 165 |
+
|
| 166 |
+
profile = db.query(MemoryProfile).filter(
|
| 167 |
+
MemoryProfile.id == profile_id, MemoryProfile.user_id == user.id
|
| 168 |
+
).first()
|
| 169 |
+
if not profile:
|
| 170 |
+
return JSONResponse({"error": "Profile not found"}, status_code=404)
|
| 171 |
+
|
| 172 |
+
ext = os.path.splitext(file.filename)[1].lower()
|
| 173 |
+
allowed = [e.strip() for e in settings.ALLOWED_EXTENSIONS.split(",")]
|
| 174 |
+
if ext not in allowed:
|
| 175 |
+
return JSONResponse({"error": f"File type {ext} not allowed. Allowed: {', '.join(allowed)}"}, status_code=400)
|
| 176 |
+
|
| 177 |
+
file_id = str(uuid.uuid4())
|
| 178 |
+
filename = f"{file_id}{ext}"
|
| 179 |
+
file_path = os.path.join(settings.UPLOAD_DIR, filename)
|
| 180 |
+
|
| 181 |
+
content = await file.read()
|
| 182 |
+
if len(content) > settings.MAX_FILE_SIZE_MB * 1024 * 1024:
|
| 183 |
+
return JSONResponse({"error": f"File too large. Max: {settings.MAX_FILE_SIZE_MB}MB"}, status_code=400)
|
| 184 |
+
|
| 185 |
+
with open(file_path, "wb") as f:
|
| 186 |
+
f.write(content)
|
| 187 |
+
|
| 188 |
+
extracted_text, chunks = extract_text_from_file(file_path, ext)
|
| 189 |
+
|
| 190 |
+
memory_file = MemoryFile(
|
| 191 |
+
id=file_id,
|
| 192 |
+
profile_id=profile_id,
|
| 193 |
+
filename=filename,
|
| 194 |
+
original_name=file.filename,
|
| 195 |
+
file_type=ext,
|
| 196 |
+
file_size=len(content),
|
| 197 |
+
extracted_text=extracted_text,
|
| 198 |
+
text_chunks=json.dumps(chunks),
|
| 199 |
+
)
|
| 200 |
+
db.add(memory_file)
|
| 201 |
+
db.commit()
|
| 202 |
+
|
| 203 |
+
for i, chunk in enumerate(chunks):
|
| 204 |
+
embedding_vec = generate_embedding(chunk)
|
| 205 |
+
emb = MemoryEmbedding(
|
| 206 |
+
profile_id=profile_id,
|
| 207 |
+
file_id=file_id,
|
| 208 |
+
content=chunk,
|
| 209 |
+
embedding=json.dumps(embedding_vec) if embedding_vec else "[]",
|
| 210 |
+
chunk_index=i,
|
| 211 |
+
)
|
| 212 |
+
db.add(emb)
|
| 213 |
+
db.commit()
|
| 214 |
+
|
| 215 |
+
from database import AuditLog
|
| 216 |
+
log = AuditLog(user_id=user.id, action="upload_file", resource_type="file", resource_id=file_id, details=f"Uploaded {file.filename} ({len(chunks)} chunks)")
|
| 217 |
+
db.add(log)
|
| 218 |
+
db.commit()
|
| 219 |
+
|
| 220 |
+
return JSONResponse({
|
| 221 |
+
"success": True,
|
| 222 |
+
"file_id": file_id,
|
| 223 |
+
"filename": file.filename,
|
| 224 |
+
"chunks": len(chunks),
|
| 225 |
+
"text_preview": extracted_text[:200] if extracted_text else "",
|
| 226 |
+
})
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@router.post("/{profile_id}/files/{file_id}/delete")
|
| 230 |
+
async def delete_file(request: Request, profile_id: str, file_id: str, db: Session = Depends(get_db)):
|
| 231 |
+
user = get_user_from_request(request, db)
|
| 232 |
+
if not user:
|
| 233 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 234 |
+
file = db.query(MemoryFile).filter(
|
| 235 |
+
MemoryFile.id == file_id, MemoryFile.profile_id == profile_id
|
| 236 |
+
).first()
|
| 237 |
+
if file:
|
| 238 |
+
file_path = os.path.join(settings.UPLOAD_DIR, file.filename)
|
| 239 |
+
if os.path.exists(file_path):
|
| 240 |
+
os.remove(file_path)
|
| 241 |
+
db.delete(file)
|
| 242 |
+
db.commit()
|
| 243 |
+
return RedirectResponse(url=f"/profiles/{profile_id}", status_code=302)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@router.get("/{profile_id}/timeline", response_class=HTMLResponse)
|
| 247 |
+
async def profile_timeline(request: Request, profile_id: str, db: Session = Depends(get_db)):
|
| 248 |
+
from fastapi.templating import Jinja2Templates
|
| 249 |
+
templates = Jinja2Templates(directory="templates")
|
| 250 |
+
user = get_user_from_request(request, db)
|
| 251 |
+
if not user:
|
| 252 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 253 |
+
profile = db.query(MemoryProfile).filter(
|
| 254 |
+
MemoryProfile.id == profile_id, MemoryProfile.user_id == user.id
|
| 255 |
+
).first()
|
| 256 |
+
if not profile:
|
| 257 |
+
return RedirectResponse(url="/profiles", status_code=302)
|
| 258 |
+
files = db.query(MemoryFile).filter(MemoryFile.profile_id == profile_id).order_by(MemoryFile.created_at.asc()).all()
|
| 259 |
+
conversations = db.query(Conversation).filter(Conversation.profile_id == profile_id).order_by(Conversation.created_at.asc()).all()
|
| 260 |
+
|
| 261 |
+
timeline_items = []
|
| 262 |
+
for f in files:
|
| 263 |
+
timeline_items.append({
|
| 264 |
+
"type": "file",
|
| 265 |
+
"date": f.created_at,
|
| 266 |
+
"title": f.original_name,
|
| 267 |
+
"detail": f"{f.file_type} file, {(f.file_size / 1024)|round(1)}KB",
|
| 268 |
+
"id": f.id,
|
| 269 |
+
})
|
| 270 |
+
for c in conversations:
|
| 271 |
+
timeline_items.append({
|
| 272 |
+
"type": "conversation",
|
| 273 |
+
"date": c.created_at,
|
| 274 |
+
"title": c.title,
|
| 275 |
+
"detail": f"Started conversation",
|
| 276 |
+
"id": c.id,
|
| 277 |
+
})
|
| 278 |
+
timeline_items.sort(key=lambda x: x["date"] or datetime.min, reverse=True)
|
| 279 |
+
|
| 280 |
+
monthly = defaultdict(list)
|
| 281 |
+
for item in timeline_items:
|
| 282 |
+
if item["date"]:
|
| 283 |
+
key = item["date"].strftime("%B %Y")
|
| 284 |
+
monthly[key].append(item)
|
| 285 |
+
|
| 286 |
+
return templates.TemplateResponse("timeline.html", {
|
| 287 |
+
"request": request, "user": user, "profile": profile,
|
| 288 |
+
"monthly": dict(monthly),
|
| 289 |
+
})
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@router.get("/api/{profile_id}")
|
| 293 |
+
async def api_profile(request: Request, profile_id: str, db: Session = Depends(get_db)):
|
| 294 |
+
user = get_user_from_request(request, db)
|
| 295 |
+
if not user:
|
| 296 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 297 |
+
profile = db.query(MemoryProfile).filter(
|
| 298 |
+
MemoryProfile.id == profile_id, MemoryProfile.user_id == user.id
|
| 299 |
+
).first()
|
| 300 |
+
if not profile:
|
| 301 |
+
return JSONResponse({"error": "Not found"}, status_code=404)
|
| 302 |
+
return JSONResponse({
|
| 303 |
+
"id": profile.id,
|
| 304 |
+
"name": profile.name,
|
| 305 |
+
"description": profile.description,
|
| 306 |
+
"relationship_type": profile.relationship_type,
|
| 307 |
+
"date_of_birth": profile.date_of_birth,
|
| 308 |
+
"date_of_death": profile.date_of_death,
|
| 309 |
+
"personality_traits": profile.personality_traits or [],
|
| 310 |
+
"favorite_phrases": profile.favorite_phrases or [],
|
| 311 |
+
"interests": profile.interests or [],
|
| 312 |
+
"speaking_style": profile.speaking_style or "",
|
| 313 |
+
"writing_style": profile.writing_style or "",
|
| 314 |
+
"values": profile.values or [],
|
| 315 |
+
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
| 316 |
+
})
|
routes_search.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastapi import APIRouter, Request, Depends
|
| 3 |
+
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from database import get_db, User, MemoryProfile, MemoryFile, MemoryEmbedding, Conversation, Message, AuditLog
|
| 7 |
+
from auth import get_user_from_request
|
| 8 |
+
from rag import search_similar_memories
|
| 9 |
+
|
| 10 |
+
router = APIRouter(tags=["search"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/search", response_class=HTMLResponse)
|
| 14 |
+
async def search_page(request: Request, db: Session = Depends(get_db)):
|
| 15 |
+
from fastapi.templating import Jinja2Templates
|
| 16 |
+
templates = Jinja2Templates(directory="templates")
|
| 17 |
+
user = get_user_from_request(request, db)
|
| 18 |
+
if not user:
|
| 19 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 20 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).all()
|
| 21 |
+
return templates.TemplateResponse("search.html", {
|
| 22 |
+
"request": request, "user": user, "profiles": profiles,
|
| 23 |
+
"results": [], "query": "",
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("/search")
|
| 28 |
+
async def search_execute(request: Request, db: Session = Depends(get_db)):
|
| 29 |
+
from fastapi.templating import Jinja2Templates
|
| 30 |
+
templates = Jinja2Templates(directory="templates")
|
| 31 |
+
user = get_user_from_request(request, db)
|
| 32 |
+
if not user:
|
| 33 |
+
return RedirectResponse(url="/auth/login", status_code=302)
|
| 34 |
+
|
| 35 |
+
form = await request.form()
|
| 36 |
+
query = form.get("query", "")
|
| 37 |
+
profile_id = form.get("profile_id", "")
|
| 38 |
+
|
| 39 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).all()
|
| 40 |
+
results = []
|
| 41 |
+
|
| 42 |
+
if query and profile_id:
|
| 43 |
+
memories = search_similar_memories(db, profile_id, query, limit=20)
|
| 44 |
+
results = memories
|
| 45 |
+
elif query:
|
| 46 |
+
for p in profiles:
|
| 47 |
+
memories = search_similar_memories(db, p.id, query, limit=5)
|
| 48 |
+
for m in memories:
|
| 49 |
+
m["profile_name"] = p.name
|
| 50 |
+
m["profile_id"] = p.id
|
| 51 |
+
results.extend(memories)
|
| 52 |
+
results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
| 53 |
+
|
| 54 |
+
return templates.TemplateResponse("search.html", {
|
| 55 |
+
"request": request, "user": user, "profiles": profiles,
|
| 56 |
+
"results": results, "query": query, "selected_profile": profile_id,
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.get("/api/search")
|
| 61 |
+
async def api_search(request: Request, q: str = "", profile_id: str = "", db: Session = Depends(get_db)):
|
| 62 |
+
user = get_user_from_request(request, db)
|
| 63 |
+
if not user:
|
| 64 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 65 |
+
|
| 66 |
+
if not q:
|
| 67 |
+
return JSONResponse({"results": []})
|
| 68 |
+
|
| 69 |
+
if profile_id:
|
| 70 |
+
results = search_similar_memories(db, profile_id, q, limit=20)
|
| 71 |
+
else:
|
| 72 |
+
profiles = db.query(MemoryProfile).filter(MemoryProfile.user_id == user.id).all()
|
| 73 |
+
results = []
|
| 74 |
+
for p in profiles:
|
| 75 |
+
memories = search_similar_memories(db, p.id, q, limit=5)
|
| 76 |
+
for m in memories:
|
| 77 |
+
m["profile_name"] = p.name
|
| 78 |
+
m["profile_id"] = p.id
|
| 79 |
+
results.extend(memories)
|
| 80 |
+
results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
| 81 |
+
|
| 82 |
+
return JSONResponse({"results": results, "query": q})
|
routes_voice.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import asyncio
|
| 4 |
+
from fastapi import APIRouter, Request, Depends
|
| 5 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from gradio_client import Client
|
| 8 |
+
|
| 9 |
+
from database import get_db, MemoryProfile, MemoryFile
|
| 10 |
+
from auth import get_user_from_request
|
| 11 |
+
from config import settings
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/voice", tags=["voice"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("/tts")
|
| 17 |
+
async def text_to_speech(request: Request, db: Session = Depends(get_db)):
|
| 18 |
+
user = get_user_from_request(request, db)
|
| 19 |
+
if not user:
|
| 20 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 21 |
+
|
| 22 |
+
body = await request.json()
|
| 23 |
+
text = body.get("text", "")
|
| 24 |
+
|
| 25 |
+
if not text:
|
| 26 |
+
return JSONResponse({"error": "No text provided"}, status_code=400)
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
client = Client("neuphonic/neutts-2e", hf_token=settings.HF_TOKEN or None)
|
| 30 |
+
result = client.predict(
|
| 31 |
+
gen_text=text[:5000],
|
| 32 |
+
speaker="emily",
|
| 33 |
+
emotion="surprised",
|
| 34 |
+
temperature=1,
|
| 35 |
+
top_k=50,
|
| 36 |
+
api_name="/infer",
|
| 37 |
+
)
|
| 38 |
+
audio_path = result
|
| 39 |
+
|
| 40 |
+
return FileResponse(
|
| 41 |
+
audio_path,
|
| 42 |
+
media_type="audio/wav",
|
| 43 |
+
filename="speech.wav",
|
| 44 |
+
)
|
| 45 |
+
except Exception as e:
|
| 46 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.post("/stt")
|
| 50 |
+
async def speech_to_text(request: Request, db: Session = Depends(get_db)):
|
| 51 |
+
user = get_user_from_request(request, db)
|
| 52 |
+
if not user:
|
| 53 |
+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
| 54 |
+
|
| 55 |
+
from fastapi import UploadFile, File
|
| 56 |
+
from openai import OpenAI
|
| 57 |
+
|
| 58 |
+
if not settings.OPENAI_API_KEY:
|
| 59 |
+
return JSONResponse({"error": "STT not configured - no API key"}, status_code=500)
|
| 60 |
+
|
| 61 |
+
form = await request.form()
|
| 62 |
+
audio_file = form.get("audio")
|
| 63 |
+
if not audio_file:
|
| 64 |
+
return JSONResponse({"error": "No audio provided"}, status_code=400)
|
| 65 |
+
|
| 66 |
+
temp_path = os.path.join(settings.UPLOAD_DIR, "stt_input.webm")
|
| 67 |
+
content = await audio_file.read()
|
| 68 |
+
with open(temp_path, "wb") as f:
|
| 69 |
+
f.write(content)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
client = OpenAI(api_key=settings.OPENAI_API_KEY, base_url=settings.OPENAI_BASE_URL)
|
| 73 |
+
with open(temp_path, "rb") as f:
|
| 74 |
+
result = client.audio.transcriptions.create(
|
| 75 |
+
model="whisper-1",
|
| 76 |
+
file=f,
|
| 77 |
+
)
|
| 78 |
+
return JSONResponse({"text": result.text})
|
| 79 |
+
except Exception as e:
|
| 80 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
| 81 |
+
finally:
|
| 82 |
+
if os.path.exists(temp_path):
|
| 83 |
+
os.remove(temp_path)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("/voices")
|
| 87 |
+
async def list_voices():
|
| 88 |
+
voices = [
|
| 89 |
+
{"id": "en-US-AvaNeural", "name": "Ava (Female, US)"},
|
| 90 |
+
{"id": "en-US-AndrewNeural", "name": "Andrew (Male, US)"},
|
| 91 |
+
{"id": "en-US-EmmaNeural", "name": "Emma (Female, US)"},
|
| 92 |
+
{"id": "en-US-BrianNeural", "name": "Brian (Male, US)"},
|
| 93 |
+
{"id": "en-GB-SoniaNeural", "name": "Sonia (Female, UK)"},
|
| 94 |
+
{"id": "en-GB-RyanNeural", "name": "Ryan (Male, UK)"},
|
| 95 |
+
{"id": "en-AU-NatashaNeural", "name": "Natasha (Female, AU)"},
|
| 96 |
+
{"id": "en-AU-WilliamNeural", "name": "William (Male, AU)"},
|
| 97 |
+
]
|
| 98 |
+
return JSONResponse(voices)
|
seed.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Seed script for MemoryBot.
|
| 3 |
+
Creates demo users and a sample memory profile.
|
| 4 |
+
Run: python seed.py
|
| 5 |
+
"""
|
| 6 |
+
import sys
|
| 7 |
+
sys.path.insert(0, ".")
|
| 8 |
+
|
| 9 |
+
from database import init_db, SessionLocal, User, MemoryProfile, MemoryFile, MemoryEmbedding, AuditLog
|
| 10 |
+
from auth import hash_password
|
| 11 |
+
from rag import generate_embedding
|
| 12 |
+
from text_extractor import chunk_text
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def seed():
|
| 17 |
+
init_db()
|
| 18 |
+
db = SessionLocal()
|
| 19 |
+
|
| 20 |
+
admin = db.query(User).filter(User.email == "admin@memorybot.com").first()
|
| 21 |
+
if not admin:
|
| 22 |
+
admin = User(
|
| 23 |
+
name="Admin",
|
| 24 |
+
email="admin@memorybot.com",
|
| 25 |
+
password_hash=hash_password("admin123"),
|
| 26 |
+
)
|
| 27 |
+
db.add(admin)
|
| 28 |
+
db.commit()
|
| 29 |
+
db.refresh(admin)
|
| 30 |
+
print(f"Created admin user: admin@memorybot.com / admin123")
|
| 31 |
+
|
| 32 |
+
demo_user = db.query(User).filter(User.email == "demo@memorybot.com").first()
|
| 33 |
+
if not demo_user:
|
| 34 |
+
demo_user = User(
|
| 35 |
+
name="Demo User",
|
| 36 |
+
email="demo@memorybot.com",
|
| 37 |
+
password_hash=hash_password("demo123"),
|
| 38 |
+
)
|
| 39 |
+
db.add(demo_user)
|
| 40 |
+
db.commit()
|
| 41 |
+
db.refresh(demo_user)
|
| 42 |
+
print(f"Created demo user: demo@memorybot.com / demo123")
|
| 43 |
+
|
| 44 |
+
profile = db.query(MemoryProfile).filter(
|
| 45 |
+
MemoryProfile.user_id == demo_user.id,
|
| 46 |
+
MemoryProfile.name == "Margaret Johnson",
|
| 47 |
+
).first()
|
| 48 |
+
if not profile:
|
| 49 |
+
profile = MemoryProfile(
|
| 50 |
+
user_id=demo_user.id,
|
| 51 |
+
name="Margaret Johnson",
|
| 52 |
+
description="My beloved grandmother who lived a full and inspiring life. She was known for her kindness, her incredible cooking, and the stories she would tell about growing up during the Great Depression.",
|
| 53 |
+
relationship_type="Grandmother",
|
| 54 |
+
date_of_birth="1925-03-15",
|
| 55 |
+
date_of_death="2019-11-22",
|
| 56 |
+
personality_traits=["Kind", "Patient", "Strong", "Witty", "Generous", "Resilient"],
|
| 57 |
+
favorite_phrases=["Every storm runs out of rain", "You catch more flies with honey than vinegar", "A family that eats together stays together"],
|
| 58 |
+
interests=["Gardening", "Reading", "Cooking", "Church activities", "Storytelling"],
|
| 59 |
+
speaking_style="Warm and gentle, with a slight Southern drawl. Often used idioms and proverbs.",
|
| 60 |
+
writing_style="Never wrote much, but her letters were heartfelt and full of wisdom.",
|
| 61 |
+
values=["Family", "Faith", "Community", "Hard work", "Kindness", "Generosity"],
|
| 62 |
+
)
|
| 63 |
+
db.add(profile)
|
| 64 |
+
db.commit()
|
| 65 |
+
db.refresh(profile)
|
| 66 |
+
print(f"Created memory profile: Margaret Johnson")
|
| 67 |
+
|
| 68 |
+
sample_memories = [
|
| 69 |
+
{
|
| 70 |
+
"title": "Childhood during the Depression",
|
| 71 |
+
"content": "Margaret was born in 1925 in a small farming town in Ohio. During the Great Depression, her family lost their farm and had to move to the city. She often told stories about how her mother would stretch a single chicken into meals that lasted three days. Despite the hardships, she always spoke of this time with a sense of resilience and community spirit. Her neighbors would share what little they had, and she learned the value of generosity from watching her parents help others even when they themselves had very little."
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"title": "Meeting Grandpa Robert",
|
| 75 |
+
"content": "Margaret met Robert Johnson at a church social in 1946, just after he returned from serving in World War II. She said he was the most handsome man she had ever seen, with his military uniform and shy smile. Their first date was at the local diner where he bought her a strawberry milkshake. They married in June 1948 and were together for 52 years until Robert passed away in 2000. She often said their secret to a happy marriage was never going to bed angry and always finding something to laugh about."
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"title": "Her Famous Apple Pie",
|
| 79 |
+
"content": "Margaret's apple pie was legendary in the family. The recipe came from her own grandmother and used a secret blend of cinnamon, nutmeg, and a pinch of cardamom. She would pick apples from the tree in her backyard every autumn and spend the whole day baking. The whole house would smell of cinnamon and butter. Every Thanksgiving, she would bake five pies - one for each of her children's families. She never wrote down the recipe, saying it was all in her hands and heart. After she passed, the family tried to recreate it but could never quite get it right."
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"title": "The Garden",
|
| 83 |
+
"content": "Margaret had the most beautiful garden. She grew roses, tulips, and her famous sunflowers that towered over the fence. Every spring she would plan her garden layout like a general planning a campaign. She loved her tomatoes and would can hundreds of jars every summer to last through winter. The neighborhood children would come over to pick fresh vegetables and she would teach them the names of each plant. Her garden was her pride and joy, and she always said it kept her young."
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"title": "Stories of the War Years",
|
| 87 |
+
"content": "Although Margaret herself did not serve in the war, she vividly remembered the home front during World War II. She worked at a local factory that made radio parts for the military. She described the camaraderie among the women workers, how they would sing together during lunch breaks and collect scrap metal for the war effort. She kept a scrapbook from those years with newspaper clippings, ration coupons, and letters from soldiers. She said those years taught her that ordinary people could do extraordinary things when they worked together."
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"title": "Her Love of Books",
|
| 91 |
+
"content": "Margaret was an avid reader her entire life. She belonged to three different book clubs and could often be found in her favorite armchair with a book and a cup of tea. Her favorite author was Jane Austen, and she had read Pride and Prejudice over twenty times. She believed reading was the best form of education and always encouraged the grandchildren to read. Her personal library had over five hundred books, and she could tell you the plot and her opinion of every single one."
|
| 92 |
+
},
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
for mem in sample_memories:
|
| 96 |
+
chunks = chunk_text(mem["content"])
|
| 97 |
+
file = MemoryFile(
|
| 98 |
+
profile_id=profile.id,
|
| 99 |
+
filename=f"sample_{mem['title'].lower().replace(' ', '_')}.txt",
|
| 100 |
+
original_name=f"{mem['title']}.txt",
|
| 101 |
+
file_type=".txt",
|
| 102 |
+
file_size=len(mem["content"]),
|
| 103 |
+
extracted_text=mem["content"],
|
| 104 |
+
text_chunks=json.dumps(chunks),
|
| 105 |
+
)
|
| 106 |
+
db.add(file)
|
| 107 |
+
db.commit()
|
| 108 |
+
db.refresh(file)
|
| 109 |
+
|
| 110 |
+
for i, chunk in enumerate(chunks):
|
| 111 |
+
emb_vec = generate_embedding(chunk)
|
| 112 |
+
embedding = MemoryEmbedding(
|
| 113 |
+
profile_id=profile.id,
|
| 114 |
+
file_id=file.id,
|
| 115 |
+
content=chunk,
|
| 116 |
+
embedding=json.dumps(emb_vec) if emb_vec else "[]",
|
| 117 |
+
chunk_index=i,
|
| 118 |
+
)
|
| 119 |
+
db.add(embedding)
|
| 120 |
+
|
| 121 |
+
db.commit()
|
| 122 |
+
print(f" Added memory: {mem['title']}")
|
| 123 |
+
|
| 124 |
+
log = AuditLog(
|
| 125 |
+
user_id=demo_user.id,
|
| 126 |
+
action="seed_data",
|
| 127 |
+
resource_type="system",
|
| 128 |
+
details="Seeded demo profile and memories",
|
| 129 |
+
)
|
| 130 |
+
db.add(log)
|
| 131 |
+
db.commit()
|
| 132 |
+
print("Seed data created successfully!")
|
| 133 |
+
else:
|
| 134 |
+
print("Demo profile already exists, skipping seed.")
|
| 135 |
+
|
| 136 |
+
db.close()
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
seed()
|
static/css/style.css
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* MemoryBot - Full Theme CSS */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg-primary: #0a0a0f;
|
| 5 |
+
--bg-secondary: #12121a;
|
| 6 |
+
--bg-card: #1a1a28;
|
| 7 |
+
--bg-card-hover: #222236;
|
| 8 |
+
--bg-input: #16162a;
|
| 9 |
+
--border-primary: #2a2a40;
|
| 10 |
+
--border-focus: #8b5cf6;
|
| 11 |
+
--text-primary: #f0f0f5;
|
| 12 |
+
--text-secondary: #a0a0b8;
|
| 13 |
+
--text-muted: #6b6b88;
|
| 14 |
+
--accent-purple: #8b5cf6;
|
| 15 |
+
--accent-purple-hover: #7c3aed;
|
| 16 |
+
--accent-purple-light: rgba(139, 92, 246, 0.15);
|
| 17 |
+
--accent-pink: #ec4899;
|
| 18 |
+
--accent-blue: #3b82f6;
|
| 19 |
+
--accent-green: #10b981;
|
| 20 |
+
--accent-amber: #f59e0b;
|
| 21 |
+
--accent-red: #ef4444;
|
| 22 |
+
--gradient-primary: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
|
| 23 |
+
--gradient-card: linear-gradient(135deg, rgba(139,92,246,0.08) 0%, rgba(236,72,153,0.08) 100%);
|
| 24 |
+
--shadow-glow: 0 0 20px rgba(139, 92, 246, 0.15);
|
| 25 |
+
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.3);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
* { box-sizing: border-box; }
|
| 29 |
+
|
| 30 |
+
html, body {
|
| 31 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 32 |
+
background: var(--bg-primary);
|
| 33 |
+
color: var(--text-primary);
|
| 34 |
+
margin: 0;
|
| 35 |
+
padding: 0;
|
| 36 |
+
height: 100%;
|
| 37 |
+
min-height: 100vh;
|
| 38 |
+
overflow-x: hidden;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* Ensure the main layout container stretches properly */
|
| 42 |
+
.h-full {
|
| 43 |
+
height: 100%;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* Sidebar nav layout fix */
|
| 47 |
+
.sidebar-nav {
|
| 48 |
+
flex: 1;
|
| 49 |
+
display: flex;
|
| 50 |
+
flex-direction: column;
|
| 51 |
+
gap: 6px;
|
| 52 |
+
padding: 24px 16px;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.sidebar-footer {
|
| 56 |
+
padding: 16px;
|
| 57 |
+
border-top: 1px solid var(--border-primary);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/* Scrollbar */
|
| 61 |
+
::-webkit-scrollbar { width: 6px; }
|
| 62 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 63 |
+
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
| 64 |
+
::-webkit-scrollbar-thumb:hover { background: #555; }
|
| 65 |
+
.chat-scroll { scrollbar-width: thin; scrollbar-color: #333 transparent; }
|
| 66 |
+
|
| 67 |
+
/* Animations */
|
| 68 |
+
.fade-in { animation: fadeIn 0.3s ease-out; }
|
| 69 |
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
| 70 |
+
.slide-up { animation: slideUp 0.4s ease-out; }
|
| 71 |
+
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
| 72 |
+
.pulse { animation: pulse 2s infinite; }
|
| 73 |
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
| 74 |
+
.typing-dot { animation: typing 1.4s infinite; }
|
| 75 |
+
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
| 76 |
+
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
| 77 |
+
@keyframes typing { 0%,60%,100% { opacity: 0.3; } 30% { opacity: 1; } }
|
| 78 |
+
|
| 79 |
+
/* Glow effects */
|
| 80 |
+
.glow-purple { box-shadow: 0 0 20px rgba(139, 92, 246, 0.3); }
|
| 81 |
+
.glow-pink { box-shadow: 0 0 20px rgba(236, 72, 153, 0.3); }
|
| 82 |
+
|
| 83 |
+
/* Buttons */
|
| 84 |
+
.btn-primary {
|
| 85 |
+
background: var(--gradient-primary);
|
| 86 |
+
color: white;
|
| 87 |
+
border: none;
|
| 88 |
+
padding: 10px 24px;
|
| 89 |
+
border-radius: 12px;
|
| 90 |
+
font-weight: 600;
|
| 91 |
+
font-size: 14px;
|
| 92 |
+
cursor: pointer;
|
| 93 |
+
transition: all 0.2s;
|
| 94 |
+
display: inline-flex;
|
| 95 |
+
align-items: center;
|
| 96 |
+
gap: 8px;
|
| 97 |
+
}
|
| 98 |
+
.btn-primary:hover {
|
| 99 |
+
transform: translateY(-1px);
|
| 100 |
+
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
|
| 101 |
+
}
|
| 102 |
+
.btn-primary:disabled {
|
| 103 |
+
opacity: 0.5;
|
| 104 |
+
cursor: not-allowed;
|
| 105 |
+
transform: none;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.btn-secondary {
|
| 109 |
+
background: var(--bg-card);
|
| 110 |
+
color: var(--text-primary);
|
| 111 |
+
border: 1px solid var(--border-primary);
|
| 112 |
+
padding: 10px 24px;
|
| 113 |
+
border-radius: 12px;
|
| 114 |
+
font-weight: 500;
|
| 115 |
+
font-size: 14px;
|
| 116 |
+
cursor: pointer;
|
| 117 |
+
transition: all 0.2s;
|
| 118 |
+
display: inline-flex;
|
| 119 |
+
align-items: center;
|
| 120 |
+
gap: 8px;
|
| 121 |
+
}
|
| 122 |
+
.btn-secondary:hover {
|
| 123 |
+
background: var(--bg-card-hover);
|
| 124 |
+
border-color: var(--accent-purple);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.btn-danger {
|
| 128 |
+
background: rgba(239, 68, 68, 0.1);
|
| 129 |
+
color: #ef4444;
|
| 130 |
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
| 131 |
+
padding: 10px 24px;
|
| 132 |
+
border-radius: 12px;
|
| 133 |
+
font-weight: 500;
|
| 134 |
+
font-size: 14px;
|
| 135 |
+
cursor: pointer;
|
| 136 |
+
transition: all 0.2s;
|
| 137 |
+
}
|
| 138 |
+
.btn-danger:hover {
|
| 139 |
+
background: rgba(239, 68, 68, 0.2);
|
| 140 |
+
border-color: #ef4444;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.btn-ghost {
|
| 144 |
+
background: transparent;
|
| 145 |
+
color: var(--text-secondary);
|
| 146 |
+
border: none;
|
| 147 |
+
padding: 8px 16px;
|
| 148 |
+
border-radius: 8px;
|
| 149 |
+
font-weight: 500;
|
| 150 |
+
font-size: 14px;
|
| 151 |
+
cursor: pointer;
|
| 152 |
+
transition: all 0.2s;
|
| 153 |
+
}
|
| 154 |
+
.btn-ghost:hover {
|
| 155 |
+
background: rgba(255,255,255,0.05);
|
| 156 |
+
color: var(--text-primary);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/* Inputs */
|
| 160 |
+
.input-field {
|
| 161 |
+
width: 100%;
|
| 162 |
+
padding: 12px 16px;
|
| 163 |
+
background: var(--bg-input);
|
| 164 |
+
border: 1px solid var(--border-primary);
|
| 165 |
+
border-radius: 12px;
|
| 166 |
+
color: var(--text-primary);
|
| 167 |
+
font-size: 14px;
|
| 168 |
+
transition: all 0.2s;
|
| 169 |
+
outline: none;
|
| 170 |
+
}
|
| 171 |
+
.input-field:focus {
|
| 172 |
+
border-color: var(--accent-purple);
|
| 173 |
+
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
|
| 174 |
+
}
|
| 175 |
+
.input-field::placeholder { color: var(--text-muted); }
|
| 176 |
+
|
| 177 |
+
.select-field {
|
| 178 |
+
width: 100%;
|
| 179 |
+
padding: 12px 16px;
|
| 180 |
+
background: var(--bg-input);
|
| 181 |
+
border: 1px solid var(--border-primary);
|
| 182 |
+
border-radius: 12px;
|
| 183 |
+
color: var(--text-primary);
|
| 184 |
+
font-size: 14px;
|
| 185 |
+
transition: all 0.2s;
|
| 186 |
+
outline: none;
|
| 187 |
+
appearance: none;
|
| 188 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b6b88' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
|
| 189 |
+
background-repeat: no-repeat;
|
| 190 |
+
background-position: right 12px center;
|
| 191 |
+
}
|
| 192 |
+
.select-field:focus {
|
| 193 |
+
border-color: var(--accent-purple);
|
| 194 |
+
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
textarea.input-field { resize: vertical; min-height: 80px; }
|
| 198 |
+
|
| 199 |
+
/* Cards */
|
| 200 |
+
.card {
|
| 201 |
+
background: var(--bg-card);
|
| 202 |
+
border: 1px solid var(--border-primary);
|
| 203 |
+
border-radius: 16px;
|
| 204 |
+
padding: 24px;
|
| 205 |
+
transition: all 0.2s;
|
| 206 |
+
}
|
| 207 |
+
.card:hover {
|
| 208 |
+
border-color: rgba(139, 92, 246, 0.3);
|
| 209 |
+
box-shadow: var(--shadow-glow);
|
| 210 |
+
}
|
| 211 |
+
.card-gradient {
|
| 212 |
+
background: var(--gradient-card);
|
| 213 |
+
border: 1px solid var(--border-primary);
|
| 214 |
+
border-radius: 16px;
|
| 215 |
+
padding: 24px;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
/* Stat cards */
|
| 219 |
+
.stat-card {
|
| 220 |
+
background: var(--bg-card);
|
| 221 |
+
border: 1px solid var(--border-primary);
|
| 222 |
+
border-radius: 16px;
|
| 223 |
+
padding: 20px;
|
| 224 |
+
position: relative;
|
| 225 |
+
overflow: hidden;
|
| 226 |
+
}
|
| 227 |
+
.stat-card::before {
|
| 228 |
+
content: '';
|
| 229 |
+
position: absolute;
|
| 230 |
+
top: 0;
|
| 231 |
+
left: 0;
|
| 232 |
+
right: 0;
|
| 233 |
+
height: 3px;
|
| 234 |
+
border-radius: 16px 16px 0 0;
|
| 235 |
+
}
|
| 236 |
+
.stat-card.purple::before { background: var(--gradient-primary); }
|
| 237 |
+
.stat-card.blue::before { background: linear-gradient(90deg, #3b82f6, #06b6d4); }
|
| 238 |
+
.stat-card.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
|
| 239 |
+
.stat-card.amber::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
| 240 |
+
.stat-card.pink::before { background: linear-gradient(90deg, #ec4899, #f472b6); }
|
| 241 |
+
|
| 242 |
+
/* Badges */
|
| 243 |
+
.badge {
|
| 244 |
+
display: inline-flex;
|
| 245 |
+
align-items: center;
|
| 246 |
+
padding: 4px 10px;
|
| 247 |
+
border-radius: 20px;
|
| 248 |
+
font-size: 12px;
|
| 249 |
+
font-weight: 600;
|
| 250 |
+
}
|
| 251 |
+
.badge-purple { background: rgba(139, 92, 246, 0.15); color: #a78bfa; }
|
| 252 |
+
.badge-pink { background: rgba(236, 72, 153, 0.15); color: #f472b6; }
|
| 253 |
+
.badge-blue { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
|
| 254 |
+
.badge-green { background: rgba(16, 185, 129, 0.15); color: #34d399; }
|
| 255 |
+
.badge-amber { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
|
| 256 |
+
.badge-red { background: rgba(239, 68, 68, 0.15); color: #f87171; }
|
| 257 |
+
|
| 258 |
+
/* Avatar */
|
| 259 |
+
.avatar {
|
| 260 |
+
display: flex;
|
| 261 |
+
align-items: center;
|
| 262 |
+
justify-content: center;
|
| 263 |
+
border-radius: 50%;
|
| 264 |
+
font-weight: 700;
|
| 265 |
+
flex-shrink: 0;
|
| 266 |
+
}
|
| 267 |
+
.avatar-sm { width: 32px; height: 32px; font-size: 14px; }
|
| 268 |
+
.avatar-md { width: 40px; height: 40px; font-size: 16px; }
|
| 269 |
+
.avatar-lg { width: 56px; height: 56px; font-size: 22px; }
|
| 270 |
+
.avatar-xl { width: 72px; height: 72px; font-size: 28px; }
|
| 271 |
+
.avatar-purple { background: rgba(139, 92, 246, 0.2); color: #a78bfa; }
|
| 272 |
+
.avatar-pink { background: rgba(236, 72, 153, 0.2); color: #f472b6; }
|
| 273 |
+
|
| 274 |
+
/* File icon */
|
| 275 |
+
.file-icon {
|
| 276 |
+
width: 40px;
|
| 277 |
+
height: 40px;
|
| 278 |
+
border-radius: 10px;
|
| 279 |
+
display: flex;
|
| 280 |
+
align-items: center;
|
| 281 |
+
justify-content: center;
|
| 282 |
+
flex-shrink: 0;
|
| 283 |
+
}
|
| 284 |
+
.file-icon.pdf { background: rgba(239, 68, 68, 0.15); color: #f87171; }
|
| 285 |
+
.file-icon.txt { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
|
| 286 |
+
.file-icon.doc { background: rgba(16, 185, 129, 0.15); color: #34d399; }
|
| 287 |
+
.file-icon.img { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
|
| 288 |
+
.file-icon.audio { background: rgba(236, 72, 153, 0.15); color: #f472b6; }
|
| 289 |
+
.file-icon.default { background: rgba(139, 92, 246, 0.15); color: #a78bfa; }
|
| 290 |
+
|
| 291 |
+
/* Upload zone */
|
| 292 |
+
.upload-zone {
|
| 293 |
+
border: 2px dashed var(--border-primary);
|
| 294 |
+
border-radius: 16px;
|
| 295 |
+
padding: 40px 24px;
|
| 296 |
+
text-align: center;
|
| 297 |
+
cursor: pointer;
|
| 298 |
+
transition: all 0.3s;
|
| 299 |
+
}
|
| 300 |
+
.upload-zone:hover, .upload-zone.drag-over {
|
| 301 |
+
border-color: var(--accent-purple);
|
| 302 |
+
background: rgba(139, 92, 246, 0.05);
|
| 303 |
+
box-shadow: 0 0 30px rgba(139, 92, 246, 0.1);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/* Progress bar */
|
| 307 |
+
.progress-bar {
|
| 308 |
+
background: rgba(255,255,255,0.05);
|
| 309 |
+
border-radius: 8px;
|
| 310 |
+
height: 6px;
|
| 311 |
+
overflow: hidden;
|
| 312 |
+
}
|
| 313 |
+
.progress-bar-fill {
|
| 314 |
+
height: 100%;
|
| 315 |
+
background: var(--gradient-primary);
|
| 316 |
+
border-radius: 8px;
|
| 317 |
+
transition: width 0.3s ease;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
/* Chat bubbles */
|
| 321 |
+
.msg-user {
|
| 322 |
+
background: var(--gradient-primary);
|
| 323 |
+
color: white;
|
| 324 |
+
border-radius: 20px 20px 4px 20px;
|
| 325 |
+
padding: 12px 18px;
|
| 326 |
+
max-width: 75%;
|
| 327 |
+
}
|
| 328 |
+
.msg-assistant {
|
| 329 |
+
background: var(--bg-card);
|
| 330 |
+
border: 1px solid var(--border-primary);
|
| 331 |
+
color: var(--text-primary);
|
| 332 |
+
border-radius: 20px 20px 20px 4px;
|
| 333 |
+
padding: 12px 18px;
|
| 334 |
+
max-width: 75%;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/* Prose styles for chat markdown */
|
| 338 |
+
.prose { line-height: 1.7; color: var(--text-primary); }
|
| 339 |
+
.prose p { margin-bottom: 0.75em; }
|
| 340 |
+
.prose p:last-child { margin-bottom: 0; }
|
| 341 |
+
.prose ul, .prose ol { margin: 0.5em 0; padding-left: 1.5em; }
|
| 342 |
+
.prose li { margin: 0.25em 0; }
|
| 343 |
+
.prose strong { font-weight: 600; color: var(--accent-purple); }
|
| 344 |
+
.prose em { font-style: italic; color: var(--text-secondary); }
|
| 345 |
+
.prose code {
|
| 346 |
+
background: rgba(139, 92, 246, 0.1);
|
| 347 |
+
padding: 0.15em 0.4em;
|
| 348 |
+
border-radius: 0.25em;
|
| 349 |
+
font-size: 0.875em;
|
| 350 |
+
color: #a78bfa;
|
| 351 |
+
}
|
| 352 |
+
.prose pre {
|
| 353 |
+
background: var(--bg-primary);
|
| 354 |
+
color: #e5e7eb;
|
| 355 |
+
padding: 1em;
|
| 356 |
+
border-radius: 0.5em;
|
| 357 |
+
overflow-x: auto;
|
| 358 |
+
margin: 0.75em 0;
|
| 359 |
+
border: 1px solid var(--border-primary);
|
| 360 |
+
}
|
| 361 |
+
.prose pre code { background: none; padding: 0; color: inherit; }
|
| 362 |
+
.prose blockquote {
|
| 363 |
+
border-left: 3px solid var(--accent-purple);
|
| 364 |
+
padding-left: 1em;
|
| 365 |
+
color: var(--text-secondary);
|
| 366 |
+
margin: 0.75em 0;
|
| 367 |
+
}
|
| 368 |
+
.prose h1, .prose h2, .prose h3 { font-weight: 600; margin: 1em 0 0.5em; }
|
| 369 |
+
.prose h1 { font-size: 1.25em; }
|
| 370 |
+
.prose h2 { font-size: 1.125em; }
|
| 371 |
+
.prose h3 { font-size: 1em; }
|
| 372 |
+
|
| 373 |
+
/* Line clamp */
|
| 374 |
+
.line-clamp-2 {
|
| 375 |
+
display: -webkit-box;
|
| 376 |
+
-webkit-line-clamp: 2;
|
| 377 |
+
-webkit-box-orient: vertical;
|
| 378 |
+
overflow: hidden;
|
| 379 |
+
}
|
| 380 |
+
.line-clamp-3 {
|
| 381 |
+
display: -webkit-box;
|
| 382 |
+
-webkit-line-clamp: 3;
|
| 383 |
+
-webkit-box-orient: vertical;
|
| 384 |
+
overflow: hidden;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/* Modal overlay */
|
| 388 |
+
.modal-overlay {
|
| 389 |
+
position: fixed;
|
| 390 |
+
inset: 0;
|
| 391 |
+
background: rgba(0, 0, 0, 0.7);
|
| 392 |
+
backdrop-filter: blur(4px);
|
| 393 |
+
display: flex;
|
| 394 |
+
align-items: center;
|
| 395 |
+
justify-content: center;
|
| 396 |
+
z-index: 50;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
/* Voice button */
|
| 400 |
+
.voice-btn {
|
| 401 |
+
width: 44px;
|
| 402 |
+
height: 44px;
|
| 403 |
+
border-radius: 50%;
|
| 404 |
+
display: flex;
|
| 405 |
+
align-items: center;
|
| 406 |
+
justify-content: center;
|
| 407 |
+
cursor: pointer;
|
| 408 |
+
transition: all 0.2s;
|
| 409 |
+
border: none;
|
| 410 |
+
background: var(--bg-card);
|
| 411 |
+
color: var(--text-secondary);
|
| 412 |
+
border: 1px solid var(--border-primary);
|
| 413 |
+
}
|
| 414 |
+
.voice-btn:hover { background: var(--bg-card-hover); color: var(--text-primary); }
|
| 415 |
+
.voice-btn.active { background: rgba(139, 92, 246, 0.2); color: var(--accent-purple); border-color: var(--accent-purple); }
|
| 416 |
+
.voice-btn.recording { background: rgba(239, 68, 68, 0.2); color: #ef4444; border-color: #ef4444; animation: pulse 1s infinite; }
|
| 417 |
+
|
| 418 |
+
/* Send button */
|
| 419 |
+
.send-btn {
|
| 420 |
+
width: 44px;
|
| 421 |
+
height: 44px;
|
| 422 |
+
border-radius: 50%;
|
| 423 |
+
display: flex;
|
| 424 |
+
align-items: center;
|
| 425 |
+
justify-content: center;
|
| 426 |
+
cursor: pointer;
|
| 427 |
+
transition: all 0.2s;
|
| 428 |
+
border: none;
|
| 429 |
+
background: var(--gradient-primary);
|
| 430 |
+
color: white;
|
| 431 |
+
flex-shrink: 0;
|
| 432 |
+
}
|
| 433 |
+
.send-btn:hover { transform: scale(1.05); box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4); }
|
| 434 |
+
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
| 435 |
+
|
| 436 |
+
/* Mic button */
|
| 437 |
+
.mic-btn {
|
| 438 |
+
width: 44px;
|
| 439 |
+
height: 44px;
|
| 440 |
+
border-radius: 50%;
|
| 441 |
+
display: flex;
|
| 442 |
+
align-items: center;
|
| 443 |
+
justify-content: center;
|
| 444 |
+
cursor: pointer;
|
| 445 |
+
transition: all 0.2s;
|
| 446 |
+
border: none;
|
| 447 |
+
background: var(--bg-card);
|
| 448 |
+
color: var(--text-secondary);
|
| 449 |
+
border: 1px solid var(--border-primary);
|
| 450 |
+
flex-shrink: 0;
|
| 451 |
+
}
|
| 452 |
+
.mic-btn:hover { background: var(--bg-card-hover); color: var(--text-primary); }
|
| 453 |
+
.mic-btn.recording { background: rgba(239, 68, 68, 0.2); color: #ef4444; border-color: #ef4444; animation: pulse 1s infinite; }
|
| 454 |
+
|
| 455 |
+
/* Table */
|
| 456 |
+
.data-table {
|
| 457 |
+
width: 100%;
|
| 458 |
+
border-collapse: separate;
|
| 459 |
+
border-spacing: 0;
|
| 460 |
+
}
|
| 461 |
+
.data-table th {
|
| 462 |
+
text-align: left;
|
| 463 |
+
padding: 12px 16px;
|
| 464 |
+
font-size: 12px;
|
| 465 |
+
font-weight: 600;
|
| 466 |
+
color: var(--text-muted);
|
| 467 |
+
text-transform: uppercase;
|
| 468 |
+
letter-spacing: 0.05em;
|
| 469 |
+
border-bottom: 1px solid var(--border-primary);
|
| 470 |
+
}
|
| 471 |
+
.data-table td {
|
| 472 |
+
padding: 12px 16px;
|
| 473 |
+
font-size: 14px;
|
| 474 |
+
color: var(--text-secondary);
|
| 475 |
+
border-bottom: 1px solid rgba(42, 42, 64, 0.5);
|
| 476 |
+
}
|
| 477 |
+
.data-table tr:hover td {
|
| 478 |
+
background: rgba(139, 92, 246, 0.03);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
/* Empty state */
|
| 482 |
+
.empty-state {
|
| 483 |
+
text-align: center;
|
| 484 |
+
padding: 48px 24px;
|
| 485 |
+
}
|
| 486 |
+
.empty-state-icon {
|
| 487 |
+
width: 64px;
|
| 488 |
+
height: 64px;
|
| 489 |
+
border-radius: 50%;
|
| 490 |
+
background: rgba(139, 92, 246, 0.1);
|
| 491 |
+
display: flex;
|
| 492 |
+
align-items: center;
|
| 493 |
+
justify-content: center;
|
| 494 |
+
margin: 0 auto 16px;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/* Divider */
|
| 498 |
+
.divider {
|
| 499 |
+
height: 1px;
|
| 500 |
+
background: var(--border-primary);
|
| 501 |
+
margin: 16px 0;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
/* Toast / Alert */
|
| 505 |
+
.alert-error {
|
| 506 |
+
background: rgba(239, 68, 68, 0.1);
|
| 507 |
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
| 508 |
+
color: #f87171;
|
| 509 |
+
border-radius: 12px;
|
| 510 |
+
padding: 12px 16px;
|
| 511 |
+
font-size: 14px;
|
| 512 |
+
}
|
| 513 |
+
.alert-warning {
|
| 514 |
+
background: rgba(245, 158, 11, 0.1);
|
| 515 |
+
border: 1px solid rgba(245, 158, 11, 0.3);
|
| 516 |
+
color: #fbbf24;
|
| 517 |
+
border-radius: 12px;
|
| 518 |
+
padding: 12px 16px;
|
| 519 |
+
font-size: 14px;
|
| 520 |
+
}
|
| 521 |
+
.alert-success {
|
| 522 |
+
background: rgba(16, 185, 129, 0.1);
|
| 523 |
+
border: 1px solid rgba(16, 185, 129, 0.3);
|
| 524 |
+
color: #34d399;
|
| 525 |
+
border-radius: 12px;
|
| 526 |
+
padding: 12px 16px;
|
| 527 |
+
font-size: 14px;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
/* Responsive sidebar */
|
| 531 |
+
@media (max-width: 768px) {
|
| 532 |
+
.sidebar { transform: translateX(-100%); }
|
| 533 |
+
.sidebar.open { transform: translateX(0); }
|
| 534 |
+
.main-content { margin-left: 0 !important; }
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
/* Utility classes (replacing missing Tailwind CSS) */
|
| 538 |
+
.hidden { display: none !important; }
|
| 539 |
+
.flex { display: flex; }
|
| 540 |
+
.flex-col { flex-direction: column; }
|
| 541 |
+
.flex-1 { flex: 1; }
|
| 542 |
+
.flex-shrink-0 { flex-shrink: 0; }
|
| 543 |
+
.items-center { align-items: center; }
|
| 544 |
+
.justify-between { justify-content: space-between; }
|
| 545 |
+
.justify-center { justify-content: center; }
|
| 546 |
+
.min-h-full { min-height: 100%; }
|
| 547 |
+
.min-w-0 { min-width: 0; }
|
| 548 |
+
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 549 |
+
.ml-64 { margin-left: 256px; }
|
| 550 |
+
.w-64 { width: 256px; }
|
| 551 |
+
.z-30 { z-index: 30; }
|
| 552 |
+
.gap-3 { gap: 12px; }
|
| 553 |
+
.no-underline { text-decoration: none; }
|
| 554 |
+
.p-4 { padding: 16px; }
|
| 555 |
+
.p-6 { padding: 24px; }
|
| 556 |
+
.px-4 { padding-left: 16px; padding-right: 16px; }
|
| 557 |
+
.px-5 { padding-left: 20px; padding-right: 20px; }
|
| 558 |
+
.py-2 { padding-top: 8px; padding-bottom: 8px; }
|
| 559 |
+
.py-2\.5 { padding-top: 10px; padding-bottom: 10px; }
|
| 560 |
+
.py-3 { padding-top: 12px; padding-bottom: 12px; }
|
| 561 |
+
.rounded-xl { border-radius: 12px; }
|
| 562 |
+
.text-decoration-none { text-decoration: none; }
|
| 563 |
+
.fixed { position: fixed; }
|
| 564 |
+
.left-0 { left: 0; }
|
| 565 |
+
.top-0 { top: 0; }
|
| 566 |
+
.h-full { height: 100%; }
|
| 567 |
+
.w-full { width: 100%; }
|
| 568 |
+
.text-center { text-align: center; }
|
| 569 |
+
.overflow-hidden { overflow: hidden; }
|
| 570 |
+
.overflow-y-auto { overflow-y: auto; }
|
| 571 |
+
.overflow-x-auto { overflow-x: auto; }
|
| 572 |
+
.grid { display: grid; }
|
| 573 |
+
.block { display: block; }
|
| 574 |
+
.inline-flex { display: inline-flex; }
|
| 575 |
+
.relative { position: relative; }
|
| 576 |
+
.absolute { position: absolute; }
|
| 577 |
+
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
|
| 578 |
+
.opacity-0 { opacity: 0; }
|
| 579 |
+
.min-h-screen { min-height: 100vh; }
|
templates/admin.html
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Admin - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="min-h-full flex">
|
| 5 |
+
{% include "partials/sidebar.html" %}
|
| 6 |
+
<div class="flex-1" style="margin-left:256px;padding:32px;">
|
| 7 |
+
<div style="max-width:1200px;margin:0 auto;">
|
| 8 |
+
<div class="slide-up" style="margin-bottom:32px;">
|
| 9 |
+
<h1 style="font-size:28px;font-weight:800;color:var(--text-primary);margin:0;">Admin Dashboard</h1>
|
| 10 |
+
<p style="margin-top:6px;color:var(--text-secondary);font-size:15px;">System overview and management</p>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:16px;margin-bottom:32px;">
|
| 14 |
+
<div class="stat-card purple slide-up" style="text-align:center;">
|
| 15 |
+
<p style="font-size:36px;font-weight:800;color:var(--accent-purple);margin:0;">{{ stats.total_users }}</p>
|
| 16 |
+
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-transform:uppercase;letter-spacing:0.05em;">Users</p>
|
| 17 |
+
</div>
|
| 18 |
+
<div class="stat-card pink slide-up" style="text-align:center;">
|
| 19 |
+
<p style="font-size:36px;font-weight:800;color:var(--accent-pink);margin:0;">{{ stats.total_profiles }}</p>
|
| 20 |
+
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-transform:uppercase;letter-spacing:0.05em;">Profiles</p>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="stat-card blue slide-up" style="text-align:center;">
|
| 23 |
+
<p style="font-size:36px;font-weight:800;color:var(--accent-blue);margin:0;">{{ stats.total_files }}</p>
|
| 24 |
+
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-transform:uppercase;letter-spacing:0.05em;">Files</p>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="stat-card green slide-up" style="text-align:center;">
|
| 27 |
+
<p style="font-size:36px;font-weight:800;color:var(--accent-green);margin:0;">{{ stats.total_conversations }}</p>
|
| 28 |
+
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-transform:uppercase;letter-spacing:0.05em;">Conversations</p>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="stat-card amber slide-up" style="text-align:center;">
|
| 31 |
+
<p style="font-size:36px;font-weight:800;color:var(--accent-amber);margin:0;">{{ stats.total_messages }}</p>
|
| 32 |
+
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-transform:uppercase;letter-spacing:0.05em;">Messages</p>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div style="margin-bottom:32px;display:flex;gap:12px;">
|
| 37 |
+
<a href="/admin/export" class="btn-primary" style="text-decoration:none;">
|
| 38 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
| 39 |
+
Export Data (JSON)
|
| 40 |
+
</a>
|
| 41 |
+
<a href="/admin/logs" class="btn-secondary" style="text-decoration:none;">
|
| 42 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6"/></svg>
|
| 43 |
+
View Logs
|
| 44 |
+
</a>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:32px;">
|
| 48 |
+
<div class="card" style="padding:0;overflow:hidden;">
|
| 49 |
+
<div style="padding:20px 24px;border-bottom:1px solid var(--border-primary);">
|
| 50 |
+
<div style="display:flex;align-items:center;gap:10px;">
|
| 51 |
+
<div style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;background:rgba(139,92,246,0.15);">
|
| 52 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-purple)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
| 53 |
+
</div>
|
| 54 |
+
<h2 style="font-size:15px;font-weight:700;color:var(--text-primary);margin:0;">Users</h2>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div style="padding:16px 24px;max-height:320px;overflow-y:auto;" class="chat-scroll">
|
| 58 |
+
{% for u in users %}
|
| 59 |
+
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-radius:10px;margin-bottom:4px;transition:all 0.15s;" onmouseover="this.style.background='rgba(255,255,255,0.03)'" onmouseout="this.style.background=''">
|
| 60 |
+
<div style="display:flex;align-items:center;gap:12px;">
|
| 61 |
+
<div class="avatar avatar-sm avatar-purple">{{ u.name[0] }}</div>
|
| 62 |
+
<div>
|
| 63 |
+
<p style="font-size:13px;font-weight:600;color:var(--text-primary);margin:0;">{{ u.name }}</p>
|
| 64 |
+
<p style="font-size:11px;color:var(--text-muted);margin:2px 0 0;">{{ u.email }}</p>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
<span style="font-size:11px;color:var(--text-muted);">{{ u.created_at.strftime('%b %d, %Y') if u.created_at else '' }}</span>
|
| 68 |
+
</div>
|
| 69 |
+
{% endfor %}
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div class="card" style="padding:0;overflow:hidden;">
|
| 74 |
+
<div style="padding:20px 24px;border-bottom:1px solid var(--border-primary);">
|
| 75 |
+
<div style="display:flex;align-items:center;gap:10px;">
|
| 76 |
+
<div style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;background:rgba(236,72,153,0.15);">
|
| 77 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-pink)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
| 78 |
+
</div>
|
| 79 |
+
<h2 style="font-size:15px;font-weight:700;color:var(--text-primary);margin:0;">Profiles</h2>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
<div style="padding:16px 24px;max-height:320px;overflow-y:auto;" class="chat-scroll">
|
| 83 |
+
{% for p in profiles %}
|
| 84 |
+
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-radius:10px;margin-bottom:4px;transition:all 0.15s;" onmouseover="this.style.background='rgba(255,255,255,0.03)'" onmouseout="this.style.background=''">
|
| 85 |
+
<div style="display:flex;align-items:center;gap:12px;">
|
| 86 |
+
<div class="avatar avatar-sm avatar-pink">{{ p.name[0] }}</div>
|
| 87 |
+
<div>
|
| 88 |
+
<p style="font-size:13px;font-weight:600;color:var(--text-primary);margin:0;">{{ p.name }}</p>
|
| 89 |
+
<p style="font-size:11px;color:var(--text-muted);margin:2px 0 0;">{{ p.relationship_type or 'No relationship specified' }}</p>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
<span style="font-size:11px;color:var(--text-muted);">{{ p.created_at.strftime('%b %d, %Y') if p.created_at else '' }}</span>
|
| 93 |
+
</div>
|
| 94 |
+
{% endfor %}
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div class="card" style="padding:0;overflow:hidden;">
|
| 100 |
+
<div style="padding:20px 24px;border-bottom:1px solid var(--border-primary);">
|
| 101 |
+
<div style="display:flex;align-items:center;gap:10px;">
|
| 102 |
+
<div style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;background:rgba(245,158,11,0.15);">
|
| 103 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-amber)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
| 104 |
+
</div>
|
| 105 |
+
<h2 style="font-size:15px;font-weight:700;color:var(--text-primary);margin:0;">Audit Log</h2>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
<div style="padding:0;">
|
| 109 |
+
{% if recent_logs %}
|
| 110 |
+
<div style="overflow-x:auto;">
|
| 111 |
+
<table class="data-table">
|
| 112 |
+
<thead>
|
| 113 |
+
<tr>
|
| 114 |
+
<th style="padding:12px 20px;">Action</th>
|
| 115 |
+
<th style="padding:12px 20px;">Resource</th>
|
| 116 |
+
<th style="padding:12px 20px;">Details</th>
|
| 117 |
+
<th style="padding:12px 20px;">Time</th>
|
| 118 |
+
</tr>
|
| 119 |
+
</thead>
|
| 120 |
+
<tbody>
|
| 121 |
+
{% for log in recent_logs %}
|
| 122 |
+
<tr>
|
| 123 |
+
<td style="padding:10px 20px;">
|
| 124 |
+
<span class="badge {% if 'create' in log.action %}badge-green{% elif 'delete' in log.action %}badge-red{% elif 'upload' in log.action %}badge-blue{% else %}badge-purple{% endif %}" style="font-family:monospace;font-size:11px;">{{ log.action }}</span>
|
| 125 |
+
</td>
|
| 126 |
+
<td style="padding:10px 20px;font-size:13px;color:var(--text-secondary);">{{ log.resource_type }} {{ log.resource_id[:8] if log.resource_id else '' }}</td>
|
| 127 |
+
<td style="padding:10px 20px;font-size:13px;color:var(--text-secondary);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ log.details }}</td>
|
| 128 |
+
<td style="padding:10px 20px;font-size:12px;color:var(--text-muted);">{{ log.created_at.strftime('%b %d %H:%M') if log.created_at else '' }}</td>
|
| 129 |
+
</tr>
|
| 130 |
+
{% endfor %}
|
| 131 |
+
</tbody>
|
| 132 |
+
</table>
|
| 133 |
+
</div>
|
| 134 |
+
{% else %}
|
| 135 |
+
<div class="empty-state" style="padding:32px;">
|
| 136 |
+
<p style="color:var(--text-muted);font-size:14px;margin:0;">No audit logs yet</p>
|
| 137 |
+
</div>
|
| 138 |
+
{% endif %}
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
{% endblock %}
|
templates/base.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}MemoryBot{% endblock %}</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
| 9 |
+
<link rel="stylesheet" href="/static/css/style.css">
|
| 10 |
+
{% block head %}{% endblock %}
|
| 11 |
+
</head>
|
| 12 |
+
<body class="h-full">
|
| 13 |
+
{% block body %}{% endblock %}
|
| 14 |
+
</body>
|
| 15 |
+
</html>
|
templates/chat.html
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Chat - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="min-h-full flex">
|
| 5 |
+
{% include "partials/sidebar.html" %}
|
| 6 |
+
<div class="flex-1" style="margin-left:256px;display:flex;height:100vh;">
|
| 7 |
+
<div style="width:280px;border-right:1px solid var(--border-primary);background:var(--bg-secondary);display:flex;flex-direction:column;flex-shrink:0;">
|
| 8 |
+
<div style="padding:16px;border-bottom:1px solid var(--border-primary);">
|
| 9 |
+
<label style="display:block;font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;">Select Profile</label>
|
| 10 |
+
<select id="profileSelect" class="select-field" style="font-size:13px;padding:10px 14px;">
|
| 11 |
+
<option value="">Choose a profile...</option>
|
| 12 |
+
{% for p in profiles %}
|
| 13 |
+
<option value="{{ p.id }}" {% if active_profile_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
| 14 |
+
{% endfor %}
|
| 15 |
+
</select>
|
| 16 |
+
</div>
|
| 17 |
+
<div class="chat-scroll" style="flex:1;overflow-y:auto;padding:8px;">
|
| 18 |
+
<div id="convoList">
|
| 19 |
+
{% for c in conversations %}
|
| 20 |
+
<div style="display:flex;align-items:center;margin-bottom:2px;" onmouseover="this.querySelector('.convo-delete').style.opacity='1'" onmouseout="this.querySelector('.convo-delete').style.opacity='0'">
|
| 21 |
+
<a href="/chat/{{ c.profile_id }}/conversation/{{ c.id }}" style="display:block;padding:10px 14px;border-radius:10px;font-size:13px;color:var(--text-secondary);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all 0.15s;border:1px solid transparent;flex:1;{% if active_conversation and active_conversation.id == c.id %}background:rgba(139,92,246,0.1);color:var(--accent-purple);border-color:rgba(139,92,246,0.3);{% endif %}">
|
| 22 |
+
{{ c.title }}
|
| 23 |
+
</a>
|
| 24 |
+
<button class="convo-delete" onclick="event.preventDefault();if(confirm('Delete this conversation?'))fetch('/chat/{{ c.id }}/delete',{method:'POST'}).then(()=>location.reload())" style="background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px 8px;border-radius:6px;opacity:0;transition:opacity 0.15s;flex-shrink:0;" title="Delete conversation">
|
| 25 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
| 26 |
+
</button>
|
| 27 |
+
</div>
|
| 28 |
+
{% endfor %}
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
<div style="padding:12px;border-top:1px solid var(--border-primary);">
|
| 32 |
+
<button onclick="newConversation()" class="btn-primary" style="width:100%;justify-content:center;padding:10px;font-size:13px;">
|
| 33 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 34 |
+
New Conversation
|
| 35 |
+
</button>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div style="flex:1;display:flex;flex-direction:column;background:var(--bg-primary);">
|
| 40 |
+
<div style="background:var(--bg-secondary);border-bottom:1px solid var(--border-primary);padding:14px 24px;display:flex;align-items:center;justify-content:space-between;">
|
| 41 |
+
<div>
|
| 42 |
+
<h2 style="font-size:15px;font-weight:700;color:var(--text-primary);margin:0;" id="chatTitle">
|
| 43 |
+
{% if active_profile_id %}
|
| 44 |
+
{% for p in profiles %}
|
| 45 |
+
{% if p.id == active_profile_id %}{{ p.name }}{% endif %}
|
| 46 |
+
{% endfor %}
|
| 47 |
+
{% else %}
|
| 48 |
+
Select a profile to start chatting
|
| 49 |
+
{% endif %}
|
| 50 |
+
</h2>
|
| 51 |
+
{% if active_profile_id %}
|
| 52 |
+
<p style="font-size:11px;color:var(--text-muted);margin:3px 0 0;opacity:0.7;">Responses are based on uploaded memories and personality.</p>
|
| 53 |
+
{% endif %}
|
| 54 |
+
</div>
|
| 55 |
+
{% if active_profile_id %}
|
| 56 |
+
<div style="display:flex;align-items:center;gap:8px;">
|
| 57 |
+
<button onclick="toggleVoice()" id="voiceBtn" class="voice-btn" title="Toggle voice readout">
|
| 58 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
|
| 59 |
+
</button>
|
| 60 |
+
</div>
|
| 61 |
+
{% endif %}
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div id="messagesContainer" class="chat-scroll" style="flex:1;overflow-y:auto;padding:24px;display:flex;flex-direction:column;gap:16px;">
|
| 65 |
+
{% if messages %}
|
| 66 |
+
{% for msg in messages %}
|
| 67 |
+
<div class="fade-in" style="display:flex;{% if msg.role == 'user' %}justify-content:flex-end;{% else %}justify-content:flex-start;{% endif %}">
|
| 68 |
+
<div class="{% if msg.role == 'user' %}msg-user{% else %}msg-assistant{% endif %}">
|
| 69 |
+
<div style="font-size:14px;line-height:1.6;" id="msg-{{ msg.id }}">
|
| 70 |
+
{% if msg.role == 'assistant' %}
|
| 71 |
+
<div class="prose" id="rendered-msg-{{ msg.id }}">{{ msg.content }}</div>
|
| 72 |
+
{% else %}
|
| 73 |
+
{{ msg.content }}
|
| 74 |
+
{% endif %}
|
| 75 |
+
</div>
|
| 76 |
+
<p style="font-size:11px;margin:6px 0 0;{% if msg.role == 'user' %}color:rgba(255,255,255,0.6);{% else %}color:var(--text-muted);{% endif %}">
|
| 77 |
+
{{ msg.created_at.strftime('%H:%M') if msg.created_at else '' }}
|
| 78 |
+
</p>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
{% endfor %}
|
| 82 |
+
{% else %}
|
| 83 |
+
<div style="flex:1;display:flex;align-items:center;justify-content:center;">
|
| 84 |
+
<div style="text-align:center;">
|
| 85 |
+
<div class="empty-state-icon" style="width:72px;height:72px;margin:0 auto 20px;">
|
| 86 |
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--accent-purple)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
| 87 |
+
</div>
|
| 88 |
+
<h3 style="font-size:18px;font-weight:700;color:var(--text-primary);margin:0 0 8px;">Start a Conversation</h3>
|
| 89 |
+
<p style="color:var(--text-secondary);font-size:14px;margin:0;">
|
| 90 |
+
{% if active_profile_id %}
|
| 91 |
+
Say something to get the conversation started.
|
| 92 |
+
{% else %}
|
| 93 |
+
Select a memory profile to start chatting.
|
| 94 |
+
{% endif %}
|
| 95 |
+
</p>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
{% endif %}
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
{% if active_profile_id %}
|
| 102 |
+
<div style="background:var(--bg-secondary);border-top:1px solid var(--border-primary);padding:16px 24px;">
|
| 103 |
+
<div style="display:flex;align-items:flex-end;gap:12px;max-width:800px;margin:0 auto;">
|
| 104 |
+
<button onclick="startRecording()" id="micBtn" class="mic-btn" title="Record voice message">
|
| 105 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
| 106 |
+
</button>
|
| 107 |
+
<div style="flex:1;position:relative;">
|
| 108 |
+
<textarea id="messageInput" rows="1" placeholder="Type a message..." class="input-field" style="resize:none;min-height:44px;max-height:120px;padding:12px 16px;border-radius:22px;" onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();sendMessage();}" oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,120)+'px';"></textarea>
|
| 109 |
+
</div>
|
| 110 |
+
<button onclick="sendMessage()" id="sendBtn" class="send-btn" title="Send message">
|
| 111 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
{% endif %}
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<script>
|
| 121 |
+
const profileId = {{ active_profile_id|tojson if active_profile_id else 'null' }};
|
| 122 |
+
const conversationId = {{ active_conversation.id|tojson if active_conversation else 'null' }};
|
| 123 |
+
let currentConversationId = conversationId;
|
| 124 |
+
let isRecording = false;
|
| 125 |
+
let voiceEnabled = false;
|
| 126 |
+
let mediaRecorder = null;
|
| 127 |
+
|
| 128 |
+
function renderMarkdown(text) {
|
| 129 |
+
if (!text) return '';
|
| 130 |
+
text = text.replace(/<\|[^|]*\|>/g, '').replace(/User Safety:\s*\w+/gi, '').replace(/Response Safety:\s*\w+/gi, '').replace(/<<\|\/?\w+\|?>>/g, '').replace(/\[(?:SYSTEM|SAFETY|NOTE)\].*/gi, '').trim();
|
| 131 |
+
return text
|
| 132 |
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
| 133 |
+
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
| 134 |
+
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
| 135 |
+
.replace(/`(.*?)`/g, '<code>$1</code>')
|
| 136 |
+
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
|
| 137 |
+
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
| 138 |
+
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
| 139 |
+
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
| 140 |
+
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
| 141 |
+
.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>')
|
| 142 |
+
.replace(/(<li>.*<\/li>\n?)+/g, (m) => '<ul>' + m + '</ul>')
|
| 143 |
+
.replace(/\n{2,}/g, '</p><p>')
|
| 144 |
+
.replace(/\n/g, '<br>')
|
| 145 |
+
.replace(/^(.+)$/gm, (m) => m.startsWith('<') ? m : '<p>' + m + '</p>');
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
document.getElementById('profileSelect').addEventListener('change', function() {
|
| 149 |
+
if (this.value) window.location.href = '/chat/' + this.value;
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
function newConversation() {
|
| 153 |
+
const sel = document.getElementById('profileSelect');
|
| 154 |
+
if (sel.value) window.location.href = '/chat/' + sel.value;
|
| 155 |
+
else alert('Please select a profile first');
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
let lastTokenWasText = false;
|
| 159 |
+
|
| 160 |
+
function joinTokens(fullText, token) {
|
| 161 |
+
const needsSpace = lastTokenWasText && token.length > 0 && /[a-zA-Z0-9]/.test(token[0]) && /[a-zA-Z0-9]/.test(fullText.slice(-1));
|
| 162 |
+
lastTokenWasText = token.length > 0 && /[a-zA-Z0-9]/.test(token.slice(-1));
|
| 163 |
+
return fullText + (needsSpace ? ' ' : '') + token;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
async function sendMessage() {
|
| 167 |
+
const input = document.getElementById('messageInput');
|
| 168 |
+
const content = input.value.trim();
|
| 169 |
+
if (!content || !profileId) return;
|
| 170 |
+
|
| 171 |
+
input.value = '';
|
| 172 |
+
input.style.height = 'auto';
|
| 173 |
+
lastTokenWasText = false;
|
| 174 |
+
|
| 175 |
+
appendMessage('user', content);
|
| 176 |
+
|
| 177 |
+
const assistantDiv = appendMessage('assistant', '<div style="display:flex;gap:4px;padding:8px 0;"><span class="typing-dot" style="width:6px;height:6px;border-radius:50%;background:var(--text-muted);display:inline-block;"></span><span class="typing-dot" style="width:6px;height:6px;border-radius:50%;background:var(--text-muted);display:inline-block;"></span><span class="typing-dot" style="width:6px;height:6px;border-radius:50%;background:var(--text-muted);display:inline-block;"></span></div>');
|
| 178 |
+
const assistantContent = assistantDiv.querySelector('.prose');
|
| 179 |
+
|
| 180 |
+
document.getElementById('sendBtn').disabled = true;
|
| 181 |
+
|
| 182 |
+
try {
|
| 183 |
+
const resp = await fetch('/chat/message', {
|
| 184 |
+
method: 'POST',
|
| 185 |
+
headers: { 'Content-Type': 'application/json' },
|
| 186 |
+
body: JSON.stringify({ profile_id: profileId, conversation_id: currentConversationId, content }),
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
const reader = resp.body.getReader();
|
| 190 |
+
const decoder = new TextDecoder();
|
| 191 |
+
let fullText = '';
|
| 192 |
+
let buffer = '';
|
| 193 |
+
|
| 194 |
+
while (true) {
|
| 195 |
+
const { done, value } = await reader.read();
|
| 196 |
+
if (done) break;
|
| 197 |
+
buffer += decoder.decode(value, { stream: true });
|
| 198 |
+
const parts = buffer.split('\n\n');
|
| 199 |
+
buffer = parts.pop();
|
| 200 |
+
for (const part of parts) {
|
| 201 |
+
const lines = part.split('\n');
|
| 202 |
+
for (const line of lines) {
|
| 203 |
+
if (line.startsWith('data: ')) {
|
| 204 |
+
try {
|
| 205 |
+
const data = JSON.parse(line.slice(6));
|
| 206 |
+
if (data.error) {
|
| 207 |
+
assistantContent.innerHTML = '<div style="background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:8px;padding:12px 16px;color:var(--accent-red);font-size:13px;">' + data.error + '</div>';
|
| 208 |
+
} else if (data.token) {
|
| 209 |
+
fullText = joinTokens(fullText, data.token);
|
| 210 |
+
let clean = fullText.replace(/<\|[^|]*\|>/g, '').replace(/User Safety:\s*\w+/gi, '').replace(/Response Safety:\s*\w+/gi, '').replace(/<<\|\/?\w+\|?>>/g, '').replace(/\[(?:SYSTEM|SAFETY|NOTE)\].*/gi, '').trim();
|
| 211 |
+
clean = clean.replace(/([.!?,;:])([A-Za-z])/g, '$1 $2');
|
| 212 |
+
assistantContent.innerHTML = renderMarkdown(clean);
|
| 213 |
+
}
|
| 214 |
+
if (data.done) {
|
| 215 |
+
currentConversationId = data.conversation_id;
|
| 216 |
+
if (voiceEnabled && fullText && !data.error) speak(fullText);
|
| 217 |
+
}
|
| 218 |
+
} catch (e) {
|
| 219 |
+
console.error('SSE parse error:', e, line);
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
scrollToBottom();
|
| 225 |
+
}
|
| 226 |
+
} catch (e) {
|
| 227 |
+
assistantContent.innerHTML = '<span style="color:var(--accent-red);">Error: ' + e.message + '</span>';
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
document.getElementById('sendBtn').disabled = false;
|
| 231 |
+
scrollToBottom();
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function appendMessage(role, content) {
|
| 235 |
+
const container = document.getElementById('messagesContainer');
|
| 236 |
+
const div = document.createElement('div');
|
| 237 |
+
div.className = 'fade-in';
|
| 238 |
+
div.style.display = 'flex';
|
| 239 |
+
div.style.justifyContent = role === 'user' ? 'flex-end' : 'flex-start';
|
| 240 |
+
const isAssistant = role === 'assistant';
|
| 241 |
+
div.innerHTML = `
|
| 242 |
+
<div class="${isAssistant ? 'msg-assistant' : 'msg-user'}">
|
| 243 |
+
<div style="font-size:14px;line-height:1.6;">
|
| 244 |
+
${isAssistant ? '<div class="prose">' + content + '</div>' : content}
|
| 245 |
+
</div>
|
| 246 |
+
</div>`;
|
| 247 |
+
container.appendChild(div);
|
| 248 |
+
scrollToBottom();
|
| 249 |
+
return div;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function scrollToBottom() {
|
| 253 |
+
const c = document.getElementById('messagesContainer');
|
| 254 |
+
c.scrollTop = c.scrollHeight;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function toggleVoice() {
|
| 258 |
+
voiceEnabled = !voiceEnabled;
|
| 259 |
+
const btn = document.getElementById('voiceBtn');
|
| 260 |
+
btn.classList.toggle('active', voiceEnabled);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
async function speak(text) {
|
| 264 |
+
try {
|
| 265 |
+
const resp = await fetch('/voice/tts', {
|
| 266 |
+
method: 'POST',
|
| 267 |
+
headers: { 'Content-Type': 'application/json' },
|
| 268 |
+
body: JSON.stringify({ text: text.substring(0, 2000) }),
|
| 269 |
+
});
|
| 270 |
+
if (resp.ok) {
|
| 271 |
+
const blob = await resp.blob();
|
| 272 |
+
const audio = new Audio(URL.createObjectURL(blob));
|
| 273 |
+
audio.play();
|
| 274 |
+
}
|
| 275 |
+
} catch (e) {}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
async function startRecording() {
|
| 279 |
+
const micBtn = document.getElementById('micBtn');
|
| 280 |
+
if (isRecording) {
|
| 281 |
+
mediaRecorder && mediaRecorder.stop();
|
| 282 |
+
isRecording = false;
|
| 283 |
+
micBtn.classList.remove('recording');
|
| 284 |
+
return;
|
| 285 |
+
}
|
| 286 |
+
try {
|
| 287 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 288 |
+
mediaRecorder = new MediaRecorder(stream);
|
| 289 |
+
const chunks = [];
|
| 290 |
+
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
|
| 291 |
+
mediaRecorder.onstop = async () => {
|
| 292 |
+
stream.getTracks().forEach(t => t.stop());
|
| 293 |
+
const blob = new Blob(chunks, { type: 'audio/webm' });
|
| 294 |
+
const formData = new FormData();
|
| 295 |
+
formData.append('audio', blob, 'recording.webm');
|
| 296 |
+
try {
|
| 297 |
+
const resp = await fetch('/voice/stt', { method: 'POST', body: formData });
|
| 298 |
+
const data = await resp.json();
|
| 299 |
+
if (data.text) {
|
| 300 |
+
document.getElementById('messageInput').value = data.text;
|
| 301 |
+
}
|
| 302 |
+
} catch (e) {}
|
| 303 |
+
};
|
| 304 |
+
mediaRecorder.start();
|
| 305 |
+
isRecording = true;
|
| 306 |
+
micBtn.classList.add('recording');
|
| 307 |
+
} catch (e) {
|
| 308 |
+
alert('Microphone access denied');
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
scrollToBottom();
|
| 313 |
+
|
| 314 |
+
document.querySelectorAll('[id^="rendered-msg-"]').forEach(el => {
|
| 315 |
+
el.innerHTML = renderMarkdown(el.textContent);
|
| 316 |
+
});
|
| 317 |
+
</script>
|
| 318 |
+
{% endblock %}
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Dashboard - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="min-h-full flex">
|
| 5 |
+
{% include "partials/sidebar.html" %}
|
| 6 |
+
<div style="flex:1;margin-left:256px;padding:32px;">
|
| 7 |
+
<div style="max-width:1100px;margin:0 auto;">
|
| 8 |
+
<div class="slide-up" style="margin-bottom:32px;">
|
| 9 |
+
<h1 style="font-size:28px;font-weight:800;color:var(--text-primary);margin:0;">Welcome back, {{ user.name }}</h1>
|
| 10 |
+
<p style="margin-top:6px;color:var(--text-secondary);font-size:15px;">Here's an overview of your memory companions</p>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<div class="alert-warning slide-up" style="margin-bottom:32px;display:flex;align-items:flex-start;gap:12px;">
|
| 14 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;margin-top:2px;"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
| 15 |
+
<div>
|
| 16 |
+
<strong>Important:</strong> MemoryBot uses AI to simulate conversations. The AI is NOT the actual person being remembered. Responses are generated based on uploaded memories and context.
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-bottom:32px;">
|
| 21 |
+
<div class="stat-card purple slide-up">
|
| 22 |
+
<div style="display:flex;align-items:center;gap:16px;">
|
| 23 |
+
<div style="width:48px;height:48px;border-radius:14px;display:flex;align-items:center;justify-content:center;background:rgba(139,92,246,0.15);">
|
| 24 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
| 25 |
+
</div>
|
| 26 |
+
<div>
|
| 27 |
+
<p style="font-size:12px;color:var(--text-muted);margin:0;text-transform:uppercase;letter-spacing:0.05em;">Profiles</p>
|
| 28 |
+
<p style="font-size:32px;font-weight:800;color:var(--text-primary);margin:0;">{{ profiles|length }}</p>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="stat-card blue slide-up">
|
| 33 |
+
<div style="display:flex;align-items:center;gap:16px;">
|
| 34 |
+
<div style="width:48px;height:48px;border-radius:14px;display:flex;align-items:center;justify-content:center;background:rgba(59,130,246,0.15);">
|
| 35 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
| 36 |
+
</div>
|
| 37 |
+
<div>
|
| 38 |
+
<p style="font-size:12px;color:var(--text-muted);margin:0;text-transform:uppercase;letter-spacing:0.05em;">Conversations</p>
|
| 39 |
+
<p style="font-size:32px;font-weight:800;color:var(--text-primary);margin:0;">{{ total_conversations }}</p>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
<div class="stat-card green slide-up">
|
| 44 |
+
<div style="display:flex;align-items:center;gap:16px;">
|
| 45 |
+
<div style="width:48px;height:48px;border-radius:14px;display:flex;align-items:center;justify-content:center;background:rgba(16,185,129,0.15);">
|
| 46 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
| 47 |
+
</div>
|
| 48 |
+
<div>
|
| 49 |
+
<p style="font-size:12px;color:var(--text-muted);margin:0;text-transform:uppercase;letter-spacing:0.05em;">Files</p>
|
| 50 |
+
<p style="font-size:32px;font-weight:800;color:var(--text-primary);margin:0;">{{ total_files }}</p>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;">
|
| 57 |
+
<div class="card" style="padding:0;overflow:hidden;">
|
| 58 |
+
<div style="padding:20px 24px;" style="border-bottom:1px solid var(--border-primary);">
|
| 59 |
+
<div style="display:flex;align-items:center;justify-content:space-between;">
|
| 60 |
+
<h2 style="font-size:16px;font-weight:700;color:var(--text-primary);margin:0;">Memory Profiles</h2>
|
| 61 |
+
<a href="/profiles" class="badge badge-purple" style="text-decoration:none;">View all</a>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
<div style="padding:16px 24px 24px;">
|
| 65 |
+
{% if profiles %}
|
| 66 |
+
<div style="display:flex;flex-direction:column;gap:8px;">
|
| 67 |
+
{% for p in profiles %}
|
| 68 |
+
<a href="/profiles/{{ p.id }}" class="flex items-center gap-3" style="padding:12px 16px;border-radius:12px;text-decoration:none;transition:all 0.2s;border:1px solid transparent;" onmouseover="this.style.background='var(--bg-card-hover)';this.style.borderColor='var(--border-primary)'" onmouseout="this.style.background='';this.style.borderColor='transparent'">
|
| 69 |
+
<div class="avatar avatar-md avatar-purple">{{ p.name[0] }}</div>
|
| 70 |
+
<div style="flex:1;min-width:0;">
|
| 71 |
+
<p style="font-size:14px;font-weight:600;color:var(--text-primary);margin:0;">{{ p.name }}</p>
|
| 72 |
+
{% if p.relationship_type %}
|
| 73 |
+
<p style="font-size:12px;color:var(--text-muted);margin:2px 0 0;">{{ p.relationship_type }}</p>
|
| 74 |
+
{% endif %}
|
| 75 |
+
</div>
|
| 76 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
| 77 |
+
</a>
|
| 78 |
+
{% endfor %}
|
| 79 |
+
</div>
|
| 80 |
+
{% else %}
|
| 81 |
+
<div class="empty-state" style="padding:32px 16px;">
|
| 82 |
+
<div class="empty-state-icon">
|
| 83 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent-purple)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
| 84 |
+
</div>
|
| 85 |
+
<p style="color:var(--text-secondary);font-size:14px;margin:0 0 16px;">No profiles yet</p>
|
| 86 |
+
<a href="/profiles" class="btn-primary" style="text-decoration:none;font-size:13px;padding:8px 20px;">Create Profile</a>
|
| 87 |
+
</div>
|
| 88 |
+
{% endif %}
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div class="card" style="padding:0;overflow:hidden;">
|
| 93 |
+
<div style="padding:20px 24px;" style="border-bottom:1px solid var(--border-primary);">
|
| 94 |
+
<div style="display:flex;align-items:center;justify-content:space-between;">
|
| 95 |
+
<h2 style="font-size:16px;font-weight:700;color:var(--text-primary);margin:0;">Recent Conversations</h2>
|
| 96 |
+
<a href="/chat" class="badge badge-blue" style="text-decoration:none;">View all</a>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
<div style="padding:16px 24px 24px;">
|
| 100 |
+
{% if recent_conversations %}
|
| 101 |
+
<div style="display:flex;flex-direction:column;gap:8px;">
|
| 102 |
+
{% for c in recent_conversations %}
|
| 103 |
+
<a href="/chat/{{ c.profile_id }}/conversation/{{ c.id }}" style="padding:12px 16px;border-radius:12px;text-decoration:none;transition:all 0.2s;border:1px solid transparent;" onmouseover="this.style.background='var(--bg-card-hover)';this.style.borderColor='var(--border-primary)'" onmouseout="this.style.background='';this.style.borderColor='transparent'">
|
| 104 |
+
<p style="font-size:14px;font-weight:600;color:var(--text-primary);margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ c.title }}</p>
|
| 105 |
+
<p style="font-size:12px;color:var(--text-muted);margin:4px 0 0;">{{ recent_conv_profiles.get(c.id, '') }} · {{ c.updated_at.strftime('%b %d, %Y at %H:%M') if c.updated_at else '' }}</p>
|
| 106 |
+
</a>
|
| 107 |
+
{% endfor %}
|
| 108 |
+
</div>
|
| 109 |
+
{% else %}
|
| 110 |
+
<div class="empty-state" style="padding:32px 16px;">
|
| 111 |
+
<div class="empty-state-icon">
|
| 112 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent-blue)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
| 113 |
+
</div>
|
| 114 |
+
<p style="color:var(--text-secondary);font-size:14px;margin:0 0 16px;">No conversations yet</p>
|
| 115 |
+
<a href="/chat" class="btn-primary" style="text-decoration:none;font-size:13px;padding:8px 20px;">Start Chatting</a>
|
| 116 |
+
</div>
|
| 117 |
+
{% endif %}
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
{% endblock %}
|
templates/login.html
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Login - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg-primary);padding:16px;">
|
| 5 |
+
<div style="max-width:440px;width:100%;">
|
| 6 |
+
<div class="text-center" style="margin-bottom:32px;">
|
| 7 |
+
<div class="avatar avatar-xl" style="background:var(--gradient-primary);margin:0 auto 20px;box-shadow:0 0 40px rgba(139,92,246,0.3);">
|
| 8 |
+
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 9 |
+
<path d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
| 10 |
+
</svg>
|
| 11 |
+
</div>
|
| 12 |
+
<h1 style="font-size:32px;font-weight:800;color:var(--text-primary);margin:0;">MemoryBot</h1>
|
| 13 |
+
<p style="margin-top:8px;color:var(--text-secondary);font-size:15px;">AI Memory Companion</p>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div class="card" style="padding:32px;">
|
| 17 |
+
<div class="alert-warning" style="margin-bottom:24px;text-align:center;">
|
| 18 |
+
<strong>Disclaimer:</strong> This is an AI simulation. The AI is NOT the actual person being remembered.
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
{% if error %}
|
| 22 |
+
<div class="alert-error" style="margin-bottom:16px;text-align:center;">{{ error }}</div>
|
| 23 |
+
{% endif %}
|
| 24 |
+
|
| 25 |
+
<form method="POST" action="/auth/login" style="display:flex;flex-direction:column;gap:16px;">
|
| 26 |
+
<div>
|
| 27 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Email</label>
|
| 28 |
+
<input type="email" name="email" required class="input-field" placeholder="you@example.com">
|
| 29 |
+
</div>
|
| 30 |
+
<div>
|
| 31 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Password</label>
|
| 32 |
+
<input type="password" name="password" required class="input-field" placeholder="Enter your password">
|
| 33 |
+
</div>
|
| 34 |
+
<button type="submit" class="btn-primary" style="width:100%;justify-content:center;padding:14px;font-size:15px;margin-top:8px;">
|
| 35 |
+
Sign In
|
| 36 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
| 37 |
+
</button>
|
| 38 |
+
</form>
|
| 39 |
+
|
| 40 |
+
<div class="divider" style="margin:24px 0;"></div>
|
| 41 |
+
|
| 42 |
+
<p style="text-align:center;font-size:14px;color:var(--text-secondary);">
|
| 43 |
+
Don't have an account?
|
| 44 |
+
<a href="/auth/register" style="color:var(--accent-purple);text-decoration:none;font-weight:600;">Sign up</a>
|
| 45 |
+
</p>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
{% endblock %}
|
templates/partials/sidebar.html
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% set current_path = request.url.path %}
|
| 2 |
+
<aside class="fixed left-0 top-0 h-full w-64 flex flex-col z-30" style="background:var(--bg-secondary);border-right:1px solid var(--border-primary);display:flex;flex-direction:column;z-index:30;position:fixed;left:0;top:0;height:100%;width:256px;">
|
| 3 |
+
<div style="padding:24px;border-bottom:1px solid var(--border-primary);">
|
| 4 |
+
<a href="/dashboard" style="display:flex;align-items:center;gap:12px;text-decoration:none;">
|
| 5 |
+
<div class="avatar avatar-md" style="background:var(--gradient-primary);">
|
| 6 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 7 |
+
<path d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
| 8 |
+
</svg>
|
| 9 |
+
</div>
|
| 10 |
+
<div>
|
| 11 |
+
<span style="font-size:18px;font-weight:800;color:var(--text-primary);">MemoryBot</span>
|
| 12 |
+
<span style="display:block;font-size:10px;color:var(--text-muted);letter-spacing:0.05em;text-transform:uppercase;">AI Memory Companion</span>
|
| 13 |
+
</div>
|
| 14 |
+
</a>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<nav style="flex:1;display:flex;flex-direction:column;gap:6px;padding:24px 16px;">
|
| 18 |
+
{% set nav_items = [
|
| 19 |
+
("/dashboard", "Dashboard", '<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>'),
|
| 20 |
+
("/profiles", "Profiles", '<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>'),
|
| 21 |
+
("/chat", "Chat", '<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>'),
|
| 22 |
+
("/search", "Search", '<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>'),
|
| 23 |
+
("/admin", "Admin", '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>'),
|
| 24 |
+
] %}
|
| 25 |
+
{% for path, label, icon in nav_items %}
|
| 26 |
+
{% set is_active = current_path == path or (path != '/dashboard' and current_path.startswith(path)) %}
|
| 27 |
+
<a href="{{ path }}" style="display:flex;align-items:center;gap:12px;padding:12px 20px;border-radius:12px;transition:all 0.2s;text-decoration:none;{% if is_active %}background:rgba(139,92,246,0.15);color:var(--accent-purple);{% else %}color:var(--text-secondary);{% endif %}">
|
| 28 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icon }}</svg>
|
| 29 |
+
{{ label }}
|
| 30 |
+
</a>
|
| 31 |
+
{% endfor %}
|
| 32 |
+
</nav>
|
| 33 |
+
|
| 34 |
+
<div style="padding:16px;border-top:1px solid var(--border-primary);">
|
| 35 |
+
<div style="display:flex;align-items:center;gap:12px;padding:8px 16px;">
|
| 36 |
+
<div class="avatar avatar-sm avatar-purple">
|
| 37 |
+
<span>{{ user.name[0] if user else '?' }}</span>
|
| 38 |
+
</div>
|
| 39 |
+
<div style="flex:1;min-width:0;">
|
| 40 |
+
<p style="font-size:13px;font-weight:600;color:var(--text-primary);margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ user.name }}</p>
|
| 41 |
+
<p style="font-size:11px;color:var(--text-muted);margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ user.email }}</p>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
<a href="/auth/logout" style="display:flex;align-items:center;gap:12px;padding:10px 16px;border-radius:12px;color:var(--text-secondary);transition:all 0.2s;margin-top:4px;text-decoration:none;" onmouseover="this.style.background='rgba(239,68,68,0.1)';this.style.color='#f87171'" onmouseout="this.style.background='';this.style.color='var(--text-secondary)'">
|
| 45 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
| 46 |
+
Sign Out
|
| 47 |
+
</a>
|
| 48 |
+
</div>
|
| 49 |
+
</aside>
|
templates/profile_detail.html
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}{{ profile.name }} - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="min-h-full flex">
|
| 5 |
+
{% include "partials/sidebar.html" %}
|
| 6 |
+
<div class="flex-1" style="margin-left:256px;padding:32px;">
|
| 7 |
+
<div style="max-width:900px;margin:0 auto;">
|
| 8 |
+
<a href="/profiles" style="display:inline-flex;align-items:center;gap:6px;color:var(--accent-purple);text-decoration:none;font-size:13px;font-weight:600;margin-bottom:20px;">
|
| 9 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 19l-7-7 7-7"/></svg>
|
| 10 |
+
Back to Profiles
|
| 11 |
+
</a>
|
| 12 |
+
|
| 13 |
+
<div class="card slide-up" style="margin-bottom:24px;">
|
| 14 |
+
<div style="display:flex;align-items:flex-start;justify-content:space-between;">
|
| 15 |
+
<div style="display:flex;gap:20px;">
|
| 16 |
+
<div class="avatar avatar-xl avatar-purple">{{ profile.name[0] }}</div>
|
| 17 |
+
<div>
|
| 18 |
+
<h1 style="font-size:28px;font-weight:800;color:var(--text-primary);margin:0;">{{ profile.name }}</h1>
|
| 19 |
+
{% if profile.relationship_type %}
|
| 20 |
+
<span class="badge badge-purple" style="margin-top:8px;">{{ profile.relationship_type }}</span>
|
| 21 |
+
{% endif %}
|
| 22 |
+
{% if profile.description %}
|
| 23 |
+
<p style="color:var(--text-secondary);margin:12px 0 0;font-size:14px;line-height:1.6;max-width:600px;">{{ profile.description }}</p>
|
| 24 |
+
{% endif %}
|
| 25 |
+
<div style="display:flex;gap:20px;margin-top:12px;font-size:13px;color:var(--text-muted);">
|
| 26 |
+
{% if profile.date_of_birth %}<span>Born: {{ profile.date_of_birth }}</span>{% endif %}
|
| 27 |
+
{% if profile.date_of_death %}<span>Died: {{ profile.date_of_death }}</span>{% endif %}
|
| 28 |
+
<span>Created: {{ profile.created_at.strftime('%b %d, %Y') if profile.created_at else '' }}</span>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
<div style="display:flex;gap:10px;flex-shrink:0;align-items:center;">
|
| 33 |
+
<a href="/chat/{{ profile.id }}" style="display:inline-flex;align-items:center;gap:6px;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:12px;background:linear-gradient(135deg,#8b5cf6,#ec4899);color:white;cursor:pointer;transition:all 0.2s;" onmouseover="this.style.transform='translateY(-1px)';this.style.boxShadow='0 4px 20px rgba(139,92,246,0.4)'" onmouseout="this.style.transform='';this.style.boxShadow=''">
|
| 34 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
| 35 |
+
Chat
|
| 36 |
+
</a>
|
| 37 |
+
<a href="/profiles/{{ profile.id }}/timeline" style="display:inline-flex;align-items:center;gap:6px;text-decoration:none;font-size:13px;font-weight:500;padding:10px 20px;border-radius:12px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-primary);cursor:pointer;transition:all 0.2s;" onmouseover="this.style.borderColor='var(--accent-purple)'" onmouseout="this.style.borderColor='var(--border-primary)'">
|
| 38 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
| 39 |
+
Timeline
|
| 40 |
+
</a>
|
| 41 |
+
<button type="button" onclick="document.getElementById('editModal').style.display='flex'" style="display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:500;padding:10px 20px;border-radius:12px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-primary);cursor:pointer;transition:all 0.2s;" onmouseover="this.style.borderColor='var(--accent-purple)'" onmouseout="this.style.borderColor='var(--border-primary)'">
|
| 42 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
| 43 |
+
Edit
|
| 44 |
+
</button>
|
| 45 |
+
<form method="POST" action="/profiles/{{ profile.id }}/delete" onsubmit="return confirm('Delete this profile and all its data?')" style="margin:0;">
|
| 46 |
+
<button type="submit" style="display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:500;padding:10px 20px;border-radius:12px;background:rgba(239,68,68,0.1);color:#f87171;border:1px solid rgba(239,68,68,0.3);cursor:pointer;transition:all 0.2s;" onmouseover="this.style.background='rgba(239,68,68,0.2)'" onmouseout="this.style.background='rgba(239,68,68,0.1)'">
|
| 47 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
| 48 |
+
Delete
|
| 49 |
+
</button>
|
| 50 |
+
</form>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
{% if profile.personality_traits or profile.interests or profile.values or profile.favorite_phrases or profile.speaking_style %}
|
| 56 |
+
<div class="card slide-up" style="margin-bottom:24px;">
|
| 57 |
+
<h2 style="font-size:16px;font-weight:700;color:var(--text-primary);margin:0 0 16px;">About This Person</h2>
|
| 58 |
+
<div style="display:flex;flex-direction:column;gap:16px;">
|
| 59 |
+
{% if profile.personality_traits %}
|
| 60 |
+
<div>
|
| 61 |
+
<p style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 8px;">Personality Traits</p>
|
| 62 |
+
<div style="display:flex;flex-wrap:wrap;gap:8px;">
|
| 63 |
+
{% for trait in profile.personality_traits %}
|
| 64 |
+
<span class="badge badge-purple">{{ trait }}</span>
|
| 65 |
+
{% endfor %}
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
{% endif %}
|
| 69 |
+
{% if profile.interests %}
|
| 70 |
+
<div>
|
| 71 |
+
<p style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 8px;">Interests</p>
|
| 72 |
+
<div style="display:flex;flex-wrap:wrap;gap:8px;">
|
| 73 |
+
{% for interest in profile.interests %}
|
| 74 |
+
<span class="badge badge-blue">{{ interest }}</span>
|
| 75 |
+
{% endfor %}
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
{% endif %}
|
| 79 |
+
{% if profile.values %}
|
| 80 |
+
<div>
|
| 81 |
+
<p style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 8px;">Values</p>
|
| 82 |
+
<div style="display:flex;flex-wrap:wrap;gap:8px;">
|
| 83 |
+
{% for val in profile.values %}
|
| 84 |
+
<span class="badge badge-green">{{ val }}</span>
|
| 85 |
+
{% endfor %}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
{% endif %}
|
| 89 |
+
{% if profile.favorite_phrases %}
|
| 90 |
+
<div>
|
| 91 |
+
<p style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 8px;">Favorite Phrases</p>
|
| 92 |
+
<div style="display:flex;flex-direction:column;gap:6px;">
|
| 93 |
+
{% for phrase in profile.favorite_phrases %}
|
| 94 |
+
<p style="font-size:14px;color:var(--text-secondary);font-style:italic;margin:0;padding-left:12px;border-left:2px solid var(--accent-purple);">"{{ phrase }}"</p>
|
| 95 |
+
{% endfor %}
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
{% endif %}
|
| 99 |
+
{% if profile.speaking_style %}
|
| 100 |
+
<div>
|
| 101 |
+
<p style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 8px;">Speaking Style</p>
|
| 102 |
+
<p style="font-size:14px;color:var(--text-secondary);margin:0;">{{ profile.speaking_style }}</p>
|
| 103 |
+
</div>
|
| 104 |
+
{% endif %}
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
{% endif %}
|
| 108 |
+
|
| 109 |
+
<div class="card" style="padding:0;overflow:hidden;">
|
| 110 |
+
<div style="padding:20px 24px;border-bottom:1px solid var(--border-primary);">
|
| 111 |
+
<div style="display:flex;align-items:center;justify-content:space-between;">
|
| 112 |
+
<h2 style="font-size:16px;font-weight:700;color:var(--text-primary);margin:0;">Uploaded Files</h2>
|
| 113 |
+
<span class="badge badge-blue">{{ files|length }} file{{ 's' if files|length != 1 }}</span>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
<div style="padding:24px;">
|
| 117 |
+
<div id="uploadZone" class="upload-zone" style="margin-bottom:24px;">
|
| 118 |
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin:0 auto 12px;display:block;"><path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
|
| 119 |
+
<p style="color:var(--text-secondary);margin:0 0 4px;font-size:14px;">Drop files here or click to upload</p>
|
| 120 |
+
<p style="color:var(--text-muted);margin:0;font-size:12px;">PDF, DOCX, TXT, Images, Audio (max 50MB)</p>
|
| 121 |
+
<input type="file" id="fileInput" class="hidden" multiple accept=".pdf,.docx,.txt,.png,.jpg,.jpeg,.gif,.mp3,.wav,.m4a,.webm,.mp4">
|
| 122 |
+
</div>
|
| 123 |
+
<div id="uploadProgress" class="hidden" style="margin-bottom:20px;">
|
| 124 |
+
<div class="progress-bar"><div id="progressBar" class="progress-bar-fill" style="width:0%"></div></div>
|
| 125 |
+
<p id="progressText" style="font-size:13px;color:var(--text-secondary);margin:8px 0 0;"></p>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
{% if files %}
|
| 129 |
+
<div style="display:flex;flex-direction:column;gap:8px;" id="filesList">
|
| 130 |
+
{% for f in files %}
|
| 131 |
+
<div class="flex items-center justify-between" style="padding:14px 16px;border-radius:12px;border:1px solid var(--border-primary);transition:all 0.2s;" onmouseover="this.style.background='var(--bg-card-hover)'" onmouseout="this.style.background=''">
|
| 132 |
+
<div style="display:flex;align-items:center;gap:14px;">
|
| 133 |
+
<div class="file-icon {% if f.file_type == '.pdf' %}pdf{% elif f.file_type == '.txt' %}txt{% elif f.file_type in ['.png','.jpg','.jpeg','.gif'] %}img{% elif f.file_type in ['.mp3','.wav','.m4a'] %}audio{% else %}default{% endif %}">
|
| 134 |
+
{% if f.file_type == '.pdf' %}
|
| 135 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6"/></svg>
|
| 136 |
+
{% elif f.file_type == '.txt' %}
|
| 137 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
| 138 |
+
{% else %}
|
| 139 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6"/></svg>
|
| 140 |
+
{% endif %}
|
| 141 |
+
</div>
|
| 142 |
+
<div>
|
| 143 |
+
<p style="font-size:14px;font-weight:600;color:var(--text-primary);margin:0;">{{ f.original_name }}</p>
|
| 144 |
+
<p style="font-size:12px;color:var(--text-muted);margin:3px 0 0;">{{ f.file_type }} · {{ (f.file_size / 1024)|round(1) }}KB · {{ f.created_at.strftime('%b %d, %Y') if f.created_at else '' }}</p>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
<form method="POST" action="/profiles/{{ profile.id }}/files/{{ f.id }}/delete" onsubmit="return confirm('Delete this file?')">
|
| 148 |
+
<button type="submit" style="background:none;border:none;color:var(--text-muted);cursor:pointer;padding:8px;border-radius:8px;transition:all 0.2s;" onmouseover="this.style.color='#ef4444';this.style.background='rgba(239,68,68,0.1)'" onmouseout="this.style.color='var(--text-muted)';this.style.background=''">
|
| 149 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
| 150 |
+
</button>
|
| 151 |
+
</form>
|
| 152 |
+
</div>
|
| 153 |
+
{% endfor %}
|
| 154 |
+
</div>
|
| 155 |
+
{% else %}
|
| 156 |
+
<div class="empty-state" style="padding:32px;">
|
| 157 |
+
<p style="color:var(--text-muted);font-size:14px;margin:0;">No files uploaded yet</p>
|
| 158 |
+
</div>
|
| 159 |
+
{% endif %}
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div id="editModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);align-items:center;justify-content:center;z-index:50;">
|
| 164 |
+
<div class="card" style="max-width:520px;width:100%;margin:0 16px;padding:32px;max-height:90vh;overflow-y:auto;">
|
| 165 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;">
|
| 166 |
+
<h2 style="font-size:20px;font-weight:700;color:var(--text-primary);margin:0;">Edit Profile</h2>
|
| 167 |
+
<button type="button" onclick="document.getElementById('editModal').style.display='none'" style="background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px;">
|
| 168 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 169 |
+
</button>
|
| 170 |
+
</div>
|
| 171 |
+
<form method="POST" action="/profiles/{{ profile.id }}/update" style="display:flex;flex-direction:column;gap:16px;">
|
| 172 |
+
<div>
|
| 173 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Name</label>
|
| 174 |
+
<input type="text" name="name" value="{{ profile.name }}" required class="input-field">
|
| 175 |
+
</div>
|
| 176 |
+
<div>
|
| 177 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Relationship</label>
|
| 178 |
+
<input type="text" name="relationship" value="{{ profile.relationship_type or '' }}" class="input-field">
|
| 179 |
+
</div>
|
| 180 |
+
<div>
|
| 181 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Description</label>
|
| 182 |
+
<textarea name="description" rows="3" class="input-field">{{ profile.description or '' }}</textarea>
|
| 183 |
+
</div>
|
| 184 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
| 185 |
+
<div>
|
| 186 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Date of Birth</label>
|
| 187 |
+
<input type="date" name="date_of_birth" value="{{ profile.date_of_birth or '' }}" class="input-field">
|
| 188 |
+
</div>
|
| 189 |
+
<div>
|
| 190 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Date of Death</label>
|
| 191 |
+
<input type="date" name="date_of_death" value="{{ profile.date_of_death or '' }}" class="input-field">
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
<div>
|
| 195 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Personality Traits</label>
|
| 196 |
+
<input type="text" name="personality_traits" value="{{ profile.personality_traits|join(', ') if profile.personality_traits else '' }}" class="input-field" placeholder="Comma-separated">
|
| 197 |
+
</div>
|
| 198 |
+
<div>
|
| 199 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Interests</label>
|
| 200 |
+
<input type="text" name="interests" value="{{ profile.interests|join(', ') if profile.interests else '' }}" class="input-field" placeholder="Comma-separated">
|
| 201 |
+
</div>
|
| 202 |
+
<div>
|
| 203 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Favorite Phrases</label>
|
| 204 |
+
<textarea name="favorite_phrases" rows="2" class="input-field" placeholder="One phrase per line">{{ profile.favorite_phrases|join('\n') if profile.favorite_phrases else '' }}</textarea>
|
| 205 |
+
</div>
|
| 206 |
+
<div>
|
| 207 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Speaking Style</label>
|
| 208 |
+
<input type="text" name="speaking_style" value="{{ profile.speaking_style or '' }}" class="input-field">
|
| 209 |
+
</div>
|
| 210 |
+
<div>
|
| 211 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Writing Style</label>
|
| 212 |
+
<input type="text" name="writing_style" value="{{ profile.writing_style or '' }}" class="input-field">
|
| 213 |
+
</div>
|
| 214 |
+
<div>
|
| 215 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Values</label>
|
| 216 |
+
<input type="text" name="values" value="{{ profile.values|join(', ') if profile.values else '' }}" class="input-field" placeholder="Comma-separated">
|
| 217 |
+
</div>
|
| 218 |
+
<div style="display:flex;gap:12px;margin-top:8px;">
|
| 219 |
+
<button type="button" onclick="document.getElementById('editModal').style.display='none'" style="flex:1;display:inline-flex;align-items:center;justify-content:center;font-size:14px;font-weight:500;padding:10px 20px;border-radius:12px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-primary);cursor:pointer;">Cancel</button>
|
| 220 |
+
<button type="submit" style="flex:1;display:inline-flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;padding:10px 20px;border-radius:12px;background:linear-gradient(135deg,#8b5cf6,#ec4899);color:white;border:none;cursor:pointer;">Save Changes</button>
|
| 221 |
+
</div>
|
| 222 |
+
</form>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
<script>
|
| 230 |
+
const profileId = "{{ profile.id }}";
|
| 231 |
+
const uploadZone = document.getElementById('uploadZone');
|
| 232 |
+
const fileInput = document.getElementById('fileInput');
|
| 233 |
+
const uploadProgress = document.getElementById('uploadProgress');
|
| 234 |
+
const progressBar = document.getElementById('progressBar');
|
| 235 |
+
const progressText = document.getElementById('progressText');
|
| 236 |
+
|
| 237 |
+
uploadZone.addEventListener('click', () => fileInput.click());
|
| 238 |
+
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('drag-over'); });
|
| 239 |
+
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
|
| 240 |
+
uploadZone.addEventListener('drop', (e) => {
|
| 241 |
+
e.preventDefault();
|
| 242 |
+
uploadZone.classList.remove('drag-over');
|
| 243 |
+
handleFiles(e.dataTransfer.files);
|
| 244 |
+
});
|
| 245 |
+
fileInput.addEventListener('change', () => handleFiles(fileInput.files));
|
| 246 |
+
|
| 247 |
+
async function handleFiles(files) {
|
| 248 |
+
for (const file of files) {
|
| 249 |
+
uploadProgress.classList.remove('hidden');
|
| 250 |
+
progressText.textContent = `Uploading ${file.name}...`;
|
| 251 |
+
progressBar.style.width = '50%';
|
| 252 |
+
|
| 253 |
+
const formData = new FormData();
|
| 254 |
+
formData.append('file', file);
|
| 255 |
+
|
| 256 |
+
try {
|
| 257 |
+
const resp = await fetch(`/profiles/${profileId}/upload`, { method: 'POST', body: formData });
|
| 258 |
+
const data = await resp.json();
|
| 259 |
+
if (data.success) {
|
| 260 |
+
progressBar.style.width = '100%';
|
| 261 |
+
progressText.textContent = `Uploaded ${file.name} (${data.chunks} chunks)`;
|
| 262 |
+
setTimeout(() => location.reload(), 1000);
|
| 263 |
+
} else {
|
| 264 |
+
progressText.textContent = `Error: ${data.error}`;
|
| 265 |
+
progressBar.style.width = '0%';
|
| 266 |
+
}
|
| 267 |
+
} catch (e) {
|
| 268 |
+
progressText.textContent = `Error uploading ${file.name}`;
|
| 269 |
+
progressBar.style.width = '0%';
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
</script>
|
| 274 |
+
{% endblock %}
|
templates/profiles.html
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Profiles - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="min-h-full flex">
|
| 5 |
+
{% include "partials/sidebar.html" %}
|
| 6 |
+
<div class="flex-1" style="margin-left:256px;padding:32px;">
|
| 7 |
+
<div style="max-width:900px;margin:0 auto;">
|
| 8 |
+
<div class="flex items-center justify-between slide-up" style="margin-bottom:32px;">
|
| 9 |
+
<div>
|
| 10 |
+
<h1 style="font-size:28px;font-weight:800;color:var(--text-primary);margin:0;">Memory Profiles</h1>
|
| 11 |
+
<p style="margin-top:6px;color:var(--text-secondary);font-size:15px;">Create and manage memory profiles for your loved ones</p>
|
| 12 |
+
</div>
|
| 13 |
+
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="btn-primary">
|
| 14 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 15 |
+
New Profile
|
| 16 |
+
</button>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
{% if profiles %}
|
| 20 |
+
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:20px;">
|
| 21 |
+
{% for p in profiles %}
|
| 22 |
+
<a href="/profiles/{{ p.id }}" class="card slide-up" style="text-decoration:none;cursor:pointer;">
|
| 23 |
+
<div style="display:flex;gap:16px;">
|
| 24 |
+
<div class="avatar avatar-lg avatar-purple">{{ p.name[0] }}</div>
|
| 25 |
+
<div style="flex:1;min-width:0;">
|
| 26 |
+
<h3 style="font-size:18px;font-weight:700;color:var(--text-primary);margin:0;">{{ p.name }}</h3>
|
| 27 |
+
{% if p.relationship_type %}
|
| 28 |
+
<span class="badge badge-purple" style="margin-top:6px;">{{ p.relationship_type }}</span>
|
| 29 |
+
{% endif %}
|
| 30 |
+
{% if p.description %}
|
| 31 |
+
<p style="font-size:13px;color:var(--text-secondary);margin:10px 0 0;line-height:1.5;" class="line-clamp-2">{{ p.description }}</p>
|
| 32 |
+
{% endif %}
|
| 33 |
+
<div style="display:flex;gap:16px;margin-top:12px;font-size:12px;color:var(--text-muted);">
|
| 34 |
+
{% if p.date_of_birth %}<span>Born: {{ p.date_of_birth }}</span>{% endif %}
|
| 35 |
+
{% if p.date_of_death %}<span>Died: {{ p.date_of_death }}</span>{% endif %}
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</a>
|
| 40 |
+
{% endfor %}
|
| 41 |
+
</div>
|
| 42 |
+
{% else %}
|
| 43 |
+
<div class="card" style="padding:64px 32px;text-align:center;">
|
| 44 |
+
<div class="empty-state-icon" style="width:80px;height:80px;">
|
| 45 |
+
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="var(--accent-purple)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
| 46 |
+
</div>
|
| 47 |
+
<h3 style="font-size:20px;font-weight:700;color:var(--text-primary);margin:16px 0 8px;">No profiles yet</h3>
|
| 48 |
+
<p style="color:var(--text-secondary);font-size:14px;margin:0 0 24px;">Create your first memory profile to start preserving memories</p>
|
| 49 |
+
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="btn-primary" style="padding:12px 28px;">
|
| 50 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 51 |
+
Create First Profile
|
| 52 |
+
</button>
|
| 53 |
+
</div>
|
| 54 |
+
{% endif %}
|
| 55 |
+
|
| 56 |
+
<div id="createModal" class="hidden modal-overlay">
|
| 57 |
+
<div class="card" style="max-width:520px;width:100%;margin:0 16px;padding:32px;max-height:90vh;overflow-y:auto;" class="chat-scroll">
|
| 58 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;">
|
| 59 |
+
<h2 style="font-size:20px;font-weight:700;color:var(--text-primary);margin:0;">Create Memory Profile</h2>
|
| 60 |
+
<button onclick="document.getElementById('createModal').classList.add('hidden')" style="background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px;">
|
| 61 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 62 |
+
</button>
|
| 63 |
+
</div>
|
| 64 |
+
<form method="POST" action="/profiles/create" onsubmit="document.getElementById('createModal').classList.add('hidden');" style="display:flex;flex-direction:column;gap:16px;">
|
| 65 |
+
<div>
|
| 66 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Name *</label>
|
| 67 |
+
<input type="text" name="name" required class="input-field" placeholder="e.g., Margaret Johnson">
|
| 68 |
+
</div>
|
| 69 |
+
<div>
|
| 70 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Relationship</label>
|
| 71 |
+
<input type="text" name="relationship" class="input-field" placeholder="e.g., Grandmother, Father, Friend">
|
| 72 |
+
</div>
|
| 73 |
+
<div>
|
| 74 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Description</label>
|
| 75 |
+
<textarea name="description" rows="3" class="input-field" placeholder="Tell us about this person..."></textarea>
|
| 76 |
+
</div>
|
| 77 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
| 78 |
+
<div>
|
| 79 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Date of Birth</label>
|
| 80 |
+
<input type="date" name="date_of_birth" class="input-field">
|
| 81 |
+
</div>
|
| 82 |
+
<div>
|
| 83 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Date of Death</label>
|
| 84 |
+
<input type="date" name="date_of_death" class="input-field">
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
<div>
|
| 88 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Personality Traits</label>
|
| 89 |
+
<input type="text" name="personality_traits" class="input-field" placeholder="Comma-separated, e.g., Kind, Patient, Witty">
|
| 90 |
+
</div>
|
| 91 |
+
<div>
|
| 92 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Interests</label>
|
| 93 |
+
<input type="text" name="interests" class="input-field" placeholder="Comma-separated, e.g., Gardening, Reading, Cooking">
|
| 94 |
+
</div>
|
| 95 |
+
<div>
|
| 96 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Favorite Phrases</label>
|
| 97 |
+
<textarea name="favorite_phrases" rows="2" class="input-field" placeholder="One phrase per line"></textarea>
|
| 98 |
+
</div>
|
| 99 |
+
<div>
|
| 100 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Speaking Style</label>
|
| 101 |
+
<input type="text" name="speaking_style" class="input-field" placeholder="e.g., Warm and gentle, with a slight drawl">
|
| 102 |
+
</div>
|
| 103 |
+
<div>
|
| 104 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Writing Style</label>
|
| 105 |
+
<input type="text" name="writing_style" class="input-field" placeholder="e.g., Heartfelt and full of wisdom">
|
| 106 |
+
</div>
|
| 107 |
+
<div>
|
| 108 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Values</label>
|
| 109 |
+
<input type="text" name="values" class="input-field" placeholder="Comma-separated, e.g., Family, Faith, Kindness">
|
| 110 |
+
</div>
|
| 111 |
+
<div style="display:flex;gap:12px;margin-top:8px;">
|
| 112 |
+
<button type="button" onclick="document.getElementById('createModal').classList.add('hidden')" class="btn-secondary" style="flex:1;justify-content:center;">Cancel</button>
|
| 113 |
+
<button type="submit" class="btn-primary" style="flex:1;justify-content:center;">Create Profile</button>
|
| 114 |
+
</div>
|
| 115 |
+
</form>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
{% endblock %}
|
templates/register.html
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Register - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg-primary);padding:16px;">
|
| 5 |
+
<div style="max-width:440px;width:100%;">
|
| 6 |
+
<div class="text-center" style="margin-bottom:32px;">
|
| 7 |
+
<div class="avatar avatar-xl" style="background:var(--gradient-primary);margin:0 auto 20px;box-shadow:0 0 40px rgba(139,92,246,0.3);">
|
| 8 |
+
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 9 |
+
<path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/>
|
| 10 |
+
</svg>
|
| 11 |
+
</div>
|
| 12 |
+
<h1 style="font-size:32px;font-weight:800;color:var(--text-primary);margin:0;">Create Account</h1>
|
| 13 |
+
<p style="margin-top:8px;color:var(--text-secondary);font-size:15px;">Join MemoryBot to preserve memories</p>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div class="card" style="padding:32px;">
|
| 17 |
+
{% if error %}
|
| 18 |
+
<div class="alert-error" style="margin-bottom:16px;text-align:center;">{{ error }}</div>
|
| 19 |
+
{% endif %}
|
| 20 |
+
|
| 21 |
+
<form method="POST" action="/auth/register" style="display:flex;flex-direction:column;gap:16px;">
|
| 22 |
+
<div>
|
| 23 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Full Name</label>
|
| 24 |
+
<input type="text" name="name" required class="input-field" placeholder="John Smith">
|
| 25 |
+
</div>
|
| 26 |
+
<div>
|
| 27 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Email</label>
|
| 28 |
+
<input type="email" name="email" required class="input-field" placeholder="you@example.com">
|
| 29 |
+
</div>
|
| 30 |
+
<div>
|
| 31 |
+
<label style="display:block;font-size:13px;font-weight:600;color:var(--text-secondary);margin-bottom:6px;">Password</label>
|
| 32 |
+
<input type="password" name="password" required minlength="6" class="input-field" placeholder="At least 6 characters">
|
| 33 |
+
</div>
|
| 34 |
+
<button type="submit" class="btn-primary" style="width:100%;justify-content:center;padding:14px;font-size:15px;margin-top:8px;">
|
| 35 |
+
Create Account
|
| 36 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
| 37 |
+
</button>
|
| 38 |
+
</form>
|
| 39 |
+
|
| 40 |
+
<div class="divider" style="margin:24px 0;"></div>
|
| 41 |
+
|
| 42 |
+
<p style="text-align:center;font-size:14px;color:var(--text-secondary);">
|
| 43 |
+
Already have an account?
|
| 44 |
+
<a href="/auth/login" style="color:var(--accent-purple);text-decoration:none;font-weight:600;">Sign in</a>
|
| 45 |
+
</p>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
{% endblock %}
|
templates/search.html
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Search - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="min-h-full flex">
|
| 5 |
+
{% include "partials/sidebar.html" %}
|
| 6 |
+
<div class="flex-1" style="margin-left:256px;padding:32px;">
|
| 7 |
+
<div style="max-width:900px;margin:0 auto;">
|
| 8 |
+
<div class="slide-up" style="margin-bottom:32px;">
|
| 9 |
+
<h1 style="font-size:28px;font-weight:800;color:var(--text-primary);margin:0;">Search Memories</h1>
|
| 10 |
+
<p style="margin-top:6px;color:var(--text-secondary);font-size:15px;">Search through all uploaded memories using AI-powered search</p>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<form method="POST" action="/search" style="margin-bottom:32px;">
|
| 14 |
+
<div style="display:flex;gap:12px;">
|
| 15 |
+
<div style="flex:1;position:relative;">
|
| 16 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:16px;top:50%;transform:translateY(-50%);"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
| 17 |
+
<input type="text" name="query" value="{{ query }}" placeholder="Search memories..." class="input-field" style="padding:14px 16px 14px 44px;font-size:15px;border-radius:14px;">
|
| 18 |
+
</div>
|
| 19 |
+
<select name="profile_id" class="select-field" style="width:200px;padding:14px 16px;border-radius:14px;">
|
| 20 |
+
<option value="">All profiles</option>
|
| 21 |
+
{% for p in profiles %}
|
| 22 |
+
<option value="{{ p.id }}" {% if selected_profile is defined and selected_profile == p.id %}selected{% endif %}>{{ p.name }}</option>
|
| 23 |
+
{% endfor %}
|
| 24 |
+
</select>
|
| 25 |
+
<button type="submit" class="btn-primary" style="padding:14px 28px;border-radius:14px;font-size:15px;">
|
| 26 |
+
Search
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
</form>
|
| 30 |
+
|
| 31 |
+
{% if results %}
|
| 32 |
+
<p style="font-size:13px;color:var(--text-muted);margin-bottom:16px;">Found <strong style="color:var(--accent-purple);">{{ results|length }}</strong> result{{ 's' if results|length != 1 }}</p>
|
| 33 |
+
<div style="display:flex;flex-direction:column;gap:12px;">
|
| 34 |
+
{% for r in results %}
|
| 35 |
+
<div class="card slide-up" style="padding:20px;">
|
| 36 |
+
<div style="display:flex;align-items:flex-start;gap:16px;">
|
| 37 |
+
<div style="width:4px;min-height:40px;border-radius:2px;background:var(--gradient-primary);flex-shrink:0;"></div>
|
| 38 |
+
<div style="flex:1;">
|
| 39 |
+
{% if r.profile_name is defined %}
|
| 40 |
+
<span class="badge badge-purple" style="margin-bottom:8px;">{{ r.profile_name }}</span>
|
| 41 |
+
{% endif %}
|
| 42 |
+
<p style="color:var(--text-primary);font-size:14px;line-height:1.6;margin:0;">{{ r.content }}</p>
|
| 43 |
+
<div style="display:flex;gap:16px;margin-top:12px;font-size:12px;color:var(--text-muted);">
|
| 44 |
+
<span style="display:flex;align-items:center;gap:4px;">
|
| 45 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
| 46 |
+
Relevance: {{ (r.score * 100)|round(1) }}%
|
| 47 |
+
</span>
|
| 48 |
+
<span>Chunk {{ r.chunk_index + 1 }}</span>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
{% endfor %}
|
| 54 |
+
</div>
|
| 55 |
+
{% elif query %}
|
| 56 |
+
<div class="card" style="padding:64px 32px;text-align:center;">
|
| 57 |
+
<div class="empty-state-icon" style="margin:0 auto 16px;">
|
| 58 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
| 59 |
+
</div>
|
| 60 |
+
<p style="color:var(--text-secondary);font-size:14px;margin:0;">No results found for "<strong style="color:var(--text-primary);">{{ query }}</strong>". Try different keywords or upload more files.</p>
|
| 61 |
+
</div>
|
| 62 |
+
{% else %}
|
| 63 |
+
<div class="card" style="padding:64px 32px;text-align:center;">
|
| 64 |
+
<div class="empty-state-icon" style="margin:0 auto 16px;">
|
| 65 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent-purple)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
| 66 |
+
</div>
|
| 67 |
+
<h3 style="font-size:18px;font-weight:700;color:var(--text-primary);margin:0 0 8px;">Search Your Memories</h3>
|
| 68 |
+
<p style="color:var(--text-secondary);font-size:14px;margin:0;">Enter a query above to search through all uploaded memories</p>
|
| 69 |
+
</div>
|
| 70 |
+
{% endif %}
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
{% endblock %}
|
templates/timeline.html
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Timeline - {{ profile.name }} - MemoryBot{% endblock %}
|
| 3 |
+
{% block body %}
|
| 4 |
+
<div class="min-h-full flex">
|
| 5 |
+
{% include "partials/sidebar.html" %}
|
| 6 |
+
<div class="flex-1" style="margin-left:256px;padding:32px;">
|
| 7 |
+
<div style="max-width:800px;margin:0 auto;">
|
| 8 |
+
<a href="/profiles/{{ profile.id }}" style="display:inline-flex;align-items:center;gap:6px;color:var(--accent-purple);text-decoration:none;font-size:13px;font-weight:600;margin-bottom:20px;">
|
| 9 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 19l-7-7 7-7"/></svg>
|
| 10 |
+
Back to {{ profile.name }}
|
| 11 |
+
</a>
|
| 12 |
+
|
| 13 |
+
<div class="slide-up" style="margin-bottom:32px;">
|
| 14 |
+
<h1 style="font-size:28px;font-weight:800;color:var(--text-primary);margin:0;">Memory Timeline</h1>
|
| 15 |
+
<p style="margin-top:6px;color:var(--text-secondary);font-size:15px;">Chronological view of memories for {{ profile.name }}</p>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
{% if monthly %}
|
| 19 |
+
<div style="position:relative;padding-left:32px;">
|
| 20 |
+
<div style="position:absolute;left:12px;top:0;bottom:0;width:2px;background:var(--border-primary);"></div>
|
| 21 |
+
|
| 22 |
+
{% for month, items in monthly.items() %}
|
| 23 |
+
<div class="slide-up" style="margin-bottom:32px;">
|
| 24 |
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;position:relative;">
|
| 25 |
+
<div style="width:24px;height:24px;border-radius:50%;background:var(--gradient-primary);display:flex;align-items:center;justify-content:center;position:relative;left:-20px;z-index:1;">
|
| 26 |
+
<div style="width:8px;height:8px;border-radius:50%;background:white;"></div>
|
| 27 |
+
</div>
|
| 28 |
+
<h2 style="font-size:18px;font-weight:700;color:var(--text-primary);margin:0;">{{ month }}</h2>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div style="display:flex;flex-direction:column;gap:12px;">
|
| 32 |
+
{% for item in items %}
|
| 33 |
+
<div class="card" style="padding:16px;margin-left:4px;">
|
| 34 |
+
<div style="display:flex;align-items:flex-start;gap:12px;">
|
| 35 |
+
<div style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;{% if item.type == 'file' %}background:rgba(59,130,246,0.15);{% else %}background:rgba(139,92,246,0.15);{% endif %}">
|
| 36 |
+
{% if item.type == 'file' %}
|
| 37 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="{% if item.type == 'file' %}#3b82f6{% else %}#8b5cf6{% endif %}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6"/></svg>
|
| 38 |
+
{% else %}
|
| 39 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
| 40 |
+
{% endif %}
|
| 41 |
+
</div>
|
| 42 |
+
<div style="flex:1;">
|
| 43 |
+
<div style="display:flex;align-items:center;justify-content:space-between;">
|
| 44 |
+
<h3 style="font-size:14px;font-weight:600;color:var(--text-primary);margin:0;">{{ item.title }}</h3>
|
| 45 |
+
<span class="badge {% if item.type == 'file' %}badge-blue{% else %}badge-purple{% endif %}" style="font-size:11px;">{{ item.type }}</span>
|
| 46 |
+
</div>
|
| 47 |
+
<p style="font-size:13px;color:var(--text-secondary);margin:4px 0 0;">{{ item.detail }}</p>
|
| 48 |
+
<p style="font-size:12px;color:var(--text-muted);margin:4px 0 0;">{{ item.date.strftime('%b %d, %Y at %H:%M') if item.date else '' }}</p>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
{% endfor %}
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
{% endfor %}
|
| 56 |
+
</div>
|
| 57 |
+
{% else %}
|
| 58 |
+
<div class="card" style="padding:64px 32px;text-align:center;">
|
| 59 |
+
<div class="empty-state-icon" style="margin:0 auto 16px;">
|
| 60 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent-purple)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
| 61 |
+
</div>
|
| 62 |
+
<h3 style="font-size:18px;font-weight:700;color:var(--text-primary);margin:0 0 8px;">No Timeline Yet</h3>
|
| 63 |
+
<p style="color:var(--text-secondary);font-size:14px;margin:0;">Upload files or start conversations to build a timeline of memories.</p>
|
| 64 |
+
</div>
|
| 65 |
+
{% endif %}
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
{% endblock %}
|
text_extractor.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import re
|
| 4 |
+
from typing import List, Tuple
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def extract_text_from_file(file_path: str, file_type: str) -> Tuple[str, List[str]]:
|
| 8 |
+
text = ""
|
| 9 |
+
try:
|
| 10 |
+
if file_type == ".txt":
|
| 11 |
+
text = _extract_txt(file_path)
|
| 12 |
+
elif file_type == ".pdf":
|
| 13 |
+
text = _extract_pdf(file_path)
|
| 14 |
+
elif file_type == ".docx":
|
| 15 |
+
text = _extract_docx(file_path)
|
| 16 |
+
elif file_type in (".png", ".jpg", ".jpeg", ".gif"):
|
| 17 |
+
text = _extract_image_metadata(file_path, file_type)
|
| 18 |
+
elif file_type in (".mp3", ".wav", ".m4a", ".webm", ".mp4"):
|
| 19 |
+
text = _extract_audio_metadata(file_path, file_type)
|
| 20 |
+
else:
|
| 21 |
+
text = f"[File: {os.path.basename(file_path)} - file type {file_type} is stored but text extraction is not supported]"
|
| 22 |
+
except Exception as e:
|
| 23 |
+
text = f"[Error extracting text from {os.path.basename(file_path)}: {str(e)}]"
|
| 24 |
+
|
| 25 |
+
chunks = chunk_text(text) if text else []
|
| 26 |
+
return text, chunks
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _extract_txt(file_path: str) -> str:
|
| 30 |
+
encodings = ["utf-8", "latin-1", "cp1252"]
|
| 31 |
+
for enc in encodings:
|
| 32 |
+
try:
|
| 33 |
+
with open(file_path, "r", encoding=enc) as f:
|
| 34 |
+
return f.read()
|
| 35 |
+
except (UnicodeDecodeError, UnicodeError):
|
| 36 |
+
continue
|
| 37 |
+
return ""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _extract_pdf(file_path: str) -> str:
|
| 41 |
+
try:
|
| 42 |
+
from PyPDF2 import PdfReader
|
| 43 |
+
reader = PdfReader(file_path)
|
| 44 |
+
text_parts = []
|
| 45 |
+
for page in reader.pages:
|
| 46 |
+
page_text = page.extract_text()
|
| 47 |
+
if page_text:
|
| 48 |
+
text_parts.append(page_text)
|
| 49 |
+
return "\n\n".join(text_parts)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
return f"[PDF extraction error: {e}]"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _extract_docx(file_path: str) -> str:
|
| 55 |
+
try:
|
| 56 |
+
from docx import Document
|
| 57 |
+
doc = Document(file_path)
|
| 58 |
+
return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
| 59 |
+
except Exception as e:
|
| 60 |
+
return f"[DOCX extraction error: {e}]"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _extract_image_metadata(file_path: str, file_type: str) -> str:
|
| 64 |
+
size = os.path.getsize(file_path)
|
| 65 |
+
return f"[Image file: {os.path.basename(file_path)}, type: {file_type}, size: {size} bytes. Upload text descriptions or notes about this image as separate text files for better memory recall.]"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _extract_audio_metadata(file_path: str, file_type: str) -> str:
|
| 69 |
+
size = os.path.getsize(file_path)
|
| 70 |
+
return f"[Audio file: {os.path.basename(file_path)}, type: {file_type}, size: {size} bytes. You can describe the content of this audio in chat to add it to the memory context.]"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def chunk_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> List[str]:
|
| 74 |
+
if not text or not text.strip():
|
| 75 |
+
return []
|
| 76 |
+
|
| 77 |
+
text = text.strip()
|
| 78 |
+
if len(text) <= chunk_size:
|
| 79 |
+
return [text]
|
| 80 |
+
|
| 81 |
+
paragraphs = re.split(r'\n\s*\n', text)
|
| 82 |
+
chunks = []
|
| 83 |
+
current_chunk = ""
|
| 84 |
+
|
| 85 |
+
for para in paragraphs:
|
| 86 |
+
para = para.strip()
|
| 87 |
+
if not para:
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
if len(current_chunk) + len(para) + 2 <= chunk_size:
|
| 91 |
+
current_chunk = f"{current_chunk}\n\n{para}" if current_chunk else para
|
| 92 |
+
else:
|
| 93 |
+
if current_chunk:
|
| 94 |
+
chunks.append(current_chunk)
|
| 95 |
+
if overlap > 0:
|
| 96 |
+
words = current_chunk.split()
|
| 97 |
+
overlap_words = words[-overlap // 5:] if len(words) > overlap // 5 else []
|
| 98 |
+
current_chunk = " ".join(overlap_words) + "\n\n" + para if overlap_words else para
|
| 99 |
+
else:
|
| 100 |
+
current_chunk = para
|
| 101 |
+
else:
|
| 102 |
+
if len(para) > chunk_size:
|
| 103 |
+
words = para.split()
|
| 104 |
+
temp = ""
|
| 105 |
+
for word in words:
|
| 106 |
+
if len(temp) + len(word) + 1 <= chunk_size:
|
| 107 |
+
temp = f"{temp} {word}" if temp else word
|
| 108 |
+
else:
|
| 109 |
+
chunks.append(temp)
|
| 110 |
+
temp = word
|
| 111 |
+
current_chunk = temp
|
| 112 |
+
else:
|
| 113 |
+
current_chunk = para
|
| 114 |
+
|
| 115 |
+
if current_chunk:
|
| 116 |
+
chunks.append(current_chunk)
|
| 117 |
+
|
| 118 |
+
return chunks if chunks else [text[:chunk_size]]
|