"""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""
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""
)
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)