Spaces:
Sleeping
Sleeping
| import os | |
| import re | |
| import wave | |
| import tempfile | |
| import subprocess | |
| 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 | |
| # ============================================================ | |
| # ARABIC TTS FIX | |
| # ============================================================ | |
| try: | |
| subprocess.run(["espeak-ng", "--voices=ar"], capture_output=True, timeout=10) | |
| except: | |
| pass | |
| # ============================================================ | |
| # 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", | |
| }, | |
| } | |
| 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 | |
| syn = SynthesisConfig( | |
| length_scale=1.0, | |
| noise_scale=0.667, | |
| noise_w_scale=0.8, | |
| ) | |
| # Try 1: original text | |
| try: | |
| 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 e1: | |
| print(f"[Latifa] TTS attempt 1 failed ({lang}): {e1}") | |
| # Try 2: Arabic without diacritics (tashkeel) | |
| if lang == "ar": | |
| try: | |
| no_diac = re.sub(r'[\u064B-\u065F\u0670]+', '', clean) | |
| if no_diac and len(no_diac) >= 2: | |
| tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) | |
| tmp.close() | |
| with wave.open(tmp.name, "wb") as wf: | |
| voice.synthesize_wav(no_diac, wf, syn_config=syn) | |
| print("[Latifa] Arabic TTS worked WITHOUT diacritics") | |
| return tmp.name | |
| except Exception as e2: | |
| print(f"[Latifa] TTS attempt 2 failed: {e2}") | |
| # Try 3: short Arabic text | |
| try: | |
| short = clean[:100] | |
| tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) | |
| tmp.close() | |
| with wave.open(tmp.name, "wb") as wf: | |
| voice.synthesize_wav(short, wf, syn_config=syn) | |
| print("[Latifa] Arabic TTS worked with SHORT text") | |
| return tmp.name | |
| except Exception as e3: | |
| print(f"[Latifa] TTS attempt 3 failed: {e3}") | |
| 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/characters — Story characters", | |
| "chapters": "https://www.under-palm-tree.com/chapters — Story chapters in 1973 Oman", | |
| "games": "https://www.under-palm-tree.com/games — Interactive educational games", | |
| "skills": "https://www.under-palm-tree.com/skills — Skills practice hub", | |
| "gallery": "https://www.under-palm-tree.com/gallery — Visual gallery", | |
| "music": "https://www.under-palm-tree.com/music — Music and audio", | |
| "story world": "https://www.under-palm-tree.com/story-world — Immersive 1973 world", | |
| "latifa": "https://www.under-palm-tree.com/latifa-ai — Latifa AI page", | |
| }, | |
| } | |
| # ============================================================ | |
| # 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 = '<div id="status-bar">🟡 Ready</div>' | |
| STATUS_SPEAKING = '<div id="status-bar">🔊 Speaking</div>' | |
| STATUS_ERROR = '<div id="status-bar">❌ Error</div>' | |
| 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(135deg, var(--soil) 0%, var(--burgundy) 100%); | |
| border-radius: 20px; | |
| padding: 36px 28px 28px; | |
| text-align: center; | |
| margin-bottom: 16px; | |
| position: relative; | |
| overflow: hidden; | |
| animation: fadeUp 0.6s ease-out; | |
| } | |
| #latifa-header * { | |
| color: var(--sand) !important; | |
| } | |
| #latifa-header::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 0 L60 30 L30 60 L0 30Z' fill='none' stroke='%23F5EDD8' stroke-opacity='0.05' stroke-width='1'/%3E%3C/svg%3E") repeat; | |
| pointer-events: none; | |
| } | |
| #latifa-header h1 { | |
| font-family: 'Ovo', serif !important; | |
| font-size: 2.4rem !important; | |
| color: var(--sand) !important; | |
| margin: 0 0 6px !important; | |
| letter-spacing: 0.02em; | |
| position: relative; | |
| } | |
| #latifa-header .tagline { | |
| font-size: 1rem; | |
| color: var(--clay) !important; | |
| letter-spacing: 0.04em; | |
| margin: 0; | |
| position: relative; | |
| } | |
| #latifa-header .tagline-ar { | |
| font-size: 0.9rem; | |
| color: var(--clay) !important; | |
| opacity: 0.8; | |
| margin: 4px 0 0; | |
| position: relative; | |
| direction: rtl; | |
| } | |
| #latifa-header .palm-deco { | |
| font-size: 2.2rem; | |
| margin-bottom: 8px; | |
| position: relative; | |
| } | |
| #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; | |
| } | |
| """ | |
| # ============================================================ | |
| # 9. GRADIO UI | |
| # ============================================================ | |
| with gr.Blocks( | |
| css=CUSTOM_CSS, | |
| title="Latifa AI — Under The Palm Tree", | |
| theme=latifa_theme, | |
| ) as demo: | |
| # ---------- Header ---------- | |
| gr.HTML( | |
| """ | |
| <link href="https://fonts.googleapis.com/css2?family=Ovo&family=Almarai:wght@300;400;700;800&display=swap" rel="stylesheet"> | |
| <div id="latifa-header"> | |
| <div class="palm-deco">🌴</div> | |
| <h1>Latifa AI — لطيفة</h1> | |
| <p class="tagline">Your Voice Guide Under The Palm Tree</p> | |
| <p class="tagline-ar">دليلك الصوتي تحت شجرة النخيل</p> | |
| </div> | |
| """ | |
| ) | |
| # ---------- Status ---------- | |
| status = gr.HTML(STATUS_READY) | |
| # ---------- Chat ---------- | |
| chatbot = gr.Chatbot( | |
| label="Conversation", | |
| height=380, | |
| type="messages", | |
| elem_id="chat-window", | |
| ) | |
| # ---------- Quick Action Chips ---------- | |
| gr.HTML('<div class="chips-label">Quick Actions | إجراءات سريعة</div>') | |
| 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") | |
| 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( | |
| """ | |
| <div id="footer-credit"> | |
| Created by <strong>Thuraya Mohammed Ali Al-Naabia</strong> | |
| — Founder of Under the Palm Tree<br> | |
| <a href="https://www.under-palm-tree.com" target="_blank"> | |
| www.under-palm-tree.com | |
| </a> | |
| </div> | |
| """ | |
| ) | |
| # ======================================================== | |
| # 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 = """ | |
| <script> | |
| (function() { | |
| function send(cmd) { | |
| try { window.parent.postMessage({latifa: cmd}, '*'); } catch(e) {} | |
| } | |
| function watchAudio() { | |
| var audioEls = document.querySelectorAll('audio'); | |
| for (var i = 0; i < audioEls.length; i++) { | |
| var a = audioEls[i]; | |
| if (a._bridged) continue; | |
| a._bridged = true; | |
| a.addEventListener('playing', function() { send('talking'); }); | |
| a.addEventListener('ended', function() { send('stop'); }); | |
| a.addEventListener('pause', function() { send('stop'); }); | |
| } | |
| } | |
| function watchChat() { | |
| var observer = new MutationObserver(function(mutations) { | |
| for (var i = 0; i < mutations.length; i++) { | |
| var nodes = mutations[i].addedNodes; | |
| for (var j = 0; j < nodes.length; j++) { | |
| var node = nodes[j]; | |
| if (node.nodeType !== 1) continue; | |
| var isBot = false; | |
| try { | |
| isBot = (node.querySelector && node.querySelector('[data-testid*="bot"]')) | |
| || (node.classList && node.classList.contains('bot')); | |
| } catch(e) {} | |
| if (isBot) { | |
| send('thinking'); | |
| setTimeout(function() { watchAudio(); }, 1000); | |
| setTimeout(function() { send('stop'); }, 15000); | |
| } | |
| } | |
| } | |
| }); | |
| var chatbot = null; | |
| try { | |
| chatbot = document.querySelector('.gradio-chatbot') | |
| || document.querySelector('[data-testid="chatbot"]'); | |
| } catch(e) {} | |
| if (chatbot) { | |
| observer.observe(chatbot, { childList: true, subtree: true }); | |
| } else { | |
| setTimeout(watchChat, 2000); | |
| } | |
| } | |
| function watchMic() { | |
| document.addEventListener('click', function(e) { | |
| var el = e.target; | |
| if (el.closest && ( | |
| el.closest('[data-testid="audio"]') || | |
| el.closest('.audio-container') | |
| )) { | |
| send('listening'); | |
| setTimeout(function() { send('stop'); }, 15000); | |
| } | |
| }); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', function() { | |
| watchChat(); watchMic(); watchAudio(); | |
| }); | |
| } else { | |
| watchChat(); watchMic(); watchAudio(); | |
| } | |
| })(); | |
| </script> | |
| """ | |
| with demo: | |
| gr.HTML(BRIDGE_JS, visible=False) | |
| # ============================================================ | |
| # 12. LAUNCH | |
| # ============================================================ | |
| if __name__ == "__main__": | |
| demo.launch() |