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 | 33 | |
| 34 | 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 | 3 | Multi-modal fashion shopping assistant with conversational AI |
| 4 | 4 | """ |
| 5 | 5 | |
| 6 | +import html | |
| 6 | 7 | import logging |
| 7 | 8 | import re |
| 8 | 9 | import uuid |
| 9 | 10 | from pathlib import Path |
| 10 | -from typing import Optional | |
| 11 | +from typing import Any, Optional | |
| 11 | 12 | |
| 12 | 13 | import streamlit as st |
| 13 | 14 | from PIL import Image, ImageOps |
| ... | ... | @@ -222,6 +223,120 @@ st.markdown( |
| 222 | 223 | .uploadedFile { |
| 223 | 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 | 340 | </style> |
| 226 | 341 | """, |
| 227 | 342 | unsafe_allow_html=True, |
| ... | ... | @@ -252,6 +367,22 @@ def initialize_session(): |
| 252 | 367 | if "show_debug" not in st.session_state: |
| 253 | 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 | 387 | def save_uploaded_image(uploaded_file) -> Optional[str]: |
| 257 | 388 | """Save uploaded image to temp directory""" |
| ... | ... | @@ -275,6 +406,87 @@ def save_uploaded_image(uploaded_file) -> Optional[str]: |
| 275 | 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 | 490 | def _load_product_image(product: ProductItem) -> Optional[Image.Image]: |
| 279 | 491 | """Try to load a product image: image_url from API (normalized when stored) → local data/images → None.""" |
| 280 | 492 | if product.image_url: |
| ... | ... | @@ -296,10 +508,39 @@ def _load_product_image(product: ProductItem) -> Optional[Image.Image]: |
| 296 | 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 | 544 | if img: |
| 304 | 545 | target = (220, 220) |
| 305 | 546 | try: |
| ... | ... | @@ -324,6 +565,47 @@ def display_product_card_from_item(product: ProductItem) -> None: |
| 324 | 565 | label_style = "⭐" if product.match_label == "Relevant" else "✦" |
| 325 | 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 | 610 | def render_search_result_block(result: SearchResult) -> None: |
| 329 | 611 | """ |
| ... | ... | @@ -358,7 +640,7 @@ def render_search_result_block(result: SearchResult) -> None: |
| 358 | 640 | cols = st.columns(min(len(to_show), 3)) |
| 359 | 641 | for i, product in enumerate(to_show): |
| 360 | 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 | 646 | def render_message_with_refs( |
| ... | ... | @@ -463,11 +745,142 @@ def display_message(message: dict): |
| 463 | 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 | 881 | def display_welcome(): |
| 467 | 882 | """Display welcome screen""" |
| 468 | - | |
| 469 | 883 | col1, col2, col3, col4 = st.columns(4) |
| 470 | - | |
| 471 | 884 | with col1: |
| 472 | 885 | st.markdown( |
| 473 | 886 | """ |
| ... | ... | @@ -518,10 +931,17 @@ def display_welcome(): |
| 518 | 931 | |
| 519 | 932 | st.markdown("<br><br>", unsafe_allow_html=True) |
| 520 | 933 | |
| 521 | - | |
| 522 | 934 | def main(): |
| 523 | 935 | """Main Streamlit app""" |
| 524 | 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 | 946 | # Header |
| 527 | 947 | st.markdown( |
| ... | ... | @@ -547,6 +967,9 @@ def main(): |
| 547 | 967 | global_registry.clear_session(session_id) |
| 548 | 968 | st.session_state.messages = [] |
| 549 | 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 | 973 | st.rerun() |
| 551 | 974 | |
| 552 | 975 | # Debug toggle |
| ... | ... | @@ -561,15 +984,14 @@ def main(): |
| 561 | 984 | st.markdown("---") |
| 562 | 985 | st.caption(f"Session: `{st.session_state.session_id[:8]}...`") |
| 563 | 986 | |
| 564 | - # Chat messages container | |
| 565 | 987 | messages_container = st.container() |
| 566 | - | |
| 567 | 988 | with messages_container: |
| 568 | 989 | if not st.session_state.messages: |
| 569 | 990 | display_welcome() |
| 570 | 991 | else: |
| 571 | 992 | for message in st.session_state.messages: |
| 572 | 993 | display_message(message) |
| 994 | + render_bottom_actions_bar() | |
| 573 | 995 | |
| 574 | 996 | # Fixed input area at bottom (using container to simulate fixed position) |
| 575 | 997 | st.markdown('<div class="fixed-input-container">', unsafe_allow_html=True) |
| ... | ... | @@ -598,6 +1020,9 @@ def main(): |
| 598 | 1020 | st.session_state.show_image_upload = False |
| 599 | 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 | 1026 | # Input row |
| 602 | 1027 | col1, col2 = st.columns([1, 12]) |
| 603 | 1028 | |
| ... | ... | @@ -620,6 +1045,12 @@ def main(): |
| 620 | 1045 | |
| 621 | 1046 | # Process user input |
| 622 | 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 | 1054 | # Ensure shopping agent is initialized |
| 624 | 1055 | if "shopping_agent" not in st.session_state: |
| 625 | 1056 | st.error("Session not initialized. Please refresh the page.") |
| ... | ... | @@ -632,9 +1063,8 @@ def main(): |
| 632 | 1063 | image_path = save_uploaded_image(st.session_state.uploaded_image) |
| 633 | 1064 | else: |
| 634 | 1065 | # Check if query refers to a previous image |
| 635 | - query_lower = user_query.lower() | |
| 636 | 1066 | if any( |
| 637 | - ref in query_lower | |
| 1067 | + ref in raw_user_query.lower() | |
| 638 | 1068 | for ref in [ |
| 639 | 1069 | "this", |
| 640 | 1070 | "that", |
| ... | ... | @@ -655,11 +1085,14 @@ def main(): |
| 655 | 1085 | st.session_state.messages.append( |
| 656 | 1086 | { |
| 657 | 1087 | "role": "user", |
| 658 | - "content": user_query, | |
| 1088 | + "content": raw_user_query, | |
| 659 | 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 | 1096 | # Display user message immediately |
| 664 | 1097 | with messages_container: |
| 665 | 1098 | display_message(st.session_state.messages[-1]) |
| ... | ... | @@ -667,12 +1100,10 @@ def main(): |
| 667 | 1100 | # Process with shopping agent |
| 668 | 1101 | try: |
| 669 | 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 | 1104 | # Process with agent |
| 674 | 1105 | result = shopping_agent.chat( |
| 675 | - query=user_query, | |
| 1106 | + query=agent_query, | |
| 676 | 1107 | image_path=image_path, |
| 677 | 1108 | ) |
| 678 | 1109 | response = result["response"] | ... | ... |