Commit 1a7debb320b887dd103c5647642d2db4253165f3
1 parent
621b6925
引用商品进行追问
Showing
2 changed files
with
486 additions
and
17 deletions
Show diff stats
README_prompts.md
| @@ -33,3 +33,41 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊 | @@ -33,3 +33,41 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊 | ||
| 33 | 33 | ||
| 34 | 请深度思考如何让 最终 AI 消息 可以引用某次搜索的结果,而不是重新复述,并且废除extract_products_from_response这种方法。要规划一套健全的商品搜索结果的管理、和引用的方法。 | 34 | 请深度思考如何让 最终 AI 消息 可以引用某次搜索的结果,而不是重新复述,并且废除extract_products_from_response这种方法。要规划一套健全的商品搜索结果的管理、和引用的方法。 |
| 35 | 35 | ||
| 36 | + | ||
| 37 | + | ||
| 38 | + | ||
| 39 | + | ||
| 40 | + | ||
| 41 | + | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + | ||
| 45 | + | ||
| 46 | +请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西: | ||
| 47 | +1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。 | ||
| 48 | +2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。 | ||
| 49 | +下方也悬浮两个菜单,一个ask,一个compare。 | ||
| 50 | +如果是点击了ask,那么,将引用这两个商品进行继续对话,如果点击了compare,那么,也是从右侧拉出一个页面,覆盖到上面,对这两个商品进行对比,页面内容为空,提示暂未实现即可) | ||
| 51 | + | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +如果我要引用其中几款商品,进行对话,请给我一个后端(LLM、智能体交互方面)的方案 | ||
| 55 | +前端,对话框里面,也要显示引用的商品,每个引用的商品右上角给一个删除号,下面正常聊天,点击发送后,后端要引用这几款商品进行对话。 | ||
| 56 | +请深度思考、设计智能体如何支持这个 chat with的功能 | ||
| 57 | + | ||
| 58 | + | ||
| 59 | + | ||
| 60 | + | ||
| 61 | + | ||
| 62 | + | ||
| 63 | + | ||
| 64 | + | ||
| 65 | + | ||
| 66 | + | ||
| 67 | + | ||
| 68 | + | ||
| 69 | +请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西: | ||
| 70 | +1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。 | ||
| 71 | +2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。 | ||
| 72 | +下方也悬浮两个菜单,一个ask,一个compare。 | ||
| 73 | +如果是点击了ask,那么,将引用这两个商品进行继续对话,如果点击了compare,那么,也是从右侧拉出一个页面,覆盖到上面,对这两个商品进行对比,页面内容为空,提示暂未实现即可) |
| @@ -3,11 +3,12 @@ ShopAgent - Streamlit UI | @@ -3,11 +3,12 @@ ShopAgent - Streamlit UI | ||
| 3 | Multi-modal fashion shopping assistant with conversational AI | 3 | Multi-modal fashion shopping assistant with conversational AI |
| 4 | """ | 4 | """ |
| 5 | 5 | ||
| 6 | +import html | ||
| 6 | import logging | 7 | import logging |
| 7 | import re | 8 | import re |
| 8 | import uuid | 9 | import uuid |
| 9 | from pathlib import Path | 10 | from pathlib import Path |
| 10 | -from typing import Optional | 11 | +from typing import Any, Optional |
| 11 | 12 | ||
| 12 | import streamlit as st | 13 | import streamlit as st |
| 13 | from PIL import Image, ImageOps | 14 | from PIL import Image, ImageOps |
| @@ -222,6 +223,120 @@ st.markdown( | @@ -222,6 +223,120 @@ st.markdown( | ||
| 222 | .uploadedFile { | 223 | .uploadedFile { |
| 223 | display: none; | 224 | display: none; |
| 224 | } | 225 | } |
| 226 | + | ||
| 227 | + /* Product card wrapper: hover reveals action bar */ | ||
| 228 | + .product-card-wrapper { | ||
| 229 | + position: relative; | ||
| 230 | + border-radius: 8px; | ||
| 231 | + overflow: hidden; | ||
| 232 | + border: 1px solid #e5e5e5; | ||
| 233 | + background: #fff; | ||
| 234 | + } | ||
| 235 | + .product-card-actions { | ||
| 236 | + display: flex; | ||
| 237 | + align-items: center; | ||
| 238 | + justify-content: space-between; | ||
| 239 | + gap: 8px; | ||
| 240 | + padding: 6px 8px; | ||
| 241 | + background: rgba(0,0,0,0.04); | ||
| 242 | + border-top: 1px solid #eee; | ||
| 243 | + opacity: 0.85; | ||
| 244 | + transition: opacity 0.2s; | ||
| 245 | + } | ||
| 246 | + .product-card-wrapper:hover .product-card-actions { | ||
| 247 | + opacity: 1; | ||
| 248 | + background: rgba(0,0,0,0.06); | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + /* Right side drawer (off-canvas) */ | ||
| 252 | + .side-drawer-backdrop { | ||
| 253 | + position: fixed; | ||
| 254 | + top: 0; | ||
| 255 | + left: 0; | ||
| 256 | + right: 0; | ||
| 257 | + bottom: 0; | ||
| 258 | + background: rgba(0,0,0,0.35); | ||
| 259 | + z-index: 999998; | ||
| 260 | + transition: opacity 0.25s; | ||
| 261 | + } | ||
| 262 | + .side-drawer-panel { | ||
| 263 | + position: fixed; | ||
| 264 | + top: 56px; | ||
| 265 | + right: 0; | ||
| 266 | + width: 85%; | ||
| 267 | + max-width: 560px; | ||
| 268 | + height: calc(100vh - 56px); | ||
| 269 | + background: white; | ||
| 270 | + box-shadow: -4px 0 20px rgba(0,0,0,0.12); | ||
| 271 | + z-index: 999999; | ||
| 272 | + overflow-y: auto; | ||
| 273 | + transition: transform 0.25s ease-out; | ||
| 274 | + } | ||
| 275 | + .side-drawer-panel.open { | ||
| 276 | + transform: translateX(0); | ||
| 277 | + } | ||
| 278 | + .side-drawer-header { | ||
| 279 | + position: sticky; | ||
| 280 | + top: 0; | ||
| 281 | + background: white; | ||
| 282 | + border-bottom: 1px solid #e5e5e5; | ||
| 283 | + padding: 12px 16px; | ||
| 284 | + display: flex; | ||
| 285 | + align-items: center; | ||
| 286 | + justify-content: space-between; | ||
| 287 | + z-index: 1; | ||
| 288 | + } | ||
| 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 { | ||
| 310 | + padding: 14px 16px 20px 16px; | ||
| 311 | + } | ||
| 312 | + /* Ensure drawer overlay is on top and not clipped by Streamlit blocks */ | ||
| 313 | + .side-drawer-backdrop, | ||
| 314 | + .side-drawer-panel { | ||
| 315 | + position: fixed !important; | ||
| 316 | + } | ||
| 317 | + | ||
| 318 | + /* Bottom floating ask/compare bar */ | ||
| 319 | + .bottom-actions-bar { | ||
| 320 | + position: fixed; | ||
| 321 | + bottom: 70px; | ||
| 322 | + left: 50%; | ||
| 323 | + transform: translateX(-50%); | ||
| 324 | + display: flex; | ||
| 325 | + align-items: center; | ||
| 326 | + gap: 12px; | ||
| 327 | + padding: 8px 16px; | ||
| 328 | + background: white; | ||
| 329 | + border: 1px solid #e5e5e5; | ||
| 330 | + border-radius: 24px; | ||
| 331 | + box-shadow: 0 2px 12px rgba(0,0,0,0.08); | ||
| 332 | + z-index: 1005; | ||
| 333 | + max-width: 90%; | ||
| 334 | + } | ||
| 335 | + .bottom-actions-bar .selected-count { | ||
| 336 | + font-size: 0.85rem; | ||
| 337 | + color: #666; | ||
| 338 | + margin-right: 4px; | ||
| 339 | + } | ||
| 225 | </style> | 340 | </style> |
| 226 | """, | 341 | """, |
| 227 | unsafe_allow_html=True, | 342 | unsafe_allow_html=True, |
| @@ -252,6 +367,22 @@ def initialize_session(): | @@ -252,6 +367,22 @@ def initialize_session(): | ||
| 252 | if "show_debug" not in st.session_state: | 367 | if "show_debug" not in st.session_state: |
| 253 | st.session_state.show_debug = True | 368 | st.session_state.show_debug = True |
| 254 | 369 | ||
| 370 | + # Selected products for ask/compare (key -> product info dict) | ||
| 371 | + if "selected_products" not in st.session_state: | ||
| 372 | + st.session_state.selected_products = {} | ||
| 373 | + | ||
| 374 | + # Right side panel: visible, mode in ("similar", "compare"), payload (e.g. ref_id, query, or list of selected items) | ||
| 375 | + if "side_panel" not in st.session_state: | ||
| 376 | + st.session_state.side_panel = { | ||
| 377 | + "visible": False, | ||
| 378 | + "mode": None, | ||
| 379 | + "payload": None, | ||
| 380 | + } | ||
| 381 | + | ||
| 382 | + # Products currently referenced in chat input (list of product summary dicts) | ||
| 383 | + if "referenced_products" not in st.session_state: | ||
| 384 | + st.session_state.referenced_products = [] | ||
| 385 | + | ||
| 255 | 386 | ||
| 256 | def save_uploaded_image(uploaded_file) -> Optional[str]: | 387 | def save_uploaded_image(uploaded_file) -> Optional[str]: |
| 257 | """Save uploaded image to temp directory""" | 388 | """Save uploaded image to temp directory""" |
| @@ -275,6 +406,87 @@ def save_uploaded_image(uploaded_file) -> Optional[str]: | @@ -275,6 +406,87 @@ def save_uploaded_image(uploaded_file) -> Optional[str]: | ||
| 275 | return None | 406 | return None |
| 276 | 407 | ||
| 277 | 408 | ||
| 409 | +def _product_key(ref_id: str, index: int, product: ProductItem) -> str: | ||
| 410 | + """Stable unique key for a product in the session (for selection and side panel).""" | ||
| 411 | + return f"{ref_id}_{index}_{product.spu_id or index}" | ||
| 412 | + | ||
| 413 | + | ||
| 414 | +def _product_to_info(product: ProductItem, ref_id: str) -> dict: | ||
| 415 | + """Serialize product to a small dict for selected_products and ask/compare.""" | ||
| 416 | + return { | ||
| 417 | + "ref_id": ref_id, | ||
| 418 | + "spu_id": product.spu_id, | ||
| 419 | + "sku_id": product.spu_id, | ||
| 420 | + "title": product.title or "未知商品", | ||
| 421 | + "price": product.price, | ||
| 422 | + "tags": product.tags or [], | ||
| 423 | + "specifications": product.specifications or [], | ||
| 424 | + } | ||
| 425 | + | ||
| 426 | + | ||
| 427 | +def _compact_field(value: Any) -> str: | ||
| 428 | + """Format a field into one readable line for chat reference payload.""" | ||
| 429 | + if value is None: | ||
| 430 | + return "-" | ||
| 431 | + if isinstance(value, list): | ||
| 432 | + if not value: | ||
| 433 | + return "-" | ||
| 434 | + parts = [] | ||
| 435 | + for item in value: | ||
| 436 | + if isinstance(item, dict): | ||
| 437 | + text = ", ".join(f"{k}:{v}" for k, v in item.items()) | ||
| 438 | + parts.append(text if text else str(item)) | ||
| 439 | + else: | ||
| 440 | + parts.append(str(item)) | ||
| 441 | + return " | ".join(p for p in parts if p) or "-" | ||
| 442 | + return str(value) | ||
| 443 | + | ||
| 444 | + | ||
| 445 | +def _build_reference_prefix(products: list[dict]) -> str: | ||
| 446 | + """Build backend prompt prefix for 'chat with referenced products'.""" | ||
| 447 | + lines = [f"引用 {len(products)} 款商品:"] | ||
| 448 | + for i, p in enumerate(products, 1): | ||
| 449 | + sku_id = _compact_field(p.get("sku_id") or p.get("spu_id")) | ||
| 450 | + title = _compact_field(p.get("title")) | ||
| 451 | + price = _compact_field(p.get("price")) | ||
| 452 | + tags = _compact_field(p.get("tags")) | ||
| 453 | + specifications = _compact_field(p.get("specifications")) | ||
| 454 | + lines.append( | ||
| 455 | + f"{i}. sku_id={sku_id}; title={title}; price={price}; " | ||
| 456 | + f"tags={tags}; specifications={specifications}" | ||
| 457 | + ) | ||
| 458 | + return "\n".join(lines) | ||
| 459 | + | ||
| 460 | + | ||
| 461 | +def render_referenced_products_in_input() -> None: | ||
| 462 | + """Render referenced products above chat input, each with remove button.""" | ||
| 463 | + refs = st.session_state.get("referenced_products", []) | ||
| 464 | + if not refs: | ||
| 465 | + return | ||
| 466 | + | ||
| 467 | + st.markdown("**已引用商品**") | ||
| 468 | + remove_idx = None | ||
| 469 | + for idx, item in enumerate(refs): | ||
| 470 | + with st.container(border=True): | ||
| 471 | + c1, c2 = st.columns([12, 1]) | ||
| 472 | + with c1: | ||
| 473 | + title = (item.get("title") or "未知商品")[:80] | ||
| 474 | + st.markdown(f"**{title}**") | ||
| 475 | + st.caption( | ||
| 476 | + f"sku_id={item.get('sku_id') or item.get('spu_id') or '-'}; " | ||
| 477 | + f"price={_compact_field(item.get('price'))}; " | ||
| 478 | + f"tags={_compact_field(item.get('tags'))}; " | ||
| 479 | + f"specifications={_compact_field(item.get('specifications'))}" | ||
| 480 | + ) | ||
| 481 | + with c2: | ||
| 482 | + if st.button("✕", key=f"remove_ref_{idx}", help="删除该引用"): | ||
| 483 | + remove_idx = idx | ||
| 484 | + if remove_idx is not None: | ||
| 485 | + refs.pop(remove_idx) | ||
| 486 | + st.session_state.referenced_products = refs | ||
| 487 | + st.rerun() | ||
| 488 | + | ||
| 489 | + | ||
| 278 | def _load_product_image(product: ProductItem) -> Optional[Image.Image]: | 490 | def _load_product_image(product: ProductItem) -> Optional[Image.Image]: |
| 279 | """Try to load a product image: image_url from API (normalized when stored) → local data/images → None.""" | 491 | """Try to load a product image: image_url from API (normalized when stored) → local data/images → None.""" |
| 280 | if product.image_url: | 492 | if product.image_url: |
| @@ -296,10 +508,39 @@ def _load_product_image(product: ProductItem) -> Optional[Image.Image]: | @@ -296,10 +508,39 @@ def _load_product_image(product: ProductItem) -> Optional[Image.Image]: | ||
| 296 | return None | 508 | return None |
| 297 | 509 | ||
| 298 | 510 | ||
| 299 | -def display_product_card_from_item(product: ProductItem) -> None: | ||
| 300 | - """Render a single product card from a ProductItem (registry entry).""" | ||
| 301 | - img = _load_product_image(product) | 511 | +def _run_similar_search(query: str) -> Optional[str]: |
| 512 | + """Run product search with query, register result, return new ref_id or None.""" | ||
| 513 | + if not query or not query.strip(): | ||
| 514 | + return None | ||
| 515 | + from app.tools.search_tools import make_search_products_tool | ||
| 516 | + | ||
| 517 | + session_id = st.session_state.get("session_id", "") | ||
| 518 | + if not session_id: | ||
| 519 | + return None | ||
| 520 | + tool = make_search_products_tool(session_id, global_registry) | ||
| 521 | + try: | ||
| 522 | + out = tool.invoke({"query": query.strip(), "limit": 12}) | ||
| 523 | + match = SEARCH_REF_PATTERN.search(out) | ||
| 524 | + if match: | ||
| 525 | + return match.group(1).strip() | ||
| 526 | + except Exception as e: | ||
| 527 | + logger.warning(f"Similar search failed: {e}") | ||
| 528 | + return None | ||
| 302 | 529 | ||
| 530 | + | ||
| 531 | +def display_product_card_from_item( | ||
| 532 | + product: ProductItem, | ||
| 533 | + ref_id: str, | ||
| 534 | + index: int, | ||
| 535 | +) -> None: | ||
| 536 | + """Render a single product card with hover actions: Similar products + checkbox.""" | ||
| 537 | + pkey = _product_key(ref_id, index, product) | ||
| 538 | + info = _product_to_info(product, ref_id) | ||
| 539 | + selected = st.session_state.selected_products | ||
| 540 | + | ||
| 541 | + st.markdown('<div class="product-card-wrapper">', unsafe_allow_html=True) | ||
| 542 | + | ||
| 543 | + img = _load_product_image(product) | ||
| 303 | if img: | 544 | if img: |
| 304 | target = (220, 220) | 545 | target = (220, 220) |
| 305 | try: | 546 | try: |
| @@ -324,6 +565,47 @@ def display_product_card_from_item(product: ProductItem) -> None: | @@ -324,6 +565,47 @@ def display_product_card_from_item(product: ProductItem) -> None: | ||
| 324 | label_style = "⭐" if product.match_label == "Relevant" else "✦" | 565 | label_style = "⭐" if product.match_label == "Relevant" else "✦" |
| 325 | st.caption(f"{label_style} {product.match_label}") | 566 | st.caption(f"{label_style} {product.match_label}") |
| 326 | 567 | ||
| 568 | + st.markdown('<div class="product-card-actions">', unsafe_allow_html=True) | ||
| 569 | + col_a, col_b = st.columns([1, 1]) | ||
| 570 | + with col_a: | ||
| 571 | + similar_clicked = st.button( | ||
| 572 | + "Similar products", | ||
| 573 | + key=f"similar_{pkey}", | ||
| 574 | + help="Search by this product title and show in side panel", | ||
| 575 | + ) | ||
| 576 | + with col_b: | ||
| 577 | + is_checked = st.checkbox( | ||
| 578 | + "Select", | ||
| 579 | + key=f"select_{pkey}", | ||
| 580 | + value=(pkey in selected), | ||
| 581 | + label_visibility="collapsed", | ||
| 582 | + ) | ||
| 583 | + st.markdown("</div>", unsafe_allow_html=True) | ||
| 584 | + st.markdown("</div>", unsafe_allow_html=True) | ||
| 585 | + | ||
| 586 | + if similar_clicked: | ||
| 587 | + search_query = (product.title or "").strip() or "商品" | ||
| 588 | + new_ref = _run_similar_search(search_query) | ||
| 589 | + if new_ref: | ||
| 590 | + st.session_state.side_panel = { | ||
| 591 | + "visible": True, | ||
| 592 | + "mode": "similar", | ||
| 593 | + "payload": {"ref_id": new_ref, "query": search_query}, | ||
| 594 | + } | ||
| 595 | + else: | ||
| 596 | + st.session_state.side_panel = { | ||
| 597 | + "visible": True, | ||
| 598 | + "mode": "similar", | ||
| 599 | + "payload": {"ref_id": None, "query": search_query, "error": True}, | ||
| 600 | + } | ||
| 601 | + st.rerun() | ||
| 602 | + | ||
| 603 | + if is_checked: | ||
| 604 | + if pkey not in selected: | ||
| 605 | + selected[pkey] = info | ||
| 606 | + else: | ||
| 607 | + selected.pop(pkey, None) | ||
| 608 | + | ||
| 327 | 609 | ||
| 328 | def render_search_result_block(result: SearchResult) -> None: | 610 | def render_search_result_block(result: SearchResult) -> None: |
| 329 | """ | 611 | """ |
| @@ -358,7 +640,7 @@ def render_search_result_block(result: SearchResult) -> None: | @@ -358,7 +640,7 @@ def render_search_result_block(result: SearchResult) -> None: | ||
| 358 | cols = st.columns(min(len(to_show), 3)) | 640 | cols = st.columns(min(len(to_show), 3)) |
| 359 | for i, product in enumerate(to_show): | 641 | for i, product in enumerate(to_show): |
| 360 | with cols[i % 3]: | 642 | with cols[i % 3]: |
| 361 | - display_product_card_from_item(product) | 643 | + display_product_card_from_item(product, result.ref_id, i) |
| 362 | 644 | ||
| 363 | 645 | ||
| 364 | def render_message_with_refs( | 646 | def render_message_with_refs( |
| @@ -463,11 +745,142 @@ def display_message(message: dict): | @@ -463,11 +745,142 @@ def display_message(message: dict): | ||
| 463 | st.markdown("</div>", unsafe_allow_html=True) | 745 | st.markdown("</div>", unsafe_allow_html=True) |
| 464 | 746 | ||
| 465 | 747 | ||
| 748 | +def render_bottom_actions_bar() -> None: | ||
| 749 | + """Show Ask and Compare when there are selected products. Disabled when none selected.""" | ||
| 750 | + selected = st.session_state.selected_products | ||
| 751 | + n = len(selected) | ||
| 752 | + if n == 0: | ||
| 753 | + return | ||
| 754 | + st.markdown( | ||
| 755 | + '<div class="bottom-actions-bar">', | ||
| 756 | + unsafe_allow_html=True, | ||
| 757 | + ) | ||
| 758 | + col_sel, col_ask, col_cmp = st.columns([2, 1, 1]) | ||
| 759 | + with col_sel: | ||
| 760 | + st.caption(f"Selected: {n}") | ||
| 761 | + with col_ask: | ||
| 762 | + ask_clicked = st.button("Ask", key="bottom_ask", help="Continue conversation with selected products") | ||
| 763 | + with col_cmp: | ||
| 764 | + compare_clicked = st.button("Compare", key="bottom_compare", help="Compare selected products") | ||
| 765 | + st.markdown("</div>", unsafe_allow_html=True) | ||
| 766 | + | ||
| 767 | + if ask_clicked: | ||
| 768 | + st.session_state.referenced_products = list(selected.values()) | ||
| 769 | + st.rerun() | ||
| 770 | + if compare_clicked: | ||
| 771 | + st.session_state.side_panel = { | ||
| 772 | + "visible": True, | ||
| 773 | + "mode": "compare", | ||
| 774 | + "payload": list(selected.values()), | ||
| 775 | + } | ||
| 776 | + st.rerun() | ||
| 777 | + | ||
| 778 | + | ||
| 779 | +def render_side_drawer() -> None: | ||
| 780 | + """Render a fixed overlay side drawer that does not change background layout.""" | ||
| 781 | + panel = st.session_state.side_panel | ||
| 782 | + if not panel.get("visible") or not panel.get("mode"): | ||
| 783 | + return | ||
| 784 | + | ||
| 785 | + mode = panel["mode"] | ||
| 786 | + payload = panel.get("payload") or {} | ||
| 787 | + session_id = st.session_state.get("session_id", "") | ||
| 788 | + | ||
| 789 | + title = "Similar products" if mode == "similar" else "Compare" | ||
| 790 | + body_html = "" | ||
| 791 | + | ||
| 792 | + if mode == "similar": | ||
| 793 | + ref_id = payload.get("ref_id") | ||
| 794 | + query = html.escape(payload.get("query", "")) | ||
| 795 | + if payload.get("error") or not ref_id: | ||
| 796 | + body_html = '<p style="color:#666;">搜索失败或暂无结果。</p>' | ||
| 797 | + else: | ||
| 798 | + result = global_registry.get(session_id, ref_id) | ||
| 799 | + if not result: | ||
| 800 | + body_html = f'<p style="color:#666;">[搜索结果 {html.escape(ref_id)} 不可用]</p>' | ||
| 801 | + else: | ||
| 802 | + perfect = [p for p in result.products if p.match_label == "Relevant"] | ||
| 803 | + partial = [p for p in result.products if p.match_label == "Partially Relevant"] | ||
| 804 | + to_show = (perfect + partial)[:12] if perfect else partial[:12] | ||
| 805 | + cards = [] | ||
| 806 | + for product in to_show: | ||
| 807 | + p_title = html.escape((product.title or "未知商品")[:80]) | ||
| 808 | + p_label = html.escape(product.match_label or "Partially Relevant") | ||
| 809 | + price = ( | ||
| 810 | + f"¥{product.price:.2f}" | ||
| 811 | + if product.price is not None | ||
| 812 | + else "价格待更新" | ||
| 813 | + ) | ||
| 814 | + image_html = ( | ||
| 815 | + f'<img src="{html.escape(product.image_url)}" alt="{p_title}" ' | ||
| 816 | + 'style="width:64px;height:64px;object-fit:cover;border-radius:8px;border:1px solid #eee;" />' | ||
| 817 | + if product.image_url | ||
| 818 | + else '<div style="width:64px;height:64px;background:#f5f5f5;border-radius:8px;' | ||
| 819 | + 'display:flex;align-items:center;justify-content:center;color:#bbb;">🛍️</div>' | ||
| 820 | + ) | ||
| 821 | + cards.append( | ||
| 822 | + '<div style="display:flex;gap:10px;border:1px solid #eee;border-radius:10px;' | ||
| 823 | + 'padding:10px;background:#fff;">' | ||
| 824 | + f"{image_html}" | ||
| 825 | + '<div style="flex:1;min-width:0;">' | ||
| 826 | + f'<div style="font-weight:600;color:#111;line-height:1.35;">{p_title}</div>' | ||
| 827 | + f'<div style="font-size:0.9rem;color:#555;margin-top:4px;">{price}</div>' | ||
| 828 | + f'<div style="font-size:0.8rem;color:#777;margin-top:4px;">{p_label}</div>' | ||
| 829 | + "</div></div>" | ||
| 830 | + ) | ||
| 831 | + | ||
| 832 | + cards_html = "".join(cards) if cards else '<p style="color:#666;">(未找到可展示的商品)</p>' | ||
| 833 | + body_html = ( | ||
| 834 | + f'<div style="font-size:0.92rem;color:#555;margin-bottom:10px;">' | ||
| 835 | + f'基于「{query}」的搜索结果:</div>' | ||
| 836 | + '<div style="display:grid;gap:10px;">' | ||
| 837 | + f"{cards_html}" | ||
| 838 | + "</div>" | ||
| 839 | + ) | ||
| 840 | + else: | ||
| 841 | + items = payload if isinstance(payload, list) else [] | ||
| 842 | + if items: | ||
| 843 | + rows = [] | ||
| 844 | + for item in items: | ||
| 845 | + t = html.escape((item.get("title") or "未知商品")[:80]) | ||
| 846 | + p = item.get("price") | ||
| 847 | + ptext = f"¥{p:.2f}" if p is not None else "价格待更新" | ||
| 848 | + rows.append( | ||
| 849 | + '<div style="border:1px solid #eee;border-radius:10px;padding:10px;background:#fff;">' | ||
| 850 | + f'<div style="font-weight:600;color:#111;">{t}</div>' | ||
| 851 | + f'<div style="font-size:0.9rem;color:#555;margin-top:4px;">{ptext}</div>' | ||
| 852 | + "</div>" | ||
| 853 | + ) | ||
| 854 | + items_html = "".join(rows) | ||
| 855 | + else: | ||
| 856 | + items_html = '<p style="color:#666;">当前未选中商品。</p>' | ||
| 857 | + body_html = ( | ||
| 858 | + '<div style="margin-bottom:10px;color:#555;">已选商品:</div>' | ||
| 859 | + f'<div style="display:grid;gap:10px;">{items_html}</div>' | ||
| 860 | + '<div style="margin-top:14px;padding:10px 12px;border-radius:8px;' | ||
| 861 | + 'background:#fff3cd;color:#856404;">对比功能暂未实现。</div>' | ||
| 862 | + ) | ||
| 863 | + | ||
| 864 | + st.markdown( | ||
| 865 | + f""" | ||
| 866 | + <div class="side-drawer-backdrop"></div> | ||
| 867 | + <div class="side-drawer-panel open"> | ||
| 868 | + <div class="side-drawer-header"> | ||
| 869 | + <div style="font-weight:600;">{html.escape(title)}</div> | ||
| 870 | + <a class="side-drawer-close-link" href="./?close_side_panel=1" target="_self">✕ 关闭</a> | ||
| 871 | + </div> | ||
| 872 | + <div class="side-drawer-content"> | ||
| 873 | + {body_html} | ||
| 874 | + </div> | ||
| 875 | + </div> | ||
| 876 | + """, | ||
| 877 | + unsafe_allow_html=True, | ||
| 878 | + ) | ||
| 879 | + | ||
| 880 | + | ||
| 466 | def display_welcome(): | 881 | def display_welcome(): |
| 467 | """Display welcome screen""" | 882 | """Display welcome screen""" |
| 468 | - | ||
| 469 | col1, col2, col3, col4 = st.columns(4) | 883 | col1, col2, col3, col4 = st.columns(4) |
| 470 | - | ||
| 471 | with col1: | 884 | with col1: |
| 472 | st.markdown( | 885 | st.markdown( |
| 473 | """ | 886 | """ |
| @@ -518,10 +931,17 @@ def display_welcome(): | @@ -518,10 +931,17 @@ def display_welcome(): | ||
| 518 | 931 | ||
| 519 | st.markdown("<br><br>", unsafe_allow_html=True) | 932 | st.markdown("<br><br>", unsafe_allow_html=True) |
| 520 | 933 | ||
| 521 | - | ||
| 522 | def main(): | 934 | def main(): |
| 523 | """Main Streamlit app""" | 935 | """Main Streamlit app""" |
| 524 | initialize_session() | 936 | initialize_session() |
| 937 | + # Close overlay via query param (used by HTML close link in side drawer) | ||
| 938 | + if st.query_params.get("close_side_panel"): | ||
| 939 | + st.session_state.side_panel = {"visible": False, "mode": None, "payload": None} | ||
| 940 | + del st.query_params["close_side_panel"] | ||
| 941 | + st.rerun() | ||
| 942 | + | ||
| 943 | + # Drawer overlay first so fixed positioning is relative to viewport (not a bottom block) | ||
| 944 | + render_side_drawer() | ||
| 525 | 945 | ||
| 526 | # Header | 946 | # Header |
| 527 | st.markdown( | 947 | st.markdown( |
| @@ -547,6 +967,9 @@ def main(): | @@ -547,6 +967,9 @@ def main(): | ||
| 547 | global_registry.clear_session(session_id) | 967 | global_registry.clear_session(session_id) |
| 548 | st.session_state.messages = [] | 968 | st.session_state.messages = [] |
| 549 | st.session_state.uploaded_image = None | 969 | st.session_state.uploaded_image = None |
| 970 | + st.session_state.selected_products = {} | ||
| 971 | + st.session_state.referenced_products = [] | ||
| 972 | + st.session_state.side_panel = {"visible": False, "mode": None, "payload": None} | ||
| 550 | st.rerun() | 973 | st.rerun() |
| 551 | 974 | ||
| 552 | # Debug toggle | 975 | # Debug toggle |
| @@ -561,15 +984,14 @@ def main(): | @@ -561,15 +984,14 @@ def main(): | ||
| 561 | st.markdown("---") | 984 | st.markdown("---") |
| 562 | st.caption(f"Session: `{st.session_state.session_id[:8]}...`") | 985 | st.caption(f"Session: `{st.session_state.session_id[:8]}...`") |
| 563 | 986 | ||
| 564 | - # Chat messages container | ||
| 565 | messages_container = st.container() | 987 | messages_container = st.container() |
| 566 | - | ||
| 567 | with messages_container: | 988 | with messages_container: |
| 568 | if not st.session_state.messages: | 989 | if not st.session_state.messages: |
| 569 | display_welcome() | 990 | display_welcome() |
| 570 | else: | 991 | else: |
| 571 | for message in st.session_state.messages: | 992 | for message in st.session_state.messages: |
| 572 | display_message(message) | 993 | display_message(message) |
| 994 | + render_bottom_actions_bar() | ||
| 573 | 995 | ||
| 574 | # Fixed input area at bottom (using container to simulate fixed position) | 996 | # Fixed input area at bottom (using container to simulate fixed position) |
| 575 | st.markdown('<div class="fixed-input-container">', unsafe_allow_html=True) | 997 | st.markdown('<div class="fixed-input-container">', unsafe_allow_html=True) |
| @@ -598,6 +1020,9 @@ def main(): | @@ -598,6 +1020,9 @@ def main(): | ||
| 598 | st.session_state.show_image_upload = False | 1020 | st.session_state.show_image_upload = False |
| 599 | st.rerun() | 1021 | st.rerun() |
| 600 | 1022 | ||
| 1023 | + # Referenced products area (shown above chat input, each can be removed) | ||
| 1024 | + render_referenced_products_in_input() | ||
| 1025 | + | ||
| 601 | # Input row | 1026 | # Input row |
| 602 | col1, col2 = st.columns([1, 12]) | 1027 | col1, col2 = st.columns([1, 12]) |
| 603 | 1028 | ||
| @@ -620,6 +1045,12 @@ def main(): | @@ -620,6 +1045,12 @@ def main(): | ||
| 620 | 1045 | ||
| 621 | # Process user input | 1046 | # Process user input |
| 622 | if user_query: | 1047 | if user_query: |
| 1048 | + raw_user_query = user_query | ||
| 1049 | + referenced_products = list(st.session_state.get("referenced_products", [])) | ||
| 1050 | + agent_query = raw_user_query | ||
| 1051 | + if referenced_products: | ||
| 1052 | + agent_query = f"{_build_reference_prefix(referenced_products)}\n\n{raw_user_query}" | ||
| 1053 | + | ||
| 623 | # Ensure shopping agent is initialized | 1054 | # Ensure shopping agent is initialized |
| 624 | if "shopping_agent" not in st.session_state: | 1055 | if "shopping_agent" not in st.session_state: |
| 625 | st.error("Session not initialized. Please refresh the page.") | 1056 | st.error("Session not initialized. Please refresh the page.") |
| @@ -632,9 +1063,8 @@ def main(): | @@ -632,9 +1063,8 @@ def main(): | ||
| 632 | image_path = save_uploaded_image(st.session_state.uploaded_image) | 1063 | image_path = save_uploaded_image(st.session_state.uploaded_image) |
| 633 | else: | 1064 | else: |
| 634 | # Check if query refers to a previous image | 1065 | # Check if query refers to a previous image |
| 635 | - query_lower = user_query.lower() | ||
| 636 | if any( | 1066 | if any( |
| 637 | - ref in query_lower | 1067 | + ref in raw_user_query.lower() |
| 638 | for ref in [ | 1068 | for ref in [ |
| 639 | "this", | 1069 | "this", |
| 640 | "that", | 1070 | "that", |
| @@ -655,11 +1085,14 @@ def main(): | @@ -655,11 +1085,14 @@ def main(): | ||
| 655 | st.session_state.messages.append( | 1085 | st.session_state.messages.append( |
| 656 | { | 1086 | { |
| 657 | "role": "user", | 1087 | "role": "user", |
| 658 | - "content": user_query, | 1088 | + "content": raw_user_query, |
| 659 | "image_path": image_path, | 1089 | "image_path": image_path, |
| 660 | } | 1090 | } |
| 661 | ) | 1091 | ) |
| 662 | 1092 | ||
| 1093 | + # References are consumed once this message is sent | ||
| 1094 | + st.session_state.referenced_products = [] | ||
| 1095 | + | ||
| 663 | # Display user message immediately | 1096 | # Display user message immediately |
| 664 | with messages_container: | 1097 | with messages_container: |
| 665 | display_message(st.session_state.messages[-1]) | 1098 | display_message(st.session_state.messages[-1]) |
| @@ -667,12 +1100,10 @@ def main(): | @@ -667,12 +1100,10 @@ def main(): | ||
| 667 | # Process with shopping agent | 1100 | # Process with shopping agent |
| 668 | try: | 1101 | try: |
| 669 | shopping_agent = st.session_state.shopping_agent | 1102 | shopping_agent = st.session_state.shopping_agent |
| 670 | - | ||
| 671 | - # Handle greetings without invoking the agent | ||
| 672 | - query_lower = user_query.lower().strip() | 1103 | + |
| 673 | # Process with agent | 1104 | # Process with agent |
| 674 | result = shopping_agent.chat( | 1105 | result = shopping_agent.chat( |
| 675 | - query=user_query, | 1106 | + query=agent_query, |
| 676 | image_path=image_path, | 1107 | image_path=image_path, |
| 677 | ) | 1108 | ) |
| 678 | response = result["response"] | 1109 | response = result["response"] |