"""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": ( "" ), "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": "🛒 购物车空空如也
贴几个商品链接,或者直接写「商品名 价格」", "arena_empty": "⚔️ 竞技场静悄悄……
购物车里放至少 2 个商品,然后点「开战!」", "badges": {"scraped": "已抓取", "inferred": "链接推断", "manual": "手动添加", "demo": "演示数据"}, "warn_empty": "先贴链接或写商品,比如:Sony XM5 $348", "warn_two": "至少要 2 个商品才能打起来!", "warn_pick": "先选一个赢家", "souls": "🪄 正在给每个商品注入灵魂…", "bought": "💸 主人按下了购买键:{name}!", "over": "🏁 本场大乱斗结束。换一车货,再来一架?", }, "en": { "header": ( "" ), "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
Paste some shop links, or just type “product name price”", "arena_empty": "⚔️ The arena is quiet…
Put at least 2 items in the cart, then hit FIGHT!", "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"
{t['cart_empty']}
" 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"
" f"{emoji}" f"
{html.escape(p.name)}
" f"{html.escape(p.price_label())} " f"{badge}
" ) return "
" + "".join(cards) + "
" def render_arena(state: dict) -> str: t = T[state.get("ui", "zh")] if not state["log"]: return f"
{t['arena_empty']}
" bubbles = [] for e in state["log"]: text = html.escape(e["text"]).replace("\n", "
") if e["kind"] == "system": bubbles.append(f"
{text}
") elif e["kind"] == "user": bubbles.append( f"
{e['who']}
{text}
" ) else: cursor = "" if e.get("live") else "" bubbles.append( f"
" f"
{e['emoji']} {html.escape(e['who'])}
" f"{text}{cursor}
" ) # column-reverse keeps the scroll pinned to the newest bubble return "
" + "".join(reversed(bubbles)) + "
" 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)