import os import re import wave import tempfile from functools import lru_cache import gradio as gr from huggingface_hub import hf_hub_download from piper import PiperVoice, SynthesisConfig from groq import Groq # ============================================================ # 1. CONFIGURATION # ============================================================ GROQ_API_KEY = os.environ.get("GROQ_API_KEY") client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None REPO_ID = "rhasspy/piper-voices" # ============================================================ # 2. LATIFA AI — SYSTEM PROMPT # ============================================================ LATIFA_PROMPT = ( "You are Latifa AI — the official voice guide of 'Under the Palm Tree' " "(تحت شجرة النخيل), an interactive English learning project set in Oman, 1973.\n\n" "IDENTITY:\n" "- You are an Omani educational guide created by Thuraya Mohammed Ali Al-Naabia.\n" "- You are warm, professional, knowledgeable, and encouraging.\n" "- You speak naturally like a real Omani English teacher.\n\n" "═══════════════════════════════════════\n" "LANGUAGE RULES (CRITICAL — FOLLOW STRICTLY):\n" "═══════════════════════════════════════\n" "- Detect the user's language automatically.\n" "- If the user writes in Arabic → respond ENTIRELY in Arabic. No English words mixed in.\n" "- If the user writes in English → respond ENTIRELY in English. No Arabic words mixed in.\n" "- If the user writes in mixed Arabic-English → respond in the SAME language the user used MOST.\n" "- NEVER switch languages mid-sentence. Pick ONE language per response.\n" "- When responding in Arabic, you MUST add full tashkeel (diacritics) to ALL Arabic words.\n" " Example: مَرْحَبًا بِكُمْ فِي تَحْتَ شَجَرَةِ النَّخِيل\n" " The tashkeel is essential for correct pronunciation by the text-to-speech system.\n" "- When responding in English, use simple clear English suitable for language learners.\n\n" "PROJECT:\n" "- Name: 123 Let's Learn English Under The Palm Tree\n" "- Founder: Thuraya Mohammed Ali Al-Naabia — Omani English Teacher & Educational Innovator\n" "- Website: www.under-palm-tree.com\n" "- Setting: Oman, 1973, Al-Qurawashiyah Village, Samail\n" "- The project blends English learning with Omani heritage through interactive storytelling, " "games, and cultural exploration.\n\n" "MAIN CHARACTERS:\n" "- Thuraya: Omani English teacher, black hijab, black abaya/blazer, story narrator. " "Mature, natural, warm.\n" "- John: British male teacher, tall, brown hair, light beard, beige shirt, brown trousers. " "Professional, kind.\n" "- Sophia: British female teacher, blonde hair/bun, green eyes, soft blouse, long skirt. " "Friendly, gentle, curious.\n" "- Malood: Small white baby goat, funny, mischievous. Eats Sophia's ring, chews notebooks.\n\n" "LOCATIONS:\n" "- Al-Qurawashiyah Village, Samail: Traditional Omani village with palm groves, " "falaj irrigation, mud houses, old school.\n" "- Seeb Airport (Muscat): Where John and Sophia first arrive.\n" "- Dukkan Al-Hillah: The village shop.\n" "- Samail Market (Souq): Local market visited via Uncle Nasser's pickup.\n" "- Falaj: Traditional Omani water channels, UNESCO heritage.\n\n" "CHAPTERS:\n" "1. 'Thuraya's Book of Thoughts' — Turkey, 2026. Thuraya remembers: John reading the letter, " "Sophia's hesitation, the flight, arrival in Oman.\n" "2. 'The First Walk in the Village' — Oman, 1973. Sophia's first night in the clay house. " "Morning walk: letj game, falaj, Sadoo's shop, Khamis, nahsher trees.\n" "3. 'The First Day at School' — John writes 'HELLO' on chalkboard. Malood eats Sophia's ring " "and chews notebooks.\n" "4. 'The Morning Departure to the Souq' — Trip to Dukkan Al-Hillah, Uncle Nasser's pickup, " "Samail market. Learning 'I want.' Meeting Habbabouh Saif.\n\n" "GAMES:\n" "- Palm Echo: Speaking and pronunciation practice. Listen, repeat, improve confidence.\n\n" "SKILLS: Speaking, listening, reading, writing, vocabulary, grammar, pronunciation, spelling.\n\n" "CULTURAL NOTES:\n" "- Falaj: Traditional irrigation channels, UNESCO heritage.\n" "- Oud & Luban: Fragrant incense (oud wood, frankincense).\n" "- Letj: Traditional Omani children's game.\n" "- 1973: Oman modernizing under Sultan Qaboos.\n\n" "AUTHOR:\n" "Thuraya Mohammed Ali Al-Naabia — Omani English teacher, educational innovator, storyteller.\n" "Quote: 'Learning is not only about mastering a language; it is about discovering stories, " "building connections, and creating new possibilities.'\n\n" "WEBSITE PAGES: Home, Characters, Chapters, Games, Skills Practice, " "Gallery, Music, Story World, Latifa AI. Guide users to pages when asked.\n\n" "STORY RULES:\n" "- Never invent story events or change character identities.\n" "- Never change the era (always 1973) or add modern objects.\n" "- If you don't know something, say: 'I don't know that part of the story yet.'\n\n" "RESPONSE FORMATTING:\n" "- Do NOT use emojis in your responses.\n" "- Do NOT use markdown formatting (no **, ##, bullets with special chars).\n" "- Write plain text only — the response will be spoken aloud by a voice system.\n" "- Keep answers clear, natural, and conversational.\n" "- For vocabulary: word → meaning in the other language → example sentence.\n" "- For quizzes: ask one question at a time.\n" "- Be culturally respectful and proud of Omani heritage.\n" "- Maximum 3-4 sentences per response to keep it conversational." ) # ============================================================ # 3. VOICE CONFIGURATION & LOADER # ============================================================ VOICE_MAP = { "en": { "model": "en/en_GB/alba/medium/en_GB-alba-medium.onnx", "config": "en/en_GB/alba/medium/en_GB-alba-medium.onnx.json", }, "ar": { "model": "ar/ar_JO/khalil/medium/ar_JO-khalil-medium.onnx", "config": "ar/ar_JO/khalil/medium/ar_JO-khalil-medium.onnx.json", }, } @lru_cache(maxsize=4) def load_piper_voice(model_file, config_file): """Download and cache a Piper voice model from Hugging Face.""" try: mpath = hf_hub_download( repo_id=REPO_ID, filename=model_file, repo_type="model" ) cpath = hf_hub_download( repo_id=REPO_ID, filename=config_file, repo_type="model" ) return PiperVoice.load(mpath, cpath) except Exception as e: print(f"[Latifa] Voice load failed: {e}") return None def detect_lang(text): """Return 'ar' if text is predominantly Arabic, else 'en'.""" if not text: return "en" arabic_chars = 0 total_chars = 0 for c in text: if "\u0600" <= c <= "\u06FF" or "\u0750" <= c <= "\u077F" or "\u064B" <= c <= "\u065F": arabic_chars += 1 if c.isalpha(): total_chars += 1 if total_chars == 0: return "en" return "ar" if arabic_chars / total_chars > 0.3 else "en" def clean_text_for_tts(text, lang): """Clean text before sending to Piper TTS.""" if not text: return "" text = re.sub(r'https?://\S+', '', text) text = re.sub(r'\S+@\S+', '', text) emoji_pattern = re.compile( "[" "\U0001F600-\U0001F64F" "\U0001F300-\U0001F5FF" "\U0001F680-\U0001F6FF" "\U0001F1E0-\U0001F1FF" "\U00002702-\U000027B0" "\U000024C2-\U0001F251" "\U0001f926-\U0001f937" "\U00010000-\U0010ffff" "\u2640-\u2642" "\u2600-\u2B55" "\u200d" "\u23cf" "\u23e9" "\u231a" "\ufe0f" "\u3030" "]+", flags=re.UNICODE, ) text = emoji_pattern.sub('', text) text = re.sub(r'\*+', '', text) text = re.sub(r'#+\s*', '', text) text = re.sub(r'`+', '', text) text = re.sub(r'_{2,}', '', text) text = re.sub(r'-{2,}', '', text) text = re.sub(r'\|', '', text) text = re.sub(r'[{}[\]<>()@#$%^&*=+~\\]', '', text) if lang == "ar": text = re.sub( r'[^\u0600-\u06FF\u0750-\u077F\u064B-\u065F\u0670\s.,!?;:،؟!\n]', ' ', text, ) else: text = re.sub(r'[^a-zA-Z\s.,!?;:\'"\-\n]', ' ', text) text = re.sub(r'\s+', ' ', text).strip() max_chars = 800 if len(text) > max_chars: text = text[:max_chars].rsplit(' ', 1)[0] return text def speak(text, lang="en"): """Synthesize speech with Piper. Returns file path or None.""" clean = clean_text_for_tts(text, lang) if not clean or len(clean) < 2: return None cfg = VOICE_MAP.get(lang, VOICE_MAP["en"]) voice = load_piper_voice(cfg["model"], cfg["config"]) if voice is None: return None try: syn = SynthesisConfig( length_scale=1.0, noise_scale=0.667, noise_w_scale=0.8, ) tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp.close() with wave.open(tmp.name, "wb") as wf: voice.synthesize_wav(clean, wf, syn_config=syn) return tmp.name except Exception as e: print(f"[Latifa] TTS error: {e}") return None # ============================================================ # 4. KNOWLEDGE BASE # ============================================================ KB = { "characters": { "thuraya": ( "Thuraya — Omani English teacher, story narrator. Black hijab, " "black abaya/blazer. Mature, natural, warm. Leads the story and " "teaches English in Al-Qurawashiyah village." ), "john": ( "John — British male teacher. Tall, brown hair, light beard. " "Beige shirt, brown trousers. Professional and kind. Comes to " "Oman to teach English." ), "sophia": ( "Sophia — British female teacher. Blonde hair in a bun, green eyes. " "Soft blouse, long skirt. Friendly, gentle, curious about Omani culture. " "Her ring gets eaten by Malood!" ), "malood": ( "Malood — Small white baby goat. Funny and mischievous. Famous for " "eating Sophia's ring and chewing notebooks. A beloved chaos-maker." ), }, "chapters": { "1": ( "Chapter 1: 'Thuraya's Book of Thoughts' — Turkey, 2026. Thuraya " "opens her notebook and remembers: John reading the letter in London, " "Sophia's hesitation, the long flight, the heat of Oman's arrival, " "and their first meeting at the airport." ), "2": ( "Chapter 2: 'The First Walk in the Village' — Oman, 1973, late afternoon. " "Sophia's first night in the clay house with oud and luban. Morning walk " "through the village: letj game, falaj, Sadoo's shop, Khamis the " "Thursday-named man, nahsher trees." ), "3": ( "Chapter 3: 'The First Day at School' — Al-Qurawashiyah, 1973. John " "writes 'HELLO' on the chalkboard and meets students. Malood the goat " "eats Sophia's ring and chews the notebooks!" ), "4": ( "Chapter 4: 'The Morning Departure to the Souq' — Trip to Dukkan " "Al-Hillah, then Uncle Nasser's pickup to Samail market. Class learns " "'I want.' Meet the unforgettable Habbabouh Saif." ), }, "locations": { "al-qurawashiyah": ( "Al-Qurawashiyah Village, Samail, Oman — Traditional Omani village " "with palm groves, falaj irrigation, mud/clay houses, old school." ), "seeb": "Seeb Airport (Muscat) — Where John and Sophia first arrive in 1973.", "falaj": ( "Falaj — Traditional Omani irrigation channels carrying mountain water " "to villages. UNESCO heritage." ), "souq": ( "Samail Market (Souq) — Local market visited via Uncle Nasser's pickup." ), "dukkan": "Dukkan Al-Hillah — The village shop in Al-Qurawashiyah.", }, "games": { "palm echo": ( "Palm Echo — Speaking and pronunciation practice game. Listen to words, " "repeat them, build confidence. Skills: pronunciation, speaking, listening." ), }, "skills": { "speaking": "Speaking Practice — Conversations, repetition exercises, pronunciation drills. Try Palm Echo.", "listening": "Listening Practice — Audio activities and story narration.", "reading": "Reading Practice — Story chapters and interactive texts set in Oman.", "writing": "Writing Practice — Guided exercises and creative prompts.", "vocabulary": "Vocabulary — Contextual learning tied to Omani culture and daily life.", "grammar": "Grammar — Rules learned through story examples.", "pronunciation": "Pronunciation — Palm Echo game and repetition exercises.", "spelling": "Spelling — Word games and activities.", }, "cultural": { "falaj": "Falaj: Traditional Omani irrigation — channels from mountains to villages. UNESCO heritage.", "oud": "Oud: Fragrant wood incense, deeply valued in Omani homes.", "luban": "Luban (Frankincense): Traditional Omani aromatic resin. Oman was the world's main source.", "letj": "Letj: Traditional Omani children's game played in villages.", "1973": ( "Oman 1973: Modernizing under Sultan Qaboos. Traditional village life " "coexisting with development. Foreign teachers building the education system." ), }, "author": { "name": "Thuraya Mohammed Ali Al-Naabia", "title": "Founder of Under the Palm Tree", "role": "Omani English Teacher & Educational Innovator", "bio": ( "An Omani English teacher dedicated to transforming language learning " "through creativity and technology. Combines storytelling, cultural " "heritage, digital learning, and AI." ), "vision": "English learning as an immersive journey connecting language, culture, creativity.", "philosophy": "The most effective learning happens when students are emotionally connected.", "quote": ( "Learning is not only about mastering a language; it is about discovering " "stories, building connections, and creating new possibilities." ), }, "sitemap": { "home": "https://www.under-palm-tree.com — Project introduction and mission", "characters": "https://www.under-palm-tree.com/palm-characters — Story characters", "chapters": "https://www.under-palm-tree.com/underthepalm — Read the story chapters", "games": "https://www.under-palm-tree.com/palmgames — Interactive educational games", "skills": "https://www.under-palm-tree.com/four-and-more-skills — Skills practice hub", "gallery": "https://www.under-palm-tree.com/gallery-1 — Visual gallery", "music": "https://www.under-palm-tree.com/songs — Music and audio", }, } # ============================================================ # 5. KNOWLEDGE SEARCH # ============================================================ QUERY_KEYWORDS = { "character": "characters", "who is": "characters", "john": "characters", "sophia": "characters", "malood": "characters", "chapter": "chapters", "story": "chapters", "episode": "chapters", "game": "games", "play": "games", "echo": "games", "palm echo": "games", "practice": "skills", "skill": "skills", "learn": "skills", "speaking": "skills", "listening": "skills", "reading": "skills", "writing": "skills", "vocab": "skills", "grammar": "skills", "pronunciation": "skills", "spelling": "skills", "author": "author", "founder": "author", "creator": "author", "thuraya mohammed": "author", "oman": "cultural", "culture": "cultural", "1973": "cultural", "falaj": "cultural", "oud": "cultural", "luban": "cultural", "letj": "cultural", "page": "sitemap", "website": "sitemap", "navigate": "sitemap", "where": "sitemap", "link": "sitemap", "show me": "sitemap", "village": "locations", "location": "locations", "samail": "locations", "airport": "locations", "souq": "locations", "shop": "locations", "ثريا": "characters", "صوفيا": "characters", "معلود": "characters", "الماعز": "characters", "شخصية": "characters", "شخصيات": "characters", "فصل": "chapters", "قصة": "chapters", "حكاية": "chapters", "لعبة": "games", "بالم ايكو": "games", "صدى النخلة": "games", "تمرين": "skills", "تعلم": "skills", "ممارسة": "skills", "كتابة": "skills", "قواعد": "skills", "تهجئة": "skills", "مؤلف": "author", "المؤسسة": "author", "المعلمة": "author", "ثريا محمد": "author", "عمان": "cultural", "ثقافة": "cultural", "تراث": "cultural", "فلج": "cultural", "عود": "cultural", "لبان": "cultural", "لتج": "cultural", "صفحة": "sitemap", "موقع": "sitemap", "رابط": "sitemap", "قرية": "locations", "سمائل": "locations", "مطار": "locations", "سوق": "locations", "دكان": "locations", "مرحبا": None, "هلا": None, "السلام": None, } def search_kb(query): """Return relevant knowledge-base entries as a context string.""" if not query: return "" q = query.lower() cats = set() for kw, cat in QUERY_KEYWORDS.items(): if kw in q and cat: cats.add(cat) if not cats: return "" hits = [] for cat in cats: if cat == "author": a = KB["author"] hits.append( f"Author: {a['name']} — {a['title']}. {a['bio']} " f"Philosophy: {a['philosophy']} Quote: \"{a['quote']}\"" ) elif cat in KB: for val in KB[cat].values(): hits.append(val) seen = set() unique = [] for h in hits: if h not in seen: seen.add(h) unique.append(h) return "\n---\n".join(unique[:6]) # ============================================================ # 6. CHAT FUNCTIONS # ============================================================ STATUS_READY = '
🟡 Ready
' STATUS_SPEAKING = '
🔊 Speaking
' STATUS_ERROR = '
❌ Error
' def build_messages(history, user_msg): """Build Groq message list with knowledge-base context injected.""" system = LATIFA_PROMPT kb = search_kb(user_msg) if kb: system += f"\n\nRELEVANT CONTEXT:\n{kb}" msgs = [{"role": "system", "content": system}] if history: for m in history: if isinstance(m, dict): r, c = m.get("role"), m.get("content") if r in ("user", "assistant") and c: msgs.append({"role": r, "content": str(c)}) elif isinstance(m, (list, tuple)) and len(m) == 2: u, b = m if u: msgs.append({"role": "user", "content": str(u)}) if b: msgs.append({"role": "assistant", "content": str(b)}) msgs.append({"role": "user", "content": user_msg}) return msgs def push(history, user_msg, bot_msg): history.append({"role": "user", "content": user_msg}) history.append({"role": "assistant", "content": bot_msg}) return history def latifa_respond(user_text, voice_file, history): """Main handler: text or voice → text reply + Piper audio.""" if history is None: history = [] # Voice input → Whisper transcription if voice_file and os.path.exists(voice_file): try: with open(voice_file, "rb") as af: tr = client.audio.transcriptions.create( model="whisper-large-v3", file=af ) user_text = tr.text except Exception as e: history = push(history, "🎙️ Voice", f"Transcription failed: {e}") return history, None, "", None, STATUS_ERROR if not user_text or not user_text.strip(): return history, None, "", None, STATUS_READY user_text = user_text.strip() if not client: history = push( history, user_text, "API key not set. Please add GROQ_API_KEY in Space settings.", ) return history, None, "", None, STATUS_ERROR messages = build_messages(history, user_text) try: resp = client.chat.completions.create( model="llama-3.3-70b-versatile", messages=messages, temperature=0.6, max_tokens=300, ) reply = resp.choices[0].message.content except Exception as e: history = push(history, user_text, f"AI error: {e}") return history, None, "", None, STATUS_ERROR lang = detect_lang(reply) audio = speak(reply, lang) history = push(history, user_text, reply) status = STATUS_SPEAKING if audio else STATUS_READY return history, audio, "", None, status def replay(history): """Re-synthesize the last assistant message.""" if not history: return None, STATUS_READY for m in reversed(history): if isinstance(m, dict) and m.get("role") == "assistant": txt = m.get("content", "") if txt: a = speak(txt, detect_lang(txt)) return a, (STATUS_SPEAKING if a else STATUS_READY) return None, STATUS_READY def clear_all(): return [], None, "", STATUS_READY # ============================================================ # 7. CUSTOM THEME # ============================================================ latifa_theme = gr.themes.Base( primary_hue=gr.themes.colors.Color( c50="#F5EDD8", c100="#EDE0C4", c200="#C9AA80", c300="#8C6A3F", c400="#4A3520", c500="#360304", c600="#360304", c700="#360304", c800="#360304", c900="#360304", c950="#360304", ), secondary_hue=gr.themes.colors.Color( c50="#F5EDD8", c100="#EDE0C4", c200="#C9AA80", c300="#8C6A3F", c400="#4A3520", c500="#360304", c600="#360304", c700="#360304", c800="#360304", c900="#360304", c950="#360304", ), neutral_hue=gr.themes.colors.Color( c50="#F5EDD8", c100="#EDE0C4", c200="#C9AA80", c300="#8C6A3F", c400="#4A3520", c500="#4A3520", c600="#4A3520", c700="#4A3520", c800="#4A3520", c900="#4A3520", c950="#4A3520", ), font=[ gr.themes.GoogleFont("Almarai"), "ui-sans-serif", "sans-serif", ], font_mono=[ gr.themes.GoogleFont("Almarai"), "ui-monospace", "monospace", ], ) # ============================================================ # 8. CSS # ============================================================ CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Ovo&family=Almarai:wght@300;400;700;800&display=swap'); :root { --sand: #F5EDD8; --linen: #EDE0C4; --clay: #C9AA80; --earth: #8C6A3F; --soil: #4A3520; --burgundy: #360304; } .gradio-container { background: var(--sand) !important; font-family: 'Almarai', sans-serif !important; color: var(--soil) !important; } body, .gradio-container, .gradio-container *, .gradio-container p, .gradio-container span, .gradio-container label, .gradio-container div, .prose, .prose p, .prose span { color: var(--soil) !important; font-family: 'Almarai', sans-serif !important; } #latifa-header { background: linear-gradient(180deg, #FFFFFF 0%, var(--sand) 100%); border: 1px solid var(--clay); border-radius: 20px; padding: 24px 28px 22px; text-align: center; margin-bottom: 16px; position: relative; overflow: hidden; animation: fadeUp 0.6s ease-out; } #latifa-header * { color: var(--soil) !important; } #latifa-header .logo-img { height: 96px; width: auto; margin-bottom: 10px; position: relative; } #latifa-header h1 { font-family: 'Ovo', serif !important; font-size: 1.7rem !important; color: var(--burgundy) !important; margin: 0 0 6px !important; letter-spacing: 0.02em; position: relative; } #latifa-header .tagline { font-size: 0.95rem; color: var(--earth) !important; letter-spacing: 0.04em; margin: 0; position: relative; } #latifa-header .tagline-ar { font-size: 0.88rem; color: var(--earth) !important; opacity: 0.85; margin: 4px 0 0; position: relative; direction: rtl; } #status-bar { text-align: center; padding: 6px 18px; border-radius: 16px; font-size: 0.82rem; font-weight: 700; color: var(--soil) !important; background: var(--linen) !important; border: 1px solid var(--clay); margin-bottom: 10px; display: inline-block; } #chat-window, #chat-window *, #chat-window div, #chat-window span, #chat-window p, #chat-window label { border-radius: 16px !important; } #chat-window { background: var(--linen) !important; border: 1px solid var(--clay) !important; box-shadow: 0 4px 24px rgba(74,53,32,0.08) !important; animation: fadeUp 0.8s ease-out; } #chat-window .user, #chat-window .message.user, #chat-window [class*="user"], div[data-testid*="user"] .message, div[data-testid*="user"] p, div[data-testid*="user"] span { background: var(--burgundy) !important; color: #F5EDD8 !important; border-radius: 14px !important; } #chat-window .bot, #chat-window .message.bot, #chat-window [class*="bot"], div[data-testid*="bot"] .message, div[data-testid*="bot"] p, div[data-testid*="bot"] span { background: var(--sand) !important; color: var(--soil) !important; border: 1px solid var(--clay) !important; border-radius: 14px !important; } .gradio-chatbot .message, .gradio-chatbot p, .gradio-chatbot span, .gradio-chatbot div { color: var(--soil) !important; } .gradio-chatbot .user p, .gradio-chatbot .user span, .gradio-chatbot .user div { color: #F5EDD8 !important; } .gradio-chatbot .bot p, .gradio-chatbot .bot span, .gradio-chatbot .bot div { color: var(--soil) !important; } .chip-btn { border-radius: 20px !important; background: var(--sand) !important; border: 1.5px solid var(--clay) !important; color: var(--soil) !important; padding: 6px 18px !important; font-size: 0.82rem !important; font-weight: 700 !important; cursor: pointer !important; transition: all 0.25s ease !important; white-space: nowrap !important; } .chip-btn:hover { background: var(--earth) !important; color: var(--sand) !important; border-color: var(--earth) !important; transform: translateY(-1px) !important; box-shadow: 0 4px 12px rgba(74,53,32,0.15) !important; } .chip-btn:active { transform: scale(0.97) !important; } #input-row textarea, #input-row input { background: var(--sand) !important; border: 1.5px solid var(--clay) !important; border-radius: 12px !important; color: var(--soil) !important; font-size: 0.95rem !important; } #input-row textarea:focus, #input-row input:focus { border-color: var(--earth) !important; box-shadow: 0 0 0 2px rgba(140,106,63,0.2) !important; } #input-row textarea::placeholder, #input-row input::placeholder { color: var(--earth) !important; opacity: 0.7 !important; } #send-btn { background: var(--burgundy) !important; color: var(--sand) !important; border: none !important; border-radius: 12px !important; font-weight: 700 !important; font-size: 0.95rem !important; padding: 10px 24px !important; transition: all 0.25s ease !important; } #send-btn:hover { background: var(--soil) !important; transform: translateY(-1px) !important; } #replay-btn, #clear-btn { border-radius: 12px !important; font-weight: 700 !important; border: 1.5px solid var(--clay) !important; color: var(--soil) !important; background: var(--sand) !important; transition: all 0.25s ease !important; } #replay-btn:hover, #clear-btn:hover { background: var(--earth) !important; color: var(--sand) !important; } #audio-input, #audio-output { border-radius: 12px !important; background: var(--linen) !important; border: 1px solid var(--clay) !important; } label, .label-wrap span { color: var(--soil) !important; font-weight: 700 !important; } #footer-credit { text-align: center; padding: 18px 10px 8px; font-size: 0.75rem; color: var(--earth) !important; line-height: 1.6; } #footer-credit a { color: var(--earth) !important; text-decoration: underline; } @keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } @media (max-width: 640px) { #latifa-header { padding: 24px 16px 20px; } #latifa-header h1 { font-size: 1.8rem !important; } .chip-btn { font-size: 0.72rem !important; padding: 5px 10px !important; } } ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--linen); } ::-webkit-scrollbar-thumb { background: var(--clay); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--earth); } .gradio-accordion, .gradio-group, .gradio-box { background: var(--linen) !important; border-color: var(--clay) !important; color: var(--soil) !important; } .gradio-container .prose h1, .gradio-container .prose h2, .gradio-container .prose h3, .gradio-container .prose h4 { color: var(--soil) !important; } .chips-label { text-align: center; margin: 8px 0 4px; font-size: 0.8rem; color: var(--earth) !important; font-weight: 700; } #quick-actions-acc { border: 1px solid var(--clay) !important; border-radius: 14px !important; background: var(--linen) !important; margin-bottom: 10px !important; } #quick-actions-acc .label-wrap { padding: 8px 14px !important; } #quick-actions-acc button.label-wrap, #quick-actions-acc .label-wrap span { color: var(--soil) !important; font-weight: 700 !important; font-size: 0.85rem !important; } """ # ============================================================ # 9. GRADIO UI # ============================================================ with gr.Blocks( css=CUSTOM_CSS, title="Latifa AI — Under The Palm Tree", theme=latifa_theme, ) as demo: # ---------- Header ---------- gr.HTML( """
Under The Palm Tree

Latifa AI — لطيفة

Your Voice Guide Under The Palm Tree

دليلك الصوتي تحت شجرة النخيل

""" ) # ---------- Status ---------- status = gr.HTML(STATUS_READY) # ---------- Chat ---------- chatbot = gr.Chatbot( label="Conversation", height=380, type="messages", elem_id="chat-window", ) # ---------- Quick Action Chips (collapsed by default to reduce clutter) ---------- with gr.Accordion("Quick Actions | إجراءات سريعة", open=False, elem_id="quick-actions-acc"): with gr.Row(equal_height=False): chip_char = gr.Button("Characters | الشخصيات", elem_classes=["chip-btn"], size="sm") chip_chap = gr.Button("Chapters | الفصول", elem_classes=["chip-btn"], size="sm") chip_game = gr.Button("Games | الألعاب", elem_classes=["chip-btn"], size="sm") with gr.Row(equal_height=False): chip_vocab = gr.Button("Vocabulary | المفردات", elem_classes=["chip-btn"], size="sm") chip_quiz = gr.Button("Quiz | اختبرني", elem_classes=["chip-btn"], size="sm") chip_oman = gr.Button("Oman 1973", elem_classes=["chip-btn"], size="sm") # ---------- Input ---------- with gr.Row(elem_id="input-row"): user_input = gr.Textbox( show_label=False, placeholder="Ask Latifa anything... | اسأل لطيفة عن أي شيء…", scale=4, elem_id="text-input", ) voice_input = gr.Audio( sources=["microphone"], type="filepath", show_label=False, scale=2, elem_id="audio-input", ) # ---------- Action Buttons ---------- with gr.Row(): send_btn = gr.Button( "Send & Speak | أرسل ✨", variant="primary", elem_id="send-btn", scale=3 ) replay_btn = gr.Button("🔊 Replay | أعد", elem_id="replay-btn", scale=1) clear_btn = gr.Button("🗑️ Clear | مسح", elem_id="clear-btn", scale=1) # ---------- Audio Output ---------- audio_output = gr.Audio( label="Voice Response | الرد الصوتي", autoplay=True, elem_id="audio-output" ) # ---------- Footer ---------- gr.HTML( """ """ ) # ======================================================== # 10. EVENT WIRING # ======================================================== main_inputs = [user_input, voice_input, chatbot] main_outputs = [chatbot, audio_output, user_input, voice_input, status] send_btn.click( fn=latifa_respond, inputs=main_inputs, outputs=main_outputs ) user_input.submit( fn=latifa_respond, inputs=main_inputs, outputs=main_outputs ) replay_btn.click( fn=replay, inputs=[chatbot], outputs=[audio_output, status] ) clear_btn.click( fn=clear_all, outputs=[chatbot, audio_output, user_input, status] ) chip_presets = { chip_char: "Tell me about the characters in Under the Palm Tree", chip_chap: "Show me the story chapters", chip_game: "What games can I play to practice English?", chip_vocab: "Help me practice vocabulary from the story", chip_quiz: "Quiz me on Under the Palm Tree", chip_oman: "Tell me about Oman in 1973", } for btn, prompt in chip_presets.items(): btn.click( fn=lambda h, p=prompt: latifa_respond(p, None, h), inputs=[chatbot], outputs=main_outputs, ) # ============================================================ # 11. AVATAR BRIDGE — sends signals to parent website # ============================================================ BRIDGE_JS = """ """ with demo: gr.HTML(BRIDGE_JS, visible=False) # ============================================================ # 12. LAUNCH # ============================================================ if __name__ == "__main__": demo.launch()