| """ |
| LegacyScribe — Family Memory Agent |
| Gradio Space entry point |
| """ |
|
|
| import gradio as gr |
| import json |
| import os |
| from pathlib import Path |
| from gradio_client import Client |
|
|
| |
| |
| |
| |
| |
|
|
|
|
| BACKEND_URL = os.environ.get("BACKEND_URL", "https://safalnarshing-legacyscribe-backend.hf.space") |
|
|
| |
| |
| |
| |
|
|
| _client = None |
| def get_client(): |
| global _client |
| if _client is None: |
| _client = Client(BACKEND_URL) |
| return _client |
|
|
| |
| SYSTEM_PROMPTS = { |
| "questioner": ( |
| "You are a gentle memory guide helping an elderly person tell their life story. " |
| "Ask exactly one warm, open follow-up question. Never ask more than one question. " |
| "Be patient, kind, and culturally sensitive to Nepali and South Asian contexts." |
| ), |
| "extractor": ( |
| "You are an extractor agent. Given a memory fragment, extract structured information " |
| "as JSON with keys relevant to the content (who, when, where, what, emotion). " |
| "Output only valid JSON, nothing else." |
| ), |
| "arcdetector": ( |
| "You are an arc detector agent. Given a memory fragment, identify the narrative stage. " |
| "Output one word only: setup, tension, turn, or meaning." |
| ), |
| "publisher": ( |
| "You are a publisher agent. Given memory notes, synthesize them into a single warm, " |
| "narrative paragraph suitable for a family memory book. Write in first person. " |
| "Use natural, unhurried language. Output only the paragraph, nothing else." |
| ), |
| } |
|
|
| def call_agent(agent: str, user_text: str, max_tokens: int = -1, temp: float = -1.0) -> str: |
| try: |
| return get_client().predict( |
| agent, |
| user_text, |
| float(max_tokens), |
| float(temp), |
| api_name="/predict", |
| ) |
| except Exception as e: |
| print(f"Backend error: {e}") |
| return "" |
|
|
| |
| def init_state(): |
| return {"turns": [], "all_notes": [], "chapter": "", "chapter_num": 1} |
|
|
| def process_turn(user_input: str, state: dict): |
| if not user_input.strip(): |
| return state, "", "", "", [] |
|
|
| arc = call_agent("arcdetector", user_input, max_tokens=10, temp=0.1) |
| arc = arc.lower().strip() |
| if arc not in ("setup", "tension", "turn", "meaning"): |
| arc = "setup" |
|
|
| try: |
| raw = call_agent("extractor", user_input, max_tokens=200, temp=0.1) |
| notes = json.loads(raw) |
| note_lines = [f"{k}: {v}" for k, v in notes.items() if v] |
| except Exception: |
| note_lines = [user_input[:120]] |
|
|
| state["all_notes"].extend(note_lines) |
|
|
| history_ctx = "\n".join(f"Memory: {t['user']}" for t in state["turns"][-3:]) |
| question_ctx = f"{history_ctx}\nMemory: {user_input}" if history_ctx else f"Memory: {user_input}" |
| question = call_agent("questioner", question_ctx, max_tokens=80, temp=0.7) |
|
|
| chapter = state["chapter"] |
| prev_chapter = chapter |
| if len(state["all_notes"]) >= 3: |
| notes_text = "\n".join(f"{i+1}. {n}" for i, n in enumerate(state["all_notes"][-6:])) |
| chapter = call_agent("publisher", f"Notes:\n{notes_text}", max_tokens=400, temp=0.4) |
| if chapter != prev_chapter and prev_chapter: |
| state["chapter_num"] += 1 |
| state["chapter"] = chapter |
|
|
| state["turns"].append({"user": user_input, "question": question, "arc": arc, "notes": note_lines}) |
| return state, question, arc, chapter, note_lines |
|
|
| |
| ARC_ICONS = {"setup": "◎", "tension": "◈", "turn": "◉", "meaning": "✦"} |
| ARC_LABELS = {"setup": "Setting the scene", "tension": "Something changed", "turn": "A new direction", "meaning": "What it meant"} |
|
|
| def build_chat_html(turns): |
| if not turns: |
| return ''' |
| <div class="ls-chat" id="ls-chat"> |
| <div class="ls-welcome"> |
| <div class="ls-welcome-icon">📖</div> |
| <div class="ls-welcome-text">Tell me a memory — anything at all.<br>A meal, a person, a festival, a place.<br><em>I am here to listen.</em></div> |
| </div> |
| </div>''' |
| html = '<div class="ls-chat" id="ls-chat">' |
| for t in turns: |
| arc = t.get("arc", "setup") |
| icon = ARC_ICONS.get(arc, "◎") |
| label = ARC_LABELS.get(arc, arc) |
| html += f''' |
| <div class="ls-turn"> |
| <div class="ls-bubble-user">{t["user"]}</div> |
| <div class="ls-arc-pill {arc}"><span>{icon}</span><span>{label}</span></div> |
| <div class="ls-bubble-agent">{t["question"]}</div> |
| </div>''' |
| html += '</div>' |
| html += '<script>setTimeout(function(){var c=document.getElementById("ls-chat");if(c)c.scrollTop=c.scrollHeight;},80);</script>' |
| return html |
|
|
| def build_notes_html(notes): |
| if not notes: |
| return '' |
| html = '<div class="ls-fragments-label">Memory fragments extracted</div><div class="ls-fragments">' |
| for n in notes[-5:]: |
| parts = n.split(": ", 1) |
| if len(parts) == 2: |
| html += f'<div class="ls-fragment"><span class="ls-fkey">{parts[0]}</span><span class="ls-fval">{parts[1]}</span></div>' |
| else: |
| html += f'<div class="ls-fragment"><span class="ls-fval">{n}</span></div>' |
| html += '</div>' |
| return html |
|
|
| LINE_H = 28 |
|
|
| def build_page_html(chapter, chapter_num): |
| if not chapter: |
| return ''' |
| <div class="ls-book-wrap"> |
| <div class="ls-book-cover-hint">— waiting for your story —</div> |
| <div class="ls-page" id="ls-page"> |
| <div class="ls-page-inner"> |
| <div class="ls-chapter-label">Chapter One</div> |
| <div class="ls-ruled-area"> |
| <div class="ls-ruled-lines"></div> |
| <div class="ls-page-empty">Your memories are gathering.<br>Share a few more and I will begin to write.</div> |
| </div> |
| </div> |
| <div class="ls-spine"></div> |
| <div class="ls-page-shadow"></div> |
| </div> |
| </div>''' |
|
|
| words = chapter.split() |
| lines, current = [], [] |
| chars = 0 |
| for w in words: |
| if chars + len(w) + 1 > 52: |
| lines.append(" ".join(current)) |
| current, chars = [w], len(w) |
| else: |
| current.append(w) |
| chars += len(w) + 1 |
| if current: |
| lines.append(" ".join(current)) |
|
|
| text_html = "" |
| for line in lines: |
| text_html += f'<div class="ls-text-line">{line}</div>' |
|
|
| return f''' |
| <div class="ls-book-wrap"> |
| <div class="ls-page" id="ls-page"> |
| <div class="ls-page-inner"> |
| <div class="ls-chapter-label">Chapter {chapter_num}</div> |
| <div class="ls-ruled-area"> |
| <div class="ls-ruled-lines"></div> |
| <div class="ls-text-block">{text_html}</div> |
| </div> |
| </div> |
| <div class="ls-spine"></div> |
| <div class="ls-page-curl"></div> |
| <div class="ls-page-shadow"></div> |
| </div> |
| </div> |
| <script> |
| (function(){{ |
| var p = document.getElementById("ls-page"); |
| if(!p) return; |
| p.classList.remove("ls-flip"); |
| void p.offsetWidth; |
| p.classList.add("ls-flip"); |
| }})(); |
| </script>''' |
|
|
| |
| HEAD = """ |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,500;0,700;1,400;1,500&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;1,8..60,300;1,8..60,400&family=JetBrains+Mono:wght@300;400&display=swap" rel="stylesheet"> |
| <style> |
| /* ── Reset ── */ |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;} |
| |
| :root{ |
| --walnut: #241208; |
| --walnut2: #2E1810; |
| --parchment:#F0E4CC; |
| --parchment2:#FAF4E8; |
| --ink: #1C0E06; |
| --gold: #C8861E; |
| --gold2: #E8A830; |
| --sage: #6A9070; |
| --dust: #B8A484; |
| --cream: #F8F2E4; |
| --red: #C04030; |
| --purple: #9060A8; |
| --shadow: rgba(20,8,2,0.5); |
| --line-h: 28px; |
| } |
| |
| html,body,.gradio-container{ |
| background: var(--walnut) !important; |
| font-family:'Source Serif 4',Georgia,serif !important; |
| color:var(--parchment) !important; |
| min-height:100vh; |
| } |
| |
| /* ── Suppress Gradio chrome ── */ |
| footer,.svelte-1rjryqp,#footer{display:none!important;} |
| .gradio-container{padding:0!important;max-width:100%!important;} |
| .gr-prose{color:var(--parchment)!important;} |
| .gr-button{font-family:'Source Serif 4',serif!important;} |
| |
| /* ════════════════════════════════ |
| HEADER |
| ════════════════════════════════ */ |
| .ls-header{ |
| text-align:center; |
| padding:3rem 1rem 2rem; |
| background: linear-gradient(to bottom, #1A0A02, var(--walnut)); |
| border-bottom:1px solid rgba(200,134,30,0.25); |
| position:relative; |
| overflow:hidden; |
| } |
| .ls-header::before{ |
| content:''; |
| position:absolute; |
| inset:0; |
| background: radial-gradient(ellipse 60% 40% at 50% 0%, rgba(200,134,30,0.08), transparent); |
| pointer-events:none; |
| } |
| .ls-wordmark{ |
| font-family:'Playfair Display',serif; |
| font-size:clamp(2.4rem,6vw,4rem); |
| font-weight:700; |
| letter-spacing:-0.03em; |
| line-height:1; |
| position:relative; |
| } |
| .ls-wordmark-legacy{ color:var(--gold2); } |
| .ls-wordmark-scribe{ color:var(--parchment); font-style:italic; font-weight:500; } |
| .ls-wordmark-dot{ color:var(--gold); font-style:normal; } |
| |
| .ls-deco-line{ |
| width:80px; height:1px; |
| background:linear-gradient(to right, transparent, var(--gold), transparent); |
| margin:0.9rem auto 0.7rem; |
| } |
| .ls-tagline{ |
| font-size:0.95rem; |
| color:var(--dust); |
| font-style:italic; |
| letter-spacing:0.05em; |
| } |
| .ls-badges{ |
| display:flex;gap:0.5rem; |
| justify-content:center; |
| margin-top:1.2rem; |
| flex-wrap:wrap; |
| } |
| .ls-badge{ |
| font-family:'JetBrains Mono',monospace; |
| font-size:0.6rem; |
| padding:0.18rem 0.65rem; |
| border:1px solid rgba(200,134,30,0.4); |
| border-radius:1px; |
| color:rgba(200,134,30,0.9); |
| letter-spacing:0.1em; |
| text-transform:uppercase; |
| } |
| |
| /* ════════════════════════════════ |
| LAYOUT |
| ════════════════════════════════ */ |
| .ls-main{ |
| display:grid; |
| grid-template-columns:1fr 1fr; |
| max-width:1280px; |
| margin:0 auto; |
| min-height:calc(100vh - 220px); |
| } |
| @media(max-width:780px){.ls-main{grid-template-columns:1fr;}} |
| |
| /* ════════════════════════════════ |
| LEFT — CONVERSATION |
| ════════════════════════════════ */ |
| .ls-conversation{ |
| border-right:1px solid rgba(200,134,30,0.15); |
| padding:2rem 1.75rem; |
| display:flex; |
| flex-direction:column; |
| gap:1.25rem; |
| background:linear-gradient(160deg, rgba(46,24,16,0.6), rgba(36,18,8,0.9)); |
| } |
| .ls-section-label{ |
| font-family:'JetBrains Mono',monospace; |
| font-size:0.6rem; |
| letter-spacing:0.18em; |
| text-transform:uppercase; |
| color:var(--gold); |
| opacity:0.8; |
| } |
| |
| /* Chat */ |
| .ls-chat{ |
| display:flex; |
| flex-direction:column; |
| gap:1.25rem; |
| max-height:360px; |
| overflow-y:auto; |
| padding-right:0.25rem; |
| scrollbar-width:thin; |
| scrollbar-color:rgba(200,134,30,0.3) transparent; |
| } |
| .ls-welcome{ |
| display:flex;flex-direction:column;align-items:center; |
| gap:1rem;padding:2rem 1rem; |
| color:var(--dust);text-align:center; |
| } |
| .ls-welcome-icon{font-size:2.5rem;opacity:0.6;} |
| .ls-welcome-text{font-size:0.9rem;line-height:1.7;font-style:italic;} |
| .ls-welcome-text em{color:var(--gold);font-style:italic;} |
| |
| .ls-turn{display:flex;flex-direction:column;gap:0.5rem;} |
| |
| .ls-bubble-user{ |
| align-self:flex-end; |
| background:rgba(200,134,30,0.12); |
| border:1px solid rgba(200,134,30,0.25); |
| border-radius:14px 14px 3px 14px; |
| padding:0.8rem 1.1rem; |
| max-width:88%; |
| font-size:0.9rem; |
| line-height:1.6; |
| color:var(--parchment); |
| position:relative; |
| } |
| |
| .ls-arc-pill{ |
| align-self:flex-start; |
| display:inline-flex;align-items:center;gap:0.35rem; |
| font-family:'JetBrains Mono',monospace; |
| font-size:0.6rem; |
| letter-spacing:0.08em; |
| text-transform:uppercase; |
| padding:0.18rem 0.7rem; |
| border-radius:2px; |
| margin-left:0.25rem; |
| } |
| .ls-arc-pill.setup {background:rgba(106,144,112,0.15);color:var(--sage); border:1px solid rgba(106,144,112,0.3);} |
| .ls-arc-pill.tension {background:rgba(192,64,48,0.12); color:#D06858; border:1px solid rgba(192,64,48,0.25);} |
| .ls-arc-pill.turn {background:rgba(200,134,30,0.12); color:var(--gold2); border:1px solid rgba(200,134,30,0.3);} |
| .ls-arc-pill.meaning {background:rgba(144,96,168,0.12); color:#B080C8; border:1px solid rgba(144,96,168,0.25);} |
| |
| .ls-bubble-agent{ |
| align-self:flex-start; |
| background:rgba(240,228,204,0.05); |
| border:1px solid rgba(184,164,132,0.15); |
| border-radius:3px 14px 14px 14px; |
| padding:0.8rem 1.1rem; |
| max-width:88%; |
| font-size:0.9rem; |
| line-height:1.65; |
| color:rgba(240,228,204,0.9); |
| font-style:italic; |
| } |
| .ls-bubble-agent::before{ |
| content:'✦ '; |
| color:var(--gold); |
| font-style:normal; |
| font-size:0.65rem; |
| vertical-align:middle; |
| } |
| |
| /* Fragments */ |
| .ls-fragments-label{ |
| font-family:'JetBrains Mono',monospace; |
| font-size:0.58rem; |
| letter-spacing:0.15em; |
| text-transform:uppercase; |
| color:rgba(200,134,30,0.7); |
| margin-bottom:0.4rem; |
| } |
| .ls-fragments{display:flex;flex-direction:column;gap:0.25rem;} |
| .ls-fragment{ |
| display:flex;gap:0.5rem;align-items:baseline; |
| font-family:'JetBrains Mono',monospace; |
| font-size:0.68rem; |
| padding:0.22rem 0.6rem 0.22rem 0.8rem; |
| border-left:2px solid rgba(200,134,30,0.5); |
| line-height:1.4; |
| } |
| .ls-fkey{color:var(--gold);min-width:60px;flex-shrink:0;} |
| .ls-fval{color:var(--dust);} |
| |
| /* Input */ |
| .ls-input-wrap textarea{ |
| width:100%; |
| background:rgba(20,8,2,0.7)!important; |
| border:1px solid rgba(184,164,132,0.2)!important; |
| border-radius:4px!important; |
| color:var(--parchment)!important; |
| font-family:'Source Serif 4',serif!important; |
| font-size:0.92rem!important; |
| line-height:1.65!important; |
| padding:0.9rem 1.1rem!important; |
| resize:none!important; |
| transition:border-color 0.2s,box-shadow 0.2s; |
| } |
| .ls-input-wrap textarea:focus{ |
| border-color:rgba(200,134,30,0.5)!important; |
| outline:none!important; |
| box-shadow:0 0 0 3px rgba(200,134,30,0.08)!important; |
| } |
| .ls-input-wrap textarea::placeholder{color:var(--dust)!important;opacity:0.45;} |
| |
| /* Buttons */ |
| .ls-btn-row{display:flex;gap:0.75rem;align-items:center;} |
| .ls-btn{ |
| font-family:'Source Serif 4',serif!important; |
| font-size:0.88rem!important; |
| font-weight:400!important; |
| letter-spacing:0.02em!important; |
| padding:0.6rem 1.5rem!important; |
| border-radius:3px!important; |
| cursor:pointer!important; |
| transition:all 0.16s!important; |
| border:none!important; |
| } |
| .ls-btn-primary{background:var(--gold)!important;color:var(--walnut)!important;font-weight:600!important;} |
| .ls-btn-primary:hover{background:var(--gold2)!important;transform:translateY(-1px);box-shadow:0 4px 16px rgba(200,134,30,0.25)!important;} |
| .ls-btn-secondary{background:transparent!important;color:var(--dust)!important;border:1px solid rgba(184,164,132,0.25)!important;} |
| .ls-btn-secondary:hover{border-color:var(--dust)!important;color:var(--parchment)!important;} |
| |
| /* Thinking dots */ |
| .ls-thinking{ |
| display:flex;align-items:center;gap:0.5rem; |
| font-size:0.78rem;color:var(--dust);font-style:italic; |
| min-height:1.2rem; |
| } |
| .ls-dot{ |
| width:4px;height:4px;border-radius:50%; |
| background:var(--gold); |
| animation:ls-pulse 1.3s ease-in-out infinite; |
| } |
| .ls-dot:nth-child(2){animation-delay:0.2s;} |
| .ls-dot:nth-child(3){animation-delay:0.4s;} |
| @keyframes ls-pulse{ |
| 0%,80%,100%{opacity:0.15;transform:scale(0.7);} |
| 40%{opacity:1;transform:scale(1);} |
| } |
| |
| /* ════════════════════════════════ |
| RIGHT — BOOK |
| ════════════════════════════════ */ |
| .ls-book{ |
| padding:2.5rem 2rem; |
| display:flex;flex-direction:column;gap:1.5rem; |
| background:linear-gradient(160deg, rgba(30,14,6,0.95), rgba(24,10,2,0.98)); |
| } |
| |
| /* Book wrapper — perspective for 3D flip */ |
| .ls-book-wrap{ |
| perspective:1200px; |
| perspective-origin:50% 40%; |
| } |
| .ls-book-cover-hint{ |
| font-family:'JetBrains Mono',monospace; |
| font-size:0.6rem; |
| letter-spacing:0.15em; |
| text-transform:uppercase; |
| color:rgba(200,134,30,0.3); |
| text-align:center; |
| margin-bottom:0.75rem; |
| } |
| |
| /* ── The page itself ── */ |
| .ls-page{ |
| position:relative; |
| background: linear-gradient(105deg, #F8F2E0 0%, #F2EAD4 40%, #EDE4C8 100%); |
| border-radius:2px 6px 6px 2px; |
| min-height:340px; |
| box-shadow: |
| -6px 0 18px rgba(20,8,2,0.5), |
| 2px 2px 6px rgba(20,8,2,0.25), |
| inset 0 0 60px rgba(180,140,80,0.06); |
| transform-origin: left center; |
| transform-style: preserve-3d; |
| transition: none; |
| overflow:hidden; |
| } |
| |
| /* Page-flip animation */ |
| @keyframes pageFlip{ |
| 0% { transform: rotateY(-25deg) skewY(1deg); opacity:0.7; } |
| 35% { transform: rotateY(-8deg) skewY(0.3deg); opacity:0.9; } |
| 65% { transform: rotateY(-3deg) skewY(0deg); opacity:0.97; } |
| 100%{ transform: rotateY(0deg) skewY(0deg); opacity:1; } |
| } |
| .ls-page.ls-flip{ |
| animation: pageFlip 0.55s cubic-bezier(0.23,1,0.32,1) forwards; |
| } |
| |
| /* Leather spine */ |
| .ls-spine{ |
| position:absolute; |
| left:0;top:0;bottom:0;width:18px; |
| background:linear-gradient(to right, |
| #6B4012 0%, #9B6422 30%, #B88030 50%, #9B6422 70%, #6B4012 100%); |
| box-shadow:inset -2px 0 4px rgba(0,0,0,0.3), 2px 0 6px rgba(0,0,0,0.2); |
| } |
| /* Spine stitching */ |
| .ls-spine::before{ |
| content:''; |
| position:absolute; |
| left:7px;top:12px;bottom:12px;width:1px; |
| background:repeating-linear-gradient(to bottom, |
| rgba(255,220,120,0.5) 0px, rgba(255,220,120,0.5) 4px, |
| transparent 4px, transparent 8px); |
| } |
| |
| /* Page curl bottom-right */ |
| .ls-page-curl{ |
| position:absolute; |
| bottom:0;right:0; |
| width:36px;height:36px; |
| background:linear-gradient(135deg, |
| transparent 50%, |
| rgba(180,140,80,0.15) 50%, |
| rgba(140,100,40,0.1) 100%); |
| border-top-left-radius:3px; |
| pointer-events:none; |
| } |
| .ls-page-curl::after{ |
| content:''; |
| position:absolute; |
| bottom:0;right:0; |
| width:24px;height:24px; |
| background:linear-gradient(135deg, transparent 50%, rgba(160,120,60,0.08) 50%); |
| border-top-left-radius:2px; |
| } |
| |
| /* Drop shadow panel */ |
| .ls-page-shadow{ |
| position:absolute; |
| bottom:-8px;left:12px;right:-4px;height:12px; |
| background:rgba(20,8,2,0.3); |
| border-radius:0 0 4px 4px; |
| filter:blur(6px); |
| z-index:-1; |
| } |
| |
| /* Page inner content */ |
| .ls-page-inner{ |
| position:relative; |
| padding:1.5rem 1.5rem 1.5rem 2.5rem; /* left pad for spine */ |
| z-index:1; |
| } |
| |
| /* Chapter label */ |
| .ls-chapter-label{ |
| font-family:'Playfair Display',serif; |
| font-size:0.72rem; |
| font-weight:700; |
| letter-spacing:0.22em; |
| text-transform:uppercase; |
| color:#8B5E1A; |
| margin-bottom:1rem; |
| padding-bottom:0.5rem; |
| border-bottom:1px solid rgba(180,140,80,0.3); |
| position:relative; |
| } |
| .ls-chapter-label::after{ |
| content:''; |
| position:absolute; |
| bottom:-1px;left:0;width:40px;height:1px; |
| background:var(--gold); |
| } |
| |
| /* Ruled area — lines + text together */ |
| .ls-ruled-area{ |
| position:relative; |
| min-height:240px; |
| } |
| .ls-ruled-lines{ |
| position:absolute; |
| inset:0; |
| background-image:repeating-linear-gradient( |
| to bottom, |
| transparent 0px, |
| transparent calc(var(--line-h) - 1px), |
| rgba(160,120,60,0.18) calc(var(--line-h) - 1px), |
| rgba(160,120,60,0.18) var(--line-h) |
| ); |
| pointer-events:none; |
| } |
| |
| /* Text sits ON the ruled lines */ |
| .ls-text-block{ |
| position:relative; |
| z-index:1; |
| } |
| .ls-text-line{ |
| font-family:'Source Serif 4',serif; |
| font-size:0.88rem; |
| line-height:var(--line-h); |
| color:#2A1408; |
| font-weight:300; |
| height:var(--line-h); |
| overflow:hidden; |
| white-space:nowrap; |
| text-overflow:ellipsis; |
| padding-right:0.5rem; |
| position:relative; |
| top:-1px; |
| } |
| |
| .ls-page-empty{ |
| position:relative; |
| z-index:1; |
| padding-top:calc(var(--line-h) * 3); |
| font-family:'Source Serif 4',serif; |
| font-style:italic; |
| color:#9A7A50; |
| font-size:0.85rem; |
| line-height:1.7; |
| text-align:center; |
| opacity:0.8; |
| } |
| |
| /* ── Download button ── */ |
| .ls-dl-btn{ |
| align-self:flex-start; |
| font-family:'JetBrains Mono',monospace!important; |
| font-size:0.65rem!important; |
| letter-spacing:0.1em!important; |
| text-transform:uppercase!important; |
| padding:0.4rem 1rem!important; |
| background:transparent!important; |
| color:rgba(200,134,30,0.7)!important; |
| border:1px solid rgba(200,134,30,0.25)!important; |
| border-radius:2px!important; |
| cursor:pointer!important; |
| transition:all 0.16s!important; |
| } |
| .ls-dl-btn:hover{ |
| background:rgba(200,134,30,0.08)!important; |
| color:var(--gold2)!important; |
| border-color:rgba(200,134,30,0.5)!important; |
| } |
| |
| /* ── Footer ── */ |
| .ls-footer{ |
| text-align:center; |
| padding:1.25rem 1rem; |
| border-top:1px solid rgba(200,134,30,0.1); |
| font-family:'JetBrains Mono',monospace; |
| font-size:0.58rem; |
| color:rgba(184,164,132,0.4); |
| letter-spacing:0.1em; |
| text-transform:uppercase; |
| } |
| </style> |
| """ |
|
|
| INTRO_JS = """ |
| () => { |
| /* Pure animation — no interaction. Just remove overlay from DOM after CSS is done. */ |
| /* Total: 3.5s delay + 0.9s fade = 4.4s. Add small buffer. */ |
| setTimeout(function() { |
| var el = document.getElementById('ls-intro'); |
| if (el) el.style.display = 'none'; |
| }, 4600); |
| } |
| """ |
|
|
| |
| with gr.Blocks() as demo: |
|
|
| state = gr.State(init_state()) |
|
|
| |
| gr.HTML(""" |
| <style> |
| /* ── Overlay ── */ |
| #ls-intro { |
| position:fixed; inset:0; z-index:9999; overflow:hidden; |
| display:flex; flex-direction:column; |
| align-items:center; justify-content:center; gap:1.4rem; |
| background:radial-gradient(ellipse 70% 80% at 50% 50%, #2A1408, #160700 70%, #0D0500); |
| pointer-events:none; |
| /* Step 4 — fade the whole overlay out */ |
| animation: _ov-out 0.9s ease 3.5s forwards; |
| } |
| /* Step 4 — golden page-light flash */ |
| #ls-intro::after { |
| content:''; position:absolute; inset:0; z-index:10; pointer-events:none; |
| background:radial-gradient(ellipse 55% 50% at 50% 48%, |
| rgba(235,175,55,0.55) 0%, rgba(200,134,30,0.2) 42%, transparent 68%); |
| opacity:0; |
| animation: _flash 0.6s ease 3.5s forwards; |
| } |
| |
| @keyframes _ov-out { 0%{opacity:1} 20%{opacity:1} 100%{opacity:0} } |
| @keyframes _flash { 0%{opacity:0} 28%{opacity:1} 100%{opacity:0} } |
| |
| /* ── Ambient glow — blooms as cover opens ── */ |
| #ls-i-glow { |
| position:absolute; width:520px; height:520px; border-radius:50%; pointer-events:none; |
| background:radial-gradient(circle, rgba(200,134,30,0.13) 0%, rgba(200,134,30,0.04) 45%, transparent 70%); |
| top:50%; left:50%; |
| transform:translate(-50%,-50%) scale(0.3); opacity:0; |
| animation:_glow-in 1s ease 1.9s forwards; |
| } |
| @keyframes _glow-in { to{opacity:1; transform:translate(-50%,-50%) scale(1);} } |
| |
| /* ── 3-D scene — Step 4 surges toward viewer ── */ |
| #ls-i-scene { |
| perspective:1400px; perspective-origin:50% 45%; |
| /* Step 4 zoom */ |
| animation: _surge 0.82s cubic-bezier(0.3,0,0.6,1) 3.5s forwards; |
| } |
| @keyframes _surge { 0%{transform:scale(1)} 100%{transform:scale(8)} } |
| |
| /* ── Book container — Step 1 drop-in ── */ |
| #ls-i-book { |
| position:relative; width:300px; height:420px; |
| transform-style:preserve-3d; |
| filter:drop-shadow(0 28px 56px rgba(10,4,0,0.92)); |
| animation:_drop 0.82s cubic-bezier(0.23,1,0.32,1) forwards; |
| } |
| @keyframes _drop { |
| from{opacity:0; transform:translateY(-65px) scale(0.82);} |
| to {opacity:1; transform:translateY(0) scale(1);} |
| } |
| |
| /* ── Interior parchment page ── */ |
| #ls-i-back { |
| position:absolute; inset:0; |
| background:linear-gradient(108deg,#F8F2E0 0%,#F3EAD5 55%,#EDE4C8 100%); |
| border-radius:2px 6px 6px 2px; overflow:hidden; |
| display:flex; align-items:center; justify-content:center; |
| } |
| #ls-i-back-spine { |
| position:absolute; left:0; top:0; bottom:0; width:14px; |
| background:linear-gradient(to right,#5A3010 0%,#9A6020 40%,#C08030 50%,#9A6020 70%,#5A3010 100%); |
| } |
| #ls-i-back-content { |
| display:flex; flex-direction:column; align-items:center; |
| gap:0.55rem; padding-left:0.75rem; text-align:center; |
| } |
| /* Step 3 — text rises after cover opens */ |
| #ls-i-back-title { |
| font-family:'Playfair Display',serif; line-height:1.15; |
| opacity:0; animation:_rise 0.55s ease 2.2s forwards; |
| } |
| #ls-i-back-title .t { display:block; font-size:1.9rem; font-weight:700; color:#8B5E1A; } |
| #ls-i-back-title .i { display:block; font-size:1.6rem; font-style:italic; font-weight:500; color:#2A1408; } |
| #ls-i-back-rule { |
| width:52px; height:1px; |
| background:linear-gradient(to right,transparent,#C8861E,transparent); |
| opacity:0; animation:_rise 0.45s ease 2.32s forwards; |
| } |
| #ls-i-back-sub { |
| font-family:'Source Serif 4',serif; font-size:0.75rem; font-style:italic; color:#9A7A50; |
| opacity:0; animation:_rise 0.45s ease 2.44s forwards; |
| } |
| @keyframes _rise { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} } |
| |
| /* ── Front leather cover — Step 2 flip ── */ |
| #ls-i-cover { |
| position:absolute; inset:0; |
| transform-origin:left center; transform-style:preserve-3d; |
| animation:_flip 1.2s cubic-bezier(0.4,0,0.2,1) 1.0s forwards; |
| } |
| @keyframes _flip { 0%{transform:rotateY(0deg);} 100%{transform:rotateY(-162deg);} } |
| |
| #ls-i-cover-front { |
| position:absolute; inset:0; |
| backface-visibility:hidden; -webkit-backface-visibility:hidden; |
| background:linear-gradient(158deg,#4A200A 0%,#2C1005 55%,#1C0803 100%); |
| border-radius:2px 6px 6px 2px; overflow:hidden; |
| box-shadow:6px 0 24px rgba(20,8,2,0.6), inset 0 0 80px rgba(0,0,0,0.2); |
| } |
| #ls-i-cover-front::before { |
| content:''; position:absolute; inset:0; pointer-events:none; |
| background: |
| repeating-linear-gradient( 45deg,transparent 0,transparent 3px,rgba(255,255,255,0.013) 3px,rgba(255,255,255,0.013) 4px), |
| repeating-linear-gradient(-45deg,transparent 0,transparent 3px,rgba(255,255,255,0.013) 3px,rgba(255,255,255,0.013) 4px); |
| } |
| #ls-i-cover-front::after { |
| content:''; position:absolute; inset:0; pointer-events:none; |
| background:radial-gradient(ellipse 60% 30% at 70% 15%,rgba(200,134,30,0.07),transparent); |
| } |
| |
| #ls-i-spine { |
| position:absolute; left:0; top:0; bottom:0; width:16px; |
| background:linear-gradient(to right,#0A0300 0%,#1C0803 35%,#2E1005 55%,#1C0803 75%,#0A0300 100%); |
| box-shadow:inset -2px 0 5px rgba(0,0,0,0.5); |
| } |
| #ls-i-spine::before { |
| content:''; position:absolute; left:6px; top:20px; bottom:20px; width:1px; |
| background:repeating-linear-gradient(to bottom,rgba(200,134,30,0.45) 0,rgba(200,134,30,0.45) 4px,transparent 4px,transparent 9px); |
| } |
| |
| #ls-i-body { |
| position:absolute; inset:0; left:18px; |
| display:flex; flex-direction:column; align-items:center; justify-content:center; |
| gap:0.7rem; padding:2.5rem 1.5rem; |
| } |
| #ls-i-frame { |
| position:absolute; inset:10px; left:22px; |
| border:1px solid rgba(200,134,30,0.22); pointer-events:none; |
| } |
| #ls-i-frame::before { content:''; position:absolute; inset:5px; border:1px solid rgba(200,134,30,0.1); } |
| #ls-i-frame::after { content:'◆'; position:absolute; top:-7px; left:50%; transform:translateX(-50%); font-size:0.5rem; color:rgba(200,134,30,0.45); } |
| |
| #ls-i-ornament { font-size:0.95rem; color:rgba(200,134,30,0.75); letter-spacing:0.3em; } |
| #ls-i-title { font-family:'Playfair Display',serif; text-align:center; line-height:1.15; } |
| #ls-i-title .t { display:block; font-size:2.6rem; font-weight:700; color:#E8A830; } |
| #ls-i-title .i { display:block; font-size:2.2rem; font-style:italic; font-weight:500; color:#FAF4E8; } |
| #ls-i-deco { width:55px; height:1px; background:linear-gradient(to right,transparent,#C8861E,transparent); } |
| #ls-i-desc { font-family:'Source Serif 4',serif; font-size:0.72rem; color:#B8A484; font-style:italic; letter-spacing:0.06em; text-align:center; opacity:0.75; } |
| #ls-i-year { font-family:'JetBrains Mono',monospace; font-size:0.55rem; color:rgba(200,134,30,0.4); letter-spacing:0.22em; margin-top:0.3rem; } |
| |
| #ls-i-cover-inside { |
| position:absolute; inset:0; |
| backface-visibility:hidden; -webkit-backface-visibility:hidden; |
| transform:rotateY(180deg); |
| background:linear-gradient(108deg,#EAE0C8,#E4D8B8); |
| border-radius:6px 2px 2px 6px; |
| } |
| |
| /* Caption fades in early */ |
| #ls-i-caption { |
| font-family:'JetBrains Mono',monospace; font-size:0.57rem; |
| letter-spacing:0.22em; text-transform:uppercase; |
| color:rgba(200,134,30,0.35); |
| opacity:0; animation:_rise 0.5s ease 0.35s forwards; |
| } |
| </style> |
| |
| <div id="ls-intro"> |
| <div id="ls-i-glow"></div> |
| <div id="ls-i-scene"> |
| <div id="ls-i-book"> |
| <div id="ls-i-back"> |
| <div id="ls-i-back-spine"></div> |
| <div id="ls-i-back-content"> |
| <div id="ls-i-back-title"> |
| <span class="t">Legacy</span><em class="i">Scribe</em> |
| </div> |
| <div id="ls-i-back-rule"></div> |
| <div id="ls-i-back-sub">Every family has a story worth keeping</div> |
| </div> |
| </div> |
| <div id="ls-i-cover"> |
| <div id="ls-i-cover-front"> |
| <div id="ls-i-spine"></div> |
| <div id="ls-i-frame"></div> |
| <div id="ls-i-body"> |
| <div id="ls-i-ornament">✦ · ✦</div> |
| <div id="ls-i-title"> |
| <span class="t">Legacy</span><em class="i">Scribe</em> |
| </div> |
| <div id="ls-i-deco"></div> |
| <div id="ls-i-desc">A Family Memory Journal</div> |
| <div id="ls-i-year">· 2025 ·</div> |
| </div> |
| </div> |
| <div id="ls-i-cover-inside"></div> |
| </div> |
| </div> |
| </div> |
| <div id="ls-i-caption">Family Memory Agent</div> |
| </div> |
| """) |
|
|
| |
| gr.HTML(""" |
| <div class="ls-header"> |
| <div class="ls-wordmark"> |
| <span class="ls-wordmark-legacy">Legacy</span><span class="ls-wordmark-scribe">Scribe</span><span class="ls-wordmark-dot">.</span> |
| </div> |
| <div class="ls-deco-line"></div> |
| <div class="ls-tagline">Every family has a story worth keeping</div> |
| <div class="ls-badges"> |
| <span class="ls-badge">Off the Grid</span> |
| <span class="ls-badge">Qwen3.5 · 9B</span> |
| <span class="ls-badge">LoRA Fine-tuned</span> |
| <span class="ls-badge">Nepali · English</span> |
| <span class="ls-badge">5-Agent Pipeline</span> |
| <span class="ls-badge">Build Small 2025</span> |
| </div> |
| </div> |
| """) |
|
|
| |
| with gr.Row(elem_classes=["ls-main"]): |
|
|
| |
| with gr.Column(elem_classes=["ls-conversation"]): |
| gr.HTML('<div class="ls-section-label">Your memory</div>') |
| chat_display = gr.HTML(build_chat_html([])) |
| notes_display = gr.HTML("") |
|
|
| with gr.Column(elem_classes=["ls-input-wrap"]): |
| user_input = gr.Textbox( |
| placeholder="Tell me about a person, a festival, a meal, a place — anything you remember…", |
| lines=3, |
| show_label=False, |
| container=False, |
| ) |
|
|
| with gr.Row(elem_classes=["ls-btn-row"]): |
| submit_btn = gr.Button("Share this memory →", elem_classes=["ls-btn", "ls-btn-primary"]) |
| clear_btn = gr.Button("Start over", elem_classes=["ls-btn", "ls-btn-secondary"]) |
|
|
| thinking = gr.HTML( |
| '<div class="ls-thinking" style="display:none">' |
| '<div class="ls-dot"></div><div class="ls-dot"></div><div class="ls-dot"></div>' |
| ' Listening and writing…</div>' |
| ) |
|
|
| |
| with gr.Column(elem_classes=["ls-book"]): |
| gr.HTML('<div class="ls-section-label">Your memory book</div>') |
| page_display = gr.HTML(build_page_html("", 1)) |
|
|
| def export_memory(state): |
| if not state["chapter"]: |
| return gr.update(visible=False) |
| content = "# My Memory Book\n\n" |
| content += f"## Chapter {state['chapter_num']}\n\n" |
| content += state["chapter"] + "\n\n---\n\n" |
| content += "### Memory fragments\n\n" |
| content += "\n".join(f"- {n}" for n in state["all_notes"]) |
| import tempfile, pathlib |
| path = pathlib.Path(tempfile.gettempdir()) / "legacyscribe_memory.txt" |
| path.write_text(content, encoding="utf-8") |
| return gr.update(visible=True, value=str(path)) |
|
|
| dl_btn = gr.Button("↓ Download memory book", elem_classes=["ls-dl-btn"]) |
| dl_output = gr.File(visible=False, label="Your memory book") |
| dl_btn.click(fn=export_memory, inputs=[state], outputs=[dl_output]) |
|
|
| |
| gr.HTML(""" |
| <div class="ls-footer"> |
| LegacyScribe · Build Small Hackathon 2025 · |
| Fine-tuned Qwen3.5-9B · Runs fully offline via llama.cpp |
| </div> |
| """) |
|
|
| |
| def on_submit(user_text, state): |
| if not user_text.strip(): |
| return (state, |
| build_chat_html(state["turns"]), |
| "", |
| build_page_html(state["chapter"], state["chapter_num"]), |
| "") |
| new_state, question, arc, chapter, notes = process_turn(user_text, state) |
| return (new_state, |
| build_chat_html(new_state["turns"]), |
| build_notes_html(notes), |
| build_page_html(chapter, new_state["chapter_num"]), |
| "") |
|
|
| def on_clear(): |
| s = init_state() |
| return s, build_chat_html([]), "", build_page_html("", 1), "" |
|
|
| submit_btn.click( |
| fn=on_submit, |
| inputs=[user_input, state], |
| outputs=[state, chat_display, notes_display, page_display, user_input], |
| ) |
| user_input.submit( |
| fn=on_submit, |
| inputs=[user_input, state], |
| outputs=[state, chat_display, notes_display, page_display, user_input], |
| ) |
| clear_btn.click( |
| fn=on_clear, |
| inputs=[], |
| outputs=[state, chat_display, notes_display, page_display, user_input], |
| ) |
|
|
|
|
| print("=== Launching Gradio demo ===") |
| if __name__ == "__main__": |
| demo.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| show_error=True, |
| ssr_mode=False, |
| ) |