devvibes's picture
Initial commit
a4d1533
"""
🌿 Haven Kitchen OS - Streamlit Interface
The main chat interface for Olivia & Brie
"""
import streamlit as st
import asyncio
from datetime import datetime
from dotenv import load_dotenv
import sys
import os
# Add package directory to path for imports when running as subprocess
_pkg_dir = os.path.dirname(os.path.abspath(__file__))
if _pkg_dir not in sys.path:
sys.path.insert(0, _pkg_dir)
# Now import modules (works both as package and standalone)
from src.config import SystemConfig
from src.agents.brain import KitchenBrain
from src.utils.card_gen import generate_luxury_card
from speech import speak, olivia_handoff_to_brie, celebrate_completion, start_listening
from recipe_manager import get_recipe_session, parse_recipe
from recipe_cast import cast_recipe, generate_qr_code, get_cast_url, start_cast_server
from animations import animate, idle_mode
from audio_recorder_streamlit import audio_recorder
import speech_recognition as sr
import io
load_dotenv()
st.set_page_config(page_title="Haven", page_icon="🌿", layout="wide")
# 🎀 Voice-to-Text Helper
def transcribe_audio(audio_bytes):
"""Convert audio bytes to text using Google Speech Recognition."""
if not audio_bytes:
return None
recognizer = sr.Recognizer()
try:
audio_file = io.BytesIO(audio_bytes)
with sr.AudioFile(audio_file) as source:
audio_data = recognizer.record(source)
text = recognizer.recognize_google(audio_data)
if text:
text = text[0].upper() + text[1:] if len(text) > 1 else text.upper()
return text
except sr.UnknownValueError:
st.warning("🎀 Couldn't understand audio. Please try again.")
return None
except sr.RequestError as e:
st.error(f"🎀 Speech recognition error: {e}")
return None
except Exception as e:
st.error(f"🎀 Audio error: {e}")
return None
# Start cast server on app load
start_cast_server()
# --- THE SAGE & LATTE THEME ---
st.markdown("""
<style>
/* Layout & Reset */
#MainMenu, footer, header, section[data-testid="stSidebar"] {display: none;}
.main .block-container { padding-bottom: 150px; max-width: 1000px; padding-top: 20px; }
.stApp { background-color: #EBE7DE; color: #2C3E50; }
/* Hide default chat input - we use custom input bar */
.stChatInput { display: none !important; }
/* 🎀 Mic Button Styling */
iframe[title="audio_recorder_streamlit.audio_recorder"] { border: none; height: 50px !important; }
.stSpinner { color: #6B8E6B !important; }
/* Hero Header */
.hero-container { text-align: center; padding: 20px 0; background: #FAF9F6; border-radius: 20px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
.hero-title { font-family: 'Georgia', serif; font-size: 36px; color: #1A1A1A; margin: 0; }
/* Chat Bubbles - TIGHTER spacing */
.stChatMessage[data-testid="stChatMessage"] {
padding: 0.75rem 1rem !important;
border-radius: 15px;
margin-bottom: 0.5rem !important;
}
[data-testid="stChatMessageContent"] > div {
padding: 0 !important;
margin: 0 !important;
}
/* User = Sage */
.stChatMessage[data-testid="stChatMessage"]:nth-child(odd) { background-color: #D4E6D5; border: 1px solid #B4C6B5; }
/* Assistant = Latte */
.stChatMessage[data-testid="stChatMessage"]:nth-child(even) { background-color: #FFF8F0; border: 1px solid #E6DCCA; }
.stChatMessage p, .stChatMessage div { color: #000000 !important; }
/* Buttons & Tabs */
.stDownloadButton button { background-color: #C07A5C !important; color: #FFFFFF !important; border: none !important; width: 100%; }
.stTabs [data-baseweb="tab-list"] { gap: 20px; justify-content: center; width: 100%; }
.stTabs [data-baseweb="tab"] { background-color: #FFF; border-radius: 15px; padding: 10px 40px; border: 1px solid #DDD; flex-grow: 1; text-align: center; }
.stTabs [aria-selected="true"] { background-color: #C07A5C; color: white !important; border: none; }
/* Cooking Mode Banner */
.cooking-mode {
background: linear-gradient(135deg, #C07A5C 0%, #A66B4F 100%);
color: white;
padding: 15px 20px;
border-radius: 15px;
margin: 10px 0;
text-align: center;
}
.cooking-mode h3 { margin: 0 0 10px 0; }
.voice-commands {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
margin-top: 10px;
}
.voice-cmd {
background: rgba(255,255,255,0.2);
padding: 5px 12px;
border-radius: 20px;
font-size: 0.9em;
}
</style>
<script>
function scrollBottom() {
window.scrollTo(0, document.body.scrollHeight);
}
setInterval(scrollBottom, 500);
</script>
""", unsafe_allow_html=True)
def get_greeting():
h = datetime.now().hour
return "Good Morning" if 5<=h<12 else "Good Afternoon" if 12<=h<17 else "Good Evening"
TIME_OPTIONS = [datetime.strptime(f"{h}:{m}", "%H:%M").strftime("%I:%M %p") for h in range(24) for m in (0, 15, 30, 45)]
def show_cooking_mode_banner(recipe_title: str, current_step: int, total_steps: int, is_active: bool):
"""Display the cooking mode status banner."""
if is_active:
st.markdown(f"""
<div class="cooking-mode">
<h3>πŸ‘©πŸ»β€πŸ³ Cooking: {recipe_title}</h3>
<div>Step {current_step + 1} of {total_steps}</div>
<div class="voice-commands">
<span class="voice-cmd">πŸ—£οΈ "Next"</span>
<span class="voice-cmd">πŸ—£οΈ "Repeat"</span>
<span class="voice-cmd">πŸ—£οΈ "Back"</span>
<span class="voice-cmd">πŸ—£οΈ "Ingredients"</span>
</div>
</div>
""", unsafe_allow_html=True)
else:
st.markdown(f"""
<div class="cooking-mode">
<h3>πŸ‘©πŸ»β€πŸ³ Recipe Ready: {recipe_title}</h3>
<div class="voice-commands">
<span class="voice-cmd">πŸ—£οΈ "Let's cook" to start</span>
<span class="voice-cmd">πŸ—£οΈ "Ingredients" for the list</span>
</div>
</div>
""", unsafe_allow_html=True)
def main():
if "brain" not in st.session_state:
st.session_state.brain = KitchenBrain()
if "recipe_session" not in st.session_state:
st.session_state.recipe_session = get_recipe_session()
config = st.session_state.brain.cfg
recipe_session = st.session_state.recipe_session
st.markdown("""
<div class="hero-container">
<div style="font-size: 52px; margin-bottom: 10px;">πŸ‘©πŸ»β€πŸ’Ό &nbsp; 🌿 &nbsp; πŸ‘©πŸ»β€πŸ³</div>
<div class="hero-title">Olivia & Brie</div>
<div style="font-family: 'Helvetica Neue'; color: #888; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">The Haven System</div>
</div>
""", unsafe_allow_html=True)
tab_home, tab_settings = st.tabs(["🌿 Living Room", "βš™οΈ Settings"])
with tab_home:
# Show cooking mode banner if recipe is active
if recipe_session.has_active_recipe():
recipe = recipe_session.current_recipe
show_cooking_mode_banner(
recipe.title,
recipe.current_step,
recipe.total_steps,
recipe_session.cooking_mode
)
if "messages" not in st.session_state:
history = st.session_state.brain.memory.load_history()
if history:
st.session_state.messages = history
else:
greeting = get_greeting()
st.session_state.messages = [{
"role": "assistant",
"content": f"**{greeting}, {config.user_name}.**\n\nWelcome home. I'm reviewing the daily agenda, and Brie is standing by in the kitchen.",
"avatar": "πŸ‘©πŸ»β€πŸ’Ό"
}]
for msg in st.session_state.messages:
with st.chat_message(msg["role"], avatar=msg.get("avatar", "πŸ‘©πŸ»β€πŸ’Ό")):
clean = msg["content"].replace("**Brie:**", "").strip()
st.markdown(clean)
# Show cast button for recipe messages
if msg.get("has_recipe") and msg.get("cast_url"):
col1, col2 = st.columns([3, 1])
with col1:
st.markdown(f"πŸ“± **Send to your phone:** `{msg['cast_url']}`")
with col2:
if msg.get("qr_code"):
st.image(msg["qr_code"], width=80)
st.markdown("<div id='end-of-chat'></div>", unsafe_allow_html=True)
# 🎀 Voice Input - integrated into input bar
if "last_audio_hash" not in st.session_state:
st.session_state.last_audio_hash = None
if "voice_input" not in st.session_state:
st.session_state.voice_input = ""
if "input_key" not in st.session_state:
st.session_state.input_key = 0
st.markdown("""
<style>
section[data-testid="stMain"] { padding-bottom: 100px !important; }
</style>
""", unsafe_allow_html=True)
# Input bar with mic on left, text input on right
mic_col, input_col, send_col = st.columns([1, 10, 1])
with mic_col:
audio_bytes = audio_recorder(
text="",
recording_color="#e74c3c",
neutral_color="#6B8E6B",
icon_name="microphone",
icon_size="lg",
pause_threshold=2.0,
sample_rate=16000
)
# Process voice input if we got NEW audio
prompt = None
voice_text = ""
if audio_bytes:
audio_hash = hash(audio_bytes)
if audio_hash != st.session_state.last_audio_hash:
st.session_state.last_audio_hash = audio_hash
transcribed = transcribe_audio(audio_bytes)
if transcribed:
voice_text = transcribed
prompt = transcribed
st.toast(f"🎀 \"{prompt}\"")
with input_col:
typed_prompt = st.text_input(
"message",
placeholder="Chat with Olivia... (or click 🎀 to speak)",
label_visibility="collapsed",
key=f"text_input_{st.session_state.input_key}"
)
with send_col:
send_clicked = st.button("➀", use_container_width=True)
# Handle send button or Enter key
if typed_prompt and send_clicked:
prompt = typed_prompt
st.session_state.input_key += 1
elif typed_prompt:
if "last_typed" not in st.session_state:
st.session_state.last_typed = ""
if typed_prompt != st.session_state.last_typed:
prompt = typed_prompt
st.session_state.last_typed = typed_prompt
st.session_state.input_key += 1
if prompt:
st.session_state.messages.append({"role": "user", "content": prompt, "avatar": config.user_avatar})
with st.chat_message("user", avatar=config.user_avatar):
st.markdown(prompt)
# πŸŽ™οΈ Check for recipe voice commands FIRST
voice_response = recipe_session.handle_command(prompt)
if voice_response:
if any(word in prompt.lower() for word in ["next", "done", "finished"]):
animate("step_complete", persona="Brie")
with st.chat_message("assistant", avatar="πŸ‘©πŸ»β€πŸ³"):
st.markdown(voice_response)
speak(voice_response, "Brie")
st.session_state.messages.append({
"role": "assistant",
"content": voice_response,
"avatar": "πŸ‘©πŸ»β€πŸ³"
})
st.session_state.brain.memory.save_interaction("assistant", voice_response, "πŸ‘©πŸ»β€πŸ³")
st.session_state.last_typed = ""
st.rerun()
# Normal routing to Olivia/Brie
start_listening()
persona, handoff, generator = asyncio.run(st.session_state.brain.route_and_process(prompt))
av = "πŸ‘©πŸ»β€πŸ³" if persona == "Brie" else "πŸ‘©πŸ»β€πŸ’Ό"
full_response = ""
with st.chat_message("assistant", avatar=av):
if handoff:
olivia_handoff_to_brie()
st.markdown(handoff)
full_response += handoff + "\n\n"
placeholder = st.empty()
streamed_text = ""
async def consume():
nonlocal streamed_text
async for chunk in generator:
streamed_text += chunk
placeholder.markdown(streamed_text + "β–Œ")
placeholder.markdown(streamed_text)
asyncio.run(consume())
full_response += streamed_text
# πŸ“– Check if this is a recipe response from Brie
recipe = None
cast_url = None
qr_bytes = None
if persona == "Brie":
recipe = recipe_session.load_recipe(full_response)
if recipe:
animate("excited", persona="Brie")
speak(recipe.get_intro(), "Brie")
cast_url = cast_recipe(recipe.to_dict())
qr_bytes = generate_qr_code(cast_url)
st.markdown("---")
st.info("πŸ—£οΈ **Ready to cook?** Just say **'let's cook'** to start, or **'ingredients'** for the shopping list!")
col1, col2 = st.columns([2, 1])
with col1:
st.markdown(f"πŸ“± **View on your phone:**")
st.code(cast_url, language=None)
with col2:
if qr_bytes:
st.image(qr_bytes, caption="Scan me!", width=100)
try:
card_img = generate_luxury_card(recipe.title, full_response.replace("**", ""))
st.download_button("πŸ“„ Save Recipe Card", card_img, f"{recipe.title}.png", "image/png")
except:
pass
else:
speak(streamed_text, persona)
else:
speak(streamed_text, persona)
msg_data = {
"role": "assistant",
"content": full_response,
"avatar": av,
"has_recipe": recipe is not None,
"cast_url": cast_url,
"qr_code": qr_bytes
}
st.session_state.brain.memory.save_interaction("assistant", full_response, av)
st.session_state.messages.append(msg_data)
st.session_state.last_typed = ""
st.rerun()
with tab_settings:
st.header("Preferences")
c1, c2 = st.columns(2)
with c1:
new_name = st.text_input("Name", value=config.user_name)
avatars = ["πŸ‘©", "πŸ‘©πŸ»", "πŸ‘©πŸΌ", "πŸ‘©πŸ½", "πŸ‘©πŸΎ", "πŸ‘©πŸΏ", "πŸ‘¨", "πŸ‘¨πŸ»", "πŸ‘¨πŸΌ", "πŸ‘¨πŸ½", "πŸ‘¨πŸΎ", "πŸ‘¨πŸΏ"]
try: idx = avatars.index(config.user_avatar)
except: idx = 0
new_avatar = st.selectbox("Avatar", options=avatars, index=idx)
with c2:
new_loc = st.text_input("Location", value=config.location)
new_diet = st.text_area("Diet", value=config.dietary_restrictions)
st.subheader("Meal Times")
def t_idx(t): return TIME_OPTIONS.index(t) if t in TIME_OPTIONS else 0
cb, cl, cd = st.columns(3)
with cb:
st.markdown("**Breakfast**")
bs = st.selectbox("Start", TIME_OPTIONS, index=t_idx(config.meal_times['breakfast']['start']), key="bs")
be = st.selectbox("End", TIME_OPTIONS, index=t_idx(config.meal_times['breakfast']['end']), key="be")
with cl:
st.markdown("**Lunch**")
ls = st.selectbox("Start", TIME_OPTIONS, index=t_idx(config.meal_times['lunch']['start']), key="ls")
le = st.selectbox("End", TIME_OPTIONS, index=t_idx(config.meal_times['lunch']['end']), key="le")
with cd:
st.markdown("**Dinner**")
ds = st.selectbox("Start", TIME_OPTIONS, index=t_idx(config.meal_times['dinner']['start']), key="ds")
de = st.selectbox("End", TIME_OPTIONS, index=t_idx(config.meal_times['dinner']['end']), key="de")
if st.button("πŸ’Ύ Save Settings", type="primary"):
config.user_name = new_name
config.user_avatar = new_avatar
config.location = new_loc
config.dietary_restrictions = new_diet
config.meal_times = {"breakfast": {"start": bs, "end": be}, "lunch": {"start": ls, "end": le}, "dinner": {"start": ds, "end": de}}
config.save_preferences()
st.success("Saved!")
st.rerun()
if __name__ == "__main__":
main()