Spaces:
Running
Running
Commit ·
e494200
0
Parent(s):
deploy: sync with GitHub 3cfa37b — English UI, card style, Rockstone footer
Browse files- README.md +11 -0
- app.py +916 -0
- config.py +12 -0
- history.py +26 -0
- i18n.py +161 -0
- llm.py +138 -0
- parsers.py +256 -0
- requirements.txt +1 -0
README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: BreadBuddy
|
| 3 |
+
emoji: 🍞
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "5.50.0"
|
| 8 |
+
python_version: "3.11"
|
| 9 |
+
app_file: app.py
|
| 10 |
+
pinned: false
|
| 11 |
+
---
|
app.py
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BreadBuddy — AI 面包诊所 (v5)
|
| 3 |
+
单 Tab 诊断流程,原型稿对齐 UI,追问交互
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
import base64
|
| 8 |
+
import warnings
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
# Gradio 6.x 弃用警告(theme/css 已移到 launch())
|
| 12 |
+
warnings.filterwarnings("ignore", category=UserWarning, module="gradio")
|
| 13 |
+
|
| 14 |
+
# ─── 将 deploy/ 目录加入 sys.path 以导入模块 ───
|
| 15 |
+
_deploy_dir = os.path.dirname(os.path.abspath(__file__))
|
| 16 |
+
if _deploy_dir not in sys.path:
|
| 17 |
+
sys.path.insert(0, _deploy_dir)
|
| 18 |
+
|
| 19 |
+
from config import API_URL, MODEL_NAME, API_KEY, DEFAULT_TIMEOUT
|
| 20 |
+
|
| 21 |
+
print(f"[APP STARTUP] API_URL={API_URL} MODEL={MODEL_NAME} TIMEOUT={DEFAULT_TIMEOUT}")
|
| 22 |
+
from llm import stream_llm, call_llm, _estimate_tokens
|
| 23 |
+
from parsers import parse_diagnosis_result, render_structured_result, render_empty_hint
|
| 24 |
+
from history import get_history, append_to_history, clear_history
|
| 25 |
+
from i18n import t, get_system_prompt, get_followup_suffix, get_user_fallback, set_lang, LANG
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ─── 诊断 System Prompt ───
|
| 29 |
+
DIAGNOSIS_SYSTEM_PROMPT = get_system_prompt()
|
| 30 |
+
|
| 31 |
+
FOLLOWUP_SYSTEM_SUFFIX = get_followup_suffix()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ─── Handlers ───
|
| 35 |
+
def diagnose_bread(photo, description, temperature, humidity):
|
| 36 |
+
"""诊断面包问题 — 主 handler"""
|
| 37 |
+
if not description and not photo:
|
| 38 |
+
yield render_empty_hint(), "", "⏱️ --"
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
# 构建消息(新诊断不载入历史,追问才载入)
|
| 42 |
+
messages = [{"role": "system", "content": DIAGNOSIS_SYSTEM_PROMPT}]
|
| 43 |
+
|
| 44 |
+
user_text_parts = []
|
| 45 |
+
if description:
|
| 46 |
+
user_text_parts.append(f"Problem: {description}")
|
| 47 |
+
if temperature:
|
| 48 |
+
user_text_parts.append(f"Temperature: {temperature}°C")
|
| 49 |
+
if humidity:
|
| 50 |
+
user_text_parts.append(f"Humidity: {humidity}%")
|
| 51 |
+
user_text = "\n".join(user_text_parts) or "Please diagnose this bread photo"
|
| 52 |
+
|
| 53 |
+
if photo:
|
| 54 |
+
import os as _os
|
| 55 |
+
_photo_size = _os.path.getsize(photo) if _os.path.exists(photo) else -1
|
| 56 |
+
print(f"[DIAG DEBUG] photo type={type(photo)} value={repr(photo)}")
|
| 57 |
+
print(f"[DIAG DEBUG] file exists={_os.path.exists(photo)} size={_photo_size}")
|
| 58 |
+
with open(photo, "rb") as f:
|
| 59 |
+
img_bytes = f.read()
|
| 60 |
+
img_b64 = base64.b64encode(img_bytes).decode()
|
| 61 |
+
print(f"[DIAG] photo path={photo} size={_photo_size} b64_len={len(img_b64)}")
|
| 62 |
+
# Check image dimensions
|
| 63 |
+
try:
|
| 64 |
+
from PIL import Image
|
| 65 |
+
import io
|
| 66 |
+
pil_img = Image.open(io.BytesIO(img_bytes))
|
| 67 |
+
print(f"[DIAG DEBUG] image dimensions={pil_img.size} format={pil_img.format} mode={pil_img.mode}")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"[DIAG DEBUG] image parse error: {e}")
|
| 70 |
+
messages.append({"role": "user", "content": [
|
| 71 |
+
{"type": "text", "text": user_text},
|
| 72 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}}
|
| 73 |
+
]})
|
| 74 |
+
print(f"[DIAG] messages[-1] has image_url type + text, b64_preview={img_b64[:50]}...")
|
| 75 |
+
else:
|
| 76 |
+
messages.append({"role": "user", "content": user_text})
|
| 77 |
+
|
| 78 |
+
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>', "", "⏱️ --"
|
| 79 |
+
final_content = ""
|
| 80 |
+
chunk_count = 0
|
| 81 |
+
for display_content, reasoning, speed in stream_llm(messages):
|
| 82 |
+
final_content = display_content
|
| 83 |
+
chunk_count += 1
|
| 84 |
+
if chunk_count == 1:
|
| 85 |
+
print(f"[DIAG] stream first: content_len={len(display_content or '')} reason_len={len(reasoning or '')} speed={speed}")
|
| 86 |
+
# 只在 content 为空时用 reasoning 兜底(避免中英文混合导致 UI 跳动)
|
| 87 |
+
combined = display_content
|
| 88 |
+
if not display_content.strip() and reasoning:
|
| 89 |
+
combined = reasoning
|
| 90 |
+
structured = render_structured_result(combined, temperature or 25)
|
| 91 |
+
yield structured, reasoning, speed
|
| 92 |
+
|
| 93 |
+
print(f"[DIAG] stream done: chunks={chunk_count} final_content={len(final_content or '')} speed={speed}")
|
| 94 |
+
|
| 95 |
+
# 记录历史(纯文本,不含图片数据)
|
| 96 |
+
append_to_history(
|
| 97 |
+
{"role": "user", "content": user_text},
|
| 98 |
+
{"role": "assistant", "content": final_content or ""}
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def followup_diagnosis(question, current_result):
|
| 103 |
+
"""追问 handler — 基于已有诊断结果继续对话"""
|
| 104 |
+
if not question or not question.strip():
|
| 105 |
+
yield current_result or render_empty_hint(), "", "⏱️ --"
|
| 106 |
+
return
|
| 107 |
+
|
| 108 |
+
# 扩展 system prompt 含追问上下文
|
| 109 |
+
system = DIAGNOSIS_SYSTEM_PROMPT + FOLLOWUP_SYSTEM_SUFFIX
|
| 110 |
+
messages = [{"role": "system", "content": system}]
|
| 111 |
+
|
| 112 |
+
# 加入完整对话历史
|
| 113 |
+
for msg in get_history():
|
| 114 |
+
messages.append(msg)
|
| 115 |
+
|
| 116 |
+
# 加入追问
|
| 117 |
+
messages.append({"role": "user", "content": question.strip()})
|
| 118 |
+
|
| 119 |
+
yield current_result or '<div class="bb-loading active"><div class="loading-spinner"></div><div class="loading-text">Thinking...</div></div>', "", "⏱️ --"
|
| 120 |
+
final_content = ""
|
| 121 |
+
for display_content, reasoning, speed in stream_llm(messages):
|
| 122 |
+
final_content = display_content
|
| 123 |
+
combined = display_content
|
| 124 |
+
if reasoning:
|
| 125 |
+
combined = display_content + "\n" + reasoning
|
| 126 |
+
structured = render_structured_result(combined, 25)
|
| 127 |
+
yield structured, reasoning, speed
|
| 128 |
+
|
| 129 |
+
append_to_history(
|
| 130 |
+
{"role": "user", "content": question.strip()},
|
| 131 |
+
{"role": "assistant", "content": final_content or ""}
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def do_rediagnose():
|
| 136 |
+
"""重新诊断 — 清空历史,返回空状态"""
|
| 137 |
+
clear_history()
|
| 138 |
+
return render_empty_hint(), "", "⏱️ --"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def do_clear():
|
| 142 |
+
"""清空对话"""
|
| 143 |
+
clear_history()
|
| 144 |
+
return render_empty_hint(), "", "⏱️ --"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ─── Custom Theme ───
|
| 148 |
+
custom_theme = gr.themes.Base(
|
| 149 |
+
primary_hue=gr.themes.colors.orange,
|
| 150 |
+
secondary_hue=gr.themes.colors.amber,
|
| 151 |
+
neutral_hue=gr.themes.colors.stone,
|
| 152 |
+
font=gr.themes.GoogleFont("DM Sans"),
|
| 153 |
+
font_mono=gr.themes.GoogleFont("JetBrains Mono"),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ─── Custom CSS(对齐原型稿设计系统)───
|
| 158 |
+
custom_css = """
|
| 159 |
+
@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');
|
| 160 |
+
|
| 161 |
+
/* ═══ Design Tokens ═══ */
|
| 162 |
+
:root {
|
| 163 |
+
--primary: #D4A574; --primary-light: #E8C9A8; --secondary: #F5E6D3;
|
| 164 |
+
--accent: #E8956A; --accent-hover: #D4804F;
|
| 165 |
+
--text: #3E2723; --text-muted: #8D6E63;
|
| 166 |
+
--bg: #FAF6F1; --bg-card: #FFFFFF; --bg-input: #FFFFFF;
|
| 167 |
+
--border: #E8DDD2;
|
| 168 |
+
--shadow: rgba(62,39,35,.08); --shadow-lg: rgba(62,39,35,.12);
|
| 169 |
+
--chip-amber: #D4A574; --chip-brown: #8D6E63; --chip-red: #E8956A;
|
| 170 |
+
--highlight-bg: #FFF8F0; --highlight-border: #E8956A;
|
| 171 |
+
--radius-btn: 12px; --radius-card: 16px; --radius-input: 8px;
|
| 172 |
+
--font-display: 'Playfair Display', Georgia, serif;
|
| 173 |
+
--font-body: 'DM Sans', -apple-system, sans-serif;
|
| 174 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 175 |
+
--transition: .3s cubic-bezier(.4,0,.2,1);
|
| 176 |
+
}
|
| 177 |
+
.dark {
|
| 178 |
+
--primary: #D4A574; --primary-light: #B8916A; --secondary: #2A2523;
|
| 179 |
+
--accent: #E8956A; --accent-hover: #F0A880;
|
| 180 |
+
--text: #F5E0C3; --text-muted: #A89383;
|
| 181 |
+
--bg: #1A1715; --bg-card: #2A2523; --bg-input: #332E2B;
|
| 182 |
+
--border: #3E3835;
|
| 183 |
+
--shadow: rgba(0,0,0,.25); --shadow-lg: rgba(0,0,0,.35);
|
| 184 |
+
--chip-amber: #D4A574; --chip-brown: #A89383; --chip-red: #E8956A;
|
| 185 |
+
--highlight-bg: #33281F; --highlight-border: #E8956A;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/* Theme transition (class-based, removed after animation) */
|
| 189 |
+
.bb-theme-transitioning,
|
| 190 |
+
.bb-theme-transitioning * {
|
| 191 |
+
transition: background .3s ease, color .3s ease,
|
| 192 |
+
border-color .3s ease, box-shadow .3s ease !important;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
* { box-sizing: border-box; }
|
| 196 |
+
body { font-family: var(--font-body); -webkit-font-smoothing: antialiased; }
|
| 197 |
+
|
| 198 |
+
/* ═══ Gradio Container ═══ */
|
| 199 |
+
.gradio-container {
|
| 200 |
+
font-family: var(--font-body) !important;
|
| 201 |
+
max-width: 640px !important; margin: 0 auto !important;
|
| 202 |
+
padding: 0 !important; min-height: 100vh !important;
|
| 203 |
+
background: var(--bg) !important;
|
| 204 |
+
}
|
| 205 |
+
@media (max-width: 767px) {
|
| 206 |
+
.gradio-container { padding: 0 !important; }
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* ═══ Header ═══ */
|
| 210 |
+
.bb-header {
|
| 211 |
+
position: sticky; top: 0; z-index: 100;
|
| 212 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 213 |
+
padding: 16px 20px; max-height: 72px;
|
| 214 |
+
background: var(--bg-card); border-bottom: 1px solid var(--border);
|
| 215 |
+
backdrop-filter: blur(12px);
|
| 216 |
+
}
|
| 217 |
+
.bb-logo {
|
| 218 |
+
font-family: var(--font-display); font-size: 1.35rem; font-weight: 700;
|
| 219 |
+
color: var(--text); display: flex; align-items: center; gap: 8px;
|
| 220 |
+
}
|
| 221 |
+
.bb-theme-toggle {
|
| 222 |
+
width: 48px; height: 28px; border-radius: 14px;
|
| 223 |
+
border: 2px solid var(--border); background: var(--bg);
|
| 224 |
+
cursor: pointer; position: relative; transition: all var(--transition);
|
| 225 |
+
}
|
| 226 |
+
.bb-theme-toggle::after {
|
| 227 |
+
content: '☀️'; position: absolute; top: 2px; left: 2px;
|
| 228 |
+
width: 20px; height: 20px; border-radius: 50%;
|
| 229 |
+
background: var(--primary); display: flex; align-items: center;
|
| 230 |
+
justify-content: center; font-size: 12px;
|
| 231 |
+
transition: transform var(--transition), background var(--transition);
|
| 232 |
+
}
|
| 233 |
+
.dark .bb-theme-toggle::after {
|
| 234 |
+
content: '🌙'; transform: translateX(20px); background: var(--accent);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/* ═══ Input Card ═══ */
|
| 238 |
+
.bb-input-wrap {
|
| 239 |
+
padding: 24px 20px; background: var(--bg);
|
| 240 |
+
}
|
| 241 |
+
.bb-hero-emoji { font-size: 4rem; text-align: center; margin-bottom: 12px; animation: float 3s ease-in-out infinite; }
|
| 242 |
+
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
|
| 243 |
+
.bb-hero-text {
|
| 244 |
+
font-family: var(--font-display); font-size: 1.4rem; font-weight: 600;
|
| 245 |
+
text-align: center; color: var(--text); margin-bottom: 28px; line-height: 1.4;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/* Upload */
|
| 249 |
+
.bb-upload-area {
|
| 250 |
+
border: 2px dashed var(--border); border-radius: var(--radius-card);
|
| 251 |
+
padding: 32px 20px; text-align: center; cursor: pointer;
|
| 252 |
+
transition: all var(--transition); margin-bottom: 20px; background: var(--bg-card);
|
| 253 |
+
}
|
| 254 |
+
.bb-upload-area:hover { border-color: var(--primary); background: var(--highlight-bg); }
|
| 255 |
+
|
| 256 |
+
/* Presets */
|
| 257 |
+
.bb-presets { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
| 258 |
+
.bb-preset-btn {
|
| 259 |
+
flex: 1; min-width: calc(50% - 4px); padding: 14px 16px;
|
| 260 |
+
border: 1.5px solid var(--border); border-radius: var(--radius-btn);
|
| 261 |
+
background: var(--bg-card); color: var(--text);
|
| 262 |
+
font-family: var(--font-body); font-size: .9rem; font-weight: 500;
|
| 263 |
+
cursor: pointer; transition: all var(--transition);
|
| 264 |
+
display: flex; align-items: center; justify-content: center; gap: 4px;
|
| 265 |
+
}
|
| 266 |
+
.bb-preset-btn:hover { border-color: var(--primary); background: var(--highlight-bg); }
|
| 267 |
+
.bb-preset-btn.active { border-color: var(--primary); background: var(--primary); color: #fff; font-weight: 600; }
|
| 268 |
+
.dark .bb-preset-btn.active { color: #1A1715; }
|
| 269 |
+
|
| 270 |
+
/* Slider */
|
| 271 |
+
.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); }
|
| 272 |
+
.bb-slider-row.visible { display: flex; }
|
| 273 |
+
.bb-hidden-slider { height: 0 !important; overflow: hidden !important; margin: 0 !important; padding: 0 !important; opacity: 0 !important; pointer-events: none !important; }
|
| 274 |
+
|
| 275 |
+
/* Diagnose Button */
|
| 276 |
+
.btn-diagnose {
|
| 277 |
+
width: 100% !important; padding: 16px !important;
|
| 278 |
+
border: none !important; border-radius: var(--radius-btn) !important;
|
| 279 |
+
background: linear-gradient(135deg, var(--primary), var(--accent)) !important;
|
| 280 |
+
color: #fff !important; font-family: var(--font-body) !important;
|
| 281 |
+
font-size: 1.05rem !important; font-weight: 700 !important;
|
| 282 |
+
cursor: pointer !important; transition: all var(--transition) !important;
|
| 283 |
+
margin-top: 8px !important; box-shadow: 0 4px 16px rgba(212,165,116,.3) !important;
|
| 284 |
+
letter-spacing: .02em !important;
|
| 285 |
+
}
|
| 286 |
+
.btn-diagnose:hover { transform: translateY(-2px) !important; box-shadow: 0 6px 24px rgba(212,165,116,.4) !important; }
|
| 287 |
+
|
| 288 |
+
/* ═══ Result Card ═══ */
|
| 289 |
+
.result-card {
|
| 290 |
+
background: var(--bg-card); border-radius: 12px;
|
| 291 |
+
border: 1px solid rgba(255,255,255,0.06); overflow: visible;
|
| 292 |
+
box-shadow: 0 4px 20px var(--shadow); transition: all var(--transition);
|
| 293 |
+
animation: slideUp .4s ease-out;
|
| 294 |
+
}
|
| 295 |
+
.dark .result-card {
|
| 296 |
+
background-color: #1A1512;
|
| 297 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 298 |
+
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
| 299 |
+
}
|
| 300 |
+
.card-section { padding: 24px; border-bottom: 1px solid var(--border); }
|
| 301 |
+
.card-section:last-child { border-bottom: none; }
|
| 302 |
+
.section-title {
|
| 303 |
+
font-family: var(--font-display); font-size: 1rem; font-weight: 600;
|
| 304 |
+
color: var(--text); margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/* ─── Diagnostic Cards (vertical layout) ─── */
|
| 308 |
+
.diag-cards { display: flex; flex-direction: column; gap: 14px; }
|
| 309 |
+
.diag-card {
|
| 310 |
+
padding: 18px 20px; border-radius: 8px;
|
| 311 |
+
background: var(--bg-card); border: 1px solid var(--border);
|
| 312 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
| 313 |
+
position: relative; height: auto;
|
| 314 |
+
animation: fixSlide .4s ease both;
|
| 315 |
+
}
|
| 316 |
+
.dark .diag-card {
|
| 317 |
+
background: #1E1A17; border: 1px solid rgba(255,255,255,0.06);
|
| 318 |
+
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
| 319 |
+
}
|
| 320 |
+
.diag-card.severe { border-left: 4px solid #E28755; }
|
| 321 |
+
.diag-card.moderate { border-left: 4px solid #C69963; }
|
| 322 |
+
.diag-card.mild { border-left: 4px solid #8D6E63; }
|
| 323 |
+
.diag-card:nth-child(1) { animation-delay: .1s; }
|
| 324 |
+
.diag-card:nth-child(2) { animation-delay: .2s; }
|
| 325 |
+
.diag-card:nth-child(3) { animation-delay: .3s; }
|
| 326 |
+
|
| 327 |
+
.diag-card-header {
|
| 328 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 329 |
+
gap: 10px; margin-bottom: 6px;
|
| 330 |
+
}
|
| 331 |
+
.diag-card-title {
|
| 332 |
+
font-size: 16px; font-weight: 600; color: var(--text); line-height: 1.3;
|
| 333 |
+
}
|
| 334 |
+
.diag-card-desc {
|
| 335 |
+
font-size: 14px; font-weight: 400; line-height: 1.5;
|
| 336 |
+
color: rgba(0,0,0,0.55);
|
| 337 |
+
}
|
| 338 |
+
.dark .diag-card-desc {
|
| 339 |
+
color: rgba(255,255,255,0.7);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
/* ─── Severity Badges (low-saturation bg + high-saturation text) ─── */
|
| 343 |
+
.badge-severe, .badge-moderate, .badge-mild {
|
| 344 |
+
padding: 3px 8px; font-size: 11px; font-weight: 600;
|
| 345 |
+
text-transform: uppercase; border-radius: 4px;
|
| 346 |
+
white-space: nowrap; flex-shrink: 0; letter-spacing: .03em;
|
| 347 |
+
}
|
| 348 |
+
.badge-severe {
|
| 349 |
+
background-color: rgba(226,135,85,0.15); color: #FF9E64;
|
| 350 |
+
}
|
| 351 |
+
.badge-moderate {
|
| 352 |
+
background-color: rgba(198,153,99,0.15); color: #E2B378;
|
| 353 |
+
}
|
| 354 |
+
.badge-mild {
|
| 355 |
+
background-color: rgba(141,110,99,0.12); color: #A89383;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/* Legacy chip styles (kept for backward compat) */
|
| 359 |
+
.cause-chips { display: flex; flex-wrap: wrap; gap: 10px; }
|
| 360 |
+
.cause-chip {
|
| 361 |
+
display: flex; align-items: center; gap: 8px; padding: 8px 14px;
|
| 362 |
+
border-radius: 20px; background: var(--bg); border: 1px solid var(--border);
|
| 363 |
+
font-size: .95rem; font-weight: 500; color: var(--text);
|
| 364 |
+
animation: chipIn .4s ease both;
|
| 365 |
+
}
|
| 366 |
+
.cause-chip.severe-chip { border-left: 3px solid var(--chip-red); }
|
| 367 |
+
.cause-chip:nth-child(1) { animation-delay: .1s; }
|
| 368 |
+
.cause-chip:nth-child(2) { animation-delay: .2s; }
|
| 369 |
+
.cause-chip:nth-child(3) { animation-delay: .3s; }
|
| 370 |
+
@keyframes chipIn { from{opacity:0;transform:scale(.8)} to{opacity:1;transform:scale(1)} }
|
| 371 |
+
|
| 372 |
+
.chip-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
| 373 |
+
.chip-dot.severe { background: var(--chip-red); }
|
| 374 |
+
.chip-dot.moderate { background: var(--chip-amber); }
|
| 375 |
+
.chip-dot.mild { background: var(--chip-brown); }
|
| 376 |
+
|
| 377 |
+
.chip-severity { font-size: .72rem; font-weight: 700; padding: 2px 6px; border-radius: 4px; margin-left: 2px; }
|
| 378 |
+
.chip-severity.severe { background: #FDEAE2; color: #C0593A; }
|
| 379 |
+
.chip-severity.moderate { background: #FBF0E0; color: #9A7B4F; }
|
| 380 |
+
.chip-severity.mild { background: #F0EDE8; color: #7A6E6B; }
|
| 381 |
+
.dark .chip-severity.severe { background: #3E2518; color: #F0A880; }
|
| 382 |
+
.dark .chip-severity.moderate { background: #332B1F; color: #D4A574; }
|
| 383 |
+
.dark .chip-severity.mild { background: #2A2523; color: #B8A898; }
|
| 384 |
+
|
| 385 |
+
/* Fix List */
|
| 386 |
+
.fix-list { list-style: none; counter-reset: fix; padding: 0; margin: 0; }
|
| 387 |
+
.fix-item {
|
| 388 |
+
counter-increment: fix; padding: 10px 12px; margin-bottom: 8px;
|
| 389 |
+
border-radius: var(--radius-input); background: var(--bg);
|
| 390 |
+
border: 1px solid var(--border); font-size: .9rem; line-height: 1.5;
|
| 391 |
+
display: flex; align-items: flex-start; gap: 10px;
|
| 392 |
+
animation: fixSlide .4s ease both;
|
| 393 |
+
}
|
| 394 |
+
.fix-item:nth-child(1) { animation-delay: .15s; }
|
| 395 |
+
.fix-item:nth-child(2) { animation-delay: .2s; }
|
| 396 |
+
.fix-item:nth-child(3) { animation-delay: .25s; }
|
| 397 |
+
.fix-item:nth-child(4) { animation-delay: .3s; }
|
| 398 |
+
.fix-item:nth-child(5) { animation-delay: .35s; }
|
| 399 |
+
@keyframes fixSlide { from{opacity:0;transform:translateX(-12px)} to{opacity:1;transform:translateX(0)} }
|
| 400 |
+
.fix-item::before {
|
| 401 |
+
content: counter(fix); font-family: var(--font-mono); font-size: .78rem; font-weight: 600;
|
| 402 |
+
color: var(--primary); background: var(--highlight-bg);
|
| 403 |
+
width: 24px; height: 24px; border-radius: 50%;
|
| 404 |
+
display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px;
|
| 405 |
+
}
|
| 406 |
+
.fix-tag { font-size: .72rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; white-space: nowrap; flex-shrink: 0; margin-top: 2px; }
|
| 407 |
+
.fix-tag.fix { background: #E8F5E9; color: #2E7D32; }
|
| 408 |
+
.fix-tag.ferment { background: #FFF3E0; color: #E65100; }
|
| 409 |
+
.dark .fix-tag.fix { background: #1B3A1B; color: #81C784; }
|
| 410 |
+
.dark .fix-tag.ferment { background: #3E2A1A; color: #FFB74D; }
|
| 411 |
+
.fix-text { flex: 1; }
|
| 412 |
+
.fix-item.new-item { border-color: var(--highlight-border); background: var(--highlight-bg); }
|
| 413 |
+
|
| 414 |
+
/* Recipe Accordion */
|
| 415 |
+
.recipe-accordion { border: 1px solid var(--border); border-radius: var(--radius-input); overflow: hidden; background: var(--bg); }
|
| 416 |
+
.recipe-header {
|
| 417 |
+
padding: 14px 16px; display: flex; align-items: center;
|
| 418 |
+
justify-content: space-between; cursor: pointer;
|
| 419 |
+
font-size: .9rem; font-weight: 600; color: var(--text);
|
| 420 |
+
transition: background var(--transition); user-select: none;
|
| 421 |
+
}
|
| 422 |
+
.recipe-header:hover { background: var(--highlight-bg); }
|
| 423 |
+
.recipe-arrow { font-size: .8rem; color: var(--text-muted); transition: transform var(--transition); }
|
| 424 |
+
.recipe-accordion.open .recipe-arrow { transform: rotate(180deg); }
|
| 425 |
+
.recipe-list { max-height: 0; overflow: hidden; transition: max-height .4s ease; }
|
| 426 |
+
.recipe-accordion.open .recipe-list { max-height: 500px; }
|
| 427 |
+
.recipe-item { padding: 12px 16px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 10px; font-size: .88rem; }
|
| 428 |
+
.recipe-emoji { font-size: 1.2rem; }
|
| 429 |
+
.recipe-name { font-weight: 600; color: var(--text); }
|
| 430 |
+
.recipe-desc { font-size: .8rem; color: var(--text-muted); }
|
| 431 |
+
|
| 432 |
+
/* Stats */
|
| 433 |
+
.stats-bar {
|
| 434 |
+
padding: 14px 20px; text-align: center;
|
| 435 |
+
font-family: var(--font-mono); font-size: .78rem;
|
| 436 |
+
color: var(--text-muted); display: flex; justify-content: center; gap: 16px;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
/* ═══ Follow-up ═══ */
|
| 440 |
+
.bb-followup-banner {
|
| 441 |
+
margin: 16px 20px 0; padding: 12px 16px;
|
| 442 |
+
background: var(--highlight-bg); border: 1px solid var(--highlight-border);
|
| 443 |
+
border-radius: var(--radius-input); font-size: .88rem; color: var(--text);
|
| 444 |
+
display: none; animation: bannerIn .4s ease;
|
| 445 |
+
}
|
| 446 |
+
.bb-followup-banner.visible { display: block; }
|
| 447 |
+
@keyframes bannerIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
|
| 448 |
+
|
| 449 |
+
.bb-followup-wrap {
|
| 450 |
+
position: fixed; bottom: 0; left: 0; right: 0;
|
| 451 |
+
padding: 12px 16px; padding-bottom: max(12px, env(safe-area-inset-bottom));
|
| 452 |
+
background: var(--bg-card); border-top: 1px solid var(--border); z-index: 90;
|
| 453 |
+
display: none; animation: slideUp2 .4s ease;
|
| 454 |
+
}
|
| 455 |
+
.bb-followup-wrap.visible { display: block; }
|
| 456 |
+
@keyframes slideUp2 { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
| 457 |
+
.bb-followup-inner { max-width: 640px; margin: 0 auto; display: flex; gap: 8px; align-items: center; }
|
| 458 |
+
|
| 459 |
+
/* Preset Questions */
|
| 460 |
+
.bb-preset-questions {
|
| 461 |
+
display: flex; gap: 6px; flex-wrap: wrap;
|
| 462 |
+
padding: 0 16px 8px; max-width: 640px; margin: 0 auto;
|
| 463 |
+
}
|
| 464 |
+
.bb-preset-q {
|
| 465 |
+
padding: 6px 14px; border: 1px solid var(--border); border-radius: 16px;
|
| 466 |
+
background: var(--bg-card); color: var(--text-muted); font-size: .78rem;
|
| 467 |
+
font-family: var(--font-body); cursor: pointer;
|
| 468 |
+
transition: all var(--transition); white-space: nowrap;
|
| 469 |
+
}
|
| 470 |
+
.bb-preset-q:hover {
|
| 471 |
+
border-color: var(--primary); color: var(--text);
|
| 472 |
+
background: var(--highlight-bg);
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/* Rediagnose Button */
|
| 476 |
+
.btn-rediagnose {
|
| 477 |
+
border: 1px solid var(--border) !important; border-radius: var(--radius-btn) !important;
|
| 478 |
+
background: var(--bg-card) !important; color: var(--text) !important;
|
| 479 |
+
font-family: var(--font-body) !important; font-size: .85rem !important;
|
| 480 |
+
font-weight: 500 !important; cursor: pointer !important;
|
| 481 |
+
padding: 8px 16px !important; transition: all var(--transition) !important;
|
| 482 |
+
}
|
| 483 |
+
.btn-rediagnose:hover { border-color: var(--primary) !important; background: var(--highlight-bg) !important; }
|
| 484 |
+
|
| 485 |
+
/* ═══ Speed Display ═══ */
|
| 486 |
+
.speed-display {
|
| 487 |
+
font-family: var(--font-mono); font-size: .78rem;
|
| 488 |
+
color: var(--text-muted); padding: 4px 8px;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
/* ═══ Loading State ═══ */
|
| 492 |
+
.bb-loading {
|
| 493 |
+
display: none; flex-direction: column; align-items: center;
|
| 494 |
+
justify-content: center; padding: 60px 20px; gap: 20px;
|
| 495 |
+
animation: fadeIn .3s ease;
|
| 496 |
+
}
|
| 497 |
+
.bb-loading.active { display: flex; }
|
| 498 |
+
.loading-spinner {
|
| 499 |
+
width: 40px; height: 40px;
|
| 500 |
+
border: 3px solid var(--border); border-top-color: var(--primary);
|
| 501 |
+
border-radius: 50%; animation: spin .8s linear infinite;
|
| 502 |
+
}
|
| 503 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 504 |
+
.loading-text { font-family: var(--font-body); font-size: .95rem; color: var(--text); font-weight: 500; }
|
| 505 |
+
.loading-hint { font-size: .78rem; color: var(--text-muted); opacity: .7; }
|
| 506 |
+
|
| 507 |
+
/* ═══ Animations ═══ */
|
| 508 |
+
@keyframes slideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
| 509 |
+
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
|
| 510 |
+
|
| 511 |
+
/* ═══ Footer ═══ */
|
| 512 |
+
.bb-footer {
|
| 513 |
+
text-align: center; padding: 20px; color: var(--text-muted);
|
| 514 |
+
font-size: 12px; margin-top: 32px !important; line-height: 1.6;
|
| 515 |
+
}
|
| 516 |
+
.bb-footer p { margin: 0; }
|
| 517 |
+
|
| 518 |
+
/* ═══ Responsive ═══ */
|
| 519 |
+
|
| 520 |
+
/* Small phone: <480px */
|
| 521 |
+
@media (max-width: 479px) {
|
| 522 |
+
.bb-header { padding: 12px 14px; max-height: 60px; }
|
| 523 |
+
.bb-logo { font-size: 1.1rem; }
|
| 524 |
+
.bb-hero-emoji { font-size: 3rem; }
|
| 525 |
+
.bb-hero-text { font-size: 1.1rem; margin-bottom: 20px; }
|
| 526 |
+
.bb-input-wrap { padding: 20px 16px; }
|
| 527 |
+
.bb-upload-area { padding: 24px 16px; }
|
| 528 |
+
.bb-presets { flex-direction: column; gap: 6px; }
|
| 529 |
+
.bb-preset-btn { min-width: 100%; padding: 12px; font-size: .85rem; }
|
| 530 |
+
.result-card { border-radius: 12px; }
|
| 531 |
+
.card-section { padding: 16px; }
|
| 532 |
+
.btn-diagnose { padding: 14px !important; font-size: .95rem !important; }
|
| 533 |
+
.bb-followup-wrap { padding: 10px 12px; }
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
/* Large phone / small tablet: 480-767px */
|
| 537 |
+
@media (min-width: 480px) and (max-width: 767px) {
|
| 538 |
+
.bb-preset-btn { min-width: calc(50% - 5px); }
|
| 539 |
+
.bb-hero-text { font-size: 1.2rem; }
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
/* Desktop: 768px+ */
|
| 543 |
+
@media (min-width: 768px) {
|
| 544 |
+
.bb-input-wrap { padding: 40px 24px 60px; }
|
| 545 |
+
.bb-hero-text { font-size: 1.6rem; }
|
| 546 |
+
.bb-presets { gap: 10px; }
|
| 547 |
+
.bb-preset-btn { min-width: calc(25% - 8px); }
|
| 548 |
+
}
|
| 549 |
+
"""
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
# ─── Custom JS(Gradio 5.x 用 new AsyncFunction 包装,不能写 IIFE)───
|
| 553 |
+
custom_js = """
|
| 554 |
+
/* ═══ Dark Mode — apply theme immediately ═══ */
|
| 555 |
+
var saved = localStorage.getItem('bb-theme');
|
| 556 |
+
var initTheme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
| 557 |
+
document.body.classList.toggle('dark', initTheme === 'dark');
|
| 558 |
+
document.body.setAttribute('data-theme', initTheme);
|
| 559 |
+
|
| 560 |
+
/* ═══ Document-level event delegation ═══ */
|
| 561 |
+
document.addEventListener('click', function(e) {
|
| 562 |
+
/* ── Theme Toggle ── */
|
| 563 |
+
var langSpan = e.target.closest('.bb-lang');
|
| 564 |
+
if (langSpan) {
|
| 565 |
+
var next = langSpan.textContent === 'EN' ? '中文' : 'EN';
|
| 566 |
+
langSpan.textContent = next;
|
| 567 |
+
location.reload();
|
| 568 |
+
return;
|
| 569 |
+
}
|
| 570 |
+
var toggleBtn = e.target.closest('.bb-theme-toggle, #themeToggle, .theme-toggle');
|
| 571 |
+
if (toggleBtn) {
|
| 572 |
+
document.body.classList.add('bb-theme-transitioning');
|
| 573 |
+
var isDark = document.body.classList.toggle('dark');
|
| 574 |
+
var newTheme = isDark ? 'dark' : 'light';
|
| 575 |
+
document.body.setAttribute('data-theme', newTheme);
|
| 576 |
+
localStorage.setItem('bb-theme', newTheme);
|
| 577 |
+
setTimeout(function() {
|
| 578 |
+
document.body.classList.remove('bb-theme-transitioning');
|
| 579 |
+
}, 350);
|
| 580 |
+
return;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
/* ── Preset Buttons ── */
|
| 584 |
+
var presetBtn = e.target.closest('.bb-preset-btn');
|
| 585 |
+
if (presetBtn) {
|
| 586 |
+
var presets = document.getElementById('bb-presets');
|
| 587 |
+
if (!presets) return;
|
| 588 |
+
|
| 589 |
+
if (presetBtn.classList.contains('active')) {
|
| 590 |
+
presetBtn.classList.remove('active');
|
| 591 |
+
var sr = document.getElementById('bb-slider-row');
|
| 592 |
+
if (sr) sr.classList.remove('visible');
|
| 593 |
+
return;
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
presets.querySelectorAll('.bb-preset-btn').forEach(function(b) { b.classList.remove('active'); });
|
| 597 |
+
presetBtn.classList.add('active');
|
| 598 |
+
|
| 599 |
+
var preset = presetBtn.dataset.preset;
|
| 600 |
+
var sr2 = document.getElementById('bb-slider-row');
|
| 601 |
+
|
| 602 |
+
if (preset === 'custom') {
|
| 603 |
+
if (sr2) sr2.classList.add('visible');
|
| 604 |
+
} else {
|
| 605 |
+
if (sr2) sr2.classList.remove('visible');
|
| 606 |
+
var t = parseInt(presetBtn.dataset.temp);
|
| 607 |
+
var h = parseInt(presetBtn.dataset.humid);
|
| 608 |
+
var ts = document.querySelector('#temp-slider input[type=range]');
|
| 609 |
+
var hs = document.querySelector('#humid-slider input[type=range]');
|
| 610 |
+
if (ts) { ts.value = t; ts.dispatchEvent(new Event('input', {bubbles:true})); }
|
| 611 |
+
if (hs) { hs.value = h; hs.dispatchEvent(new Event('input', {bubbles:true})); }
|
| 612 |
+
}
|
| 613 |
+
return;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
/* ── Recipe Accordion ── */
|
| 617 |
+
var recipeHdr = e.target.closest('.recipe-header, #recipeHeader');
|
| 618 |
+
if (recipeHdr) {
|
| 619 |
+
var acc = document.getElementById('recipeAccordion');
|
| 620 |
+
if (!acc) return;
|
| 621 |
+
acc.classList.toggle('open');
|
| 622 |
+
var span = recipeHdr.querySelector('span:first-child');
|
| 623 |
+
if (span) {
|
| 624 |
+
var count = span.textContent.match(/\\d+/);
|
| 625 |
+
var n = count ? count[0] : '';
|
| 626 |
+
span.textContent = acc.classList.contains('open')
|
| 627 |
+
? n + ' recipe' + (n !== '1' ? 's' : '') + ' · Click to collapse'
|
| 628 |
+
: n + ' recipe' + (n !== '1' ? 's' : '') + ' · Click to expand';
|
| 629 |
+
}
|
| 630 |
+
return;
|
| 631 |
+
}
|
| 632 |
+
});
|
| 633 |
+
|
| 634 |
+
/* ═══ Followup Banner Auto-show ═══ */
|
| 635 |
+
function bbInitFollowup() {
|
| 636 |
+
var resultWrap = document.getElementById('bb-result-wrap');
|
| 637 |
+
var banner = document.getElementById('followupBanner');
|
| 638 |
+
if (!resultWrap || !banner || banner._bbBound) return;
|
| 639 |
+
banner._bbBound = true;
|
| 640 |
+
var observer = new MutationObserver(function() {
|
| 641 |
+
if (banner.classList.contains('visible')) return;
|
| 642 |
+
var card = resultWrap.querySelector('.result-card');
|
| 643 |
+
if (card) {
|
| 644 |
+
var chips = card.querySelectorAll('.cause-chip');
|
| 645 |
+
if (chips.length > 0) banner.classList.add('visible');
|
| 646 |
+
}
|
| 647 |
+
});
|
| 648 |
+
observer.observe(resultWrap, { childList: true, subtree: true });
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
var _bbObs = new MutationObserver(function() { bbInitFollowup(); });
|
| 652 |
+
_bbObs.observe(document.body, { childList: true, subtree: true });
|
| 653 |
+
bbInitFollowup();
|
| 654 |
+
"""
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
# ─── UI ───
|
| 658 |
+
with gr.Blocks(title="🍞 BreadBuddy", theme=custom_theme, css=custom_css) as demo:
|
| 659 |
+
# Header
|
| 660 |
+
gr.HTML("""
|
| 661 |
+
<div class="bb-header">
|
| 662 |
+
<div class="bb-logo"><span>🍞</span> BreadBuddy</div>
|
| 663 |
+
<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>
|
| 664 |
+
</div>
|
| 665 |
+
""")
|
| 666 |
+
|
| 667 |
+
# 输入区
|
| 668 |
+
with gr.Column(elem_classes=["bb-input-wrap", "bb-input-card"], elem_id="stateEmpty"):
|
| 669 |
+
gr.HTML('<div class="bb-hero-emoji">🍞</div>')
|
| 670 |
+
gr.HTML('<div class="bb-hero-text">Snap a photo, let me check your bread</div>')
|
| 671 |
+
|
| 672 |
+
photo_in = gr.Image(
|
| 673 |
+
type="filepath", sources=["upload", "webcam"],
|
| 674 |
+
label="📷 Tap to take a photo or upload",
|
| 675 |
+
height=200,
|
| 676 |
+
)
|
| 677 |
+
desc_in = gr.Textbox(
|
| 678 |
+
label="", show_label=False,
|
| 679 |
+
placeholder="Describe your bread problem... (optional)",
|
| 680 |
+
lines=1, max_lines=3,
|
| 681 |
+
elem_id="descInput",
|
| 682 |
+
elem_classes=["desc-input"],
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
# 温湿度预设
|
| 686 |
+
gr.HTML("""
|
| 687 |
+
<label style="font-size:.85rem;font-weight:600;color:var(--text-muted);margin-bottom:6px;display:block">Environment Presets</label>
|
| 688 |
+
<div class="bb-presets" id="bb-presets">
|
| 689 |
+
<button class="bb-preset-btn" data-preset="winter" data-temp="10" data-humid="30">❄️ Winter 5-15°C</button>
|
| 690 |
+
<button class="bb-preset-btn" data-preset="spring" data-temp="20" data-humid="55">🌸 Spring 15-25°C</button>
|
| 691 |
+
<button class="bb-preset-btn" data-preset="summer" data-temp="30" data-humid="75">☀️ Summer 25-35°C</button>
|
| 692 |
+
<button class="bb-preset-btn" data-preset="custom">⚙️ Custom</button>
|
| 693 |
+
</div>
|
| 694 |
+
<div class="bb-slider-row" id="bb-slider-row">
|
| 695 |
+
<label style="font-size:.82rem;font-weight:600;color:var(--text-muted)">🌡️</label>
|
| 696 |
+
</div>
|
| 697 |
+
""")
|
| 698 |
+
temp_in = gr.Slider(0, 50, value=20, label="Temperature", elem_id="temp-slider", elem_classes=["bb-hidden-slider"])
|
| 699 |
+
humid_in = gr.Slider(0, 100, value=50, label="Humidity", elem_id="humid-slider", elem_classes=["bb-hidden-slider"])
|
| 700 |
+
|
| 701 |
+
btn_diagnose = gr.Button("Start Diagnosis", variant="primary", elem_classes=["btn-diagnose"], elem_id="btnDiagnose")
|
| 702 |
+
|
| 703 |
+
# 结果区
|
| 704 |
+
with gr.Column(elem_id="bb-result-wrap"):
|
| 705 |
+
speed_out = gr.Markdown("⏱️ --", elem_classes=["speed-display"])
|
| 706 |
+
result_out = gr.HTML(render_empty_hint())
|
| 707 |
+
with gr.Accordion("🧠 Model Thinking (optional)", open=False):
|
| 708 |
+
reason_out = gr.Textbox(label="", lines=6, show_label=False)
|
| 709 |
+
|
| 710 |
+
# 操作按钮行
|
| 711 |
+
with gr.Row():
|
| 712 |
+
btn_rediagnose = gr.Button("← Restart", elem_classes=["btn-rediagnose"])
|
| 713 |
+
|
| 714 |
+
# 追问区(底部固定)
|
| 715 |
+
with gr.Column(elem_id="bb-followup-row"):
|
| 716 |
+
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>')
|
| 717 |
+
gr.HTML('''
|
| 718 |
+
<div class="bb-preset-questions" id="presetQuestions">
|
| 719 |
+
<button class="bb-preset-q" data-q="Can you explain the first cause in detail?">🔍 Details</button>
|
| 720 |
+
<button class="bb-preset-q" data-q="How should I adjust fermentation time?">⏱️ Fermentation</button>
|
| 721 |
+
<button class="bb-preset-q" data-q="Is there a simpler recipe?">📖 Simple Recipe</button>
|
| 722 |
+
</div>
|
| 723 |
+
''')
|
| 724 |
+
with gr.Row():
|
| 725 |
+
followup_in = gr.Textbox(
|
| 726 |
+
label="", show_label=False,
|
| 727 |
+
placeholder="Ask a follow-up... e.g. Can you explain the first cause?",
|
| 728 |
+
lines=1, scale=5,
|
| 729 |
+
elem_id="followupInput",
|
| 730 |
+
)
|
| 731 |
+
btn_followup = gr.Button("➤", variant="primary", scale=1, elem_id="btnSend", elem_classes=["btn-send"])
|
| 732 |
+
|
| 733 |
+
# ─── 事件绑定 ───
|
| 734 |
+
btn_diagnose.click(
|
| 735 |
+
diagnose_bread,
|
| 736 |
+
[photo_in, desc_in, temp_in, humid_in],
|
| 737 |
+
[result_out, reason_out, speed_out],
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
btn_followup.click(
|
| 741 |
+
followup_diagnosis,
|
| 742 |
+
[followup_in, result_out],
|
| 743 |
+
[result_out, reason_out, speed_out],
|
| 744 |
+
).then(
|
| 745 |
+
lambda: "", # 清空追问输入框
|
| 746 |
+
outputs=[followup_in],
|
| 747 |
+
)
|
| 748 |
+
|
| 749 |
+
# Enter 键发送追问
|
| 750 |
+
followup_in.submit(
|
| 751 |
+
followup_diagnosis,
|
| 752 |
+
[followup_in, result_out],
|
| 753 |
+
[result_out, reason_out, speed_out],
|
| 754 |
+
).then(
|
| 755 |
+
lambda: "",
|
| 756 |
+
outputs=[followup_in],
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
# 重新诊断
|
| 760 |
+
btn_rediagnose.click(
|
| 761 |
+
do_rediagnose,
|
| 762 |
+
outputs=[result_out, reason_out, speed_out],
|
| 763 |
+
)
|
| 764 |
+
|
| 765 |
+
# Footer
|
| 766 |
+
gr.HTML("""
|
| 767 |
+
<div class="bb-footer">
|
| 768 |
+
<p>© 2026 Rockstone Games. All rights reserved.</p>
|
| 769 |
+
</div>
|
| 770 |
+
""")
|
| 771 |
+
|
| 772 |
+
# ─── JS 注入: 通过 demo.load() 在页面加载时执行 ───
|
| 773 |
+
_bb_js = """(function() {
|
| 774 |
+
var saved = localStorage.getItem('bb-theme');
|
| 775 |
+
var initTheme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
| 776 |
+
document.body.classList.toggle('dark', initTheme === 'dark');
|
| 777 |
+
document.body.setAttribute('data-theme', initTheme);
|
| 778 |
+
|
| 779 |
+
document.addEventListener('click', function(e) {
|
| 780 |
+
var langSpan = e.target.closest('.bb-lang');
|
| 781 |
+
if (langSpan) {
|
| 782 |
+
var next = langSpan.textContent === 'EN' ? '中文' : 'EN';
|
| 783 |
+
langSpan.textContent = next;
|
| 784 |
+
location.reload();
|
| 785 |
+
return;
|
| 786 |
+
}
|
| 787 |
+
var toggleBtn = e.target.closest('.bb-theme-toggle, #themeToggle, .theme-toggle');
|
| 788 |
+
if (toggleBtn) {
|
| 789 |
+
document.body.classList.add('bb-theme-transitioning');
|
| 790 |
+
var isDark = document.body.classList.toggle('dark');
|
| 791 |
+
var newTheme = isDark ? 'dark' : 'light';
|
| 792 |
+
document.body.setAttribute('data-theme', newTheme);
|
| 793 |
+
localStorage.setItem('bb-theme', newTheme);
|
| 794 |
+
setTimeout(function() {
|
| 795 |
+
document.body.classList.remove('bb-theme-transitioning');
|
| 796 |
+
}, 350);
|
| 797 |
+
return;
|
| 798 |
+
}
|
| 799 |
+
var presetBtn = e.target.closest('.bb-preset-btn');
|
| 800 |
+
if (presetBtn) {
|
| 801 |
+
var presets = document.getElementById('bb-presets');
|
| 802 |
+
if (!presets) return;
|
| 803 |
+
if (presetBtn.classList.contains('active')) {
|
| 804 |
+
presetBtn.classList.remove('active');
|
| 805 |
+
var sr = document.getElementById('bb-slider-row');
|
| 806 |
+
if (sr) sr.classList.remove('visible');
|
| 807 |
+
return;
|
| 808 |
+
}
|
| 809 |
+
presets.querySelectorAll('.bb-preset-btn').forEach(function(b) { b.classList.remove('active'); });
|
| 810 |
+
presetBtn.classList.add('active');
|
| 811 |
+
var preset = presetBtn.dataset.preset;
|
| 812 |
+
var sr2 = document.getElementById('bb-slider-row');
|
| 813 |
+
if (preset === 'custom') {
|
| 814 |
+
if (sr2) sr2.classList.add('visible');
|
| 815 |
+
} else {
|
| 816 |
+
if (sr2) sr2.classList.remove('visible');
|
| 817 |
+
var t = parseInt(presetBtn.dataset.temp);
|
| 818 |
+
var h = parseInt(presetBtn.dataset.humid);
|
| 819 |
+
var ts = document.querySelector('#temp-slider input[type=range]');
|
| 820 |
+
var hs = document.querySelector('#humid-slider input[type=range]');
|
| 821 |
+
if (ts) { ts.value = t; ts.dispatchEvent(new Event('input', {bubbles:true})); }
|
| 822 |
+
if (hs) { hs.value = h; hs.dispatchEvent(new Event('input', {bubbles:true})); }
|
| 823 |
+
}
|
| 824 |
+
return;
|
| 825 |
+
}
|
| 826 |
+
/* ── Preset Questions ── */
|
| 827 |
+
var presetQ = e.target.closest('.bb-preset-q');
|
| 828 |
+
if (presetQ) {
|
| 829 |
+
var input = document.querySelector('#followupInput textarea, #followupInput input');
|
| 830 |
+
if (input) {
|
| 831 |
+
var nativeSetter = Object.getOwnPropertyDescriptor(
|
| 832 |
+
window.HTMLTextAreaElement.prototype, 'value'
|
| 833 |
+
).set;
|
| 834 |
+
nativeSetter.call(input, presetQ.dataset.q);
|
| 835 |
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
| 836 |
+
input.focus();
|
| 837 |
+
}
|
| 838 |
+
return;
|
| 839 |
+
}
|
| 840 |
+
var recipeHdr = e.target.closest('.recipe-header, #recipeHeader');
|
| 841 |
+
if (recipeHdr) {
|
| 842 |
+
var acc = document.getElementById('recipeAccordion');
|
| 843 |
+
if (!acc) return;
|
| 844 |
+
acc.classList.toggle('open');
|
| 845 |
+
var span = recipeHdr.querySelector('span:first-child');
|
| 846 |
+
if (span) {
|
| 847 |
+
var count = span.textContent.match(/\\d+/);
|
| 848 |
+
var n = count ? count[0] : '';
|
| 849 |
+
span.textContent = acc.classList.contains('open')
|
| 850 |
+
? '\U0001f35e 推荐 ' + n + ' 个配方 · 点击收起'
|
| 851 |
+
: '\U0001f35e 推荐 ' + n + ' 个配方 · 点击展开';
|
| 852 |
+
}
|
| 853 |
+
return;
|
| 854 |
+
}
|
| 855 |
+
});
|
| 856 |
+
|
| 857 |
+
function bbInitFollowup() {
|
| 858 |
+
var resultWrap = document.getElementById('bb-result-wrap');
|
| 859 |
+
var banner = document.getElementById('followupBanner');
|
| 860 |
+
if (!resultWrap || !banner || banner._bbBound) return;
|
| 861 |
+
banner._bbBound = true;
|
| 862 |
+
var observer = new MutationObserver(function() {
|
| 863 |
+
if (banner.classList.contains('visible')) return;
|
| 864 |
+
var card = resultWrap.querySelector('.result-card');
|
| 865 |
+
if (card && card.querySelectorAll('.cause-chip').length > 0) {
|
| 866 |
+
banner.classList.add('visible');
|
| 867 |
+
}
|
| 868 |
+
});
|
| 869 |
+
observer.observe(resultWrap, { childList: true, subtree: true });
|
| 870 |
+
}
|
| 871 |
+
var _obs = new MutationObserver(function() { bbInitFollowup(); });
|
| 872 |
+
_obs.observe(document.body, { childList: true, subtree: true });
|
| 873 |
+
bbInitFollowup();
|
| 874 |
+
|
| 875 |
+
/* ═══ Virtual keyboard fix (mobile) ═══ */
|
| 876 |
+
if (window.visualViewport) {
|
| 877 |
+
var followupWrap = document.querySelector('.bb-followup-wrap');
|
| 878 |
+
var rafId = null;
|
| 879 |
+
|
| 880 |
+
var adjustViewport = function() {
|
| 881 |
+
if (rafId) return;
|
| 882 |
+
rafId = requestAnimationFrame(function() {
|
| 883 |
+
rafId = null;
|
| 884 |
+
if (followupWrap) {
|
| 885 |
+
var diff = window.innerHeight - window.visualViewport.height;
|
| 886 |
+
followupWrap.style.bottom = Math.max(diff, 0) + 'px';
|
| 887 |
+
}
|
| 888 |
+
});
|
| 889 |
+
};
|
| 890 |
+
|
| 891 |
+
window.visualViewport.addEventListener('resize', adjustViewport);
|
| 892 |
+
window.visualViewport.addEventListener('scroll', adjustViewport);
|
| 893 |
+
|
| 894 |
+
if (followupWrap) {
|
| 895 |
+
followupWrap.addEventListener('focusout', function() {
|
| 896 |
+
setTimeout(function() {
|
| 897 |
+
followupWrap.style.bottom = '0px';
|
| 898 |
+
}, 350);
|
| 899 |
+
});
|
| 900 |
+
}
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
return null;
|
| 904 |
+
})()"""
|
| 905 |
+
demo.load(fn=None, inputs=None, outputs=None, js=_bb_js)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
if __name__ == "__main__":
|
| 909 |
+
print(f"[STARTUP] BreadBuddy starting on 0.0.0.0:7860")
|
| 910 |
+
print(f"[STARTUP] API_URL={API_URL}")
|
| 911 |
+
print(f"[STARTUP] Language: {LANG}")
|
| 912 |
+
demo.launch(
|
| 913 |
+
server_name="0.0.0.0",
|
| 914 |
+
server_port=7860,
|
| 915 |
+
share=False
|
| 916 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
deploy/config.py — API 配置常量
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
API_URL = os.environ.get(
|
| 7 |
+
"BREADBUDDY_API_URL",
|
| 8 |
+
"https://chinaestone--breadbuddy-gateway-gateway-serve.modal.run/v1/chat/completions"
|
| 9 |
+
)
|
| 10 |
+
MODEL_NAME = os.environ.get("BREADBUDDY_MODEL", "breadbuddy-multimodal")
|
| 11 |
+
API_KEY = os.environ.get("BREADBUDDY_API_KEY", "")
|
| 12 |
+
DEFAULT_TIMEOUT = int(os.environ.get("BREADBUDDY_TIMEOUT", "300"))
|
history.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
deploy/history.py — 单会话对话历史管理
|
| 3 |
+
|
| 4 |
+
简化版:只维护一个 diagnosis 会话(不再有 per-tab 历史)。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
MAX_HISTORY_TURNS = 10
|
| 8 |
+
_history: list[dict] = []
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_history() -> list[dict]:
|
| 12 |
+
"""Get conversation history, truncated to MAX_HISTORY_TURNS."""
|
| 13 |
+
if len(_history) > MAX_HISTORY_TURNS * 2:
|
| 14 |
+
return _history[-(MAX_HISTORY_TURNS * 2):]
|
| 15 |
+
return list(_history)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def append_to_history(user_msg: dict, assistant_msg: dict):
|
| 19 |
+
"""Append a turn (user + assistant) to history."""
|
| 20 |
+
_history.append(user_msg)
|
| 21 |
+
_history.append(assistant_msg)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def clear_history():
|
| 25 |
+
"""Clear all conversation history."""
|
| 26 |
+
_history.clear()
|
i18n.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
deploy/i18n.py — 国际化字符串 (English default)
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
LANG = os.environ.get("BREADBUDDY_LANG", "en")
|
| 7 |
+
|
| 8 |
+
STRINGS = {
|
| 9 |
+
"app_title": {"en": "BreadBuddy", "zh": "BreadBuddy"},
|
| 10 |
+
"app_subtitle": {
|
| 11 |
+
"en": "AI Bread Baking Assistant — Take a photo or describe your bread problem",
|
| 12 |
+
"zh": "AI 面包烘焙助手 — 拍张照片或描述面包问题"
|
| 13 |
+
},
|
| 14 |
+
"hero_text": {
|
| 15 |
+
"en": "Snap a photo, let me check your bread",
|
| 16 |
+
"zh": "拍张照片,让我看看你的面包怎么了"
|
| 17 |
+
},
|
| 18 |
+
"photo_label": {
|
| 19 |
+
"en": "📷 Tap to take a photo or upload",
|
| 20 |
+
"zh": "📷 点击拍照或上传照片"
|
| 21 |
+
},
|
| 22 |
+
"desc_placeholder": {
|
| 23 |
+
"en": "Describe your bread problem... (optional)",
|
| 24 |
+
"zh": "描述一下你的面包问题...(可选)"
|
| 25 |
+
},
|
| 26 |
+
"desc_value": {
|
| 27 |
+
"en": "Bread collapsed, inside is sticky and wet",
|
| 28 |
+
"zh": "面包塌陷,内部湿粘"
|
| 29 |
+
},
|
| 30 |
+
"temp_preset_label": {
|
| 31 |
+
"en": "Environment Presets",
|
| 32 |
+
"zh": "环境温湿度预设"
|
| 33 |
+
},
|
| 34 |
+
"preset_winter": {"en": "❄️ Winter 5-15°C", "zh": "❄️ 冬天 5-15°C"},
|
| 35 |
+
"preset_spring": {"en": "🌸 Spring 15-25°C", "zh": "🌸 春天 15-25°C"},
|
| 36 |
+
"preset_summer": {"en": "☀️ Summer 25-35°C", "zh": "☀️ 夏天 25-35°C"},
|
| 37 |
+
"preset_custom": {"en": "⚙️ Custom", "zh": "⚙️ 自定义"},
|
| 38 |
+
"btn_diagnose": {"en": "Start Diagnosis", "zh": "开始诊断"},
|
| 39 |
+
"btn_rediagnose": {"en": "← Restart", "zh": "← 重新诊断"},
|
| 40 |
+
"btn_followup_placeholder": {
|
| 41 |
+
"en": "Ask a follow-up... e.g. Can you explain the first cause?",
|
| 42 |
+
"zh": "继续追问... 例如:能详细说一下第二个原因吗?"
|
| 43 |
+
},
|
| 44 |
+
"loading_connecting": {
|
| 45 |
+
"en": "Connecting to AI...",
|
| 46 |
+
"zh": "正在连接 AI 诊断师..."
|
| 47 |
+
},
|
| 48 |
+
"loading_first_time": {
|
| 49 |
+
"en": "First time may take 30-60s to warm up the model",
|
| 50 |
+
"zh": "首次诊断需要约 30-60 秒预热模型"
|
| 51 |
+
},
|
| 52 |
+
"thinking": {"en": "Analyzing...", "zh": "正在思考..."},
|
| 53 |
+
"thinking_bread": {"en": "🧠 Analyzing bread photo...", "zh": "🔍 正在分析面包照片..."},
|
| 54 |
+
"thinking_text": {"en": "🧠 Analyzing your question...", "zh": "🧠 正在分析面包问题..."},
|
| 55 |
+
"empty_hint_title": {
|
| 56 |
+
"en": "Start Diagnosing Your Bread",
|
| 57 |
+
"zh": "开始诊断你的面包"
|
| 58 |
+
},
|
| 59 |
+
"empty_hint_subtitle": {
|
| 60 |
+
"en": "Upload a photo or describe the problem, then click \"Start Diagnosis\"",
|
| 61 |
+
"zh": "上传照片或描述问题,点击「开始诊断」"
|
| 62 |
+
},
|
| 63 |
+
"speed_prefix": {"en": "⏱️", "zh": "⏱️"},
|
| 64 |
+
"speed_thinking": {"en": "⏱️ thinking...", "zh": "⏱️ 思考中..."},
|
| 65 |
+
"api_error_prefix": {"en": "❌ API error: ", "zh": "❌ API error: "},
|
| 66 |
+
"network_error_prefix": {"en": "❌ Network error: ", "zh": "❌ 网络错误: "},
|
| 67 |
+
"api_url_not_configured": {
|
| 68 |
+
"en": "❌ API URL not configured",
|
| 69 |
+
"zh": "❌ API URL 未配置"
|
| 70 |
+
},
|
| 71 |
+
"timeout_error": {"en": "❌ Response timeout, try again", "zh": "❌ 推理服务响应超时,请稍后重试"},
|
| 72 |
+
"unavailable_error": {"en": "❌ Service unavailable, try again", "zh": "❌ 推理服务暂时不可用,请稍后重试"},
|
| 73 |
+
"accordion_label": {
|
| 74 |
+
"en": "🧠 Model Thinking (optional)",
|
| 75 |
+
"zh": "🧠 模型思考过程(可选)"
|
| 76 |
+
},
|
| 77 |
+
"followup_banner_prefix": {
|
| 78 |
+
"en": "🧠 Follow-up context: ",
|
| 79 |
+
"zh": "🧠 追问上下文:"
|
| 80 |
+
},
|
| 81 |
+
"footer_line1": {
|
| 82 |
+
"en": "\u00a9 2026 Rockstone Games. All rights reserved.",
|
| 83 |
+
"zh": "\u00a9 2026 Rockstone Games. All rights reserved."
|
| 84 |
+
},
|
| 85 |
+
"footer_line2": {
|
| 86 |
+
"en": "",
|
| 87 |
+
"zh": ""
|
| 88 |
+
},
|
| 89 |
+
"preset_q_detail": {"en": "🔍 Details on cause 1", "zh": "🔍 详细原因"},
|
| 90 |
+
"preset_q_ferment": {"en": "⏱️ Fermentation tips", "zh": "⏱️ 发酵调整"},
|
| 91 |
+
"preset_q_recipe": {"en": "📖 Simple recipe", "zh": "📖 简易配方"},
|
| 92 |
+
"preset_q_detail_prompt": {
|
| 93 |
+
"en": "Can you explain the first cause in detail?",
|
| 94 |
+
"zh": "能详细说一下第一个原因吗?"
|
| 95 |
+
},
|
| 96 |
+
"preset_q_ferment_prompt": {
|
| 97 |
+
"en": "How should I adjust fermentation time?",
|
| 98 |
+
"zh": "我该怎么调整发酵时间?"
|
| 99 |
+
},
|
| 100 |
+
"preset_q_recipe_prompt": {
|
| 101 |
+
"en": "Is there a simpler recipe?",
|
| 102 |
+
"zh": "有没有更简单的配方?"
|
| 103 |
+
},
|
| 104 |
+
"temperature_label": {"en": "Temperature", "zh": "温度"},
|
| 105 |
+
"humidity_label": {"en": "Humidity", "zh": "湿度"},
|
| 106 |
+
"lang_toggle_en": {"en": "中文", "zh": "EN"},
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
# System prompts
|
| 110 |
+
DIAGNOSIS_SYSTEM_PROMPT = {
|
| 111 |
+
"en": """You are a bread baking diagnostic assistant. Always respond in English.
|
| 112 |
+
|
| 113 |
+
Diagnose the bread problem and provide structured output using these exact section headers:
|
| 114 |
+
|
| 115 |
+
### Possible Causes
|
| 116 |
+
1. [Severity] Cause description
|
| 117 |
+
### Fixes
|
| 118 |
+
1. Fix suggestion
|
| 119 |
+
### Recommended Recipes
|
| 120 |
+
1. Recipe Name — Description
|
| 121 |
+
|
| 122 |
+
Severity levels: [Severe], [Moderate], [Mild]""",
|
| 123 |
+
"zh": """### 可能原因
|
| 124 |
+
1. 原因
|
| 125 |
+
### 修复建议
|
| 126 |
+
1. 建议
|
| 127 |
+
### 推荐配方
|
| 128 |
+
1. 配方名 — 描述""",
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
FOLLOWUP_SYSTEM_SUFFIX = {
|
| 132 |
+
"en": "\nAnswer the follow-up based on the conversation above.",
|
| 133 |
+
"zh": "\n基于以上对话回答追问。",
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
USER_PROMPT_FALLBACK = {
|
| 137 |
+
"en": "Please diagnose this bread photo",
|
| 138 |
+
"zh": "请诊断这张面包照片的问题",
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def t(key: str) -> str:
|
| 143 |
+
"""Get translated string for current language."""
|
| 144 |
+
s = STRINGS.get(key, {})
|
| 145 |
+
return s.get(LANG, s.get("en", key))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def set_lang(lang: str):
|
| 149 |
+
"""Switch language."""
|
| 150 |
+
global LANG
|
| 151 |
+
LANG = lang if lang in ("en", "zh") else "en"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def get_system_prompt() -> str:
|
| 155 |
+
return DIAGNOSIS_SYSTEM_PROMPT.get(LANG, DIAGNOSIS_SYSTEM_PROMPT["en"])
|
| 156 |
+
|
| 157 |
+
def get_followup_suffix() -> str:
|
| 158 |
+
return FOLLOWUP_SYSTEM_SUFFIX.get(LANG, FOLLOWUP_SYSTEM_SUFFIX["en"])
|
| 159 |
+
|
| 160 |
+
def get_user_fallback() -> str:
|
| 161 |
+
return USER_PROMPT_FALLBACK.get(LANG, USER_PROMPT_FALLBACK["en"])
|
llm.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
deploy/llm.py — LLM 流式/非流式调用
|
| 3 |
+
|
| 4 |
+
从 config 导入 API 配置,提供 stream_llm() 和 call_llm() 接口。
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
import urllib.request
|
| 9 |
+
import urllib.error
|
| 10 |
+
|
| 11 |
+
from config import API_URL, MODEL_NAME, API_KEY, DEFAULT_TIMEOUT
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _estimate_tokens(text):
|
| 15 |
+
"""Rough token estimate: ~1 token per 1.5 chars for mixed CJK + English."""
|
| 16 |
+
return max(1, int(len(text) / 1.5))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _format_speed(start_time, reason_buf, content_buf, reason_start, content_start):
|
| 20 |
+
"""Format live speed stats for the UI."""
|
| 21 |
+
elapsed = time.time() - start_time
|
| 22 |
+
parts = []
|
| 23 |
+
|
| 24 |
+
if reason_buf:
|
| 25 |
+
reason_tokens = _estimate_tokens(reason_buf)
|
| 26 |
+
reason_elapsed = time.time() - reason_start if reason_start else elapsed
|
| 27 |
+
if reason_elapsed > 0:
|
| 28 |
+
reason_speed = reason_tokens / reason_elapsed
|
| 29 |
+
parts.append(f"🧠 {reason_speed:.0f} tok/s")
|
| 30 |
+
|
| 31 |
+
if content_buf:
|
| 32 |
+
content_tokens = _estimate_tokens(content_buf)
|
| 33 |
+
content_elapsed = time.time() - content_start if content_start else elapsed
|
| 34 |
+
if content_elapsed > 0:
|
| 35 |
+
content_speed = content_tokens / content_elapsed
|
| 36 |
+
parts.append(f"💬 {content_speed:.0f} tok/s")
|
| 37 |
+
|
| 38 |
+
total = _estimate_tokens(reason_buf) + _estimate_tokens(content_buf)
|
| 39 |
+
if total > 1:
|
| 40 |
+
parts.append(f"📊 {total} tok")
|
| 41 |
+
|
| 42 |
+
if not parts:
|
| 43 |
+
return "⏱️ 思考中..."
|
| 44 |
+
|
| 45 |
+
return " · ".join(parts)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def stream_llm(messages, max_tokens=4096, temperature=0.7):
|
| 49 |
+
"""Stream the LLM API (Modal OpenAI-compatible endpoint).
|
| 50 |
+
|
| 51 |
+
Yields (content, reasoning, speed) tuples.
|
| 52 |
+
Uses a 50ms debounce buffer: accumulates chunks within 50ms windows
|
| 53 |
+
before yielding, reducing Gradio frontend repaint frequency.
|
| 54 |
+
"""
|
| 55 |
+
if not API_URL:
|
| 56 |
+
yield "❌ API URL 未配置", "", "⏱️ --"
|
| 57 |
+
return
|
| 58 |
+
|
| 59 |
+
payload = json.dumps({
|
| 60 |
+
"model": MODEL_NAME,
|
| 61 |
+
"messages": messages,
|
| 62 |
+
"max_tokens": max_tokens,
|
| 63 |
+
"temperature": temperature,
|
| 64 |
+
"stream": True
|
| 65 |
+
}).encode()
|
| 66 |
+
|
| 67 |
+
headers = {
|
| 68 |
+
"Content-Type": "application/json",
|
| 69 |
+
"Accept": "text/event-stream"
|
| 70 |
+
}
|
| 71 |
+
if API_KEY:
|
| 72 |
+
headers["Authorization"] = f"Bearer {API_KEY}"
|
| 73 |
+
|
| 74 |
+
req = urllib.request.Request(API_URL, data=payload, headers=headers)
|
| 75 |
+
|
| 76 |
+
DEBOUNCE_MS = 50 # batch chunks within this window
|
| 77 |
+
|
| 78 |
+
reason_buf = ""
|
| 79 |
+
content_buf = ""
|
| 80 |
+
start_time = time.time()
|
| 81 |
+
reason_start = None
|
| 82 |
+
content_start = None
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
|
| 86 |
+
last_yield_time = time.time()
|
| 87 |
+
done = False
|
| 88 |
+
|
| 89 |
+
for line in resp:
|
| 90 |
+
line = line.decode("utf-8").strip()
|
| 91 |
+
if not line or not line.startswith("data: "):
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
data = line[6:]
|
| 95 |
+
if data == "[DONE]":
|
| 96 |
+
done = True
|
| 97 |
+
# Final yield with remaining buffer
|
| 98 |
+
break
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
chunk = json.loads(data)
|
| 102 |
+
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
| 103 |
+
|
| 104 |
+
if "reasoning_content" in delta and delta["reasoning_content"]:
|
| 105 |
+
if reason_start is None:
|
| 106 |
+
reason_start = time.time()
|
| 107 |
+
reason_buf += delta["reasoning_content"]
|
| 108 |
+
|
| 109 |
+
if "content" in delta and delta["content"]:
|
| 110 |
+
if content_start is None:
|
| 111 |
+
content_start = time.time()
|
| 112 |
+
content_buf += delta["content"]
|
| 113 |
+
|
| 114 |
+
# Debounce: only yield if enough time has passed
|
| 115 |
+
now = time.time()
|
| 116 |
+
if (now - last_yield_time) * 1000 >= DEBOUNCE_MS:
|
| 117 |
+
speed = _format_speed(start_time, reason_buf, content_buf, reason_start, content_start)
|
| 118 |
+
yield content_buf or "🧠 思考中...", reason_buf, speed
|
| 119 |
+
last_yield_time = now
|
| 120 |
+
|
| 121 |
+
except json.JSONDecodeError:
|
| 122 |
+
continue
|
| 123 |
+
|
| 124 |
+
# Always yield final state after stream ends
|
| 125 |
+
speed = _format_speed(start_time, reason_buf, content_buf, reason_start, content_start)
|
| 126 |
+
yield content_buf or "🧠 思考中...", reason_buf, speed
|
| 127 |
+
|
| 128 |
+
except urllib.error.URLError as e:
|
| 129 |
+
yield f"❌ 网络错误: {e}", reason_buf, "⏱️ --"
|
| 130 |
+
except Exception as e:
|
| 131 |
+
yield f"❌ API error: {e}", reason_buf, "⏱️ --"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def call_llm(messages, max_tokens=4096, temperature=0.7):
|
| 135 |
+
"""Call the LLM API and return the final (content, reasoning) tuple."""
|
| 136 |
+
for content, reasoning, _ in stream_llm(messages, max_tokens, temperature):
|
| 137 |
+
pass
|
| 138 |
+
return content, reasoning
|
parsers.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
deploy/parsers.py — 诊断结果解析与渲染
|
| 3 |
+
|
| 4 |
+
新格式支持:
|
| 5 |
+
- 严重程度:[严重] → severe, [中等] → moderate, [轻微] → mild
|
| 6 |
+
- 修复标签:🔧 → fix, ⏱️ → ferment
|
| 7 |
+
- 配方折叠:### 推荐配方 section
|
| 8 |
+
- CSS 类名:cause-chip, severe-chip, chip-dot, fix-tag, recipe-accordion
|
| 9 |
+
"""
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _esc(text):
|
| 14 |
+
"""Escape HTML special characters."""
|
| 15 |
+
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ─── 严重程度映射 (EN + CN) ───
|
| 19 |
+
_SEVERITY_MAP = {
|
| 20 |
+
"severe": "severe", "Severe": "severe", "严重": "severe",
|
| 21 |
+
"moderate": "moderate", "Moderate": "moderate", "中等": "moderate",
|
| 22 |
+
"mild": "mild", "Mild": "mild", "轻微": "mild",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
# ─── 修复标签映射 ───
|
| 26 |
+
_FIX_TAG_MAP = {
|
| 27 |
+
"🔧": "fix",
|
| 28 |
+
"⏱️": "ferment",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def parse_diagnosis_result(raw_text):
|
| 33 |
+
"""解析诊断结果为结构化数据(新格式)。
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
dict with keys: causes, fixes, recipes
|
| 37 |
+
causes: [{"text": str, "severity": "severe"|"moderate"|"mild"}]
|
| 38 |
+
fixes: [{"text": str, "tag": "fix"|"ferment"}]
|
| 39 |
+
recipes: [{"name": str, "desc": str}]
|
| 40 |
+
"""
|
| 41 |
+
result = {
|
| 42 |
+
"causes": [],
|
| 43 |
+
"fixes": [],
|
| 44 |
+
"recipes": [],
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
if not raw_text or not raw_text.strip():
|
| 48 |
+
return result
|
| 49 |
+
|
| 50 |
+
# ─── Parse Possible Causes ───
|
| 51 |
+
causes_match = re.search(r'#{1,4}\s*(?:Possible Causes|可能原因)\s*\n([\s\S]*?)(?=#{1,4}\s*[^\s#]|$)', raw_text)
|
| 52 |
+
if causes_match:
|
| 53 |
+
causes_text = causes_match.group(1)
|
| 54 |
+
for line in causes_text.split('\n'):
|
| 55 |
+
line = line.strip()
|
| 56 |
+
if not line:
|
| 57 |
+
continue
|
| 58 |
+
if line[0].isdigit() or line.startswith('-'):
|
| 59 |
+
# 去除编号前缀
|
| 60 |
+
cause = re.sub(r'^[\d\-]+[\.\)、]\s*', '', line).strip()
|
| 61 |
+
if not cause:
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
# Parse severity [Severe] / [Moderate] / [Mild] or [严重] / [中等] / [轻微]
|
| 65 |
+
severity = "moderate" # default
|
| 66 |
+
sev_match = re.match(r'\[([A-Za-z\u4e00-\u9fff]+)\]\s*(.*)', cause)
|
| 67 |
+
if sev_match:
|
| 68 |
+
sev_label = sev_match.group(1)
|
| 69 |
+
cause = sev_match.group(2).strip()
|
| 70 |
+
severity = _SEVERITY_MAP.get(sev_label, "moderate")
|
| 71 |
+
|
| 72 |
+
if cause:
|
| 73 |
+
result["causes"].append({
|
| 74 |
+
"text": cause,
|
| 75 |
+
"severity": severity,
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
# ─── Parse Fixes ───
|
| 79 |
+
fixes_match = re.search(r'#{1,4}\s*(?:Fixes|修复建议|修复与发酵建议)\s*\n([\s\S]*?)(?=#{1,4}\s*[^\s#]|$)', raw_text)
|
| 80 |
+
if fixes_match:
|
| 81 |
+
fixes_text = fixes_match.group(1)
|
| 82 |
+
for line in fixes_text.split('\n'):
|
| 83 |
+
line = line.strip()
|
| 84 |
+
if not line:
|
| 85 |
+
continue
|
| 86 |
+
if line[0].isdigit() or line.startswith('-'):
|
| 87 |
+
fix = re.sub(r'^[\d\-]+[\.\)、]\s*', '', line).strip()
|
| 88 |
+
if not fix:
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
# 解析标签 🔧 / ⏱️
|
| 92 |
+
tag = "fix" # 默认
|
| 93 |
+
for emoji, tag_name in _FIX_TAG_MAP.items():
|
| 94 |
+
if fix.startswith(emoji):
|
| 95 |
+
tag = tag_name
|
| 96 |
+
# 去除 emoji 和后续空格
|
| 97 |
+
fix = re.sub(rf'^{re.escape(emoji)}\s*', '', fix).strip()
|
| 98 |
+
break
|
| 99 |
+
|
| 100 |
+
if fix:
|
| 101 |
+
result["fixes"].append({
|
| 102 |
+
"text": fix,
|
| 103 |
+
"tag": tag,
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
# ─── Parse Recommended Recipes ───
|
| 107 |
+
recipes_match = re.search(r'#{1,4}\s*(?:Recommended Recipes|推荐配方)\s*\n([\s\S]*?)(?=#{1,4}\s*[^\s#]|$)', raw_text)
|
| 108 |
+
if recipes_match:
|
| 109 |
+
recipes_text = recipes_match.group(1)
|
| 110 |
+
for line in recipes_text.split('\n'):
|
| 111 |
+
line = line.strip()
|
| 112 |
+
if not line:
|
| 113 |
+
continue
|
| 114 |
+
if line[0].isdigit() or line.startswith('-'):
|
| 115 |
+
recipe_line = re.sub(r'^[\d\-]+[\.\)、]\s*', '', line).strip()
|
| 116 |
+
if not recipe_line:
|
| 117 |
+
continue
|
| 118 |
+
|
| 119 |
+
# 解析 "配方名 — 描述" 或 "配方名 - 描述"
|
| 120 |
+
name = recipe_line
|
| 121 |
+
desc = ""
|
| 122 |
+
dash_match = re.match(r'^(.+?)\s*[—\-–]\s*(.+)$', recipe_line)
|
| 123 |
+
if dash_match:
|
| 124 |
+
name = dash_match.group(1).strip()
|
| 125 |
+
desc = dash_match.group(2).strip()
|
| 126 |
+
|
| 127 |
+
if name:
|
| 128 |
+
result["recipes"].append({
|
| 129 |
+
"name": name,
|
| 130 |
+
"desc": desc,
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
return result
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def render_structured_result(raw_text, ambient_temp=25):
|
| 137 |
+
"""将原始文本渲染为结构化 HTML(新格式)。
|
| 138 |
+
|
| 139 |
+
CSS 类名���齐 v1.0.5 卡片升级:
|
| 140 |
+
- .result-card > .card-section
|
| 141 |
+
- .diag-cards > .diag-card.severe/.moderate/.mild (垂直卡片布局)
|
| 142 |
+
- .diag-card-header > .diag-card-title + .badge-severe/.moderate/.mild
|
| 143 |
+
- .diag-card-desc (分行描述)
|
| 144 |
+
- .fix-list > .fix-item > .fix-tag.fix/.ferment
|
| 145 |
+
- .recipe-accordion > .recipe-header > .recipe-list
|
| 146 |
+
- .stats-bar
|
| 147 |
+
"""
|
| 148 |
+
result = parse_diagnosis_result(raw_text)
|
| 149 |
+
html_parts = []
|
| 150 |
+
html_parts.append('<div class="result-card">')
|
| 151 |
+
|
| 152 |
+
has_content = False
|
| 153 |
+
|
| 154 |
+
# ─── 原因 Diagnostic Cards ───
|
| 155 |
+
if result["causes"]:
|
| 156 |
+
has_content = True
|
| 157 |
+
html_parts.append('<div class="card-section">')
|
| 158 |
+
html_parts.append('<div class="section-title">Diagnosis</div>')
|
| 159 |
+
html_parts.append('<div class="diag-cards">')
|
| 160 |
+
for cause in result["causes"][:6]:
|
| 161 |
+
sev = cause["severity"]
|
| 162 |
+
severity_label = {"severe": "Severe", "moderate": "Moderate", "mild": "Mild"}.get(sev, "")
|
| 163 |
+
# Split title and description on first ": " or " — "
|
| 164 |
+
raw = cause["text"]
|
| 165 |
+
title, desc = raw, ""
|
| 166 |
+
for sep in [": ", " — ", " - "]:
|
| 167 |
+
idx = raw.find(sep)
|
| 168 |
+
if 0 < idx < 60:
|
| 169 |
+
title = raw[:idx].strip()
|
| 170 |
+
desc = raw[idx + len(sep):].strip()
|
| 171 |
+
break
|
| 172 |
+
html_parts.append(
|
| 173 |
+
f'<div class="diag-card {sev}">'
|
| 174 |
+
f'<div class="diag-card-header">'
|
| 175 |
+
f'<span class="diag-card-title">{_esc(title)}</span>'
|
| 176 |
+
f'<span class="badge-{sev}">{severity_label}</span>'
|
| 177 |
+
f'</div>'
|
| 178 |
+
)
|
| 179 |
+
if desc:
|
| 180 |
+
html_parts.append(f'<div class="diag-card-desc">{_esc(desc)}</div>')
|
| 181 |
+
html_parts.append('</div>')
|
| 182 |
+
html_parts.append('</div></div>')
|
| 183 |
+
|
| 184 |
+
# ─── 修复建议列表 ───
|
| 185 |
+
if result["fixes"]:
|
| 186 |
+
has_content = True
|
| 187 |
+
html_parts.append('<div class="card-section">')
|
| 188 |
+
html_parts.append('<div class="section-title">Fixes & Tips</div>')
|
| 189 |
+
html_parts.append('<ol class="fix-list">')
|
| 190 |
+
for fix in result["fixes"][:8]:
|
| 191 |
+
tag = fix["tag"]
|
| 192 |
+
tag_label = "Fix" if tag == "fix" else "Ferment"
|
| 193 |
+
html_parts.append(
|
| 194 |
+
f'<li class="fix-item">'
|
| 195 |
+
f'<span class="fix-tag {tag}">{tag_label}</span>'
|
| 196 |
+
f'<span class="fix-text">{_esc(fix["text"])}</span>'
|
| 197 |
+
f'</li>'
|
| 198 |
+
)
|
| 199 |
+
html_parts.append('</ol></div>')
|
| 200 |
+
|
| 201 |
+
# ─── 推荐配方折叠区 ───
|
| 202 |
+
if result["recipes"]:
|
| 203 |
+
has_content = True
|
| 204 |
+
count = len(result["recipes"])
|
| 205 |
+
first_name = result["recipes"][0]["name"] if result["recipes"] else ""
|
| 206 |
+
html_parts.append('<div class="card-section">')
|
| 207 |
+
html_parts.append('<div class="section-title">Recommended Recipes</div>')
|
| 208 |
+
html_parts.append('<div class="recipe-accordion" id="recipeAccordion">')
|
| 209 |
+
html_parts.append(
|
| 210 |
+
f'<div class="recipe-header" id="recipeHeader">'
|
| 211 |
+
f'<span>{count} recipe{"s" if count != 1 else ""} · Click to expand</span>'
|
| 212 |
+
f'<span class="recipe-arrow">▼</span>'
|
| 213 |
+
f'</div>'
|
| 214 |
+
)
|
| 215 |
+
html_parts.append('<div class="recipe-list" id="recipeList">')
|
| 216 |
+
for recipe in result["recipes"][:5]:
|
| 217 |
+
html_parts.append(
|
| 218 |
+
f'<div class="recipe-item">'
|
| 219 |
+
f'<span class="recipe-emoji">🍞</span>'
|
| 220 |
+
f'<div>'
|
| 221 |
+
f'<div class="recipe-name">{_esc(recipe["name"])}</div>'
|
| 222 |
+
f'<div class="recipe-desc">{_esc(recipe["desc"])}</div>'
|
| 223 |
+
f'</div></div>'
|
| 224 |
+
)
|
| 225 |
+
html_parts.append('</div></div></div>')
|
| 226 |
+
|
| 227 |
+
# ─── Fallback: 没有匹配到结构化内容时,显示原始文本 ───
|
| 228 |
+
if not has_content and raw_text and raw_text.strip():
|
| 229 |
+
display_text = raw_text.strip()
|
| 230 |
+
# 去除 "🧠 思考中..." 占位符
|
| 231 |
+
if display_text.startswith("🧠 思考中"):
|
| 232 |
+
lines = display_text.split('\n')
|
| 233 |
+
display_text = '\n'.join(l for l in lines if not l.strip().startswith('🧠 思考中')).strip()
|
| 234 |
+
if display_text:
|
| 235 |
+
html_parts.append('<div class="card-section">')
|
| 236 |
+
html_parts.append(f'<div style="font-size:.9rem;line-height:1.6;color:var(--text);white-space:pre-wrap">{_esc(display_text)}</div>')
|
| 237 |
+
html_parts.append('</div>')
|
| 238 |
+
|
| 239 |
+
# ─── 速度统计 ───
|
| 240 |
+
html_parts.append('<div class="stats-bar" id="statsBar">')
|
| 241 |
+
html_parts.append('<span>⏱️ --</span>')
|
| 242 |
+
html_parts.append('</div>')
|
| 243 |
+
|
| 244 |
+
html_parts.append('</div>')
|
| 245 |
+
return "\n".join(html_parts)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def render_empty_hint():
|
| 249 |
+
"""渲染空状态提示 HTML(兼容旧接口,实际文案由 app.py 的 gr.HTML 控制)"""
|
| 250 |
+
return (
|
| 251 |
+
'<div class="result-card">'
|
| 252 |
+
'<div class="card-section" style="text-align:center;padding:40px 20px">'
|
| 253 |
+
'<p style="font-size:1.1rem;color:var(--text);font-weight:600">Start Diagnosing Your Bread</p>'
|
| 254 |
+
'<p style="font-size:.85rem;color:var(--text-muted)">Upload a photo or describe the problem</p>'
|
| 255 |
+
'</div></div>'
|
| 256 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0,<7.0
|