BreadBuddy / app.py
CEO的小跟班
fix: downgrade Gradio to 5.50.0, fix app_file path
3933b9c
Raw
History Blame Contribute Delete
36.8 kB
"""
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>&copy; 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
)