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('
搜索失败或暂无结果。
' + 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'(未找到可展示的商品)
' + body_html = ( + f'当前未选中商品。
' + body_html = ( + '