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 ( "
" "" "BUILD SMALL HACKATHON" "·" f"{title}" f"0{step} / 07" "" f"
" ) 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, )