From 1a7debb320b887dd103c5647642d2db4253165f3 Mon Sep 17 00:00:00 2001 From: tangwang Date: Sat, 21 Feb 2026 20:04:29 +0800 Subject: [PATCH] 引用商品进行追问 --- README_prompts.md | 38 ++++++++++++++++++++++++++++++++++++++ app.py | 465 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- 2 files changed, 486 insertions(+), 17 deletions(-) diff --git a/README_prompts.md b/README_prompts.md index 3425af6..71abdc1 100644 --- a/README_prompts.md +++ b/README_prompts.md @@ -33,3 +33,41 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊 请深度思考如何让 最终 AI 消息 可以引用某次搜索的结果,而不是重新复述,并且废除extract_products_from_response这种方法。要规划一套健全的商品搜索结果的管理、和引用的方法。 + + + + + + + + + + +请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西: +1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。 +2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。 +下方也悬浮两个菜单,一个ask,一个compare。 +如果是点击了ask,那么,将引用这两个商品进行继续对话,如果点击了compare,那么,也是从右侧拉出一个页面,覆盖到上面,对这两个商品进行对比,页面内容为空,提示暂未实现即可) + + + +如果我要引用其中几款商品,进行对话,请给我一个后端(LLM、智能体交互方面)的方案 +前端,对话框里面,也要显示引用的商品,每个引用的商品右上角给一个删除号,下面正常聊天,点击发送后,后端要引用这几款商品进行对话。 +请深度思考、设计智能体如何支持这个 chat with的功能 + + + + + + + + + + + + +请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西: +1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。 +2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。 +下方也悬浮两个菜单,一个ask,一个compare。 +如果是点击了ask,那么,将引用这两个商品进行继续对话,如果点击了compare,那么,也是从右侧拉出一个页面,覆盖到上面,对这两个商品进行对比,页面内容为空,提示暂未实现即可) diff --git a/app.py b/app.py index 99bc344..89e5a13 100644 --- a/app.py +++ b/app.py @@ -3,11 +3,12 @@ ShopAgent - Streamlit UI Multi-modal fashion shopping assistant with conversational AI """ +import html import logging import re import uuid from pathlib import Path -from typing import Optional +from typing import Any, Optional import streamlit as st from PIL import Image, ImageOps @@ -222,6 +223,120 @@ st.markdown( .uploadedFile { display: none; } + + /* Product card wrapper: hover reveals action bar */ + .product-card-wrapper { + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e5e5e5; + background: #fff; + } + .product-card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + background: rgba(0,0,0,0.04); + border-top: 1px solid #eee; + opacity: 0.85; + transition: opacity 0.2s; + } + .product-card-wrapper:hover .product-card-actions { + opacity: 1; + background: rgba(0,0,0,0.06); + } + + /* Right side drawer (off-canvas) */ + .side-drawer-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.35); + z-index: 999998; + transition: opacity 0.25s; + } + .side-drawer-panel { + position: fixed; + top: 56px; + right: 0; + width: 85%; + max-width: 560px; + height: calc(100vh - 56px); + background: white; + box-shadow: -4px 0 20px rgba(0,0,0,0.12); + z-index: 999999; + overflow-y: auto; + transition: transform 0.25s ease-out; + } + .side-drawer-panel.open { + transform: translateX(0); + } + .side-drawer-header { + position: sticky; + top: 0; + background: white; + border-bottom: 1px solid #e5e5e5; + padding: 12px 16px; + display: flex; + align-items: center; + 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; + } + + /* Bottom floating ask/compare bar */ + .bottom-actions-bar { + position: fixed; + bottom: 70px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: white; + border: 1px solid #e5e5e5; + border-radius: 24px; + box-shadow: 0 2px 12px rgba(0,0,0,0.08); + z-index: 1005; + max-width: 90%; + } + .bottom-actions-bar .selected-count { + font-size: 0.85rem; + color: #666; + margin-right: 4px; + } """, unsafe_allow_html=True, @@ -252,6 +367,22 @@ def initialize_session(): if "show_debug" not in st.session_state: st.session_state.show_debug = True + # Selected products for ask/compare (key -> product info dict) + if "selected_products" not in st.session_state: + st.session_state.selected_products = {} + + # Right side panel: visible, mode in ("similar", "compare"), payload (e.g. ref_id, query, or list of selected items) + if "side_panel" not in st.session_state: + st.session_state.side_panel = { + "visible": False, + "mode": None, + "payload": None, + } + + # Products currently referenced in chat input (list of product summary dicts) + if "referenced_products" not in st.session_state: + st.session_state.referenced_products = [] + def save_uploaded_image(uploaded_file) -> Optional[str]: """Save uploaded image to temp directory""" @@ -275,6 +406,87 @@ def save_uploaded_image(uploaded_file) -> Optional[str]: return None +def _product_key(ref_id: str, index: int, product: ProductItem) -> str: + """Stable unique key for a product in the session (for selection and side panel).""" + return f"{ref_id}_{index}_{product.spu_id or index}" + + +def _product_to_info(product: ProductItem, ref_id: str) -> dict: + """Serialize product to a small dict for selected_products and ask/compare.""" + return { + "ref_id": ref_id, + "spu_id": product.spu_id, + "sku_id": product.spu_id, + "title": product.title or "未知商品", + "price": product.price, + "tags": product.tags or [], + "specifications": product.specifications or [], + } + + +def _compact_field(value: Any) -> str: + """Format a field into one readable line for chat reference payload.""" + if value is None: + return "-" + if isinstance(value, list): + if not value: + return "-" + parts = [] + for item in value: + if isinstance(item, dict): + text = ", ".join(f"{k}:{v}" for k, v in item.items()) + parts.append(text if text else str(item)) + else: + parts.append(str(item)) + return " | ".join(p for p in parts if p) or "-" + return str(value) + + +def _build_reference_prefix(products: list[dict]) -> str: + """Build backend prompt prefix for 'chat with referenced products'.""" + lines = [f"引用 {len(products)} 款商品:"] + for i, p in enumerate(products, 1): + sku_id = _compact_field(p.get("sku_id") or p.get("spu_id")) + title = _compact_field(p.get("title")) + price = _compact_field(p.get("price")) + tags = _compact_field(p.get("tags")) + specifications = _compact_field(p.get("specifications")) + lines.append( + f"{i}. sku_id={sku_id}; title={title}; price={price}; " + f"tags={tags}; specifications={specifications}" + ) + return "\n".join(lines) + + +def render_referenced_products_in_input() -> None: + """Render referenced products above chat input, each with remove button.""" + refs = st.session_state.get("referenced_products", []) + if not refs: + return + + st.markdown("**已引用商品**") + remove_idx = None + for idx, item in enumerate(refs): + with st.container(border=True): + c1, c2 = st.columns([12, 1]) + with c1: + title = (item.get("title") or "未知商品")[:80] + st.markdown(f"**{title}**") + st.caption( + f"sku_id={item.get('sku_id') or item.get('spu_id') or '-'}; " + f"price={_compact_field(item.get('price'))}; " + f"tags={_compact_field(item.get('tags'))}; " + f"specifications={_compact_field(item.get('specifications'))}" + ) + with c2: + if st.button("✕", key=f"remove_ref_{idx}", help="删除该引用"): + remove_idx = idx + if remove_idx is not None: + refs.pop(remove_idx) + st.session_state.referenced_products = refs + st.rerun() + + def _load_product_image(product: ProductItem) -> Optional[Image.Image]: """Try to load a product image: image_url from API (normalized when stored) → local data/images → None.""" if product.image_url: @@ -296,10 +508,39 @@ def _load_product_image(product: ProductItem) -> Optional[Image.Image]: return None -def display_product_card_from_item(product: ProductItem) -> None: - """Render a single product card from a ProductItem (registry entry).""" - img = _load_product_image(product) +def _run_similar_search(query: str) -> Optional[str]: + """Run product search with query, register result, return new ref_id or None.""" + if not query or not query.strip(): + return None + from app.tools.search_tools import make_search_products_tool + + session_id = st.session_state.get("session_id", "") + if not session_id: + return None + tool = make_search_products_tool(session_id, global_registry) + try: + out = tool.invoke({"query": query.strip(), "limit": 12}) + match = SEARCH_REF_PATTERN.search(out) + if match: + return match.group(1).strip() + except Exception as e: + logger.warning(f"Similar search failed: {e}") + return None + +def display_product_card_from_item( + product: ProductItem, + ref_id: str, + index: int, +) -> None: + """Render a single product card with hover actions: Similar products + checkbox.""" + pkey = _product_key(ref_id, index, product) + info = _product_to_info(product, ref_id) + selected = st.session_state.selected_products + + st.markdown('
', unsafe_allow_html=True) + + img = _load_product_image(product) if img: target = (220, 220) try: @@ -324,6 +565,47 @@ def display_product_card_from_item(product: ProductItem) -> None: label_style = "⭐" if product.match_label == "Relevant" else "✦" st.caption(f"{label_style} {product.match_label}") + st.markdown('
', unsafe_allow_html=True) + col_a, col_b = st.columns([1, 1]) + with col_a: + similar_clicked = st.button( + "Similar products", + key=f"similar_{pkey}", + help="Search by this product title and show in side panel", + ) + with col_b: + is_checked = st.checkbox( + "Select", + key=f"select_{pkey}", + value=(pkey in selected), + label_visibility="collapsed", + ) + st.markdown("
", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + if similar_clicked: + search_query = (product.title or "").strip() or "商品" + new_ref = _run_similar_search(search_query) + if new_ref: + st.session_state.side_panel = { + "visible": True, + "mode": "similar", + "payload": {"ref_id": new_ref, "query": search_query}, + } + else: + st.session_state.side_panel = { + "visible": True, + "mode": "similar", + "payload": {"ref_id": None, "query": search_query, "error": True}, + } + st.rerun() + + if is_checked: + if pkey not in selected: + selected[pkey] = info + else: + selected.pop(pkey, None) + def render_search_result_block(result: SearchResult) -> None: """ @@ -358,7 +640,7 @@ 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) + display_product_card_from_item(product, result.ref_id, i) def render_message_with_refs( @@ -463,11 +745,142 @@ def display_message(message: dict): st.markdown("", unsafe_allow_html=True) +def render_bottom_actions_bar() -> None: + """Show Ask and Compare when there are selected products. Disabled when none selected.""" + selected = st.session_state.selected_products + n = len(selected) + if n == 0: + return + st.markdown( + '
', + unsafe_allow_html=True, + ) + col_sel, col_ask, col_cmp = st.columns([2, 1, 1]) + with col_sel: + st.caption(f"Selected: {n}") + with col_ask: + ask_clicked = st.button("Ask", key="bottom_ask", help="Continue conversation with selected products") + with col_cmp: + compare_clicked = st.button("Compare", key="bottom_compare", help="Compare selected products") + st.markdown("
", unsafe_allow_html=True) + + if ask_clicked: + st.session_state.referenced_products = list(selected.values()) + st.rerun() + if compare_clicked: + st.session_state.side_panel = { + "visible": True, + "mode": "compare", + "payload": list(selected.values()), + } + st.rerun() + + +def render_side_drawer() -> None: + """Render a fixed overlay side drawer that does not change background layout.""" + panel = st.session_state.side_panel + if not panel.get("visible") or not panel.get("mode"): + return + + mode = panel["mode"] + payload = panel.get("payload") or {} + session_id = st.session_state.get("session_id", "") + + title = "Similar products" if mode == "similar" else "Compare" + body_html = "" + + if mode == "similar": + ref_id = payload.get("ref_id") + query = html.escape(payload.get("query", "")) + if payload.get("error") or not ref_id: + body_html = '

搜索失败或暂无结果。

' + else: + result = global_registry.get(session_id, ref_id) + if not result: + body_html = f'

[搜索结果 {html.escape(ref_id)} 不可用]

' + else: + perfect = [p for p in result.products if p.match_label == "Relevant"] + partial = [p for p in result.products if p.match_label == "Partially Relevant"] + to_show = (perfect + partial)[:12] if perfect else partial[:12] + cards = [] + for product in to_show: + p_title = html.escape((product.title or "未知商品")[:80]) + p_label = html.escape(product.match_label or "Partially Relevant") + price = ( + f"¥{product.price:.2f}" + if product.price is not None + else "价格待更新" + ) + image_html = ( + f'{p_title}' + if product.image_url + else '
🛍️
' + ) + cards.append( + '
' + f"{image_html}" + '
' + f'
{p_title}
' + f'
{price}
' + f'
{p_label}
' + "
" + ) + + cards_html = "".join(cards) if cards else '

(未找到可展示的商品)

' + body_html = ( + f'
' + f'基于「{query}」的搜索结果:
' + '
' + f"{cards_html}" + "
" + ) + else: + items = payload if isinstance(payload, list) else [] + if items: + rows = [] + for item in items: + t = html.escape((item.get("title") or "未知商品")[:80]) + p = item.get("price") + ptext = f"¥{p:.2f}" if p is not None else "价格待更新" + rows.append( + '
' + f'
{t}
' + f'
{ptext}
' + "
" + ) + items_html = "".join(rows) + else: + items_html = '

当前未选中商品。

' + body_html = ( + '
已选商品:
' + f'
{items_html}
' + '
对比功能暂未实现。
' + ) + + st.markdown( + f""" +
+
+
+
{html.escape(title)}
+ ✕ 关闭 +
+
+ {body_html} +
+
+ """, + unsafe_allow_html=True, + ) + + def display_welcome(): """Display welcome screen""" - col1, col2, col3, col4 = st.columns(4) - with col1: st.markdown( """ @@ -518,10 +931,17 @@ def display_welcome(): st.markdown("

", unsafe_allow_html=True) - def main(): """Main Streamlit app""" initialize_session() + # Close overlay via query param (used by HTML close link in side drawer) + 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() + + # Drawer overlay first so fixed positioning is relative to viewport (not a bottom block) + render_side_drawer() # Header st.markdown( @@ -547,6 +967,9 @@ def main(): global_registry.clear_session(session_id) st.session_state.messages = [] st.session_state.uploaded_image = None + st.session_state.selected_products = {} + st.session_state.referenced_products = [] + st.session_state.side_panel = {"visible": False, "mode": None, "payload": None} st.rerun() # Debug toggle @@ -561,15 +984,14 @@ def main(): st.markdown("---") st.caption(f"Session: `{st.session_state.session_id[:8]}...`") - # Chat messages container messages_container = st.container() - with messages_container: if not st.session_state.messages: display_welcome() else: for message in st.session_state.messages: display_message(message) + render_bottom_actions_bar() # Fixed input area at bottom (using container to simulate fixed position) st.markdown('
', unsafe_allow_html=True) @@ -598,6 +1020,9 @@ def main(): st.session_state.show_image_upload = False st.rerun() + # Referenced products area (shown above chat input, each can be removed) + render_referenced_products_in_input() + # Input row col1, col2 = st.columns([1, 12]) @@ -620,6 +1045,12 @@ def main(): # Process user input if user_query: + raw_user_query = user_query + referenced_products = list(st.session_state.get("referenced_products", [])) + agent_query = raw_user_query + if referenced_products: + agent_query = f"{_build_reference_prefix(referenced_products)}\n\n{raw_user_query}" + # Ensure shopping agent is initialized if "shopping_agent" not in st.session_state: st.error("Session not initialized. Please refresh the page.") @@ -632,9 +1063,8 @@ def main(): image_path = save_uploaded_image(st.session_state.uploaded_image) else: # Check if query refers to a previous image - query_lower = user_query.lower() if any( - ref in query_lower + ref in raw_user_query.lower() for ref in [ "this", "that", @@ -655,11 +1085,14 @@ def main(): st.session_state.messages.append( { "role": "user", - "content": user_query, + "content": raw_user_query, "image_path": image_path, } ) + # References are consumed once this message is sent + st.session_state.referenced_products = [] + # Display user message immediately with messages_container: display_message(st.session_state.messages[-1]) @@ -667,12 +1100,10 @@ def main(): # Process with shopping agent try: shopping_agent = st.session_state.shopping_agent - - # Handle greetings without invoking the agent - query_lower = user_query.lower().strip() + # Process with agent result = shopping_agent.chat( - query=user_query, + query=agent_query, image_path=image_path, ) response = result["response"] -- libgit2 0.21.2