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 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' &nbsp;·&nbsp;{result.quality_summary}' if result.quality_summary else ''
619 616 header_html = (
... ... @@ -640,45 +637,42 @@ def render_search_result_block(result: SearchResult) -&gt; 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() -&gt; 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() -&gt; 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)
... ...