Download src/streamlit_app.py from jofaichow/roamify: direct link, hf CLI and curl.
- Browser
- Download file 20.4 kB
-
https://huggingface.co/spaces/jofaichow/roamify/resolve/main/src/streamlit_app.py
- Command line
-
hf download hf://spaces/jofaichow/roamify/src/streamlit_app.py
-
curl -L -o streamlit_app.py https://huggingface.co/spaces/jofaichow/roamify/resolve/main/src/streamlit_app.py
20.4 kB
| """Roam Service — Streamlit App with dark theme and big fonts.""" | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".env", override=True) | |
| import streamlit as st | |
| import json | |
| import logging | |
| import folium | |
| from streamlit_folium import st_folium | |
| from styles.dark_theme import apply_dark_theme, EMOJI_MAP | |
| from services.recommender import get_recommendations_cached, translate_items_cached | |
| # ── Popular city suggestions ── | |
| CITY_SUGGESTIONS = [ | |
| "Amsterdam", "Athens", "Bali", | |
| "Bangkok", "Barcelona", "Beijing", "Berlin", | |
| "Budapest", "Buenos Aires", | |
| "Cairo", "Cape Town", "Chicago", | |
| "Copenhagen", "Delhi", | |
| "Dubai", "Dublin", "Edinburgh", | |
| "Florence", "Hanoi", "Hong Kong", | |
| "Honolulu", "Istanbul", | |
| "Kuala Lumpur", "Kyoto", | |
| "Las Vegas", "Lisbon", "London", | |
| "Los Angeles", "Madrid", "Marrakech", | |
| "Melbourne", "Mexico City", "Miami", | |
| "Milan", "Montreal", "Moscow", | |
| "Mumbai", "New York", "Osaka", | |
| "Oslo", "Paris", "Prague", | |
| "Reykjavik", "Rio de Janeiro", "Rome", | |
| "San Francisco", "Santiago", "Seoul", | |
| "Shanghai", "Singapore", "Stockholm", | |
| "Sydney", "Taipei", "Tel Aviv", | |
| "Tokyo", "Toronto", "Vancouver", | |
| "Venice", "Vienna", "Warsaw", | |
| "Washington", | |
| ] | |
| # ── Page config ── | |
| st.set_page_config( | |
| page_title="Roamify", | |
| page_icon="✈️", | |
| layout="wide", | |
| initial_sidebar_state="collapsed", | |
| ) | |
| # ── Apply dark theme ── | |
| apply_dark_theme() | |
| # ── Title ── | |
| st.title("✈️ Roamify") | |
| st.markdown( | |
| '<div style="font-size:15px; color:#888; margin-top:-10px; margin-bottom:18px;">Designed by Joe, powered by Hermes Agent · 2026</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # ── Category filter (single-select) ── | |
| CATEGORIES = [ | |
| ("Landmark", "🗼"), | |
| ("Culture", "🏛️"), | |
| ("Nature", "🌿"), | |
| ("Gems", "💎"), | |
| ("Photo", "📸"), | |
| ("Food", "🍽️"), | |
| ("Shopping", "🛍️"), | |
| ] | |
| CATEGORY_LABELS = [f"{emoji} {name}" for name, emoji in CATEGORIES] | |
| LANG_OPTIONS = { | |
| "None (English only)": None, | |
| "繁體中文 (Traditional Chinese)": "Traditional Chinese", | |
| "简体中文 (Simplified Chinese)": "Simplified Chinese", | |
| "日本語 (Japanese)": "Japanese", | |
| "한국어 (Korean)": "Korean", | |
| "Français (French)": "French", | |
| "Español (Spanish)": "Spanish", | |
| "Deutsch (German)": "German", | |
| } | |
| # ── Search controls — single responsive row ── | |
| # Category radio key — initialized for dynamic label | |
| if "cat_idx" not in st.session_state: | |
| st.session_state.cat_idx = 0 | |
| st.markdown('<div id="search-controls"></div>', unsafe_allow_html=True) | |
| col_city, col_cat, col_num, col_lang, col_search, col_refresh = st.columns([13, 50, 8, 15, 7, 7], gap="small") | |
| with col_city: | |
| city = st.selectbox("City", CITY_SUGGESTIONS, index=CITY_SUGGESTIONS.index("Tokyo")) | |
| with col_cat: | |
| selected_category = st.radio( | |
| label="Category", | |
| options=range(len(CATEGORIES)), | |
| format_func=lambda i: CATEGORY_LABELS[i], | |
| horizontal=True, | |
| key="cat_idx", | |
| ) | |
| with col_num: | |
| num_attractions = st.selectbox("Picks", [3, 6, 9, 12, 15], index=1) | |
| with col_lang: | |
| selected_lang = st.selectbox("Translation", list(LANG_OPTIONS.keys()), index=0) | |
| second_language = LANG_OPTIONS[selected_lang] | |
| with col_search: | |
| search = st.button("🔍", use_container_width=True, key="search_btn") | |
| with col_refresh: | |
| refresh = st.button("🔄", use_container_width=True, key="refresh_btn") | |
| # ── Track search mode ── | |
| if "refresh_mode" not in st.session_state: | |
| st.session_state.refresh_mode = False | |
| if refresh: | |
| st.session_state.refresh_mode = True | |
| # ── Validation ── | |
| if search or refresh: | |
| # Build categories dict from single-select radio | |
| categories = {name: (i == selected_category) for i, (name, _) in enumerate(CATEGORIES)} | |
| if not city.strip(): | |
| st.error("Please enter a city!") | |
| else: | |
| st.session_state["do_search"] = True | |
| st.session_state["search_params"] = { | |
| "city": city.strip(), | |
| "num_attractions": num_attractions, | |
| "second_language": second_language, | |
| "categories": categories, | |
| } | |
| def _short_name(text: str, max_len: int = 22) -> str: | |
| """Truncate name to fit one line in the card summary.""" | |
| if len(text) <= max_len: | |
| return text | |
| return text[:max_len].rstrip() + "…" | |
| def _render_cards(items: list[dict], translated: bool = False) -> None: | |
| """Render items as a 3-column grid of expandable cards with uniform heights per row.""" | |
| COLS = 3 | |
| # Build rows of items | |
| rows_data = [] | |
| for row_start in range(0, len(items), COLS): | |
| rows_data.append(items[row_start:row_start + COLS]) | |
| # For each row, compute max lines needed for descriptions | |
| CHARS_PER_LINE = 30 # estimated chars per line at 16px in 3-col layout | |
| for row_idx, row_items in enumerate(rows_data): | |
| # Find max description length in this row | |
| descs = [] | |
| for item in row_items: | |
| d = item.get("description_local" if translated and item.get("description_local") else "description", "") | |
| descs.append(d) | |
| max_desc_lines = max((len(d) + CHARS_PER_LINE - 1) // CHARS_PER_LINE for d in descs) if descs else 1 | |
| # Render this row | |
| cols = st.columns(COLS, gap="small") | |
| for col_idx, item in enumerate(row_items): | |
| i = row_idx * COLS + col_idx + 1 | |
| name = item.get("name", "Unknown") | |
| description = item.get("description", "") | |
| name_local = item.get("name_local", "") | |
| description_local = item.get("description_local", "") | |
| label = f"**{i}. {_short_name(name)}**" | |
| if translated and name_local: | |
| label += f" **— {_short_name(name_local)}**" | |
| # Compute padding for this card's description | |
| actual_desc = description_local if translated and description_local else description | |
| desc_lines = (len(actual_desc) + CHARS_PER_LINE - 1) // CHARS_PER_LINE | |
| desc_padding = "<br>" * (max_desc_lines - desc_lines) | |
| with cols[col_idx]: | |
| expand_by_default = (len(items) <= 6) or (i <= 3) | |
| # Hidden marker for card↔map hover sync | |
| st.markdown(f'<div class="card-pin" data-card-idx="{i}" style="display:none;"></div>', unsafe_allow_html=True) | |
| with st.expander(label, expanded=expand_by_default): | |
| image_url = item.get("image_url", "") | |
| if image_url: | |
| st.markdown( | |
| f'<div style="width:100%;aspect-ratio:16/9;overflow:hidden;' | |
| f'border-radius:8px;background:#1c2333;margin-bottom:12px;">' | |
| f'<img src="{image_url}" style="width:100%;height:100%;' | |
| f'object-fit:cover;object-position:center;display:block;" ' | |
| f'loading="lazy" alt="{name}" class="card-img"/>' | |
| f'</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| st.markdown( | |
| '<div style="display:flex;align-items:center;justify-content:center;' | |
| 'width:100%;aspect-ratio:16/9;background:#111;border-radius:8px;font-size:48px;' | |
| 'margin-bottom:12px;">' | |
| '🏛️</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # Description only (tips moved to map popups) | |
| st.markdown(f'<div class="card-desc">{actual_desc}{desc_padding}</div>', unsafe_allow_html=True) | |
| def _build_map(items: list[dict]) -> folium.Map: | |
| """Build a folium map with true spider legs: overlapping numbered circles | |
| fan out radially from their cluster centroid with straight leader lines | |
| connecting back to small dots at the true locations.""" | |
| valid_coords = [ | |
| (float(item["latitude"]), float(item["longitude"])) | |
| for item in items | |
| if item.get("latitude") is not None and item.get("longitude") is not None | |
| and str(item.get("latitude", "")).strip() != "" | |
| and str(item.get("longitude", "")).strip() != "" | |
| ] | |
| if valid_coords: | |
| center_lat = sum(c[0] for c in valid_coords) / len(valid_coords) | |
| center_lon = sum(c[1] for c in valid_coords) / len(valid_coords) | |
| else: | |
| center_lat, center_lon = 48.8566, 2.3522 | |
| m = folium.Map( | |
| location=[center_lat, center_lon], | |
| tiles="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", | |
| attr="© <a href='https://carto.com/'>CARTO</a>", | |
| name="CartoDB dark", | |
| zoom_control=False, | |
| ) | |
| # Remove Leaflet attribution control entirely | |
| m.get_root().html.add_child(folium.Element( | |
| '<style>.leaflet-control-attribution{display:none!important}</style>' | |
| )) | |
| marker_coords = [] | |
| for i, item in enumerate(items, 1): | |
| try: | |
| lat = float(item.get("latitude", 0)) | |
| lon = float(item.get("longitude", 0)) | |
| except (ValueError, TypeError): | |
| continue | |
| if lat == 0 and lon == 0: | |
| continue | |
| name = item.get("name", "Unknown") | |
| name_local = item.get("name_local", "") | |
| tip = item.get("tip_local", "") or item.get("tip", "") | |
| # Build popup with Name and Tip — block layout for spacing | |
| lines = [f"<div style='color:#2a9fd6; font-size:16px; font-weight:bold'>{i}. {name}</div>"] | |
| if name_local: | |
| lines.append(f"<div style='color:#aaa; font-size:13px'>{name_local}</div>") | |
| if tip: | |
| lines.append(f"<div style='font-size:15px; margin-top:6px'>💡 {tip}</div>") | |
| popup_html = "".join(lines) | |
| marker_coords.append([lat, lon]) | |
| # Small anchor dot at true position | |
| folium.CircleMarker( | |
| location=[lat, lon], | |
| radius=4, | |
| color="#2a9fd6", | |
| fill=True, | |
| fill_color="#2a9fd6", | |
| fill_opacity=0.9, | |
| weight=1, | |
| ).add_to(m) | |
| # Numbered circle marker (position updated by JS) | |
| folium.Marker( | |
| location=[lat, lon], | |
| popup=folium.Popup(popup_html, max_width=260, offset=(0, -25)), | |
| icon=folium.DivIcon( | |
| html=( | |
| f'<div class="spider-marker" data-idx="{i}" data-lat="{lat}" data-lng="{lon}" style="' | |
| f'display:flex;align-items:center;justify-content:center;' | |
| f'width:36px;height:36px;border-radius:50%;' | |
| f'background:#2a9fd6;color:#fff;font-size:18px;font-weight:700;' | |
| f'box-shadow:0 2px 6px rgba(0,0,0,0.5);' | |
| f'cursor:pointer;">' | |
| f'{i}</div>' | |
| ), | |
| icon_size=(36, 36), | |
| icon_anchor=(18, 18), | |
| ), | |
| ).add_to(m) | |
| # Fit map bounds to show all markers with slight padding | |
| if marker_coords: | |
| m.fit_bounds(marker_coords, padding=(30, 30)) | |
| # Spider legs: cluster detection → radial fan-out → leader lines | |
| spider_js = """<script> | |
| (function(){ | |
| var MIN_DIST=48, LEG_LENGTH=44, svgEl=null; | |
| function findMap(){for(var k in window){try{if(window[k] instanceof L.Map)return window[k]}catch(e){}}return null} | |
| function ensureSvg(m){ | |
| if(svgEl)return svgEl; | |
| var c=m.getContainer(); | |
| svgEl=document.createElementNS('http://www.w3.org/2000/svg','svg'); | |
| svgEl.style.cssText='position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:450;'; | |
| c.appendChild(svgEl);return svgEl; | |
| } | |
| function run(){ | |
| var map=findMap();if(!map)return; | |
| var svg=ensureSvg(map); | |
| var els=document.querySelectorAll('.spider-marker'); | |
| if(!els.length)return; | |
| var pts=[]; | |
| els.forEach(function(el){ | |
| var lat=parseFloat(el.getAttribute('data-lat')),lng=parseFloat(el.getAttribute('data-lng')); | |
| var cp=map.latLngToContainerPoint([lat,lng]); | |
| pts.push({el:el,x:cp.x,y:cp.y,ox:cp.x,oy:cp.y,idx:parseInt(el.getAttribute('data-idx'))}); | |
| }); | |
| // Reset all positions | |
| pts.forEach(function(p){p.x=p.ox;p.y=p.oy;p.el.style.transform=''}); | |
| // Find clusters (groups of markers within MIN_DIST of each other) | |
| var clusters=[], assigned={}; | |
| for(var i=0;i<pts.length;i++){ | |
| if(assigned[i])continue; | |
| var cluster=[i]; assigned[i]=true; | |
| for(var j=i+1;j<pts.length;j++){ | |
| if(assigned[j])continue; | |
| for(var k=0;k<cluster.length;k++){ | |
| var ci=cluster[k]; | |
| var dx=pts[j].x-pts[ci].x, dy=pts[j].y-pts[ci].y; | |
| if(Math.sqrt(dx*dx+dy*dy)<MIN_DIST){cluster.push(j);assigned[j]=true;break;} | |
| } | |
| } | |
| if(cluster.length>1)clusters.push(cluster); | |
| } | |
| // Clear old lines | |
| svg.querySelectorAll('line').forEach(function(l){l.remove()}); | |
| // For each cluster: compute centroid, fan out radially | |
| clusters.forEach(function(cidxs){ | |
| var cx=0,cy=0; | |
| cidxs.forEach(function(i){cx+=pts[i].ox;cy+=pts[i].oy;}); | |
| cx/=cidxs.length;cy/=cidxs.length; | |
| var n=cidxs.length; | |
| var startAngle=0; | |
| cidxs.forEach(function(i,k){ | |
| var angle=startAngle+(k*2*Math.PI/n); | |
| var tx=cx+Math.cos(angle)*LEG_LENGTH; | |
| var ty=cy+Math.sin(angle)*LEG_LENGTH; | |
| var ox=tx-pts[i].ox, oy=ty-pts[i].oy; | |
| pts[i].x=tx;pts[i].y=ty; | |
| pts[i].el.style.transform='translate('+ox+'px,'+oy+'px)'; | |
| var line=document.createElementNS('http://www.w3.org/2000/svg','line'); | |
| line.setAttribute('x1',pts[i].ox);line.setAttribute('y1',pts[i].oy); | |
| line.setAttribute('x2',tx);line.setAttribute('y2',ty); | |
| line.setAttribute('stroke','#2a9fd6'); | |
| line.setAttribute('stroke-width','1.5'); | |
| line.setAttribute('stroke-opacity','0.7'); | |
| svg.appendChild(line); | |
| }); | |
| }); | |
| } | |
| function init(){ | |
| var m=findMap();if(!m){setTimeout(init,200);return} | |
| m.on('moveend',run);m.on('zoomend',run);setTimeout(run,300); | |
| } | |
| if(document.readyState==='complete')init();else window.addEventListener('load',init); | |
| })(); | |
| </script>""" | |
| m.get_root().html.add_child(folium.Element(spider_js)) | |
| return m | |
| # ── Results ── | |
| if st.session_state.get("do_search"): | |
| params = st.session_state["search_params"] | |
| sec_lang = params.get("second_language") | |
| temperature = 0.7 if st.session_state.pop("refresh_mode", False) else 0 | |
| try: | |
| with st.spinner(f"Finding recommendations in {params['city']}..."): | |
| attractions = get_recommendations_cached( | |
| city=params["city"], | |
| num_attractions=params["num_attractions"], | |
| categories=params.get("categories"), | |
| temperature=temperature, | |
| ) | |
| if attractions is None: | |
| st.error("Failed to get recommendations. The AI response couldn't be parsed. Please try again.") | |
| st.stop() | |
| # Store in session state for survival across Clear clicks | |
| st.session_state["last_attractions"] = attractions | |
| if sec_lang: | |
| with st.spinner(f"Translating into {sec_lang}..."): | |
| # Translate full cached set so hash matches the stored 19-item key | |
| full_attractions = get_recommendations_cached( | |
| city=params["city"], | |
| num_attractions=19, | |
| categories=params.get("categories"), | |
| temperature=temperature, | |
| ) | |
| if full_attractions: | |
| translated = translate_items_cached( | |
| items=full_attractions, | |
| second_language=sec_lang, | |
| city=params["city"], | |
| categories=params.get("categories"), | |
| ) | |
| num = len(attractions) # user's requested count | |
| attractions = translated[:num] | |
| st.session_state["last_attractions"] = attractions | |
| except RuntimeError as e: | |
| st.error(f"⚠️ {e}") | |
| st.stop() | |
| except Exception as e: | |
| st.error(f"Something went wrong: {e}") | |
| st.stop() | |
| # ── Two-column layout: cards (left) | map (right) ── | |
| left_col, right_col = st.columns([1, 1]) | |
| with left_col: | |
| st.subheader(f"{EMOJI_MAP['attractions']} Recommendations") | |
| with st.container(height=800, border=False): | |
| _render_cards(attractions, translated=bool(sec_lang)) | |
| with right_col: | |
| st.subheader("🗺️ Map") | |
| st.markdown('<div style="margin-bottom:10px;"></div>', unsafe_allow_html=True) | |
| m = _build_map(attractions) | |
| st_folium(m, width="100%", height=800, returned_objects=[]) | |
| elif st.session_state.get("last_attractions"): | |
| # Page reload or re-run: show previous results without re-calling LLM | |
| attractions = st.session_state["last_attractions"] | |
| left_col, right_col = st.columns([1, 1]) | |
| with left_col: | |
| st.subheader(f"{EMOJI_MAP['attractions']} Recommendations") | |
| with st.container(height=800, border=False): | |
| _render_cards(attractions, translated=False) | |
| with right_col: | |
| st.subheader("🗺️ Map") | |
| st.markdown('<div style="margin-bottom:10px;"></div>', unsafe_allow_html=True) | |
| m = _build_map(attractions) | |
| st_folium(m, width="100%", height=800, returned_objects=[]) | |
| else: | |
| # ── Onboarding: hero card panel ── | |
| import re | |
| hero_html = """<div class="hero-section" style="display:flex;flex-direction:column;align-items:center;justify-content:center; | |
| min-height:600px;padding:80px 20px 40px;text-align:center;"> | |
| <div class="hero-icon" style="font-size:120px;margin-bottom:16px;line-height:1;">🧳</div> | |
| <div class="hero-title" style="font-size:42px;font-weight:700;color:#dee2e6;margin-bottom:8px;"> | |
| Where to next? | |
| </div> | |
| <div class="hero-subtitle" style="font-size:24px;color:#888;max-width:600px;margin-bottom:32px;line-height:1.6;"> | |
| Choose a city, tell us what you love, and get tailored recommendations. | |
| </div> | |
| <div style="display:flex;gap:24px;flex-wrap:wrap;justify-content:center;margin-bottom:40px;"> | |
| <div class="hero-card" style="background:#1c2333;border-radius:14px;padding:24px 28px;width:190px;border:1px solid #2a2f3a;"> | |
| <div style="font-size:48px;margin-bottom:10px;">🗼</div> | |
| <div style="font-weight:600;color:#dee2e6;font-size:20px;">Landmarks</div> | |
| <div style="font-size:16px;color:#666;margin-top:6px;">Colosseum, Taj Mahal, Big Ben</div> | |
| </div> | |
| <div class="hero-card" style="background:#1c2333;border-radius:14px;padding:24px 28px;width:190px;border:1px solid #2a2f3a;"> | |
| <div style="font-size:48px;margin-bottom:10px;">🏛️</div> | |
| <div style="font-weight:600;color:#dee2e6;font-size:20px;">Culture</div> | |
| <div style="font-size:16px;color:#666;margin-top:6px;">Louvre, British Museum, Uffizi</div> | |
| </div> | |
| <div class="hero-card" style="background:#1c2333;border-radius:14px;padding:24px 28px;width:190px;border:1px solid #2a2f3a;"> | |
| <div style="font-size:48px;margin-bottom:10px;">🍽️</div> | |
| <div style="font-weight:600;color:#dee2e6;font-size:20px;">Food</div> | |
| <div style="font-size:16px;color:#666;margin-top:6px;">Pizza, Ramen, In-N-Out Burgers</div> | |
| </div> | |
| <div class="hero-card" style="background:#1c2333;border-radius:14px;padding:24px 28px;width:190px;border:1px solid #2a2f3a;"> | |
| <div style="font-size:48px;margin-bottom:10px;">🛍️</div> | |
| <div style="font-weight:600;color:#dee2e6;font-size:20px;">Shopping</div> | |
| <div style="font-size:16px;color:#666;margin-top:6px;">Harrods, Grand Bazaar, Ginza</div> | |
| </div> | |
| </div> | |
| </div>""" | |
| st.markdown(re.sub(r"\n\s+", "\n", hero_html), unsafe_allow_html=True) |