Commit 7e985858975f0f42be04789a5ecd922c5d91c477
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 | 11 | from typing import Any, Optional |
| 12 | 12 | |
| 13 | 13 | import streamlit as st |
| 14 | +import streamlit.components.v1 as st_components | |
| 14 | 15 | from PIL import Image, ImageOps |
| 15 | 16 | |
| 16 | 17 | from app.agents.shopping_agent import ShoppingAgent |
| ... | ... | @@ -286,34 +287,30 @@ st.markdown( |
| 286 | 287 | justify-content: space-between; |
| 287 | 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 | 290 | .side-drawer-content { |
| 310 | 291 | padding: 14px 16px 20px 16px; |
| 311 | 292 | } |
| 312 | - /* Ensure drawer overlay is on top and not clipped by Streamlit blocks */ | |
| 313 | 293 | .side-drawer-backdrop, |
| 314 | 294 | .side-drawer-panel { |
| 315 | 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 | 315 | /* Bottom floating ask/compare bar */ |
| 319 | 316 | .bottom-actions-bar { |
| ... | ... | @@ -532,9 +529,11 @@ def display_product_card_from_item( |
| 532 | 529 | product: ProductItem, |
| 533 | 530 | ref_id: str, |
| 534 | 531 | index: int, |
| 532 | + widget_prefix: str = "", | |
| 535 | 533 | ) -> None: |
| 536 | 534 | """Render a single product card with hover actions: Similar products + checkbox.""" |
| 537 | 535 | pkey = _product_key(ref_id, index, product) |
| 536 | + key_suffix = f"{widget_prefix}_{pkey}" if widget_prefix else pkey | |
| 538 | 537 | info = _product_to_info(product, ref_id) |
| 539 | 538 | selected = st.session_state.selected_products |
| 540 | 539 | |
| ... | ... | @@ -570,13 +569,13 @@ def display_product_card_from_item( |
| 570 | 569 | with col_a: |
| 571 | 570 | similar_clicked = st.button( |
| 572 | 571 | "Similar products", |
| 573 | - key=f"similar_{pkey}", | |
| 572 | + key=f"similar_{key_suffix}", | |
| 574 | 573 | help="Search by this product title and show in side panel", |
| 575 | 574 | ) |
| 576 | 575 | with col_b: |
| 577 | 576 | is_checked = st.checkbox( |
| 578 | 577 | "Select", |
| 579 | - key=f"select_{pkey}", | |
| 578 | + key=f"select_{key_suffix}", | |
| 580 | 579 | value=(pkey in selected), |
| 581 | 580 | label_visibility="collapsed", |
| 582 | 581 | ) |
| ... | ... | @@ -607,13 +606,11 @@ def display_product_card_from_item( |
| 607 | 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 | 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 | 615 | summary_line = f' · {result.quality_summary}' if result.quality_summary else '' |
| 619 | 616 | header_html = ( |
| ... | ... | @@ -640,45 +637,42 @@ def render_search_result_block(result: SearchResult) -> None: |
| 640 | 637 | cols = st.columns(min(len(to_show), 3)) |
| 641 | 638 | for i, product in enumerate(to_show): |
| 642 | 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 | 645 | def render_message_with_refs( |
| 647 | 646 | content: str, |
| 648 | 647 | session_id: str, |
| 649 | 648 | fallback_refs: Optional[dict] = None, |
| 649 | + msg_index: int = 0, | |
| 650 | 650 | ) -> None: |
| 651 | 651 | """ |
| 652 | 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 | 656 | fallback_refs = fallback_refs or {} |
| 660 | - # re.split with a capture group alternates: [text, ref_id, text, ref_id, ...] | |
| 661 | 657 | parts = SEARCH_REF_PATTERN.split(content) |
| 662 | 658 | |
| 663 | 659 | for i, segment in enumerate(parts): |
| 664 | 660 | if i % 2 == 0: |
| 665 | - # Text segment | |
| 666 | 661 | text = segment.strip() |
| 667 | 662 | if text: |
| 668 | 663 | st.markdown(text) |
| 669 | 664 | else: |
| 670 | - # ref_id segment | |
| 671 | 665 | ref_id = segment.strip() |
| 672 | 666 | result = global_registry.get(session_id, ref_id) or fallback_refs.get(ref_id) |
| 673 | 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 | 670 | else: |
| 676 | - # ref not found (e.g. old session after restart) | |
| 677 | 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 | 676 | role = message["role"] |
| 683 | 677 | content = message["content"] |
| 684 | 678 | image_path = message.get("image_path") |
| ... | ... | @@ -739,7 +733,7 @@ def display_message(message: dict): |
| 739 | 733 | # Render message: expand [SEARCH_REF:xxx] tokens into product card blocks |
| 740 | 734 | session_id = st.session_state.get("session_id", "") |
| 741 | 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 | 739 | st.markdown("</div>", unsafe_allow_html=True) |
| ... | ... | @@ -863,11 +857,11 @@ def render_side_drawer() -> None: |
| 863 | 857 | |
| 864 | 858 | st.markdown( |
| 865 | 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 | 862 | <div class="side-drawer-header"> |
| 869 | 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 | 865 | </div> |
| 872 | 866 | <div class="side-drawer-content"> |
| 873 | 867 | {body_html} |
| ... | ... | @@ -877,6 +871,26 @@ def render_side_drawer() -> None: |
| 877 | 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 | 895 | def display_welcome(): |
| 882 | 896 | """Display welcome screen""" |
| ... | ... | @@ -934,13 +948,12 @@ def display_welcome(): |
| 934 | 948 | def main(): |
| 935 | 949 | """Main Streamlit app""" |
| 936 | 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 | 952 | if st.query_params.get("close_side_panel"): |
| 939 | 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 | 957 | render_side_drawer() |
| 945 | 958 | |
| 946 | 959 | # Header |
| ... | ... | @@ -989,8 +1002,8 @@ def main(): |
| 989 | 1002 | if not st.session_state.messages: |
| 990 | 1003 | display_welcome() |
| 991 | 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 | 1007 | render_bottom_actions_bar() |
| 995 | 1008 | |
| 996 | 1009 | # Fixed input area at bottom (using container to simulate fixed position) | ... | ... |