import csv
import random
from pathlib import Path
from typing import Any
import gradio as gr
try:
import spaces
except Exception:
class _SpacesFallback:
@staticmethod
def GPU(*args: Any, **kwargs: Any):
def decorator(fn):
return fn
if args and callable(args[0]) and len(args) == 1 and not kwargs:
return args[0]
return decorator
spaces = _SpacesFallback()
APP_TITLE = "Human, Will You Come With Me?"
ROOT_DIR = Path(__file__).resolve().parent
CATS_CSV = ROOT_DIR / "cats.csv"
PLACEHOLDER_IMAGE = ROOT_DIR / "assets" / "placeholder.jpg"
CARD_CHOICES = [
"The Quiet Window",
"The Open Door",
"The Warm Lap",
]
LIFESTYLE_CHOICES = [
"Calm and home-centered",
"Balanced and flexible",
"Active and always moving",
]
RELATIONSHIP_CHOICES = [
"A close companion who shares my routines",
"An independent friend with their own space",
"A playful partner for daily adventures",
]
LIFESTYLE_UI_CHOICES = [
("◷ 规律作息,生活稳定", LIFESTYLE_CHOICES[0]),
("▦ 忙碌多变,但愿意调整", LIFESTYLE_CHOICES[1]),
("➶ 活动很多,喜欢探索世界", LIFESTYLE_CHOICES[2]),
]
RELATIONSHIP_UI_CHOICES = [
("★ 彼此靠近,常常陪伴", RELATIONSHIP_CHOICES[0]),
("● 尊重边界,各自自在", RELATIONSHIP_CHOICES[1]),
("✦ 一起玩耍,共同冒险", RELATIONSHIP_CHOICES[2]),
]
REQUIRED_COLUMNS = {
"id",
"name",
"age",
"card",
"lifestyle",
"relationship",
"summary",
"story",
"cta",
"image",
}
@spaces.GPU(duration=60)
def generate_cat_image_placeholder(prompt: str = "") -> str:
"""ZeroGPU entry point reserved for the upcoming FLUX image generator."""
del prompt
return "ZeroGPU is configured. FLUX generation will be connected here."
def load_cats(csv_path: Path | str = CATS_CSV) -> list[dict[str, str]]:
"""Load and validate the cat records used by the matcher."""
path = Path(csv_path)
with path.open("r", encoding="utf-8", newline="") as csv_file:
reader = csv.DictReader(csv_file)
columns = set(reader.fieldnames or [])
missing_columns = REQUIRED_COLUMNS - columns
if missing_columns:
missing = ", ".join(sorted(missing_columns))
raise ValueError(f"cats.csv is missing required columns: {missing}")
cats = [
{key: (value or "").strip() for key, value in row.items()}
for row in reader
]
if not cats:
raise ValueError("cats.csv must contain at least one cat.")
return cats
CATS = load_cats()
def match_cat(
selected_card: str | None,
selected_lifestyle: str | None,
selected_relationship: str | None,
) -> dict[str, str]:
"""Return the highest-scoring cat for the three saved user choices."""
choices = {
"card": selected_card,
"lifestyle": selected_lifestyle,
"relationship": selected_relationship,
}
def score(cat: dict[str, str]) -> int:
return sum(
1
for field, selected_value in choices.items()
if selected_value and cat[field] == selected_value
)
return max(CATS, key=score).copy()
def go_to_screen(active_screen: int) -> dict[str, Any]:
return gr.update(selected=active_screen)
def draw_relationship_card() -> tuple[str, str, dict[str, Any]]:
selected_card = random.choice(CARD_CHOICES)
card_copy = {
"The Quiet Window": (
"### 静谧之窗 · The Quiet Window\n"
"你会留意一个缓慢的眨眼、一段安静的陪伴,"
"以及两个人共享同一间屋子的温柔。"
),
"The Open Door": (
"### 敞开的门 · The Open Door\n"
"你愿意让信任按自己的速度到来。好奇、自由,"
"以及互相尊重,会成为关系的一部分。"
),
"The Warm Lap": (
"### 温暖的膝头 · The Warm Lap\n"
"你已经准备好迎接亲密、熟悉的生活仪式,"
"以及一位真正进入日常的伙伴。"
),
}
return selected_card, card_copy[selected_card], gr.update(interactive=True)
def save_choice(choice: str | None, fallback: str) -> str:
return choice or fallback
def build_match_result(
selected_card: str | None,
selected_lifestyle: str | None,
selected_relationship: str | None,
) -> tuple[Any, ...]:
matched_cat = match_cat(
selected_card,
selected_lifestyle,
selected_relationship,
)
heading = f"## {matched_cat['name']} / {matched_cat['age']}"
profile = (
f"{matched_cat['story']}\n\n"
f"**为什么是你们:** {matched_cat['summary']}\n\n"
f"**关系卡:** {selected_card or '尚未选择'} \n"
f"**生活节奏:** {selected_lifestyle or '尚未选择'} \n"
f"**相处方式:** {selected_relationship or '尚未选择'}"
)
return matched_cat, heading, profile
def show_final_cat(
matched_cat: dict[str, str] | None,
) -> tuple[Any, ...]:
cat = matched_cat or CATS[0]
image_path = ROOT_DIR / cat["image"]
if not image_path.exists():
image_path = PLACEHOLDER_IMAGE
final_heading = f"## 人类,你愿意跟 {cat['name']} 走吗?"
final_copy = (
f"{cat['cta']}\n\n"
"这是第一版体验原型,目前使用示例猫咪资料。接入真实救助站数据后,"
"这里会链接到正式领养页面。"
)
return str(image_path), final_heading, final_copy
def reset_values() -> tuple[Any, ...]:
return (
None,
None,
None,
None,
None,
None,
"点击「抽一张卡」,看看哪一种关系正在等你。",
gr.update(interactive=False),
)
def page_header(step: int, title: str) -> str:
return (
"
"
)
def page_footer(step: int, note: str) -> str:
return (
""
)
def ornament_title(kicker: str, title: str, subtitle: str = "") -> str:
subtitle_html = f"{subtitle}
" if subtitle else ""
return (
""
f"{kicker}
{title}
{subtitle_html}"
""
)
with gr.Blocks(
title=APP_TITLE,
) as demo:
selected_card = gr.State(value=None)
selected_lifestyle = gr.State(value=None)
selected_relationship = gr.State(value=None)
matched_cat = gr.State(value=None)
gpu_probe_input = gr.Textbox(value="", visible=False)
gpu_probe_output = gr.Textbox(visible=False)
gpu_probe_button = gr.Button("GPU probe", visible=False)
screens: list[gr.Tab] = []
with gr.Tabs(
selected=1,
elem_id="journey-tabs",
elem_classes=["journey-tabs"],
) as journey_tabs:
with gr.Tab(
"Introduction",
id=1,
elem_classes=["app-screen"],
) as screen_1:
gr.HTML(page_header(1, "首页 / 引子"))
with gr.Row(elem_classes=["screen-body", "landing-layout"]):
with gr.Column(scale=6, elem_classes=["landing-copy"]):
gr.HTML(
ornament_title(
"A SMALL ENCOUNTER",
"也许,今晚
会有一只猫选中你。",
"先别急着去寻找。
这一次,让猫来决定。",
)
)
start_button = gr.Button(
"✤ 开始抽一张猫咪关系卡",
variant="primary",
size="lg",
elem_classes=["ink-button"],
)
with gr.Column(scale=5, elem_classes=["hero-illustration"]):
gr.HTML("✦✧")
gr.Image(
value=str(PLACEHOLDER_IMAGE),
label="A future companion",
show_label=False,
interactive=False,
height=400,
elem_classes=["vintage-cat-image", "oval-image"],
)
gr.HTML("THE CAT IS LISTENING · 猫正在听
")
gr.HTML(page_footer(1, "一个关于人类和猫如何相遇的 Space"))
screens.append(screen_1)
with gr.Tab(
"Relationship card",
id=2,
elem_classes=["app-screen"],
) as screen_2:
gr.HTML(page_header(2, "抽一张猫咪关系卡"))
gr.HTML(
ornament_title(
"LET CHANCE SPEAK",
"先抽一张猫咪关系卡。",
"每一张卡,代表一种你和猫可能建立的关系。",
)
)
with gr.Row(elem_classes=["relationship-deck"]):
for card_class, label, desc in [
("quiet-card", "静谧之窗", "陪伴、安静、慢慢靠近"),
("door-card", "敞开的门", "好奇、自由、彼此尊重"),
("lap-card", "温暖的膝头", "亲密、日常、分享生活"),
]:
with gr.Column(elem_classes=["relationship-card", card_class]):
gr.HTML("✦
")
gr.Image(
value=str(PLACEHOLDER_IMAGE),
show_label=False,
interactive=False,
height=155,
elem_classes=["card-cat-image"],
)
gr.HTML(
f"{label}
{desc}
"
)
with gr.Row(elem_classes=["deck-actions"]):
draw_button = gr.Button(
"✦ 抽一张卡",
variant="primary",
elem_classes=["ink-button"],
)
card_continue_button = gr.Button(
"下一页 →",
interactive=False,
elem_classes=["paper-button"],
)
with gr.Row(elem_classes=["draw-result-row"]):
card_display = gr.Markdown(
"点击「抽一张卡」,看看哪一种关系正在等你。",
elem_classes=["drawn-card"],
)
gr.HTML(page_footer(2, "命运不会替你决定,但它会轻轻推开一扇门"))
screens.append(screen_2)
with gr.Tab(
"Your rhythm",
id=3,
elem_classes=["app-screen"],
) as screen_3:
gr.HTML(page_header(3, "问题 1 / 你的生活节奏"))
gr.HTML(
ornament_title(
"QUESTION ONE",
"你的生活节奏是?",
"选择最接近你真实日常的状态。",
)
)
with gr.Row(elem_classes=["question-layout"]):
with gr.Column(scale=5, elem_classes=["question-cat-panel"]):
gr.Image(
value=str(PLACEHOLDER_IMAGE),
show_label=False,
interactive=False,
height=350,
elem_classes=["vintage-cat-image", "cutout-cat"],
)
gr.HTML("☕
")
with gr.Column(scale=6, elem_classes=["choice-panel"]):
lifestyle_input = gr.Radio(
choices=LIFESTYLE_UI_CHOICES,
value=LIFESTYLE_CHOICES[0],
label="请选择一项",
elem_classes=["vintage-radio"],
)
with gr.Row(elem_classes=["nav-actions"]):
lifestyle_back_button = gr.Button(
"← 上一页",
elem_classes=["paper-button"],
)
lifestyle_continue_button = gr.Button(
"下一页 →",
variant="primary",
elem_classes=["ink-button"],
)
gr.HTML(page_footer(3, "生活节奏没有好坏,诚实才会带来合适的相遇"))
screens.append(screen_3)
with gr.Tab(
"Living together",
id=4,
elem_classes=["app-screen"],
) as screen_4:
gr.HTML(page_header(4, "问题 2 / 你想怎样和猫相处"))
gr.HTML(
ornament_title(
"QUESTION TWO",
"你最想怎么和它相处?",
"选择你期待的相处方式。",
)
)
with gr.Row(elem_classes=["question-layout", "relationship-question"]):
with gr.Column(scale=5, elem_classes=["question-cat-panel"]):
gr.Image(
value=str(PLACEHOLDER_IMAGE),
show_label=False,
interactive=False,
height=350,
elem_classes=["vintage-cat-image", "cutout-cat", "cat-two"],
)
gr.HTML("♧
")
with gr.Column(scale=6, elem_classes=["choice-panel"]):
relationship_input = gr.Radio(
choices=RELATIONSHIP_UI_CHOICES,
value=RELATIONSHIP_CHOICES[0],
label="请选择一项",
elem_classes=["vintage-radio"],
)
with gr.Row(elem_classes=["nav-actions"]):
relationship_back_button = gr.Button(
"← 上一页",
elem_classes=["paper-button"],
)
relationship_continue_button = gr.Button(
"开始倾听 →",
variant="primary",
elem_classes=["ink-button"],
)
gr.HTML(page_footer(4, "最好的关系,不是占有,而是彼此都能自在"))
screens.append(screen_4)
with gr.Tab(
"Matching",
id=5,
elem_classes=["app-screen"],
) as screen_5:
gr.HTML(page_header(5, "匹配中 / 我们正在听猫的声音"))
gr.HTML(
ornament_title(
"LISTENING...",
"匹配中…",
"我们正在听猫的声音,请稍等片刻。",
)
)
with gr.Row(elem_classes=["matching-stage"]):
with gr.Column(elem_classes=["matching-visual"]):
gr.Image(
value=str(PLACEHOLDER_IMAGE),
show_label=False,
interactive=False,
height=235,
elem_classes=["vintage-cat-image", "matching-cat"],
)
gr.HTML(
""
+ "".join("" for _ in range(42))
+ "
"
)
gr.HTML(
""
"
◷分析作息生活节奏
"
"
♧倾听性格相处期待
"
"
✿匹配频率关系模式
"
"
♪寻找回应猫的声音
"
"
"
)
with gr.Row(elem_classes=["matching-actions"]):
matching_back_button = gr.Button(
"← 回到问题",
elem_classes=["paper-button"],
)
reveal_match_button = gr.Button(
"听听是谁回应了你 →",
variant="primary",
size="lg",
elem_classes=["ink-button"],
)
gr.HTML(page_footer(5, "每只猫都在等待一个愿意听见它的人"))
screens.append(screen_5)
with gr.Tab(
"Cat story",
id=6,
elem_classes=["app-screen"],
) as screen_6:
gr.HTML(page_header(6, "猫咪故事页"))
gr.HTML(
ornament_title(
"A VOICE ANSWERED",
"让我告诉你我的故事。",
)
)
with gr.Row(elem_classes=["story-layout"]):
with gr.Column(scale=5, elem_classes=["polaroid-panel"]):
gr.HTML("⌇
")
story_image = gr.Image(
value=str(PLACEHOLDER_IMAGE),
label="Matched cat placeholder",
show_label=False,
interactive=False,
height=335,
elem_classes=["vintage-cat-image", "polaroid-image"],
)
gr.HTML("一张来自等待中的照片
")
with gr.Column(scale=7, elem_classes=["story-copy-panel"]):
story_heading = gr.Markdown(
"## 你的匹配正在靠近",
elem_classes=["story-heading"],
)
story_profile = gr.Markdown(
"猫咪的故事会在这里出现。",
elem_classes=["story-profile"],
)
gr.HTML(
""
"✦ 慢热但真诚⌂ 喜欢稳定空间"
"☾ 夜晚最放松❧ 等待一个家"
"
"
)
with gr.Row(elem_classes=["nav-actions"]):
story_back_button = gr.Button(
"← 重新倾听",
elem_classes=["paper-button"],
)
story_continue_button = gr.Button(
"下一页:见见我 →",
variant="primary",
elem_classes=["ink-button"],
)
gr.HTML(page_footer(6, "故事不是标签,它只是一次理解的开始"))
screens.append(screen_6)
with gr.Tab(
"Meet them",
id=7,
elem_classes=["app-screen"],
) as screen_7:
gr.HTML(page_header(7, "真实照片与领养邀请"))
with gr.Row(elem_classes=["final-layout"]):
with gr.Column(scale=7, elem_classes=["photo-collage"]):
gr.HTML(
"✦ ☾ ✧
"
)
collage_images = []
with gr.Row(elem_classes=["collage-row"]):
for photo_class in ["photo-left", "photo-center", "photo-right"]:
collage_image = gr.Image(
value=str(PLACEHOLDER_IMAGE),
label="Real cat photo placeholder",
show_label=False,
interactive=False,
height=315,
elem_classes=["vintage-cat-image", "collage-photo", photo_class],
)
collage_images.append(collage_image)
final_image = collage_images[1]
gr.HTML("三张真实照片 · 同一个正在等待的生命
")
with gr.Column(scale=5, elem_classes=["final-copy-panel"]):
gr.HTML("ONE LAST QUESTION")
final_heading = gr.Markdown(
"## 人类,你愿意跟我走吗?",
elem_classes=["final-heading"],
)
final_copy = gr.Markdown(
"给彼此一个机会,去开启新的故事。",
elem_classes=["final-copy"],
)
gr.Button(
"✤ 我愿意带它回家",
variant="primary",
link="https://www.huggingface.co/",
elem_classes=["ink-button", "cta-button"],
)
restart_button = gr.Button(
"再想想,换一只看看",
elem_classes=["paper-button"],
)
gr.HTML(page_footer(7, "领养代替购买 · 给等待一个真正的结局"))
screens.append(screen_7)
start_button.click(
fn=lambda: go_to_screen(2),
outputs=journey_tabs,
show_progress="hidden",
)
draw_button.click(
fn=draw_relationship_card,
outputs=[selected_card, card_display, card_continue_button],
show_progress="hidden",
)
card_continue_button.click(
fn=lambda: go_to_screen(3),
outputs=journey_tabs,
show_progress="hidden",
)
lifestyle_event = lifestyle_continue_button.click(
fn=lambda choice: save_choice(choice, LIFESTYLE_CHOICES[0]),
inputs=lifestyle_input,
outputs=selected_lifestyle,
show_progress="hidden",
)
lifestyle_event.then(
fn=lambda: go_to_screen(4),
outputs=journey_tabs,
show_progress="hidden",
)
lifestyle_back_button.click(
fn=lambda: go_to_screen(2),
outputs=journey_tabs,
show_progress="hidden",
)
relationship_event = relationship_continue_button.click(
fn=lambda choice: save_choice(choice, RELATIONSHIP_CHOICES[0]),
inputs=relationship_input,
outputs=selected_relationship,
show_progress="hidden",
)
relationship_event.then(
fn=lambda: go_to_screen(5),
outputs=journey_tabs,
show_progress="hidden",
)
relationship_back_button.click(
fn=lambda: go_to_screen(3),
outputs=journey_tabs,
show_progress="hidden",
)
match_event = reveal_match_button.click(
fn=build_match_result,
inputs=[
selected_card,
selected_lifestyle,
selected_relationship,
],
outputs=[
matched_cat,
story_heading,
story_profile,
],
show_progress="minimal",
)
match_event.then(
fn=lambda: go_to_screen(6),
outputs=journey_tabs,
show_progress="hidden",
)
matching_back_button.click(
fn=lambda: go_to_screen(4),
outputs=journey_tabs,
show_progress="hidden",
)
final_event = story_continue_button.click(
fn=show_final_cat,
inputs=matched_cat,
outputs=[
final_image,
final_heading,
final_copy,
],
show_progress="hidden",
)
final_event.then(
fn=lambda: go_to_screen(7),
outputs=journey_tabs,
show_progress="hidden",
)
story_back_button.click(
fn=lambda: go_to_screen(5),
outputs=journey_tabs,
show_progress="hidden",
)
restart_event = restart_button.click(
fn=reset_values,
outputs=[
selected_card,
selected_lifestyle,
selected_relationship,
matched_cat,
lifestyle_input,
relationship_input,
card_display,
card_continue_button,
],
show_progress="hidden",
)
restart_event.then(
fn=lambda: go_to_screen(1),
outputs=journey_tabs,
show_progress="hidden",
)
gpu_probe_button.click(
fn=generate_cat_image_placeholder,
inputs=gpu_probe_input,
outputs=gpu_probe_output,
show_progress="hidden",
)
if __name__ == "__main__":
demo.launch(
css_paths=[ROOT_DIR / "style.css"],
theme=gr.themes.Soft(),
ssr_mode=False,
)