Estone commited on
Commit
e494200
·
0 Parent(s):

deploy: sync with GitHub 3cfa37b — English UI, card style, Rockstone footer

Browse files
Files changed (8) hide show
  1. README.md +11 -0
  2. app.py +916 -0
  3. config.py +12 -0
  4. history.py +26 -0
  5. i18n.py +161 -0
  6. llm.py +138 -0
  7. parsers.py +256 -0
  8. 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>&copy; 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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