""" 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 '
Connecting to AI baker...
First diagnosis may take 30-60s to warm up
', "", "⏱️ --" 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 '
Thinking...
', "", "⏱️ --" 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("""
EN
""") # 输入区 with gr.Column(elem_classes=["bb-input-wrap", "bb-input-card"], elem_id="stateEmpty"): gr.HTML('
🍞
') gr.HTML('
Snap a photo, let me check your bread
') 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("""
""") 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('
🧠 Follow-up context:
') gr.HTML('''
''') 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(""" """) # ─── 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 )