Commit 7e985858975f0f42be04789a5ecd922c5d91c477

Authored by tangwang
1 parent 1a7debb3

feat(ui): product card hover actions, side drawer overlay, chat-with-products, duplicate key fix

=== 修改概要 ===

1. 商品卡片 hover 操作:Similar products + 勾选
2. 底部悬浮 Ask / Compare 操作条
3. 右侧抽屉(Modal 式覆盖层):Similar 搜索结果 / Compare 占位
4. 引用商品对话:输入区展示已引用商品(可删)、发送时注入后端格式前缀
5. 抽屉关闭:HTML 按钮 + JS 即时隐藏 + replaceState,不刷背景页
6. 多消息重复 [SEARCH_REF] 导致控件 key 冲突:按消息+块维度加唯一前缀

--- 1. 会话状态 (initialize_session) ---

- selected_products: dict, key -> 商品摘要(ref_id, spu_id, sku_id, title, price, tags, specifications),用于勾选与 Ask/Compare
- side_panel: { visible, mode, payload },mode 为 "similar" | "compare",payload 为 ref_id/query 或已选商品列表
- referenced_products: list[dict],当前输入区「已引用商品」,点击 Ask 后填入,发送后清空

--- 2. 商品卡片 (display_product_card_from_item) ---

- 新增参数 widget_prefix(默认 ""),与 _product_key 组成 key_suffix,用于 st.button / st.checkbox 的 key,避免跨消息重复
- 卡片底部操作条(product-card-actions):Similar products 按钮、Select 勾选框
- Similar 点击:_run_similar_search(product.title) 调用 make_search_products_tool,解析返回的 [SEARCH_REF:ref_id],打开 side_panel mode=similar
- 勾选:更新 selected_products[pkey],pkey = _product_key(ref_id, index, product)

--- 3. 底部操作条 (render_bottom_actions_bar) ---

- 仅当 len(selected_products) > 0 时展示
- Ask:将 selected 写入 referenced_products,rerun
- Compare:打开 side_panel mode=compare,payload=已选列表,rerun

--- 4. 右侧抽屉 (render_side_drawer) ---

- 在 main() 中优先渲染(紧跟 initialize_session 与 query_params 处理),保证 fixed 定位相对视口
- 结构:backdrop (fixed 全屏) + panel (fixed 右侧,top:56px 避顶栏),z-index 999998/999999
- similar 模式:用 payload.ref_id 从 global_registry 取 SearchResult,渲染最多 12 条商品卡片(纯 HTML,无 Streamlit 控件)
- compare 模式:列出已选商品标题/价格 +「对比功能暂未实现」提示
- 关闭:panel 内 <button id="side-drawer-close-btn">✕ 关闭</button>;通过 streamlit.components.v1.html 注入 JS:
  - 点击按钮或 backdrop 时,将 backdrop/panel 设为 display:none(即时隐藏,不刷新)
  - history.replaceState 设置 ?close_side_panel=1(同页,不导航)
- main() 顶部若检测到 close_side_panel,清除 side_panel 状态并 st.query_params.clear(),下次自然 rerun 时不再渲染抽屉

--- 5. 引用商品对话 ---

- _product_to_info:增加 sku_id(同 spu_id)、tags、specifications,供后端前缀使用
- _build_reference_prefix(products):生成「引用 x 款商品:」+ 每款一行「sku_id=...; title=...; price=...; tags=...; specifications=...」
- render_referenced_products_in_input:在输入框上方展示 referenced_products,每项可点 ✕ 移除
- 发送逻辑:若有 referenced_products,agent_query = _build_reference_prefix(referenced_products) + "\n\n" + raw_user_query;消息列表存 raw_user_query;发送后 referenced_products = []

--- 6. 多消息重复 key 修复 (StreamlitDuplicateElementKey) ---

- 同一条 [SEARCH_REF:xxx] 在不同消息中会重复渲染,导致 similar_/select_ 的 key(仅 ref_id+index+spu_id)重复
- 方案:为每个「消息+ref 块」引入唯一 widget_prefix
  - display_message(message, msg_index=0);main 中 for msg_idx, message in enumerate(messages): display_message(message, msg_index=msg_idx)
  - render_message_with_refs(..., msg_index=0);对每个 ref 段 widget_prefix = f"m{msg_index}_r{i}"(i 为 parts 下标)
  - render_search_result_block(result, widget_prefix="");将 widget_prefix 传入 display_product_card_from_item
  - display_product_card_from_item(..., widget_prefix="");key_suffix = f"{widget_prefix}_{pkey}" if widget_prefix else pkey;按钮/勾选框 key 使用 key_suffix

--- 7. 其他 ---

- Clear Chat 时一并清空 selected_products、referenced_products、side_panel
- CSS:product-card-wrapper/actions、side-drawer-*、bottom-actions-bar、#side-drawer-close-btn 等
- 依赖:streamlit.components.v1 用于注入抽屉关闭脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
Showing 1 changed file with 62 additions and 49 deletions   Show diff stats
@@ -11,6 +11,7 @@ from pathlib import Path @@ -11,6 +11,7 @@ from pathlib import Path
11 from typing import Any, Optional 11 from typing import Any, Optional
12 12
13 import streamlit as st 13 import streamlit as st
  14 +import streamlit.components.v1 as st_components
14 from PIL import Image, ImageOps 15 from PIL import Image, ImageOps
15 16
16 from app.agents.shopping_agent import ShoppingAgent 17 from app.agents.shopping_agent import ShoppingAgent
@@ -286,34 +287,30 @@ st.markdown( @@ -286,34 +287,30 @@ st.markdown(
286 justify-content: space-between; 287 justify-content: space-between;
287 z-index: 1; 288 z-index: 1;
288 } 289 }
289 - .side-drawer-close-link {  
290 - display: inline-flex;  
291 - align-items: center;  
292 - justify-content: center;  
293 - text-decoration: none;  
294 - color: #333 !important;  
295 - font-size: 14px;  
296 - font-weight: 500;  
297 - padding: 8px 14px;  
298 - border-radius: 8px;  
299 - border: 1px solid #ccc;  
300 - background: #f0f0f0 !important;  
301 - min-width: 60px;  
302 - box-shadow: 0 1px 2px rgba(0,0,0,0.06);  
303 - }  
304 - .side-drawer-close-link:hover {  
305 - color: #111 !important;  
306 - background: #e5e5e5 !important;  
307 - border-color: #999;  
308 - }  
309 .side-drawer-content { 290 .side-drawer-content {
310 padding: 14px 16px 20px 16px; 291 padding: 14px 16px 20px 16px;
311 } 292 }
312 - /* Ensure drawer overlay is on top and not clipped by Streamlit blocks */  
313 .side-drawer-backdrop, 293 .side-drawer-backdrop,
314 .side-drawer-panel { 294 .side-drawer-panel {
315 position: fixed !important; 295 position: fixed !important;
316 } 296 }
  297 + #side-drawer-close-btn {
  298 + display: inline-flex;
  299 + align-items: center;
  300 + gap: 4px;
  301 + border: 1px solid #ccc;
  302 + background: #f5f5f5;
  303 + color: #333;
  304 + font-size: 14px;
  305 + font-weight: 500;
  306 + padding: 6px 14px;
  307 + border-radius: 8px;
  308 + cursor: pointer;
  309 + }
  310 + #side-drawer-close-btn:hover {
  311 + background: #e8e8e8;
  312 + color: #111;
  313 + }
317 314
318 /* Bottom floating ask/compare bar */ 315 /* Bottom floating ask/compare bar */
319 .bottom-actions-bar { 316 .bottom-actions-bar {
@@ -532,9 +529,11 @@ def display_product_card_from_item( @@ -532,9 +529,11 @@ def display_product_card_from_item(
532 product: ProductItem, 529 product: ProductItem,
533 ref_id: str, 530 ref_id: str,
534 index: int, 531 index: int,
  532 + widget_prefix: str = "",
535 ) -> None: 533 ) -> None:
536 """Render a single product card with hover actions: Similar products + checkbox.""" 534 """Render a single product card with hover actions: Similar products + checkbox."""
537 pkey = _product_key(ref_id, index, product) 535 pkey = _product_key(ref_id, index, product)
  536 + key_suffix = f"{widget_prefix}_{pkey}" if widget_prefix else pkey
538 info = _product_to_info(product, ref_id) 537 info = _product_to_info(product, ref_id)
539 selected = st.session_state.selected_products 538 selected = st.session_state.selected_products
540 539
@@ -570,13 +569,13 @@ def display_product_card_from_item( @@ -570,13 +569,13 @@ def display_product_card_from_item(
570 with col_a: 569 with col_a:
571 similar_clicked = st.button( 570 similar_clicked = st.button(
572 "Similar products", 571 "Similar products",
573 - key=f"similar_{pkey}", 572 + key=f"similar_{key_suffix}",
574 help="Search by this product title and show in side panel", 573 help="Search by this product title and show in side panel",
575 ) 574 )
576 with col_b: 575 with col_b:
577 is_checked = st.checkbox( 576 is_checked = st.checkbox(
578 "Select", 577 "Select",
579 - key=f"select_{pkey}", 578 + key=f"select_{key_suffix}",
580 value=(pkey in selected), 579 value=(pkey in selected),
581 label_visibility="collapsed", 580 label_visibility="collapsed",
582 ) 581 )
@@ -607,13 +606,11 @@ def display_product_card_from_item( @@ -607,13 +606,11 @@ def display_product_card_from_item(
607 selected.pop(pkey, None) 606 selected.pop(pkey, None)
608 607
609 608
610 -def render_search_result_block(result: SearchResult) -> None: 609 +def render_search_result_block(result: SearchResult, widget_prefix: str = "") -> None:
611 """ 610 """
612 Render a full search result block in place of a [SEARCH_REF:xxx] token. 611 Render a full search result block in place of a [SEARCH_REF:xxx] token.
613 612
614 - Shows:  
615 - - A styled header with query + match counts + quality_summary (if any)  
616 - - A grid of product cards (Relevant first, then Partially Relevant; max 6) 613 + widget_prefix: unique per (message, ref block) so Streamlit widget keys stay unique.
617 """ 614 """
618 summary_line = f' &nbsp;·&nbsp;{result.quality_summary}' if result.quality_summary else '' 615 summary_line = f' &nbsp;·&nbsp;{result.quality_summary}' if result.quality_summary else ''
619 header_html = ( 616 header_html = (
@@ -640,45 +637,42 @@ def render_search_result_block(result: SearchResult) -&gt; None: @@ -640,45 +637,42 @@ def render_search_result_block(result: SearchResult) -&gt; None:
640 cols = st.columns(min(len(to_show), 3)) 637 cols = st.columns(min(len(to_show), 3))
641 for i, product in enumerate(to_show): 638 for i, product in enumerate(to_show):
642 with cols[i % 3]: 639 with cols[i % 3]:
643 - display_product_card_from_item(product, result.ref_id, i) 640 + display_product_card_from_item(
  641 + product, result.ref_id, i, widget_prefix=widget_prefix
  642 + )
644 643
645 644
646 def render_message_with_refs( 645 def render_message_with_refs(
647 content: str, 646 content: str,
648 session_id: str, 647 session_id: str,
649 fallback_refs: Optional[dict] = None, 648 fallback_refs: Optional[dict] = None,
  649 + msg_index: int = 0,
650 ) -> None: 650 ) -> None:
651 """ 651 """
652 Render an assistant message that may contain [SEARCH_REF:xxx] tokens. 652 Render an assistant message that may contain [SEARCH_REF:xxx] tokens.
653 653
654 - Text segments are rendered as markdown.  
655 - [SEARCH_REF:xxx] tokens are replaced with full product card blocks  
656 - loaded from the global registry, or from fallback_refs (e.g. refs stored  
657 - with the message so they survive reruns / different workers). 654 + msg_index: message index in chat, used to keep widget keys unique across messages.
658 """ 655 """
659 fallback_refs = fallback_refs or {} 656 fallback_refs = fallback_refs or {}
660 - # re.split with a capture group alternates: [text, ref_id, text, ref_id, ...]  
661 parts = SEARCH_REF_PATTERN.split(content) 657 parts = SEARCH_REF_PATTERN.split(content)
662 658
663 for i, segment in enumerate(parts): 659 for i, segment in enumerate(parts):
664 if i % 2 == 0: 660 if i % 2 == 0:
665 - # Text segment  
666 text = segment.strip() 661 text = segment.strip()
667 if text: 662 if text:
668 st.markdown(text) 663 st.markdown(text)
669 else: 664 else:
670 - # ref_id segment  
671 ref_id = segment.strip() 665 ref_id = segment.strip()
672 result = global_registry.get(session_id, ref_id) or fallback_refs.get(ref_id) 666 result = global_registry.get(session_id, ref_id) or fallback_refs.get(ref_id)
673 if result: 667 if result:
674 - render_search_result_block(result) 668 + widget_prefix = f"m{msg_index}_r{i}"
  669 + render_search_result_block(result, widget_prefix=widget_prefix)
675 else: 670 else:
676 - # ref not found (e.g. old session after restart)  
677 st.caption(f"[搜索结果 {ref_id} 不可用]") 671 st.caption(f"[搜索结果 {ref_id} 不可用]")
678 672
679 673
680 -def display_message(message: dict):  
681 - """Display a chat message""" 674 +def display_message(message: dict, msg_index: int = 0):
  675 + """Display a chat message. msg_index keeps widget keys unique across messages."""
682 role = message["role"] 676 role = message["role"]
683 content = message["content"] 677 content = message["content"]
684 image_path = message.get("image_path") 678 image_path = message.get("image_path")
@@ -739,7 +733,7 @@ def display_message(message: dict): @@ -739,7 +733,7 @@ def display_message(message: dict):
739 # Render message: expand [SEARCH_REF:xxx] tokens into product card blocks 733 # Render message: expand [SEARCH_REF:xxx] tokens into product card blocks
740 session_id = st.session_state.get("session_id", "") 734 session_id = st.session_state.get("session_id", "")
741 render_message_with_refs( 735 render_message_with_refs(
742 - content, session_id, fallback_refs=message.get("search_refs") 736 + content, session_id, fallback_refs=message.get("search_refs"), msg_index=msg_index
743 ) 737 )
744 738
745 st.markdown("</div>", unsafe_allow_html=True) 739 st.markdown("</div>", unsafe_allow_html=True)
@@ -863,11 +857,11 @@ def render_side_drawer() -&gt; None: @@ -863,11 +857,11 @@ def render_side_drawer() -&gt; None:
863 857
864 st.markdown( 858 st.markdown(
865 f""" 859 f"""
866 - <div class="side-drawer-backdrop"></div>  
867 - <div class="side-drawer-panel open"> 860 + <div class="side-drawer-backdrop" id="side-drawer-backdrop"></div>
  861 + <div class="side-drawer-panel open" id="side-drawer-panel">
868 <div class="side-drawer-header"> 862 <div class="side-drawer-header">
869 <div style="font-weight:600;">{html.escape(title)}</div> 863 <div style="font-weight:600;">{html.escape(title)}</div>
870 - <a class="side-drawer-close-link" href="./?close_side_panel=1" target="_self">✕ 关闭</a> 864 + <button id="side-drawer-close-btn">✕ 关闭</button>
871 </div> 865 </div>
872 <div class="side-drawer-content"> 866 <div class="side-drawer-content">
873 {body_html} 867 {body_html}
@@ -877,6 +871,26 @@ def render_side_drawer() -&gt; None: @@ -877,6 +871,26 @@ def render_side_drawer() -&gt; None:
877 unsafe_allow_html=True, 871 unsafe_allow_html=True,
878 ) 872 )
879 873
  874 + st_components.html("""
  875 + <script>
  876 + (function() {
  877 + var pd = window.parent.document;
  878 + var btn = pd.getElementById('side-drawer-close-btn');
  879 + var backdrop = pd.getElementById('side-drawer-backdrop');
  880 + var panel = pd.getElementById('side-drawer-panel');
  881 + function closeDrawer() {
  882 + if (backdrop) backdrop.style.display = 'none';
  883 + if (panel) panel.style.display = 'none';
  884 + var url = new URL(window.parent.location);
  885 + url.searchParams.set('close_side_panel', '1');
  886 + window.parent.history.replaceState({}, '', url);
  887 + }
  888 + if (btn) btn.onclick = closeDrawer;
  889 + if (backdrop) backdrop.onclick = closeDrawer;
  890 + })();
  891 + </script>
  892 + """, height=0)
  893 +
880 894
881 def display_welcome(): 895 def display_welcome():
882 """Display welcome screen""" 896 """Display welcome screen"""
@@ -934,13 +948,12 @@ def display_welcome(): @@ -934,13 +948,12 @@ def display_welcome():
934 def main(): 948 def main():
935 """Main Streamlit app""" 949 """Main Streamlit app"""
936 initialize_session() 950 initialize_session()
937 - # Close overlay via query param (used by HTML close link in side drawer) 951 + # Sync drawer close state from JS (set by JS via history.replaceState, no reload)
938 if st.query_params.get("close_side_panel"): 952 if st.query_params.get("close_side_panel"):
939 st.session_state.side_panel = {"visible": False, "mode": None, "payload": None} 953 st.session_state.side_panel = {"visible": False, "mode": None, "payload": None}
940 - del st.query_params["close_side_panel"]  
941 - st.rerun() 954 + st.query_params.clear()
942 955
943 - # Drawer overlay first so fixed positioning is relative to viewport (not a bottom block) 956 + # Drawer rendered early so fixed positioning works from top of DOM
944 render_side_drawer() 957 render_side_drawer()
945 958
946 # Header 959 # Header
@@ -989,8 +1002,8 @@ def main(): @@ -989,8 +1002,8 @@ def main():
989 if not st.session_state.messages: 1002 if not st.session_state.messages:
990 display_welcome() 1003 display_welcome()
991 else: 1004 else:
992 - for message in st.session_state.messages:  
993 - display_message(message) 1005 + for msg_idx, message in enumerate(st.session_state.messages):
  1006 + display_message(message, msg_index=msg_idx)
994 render_bottom_actions_bar() 1007 render_bottom_actions_bar()
995 1008
996 # Fixed input area at bottom (using container to simulate fixed position) 1009 # Fixed input area at bottom (using container to simulate fixed position)