diff --git a/app.py b/app.py index 89e5a13..b627fd6 100644 --- a/app.py +++ b/app.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any, Optional import streamlit as st +import streamlit.components.v1 as st_components from PIL import Image, ImageOps from app.agents.shopping_agent import ShoppingAgent @@ -286,34 +287,30 @@ st.markdown( justify-content: space-between; z-index: 1; } - .side-drawer-close-link { - display: inline-flex; - align-items: center; - justify-content: center; - text-decoration: none; - color: #333 !important; - font-size: 14px; - font-weight: 500; - padding: 8px 14px; - border-radius: 8px; - border: 1px solid #ccc; - background: #f0f0f0 !important; - min-width: 60px; - box-shadow: 0 1px 2px rgba(0,0,0,0.06); - } - .side-drawer-close-link:hover { - color: #111 !important; - background: #e5e5e5 !important; - border-color: #999; - } .side-drawer-content { padding: 14px 16px 20px 16px; } - /* Ensure drawer overlay is on top and not clipped by Streamlit blocks */ .side-drawer-backdrop, .side-drawer-panel { position: fixed !important; } + #side-drawer-close-btn { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid #ccc; + background: #f5f5f5; + color: #333; + font-size: 14px; + font-weight: 500; + padding: 6px 14px; + border-radius: 8px; + cursor: pointer; + } + #side-drawer-close-btn:hover { + background: #e8e8e8; + color: #111; + } /* Bottom floating ask/compare bar */ .bottom-actions-bar { @@ -532,9 +529,11 @@ def display_product_card_from_item( product: ProductItem, ref_id: str, index: int, + widget_prefix: str = "", ) -> None: """Render a single product card with hover actions: Similar products + checkbox.""" pkey = _product_key(ref_id, index, product) + key_suffix = f"{widget_prefix}_{pkey}" if widget_prefix else pkey info = _product_to_info(product, ref_id) selected = st.session_state.selected_products @@ -570,13 +569,13 @@ def display_product_card_from_item( with col_a: similar_clicked = st.button( "Similar products", - key=f"similar_{pkey}", + key=f"similar_{key_suffix}", help="Search by this product title and show in side panel", ) with col_b: is_checked = st.checkbox( "Select", - key=f"select_{pkey}", + key=f"select_{key_suffix}", value=(pkey in selected), label_visibility="collapsed", ) @@ -607,13 +606,11 @@ def display_product_card_from_item( selected.pop(pkey, None) -def render_search_result_block(result: SearchResult) -> None: +def render_search_result_block(result: SearchResult, widget_prefix: str = "") -> None: """ Render a full search result block in place of a [SEARCH_REF:xxx] token. - Shows: - - A styled header with query + match counts + quality_summary (if any) - - A grid of product cards (Relevant first, then Partially Relevant; max 6) + widget_prefix: unique per (message, ref block) so Streamlit widget keys stay unique. """ summary_line = f'  · {result.quality_summary}' if result.quality_summary else '' header_html = ( @@ -640,45 +637,42 @@ def render_search_result_block(result: SearchResult) -> None: cols = st.columns(min(len(to_show), 3)) for i, product in enumerate(to_show): with cols[i % 3]: - display_product_card_from_item(product, result.ref_id, i) + display_product_card_from_item( + product, result.ref_id, i, widget_prefix=widget_prefix + ) def render_message_with_refs( content: str, session_id: str, fallback_refs: Optional[dict] = None, + msg_index: int = 0, ) -> None: """ Render an assistant message that may contain [SEARCH_REF:xxx] tokens. - Text segments are rendered as markdown. - [SEARCH_REF:xxx] tokens are replaced with full product card blocks - loaded from the global registry, or from fallback_refs (e.g. refs stored - with the message so they survive reruns / different workers). + msg_index: message index in chat, used to keep widget keys unique across messages. """ fallback_refs = fallback_refs or {} - # re.split with a capture group alternates: [text, ref_id, text, ref_id, ...] parts = SEARCH_REF_PATTERN.split(content) for i, segment in enumerate(parts): if i % 2 == 0: - # Text segment text = segment.strip() if text: st.markdown(text) else: - # ref_id segment ref_id = segment.strip() result = global_registry.get(session_id, ref_id) or fallback_refs.get(ref_id) if result: - render_search_result_block(result) + widget_prefix = f"m{msg_index}_r{i}" + render_search_result_block(result, widget_prefix=widget_prefix) else: - # ref not found (e.g. old session after restart) st.caption(f"[搜索结果 {ref_id} 不可用]") -def display_message(message: dict): - """Display a chat message""" +def display_message(message: dict, msg_index: int = 0): + """Display a chat message. msg_index keeps widget keys unique across messages.""" role = message["role"] content = message["content"] image_path = message.get("image_path") @@ -739,7 +733,7 @@ def display_message(message: dict): # Render message: expand [SEARCH_REF:xxx] tokens into product card blocks session_id = st.session_state.get("session_id", "") render_message_with_refs( - content, session_id, fallback_refs=message.get("search_refs") + content, session_id, fallback_refs=message.get("search_refs"), msg_index=msg_index ) st.markdown("", unsafe_allow_html=True) @@ -863,11 +857,11 @@ def render_side_drawer() -> None: st.markdown( f""" -
-
+
+
{html.escape(title)}
- ✕ 关闭 +
{body_html} @@ -877,6 +871,26 @@ def render_side_drawer() -> None: unsafe_allow_html=True, ) + st_components.html(""" + + """, height=0) + def display_welcome(): """Display welcome screen""" @@ -934,13 +948,12 @@ def display_welcome(): def main(): """Main Streamlit app""" initialize_session() - # Close overlay via query param (used by HTML close link in side drawer) + # Sync drawer close state from JS (set by JS via history.replaceState, no reload) if st.query_params.get("close_side_panel"): st.session_state.side_panel = {"visible": False, "mode": None, "payload": None} - del st.query_params["close_side_panel"] - st.rerun() + st.query_params.clear() - # Drawer overlay first so fixed positioning is relative to viewport (not a bottom block) + # Drawer rendered early so fixed positioning works from top of DOM render_side_drawer() # Header @@ -989,8 +1002,8 @@ def main(): if not st.session_state.messages: display_welcome() else: - for message in st.session_state.messages: - display_message(message) + for msg_idx, message in enumerate(st.session_state.messages): + display_message(message, msg_index=msg_idx) render_bottom_actions_bar() # Fixed input area at bottom (using container to simulate fixed position) -- libgit2 0.21.2