Spaces:
Running
Running
| """CartBrawl 购物车大乱斗 — your cart items debate, trash-talk, and fight for your wallet. | |
| Paste shop links (or plain "name price" lines), every item comes alive knowing | |
| every rival's price and specs, then they brawl. You hold the buy button. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import os | |
| import re | |
| from pathlib import Path | |
| import gradio as gr | |
| # Local dev convenience: pick up the API key file if env isn't set. | |
| if not os.environ.get("CARTBRAWL_API_KEY"): | |
| _kf = Path(__file__).parent / ".cartbrawl_api_key" | |
| if _kf.exists(): | |
| os.environ["CARTBRAWL_API_KEY"] = _kf.read_text().strip() | |
| from cartbrawl import debate | |
| from cartbrawl.demo_data import DEMO_CARTS | |
| from cartbrawl.products import Product, fetch_product | |
| OWNER = {"zh": "👑 主人", "en": "👑 The Owner"} | |
| T = { | |
| "zh": { | |
| "header": ( | |
| "<div id='header'><h1>🛒⚔️ CartBrawl 购物车大乱斗</h1>" | |
| "<p>贴上商品链接,让它们自己吵。它们互相知根知底,谁吵赢了你买谁——决定权永远在你手上。</p></div>" | |
| ), | |
| "lang": "语言 / Language", | |
| "url_label": "商品链接 / 商品描述(每行一个)", | |
| "url_ph": "https://www.amazon.com/dp/B0BXYCS74H\nSony WH-1000XM5 $348\n三顿半咖啡 129元", | |
| "add": "🛒 加入购物车", | |
| "demo_label": "或者来一车演示商品", | |
| "demo_btn": "🎁 装入演示购物车", | |
| "clear": "🗑️ 清空", | |
| "rounds": "互撕轮数(每轮 = 全员攻击 + 全员反杀)", | |
| "brawl": "🔥 开战!", | |
| "more": "⚔️ 再吵一轮", | |
| "closing": "🙏 最后陈词", | |
| "note_label": "插话(告诉它们你在乎什么)", | |
| "note_ph": "我最在乎续航,而且预算只有 300 刀……", | |
| "note_btn": "📣 朕说两句", | |
| "verdict": "最终裁决:买谁?", | |
| "buy": "💸 买它!", | |
| "cart_empty": "🛒 购物车空空如也<br><small>贴几个商品链接,或者直接写「商品名 价格」</small>", | |
| "arena_empty": "⚔️ 竞技场静悄悄……<br><small>购物车里放至少 2 个商品,然后点「开战!」</small>", | |
| "badges": {"scraped": "已抓取", "inferred": "链接推断", "manual": "手动添加", "demo": "演示数据"}, | |
| "warn_empty": "先贴链接或写商品,比如:Sony XM5 $348", | |
| "warn_two": "至少要 2 个商品才能打起来!", | |
| "warn_pick": "先选一个赢家", | |
| "souls": "🪄 正在给每个商品注入灵魂…", | |
| "bought": "💸 主人按下了购买键:{name}!", | |
| "over": "🏁 本场大乱斗结束。换一车货,再来一架?", | |
| }, | |
| "en": { | |
| "header": ( | |
| "<div id='header'><h1>🛒⚔️ CartBrawl</h1>" | |
| "<p>Paste shop links. Your cart comes alive — every item knows its rivals' prices and specs, " | |
| "and they brawl for your wallet. You hold the buy button.</p></div>" | |
| ), | |
| "lang": "Language / 语言", | |
| "url_label": "Product links / descriptions (one per line)", | |
| "url_ph": "https://www.amazon.com/dp/B0BXYCS74H\nSony WH-1000XM5 $348\nAirPods Pro 2 $249", | |
| "add": "🛒 Add to cart", | |
| "demo_label": "…or load a demo cart", | |
| "demo_btn": "🎁 Load demo cart", | |
| "clear": "🗑️ Clear", | |
| "rounds": "Brawl rounds (each = everyone attacks + everyone counters)", | |
| "brawl": "🔥 FIGHT!", | |
| "more": "⚔️ One more round", | |
| "closing": "🙏 Closing pleas", | |
| "note_label": "Interject (tell them what you care about)", | |
| "note_ph": "I only care about battery life, and my budget is $300…", | |
| "note_btn": "📣 Speak", | |
| "verdict": "Final verdict: who gets bought?", | |
| "buy": "💸 BUY IT!", | |
| "cart_empty": "🛒 Your cart is empty<br><small>Paste some shop links, or just type “product name price”</small>", | |
| "arena_empty": "⚔️ The arena is quiet…<br><small>Put at least 2 items in the cart, then hit FIGHT!</small>", | |
| "badges": {"scraped": "scraped", "inferred": "from URL", "manual": "manual", "demo": "demo"}, | |
| "warn_empty": "Paste a link or type a product first, e.g.: Sony XM5 $348", | |
| "warn_two": "Need at least 2 items to brawl!", | |
| "warn_pick": "Pick a winner first", | |
| "souls": "🪄 Breathing souls into your items…", | |
| "bought": "💸 The owner hit BUY on: {name}!", | |
| "over": "🏁 Brawl over. Load another cart and run it back?", | |
| }, | |
| } | |
| BADGE_COLOR = {"scraped": "#27ae60", "inferred": "#e67e22", "manual": "#7f8c8d", "demo": "#2980b9"} | |
| def _lang(choice: str) -> str: | |
| return "zh" if choice.startswith("中") else "en" | |
| def new_state() -> dict: | |
| return {"products": [], "personas": None, "transcript": [], "log": [], "ui": "zh"} | |
| # ---------- rendering ---------- | |
| def render_cart(state: dict) -> str: | |
| t = T[state.get("ui", "zh")] | |
| products: list[Product] = state["products"] | |
| if not products: | |
| return f"<div class='cart-empty'>{t['cart_empty']}</div>" | |
| cards = [] | |
| for i, p in enumerate(products): | |
| emoji = debate.EMOJI_POOL[i % len(debate.EMOJI_POOL)] | |
| color = debate.COLOR_POOL[i % len(debate.COLOR_POOL)] | |
| badge = t["badges"].get(p.source, "?") | |
| bcolor = BADGE_COLOR.get(p.source, "#999") | |
| cards.append( | |
| f"<div class='cart-card' style='border-left:4px solid {color}'>" | |
| f"<span class='cart-emoji'>{emoji}</span>" | |
| f"<div class='cart-body'><b>{html.escape(p.name)}</b><br>" | |
| f"<span class='price'>{html.escape(p.price_label())}</span> " | |
| f"<span class='badge' style='background:{bcolor}'>{badge}</span></div></div>" | |
| ) | |
| return "<div class='cart-list'>" + "".join(cards) + "</div>" | |
| def render_arena(state: dict) -> str: | |
| t = T[state.get("ui", "zh")] | |
| if not state["log"]: | |
| return f"<div class='arena'><div class='arena-empty'>{t['arena_empty']}</div></div>" | |
| bubbles = [] | |
| for e in state["log"]: | |
| text = html.escape(e["text"]).replace("\n", "<br>") | |
| if e["kind"] == "system": | |
| bubbles.append(f"<div class='round-banner'>{text}</div>") | |
| elif e["kind"] == "user": | |
| bubbles.append( | |
| f"<div class='bubble owner'><div class='who'>{e['who']}</div>{text}</div>" | |
| ) | |
| else: | |
| cursor = "<span class='cursor'>▌</span>" if e.get("live") else "" | |
| bubbles.append( | |
| f"<div class='bubble' style='border-color:{e['color']}'>" | |
| f"<div class='who' style='color:{e['color']}'>{e['emoji']} {html.escape(e['who'])}</div>" | |
| f"{text}{cursor}</div>" | |
| ) | |
| # column-reverse keeps the scroll pinned to the newest bubble | |
| return "<div class='arena'>" + "".join(reversed(bubbles)) + "</div>" | |
| def _verdict_update(state: dict): | |
| names = [p.name for p in state["products"]] | |
| return gr.update(choices=names, value=None, label=T[state.get("ui", "zh")]["verdict"]) | |
| # ---------- cart actions ---------- | |
| _PRICE_RE = re.compile(r"([¥$€£])\s*([\d][\d,.]*)|([\d][\d,.]*)\s*(元|块|刀|usd|rmb|eur)", re.I) | |
| def _parse_manual(line: str) -> Product: | |
| m = _PRICE_RE.search(line) | |
| price, currency = "", "" | |
| if m: | |
| if m.group(1): | |
| sym = {"¥": "CNY", "$": "USD", "€": "EUR", "£": "GBP"}[m.group(1)] | |
| price, currency = m.group(2), sym | |
| else: | |
| unit = m.group(4).lower() | |
| price = m.group(3) | |
| currency = "CNY" if unit in ("元", "块", "rmb") else ("USD" if unit in ("刀", "usd") else "EUR") | |
| line = (line[: m.start()] + line[m.end():]).strip(" ,,-—") | |
| return Product(name=line.strip()[:120] or "神秘商品", price=price, currency=currency, source="manual") | |
| def add_items(text: str, state: dict): | |
| t = T[state.get("ui", "zh")] | |
| lines = [l.strip() for l in re.split(r"[\n,,]+", text or "") if l.strip()] | |
| if not lines: | |
| gr.Warning(t["warn_empty"]) | |
| return "", render_cart(state), render_arena(state), state, _verdict_update(state) | |
| for line in lines: | |
| if re.match(r"(https?://|www\.)|^\S+\.\w{2,}/", line): | |
| state["products"].append(fetch_product(line)) | |
| else: | |
| state["products"].append(_parse_manual(line)) | |
| state["products"] = state["products"][:8] # cap the brawl | |
| state["personas"], state["transcript"], state["log"] = None, [], [] | |
| return "", render_cart(state), render_arena(state), state, _verdict_update(state) | |
| def load_demo(name: str, state: dict): | |
| ui = state.get("ui", "zh") | |
| state.update(new_state()) | |
| state["ui"] = ui | |
| state["products"] = [Product(**vars(p)) for p in DEMO_CARTS[name]] | |
| return render_cart(state), render_arena(state), state, _verdict_update(state) | |
| def clear_cart(state: dict): | |
| ui = state.get("ui", "zh") | |
| state.update(new_state()) | |
| state["ui"] = ui | |
| return render_cart(state), render_arena(state), state, _verdict_update(state) | |
| # ---------- debate actions ---------- | |
| def _ensure_personas(state: dict, lang: str): | |
| # regenerate when the debate language changes, or personas go stale | |
| if not state["personas"] or state.get("persona_lang") != lang: | |
| state["personas"] = debate.make_personas(state["products"], lang) | |
| state["persona_lang"] = lang | |
| def _run_speech(state: dict, speaker: Product, round_key: str, lang: str, **fmt): | |
| persona = state["personas"][speaker.name] | |
| entry = { | |
| "kind": "speech", "who": speaker.name, "emoji": persona["emoji"], | |
| "color": persona["color"], "text": "", "live": True, | |
| } | |
| state["log"].append(entry) | |
| for delta in debate.speech( | |
| speaker, state["products"], state["personas"], state["transcript"], | |
| round_key, lang, **fmt, | |
| ): | |
| entry["text"] += delta | |
| yield | |
| entry["live"] = False | |
| state["transcript"].append((speaker.name, entry["text"])) | |
| yield | |
| def _banner(state: dict, text: str): | |
| state["log"].append({"kind": "system", "who": "", "text": text}) | |
| ROUND_BANNERS = { | |
| "zh": {"opening": "🎺 开场环节:闪亮登场", "attack": "⚔️ 互撕环节:火力全开", | |
| "rebuttal": "🛡️ 反杀环节:谁也别想全身而退", "closing": "🙏 最后陈词:拉票时间"}, | |
| "en": {"opening": "🎺 Opening: grand entrances", "attack": "⚔️ Brawl: gloves off", | |
| "rebuttal": "🛡️ Rebuttals: nobody walks away clean", "closing": "🙏 Closing pleas"}, | |
| } | |
| def _rounds(state: dict, lang: str, keys: list[str]): | |
| for key in keys: | |
| _banner(state, ROUND_BANNERS[lang][key]) | |
| yield | |
| for speaker in debate.round_order(state["products"]): | |
| yield from _run_speech(state, speaker, key, lang) | |
| def _guard(state: dict) -> bool: | |
| if len(state["products"]) < 2: | |
| gr.Warning(T[state.get("ui", "zh")]["warn_two"]) | |
| return False | |
| return True | |
| def start_brawl(state: dict, lang_choice: str, n_rounds: float): | |
| if not _guard(state): | |
| yield render_arena(state), state | |
| return | |
| lang = _lang(lang_choice) | |
| state["transcript"], state["log"] = [], [] | |
| _banner(state, T[lang]["souls"]) | |
| yield render_arena(state), state | |
| _ensure_personas(state, lang) | |
| state["log"].pop() # remove the loading banner | |
| keys = ["opening"] + ["attack", "rebuttal"] * int(n_rounds) | |
| for _ in _rounds(state, lang, keys): | |
| yield render_arena(state), state | |
| yield render_arena(state), state | |
| def another_round(state: dict, lang_choice: str): | |
| if not _guard(state): | |
| yield render_arena(state), state | |
| return | |
| lang = _lang(lang_choice) | |
| _ensure_personas(state, lang) | |
| for _ in _rounds(state, lang, ["attack", "rebuttal"]): | |
| yield render_arena(state), state | |
| yield render_arena(state), state | |
| def closing_round(state: dict, lang_choice: str): | |
| if not _guard(state): | |
| yield render_arena(state), state | |
| return | |
| lang = _lang(lang_choice) | |
| _ensure_personas(state, lang) | |
| for _ in _rounds(state, lang, ["closing"]): | |
| yield render_arena(state), state | |
| yield render_arena(state), state | |
| def interject(note: str, state: dict, lang_choice: str): | |
| note = (note or "").strip() | |
| if not note or not _guard(state): | |
| yield "", render_arena(state), state | |
| return | |
| lang = _lang(lang_choice) | |
| _ensure_personas(state, lang) | |
| state["log"].append({"kind": "user", "who": OWNER[lang], "text": note}) | |
| yield "", render_arena(state), state | |
| for speaker in debate.round_order(state["products"]): | |
| for _ in _run_speech(state, speaker, "user_reply", lang, note=note): | |
| yield "", render_arena(state), state | |
| yield "", render_arena(state), state | |
| def pick_winner(choice: str, state: dict, lang_choice: str): | |
| lang = _lang(lang_choice) | |
| if not _guard(state): | |
| yield render_arena(state), state | |
| return | |
| if not choice: | |
| gr.Warning(T[lang]["warn_pick"]) | |
| yield render_arena(state), state | |
| return | |
| _ensure_personas(state, lang) | |
| _banner(state, T[lang]["bought"].format(name=choice)) | |
| yield render_arena(state), state | |
| winner = next(p for p in state["products"] if p.name == choice) | |
| for _ in _run_speech(state, winner, "win", lang): | |
| yield render_arena(state), state | |
| for loser in state["products"]: | |
| if loser.name == choice: | |
| continue | |
| for _ in _run_speech(state, loser, "lose", lang, winner=choice): | |
| yield render_arena(state), state | |
| _banner(state, T[lang]["over"]) | |
| yield render_arena(state), state | |
| # ---------- language switch ---------- | |
| def switch_lang(lang_choice: str, state: dict): | |
| ui = _lang(lang_choice) | |
| state["ui"] = ui | |
| t = T[ui] | |
| return ( | |
| state, | |
| t["header"], | |
| gr.update(label=t["url_label"], placeholder=t["url_ph"]), | |
| gr.update(value=t["add"]), | |
| gr.update(label=t["demo_label"]), | |
| gr.update(value=t["demo_btn"]), | |
| gr.update(value=t["clear"]), | |
| gr.update(label=t["rounds"]), | |
| gr.update(value=t["brawl"]), | |
| gr.update(value=t["more"]), | |
| gr.update(value=t["closing"]), | |
| gr.update(label=t["note_label"], placeholder=t["note_ph"]), | |
| gr.update(value=t["note_btn"]), | |
| gr.update(label=t["verdict"]), | |
| gr.update(value=t["buy"]), | |
| render_cart(state), | |
| render_arena(state), | |
| ) | |
| # ---------- UI ---------- | |
| CSS = """ | |
| .gradio-container {max-width: 1280px !important} | |
| #header {text-align:center; padding: 18px 0 6px} | |
| #header h1 {font-size: 2.1em; margin: 0; | |
| background: linear-gradient(90deg,#e74c3c,#8e44ad,#2980b9); | |
| -webkit-background-clip:text; -webkit-text-fill-color:transparent} | |
| #header p {opacity:.75; margin:6px 0 0} | |
| .cart-list {display:flex; flex-direction:column; gap:8px} | |
| .cart-card {display:flex; gap:10px; align-items:center; padding:8px 10px; | |
| border-radius:10px; background:var(--block-background-fill); | |
| box-shadow:0 1px 4px rgba(0,0,0,.12)} | |
| .cart-emoji {font-size:1.6em} | |
| .cart-body {line-height:1.35; font-size:.92em} | |
| .price {font-weight:700; color:#e67e22} | |
| .badge {color:#fff; border-radius:8px; padding:1px 7px; font-size:.72em} | |
| .cart-empty, .arena-empty {text-align:center; opacity:.6; padding:30px 10px} | |
| .arena {height:600px; overflow-y:auto; display:flex; flex-direction:column-reverse; | |
| gap:10px; padding:12px; border-radius:12px; | |
| background:var(--background-fill-secondary)} | |
| .bubble {border:2px solid #888; border-radius:14px; padding:9px 13px; | |
| max-width:88%; background:var(--block-background-fill); | |
| animation:pop .25s ease; line-height:1.5} | |
| .bubble .who {font-weight:700; font-size:.85em; margin-bottom:3px} | |
| .bubble.owner {align-self:flex-end; border-color:#f1c40f; background:rgba(241,196,15,.12)} | |
| .round-banner {align-self:center; font-weight:700; opacity:.8; padding:4px 14px; | |
| border-radius:20px; background:var(--background-fill-primary); | |
| border:1px dashed var(--border-color-primary)} | |
| .cursor {animation: blink 1s steps(1) infinite} | |
| @keyframes blink {50% {opacity:0}} | |
| @keyframes pop {from {transform:scale(.92); opacity:0}} | |
| """ | |
| _t = T["zh"] | |
| with gr.Blocks(title="CartBrawl 购物车大乱斗") as demo: | |
| header = gr.HTML(_t["header"]) | |
| state = gr.State(new_state) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| lang_choice = gr.Radio(["中文", "English"], value="中文", label=_t["lang"]) | |
| url_box = gr.Textbox(label=_t["url_label"], placeholder=_t["url_ph"], lines=3) | |
| add_btn = gr.Button(_t["add"], variant="primary") | |
| demo_pick = gr.Dropdown(list(DEMO_CARTS), label=_t["demo_label"], value=list(DEMO_CARTS)[0]) | |
| with gr.Row(): | |
| demo_btn = gr.Button(_t["demo_btn"]) | |
| clear_btn = gr.Button(_t["clear"]) | |
| cart_html = gr.HTML(render_cart(new_state())) | |
| with gr.Column(scale=4): | |
| arena_html = gr.HTML(render_arena(new_state())) | |
| rounds = gr.Slider(1, 4, value=2, step=1, label=_t["rounds"]) | |
| with gr.Row(): | |
| brawl_btn = gr.Button(_t["brawl"], variant="primary") | |
| more_btn = gr.Button(_t["more"]) | |
| closing_btn = gr.Button(_t["closing"]) | |
| with gr.Row(): | |
| note_box = gr.Textbox(label=_t["note_label"], placeholder=_t["note_ph"], scale=4) | |
| note_btn = gr.Button(_t["note_btn"], scale=1) | |
| with gr.Row(): | |
| verdict_pick = gr.Radio([], label=_t["verdict"], scale=4) | |
| buy_btn = gr.Button(_t["buy"], variant="stop", scale=1) | |
| cart_outs = [cart_html, arena_html, state, verdict_pick] | |
| add_btn.click(add_items, [url_box, state], [url_box, *cart_outs]) | |
| url_box.submit(add_items, [url_box, state], [url_box, *cart_outs]) | |
| demo_btn.click(load_demo, [demo_pick, state], cart_outs) | |
| clear_btn.click(clear_cart, [state], cart_outs) | |
| brawl_btn.click(start_brawl, [state, lang_choice, rounds], [arena_html, state]) | |
| more_btn.click(another_round, [state, lang_choice], [arena_html, state]) | |
| closing_btn.click(closing_round, [state, lang_choice], [arena_html, state]) | |
| note_btn.click(interject, [note_box, state, lang_choice], [note_box, arena_html, state]) | |
| note_box.submit(interject, [note_box, state, lang_choice], [note_box, arena_html, state]) | |
| buy_btn.click(pick_winner, [verdict_pick, state, lang_choice], [arena_html, state]) | |
| lang_choice.change( | |
| switch_lang, | |
| [lang_choice, state], | |
| [state, header, url_box, add_btn, demo_pick, demo_btn, clear_btn, rounds, | |
| brawl_btn, more_btn, closing_btn, note_box, note_btn, verdict_pick, buy_btn, | |
| cart_html, arena_html], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(theme=gr.themes.Soft(), css=CSS) | |