Spaces:
Sleeping
Sleeping
| """ | |
| BreadBuddy — AI 面包诊所 (v5) | |
| 单 Tab 诊断流程,原型稿对齐 UI,追问交互 | |
| """ | |
| import os | |
| import sys | |
| import base64 | |
| import warnings | |
| import gradio as gr | |
| # Gradio 6.x 弃用警告(theme/css 已移到 launch()) | |
| warnings.filterwarnings("ignore", category=UserWarning, module="gradio") | |
| # ─── 将 deploy/ 目录加入 sys.path 以导入模块 ─── | |
| _deploy_dir = os.path.dirname(os.path.abspath(__file__)) | |
| if _deploy_dir not in sys.path: | |
| sys.path.insert(0, _deploy_dir) | |
| from config import API_URL, MODEL_NAME, API_KEY, DEFAULT_TIMEOUT | |
| print(f"[APP STARTUP] API_URL={API_URL} MODEL={MODEL_NAME} TIMEOUT={DEFAULT_TIMEOUT}") | |
| from llm import stream_llm, call_llm, _estimate_tokens | |
| from parsers import parse_diagnosis_result, render_structured_result, render_empty_hint | |
| from history import get_history, append_to_history, clear_history | |
| from i18n import t, get_system_prompt, get_followup_suffix, get_user_fallback, set_lang, LANG | |
| # ─── 诊断 System Prompt ─── | |
| DIAGNOSIS_SYSTEM_PROMPT = get_system_prompt() | |
| FOLLOWUP_SYSTEM_SUFFIX = get_followup_suffix() | |
| # ─── Handlers ─── | |
| def diagnose_bread(photo, description, temperature, humidity): | |
| """诊断面包问题 — 主 handler""" | |
| if not description and not photo: | |
| yield render_empty_hint(), "", "⏱️ --" | |
| return | |
| # 构建消息(新诊断不载入历史,追问才载入) | |
| messages = [{"role": "system", "content": DIAGNOSIS_SYSTEM_PROMPT}] | |
| user_text_parts = [] | |
| if description: | |
| user_text_parts.append(f"Problem: {description}") | |
| if temperature: | |
| user_text_parts.append(f"Temperature: {temperature}°C") | |
| if humidity: | |
| user_text_parts.append(f"Humidity: {humidity}%") | |
| user_text = "\n".join(user_text_parts) or "Please diagnose this bread photo" | |
| if photo: | |
| import os as _os | |
| _photo_size = _os.path.getsize(photo) if _os.path.exists(photo) else -1 | |
| print(f"[DIAG DEBUG] photo type={type(photo)} value={repr(photo)}") | |
| print(f"[DIAG DEBUG] file exists={_os.path.exists(photo)} size={_photo_size}") | |
| with open(photo, "rb") as f: | |
| img_bytes = f.read() | |
| img_b64 = base64.b64encode(img_bytes).decode() | |
| print(f"[DIAG] photo path={photo} size={_photo_size} b64_len={len(img_b64)}") | |
| # Check image dimensions | |
| try: | |
| from PIL import Image | |
| import io | |
| pil_img = Image.open(io.BytesIO(img_bytes)) | |
| print(f"[DIAG DEBUG] image dimensions={pil_img.size} format={pil_img.format} mode={pil_img.mode}") | |
| except Exception as e: | |
| print(f"[DIAG DEBUG] image parse error: {e}") | |
| messages.append({"role": "user", "content": [ | |
| {"type": "text", "text": user_text}, | |
| {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}} | |
| ]}) | |
| print(f"[DIAG] messages[-1] has image_url type + text, b64_preview={img_b64[:50]}...") | |
| else: | |
| messages.append({"role": "user", "content": user_text}) | |
| yield '<div class="bb-loading active"><div class="loading-spinner"></div><div class="loading-text">Connecting to AI baker...</div><div class="loading-hint">First diagnosis may take 30-60s to warm up</div></div>', "", "⏱️ --" | |
| final_content = "" | |
| chunk_count = 0 | |
| for display_content, reasoning, speed in stream_llm(messages): | |
| final_content = display_content | |
| chunk_count += 1 | |
| if chunk_count == 1: | |
| print(f"[DIAG] stream first: content_len={len(display_content or '')} reason_len={len(reasoning or '')} speed={speed}") | |
| # 只在 content 为空时用 reasoning 兜底(避免中英文混合导致 UI 跳动) | |
| combined = display_content | |
| if not display_content.strip() and reasoning: | |
| combined = reasoning | |
| structured = render_structured_result(combined, temperature or 25) | |
| yield structured, reasoning, speed | |
| print(f"[DIAG] stream done: chunks={chunk_count} final_content={len(final_content or '')} speed={speed}") | |
| # 记录历史(纯文本,不含图片数据) | |
| append_to_history( | |
| {"role": "user", "content": user_text}, | |
| {"role": "assistant", "content": final_content or ""} | |
| ) | |
| def followup_diagnosis(question, current_result): | |
| """追问 handler — 基于已有诊断结果继续对话""" | |
| if not question or not question.strip(): | |
| yield current_result or render_empty_hint(), "", "⏱️ --" | |
| return | |
| # 扩展 system prompt 含追问上下文 | |
| system = DIAGNOSIS_SYSTEM_PROMPT + FOLLOWUP_SYSTEM_SUFFIX | |
| messages = [{"role": "system", "content": system}] | |
| # 加入完整对话历史 | |
| for msg in get_history(): | |
| messages.append(msg) | |
| # 加入追问 | |
| messages.append({"role": "user", "content": question.strip()}) | |
| yield current_result or '<div class="bb-loading active"><div class="loading-spinner"></div><div class="loading-text">Thinking...</div></div>', "", "⏱️ --" | |
| final_content = "" | |
| for display_content, reasoning, speed in stream_llm(messages): | |
| final_content = display_content | |
| combined = display_content | |
| if reasoning: | |
| combined = display_content + "\n" + reasoning | |
| structured = render_structured_result(combined, 25) | |
| yield structured, reasoning, speed | |
| append_to_history( | |
| {"role": "user", "content": question.strip()}, | |
| {"role": "assistant", "content": final_content or ""} | |
| ) | |
| def do_rediagnose(): | |
| """重新诊断 — 清空历史,返回空状态""" | |
| clear_history() | |
| return render_empty_hint(), "", "⏱️ --" | |
| def do_clear(): | |
| """清空对话""" | |
| clear_history() | |
| return render_empty_hint(), "", "⏱️ --" | |
| # ─── Custom Theme ─── | |
| custom_theme = gr.themes.Base( | |
| primary_hue=gr.themes.colors.orange, | |
| secondary_hue=gr.themes.colors.amber, | |
| neutral_hue=gr.themes.colors.stone, | |
| font=gr.themes.GoogleFont("DM Sans"), | |
| font_mono=gr.themes.GoogleFont("JetBrains Mono"), | |
| ) | |
| # ─── Custom CSS(对齐原型稿设计系统)─── | |
| custom_css = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); | |
| /* ═══ Design Tokens ═══ */ | |
| :root { | |
| --primary: #D4A574; --primary-light: #E8C9A8; --secondary: #F5E6D3; | |
| --accent: #E8956A; --accent-hover: #D4804F; | |
| --text: #3E2723; --text-muted: #8D6E63; | |
| --bg: #FAF6F1; --bg-card: #FFFFFF; --bg-input: #FFFFFF; | |
| --border: #E8DDD2; | |
| --shadow: rgba(62,39,35,.08); --shadow-lg: rgba(62,39,35,.12); | |
| --chip-amber: #D4A574; --chip-brown: #8D6E63; --chip-red: #E8956A; | |
| --highlight-bg: #FFF8F0; --highlight-border: #E8956A; | |
| --radius-btn: 12px; --radius-card: 16px; --radius-input: 8px; | |
| --font-display: 'Playfair Display', Georgia, serif; | |
| --font-body: 'DM Sans', -apple-system, sans-serif; | |
| --font-mono: 'JetBrains Mono', monospace; | |
| --transition: .3s cubic-bezier(.4,0,.2,1); | |
| } | |
| .dark { | |
| --primary: #D4A574; --primary-light: #B8916A; --secondary: #2A2523; | |
| --accent: #E8956A; --accent-hover: #F0A880; | |
| --text: #F5E0C3; --text-muted: #A89383; | |
| --bg: #1A1715; --bg-card: #2A2523; --bg-input: #332E2B; | |
| --border: #3E3835; | |
| --shadow: rgba(0,0,0,.25); --shadow-lg: rgba(0,0,0,.35); | |
| --chip-amber: #D4A574; --chip-brown: #A89383; --chip-red: #E8956A; | |
| --highlight-bg: #33281F; --highlight-border: #E8956A; | |
| } | |
| /* Theme transition (class-based, removed after animation) */ | |
| .bb-theme-transitioning, | |
| .bb-theme-transitioning * { | |
| transition: background .3s ease, color .3s ease, | |
| border-color .3s ease, box-shadow .3s ease !important; | |
| } | |
| * { box-sizing: border-box; } | |
| body { font-family: var(--font-body); -webkit-font-smoothing: antialiased; } | |
| /* ═══ Gradio Container ═══ */ | |
| .gradio-container { | |
| font-family: var(--font-body) !important; | |
| max-width: 640px !important; margin: 0 auto !important; | |
| padding: 0 !important; min-height: 100vh !important; | |
| background: var(--bg) !important; | |
| } | |
| @media (max-width: 767px) { | |
| .gradio-container { padding: 0 !important; } | |
| } | |
| /* ═══ Header ═══ */ | |
| .bb-header { | |
| position: sticky; top: 0; z-index: 100; | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 16px 20px; max-height: 72px; | |
| background: var(--bg-card); border-bottom: 1px solid var(--border); | |
| backdrop-filter: blur(12px); | |
| } | |
| .bb-logo { | |
| font-family: var(--font-display); font-size: 1.35rem; font-weight: 700; | |
| color: var(--text); display: flex; align-items: center; gap: 8px; | |
| } | |
| .bb-theme-toggle { | |
| width: 48px; height: 28px; border-radius: 14px; | |
| border: 2px solid var(--border); background: var(--bg); | |
| cursor: pointer; position: relative; transition: all var(--transition); | |
| } | |
| .bb-theme-toggle::after { | |
| content: '☀️'; position: absolute; top: 2px; left: 2px; | |
| width: 20px; height: 20px; border-radius: 50%; | |
| background: var(--primary); display: flex; align-items: center; | |
| justify-content: center; font-size: 12px; | |
| transition: transform var(--transition), background var(--transition); | |
| } | |
| .dark .bb-theme-toggle::after { | |
| content: '🌙'; transform: translateX(20px); background: var(--accent); | |
| } | |
| /* ═══ Input Card ═══ */ | |
| .bb-input-wrap { | |
| padding: 24px 20px; background: var(--bg); | |
| } | |
| .bb-hero-emoji { font-size: 4rem; text-align: center; margin-bottom: 12px; animation: float 3s ease-in-out infinite; } | |
| @keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} } | |
| .bb-hero-text { | |
| font-family: var(--font-display); font-size: 1.4rem; font-weight: 600; | |
| text-align: center; color: var(--text); margin-bottom: 28px; line-height: 1.4; | |
| } | |
| /* Upload */ | |
| .bb-upload-area { | |
| border: 2px dashed var(--border); border-radius: var(--radius-card); | |
| padding: 32px 20px; text-align: center; cursor: pointer; | |
| transition: all var(--transition); margin-bottom: 20px; background: var(--bg-card); | |
| } | |
| .bb-upload-area:hover { border-color: var(--primary); background: var(--highlight-bg); } | |
| /* Presets */ | |
| .bb-presets { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } | |
| .bb-preset-btn { | |
| flex: 1; min-width: calc(50% - 4px); padding: 14px 16px; | |
| border: 1.5px solid var(--border); border-radius: var(--radius-btn); | |
| background: var(--bg-card); color: var(--text); | |
| font-family: var(--font-body); font-size: .9rem; font-weight: 500; | |
| cursor: pointer; transition: all var(--transition); | |
| display: flex; align-items: center; justify-content: center; gap: 4px; | |
| } | |
| .bb-preset-btn:hover { border-color: var(--primary); background: var(--highlight-bg); } | |
| .bb-preset-btn.active { border-color: var(--primary); background: var(--primary); color: #fff; font-weight: 600; } | |
| .dark .bb-preset-btn.active { color: #1A1715; } | |
| /* Slider — always visible for Custom mode */ | |
| .bb-slider-row { display: none; align-items: center; gap: 12px; margin-bottom: 16px; padding: 12px 16px; background: var(--bg-card); border-radius: var(--radius-input); border: 1px solid var(--border); } | |
| .bb-slider-row.visible { display: flex; } | |
| /* Diagnose Button */ | |
| .btn-diagnose { | |
| width: 100% !important; padding: 16px !important; | |
| border: none !important; border-radius: var(--radius-btn) !important; | |
| background: linear-gradient(135deg, var(--primary), var(--accent)) !important; | |
| color: #fff !important; font-family: var(--font-body) !important; | |
| font-size: 1.05rem !important; font-weight: 700 !important; | |
| cursor: pointer !important; transition: all var(--transition) !important; | |
| margin-top: 8px !important; box-shadow: 0 4px 16px rgba(212,165,116,.3) !important; | |
| letter-spacing: .02em !important; | |
| } | |
| .btn-diagnose:hover { transform: translateY(-2px) !important; box-shadow: 0 6px 24px rgba(212,165,116,.4) !important; } | |
| /* ═══ Result Card ═══ */ | |
| .result-card { | |
| background: var(--bg-card); border-radius: 12px; | |
| border: 1px solid rgba(255,255,255,0.06); overflow: visible; | |
| box-shadow: 0 4px 20px var(--shadow); transition: all var(--transition); | |
| animation: slideUp .4s ease-out; | |
| } | |
| .dark .result-card { | |
| background-color: #1A1512; | |
| border: 1px solid rgba(255,255,255,0.06); | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.4); | |
| } | |
| .card-section { padding: 24px; border-bottom: 1px solid var(--border); } | |
| .card-section:last-child { border-bottom: none; } | |
| .section-title { | |
| font-family: var(--font-display); font-size: 1rem; font-weight: 600; | |
| color: var(--text); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; | |
| } | |
| /* ─── Diagnostic Cards (vertical layout) ─── */ | |
| .diag-cards { display: flex; flex-direction: column; gap: 14px; } | |
| .diag-card { | |
| padding: 18px 20px; border-radius: 8px; | |
| background: var(--bg-card); border: 1px solid var(--border); | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.06); | |
| position: relative; height: auto; | |
| animation: fixSlide .4s ease both; | |
| } | |
| .dark .diag-card { | |
| background: #1E1A17; border: 1px solid rgba(255,255,255,0.06); | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.4); | |
| } | |
| .diag-card.severe { border-left: 4px solid #E28755; } | |
| .diag-card.moderate { border-left: 4px solid #C69963; } | |
| .diag-card.mild { border-left: 4px solid #8D6E63; } | |
| .diag-card:nth-child(1) { animation-delay: .1s; } | |
| .diag-card:nth-child(2) { animation-delay: .2s; } | |
| .diag-card:nth-child(3) { animation-delay: .3s; } | |
| .diag-card-header { | |
| display: flex; align-items: center; justify-content: space-between; | |
| gap: 10px; margin-bottom: 6px; | |
| } | |
| .diag-card-title { | |
| font-size: 16px; font-weight: 600; color: var(--text); line-height: 1.3; | |
| } | |
| .diag-card-desc { | |
| font-size: 14px; font-weight: 400; line-height: 1.5; | |
| color: rgba(0,0,0,0.55); | |
| } | |
| .dark .diag-card-desc { | |
| color: rgba(255,255,255,0.7); | |
| } | |
| /* ─── Severity Badges (low-saturation bg + high-saturation text) ─── */ | |
| .badge-severe, .badge-moderate, .badge-mild { | |
| padding: 3px 8px; font-size: 11px; font-weight: 600; | |
| text-transform: uppercase; border-radius: 4px; | |
| white-space: nowrap; flex-shrink: 0; letter-spacing: .03em; | |
| } | |
| .badge-severe { | |
| background-color: rgba(226,135,85,0.15); color: #FF9E64; | |
| } | |
| .badge-moderate { | |
| background-color: rgba(198,153,99,0.15); color: #E2B378; | |
| } | |
| .badge-mild { | |
| background-color: rgba(141,110,99,0.12); color: #A89383; | |
| } | |
| /* Legacy chip styles (kept for backward compat) */ | |
| .cause-chips { display: flex; flex-wrap: wrap; gap: 10px; } | |
| .cause-chip { | |
| display: flex; align-items: center; gap: 8px; padding: 8px 14px; | |
| border-radius: 20px; background: var(--bg); border: 1px solid var(--border); | |
| font-size: .95rem; font-weight: 500; color: var(--text); | |
| animation: chipIn .4s ease both; | |
| } | |
| .cause-chip.severe-chip { border-left: 3px solid var(--chip-red); } | |
| .cause-chip:nth-child(1) { animation-delay: .1s; } | |
| .cause-chip:nth-child(2) { animation-delay: .2s; } | |
| .cause-chip:nth-child(3) { animation-delay: .3s; } | |
| @keyframes chipIn { from{opacity:0;transform:scale(.8)} to{opacity:1;transform:scale(1)} } | |
| .chip-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } | |
| .chip-dot.severe { background: var(--chip-red); } | |
| .chip-dot.moderate { background: var(--chip-amber); } | |
| .chip-dot.mild { background: var(--chip-brown); } | |
| .chip-severity { font-size: .72rem; font-weight: 700; padding: 2px 6px; border-radius: 4px; margin-left: 2px; } | |
| .chip-severity.severe { background: #FDEAE2; color: #C0593A; } | |
| .chip-severity.moderate { background: #FBF0E0; color: #9A7B4F; } | |
| .chip-severity.mild { background: #F0EDE8; color: #7A6E6B; } | |
| .dark .chip-severity.severe { background: #3E2518; color: #F0A880; } | |
| .dark .chip-severity.moderate { background: #332B1F; color: #D4A574; } | |
| .dark .chip-severity.mild { background: #2A2523; color: #B8A898; } | |
| /* Fix List */ | |
| .fix-list { list-style: none; counter-reset: fix; padding: 0; margin: 0; } | |
| .fix-item { | |
| counter-increment: fix; padding: 10px 12px; margin-bottom: 8px; | |
| border-radius: var(--radius-input); background: var(--bg); | |
| border: 1px solid var(--border); font-size: .9rem; line-height: 1.5; | |
| display: flex; align-items: flex-start; gap: 10px; | |
| animation: fixSlide .4s ease both; | |
| } | |
| .fix-item:nth-child(1) { animation-delay: .15s; } | |
| .fix-item:nth-child(2) { animation-delay: .2s; } | |
| .fix-item:nth-child(3) { animation-delay: .25s; } | |
| .fix-item:nth-child(4) { animation-delay: .3s; } | |
| .fix-item:nth-child(5) { animation-delay: .35s; } | |
| @keyframes fixSlide { from{opacity:0;transform:translateX(-12px)} to{opacity:1;transform:translateX(0)} } | |
| .fix-item::before { | |
| content: counter(fix); font-family: var(--font-mono); font-size: .78rem; font-weight: 600; | |
| color: var(--primary); background: var(--highlight-bg); | |
| width: 24px; height: 24px; border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; | |
| } | |
| .fix-tag { font-size: .72rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; white-space: nowrap; flex-shrink: 0; margin-top: 2px; } | |
| .fix-tag.fix { background: #E8F5E9; color: #2E7D32; } | |
| .fix-tag.ferment { background: #FFF3E0; color: #E65100; } | |
| .dark .fix-tag.fix { background: #1B3A1B; color: #81C784; } | |
| .dark .fix-tag.ferment { background: #3E2A1A; color: #FFB74D; } | |
| .fix-text { flex: 1; } | |
| .fix-item.new-item { border-color: var(--highlight-border); background: var(--highlight-bg); } | |
| /* Recipe Accordion */ | |
| .recipe-accordion { border: 1px solid var(--border); border-radius: var(--radius-input); overflow: hidden; background: var(--bg); } | |
| .recipe-header { | |
| padding: 14px 16px; display: flex; align-items: center; | |
| justify-content: space-between; cursor: pointer; | |
| font-size: .9rem; font-weight: 600; color: var(--text); | |
| transition: background var(--transition); user-select: none; | |
| } | |
| .recipe-header:hover { background: var(--highlight-bg); } | |
| .recipe-arrow { font-size: .8rem; color: var(--text-muted); transition: transform var(--transition); } | |
| .recipe-accordion.open .recipe-arrow { transform: rotate(180deg); } | |
| .recipe-list { max-height: 0; overflow: hidden; transition: max-height .4s ease; } | |
| .recipe-accordion.open .recipe-list { max-height: 500px; } | |
| .recipe-item { padding: 12px 16px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 10px; font-size: .88rem; } | |
| .recipe-emoji { font-size: 1.2rem; } | |
| .recipe-name { font-weight: 600; color: var(--text); } | |
| .recipe-desc { font-size: .8rem; color: var(--text-muted); } | |
| /* Stats */ | |
| .stats-bar { | |
| padding: 14px 20px; text-align: center; | |
| font-family: var(--font-mono); font-size: .78rem; | |
| color: var(--text-muted); display: flex; justify-content: center; gap: 16px; | |
| } | |
| /* Follow-up — HIDDEN */ | |
| .bb-followup-banner { display: none !important; } | |
| .bb-followup-wrap { display: none !important; } | |
| #bb-followup-row { display: none !important; } | |
| /* Preset Questions */ | |
| .bb-preset-questions { | |
| display: flex; gap: 6px; flex-wrap: wrap; | |
| padding: 0 16px 8px; max-width: 640px; margin: 0 auto; | |
| } | |
| .bb-preset-q { | |
| padding: 6px 14px; border: 1px solid var(--border); border-radius: 16px; | |
| background: var(--bg-card); color: var(--text-muted); font-size: .78rem; | |
| font-family: var(--font-body); cursor: pointer; | |
| transition: all var(--transition); white-space: nowrap; | |
| } | |
| .bb-preset-q:hover { | |
| border-color: var(--primary); color: var(--text); | |
| background: var(--highlight-bg); | |
| } | |
| /* Rediagnose Button */ | |
| .btn-rediagnose { | |
| border: 1px solid var(--border) !important; border-radius: var(--radius-btn) !important; | |
| background: var(--bg-card) !important; color: var(--text) !important; | |
| font-family: var(--font-body) !important; font-size: .85rem !important; | |
| font-weight: 500 !important; cursor: pointer !important; | |
| padding: 8px 16px !important; transition: all var(--transition) !important; | |
| } | |
| .btn-rediagnose:hover { border-color: var(--primary) !important; background: var(--highlight-bg) !important; } | |
| /* ═══ Speed Display ═══ */ | |
| .speed-display { | |
| font-family: var(--font-mono); font-size: .78rem; | |
| color: var(--text-muted); padding: 4px 8px; | |
| } | |
| /* ═══ Loading State ═══ */ | |
| .bb-loading { | |
| display: none; flex-direction: column; align-items: center; | |
| justify-content: center; padding: 60px 20px; gap: 20px; | |
| animation: fadeIn .3s ease; | |
| } | |
| .bb-loading.active { display: flex; } | |
| .loading-spinner { | |
| width: 40px; height: 40px; | |
| border: 3px solid var(--border); border-top-color: var(--primary); | |
| border-radius: 50%; animation: spin .8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .loading-text { font-family: var(--font-body); font-size: .95rem; color: var(--text); font-weight: 500; } | |
| .loading-hint { font-size: .78rem; color: var(--text-muted); opacity: .7; } | |
| /* ═══ Animations ═══ */ | |
| @keyframes slideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} } | |
| @keyframes fadeIn { from{opacity:0} to{opacity:1} } | |
| /* ═══ Footer ═══ */ | |
| .bb-footer { | |
| text-align: center; padding: 20px; color: var(--text-muted); | |
| font-size: 12px; margin-top: 32px !important; line-height: 1.6; | |
| } | |
| .bb-footer p { margin: 0; } | |
| .bb-footer a { color: var(--text-muted); text-decoration: none; } | |
| .bb-footer a:hover { color: var(--primary); text-decoration: underline; } | |
| /* ═══ Responsive ═══ */ | |
| /* Small phone: <480px */ | |
| @media (max-width: 479px) { | |
| .bb-header { padding: 12px 14px; max-height: 60px; } | |
| .bb-logo { font-size: 1.1rem; } | |
| .bb-hero-emoji { font-size: 3rem; } | |
| .bb-hero-text { font-size: 1.1rem; margin-bottom: 20px; } | |
| .bb-input-wrap { padding: 20px 16px; } | |
| .bb-upload-area { padding: 24px 16px; } | |
| .bb-presets { flex-direction: column; gap: 6px; } | |
| .bb-preset-btn { min-width: 100%; padding: 12px; font-size: .85rem; } | |
| .result-card { border-radius: 12px; } | |
| .card-section { padding: 16px; } | |
| .btn-diagnose { padding: 14px !important; font-size: .95rem !important; } | |
| .bb-followup-wrap { padding: 10px 12px; } | |
| } | |
| /* Large phone / small tablet: 480-767px */ | |
| @media (min-width: 480px) and (max-width: 767px) { | |
| .bb-preset-btn { min-width: calc(50% - 5px); } | |
| .bb-hero-text { font-size: 1.2rem; } | |
| } | |
| /* Desktop: 768px+ */ | |
| @media (min-width: 768px) { | |
| .bb-input-wrap { padding: 40px 24px 60px; } | |
| .bb-hero-text { font-size: 1.6rem; } | |
| .bb-presets { gap: 10px; } | |
| .bb-preset-btn { min-width: calc(25% - 8px); } | |
| } | |
| """ | |
| # ─── Custom JS(Gradio 5.x 用 new AsyncFunction 包装,不能写 IIFE)─── | |
| custom_js = """ | |
| /* ═══ Dark Mode — apply theme immediately ═══ */ | |
| var saved = localStorage.getItem('bb-theme'); | |
| var initTheme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); | |
| document.body.classList.toggle('dark', initTheme === 'dark'); | |
| document.body.setAttribute('data-theme', initTheme); | |
| /* ═══ Document-level event delegation ═══ */ | |
| document.addEventListener('click', function(e) { | |
| /* ── Theme Toggle ── */ | |
| var langSpan = e.target.closest('.bb-lang'); | |
| if (langSpan) { | |
| var next = langSpan.textContent === 'EN' ? '中文' : 'EN'; | |
| langSpan.textContent = next; | |
| location.reload(); | |
| return; | |
| } | |
| var toggleBtn = e.target.closest('.bb-theme-toggle, #themeToggle, .theme-toggle'); | |
| if (toggleBtn) { | |
| document.body.classList.add('bb-theme-transitioning'); | |
| var isDark = document.body.classList.toggle('dark'); | |
| var newTheme = isDark ? 'dark' : 'light'; | |
| document.body.setAttribute('data-theme', newTheme); | |
| localStorage.setItem('bb-theme', newTheme); | |
| setTimeout(function() { | |
| document.body.classList.remove('bb-theme-transitioning'); | |
| }, 350); | |
| return; | |
| } | |
| /* ── Preset Buttons ── */ | |
| var presetBtn = e.target.closest('.bb-preset-btn'); | |
| if (presetBtn) { | |
| var presets = document.getElementById('bb-presets'); | |
| if (!presets) return; | |
| if (presetBtn.classList.contains('active')) { | |
| presetBtn.classList.remove('active'); | |
| var sr = document.getElementById('bb-slider-row'); | |
| if (sr) sr.classList.remove('visible'); | |
| return; | |
| } | |
| presets.querySelectorAll('.bb-preset-btn').forEach(function(b) { b.classList.remove('active'); }); | |
| presetBtn.classList.add('active'); | |
| var preset = presetBtn.dataset.preset; | |
| var sr2 = document.getElementById('bb-slider-row'); | |
| if (preset === 'custom') { | |
| if (sr2) sr2.classList.add('visible'); | |
| } else { | |
| if (sr2) sr2.classList.remove('visible'); | |
| var t = parseInt(presetBtn.dataset.temp); | |
| var h = parseInt(presetBtn.dataset.humid); | |
| var ts = document.querySelector('#temp-slider input[type=range]'); | |
| var hs = document.querySelector('#humid-slider input[type=range]'); | |
| if (ts) { ts.value = t; ts.dispatchEvent(new Event('input', {bubbles:true})); } | |
| if (hs) { hs.value = h; hs.dispatchEvent(new Event('input', {bubbles:true})); } | |
| } | |
| return; | |
| } | |
| /* ── Recipe Accordion ── */ | |
| var recipeHdr = e.target.closest('.recipe-header, #recipeHeader'); | |
| if (recipeHdr) { | |
| var acc = document.getElementById('recipeAccordion'); | |
| if (!acc) return; | |
| acc.classList.toggle('open'); | |
| var span = recipeHdr.querySelector('span:first-child'); | |
| if (span) { | |
| var count = span.textContent.match(/\\d+/); | |
| var n = count ? count[0] : ''; | |
| span.textContent = acc.classList.contains('open') | |
| ? n + ' recipe' + (n !== '1' ? 's' : '') + ' · Click to collapse' | |
| : n + ' recipe' + (n !== '1' ? 's' : '') + ' · Click to expand'; | |
| } | |
| return; | |
| } | |
| }); | |
| /* ═══ Followup Banner Auto-show ═══ */ | |
| function bbInitFollowup() { | |
| var resultWrap = document.getElementById('bb-result-wrap'); | |
| var banner = document.getElementById('followupBanner'); | |
| if (!resultWrap || !banner || banner._bbBound) return; | |
| banner._bbBound = true; | |
| var observer = new MutationObserver(function() { | |
| if (banner.classList.contains('visible')) return; | |
| var card = resultWrap.querySelector('.result-card'); | |
| if (card) { | |
| var chips = card.querySelectorAll('.cause-chip'); | |
| if (chips.length > 0) banner.classList.add('visible'); | |
| } | |
| }); | |
| observer.observe(resultWrap, { childList: true, subtree: true }); | |
| } | |
| var _bbObs = new MutationObserver(function() { bbInitFollowup(); }); | |
| _bbObs.observe(document.body, { childList: true, subtree: true }); | |
| bbInitFollowup(); | |
| """ | |
| # ─── UI ─── | |
| with gr.Blocks(title="BreadBuddy", theme=custom_theme, css=custom_css) as demo: | |
| # Header | |
| gr.HTML(""" | |
| <div class="bb-header"> | |
| <div class="bb-logo">BreadBuddy</div> | |
| <span class="bb-lang" id="bbLang" style="font-size:.8rem;color:var(--text-muted);margin-right:12px;cursor:pointer" title="Switch language">EN</span><button class="bb-theme-toggle theme-toggle" id="themeToggle" aria-label="切换暗色模式"></button> | |
| </div> | |
| """) | |
| # 输入区 | |
| with gr.Column(elem_classes=["bb-input-wrap", "bb-input-card"], elem_id="stateEmpty"): | |
| gr.HTML('<div class="bb-hero-emoji">🍞</div>') | |
| gr.HTML('<div class="bb-hero-text">Snap a photo, let me check your bread</div>') | |
| photo_in = gr.Image( | |
| type="filepath", sources=["upload", "webcam"], | |
| label="📷 Tap to take a photo or upload", | |
| height=200, | |
| ) | |
| desc_in = gr.Textbox( | |
| label="", show_label=False, | |
| placeholder="Describe your bread problem... (optional)", | |
| lines=1, max_lines=3, | |
| elem_id="descInput", | |
| elem_classes=["desc-input"], | |
| ) | |
| # 温湿度预设 | |
| gr.HTML(""" | |
| <label style="font-size:.85rem;font-weight:600;color:var(--text-muted);margin-bottom:6px;display:block">Environment Presets</label> | |
| <div class="bb-presets" id="bb-presets"> | |
| <button class="bb-preset-btn" data-preset="winter" data-temp="10" data-humid="30">❄️ Winter 5-15°C</button> | |
| <button class="bb-preset-btn" data-preset="spring" data-temp="20" data-humid="55">🌸 Spring 15-25°C</button> | |
| <button class="bb-preset-btn" data-preset="summer" data-temp="30" data-humid="75">☀️ Summer 25-35°C</button> | |
| <button class="bb-preset-btn" data-preset="custom">⚙️ Custom</button> | |
| </div> | |
| <div class="bb-slider-row" id="bb-slider-row"> | |
| <label style="font-size:.82rem;font-weight:600;color:var(--text-muted)">🌡️</label> | |
| </div> | |
| """) | |
| temp_in = gr.Slider(0, 50, value=20, label="Temperature (°C)", elem_id="temp-slider") | |
| humid_in = gr.Slider(0, 100, value=50, label="Humidity (%)", elem_id="humid-slider") | |
| btn_diagnose = gr.Button("Start Diagnosis", variant="primary", elem_classes=["btn-diagnose"], elem_id="btnDiagnose") | |
| # 结果区 | |
| with gr.Column(elem_id="bb-result-wrap"): | |
| speed_out = gr.Markdown("⏱️ --", elem_classes=["speed-display"]) | |
| result_out = gr.HTML(render_empty_hint()) | |
| with gr.Accordion("🧠 Model Thinking (optional)", open=False): | |
| reason_out = gr.Textbox(label="", lines=6, show_label=False) | |
| # 操作按钮行 | |
| with gr.Row(): | |
| btn_rediagnose = gr.Button("← Restart", elem_classes=["btn-rediagnose"]) | |
| # 追问区(底部固定) | |
| with gr.Column(elem_id="bb-followup-row"): | |
| gr.HTML('<div class="followup-banner bb-followup-banner" id="followupBanner"><span class="followup-label">🧠 Follow-up context: </span><span class="followup-text" id="followupText"></span></div>') | |
| gr.HTML(''' | |
| <div class="bb-preset-questions" id="presetQuestions"> | |
| <button class="bb-preset-q" data-q="Can you explain the first cause in detail?">🔍 Details</button> | |
| <button class="bb-preset-q" data-q="How should I adjust fermentation time?">⏱️ Fermentation</button> | |
| <button class="bb-preset-q" data-q="Is there a simpler recipe?">📖 Simple Recipe</button> | |
| </div> | |
| ''') | |
| with gr.Row(): | |
| followup_in = gr.Textbox( | |
| label="", show_label=False, | |
| placeholder="Ask a follow-up... e.g. Can you explain the first cause?", | |
| lines=1, scale=5, | |
| elem_id="followupInput", | |
| ) | |
| btn_followup = gr.Button("➤", variant="primary", scale=1, elem_id="btnSend", elem_classes=["btn-send"]) | |
| # ─── 事件绑定 ─── | |
| btn_diagnose.click( | |
| diagnose_bread, | |
| [photo_in, desc_in, temp_in, humid_in], | |
| [result_out, reason_out, speed_out], | |
| ) | |
| btn_followup.click( | |
| followup_diagnosis, | |
| [followup_in, result_out], | |
| [result_out, reason_out, speed_out], | |
| ).then( | |
| lambda: "", # 清空追问输入框 | |
| outputs=[followup_in], | |
| ) | |
| # Enter 键发送追问 | |
| followup_in.submit( | |
| followup_diagnosis, | |
| [followup_in, result_out], | |
| [result_out, reason_out, speed_out], | |
| ).then( | |
| lambda: "", | |
| outputs=[followup_in], | |
| ) | |
| # 重新诊断 | |
| btn_rediagnose.click( | |
| do_rediagnose, | |
| outputs=[result_out, reason_out, speed_out], | |
| ) | |
| # Footer | |
| gr.HTML(""" | |
| <div class="bb-footer"> | |
| <p>© 2026 <a href="https://github.com/the-rockstone-games" target="_blank" rel="noopener">Rockstone Games</a>. All rights reserved.</p> | |
| </div> | |
| """) | |
| # ─── JS 注入: 通过 demo.load() 在页面加载时执行 ─── | |
| _bb_js = """(function() { | |
| var saved = localStorage.getItem('bb-theme'); | |
| var initTheme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); | |
| document.body.classList.toggle('dark', initTheme === 'dark'); | |
| document.body.setAttribute('data-theme', initTheme); | |
| document.addEventListener('click', function(e) { | |
| var langSpan = e.target.closest('.bb-lang'); | |
| if (langSpan) { | |
| var next = langSpan.textContent === 'EN' ? '中文' : 'EN'; | |
| langSpan.textContent = next; | |
| location.reload(); | |
| return; | |
| } | |
| var toggleBtn = e.target.closest('.bb-theme-toggle, #themeToggle, .theme-toggle'); | |
| if (toggleBtn) { | |
| document.body.classList.add('bb-theme-transitioning'); | |
| var isDark = document.body.classList.toggle('dark'); | |
| var newTheme = isDark ? 'dark' : 'light'; | |
| document.body.setAttribute('data-theme', newTheme); | |
| localStorage.setItem('bb-theme', newTheme); | |
| setTimeout(function() { | |
| document.body.classList.remove('bb-theme-transitioning'); | |
| }, 350); | |
| return; | |
| } | |
| var presetBtn = e.target.closest('.bb-preset-btn'); | |
| if (presetBtn) { | |
| var presets = document.getElementById('bb-presets'); | |
| if (!presets) return; | |
| if (presetBtn.classList.contains('active')) { | |
| presetBtn.classList.remove('active'); | |
| var sr = document.getElementById('bb-slider-row'); | |
| if (sr) sr.classList.remove('visible'); | |
| return; | |
| } | |
| presets.querySelectorAll('.bb-preset-btn').forEach(function(b) { b.classList.remove('active'); }); | |
| presetBtn.classList.add('active'); | |
| var preset = presetBtn.dataset.preset; | |
| var sr2 = document.getElementById('bb-slider-row'); | |
| if (preset === 'custom') { | |
| if (sr2) sr2.classList.add('visible'); | |
| } else { | |
| if (sr2) sr2.classList.remove('visible'); | |
| var t = parseInt(presetBtn.dataset.temp); | |
| var h = parseInt(presetBtn.dataset.humid); | |
| var ts = document.querySelector('#temp-slider input[type=range]'); | |
| var hs = document.querySelector('#humid-slider input[type=range]'); | |
| if (ts) { ts.value = t; ts.dispatchEvent(new Event('input', {bubbles:true})); } | |
| if (hs) { hs.value = h; hs.dispatchEvent(new Event('input', {bubbles:true})); } | |
| } | |
| return; | |
| } | |
| /* ── Preset Questions ── */ | |
| var presetQ = e.target.closest('.bb-preset-q'); | |
| if (presetQ) { | |
| var input = document.querySelector('#followupInput textarea, #followupInput input'); | |
| if (input) { | |
| var nativeSetter = Object.getOwnPropertyDescriptor( | |
| window.HTMLTextAreaElement.prototype, 'value' | |
| ).set; | |
| nativeSetter.call(input, presetQ.dataset.q); | |
| input.dispatchEvent(new Event('input', { bubbles: true })); | |
| input.focus(); | |
| } | |
| return; | |
| } | |
| var recipeHdr = e.target.closest('.recipe-header, #recipeHeader'); | |
| if (recipeHdr) { | |
| var acc = document.getElementById('recipeAccordion'); | |
| if (!acc) return; | |
| acc.classList.toggle('open'); | |
| var span = recipeHdr.querySelector('span:first-child'); | |
| if (span) { | |
| var count = span.textContent.match(/\\d+/); | |
| var n = count ? count[0] : ''; | |
| span.textContent = acc.classList.contains('open') | |
| ? '\U0001f35e 推荐 ' + n + ' 个配方 · 点击收起' | |
| : '\U0001f35e 推荐 ' + n + ' 个配方 · 点击展开'; | |
| } | |
| return; | |
| } | |
| }); | |
| function bbInitFollowup() { | |
| var resultWrap = document.getElementById('bb-result-wrap'); | |
| var banner = document.getElementById('followupBanner'); | |
| if (!resultWrap || !banner || banner._bbBound) return; | |
| banner._bbBound = true; | |
| var observer = new MutationObserver(function() { | |
| if (banner.classList.contains('visible')) return; | |
| var card = resultWrap.querySelector('.result-card'); | |
| if (card && card.querySelectorAll('.cause-chip').length > 0) { | |
| banner.classList.add('visible'); | |
| } | |
| }); | |
| observer.observe(resultWrap, { childList: true, subtree: true }); | |
| } | |
| var _obs = new MutationObserver(function() { bbInitFollowup(); }); | |
| _obs.observe(document.body, { childList: true, subtree: true }); | |
| bbInitFollowup(); | |
| /* ═══ Virtual keyboard fix (mobile) ═══ */ | |
| if (window.visualViewport) { | |
| var followupWrap = document.querySelector('.bb-followup-wrap'); | |
| var rafId = null; | |
| var adjustViewport = function() { | |
| if (rafId) return; | |
| rafId = requestAnimationFrame(function() { | |
| rafId = null; | |
| if (followupWrap) { | |
| var diff = window.innerHeight - window.visualViewport.height; | |
| followupWrap.style.bottom = Math.max(diff, 0) + 'px'; | |
| } | |
| }); | |
| }; | |
| window.visualViewport.addEventListener('resize', adjustViewport); | |
| window.visualViewport.addEventListener('scroll', adjustViewport); | |
| if (followupWrap) { | |
| followupWrap.addEventListener('focusout', function() { | |
| setTimeout(function() { | |
| followupWrap.style.bottom = '0px'; | |
| }, 350); | |
| }); | |
| } | |
| } | |
| return null; | |
| })()""" | |
| demo.load(fn=None, inputs=None, outputs=None, js=_bb_js) | |
| if __name__ == "__main__": | |
| print(f"[STARTUP] BreadBuddy starting on 0.0.0.0:7860") | |
| print(f"[STARTUP] API_URL={API_URL}") | |
| print(f"[STARTUP] Language: {LANG}") | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| share=False | |
| ) | |