palmtts / app.py
omanext's picture
Update app.py
78b7b03 verified
Raw
History Blame
39.8 kB
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",
},
}
@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
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()