File size: 17,961 Bytes
a4d1533
 
 
 
 
5aeac76
 
 
 
a4d1533
 
 
 
 
 
 
 
 
5aeac76
 
 
a4d1533
 
 
 
 
 
5aeac76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4d1533
5aeac76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4d1533
5aeac76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4d1533
5aeac76
 
 
 
 
 
a4d1533
 
5aeac76
 
 
 
 
 
a4d1533
5aeac76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4d1533
5aeac76
 
 
a4d1533
5aeac76
 
 
 
 
 
a4d1533
5aeac76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4d1533
5aeac76
 
a4d1533
5aeac76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4d1533
5aeac76
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
"""
🌿 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()