likki1715 commited on
Commit
26f3daf
Β·
verified Β·
1 Parent(s): 80cb043

Initial commit of application code

Browse files

Upload custom Gradio Server UI, FastAPI app, Dockerfile, requirements, and MIT License.

Files changed (6) hide show
  1. .dockerignore +8 -0
  2. Dockerfile +21 -0
  3. LICENSE +21 -0
  4. app.py +165 -0
  5. index.html +586 -0
  6. requirements.txt +2 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .ipynb_checkpoints
4
+ *.ipynb
5
+ __pycache__
6
+ modal_inference.py
7
+ README.md
8
+ data/
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /code
4
+
5
+ COPY ./requirements.txt /code/requirements.txt
6
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Set up a non-root user for security (required by HF Spaces)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV HOME=/home/user \
14
+ PATH=/home/user/.local/bin:$PATH
15
+
16
+ WORKDIR $HOME/app
17
+
18
+ COPY --chown=user . $HOME/app
19
+
20
+ # Start the Gradio Server app on port 7860
21
+ CMD ["python", "app.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kongara Likhith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests, json, os, asyncio, time
2
+ from datetime import datetime
3
+ from fastapi import FastAPI, UploadFile, File, Form
4
+ from fastapi.responses import HTMLResponse, JSONResponse
5
+ from typing import List
6
+ import uvicorn
7
+
8
+ TRANSCRIBE_URL = "https://likhith0715--memory-keeper-transcribe-audio.modal.run"
9
+ PHOTO_URL = "https://likhith0715--memory-keeper-describe-photo.modal.run"
10
+ BOOK_URL = "https://likhith0715--memory-keeper-build-memory-book.modal.run"
11
+
12
+ # Storage directory β€” persists on HF Spaces
13
+ STORAGE_DIR = "/data/memories"
14
+ os.makedirs(STORAGE_DIR, exist_ok=True)
15
+
16
+ def profile_path(name: str) -> str:
17
+ safe = "".join(c if c.isalnum() or c in "-_ " else "_" for c in name).strip().lower()
18
+ return f"{STORAGE_DIR}/{safe}.json"
19
+
20
+ def load_profile(name: str) -> dict:
21
+ path = profile_path(name)
22
+ if os.path.exists(path):
23
+ with open(path) as f:
24
+ return json.load(f)
25
+ return {"name": name, "transcripts": [], "photo_descriptions": [], "sessions": 0, "history": []}
26
+
27
+ def save_profile(profile: dict):
28
+ with open(profile_path(profile["name"]), "w") as f:
29
+ json.dump(profile, f, indent=2)
30
+
31
+ def list_profiles() -> list:
32
+ profiles = []
33
+ for fname in os.listdir(STORAGE_DIR):
34
+ if fname.endswith(".json"):
35
+ with open(f"{STORAGE_DIR}/{fname}") as f:
36
+ try:
37
+ p = json.load(f)
38
+ profiles.append({
39
+ "name": p["name"],
40
+ "sessions": p.get("sessions", 0),
41
+ "memories": len(p.get("transcripts", [])),
42
+ "photos": len(p.get("photo_descriptions", []))
43
+ })
44
+ except: pass
45
+ import gradio as gr
46
+
47
+ fast_app = gr.Server()
48
+
49
+ async def cleanup_old_memories():
50
+ """Background task to delete profile JSONs older than 48 hours."""
51
+ while True:
52
+ now = time.time()
53
+ for fname in os.listdir(STORAGE_DIR):
54
+ if fname.endswith(".json"):
55
+ path = os.path.join(STORAGE_DIR, fname)
56
+ try:
57
+ if now - os.path.getmtime(path) > 48 * 3600:
58
+ os.remove(path)
59
+ except Exception:
60
+ pass
61
+ await asyncio.sleep(3600) # Check every hour
62
+
63
+ @fast_app.on_event("startup")
64
+ async def startup_event():
65
+ asyncio.create_task(cleanup_old_memories())
66
+
67
+ @fast_app.get("/", response_class=HTMLResponse)
68
+ def index():
69
+ return open("index.html").read()
70
+
71
+ @fast_app.get("/profiles")
72
+ def get_profiles():
73
+ return JSONResponse(list_profiles())
74
+
75
+ @fast_app.get("/profile/{name}")
76
+ def get_profile(name: str):
77
+ return JSONResponse(load_profile(name))
78
+
79
+ @fast_app.delete("/profile/{name}")
80
+ def delete_profile(name: str):
81
+ path = profile_path(name)
82
+ if os.path.exists(path):
83
+ os.remove(path)
84
+ return JSONResponse({"ok": True})
85
+
86
+ @fast_app.post("/run")
87
+ async def run(
88
+ name: str = Form(...),
89
+ text: str = Form(""),
90
+ audios: List[UploadFile] = File(default=[]),
91
+ photos: List[UploadFile] = File(default=[])
92
+ ):
93
+ # Load existing profile
94
+ profile = load_profile(name)
95
+ new_transcripts = []
96
+ new_photos = []
97
+
98
+ if text.strip():
99
+ new_transcripts.append(text)
100
+
101
+ for audio in audios:
102
+ if audio and audio.filename:
103
+ audio_bytes = await audio.read()
104
+ if audio_bytes:
105
+ resp = requests.post(
106
+ TRANSCRIBE_URL, data=audio_bytes,
107
+ headers={"Content-Type": "application/octet-stream"},
108
+ timeout=300
109
+ )
110
+ if resp.status_code == 200:
111
+ new_transcripts.append(f"[Voice]: {resp.json()}")
112
+
113
+ for photo in photos:
114
+ if photo and photo.filename:
115
+ photo_bytes = await photo.read()
116
+ if photo_bytes:
117
+ resp = requests.post(
118
+ PHOTO_URL, data=photo_bytes,
119
+ headers={"Content-Type": "application/octet-stream"},
120
+ timeout=300
121
+ )
122
+ if resp.status_code == 200:
123
+ new_photos.append(resp.json())
124
+
125
+ if not new_transcripts and not new_photos:
126
+ return JSONResponse({"error": "No memories provided"}, status_code=400)
127
+
128
+ # Accumulate into profile
129
+ profile["transcripts"].extend(new_transcripts)
130
+ profile["photo_descriptions"].extend(new_photos)
131
+ profile["sessions"] = profile.get("sessions", 0) + 1
132
+ save_profile(profile)
133
+
134
+ # Build book from ALL accumulated memories
135
+ if not profile["transcripts"]:
136
+ profile["transcripts"].append(f"Photos were shared of {name}.")
137
+
138
+ resp = requests.post(BOOK_URL, json={
139
+ "name": name,
140
+ "transcripts": profile["transcripts"],
141
+ "photo_descriptions": profile["photo_descriptions"]
142
+ }, timeout=600)
143
+
144
+ result = resp.json()
145
+
146
+ # Append to history and save again
147
+ history_entry = {
148
+ "session": profile["sessions"],
149
+ "timestamp": datetime.now().strftime("%b %d, %Y - %H:%M"),
150
+ "timeline": result.get("timeline", ""),
151
+ "chapter": result.get("chapter", ""),
152
+ "letter": result.get("letter", ""),
153
+ "people": result.get("people", "")
154
+ }
155
+ profile.setdefault("history", []).append(history_entry)
156
+ save_profile(profile)
157
+
158
+ result["sessions"] = profile["sessions"]
159
+ result["total_memories"] = len(profile["transcripts"])
160
+ result["total_photos"] = len(profile["photo_descriptions"])
161
+ result["history"] = profile["history"]
162
+ return JSONResponse(result)
163
+
164
+ if __name__ == "__main__":
165
+ fast_app.launch(server_name="0.0.0.0", server_port=7860)
index.html ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Memory Keeper</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
8
+ <script src="https://unpkg.com/lucide@latest"></script>
9
+ <style>
10
+ :root {
11
+ --gold-light: #f5dfa0;
12
+ --gold-main: #c89840;
13
+ --gold-dark: #7a5528;
14
+ --bg-dark: #0a0806;
15
+ --glass-bg: rgba(20, 15, 10, 0.45);
16
+ --glass-border: rgba(200, 160, 80, 0.15);
17
+ --glass-highlight: rgba(255, 220, 150, 0.1);
18
+ --text-primary: #e8d5b0;
19
+ --text-secondary: #9a7845;
20
+ }
21
+
22
+ /* Icons */
23
+ .lucide { width: 1.2em; height: 1.2em; vertical-align: middle; stroke-width: 2px; }
24
+
25
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
26
+
27
+ body {
28
+ background-color: var(--bg-dark);
29
+ background-image:
30
+ radial-gradient(circle at 15% 50%, rgba(122, 85, 40, 0.15), transparent 25%),
31
+ radial-gradient(circle at 85% 30%, rgba(200, 152, 64, 0.1), transparent 25%);
32
+ background-attachment: fixed;
33
+ color: var(--text-primary);
34
+ font-family: 'Inter', sans-serif;
35
+ min-height: 100vh;
36
+ overflow-x: hidden;
37
+ }
38
+
39
+ /* Custom Scrollbar */
40
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
41
+ ::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); }
42
+ ::-webkit-scrollbar-thumb { background: var(--gold-dark); border-radius: 4px; }
43
+ ::-webkit-scrollbar-thumb:hover { background: var(--gold-main); }
44
+
45
+ .wrap { max-width: 1200px; margin: 0 auto; padding: 0 24px 60px; position: relative; z-index: 1; }
46
+
47
+ /* Animated Orbs */
48
+ .orb { position: absolute; border-radius: 50%; filter: blur(80px); z-index: -1; animation: floatOrb 15s ease-in-out infinite alternate; }
49
+ .orb-1 { width: 400px; height: 400px; background: rgba(200, 152, 64, 0.1); top: -100px; left: 20%; }
50
+ .orb-2 { width: 300px; height: 300px; background: rgba(122, 85, 40, 0.15); top: 40%; right: 10%; animation-delay: -5s; }
51
+ @keyframes floatOrb { 0% { transform: translate(0, 0); } 100% { transform: translate(30px, 50px); } }
52
+
53
+ .hero { text-align: center; padding: 25px 0 20px; position: relative; }
54
+ .hero-icon { font-size: 2.5em; display: inline-block; margin-bottom: 6px; filter: drop-shadow(0 0 30px rgba(200,155,70,0.5)); animation: float 4s ease-in-out infinite; color: var(--gold-light); }
55
+ @keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
56
+
57
+ .hero h1 { font-family: 'Playfair Display', serif; font-size: 3.2em; font-weight: 700; color: var(--gold-light); letter-spacing: 2px; text-shadow: 0 0 40px rgba(200,155,70,0.3); margin-bottom: 8px; }
58
+ .hero-sub { font-size: 1.1em; color: var(--text-secondary); font-weight: 300; letter-spacing: 0.5px; margin-bottom: 24px; }
59
+
60
+
61
+
62
+ /* GRID */
63
+ .grid { display: grid; grid-template-columns: 380px 1fr; gap: 30px; align-items: start; }
64
+
65
+ .card { padding: 28px; transition: transform 0.3s; }
66
+ .card:hover { border-color: rgba(200, 160, 80, 0.25); box-shadow: 0 30px 60px -12px rgba(0,0,0,0.7); }
67
+
68
+ .card-title { font-family: 'Playfair Display', serif; font-size: 1.1em; color: var(--gold-light); letter-spacing: 1px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; font-weight: 600; text-shadow: 0 2px 4px rgba(0,0,0,0.5); }
69
+ .card-title::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, rgba(200, 160, 80, 0.2), transparent); margin-left: 10px; }
70
+
71
+ /* STATS BAR */
72
+ .stats-bar { display: flex; gap: 12px; margin-bottom: 24px; }
73
+ .stat { background: rgba(0,0,0,0.3); border: 1px solid rgba(200, 160, 80, 0.1); border-radius: 12px; padding: 12px; flex: 1; text-align: center; transition: all 0.3s; }
74
+ .stat:hover { background: rgba(200, 160, 80, 0.05); border-color: rgba(200, 160, 80, 0.2); }
75
+ .stat-num { font-family: 'Playfair Display', serif; font-size: 1.6em; color: var(--gold-main); display: block; font-weight: 600; text-shadow: 0 2px 10px rgba(200, 152, 64, 0.2); }
76
+ .stat-label { font-size: 0.65em; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 1px; font-weight: 500; margin-top: 4px; display: block; }
77
+
78
+ /* INPUTS */
79
+ .field { margin-bottom: 20px; position: relative; }
80
+ .field label { display: block; font-size: 0.75em; font-weight: 600; color: var(--text-secondary); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 8px; }
81
+ .field input[type=text], .field textarea { width: 100%; background: rgba(0,0,0,0.25); border: 1px solid rgba(200, 160, 80, 0.15); border-radius: 10px; color: var(--text-primary); font-family: 'Inter', sans-serif; font-size: 0.95em; padding: 12px 16px; outline: none; transition: all 0.3s; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); resize: vertical; }
82
+ .field input[type=text]::placeholder, .field textarea::placeholder { color: rgba(150, 120, 70, 0.4); }
83
+ .field input[type=text]:focus, .field textarea:focus { border-color: var(--gold-main); background: rgba(0,0,0,0.4); box-shadow: inset 0 2px 4px rgba(0,0,0,0.2), 0 0 0 3px rgba(200, 152, 64, 0.1); }
84
+
85
+ /* UPLOAD & RECORD SECTIONS */
86
+ .section-box { background: rgba(0,0,0,0.2); border: 1px solid rgba(200, 160, 80, 0.08); border-radius: 14px; padding: 16px; margin-bottom: 20px; }
87
+ .section-box-title { font-size: 0.75em; color: var(--gold-dark); letter-spacing: 1px; text-transform: uppercase; margin-bottom: 12px; font-weight: 600; }
88
+
89
+ .rec-row { display: flex; gap: 10px; margin-bottom: 12px; }
90
+ .action-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 10px; font-size: 0.85em; font-weight: 500; padding: 10px 14px; cursor: pointer; transition: all 0.3s; position: relative; overflow: hidden; }
91
+
92
+ .rec-btn { background: rgba(220, 60, 60, 0.1); border: 1px solid rgba(220, 60, 60, 0.2); color: #ff8888; }
93
+ .rec-btn:hover { background: rgba(220, 60, 60, 0.2); border-color: rgba(220, 60, 60, 0.4); transform: translateY(-1px); }
94
+ .rec-btn.recording { background: rgba(220, 40, 40, 0.2); border-color: #ff4444; color: #ffaaaa; animation: pulseRecord 1.5s infinite; }
95
+ @keyframes pulseRecord { 0% { box-shadow: 0 0 0 0 rgba(220, 60, 60, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(220, 60, 60, 0); } 100% { box-shadow: 0 0 0 0 rgba(220, 60, 60, 0); } }
96
+
97
+ .add-btn { background: rgba(200, 152, 64, 0.05); border: 1px dashed rgba(200, 152, 64, 0.3); color: var(--gold-main); }
98
+ .add-btn:hover { background: rgba(200, 152, 64, 0.1); border-color: var(--gold-main); border-style: solid; transform: translateY(-1px); }
99
+ .add-btn input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
100
+
101
+ .file-list { display: flex; flex-direction: column; gap: 8px; }
102
+ .file-item { display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.3); border: 1px solid rgba(200, 160, 80, 0.15); border-radius: 10px; padding: 8px 12px; animation: slideIn 0.3s ease; }
103
+ @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
104
+ .file-item .file-icon { font-size: 1.2em; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
105
+ .file-item .file-name { flex: 1; font-size: 0.85em; color: var(--gold-light); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
106
+ .file-item .file-dur { font-size: 0.7em; color: var(--text-secondary); background: rgba(0,0,0,0.4); padding: 2px 6px; border-radius: 4px; }
107
+ .file-item .remove-btn { background: rgba(255, 60, 60, 0.1); border: none; color: #ff6b6b; cursor: pointer; font-size: 1em; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: all 0.2s; }
108
+ .file-item .remove-btn:hover { background: rgba(255, 60, 60, 0.2); transform: scale(1.1); }
109
+
110
+ .empty-state { font-size: 0.8em; color: rgba(150, 120, 70, 0.4); text-align: center; padding: 12px; font-style: italic; background: rgba(0,0,0,0.1); border-radius: 8px; }
111
+
112
+ .photo-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 10px; }
113
+ .photo-thumb { position: relative; aspect-ratio: 1; border-radius: 10px; overflow: hidden; border: 1px solid rgba(200, 160, 80, 0.2); box-shadow: 0 4px 10px rgba(0,0,0,0.3); animation: slideIn 0.3s ease; }
114
+ .photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.3s; }
115
+ .photo-thumb:hover img { transform: scale(1.05); }
116
+ .photo-thumb .remove-btn { position: absolute; top: 6px; right: 6px; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,0.1); color: #fff; cursor: pointer; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: all 0.2s; z-index: 2; }
117
+ .photo-thumb .remove-btn:hover { background: rgba(220, 40, 40, 0.8); transform: scale(1.1); }
118
+
119
+ /* PRIMARY BUTTON */
120
+ .btn-primary { width: 100%; position: relative; background: linear-gradient(135deg, #a67c00 0%, #bf953f 25%, #fcf6ba 50%, #b38728 75%, #a67c00 100%); background-size: 200% auto; border: none; border-radius: 12px; color: #3a2a12; font-family: 'Playfair Display', serif; font-size: 1.1em; font-weight: 700; letter-spacing: 1px; padding: 16px; cursor: pointer; transition: all 0.4s; box-shadow: 0 4px 20px rgba(200, 152, 64, 0.3); margin-top: 10px; overflow: hidden; }
121
+ .btn-primary::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: all 0.6s; }
122
+ .btn-primary:hover:not(:disabled) { background-position: right center; box-shadow: 0 8px 30px rgba(200, 152, 64, 0.4); transform: translateY(-2px); color: #1a1409; }
123
+ .btn-primary:hover:not(:disabled)::before { left: 100%; }
124
+ .btn-primary:active:not(:disabled) { transform: translateY(0); }
125
+ .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; filter: grayscale(50%); }
126
+
127
+ .how { background: rgba(0,0,0,0.2); border: 1px solid rgba(200, 160, 80, 0.1); border-radius: 12px; padding: 14px 18px; margin-top: 24px; font-size: 0.75em; color: var(--text-secondary); line-height: 1.8; text-align: center; }
128
+ .how em { color: var(--gold-main); font-style: normal; font-weight: 500; }
129
+
130
+ /* TABS & OUTPUT */
131
+ .tab-nav { display: flex; border-bottom: 1px solid rgba(200, 160, 80, 0.15); margin-bottom: 20px; gap: 10px; padding: 0 10px; }
132
+ .tab-btn { background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-family: 'Playfair Display', serif; font-size: 0.95em; font-weight: 600; padding: 12px 16px; cursor: pointer; transition: all 0.3s; margin-bottom: -1px; display: flex; align-items: center; gap: 8px; }
133
+ .tab-btn:hover { color: var(--gold-light); }
134
+ .tab-btn.active { color: var(--gold-main); border-bottom-color: var(--gold-main); text-shadow: 0 0 10px rgba(200, 152, 64, 0.3); }
135
+ .tab-panel { display: none; animation: fadeIn 0.4s ease; }
136
+ .tab-panel.active { display: block; }
137
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
138
+
139
+ .output { width: 100%; min-height: 450px; background: rgba(0,0,0,0.3); border: 1px solid rgba(200, 160, 80, 0.1); border-radius: 12px; color: var(--text-primary); font-family: 'Playfair Display', serif; font-size: 1.1em; line-height: 1.9; padding: 24px; resize: vertical; outline: none; box-shadow: inset 0 4px 12px rgba(0,0,0,0.3); transition: border-color 0.3s; }
140
+ .output:focus { border-color: rgba(200, 160, 80, 0.3); }
141
+
142
+ /* HISTORY CARDS */
143
+ .history-list { display: flex; flex-direction: column; gap: 12px; max-height: 450px; overflow-y: auto; padding-right: 8px; }
144
+ .history-card { background: rgba(0,0,0,0.3); border: 1px solid rgba(200, 160, 80, 0.15); border-radius: 12px; padding: 16px; display: flex; justify-content: space-between; align-items: center; transition: all 0.3s; }
145
+ .history-card:hover { background: rgba(200, 160, 80, 0.05); border-color: rgba(200, 160, 80, 0.3); }
146
+ .hist-info { display: flex; flex-direction: column; gap: 4px; }
147
+ .hist-title { font-family: 'Playfair Display', serif; font-size: 1.1em; color: var(--gold-light); font-weight: 600; }
148
+ .hist-date { font-size: 0.75em; color: var(--text-secondary); }
149
+ .hist-btn { background: rgba(200, 152, 64, 0.1); border: 1px solid rgba(200, 152, 64, 0.2); color: var(--gold-main); padding: 8px 12px; border-radius: 8px; cursor: pointer; transition: all 0.2s; font-size: 0.85em; }
150
+ .hist-btn:hover { background: rgba(200, 152, 64, 0.2); color: var(--gold-light); transform: translateY(-1px); }
151
+
152
+ /* LOADING STATE */
153
+ .loading { display: none; text-align: center; padding: 80px 20px; flex-direction: column; align-items: center; justify-content: center; height: 450px; }
154
+ .loading.show { display: flex; animation: fadeIn 0.5s ease; }
155
+
156
+ .loader-core { width: 80px; height: 80px; position: relative; margin-bottom: 24px; }
157
+ .loader-core::before, .loader-core::after { content: ''; position: absolute; inset: 0; border-radius: 50%; border: 2px solid transparent; }
158
+ .loader-core::before { border-top-color: var(--gold-main); border-bottom-color: var(--gold-main); animation: spin 2s linear infinite; }
159
+ .loader-core::after { border-left-color: var(--gold-light); border-right-color: var(--gold-light); animation: spin 1.5s linear infinite reverse; inset: 10px; }
160
+ .loader-icon { font-size: 2em; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); animation: pulse 2s ease-in-out infinite; color: var(--gold-main); display: flex; align-items: center; justify-content: center; }
161
+ @keyframes spin { 100% { transform: rotate(360deg); } }
162
+ @keyframes pulse { 0%, 100% { opacity: 0.8; transform: translate(-50%, -50%) scale(0.95); filter: drop-shadow(0 0 10px rgba(200,150,60,0.5)); } 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); filter: drop-shadow(0 0 20px rgba(200,150,60,0.8)); } }
163
+
164
+ .loading p { font-family: 'Playfair Display', serif; font-style: italic; color: var(--gold-main); font-size: 1.2em; letter-spacing: 1px; }
165
+ .loading-sub { color: var(--text-secondary); font-size: 0.85em; font-family: 'Inter', sans-serif; margin-top: 8px; font-style: normal; text-transform: uppercase; letter-spacing: 2px; }
166
+
167
+ .progress-container { width: 60%; margin: 24px auto 0; background: rgba(0,0,0,0.5); border: 1px solid rgba(200, 160, 80, 0.2); border-radius: 20px; padding: 3px; }
168
+ .progress-bar { height: 6px; border-radius: 20px; overflow: hidden; position: relative; }
169
+ .progress-fill { height: 100%; background: linear-gradient(90deg, #7a5528, #c4913f, #f5dfa0, #c4913f); background-size: 300% 100%; border-radius: 20px; width: 0%; box-shadow: 0 0 10px rgba(200, 152, 64, 0.5); animation: loadingGradient 2s infinite linear; }
170
+ @keyframes loadingGradient { 0% { background-position: 100% 0; } 100% { background-position: -200% 0; } }
171
+ @keyframes progress { 0% { width: 0%; } 100% { width: 95%; } }
172
+
173
+ .footer { text-align: center; margin-top: 50px; padding-top: 30px; border-top: 1px solid rgba(200, 160, 80, 0.1); color: var(--text-secondary); font-size: 0.8em; line-height: 2; position: relative; z-index: 1; }
174
+ .footer em { color: var(--gold-main); font-family: 'Playfair Display', serif; font-size: 1.1em; display: block; margin-top: 8px; }
175
+
176
+ @media(max-width: 900px) {
177
+ .grid { grid-template-columns: 1fr; }
178
+ .hero h1 { font-size: 2.8em; }
179
+ .photo-grid { grid-template-columns: repeat(4, 1fr); }
180
+ }
181
+ @media(max-width: 600px) {
182
+ .photo-grid { grid-template-columns: repeat(3, 1fr); }
183
+ .tab-nav { overflow-x: auto; padding-bottom: 5px; }
184
+ .tab-btn { white-space: nowrap; }
185
+ .card { padding: 20px; }
186
+ }
187
+ </style>
188
+ </head>
189
+ <body>
190
+ <div class="orb orb-1"></div>
191
+ <div class="orb orb-2"></div>
192
+
193
+ <div class="wrap">
194
+
195
+ <div class="hero">
196
+ <div class="hero-icon"><i data-lucide="book-heart" style="width:1.5em;height:1.5em;stroke-width:1.5px"></i></div>
197
+ <h1>Memory Keeper</h1>
198
+ <p class="hero-sub">Preserve voices, faces & stories β€” forever, privately.</p>
199
+ </div>
200
+
201
+ <div class="grid">
202
+
203
+ <!-- INPUT -->
204
+ <div class="card glass-panel">
205
+ <div class="card-title"><i data-lucide="user-round"></i> About This Person</div>
206
+ <div class="field">
207
+ <label>Their Name</label>
208
+ <input type="text" id="name" placeholder="e.g. Grandma Rose, Uncle Samuel..." oninput="onNameChange()" onblur="loadHistory()">
209
+ </div>
210
+
211
+ <!-- PROFILE STATS -->
212
+ <div id="profileStats" style="display:none;margin-bottom:20px">
213
+ <div class="stats-bar">
214
+ <div class="stat">
215
+ <span class="stat-num" id="statSessions">0</span>
216
+ <span class="stat-label">Sessions</span>
217
+ </div>
218
+ <div class="stat">
219
+ <span class="stat-num" id="statMemories">0</span>
220
+ <span class="stat-label">Memories</span>
221
+ </div>
222
+ <div class="stat">
223
+ <span class="stat-num" id="statPhotos">0</span>
224
+ <span class="stat-label">Photos</span>
225
+ </div>
226
+ </div>
227
+ </div>
228
+
229
+ <div class="card-title" style="margin-top:10px"><i data-lucide="plus-circle"></i> Add New Memories</div>
230
+
231
+ <!-- AUDIO -->
232
+ <div class="section-box">
233
+ <div class="section-box-title" style="display:flex;align-items:center;gap:6px"><i data-lucide="mic"></i> Voice Memories</div>
234
+ <div class="rec-row">
235
+ <button class="action-btn rec-btn" id="recBtn" onclick="toggleRecord()">
236
+ <i data-lucide="mic"></i> Record
237
+ </button>
238
+ <div class="action-btn add-btn">
239
+ <i data-lucide="upload-cloud"></i> Upload Audio
240
+ <input type="file" accept="audio/*" multiple onchange="addAudioFiles(this)">
241
+ </div>
242
+ </div>
243
+ <div class="file-list" id="audioList">
244
+ <div class="empty-state" id="audioEmpty">No voice memories added</div>
245
+ </div>
246
+ </div>
247
+
248
+ <!-- PHOTOS -->
249
+ <div class="section-box">
250
+ <div class="section-box-title" style="display:flex;align-items:center;gap:6px"><i data-lucide="camera"></i> Photos</div>
251
+ <div class="action-btn add-btn" style="width:100%;margin-bottom:12px">
252
+ <i data-lucide="image-up"></i> Upload Photos
253
+ <input type="file" accept="image/*" multiple onchange="addPhotoFiles(this)">
254
+ </div>
255
+ <div class="photo-grid" id="photoGrid"></div>
256
+ <div class="empty-state" id="photoEmpty">No photos added</div>
257
+ </div>
258
+
259
+ <!-- TEXT -->
260
+ <div class="field">
261
+ <label style="display:flex;align-items:center;gap:6px"><i data-lucide="pen-line"></i> Written Memory</label>
262
+ <textarea id="text" rows="5" placeholder="Where did they grow up? Who did they love? What made them laugh?"></textarea>
263
+ </div>
264
+
265
+ <button class="btn-primary" id="btn" onclick="generate()" style="display:flex;align-items:center;justify-content:center;gap:8px">
266
+ <i data-lucide="sparkles"></i> Add Memories & Generate Book
267
+ </button>
268
+
269
+ <div class="how">
270
+ Each session adds to the profile β€” the book gets richer over time<br>
271
+ Voice β†’ <em>Whisper</em> &nbsp;Β·&nbsp; Photo β†’ <em>BLIP</em> &nbsp;Β·&nbsp; Story β†’ <em>Qwen2.5-7B</em><br>
272
+ Stored locally on server Β· Nothing sent to third parties
273
+ </div>
274
+ </div>
275
+
276
+ <!-- OUTPUT -->
277
+ <div class="card glass-panel" style="display: flex; flex-direction: column;">
278
+ <div class="card-title" style="margin-bottom: 0;"><i data-lucide="book-open-text"></i> The Memory Book</div>
279
+
280
+ <div id="loading" class="loading">
281
+ <div class="loader-core">
282
+ <div class="loader-icon"><i data-lucide="loader" class="spin-icon" style="animation: spin 2s linear infinite"></i></div>
283
+ </div>
284
+ <p>Weaving memories into stories...</p>
285
+ <span class="loading-sub">Takes about 60–90 seconds</span>
286
+ <div class="progress-container">
287
+ <div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
288
+ </div>
289
+ </div>
290
+
291
+ <div id="book" style="flex: 1; display: flex; flex-direction: column; margin-top: 20px;">
292
+ <div class="tab-nav">
293
+ <button class="tab-btn active" onclick="tab('timeline',this)"><i data-lucide="calendar"></i> Timeline</button>
294
+ <button class="tab-btn" onclick="tab('story',this)"><i data-lucide="book"></i> Story</button>
295
+ <button class="tab-btn" onclick="tab('letter',this)"><i data-lucide="mail"></i> Letter</button>
296
+ <button class="tab-btn" onclick="tab('people',this)"><i data-lucide="users"></i> People</button>
297
+ <button class="tab-btn" onclick="tab('history',this)"><i data-lucide="history"></i> History</button>
298
+ <div style="flex: 1;"></div>
299
+ <button class="tab-btn" style="color: var(--gold-main);" onclick="downloadBook()" title="Download Book"><i data-lucide="download"></i> Download</button>
300
+ </div>
301
+ <div id="tab-timeline" class="tab-panel active" style="flex: 1;">
302
+ <textarea class="output" id="out-timeline" readonly placeholder="Life milestones will appear here..."></textarea>
303
+ </div>
304
+ <div id="tab-story" class="tab-panel" style="flex: 1;">
305
+ <textarea class="output" id="out-story" readonly placeholder="Narrative chapter will appear here..."></textarea>
306
+ </div>
307
+ <div id="tab-letter" class="tab-panel" style="flex: 1;">
308
+ <textarea class="output" id="out-letter" readonly placeholder="Letter to future generations will appear here..."></textarea>
309
+ </div>
310
+ <div id="tab-people" class="tab-panel" style="flex: 1;">
311
+ <textarea class="output" id="out-people" readonly placeholder="Important people will appear here..."></textarea>
312
+ </div>
313
+ <div id="tab-history" class="tab-panel" style="flex: 1;">
314
+ <div class="history-list" id="history-container">
315
+ <div class="empty-state">Enter a name to load past sessions.</div>
316
+ </div>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ <div class="footer">
323
+ Built for the Build Small Hackathon 2026 &nbsp;Β·&nbsp; Qwen2.5-7B Β· Whisper Β· BLIP Β· Modal Β· HF Spaces<br>
324
+ <em>"The life of the dead is placed in the memory of the living." β€” Cicero</em>
325
+ </div>
326
+ </div>
327
+
328
+ <script>
329
+ let audioItems = [], photoItems = [];
330
+ let mediaRecorder = null, audioChunks = [], isRecording = false, itemId = 0;
331
+ let currentHistory = [];
332
+
333
+ async function loadHistory() {
334
+ const name = document.getElementById('name').value.trim();
335
+ if (!name) return;
336
+ try {
337
+ const resp = await fetch(`/profile/${encodeURIComponent(name)}`);
338
+ const profile = await resp.json();
339
+ document.getElementById('profileStats').style.display = 'block';
340
+ document.getElementById('statSessions').textContent = profile.sessions || 0;
341
+ document.getElementById('statMemories').textContent = profile.transcripts?.length || 0;
342
+ document.getElementById('statPhotos').textContent = profile.photo_descriptions?.length || 0;
343
+
344
+ currentHistory = profile.history || [];
345
+ renderHistory();
346
+ } catch (e) { console.error(e); }
347
+ }
348
+
349
+ function renderHistory() {
350
+ const container = document.getElementById('history-container');
351
+ if (!container) return;
352
+ if (currentHistory.length === 0) {
353
+ container.innerHTML = '<div class="empty-state">No past sessions found for this name.</div>';
354
+ return;
355
+ }
356
+ container.innerHTML = currentHistory.map((h, i) => `
357
+ <div class="history-card">
358
+ <div class="hist-info">
359
+ <span class="hist-title">Session ${h.session || (i+1)}</span>
360
+ <span class="hist-date"><i data-lucide="clock" style="width:0.9em;height:0.9em"></i> ${h.timestamp || 'Past Session'}</span>
361
+ </div>
362
+ <button class="hist-btn" onclick="downloadHistory(${i})" style="display:flex;align-items:center;gap:6px"><i data-lucide="download" style="width:1em;height:1em"></i> Download</button>
363
+ </div>
364
+ `).reverse().join('');
365
+ lucide.createIcons();
366
+ }
367
+
368
+ function downloadHistory(index) {
369
+ const h = currentHistory[index];
370
+ if (!h) return;
371
+ const name = document.getElementById('name').value.trim() || 'Unknown';
372
+ const content = `# Memory Book for ${name} (Session ${h.session || (index+1)})\n\n## Timeline\n${h.timeline || ''}\n\n## Story\n${h.chapter || ''}\n\n## Letter\n${h.letter || ''}\n\n## People\n${h.people || ''}`;
373
+ const blob = new Blob([content], { type: 'text/markdown' });
374
+ const url = URL.createObjectURL(blob);
375
+ const a = document.createElement('a');
376
+ a.href = url;
377
+ a.download = `Memory_Book_${name.replace(/\s+/g, '_')}_Session_${h.session || (index+1)}.md`;
378
+ a.click();
379
+ URL.revokeObjectURL(url);
380
+ }
381
+
382
+ function downloadBook() {
383
+ const name = document.getElementById('name').value.trim() || 'Unknown';
384
+ const timeline = document.getElementById('out-timeline').value;
385
+ const story = document.getElementById('out-story').value;
386
+ const letter = document.getElementById('out-letter').value;
387
+ const people = document.getElementById('out-people').value;
388
+
389
+ if (!timeline && !story) {
390
+ alert("No book generated yet to download.");
391
+ return;
392
+ }
393
+
394
+ const content = `# Memory Book for ${name}\n\n## Timeline\n${timeline}\n\n## Story\n${story}\n\n## Letter\n${letter}\n\n## People\n${people}`;
395
+ const blob = new Blob([content], { type: 'text/markdown' });
396
+ const url = URL.createObjectURL(blob);
397
+ const a = document.createElement('a');
398
+ a.href = url;
399
+ a.download = `Memory_Book_${name.replace(/\s+/g, '_')}.md`;
400
+ a.click();
401
+ URL.revokeObjectURL(url);
402
+ }
403
+
404
+ function onNameChange() {
405
+ const name = document.getElementById('name').value.trim();
406
+ if (!name) {
407
+ document.getElementById('profileStats').style.display = 'none';
408
+ }
409
+ }
410
+
411
+ // ── Recording ──
412
+ async function toggleRecord() {
413
+ if (!isRecording) {
414
+ try {
415
+ const stream = await navigator.mediaDevices.getUserMedia({audio:true});
416
+ mediaRecorder = new MediaRecorder(stream);
417
+ audioChunks = [];
418
+ mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
419
+ mediaRecorder.onstop = () => {
420
+ const blob = new Blob(audioChunks, {type:'audio/wav'});
421
+ const ts = new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
422
+ audioItems.push({id:++itemId, blob, name:`Recording ${ts}`, type:'recorded'});
423
+ renderAudioList();
424
+ };
425
+ mediaRecorder.start();
426
+ isRecording = true;
427
+ document.getElementById('recBtn').innerHTML = '<i data-lucide="square"></i> Stop';
428
+ document.getElementById('recBtn').classList.add('recording');
429
+ lucide.createIcons();
430
+ } catch(e) { alert('Microphone access denied.'); }
431
+ } else {
432
+ mediaRecorder.stop();
433
+ mediaRecorder.stream.getTracks().forEach(t => t.stop());
434
+ isRecording = false;
435
+ document.getElementById('recBtn').innerHTML = '<i data-lucide="mic"></i> Record';
436
+ document.getElementById('recBtn').classList.remove('recording');
437
+ lucide.createIcons();
438
+ }
439
+ }
440
+
441
+ function addAudioFiles(input) {
442
+ Array.from(input.files).forEach(f => {
443
+ audioItems.push({id:++itemId, blob:f, name:f.name, type:'file'});
444
+ });
445
+ input.value = '';
446
+ renderAudioList();
447
+ }
448
+
449
+ function removeAudio(id) {
450
+ audioItems = audioItems.filter(a => a.id !== id);
451
+ renderAudioList();
452
+ }
453
+
454
+ function renderAudioList() {
455
+ const list = document.getElementById('audioList');
456
+ const empty = document.getElementById('audioEmpty');
457
+ if (audioItems.length === 0) {
458
+ list.innerHTML = '';
459
+ list.appendChild(empty);
460
+ empty.style.display = 'block';
461
+ return;
462
+ }
463
+ empty.style.display = 'none';
464
+ list.innerHTML = '';
465
+ audioItems.forEach(item => {
466
+ const div = document.createElement('div');
467
+ div.className = 'file-item';
468
+ const size = item.blob.size ? `${(item.blob.size/1024).toFixed(0)} KB` : '';
469
+ const icon = item.type === 'recorded' ? 'mic' : 'file-audio';
470
+ div.innerHTML = `
471
+ <i data-lucide="${icon}" style="color:var(--text-secondary)"></i>
472
+ <span class="file-name">${item.name}</span>
473
+ <span class="file-dur">${size}</span>
474
+ <button class="remove-btn" onclick="removeAudio(${item.id})" title="Remove"><i data-lucide="x" style="width:14px;height:14px"></i></button>
475
+ `;
476
+ list.appendChild(div);
477
+ });
478
+ lucide.createIcons();
479
+ }
480
+
481
+ // ── Photos ──
482
+ function addPhotoFiles(input) {
483
+ Array.from(input.files).forEach(f => {
484
+ photoItems.push({id:++itemId, file:f, url:URL.createObjectURL(f)});
485
+ });
486
+ input.value = '';
487
+ renderPhotoGrid();
488
+ }
489
+
490
+ function removePhoto(id) {
491
+ const item = photoItems.find(p => p.id === id);
492
+ if (item) URL.revokeObjectURL(item.url);
493
+ photoItems = photoItems.filter(p => p.id !== id);
494
+ renderPhotoGrid();
495
+ }
496
+
497
+ function renderPhotoGrid() {
498
+ const grid = document.getElementById('photoGrid');
499
+ const empty = document.getElementById('photoEmpty');
500
+ grid.innerHTML = '';
501
+ if (photoItems.length === 0) { empty.style.display = 'block'; return; }
502
+ empty.style.display = 'none';
503
+ photoItems.forEach(item => {
504
+ const div = document.createElement('div');
505
+ div.className = 'photo-thumb';
506
+ div.innerHTML = `
507
+ <img src="${item.url}">
508
+ <button class="remove-btn" onclick="removePhoto(${item.id})" title="Remove"><i data-lucide="x" style="width:14px;height:14px"></i></button>
509
+ `;
510
+ grid.appendChild(div);
511
+ });
512
+ lucide.createIcons();
513
+ }
514
+
515
+ // ── Tabs ──
516
+ function tab(id, btn) {
517
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
518
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
519
+ document.getElementById('tab-'+id).classList.add('active');
520
+ btn.classList.add('active');
521
+ }
522
+
523
+ // ── Generate ──
524
+ async function generate() {
525
+ const name = document.getElementById('name').value.trim();
526
+ const text = document.getElementById('text').value.trim();
527
+ if (!name) { alert('Please enter a name.'); return; }
528
+ if (!text && audioItems.length === 0 && photoItems.length === 0) {
529
+ alert('Please add at least one memory.'); return;
530
+ }
531
+
532
+ document.getElementById('btn').disabled = true;
533
+ document.getElementById('loading').classList.add('show');
534
+ document.getElementById('book').style.display = 'none';
535
+
536
+ const pf = document.getElementById('progressFill');
537
+ pf.style.animation = 'none'; pf.offsetHeight;
538
+ pf.style.animation = 'progress 90s linear forwards, loadingGradient 2s infinite linear';
539
+
540
+ const fd = new FormData();
541
+ fd.append('name', name);
542
+ fd.append('text', text || '');
543
+ audioItems.forEach(item => fd.append('audios', item.blob, item.name));
544
+ photoItems.forEach(item => fd.append('photos', item.file, item.file.name));
545
+
546
+ try {
547
+ const resp = await fetch('/run', {method:'POST', body:fd});
548
+ const data = await resp.json();
549
+ if (data.error) { alert('Error: ' + data.error); return; }
550
+
551
+ document.getElementById('out-timeline').value = data.timeline || '';
552
+ document.getElementById('out-story').value = data.chapter || '';
553
+ document.getElementById('out-letter').value = data.letter || '';
554
+ document.getElementById('out-people').value = data.people || '';
555
+
556
+ // Update stats
557
+ document.getElementById('profileStats').style.display = 'block';
558
+ document.getElementById('statSessions').textContent = data.sessions || 0;
559
+ document.getElementById('statMemories').textContent = data.total_memories || 0;
560
+ document.getElementById('statPhotos').textContent = data.total_photos || 0;
561
+
562
+ // Clear inputs for next session
563
+ audioItems = []; photoItems = [];
564
+ renderAudioList(); renderPhotoGrid();
565
+ document.getElementById('text').value = '';
566
+
567
+ loadHistory();
568
+
569
+ tab('timeline', document.querySelector('.tab-btn'));
570
+ lucide.createIcons();
571
+
572
+ } catch(e) {
573
+ alert('Error: ' + e.message);
574
+ } finally {
575
+ document.getElementById('btn').disabled = false;
576
+ document.getElementById('loading').classList.remove('show');
577
+ document.getElementById('book').style.display = 'flex';
578
+ }
579
+ }
580
+ </script>
581
+ <script>
582
+ // Initialize icons on first load
583
+ lucide.createIcons();
584
+ </script>
585
+ </body>
586
+ </html>
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=5.0.0
2
+ requests