| 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 ( |
| "<div class='vintage-header'>" |
| "<span class='header-paw'>✣</span>" |
| "<span class='header-brand'>BUILD SMALL HACKATHON</span>" |
| "<span class='header-divider'>·</span>" |
| f"<span class='header-chapter'>{title}</span>" |
| f"<span class='header-step'>0{step} / 07</span>" |
| "<span class='header-moon'>☾</span>" |
| f"</div>" |
| ) |
|
|
|
|
| def page_footer(step: int, note: str) -> str: |
| return ( |
| "<div class='vintage-footer'>" |
| "<span>❧</span>" |
| f"<span>第 {step} 页 · {note}</span>" |
| "<span>✦</span>" |
| "</div>" |
| ) |
|
|
|
|
| def ornament_title(kicker: str, title: str, subtitle: str = "") -> str: |
| subtitle_html = f"<p>{subtitle}</p>" if subtitle else "" |
| return ( |
| "<div class='ornament-title'>" |
| f"<span>{kicker}</span><h1>{title}</h1>{subtitle_html}" |
| "</div>" |
| ) |
|
|
|
|
| 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", |
| "也许,今晚<br>会有一只猫选中你。", |
| "先别急着去寻找。<br>这一次,让猫来决定。", |
| ) |
| ) |
| start_button = gr.Button( |
| "✤ 开始抽一张猫咪关系卡", |
| variant="primary", |
| size="lg", |
| elem_classes=["ink-button"], |
| ) |
| with gr.Column(scale=5, elem_classes=["hero-illustration"]): |
| gr.HTML("<span class='sparkle s1'>✦</span><span class='sparkle s2'>✧</span>") |
| 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("<div class='hero-caption'>THE CAT IS LISTENING · 猫正在听</div>") |
| 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("<div class='card-corner'>✦</div>") |
| gr.Image( |
| value=str(PLACEHOLDER_IMAGE), |
| show_label=False, |
| interactive=False, |
| height=155, |
| elem_classes=["card-cat-image"], |
| ) |
| gr.HTML( |
| f"<h3>{label}</h3><p>{desc}</p>" |
| ) |
| 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("<div class='cat-prop'>☕</div>") |
| 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("<div class='cat-prop paw-prop'>♧</div>") |
| 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( |
| "<div class='sound-wave'>" |
| + "".join("<span></span>" for _ in range(42)) |
| + "</div>" |
| ) |
| gr.HTML( |
| "<div class='match-metrics'>" |
| "<div><b>◷</b><strong>分析作息</strong><span>生活节奏</span></div>" |
| "<div><b>♧</b><strong>倾听性格</strong><span>相处期待</span></div>" |
| "<div><b>✿</b><strong>匹配频率</strong><span>关系模式</span></div>" |
| "<div><b>♪</b><strong>寻找回应</strong><span>猫的声音</span></div>" |
| "</div>" |
| ) |
| 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("<div class='paperclip'>⌇</div>") |
| 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("<div class='polaroid-note'>一张来自等待中的照片</div>") |
| 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( |
| "<div class='story-traits'>" |
| "<span>✦ 慢热但真诚</span><span>⌂ 喜欢稳定空间</span>" |
| "<span>☾ 夜晚最放松</span><span>❧ 等待一个家</span>" |
| "</div>" |
| ) |
| 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( |
| "<div class='collage-stars'>✦ ☾ ✧</div>" |
| ) |
| 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("<div class='collage-caption'>三张真实照片 · 同一个正在等待的生命</div>") |
| with gr.Column(scale=5, elem_classes=["final-copy-panel"]): |
| gr.HTML("<span class='final-kicker'>ONE LAST QUESTION</span>") |
| 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, |
| ) |
|
|