Spaces:
Runtime error
Runtime error
J.B-Lin commited on
Commit ·
0155d1a
1
Parent(s): 33aa89f
feat: UI 全面重构 — 首页语音启动页+简报卡片+AI思考状态、全双工 Chatbot、CSS 美化、家庭/总结/报告 Tab 微调、中英文完整支持
Browse files- app.py +1 -6
- ui/app_builder.py +486 -148
- utils.py +176 -0
app.py
CHANGED
|
@@ -27,9 +27,4 @@ if __name__ == "__main__":
|
|
| 27 |
|
| 28 |
# 创建并启动 Gradio 应用
|
| 29 |
demo = create_app(loop)
|
| 30 |
-
demo.launch(
|
| 31 |
-
theme=gr.themes.Soft(
|
| 32 |
-
primary_hue="pink",
|
| 33 |
-
secondary_hue="green",
|
| 34 |
-
)
|
| 35 |
-
)
|
|
|
|
| 27 |
|
| 28 |
# 创建并启动 Gradio 应用
|
| 29 |
demo = create_app(loop)
|
| 30 |
+
demo.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ui/app_builder.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
PregoPal - Gradio 界面构建
|
| 3 |
===========================
|
| 4 |
-
|
| 5 |
-
1.
|
| 6 |
-
2.
|
| 7 |
-
3.
|
| 8 |
-
4.
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
import datetime
|
|
@@ -18,7 +19,7 @@ from modules.nutrition_analyzer import NutritionAnalyzer
|
|
| 18 |
from modules.family_manager import RecipeManager, PreferenceManager, MemoryManager
|
| 19 |
from modules.diet_extractor import DietExtractor
|
| 20 |
from modules.nutrition_standards import DRIsParser
|
| 21 |
-
from utils import t, ZH, EN
|
| 22 |
|
| 23 |
# ============================================================
|
| 24 |
# 全局实例(单例)
|
|
@@ -29,131 +30,418 @@ diet_logger = DietLogger()
|
|
| 29 |
nutrition_analyzer = NutritionAnalyzer()
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def _build_voice_tab(lang_state):
|
| 33 |
-
"""构建语音交互页面(
|
| 34 |
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
| 35 |
|
|
|
|
| 36 |
with gr.Row():
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
with gr.Column(scale=1):
|
| 51 |
-
gr.
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
member_name = gr.Textbox(label=t("member_name", lang), placeholder="小明")
|
| 55 |
member_relation = gr.Dropdown(
|
| 56 |
label=t("member_relation", lang),
|
| 57 |
choices=ZH["relation_choices"] if lang == "zh" else EN["relation_choices"],
|
| 58 |
-
value=ZH["relation_choices"][0] if lang == "zh" else EN["relation_choices"][0]
|
| 59 |
)
|
|
|
|
| 60 |
register_btn = gr.Button(t("btn_register", lang), variant="primary", size="sm")
|
| 61 |
-
register_result = gr.Textbox(label="", interactive=False, lines=1)
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
identify_btn = gr.Button(t("btn_identify", lang), variant="primary", size="sm")
|
| 66 |
-
identify_result = gr.Textbox(label="", interactive=False, lines=1)
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
|
|
|
| 70 |
|
| 71 |
-
# 对话事件
|
| 72 |
def respond(message, chat_history):
|
| 73 |
if not message:
|
| 74 |
-
return "", chat_history
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
extracted = DietExtractor.robust_extract(message)
|
| 77 |
-
response_parts = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
if extracted["preferences"]:
|
| 108 |
-
for pref in extracted["preferences"]:
|
| 109 |
-
PreferenceManager.add_member(
|
| 110 |
-
name=pref.get("人员", ""),
|
| 111 |
-
role="家人",
|
| 112 |
-
preferences=pref.get("内容", "") if pref.get("类型") == "偏好" else "",
|
| 113 |
-
avoid=pref.get("内容", "") if pref.get("类型") == "忌口" else "",
|
| 114 |
-
allergies=pref.get("内容", "") if pref.get("类型") == "过敏" else "",
|
| 115 |
-
)
|
| 116 |
-
response_parts.append("✅ " + (t("btn_save", lang) if lang == "zh" else "Saved preferences"))
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
response_parts.append("
|
| 130 |
-
elif any(kw in message for kw in ["菜谱", "会做", "做饭"]):
|
| 131 |
-
response_parts.append("好的,我来记录家庭菜谱!")
|
| 132 |
-
else:
|
| 133 |
-
response_parts.append("有什么我可以帮你的吗?比如记录饮食、推荐食谱、或者聊聊今天的感受。")
|
| 134 |
|
| 135 |
response = "\n\n".join(response_parts)
|
| 136 |
chat_history.append((message, response))
|
| 137 |
-
return "", chat_history
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
register_btn.click(
|
| 144 |
fn=lambda name, rel, audio: voiceprint_mgr.register_member(name, rel, audio),
|
| 145 |
inputs=[member_name, member_relation, audio_register],
|
| 146 |
-
outputs=[
|
| 147 |
)
|
| 148 |
identify_btn.click(
|
| 149 |
fn=lambda audio: voiceprint_mgr.identify_speaker(audio),
|
| 150 |
inputs=[audio_identify],
|
| 151 |
-
outputs=[
|
| 152 |
)
|
| 153 |
|
| 154 |
return chatbot, msg, send_btn, clear_btn, briefing_display
|
| 155 |
|
| 156 |
|
|
|
|
|
|
|
|
|
|
| 157 |
def _build_family_tab(lang_state):
|
| 158 |
"""构建家庭饮食习惯页面(动态语言)"""
|
| 159 |
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
|
@@ -166,11 +454,23 @@ def _build_family_tab(lang_state):
|
|
| 166 |
pref_role = gr.Dropdown(
|
| 167 |
label=t("pref_role", lang),
|
| 168 |
choices=ZH["relation_choices"] if lang == "zh" else EN["relation_choices"],
|
| 169 |
-
value=ZH["relation_choices"][0] if lang == "zh" else EN["relation_choices"][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
)
|
| 171 |
-
pref_likes = gr.Textbox(label=t("pref_likes", lang), placeholder=t("pref_likes_placeholder", lang), lines=1)
|
| 172 |
-
pref_avoid = gr.Textbox(label=t("pref_avoid", lang), placeholder=t("pref_avoid_placeholder", lang), lines=1)
|
| 173 |
-
pref_allergy = gr.Textbox(label=t("pref_allergy", lang), placeholder=t("pref_allergy_placeholder", lang), lines=1)
|
| 174 |
save_pref_btn = gr.Button(t("btn_save", lang), variant="primary", size="sm")
|
| 175 |
|
| 176 |
with gr.Column():
|
|
@@ -180,7 +480,7 @@ def _build_family_tab(lang_state):
|
|
| 180 |
save_pref_btn.click(
|
| 181 |
fn=lambda n, r, p, a, al: PreferenceManager.add_member(n, r, p, a, al, ""),
|
| 182 |
inputs=[pref_name, pref_role, pref_likes, pref_avoid, pref_allergy],
|
| 183 |
-
outputs=[gr.Textbox()]
|
| 184 |
).then(fn=lambda: PreferenceManager.format_for_prompt(), outputs=pref_display)
|
| 185 |
|
| 186 |
refresh_pref_btn.click(fn=lambda: PreferenceManager.format_for_prompt(), outputs=pref_display)
|
|
@@ -188,14 +488,24 @@ def _build_family_tab(lang_state):
|
|
| 188 |
with gr.Tab(t("tab_recipes", lang)):
|
| 189 |
with gr.Row():
|
| 190 |
with gr.Column():
|
| 191 |
-
recipe_name = gr.Textbox(
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
recipe_diff = gr.Dropdown(
|
| 194 |
label=t("recipe_diff", lang),
|
| 195 |
choices=ZH["recipe_diff_choices"] if lang == "zh" else EN["recipe_diff_choices"],
|
| 196 |
-
value=ZH["recipe_diff_choices"][1] if lang == "zh" else EN["recipe_diff_choices"][1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
)
|
| 198 |
-
recipe_ingredients = gr.Textbox(label=t("recipe_ingredients", lang), placeholder=t("recipe_ingredients_placeholder", lang), lines=1)
|
| 199 |
save_recipe_btn = gr.Button(t("btn_add", lang), variant="primary", size="sm")
|
| 200 |
|
| 201 |
with gr.Column():
|
|
@@ -205,7 +515,7 @@ def _build_family_tab(lang_state):
|
|
| 205 |
save_recipe_btn.click(
|
| 206 |
fn=lambda n, c, d, i: RecipeManager.add_recipe(n, c, d, i, ""),
|
| 207 |
inputs=[recipe_name, recipe_cook, recipe_diff, recipe_ingredients],
|
| 208 |
-
outputs=[gr.Textbox()]
|
| 209 |
).then(fn=lambda: RecipeManager.format_for_prompt(), outputs=recipe_display)
|
| 210 |
|
| 211 |
refresh_recipe_btn.click(fn=lambda: RecipeManager.format_for_prompt(), outputs=recipe_display)
|
|
@@ -216,9 +526,13 @@ def _build_family_tab(lang_state):
|
|
| 216 |
memory_type = gr.Dropdown(
|
| 217 |
label=t("memory_type", lang),
|
| 218 |
choices=ZH["memory_type_choices"] if lang == "zh" else EN["memory_type_choices"],
|
| 219 |
-
value=ZH["memory_type_choices"][0] if lang == "zh" else EN["memory_type_choices"][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
)
|
| 221 |
-
memory_content = gr.Textbox(label=t("memory_content", lang), placeholder=t("memory_content_placeholder", lang), lines=2)
|
| 222 |
save_memory_btn = gr.Button(t("btn_save_memory", lang), variant="primary", size="sm")
|
| 223 |
|
| 224 |
with gr.Column():
|
|
@@ -237,6 +551,9 @@ def _build_family_tab(lang_state):
|
|
| 237 |
refresh_memory_btn.click(fn=lambda: MemoryManager.format_for_prompt(), outputs=memory_display)
|
| 238 |
|
| 239 |
|
|
|
|
|
|
|
|
|
|
| 240 |
def _build_summary_tab(lang_state):
|
| 241 |
"""构建三天总结页面(动态语言)"""
|
| 242 |
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
|
@@ -246,7 +563,7 @@ def _build_summary_tab(lang_state):
|
|
| 246 |
trimester_select = gr.Radio(
|
| 247 |
label=t("trimester_label", lang),
|
| 248 |
choices=ZH["trimester_choices"] if lang == "zh" else EN["trimester_choices"],
|
| 249 |
-
value=ZH["trimester_choices"][1] if lang == "zh" else EN["trimester_choices"][1]
|
| 250 |
)
|
| 251 |
run_3day_btn = gr.Button(t("btn_analyze", lang), variant="primary")
|
| 252 |
|
|
@@ -281,10 +598,13 @@ def _build_summary_tab(lang_state):
|
|
| 281 |
run_3day_btn.click(
|
| 282 |
fn=run_three_day_summary,
|
| 283 |
inputs=[trimester_select],
|
| 284 |
-
outputs=[three_day_result, menu_suggestion]
|
| 285 |
)
|
| 286 |
|
| 287 |
|
|
|
|
|
|
|
|
|
|
| 288 |
def _build_report_tab(lang_state):
|
| 289 |
"""构建营养报告页面(动态语言)"""
|
| 290 |
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
|
@@ -321,19 +641,50 @@ def _build_report_tab(lang_state):
|
|
| 321 |
export_md_btn.click(fn=export_report, inputs=[analysis_days], outputs=export_result)
|
| 322 |
|
| 323 |
|
|
|
|
|
|
|
|
|
|
| 324 |
def create_app(loop=None):
|
| 325 |
-
"""创建主应用(
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
# ============================================================
|
| 336 |
-
# 语言状态
|
| 337 |
# ============================================================
|
| 338 |
lang_state = gr.State(value="zh")
|
| 339 |
|
|
@@ -342,55 +693,42 @@ def create_app(loop=None):
|
|
| 342 |
# ============================================================
|
| 343 |
with gr.Row():
|
| 344 |
with gr.Column(scale=4):
|
| 345 |
-
gr.Markdown(
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
#
|
| 349 |
-
|
| 350 |
-
)
|
| 351 |
with gr.Column(scale=1, min_width=120):
|
| 352 |
lang_selector = gr.Radio(
|
| 353 |
label="🌐 Language",
|
| 354 |
choices=["中文", "English"],
|
| 355 |
value="中文",
|
| 356 |
-
interactive=True
|
| 357 |
)
|
| 358 |
|
| 359 |
-
# ============================================================
|
| 360 |
-
# 语言切换回调:更新所有组件的 label
|
| 361 |
-
# ============================================================
|
| 362 |
def switch_lang(choice):
|
| 363 |
-
|
| 364 |
-
return new_lang
|
| 365 |
|
| 366 |
-
lang_selector.change(
|
| 367 |
-
fn=switch_lang,
|
| 368 |
-
inputs=[lang_selector],
|
| 369 |
-
outputs=[lang_state]
|
| 370 |
-
)
|
| 371 |
|
| 372 |
# ============================================================
|
| 373 |
-
#
|
| 374 |
# ============================================================
|
| 375 |
-
with gr.
|
| 376 |
-
|
|
|
|
| 377 |
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
# ============================================================
|
| 381 |
-
with gr.Tab(t("tab_family", "zh"), id="tab_family"):
|
| 382 |
-
_build_family_tab(lang_state)
|
| 383 |
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
# ============================================================
|
| 387 |
-
with gr.Tab(t("tab_summary", "zh"), id="tab_summary"):
|
| 388 |
-
_build_summary_tab(lang_state)
|
| 389 |
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
|
| 396 |
-
return demo
|
|
|
|
| 1 |
"""
|
| 2 |
PregoPal - Gradio 界面构建
|
| 3 |
===========================
|
| 4 |
+
5 个子页面 + 中英文切换 + 自定义 CSS:
|
| 5 |
+
1. 🏠 首页(语音启动 + 简报卡片 + AI 思考状态)
|
| 6 |
+
2. 🎙️ 语音交互(全双工 Chatbot + 声纹面板)
|
| 7 |
+
3. 👨👩👧👦 家庭饮食习惯
|
| 8 |
+
4. 📊 三天总结
|
| 9 |
+
5. 📈 营养报告
|
| 10 |
"""
|
| 11 |
|
| 12 |
import datetime
|
|
|
|
| 19 |
from modules.family_manager import RecipeManager, PreferenceManager, MemoryManager
|
| 20 |
from modules.diet_extractor import DietExtractor
|
| 21 |
from modules.nutrition_standards import DRIsParser
|
| 22 |
+
from utils import t, ZH, EN, get_home_cards, CUSTOM_CSS
|
| 23 |
|
| 24 |
# ============================================================
|
| 25 |
# 全局实例(单例)
|
|
|
|
| 30 |
nutrition_analyzer = NutritionAnalyzer()
|
| 31 |
|
| 32 |
|
| 33 |
+
# ============================================================
|
| 34 |
+
# Tab 1: 🏠 首页
|
| 35 |
+
# ============================================================
|
| 36 |
+
def _build_home_tab(loop, lang_state):
|
| 37 |
+
"""构建首页(语音启动页 + 简报卡片 + AI 思考状态)"""
|
| 38 |
+
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
| 39 |
+
|
| 40 |
+
# 提取简报数据
|
| 41 |
+
cards = get_home_cards(loop)
|
| 42 |
+
|
| 43 |
+
# ========== 语言相关文本 ==========
|
| 44 |
+
_home = {
|
| 45 |
+
"zh": {
|
| 46 |
+
"title": "# 🌸 欢迎来到 PregoPal",
|
| 47 |
+
"subtitle": "### 你的孕期AI伴侣,时刻陪伴在你身边",
|
| 48 |
+
"tap_speak": "🎙️ 点击说话",
|
| 49 |
+
"tap_hint": "全双工语音交互,边说边听",
|
| 50 |
+
"trimester_label": "孕期阶段",
|
| 51 |
+
"nutrition_focus": "营养关注",
|
| 52 |
+
"today_diet": "昨日饮食",
|
| 53 |
+
"family_recipe": "家庭菜谱",
|
| 54 |
+
"weight_label": "体重管理",
|
| 55 |
+
"thinking_label": "🤔 AI 思考中...",
|
| 56 |
+
"thinking_placeholder": "等待对话中...",
|
| 57 |
+
"meal_count_unit": "餐",
|
| 58 |
+
"no_data": "暂无数据",
|
| 59 |
+
"no_record": "暂无记录",
|
| 60 |
+
"recipes_unit": "道家常菜",
|
| 61 |
+
"no_recipe": "还没有菜谱",
|
| 62 |
+
},
|
| 63 |
+
"en": {
|
| 64 |
+
"title": "# 🌸 Welcome to PregoPal",
|
| 65 |
+
"subtitle": "### Your AI pregnancy companion, always by your side",
|
| 66 |
+
"tap_speak": "🎙️ Tap to Speak",
|
| 67 |
+
"tap_hint": "Full-duplex voice interaction",
|
| 68 |
+
"trimester_label": "Trimester",
|
| 69 |
+
"nutrition_focus": "Nutrition Focus",
|
| 70 |
+
"today_diet": "Yesterday's Diet",
|
| 71 |
+
"family_recipe": "Family Recipes",
|
| 72 |
+
"weight_label": "Weight",
|
| 73 |
+
"thinking_label": "🤔 AI Thinking...",
|
| 74 |
+
"thinking_placeholder": "Waiting for conversation...",
|
| 75 |
+
"meal_count_unit": " meals",
|
| 76 |
+
"no_data": "No data",
|
| 77 |
+
"no_record": "No records",
|
| 78 |
+
"recipes_unit": " recipes",
|
| 79 |
+
"no_recipe": "No recipes yet",
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
T = _home[lang]
|
| 83 |
+
|
| 84 |
+
# ========== 卡片数据渲染 ==========
|
| 85 |
+
|
| 86 |
+
# 营养关注
|
| 87 |
+
nutrition_display = "、".join(cards["focus_nutrients"][:5]) if cards["focus_nutrients"] else T["no_data"]
|
| 88 |
+
if cards["recommended_foods"]:
|
| 89 |
+
food_str = "、".join(cards["recommended_foods"][:5])
|
| 90 |
+
nutrition_display += f"\n\n🍽️ 推荐:{food_str}"
|
| 91 |
+
|
| 92 |
+
# 昨日饮食
|
| 93 |
+
diet_display = cards["yesterday_summary"]
|
| 94 |
+
if cards["meal_count"] > 0:
|
| 95 |
+
diet_display = f"{diet_display}\n({cards['meal_count']}{T['meal_count_unit']})"
|
| 96 |
+
|
| 97 |
+
# 家庭菜谱
|
| 98 |
+
if cards["recipe_count"] > 0:
|
| 99 |
+
recipe_display = f"{cards['recipe_count']}{T['recipes_unit']}:{'、'.join(cards['recipe_names'])}"
|
| 100 |
+
else:
|
| 101 |
+
recipe_display = T["no_recipe"]
|
| 102 |
+
|
| 103 |
+
# 体重
|
| 104 |
+
weight_display = cards["weight_status"]
|
| 105 |
+
if cards["weight_trend"]:
|
| 106 |
+
weight_display += f" | {cards['weight_trend']}"
|
| 107 |
+
|
| 108 |
+
# 思考关键词
|
| 109 |
+
thinking_text = cards["thinking_keywords"] if cards["thinking_keywords"] else T["thinking_placeholder"]
|
| 110 |
+
|
| 111 |
+
# ========== 布局:上部语音按钮 + 下部卡片行 ==========
|
| 112 |
+
with gr.Column(elem_classes=["home-container"]):
|
| 113 |
+
# 标题
|
| 114 |
+
gr.Markdown(T["title"])
|
| 115 |
+
gr.Markdown(T["subtitle"])
|
| 116 |
+
|
| 117 |
+
# 语音启动按钮(居中)
|
| 118 |
+
with gr.Row():
|
| 119 |
+
with gr.Column(scale=1):
|
| 120 |
+
pass
|
| 121 |
+
with gr.Column(scale=2):
|
| 122 |
+
gr.HTML(f"""
|
| 123 |
+
<div style="text-align: center; padding: 24px 0;">
|
| 124 |
+
<button class="voice-main-btn" onclick="document.querySelectorAll('.tabs button')[1].click()">
|
| 125 |
+
🎙️<br><span style="font-size:14px">{T['tap_speak']}</span>
|
| 126 |
+
</button>
|
| 127 |
+
<p style="margin-top: 12px; color: #999; font-size: 14px;">{T['tap_hint']}</p>
|
| 128 |
+
</div>
|
| 129 |
+
""")
|
| 130 |
+
with gr.Column(scale=1):
|
| 131 |
+
pass
|
| 132 |
+
|
| 133 |
+
# AI 思考状态(实时显示)
|
| 134 |
+
thinking_box = gr.Textbox(
|
| 135 |
+
value=f"{T['thinking_label']}\n{thinking_text}",
|
| 136 |
+
label="",
|
| 137 |
+
interactive=False,
|
| 138 |
+
lines=2,
|
| 139 |
+
elem_classes=["thinking-box"],
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# ========== 卡片行 ==========
|
| 143 |
+
with gr.Row():
|
| 144 |
+
# 孕期阶段卡片
|
| 145 |
+
with gr.Column(scale=1):
|
| 146 |
+
with gr.Group(elem_classes=["home-card", "card-trimester"]):
|
| 147 |
+
gr.Markdown(f"### 📅 {T['trimester_label']}")
|
| 148 |
+
gr.Markdown(f"**{cards['trimester']}**")
|
| 149 |
+
|
| 150 |
+
# 营养关注卡片
|
| 151 |
+
with gr.Column(scale=1):
|
| 152 |
+
with gr.Group(elem_classes=["home-card", "card-nutrition"]):
|
| 153 |
+
gr.Markdown(f"### 🥗 {T['nutrition_focus']}")
|
| 154 |
+
gr.Markdown(nutrition_display)
|
| 155 |
+
|
| 156 |
+
# 昨日饮食卡片
|
| 157 |
+
with gr.Column(scale=1):
|
| 158 |
+
with gr.Group(elem_classes=["home-card", "card-diet"]):
|
| 159 |
+
gr.Markdown(f"### 🍽️ {T['today_diet']}")
|
| 160 |
+
gr.Markdown(diet_display)
|
| 161 |
+
|
| 162 |
+
with gr.Row():
|
| 163 |
+
# 家庭菜谱卡片
|
| 164 |
+
with gr.Column(scale=1):
|
| 165 |
+
with gr.Group(elem_classes=["home-card", "card-recipe"]):
|
| 166 |
+
gr.Markdown(f"### 🍳 {T['family_recipe']}")
|
| 167 |
+
gr.Markdown(recipe_display)
|
| 168 |
+
|
| 169 |
+
# 体重管理卡片
|
| 170 |
+
with gr.Column(scale=1):
|
| 171 |
+
with gr.Group(elem_classes=["home-card", "card-weight"]):
|
| 172 |
+
gr.Markdown(f"### ⚖️ {T['weight_label']}")
|
| 173 |
+
gr.Markdown(weight_display)
|
| 174 |
+
|
| 175 |
+
return thinking_box
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ============================================================
|
| 179 |
+
# Tab 2: 🎙️ 语音交互
|
| 180 |
+
# ============================================================
|
| 181 |
def _build_voice_tab(lang_state):
|
| 182 |
+
"""构建语音交互页面(全双工 Chatbot + 折叠声纹面板)"""
|
| 183 |
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
| 184 |
|
| 185 |
+
# ========== 顶部:全双工状态指示器 ==========
|
| 186 |
with gr.Row():
|
| 187 |
+
full_duplex_status = gr.HTML("""
|
| 188 |
+
<div style="padding: 8px 16px; text-align: center;">
|
| 189 |
+
<span class="status-dot idle"></span>
|
| 190 |
+
<span style="color: #666; font-size: 14px;">⏸️ 待命中 — 输入文字或点击下方麦克风开始对话</span>
|
| 191 |
+
</div>
|
| 192 |
+
""")
|
| 193 |
+
|
| 194 |
+
# ========== 中部:Chatbot 对话历史 ==========
|
| 195 |
+
chatbot = gr.Chatbot(
|
| 196 |
+
label=t("chat_label", lang),
|
| 197 |
+
height=420,
|
| 198 |
+
bubble_full_width=False,
|
| 199 |
+
avatar_images=(None, "🌸"),
|
| 200 |
+
)
|
| 201 |
|
| 202 |
+
# ========== 底部:输入区域(文字 + 二合一语音按钮) ==========
|
| 203 |
+
with gr.Row():
|
| 204 |
+
msg = gr.Textbox(
|
| 205 |
+
label="",
|
| 206 |
+
placeholder=t("msg_placeholder", lang),
|
| 207 |
+
scale=5,
|
| 208 |
+
show_label=False,
|
| 209 |
+
elem_classes=["chat-input"],
|
| 210 |
+
)
|
| 211 |
+
send_btn = gr.Button(t("btn_send", lang), variant="primary", scale=1)
|
| 212 |
+
|
| 213 |
+
with gr.Row():
|
| 214 |
+
with gr.Column(scale=4):
|
| 215 |
+
audio_input = gr.Audio(
|
| 216 |
+
label="🎤 语音输入",
|
| 217 |
+
type="filepath",
|
| 218 |
+
sources=["microphone", "upload"],
|
| 219 |
+
)
|
| 220 |
with gr.Column(scale=1):
|
| 221 |
+
clear_btn = gr.Button(t("btn_clear", lang), size="sm")
|
| 222 |
+
auto_process = gr.Checkbox(label="自动发送", value=True, scale=0)
|
| 223 |
+
|
| 224 |
+
# ========== 右侧折叠面板:声纹管理 ==========
|
| 225 |
+
with gr.Accordion(t("voice_title", lang) if "###" not in t("voice_title", lang) else "🔊 声纹识别", open=False):
|
| 226 |
+
with gr.Row():
|
| 227 |
+
# 注册区
|
| 228 |
+
with gr.Column(scale=1):
|
| 229 |
+
gr.Markdown("#### 📝 注册新成员")
|
| 230 |
member_name = gr.Textbox(label=t("member_name", lang), placeholder="小明")
|
| 231 |
member_relation = gr.Dropdown(
|
| 232 |
label=t("member_relation", lang),
|
| 233 |
choices=ZH["relation_choices"] if lang == "zh" else EN["relation_choices"],
|
| 234 |
+
value=ZH["relation_choices"][0] if lang == "zh" else EN["relation_choices"][0],
|
| 235 |
)
|
| 236 |
+
audio_register = gr.Audio(label="录制声纹", type="filepath", sources=["microphone"])
|
| 237 |
register_btn = gr.Button(t("btn_register", lang), variant="primary", size="sm")
|
| 238 |
+
register_result = gr.Textbox(label="", interactive=False, lines=1, show_label=False)
|
| 239 |
|
| 240 |
+
# 识别区
|
| 241 |
+
with gr.Column(scale=1):
|
| 242 |
+
gr.Markdown("#### 🔍 识别说话人")
|
| 243 |
+
audio_identify = gr.Audio(label="上传语音", type="filepath", sources=["upload", "microphone"])
|
| 244 |
identify_btn = gr.Button(t("btn_identify", lang), variant="primary", size="sm")
|
| 245 |
+
identify_result = gr.Textbox(label="", interactive=False, lines=1, show_label=False)
|
| 246 |
|
| 247 |
+
# ========== 底部:今日简报 JSON ==========
|
| 248 |
+
with gr.Accordion(t("briefing_title", lang) if "###" not in t("briefing_title", lang) else "📋 今日简报", open=False):
|
| 249 |
+
briefing_display = gr.JSON(value={}, label="")
|
| 250 |
|
| 251 |
+
# ========== 对话事件处理 ==========
|
| 252 |
def respond(message, chat_history):
|
| 253 |
if not message:
|
| 254 |
+
return "", chat_history, full_duplex_status.value
|
| 255 |
+
|
| 256 |
+
# 更新状态为活跃
|
| 257 |
+
active_status = """
|
| 258 |
+
<div style="padding: 8px 16px; text-align: center;">
|
| 259 |
+
<span class="status-dot active"></span>
|
| 260 |
+
<span style="color: #4CAF50; font-size: 14px;">🔄 处理中...</span>
|
| 261 |
+
</div>
|
| 262 |
+
"""
|
| 263 |
|
| 264 |
extracted = DietExtractor.robust_extract(message)
|
| 265 |
+
response_parts = []
|
| 266 |
+
|
| 267 |
+
if lang == "zh":
|
| 268 |
+
greeting = "👋 收到!我来帮你处理..."
|
| 269 |
+
if extracted["diets"]:
|
| 270 |
+
for diet in extracted["diets"]:
|
| 271 |
+
meals_str = "、".join(f"{k}: {v}" for k, v in diet.get("meals", {}).items())
|
| 272 |
+
response_parts.append(f"✅ 已记录饮食:{meals_str}")
|
| 273 |
+
date_str = diet.get("日期", datetime.date.today().isoformat())
|
| 274 |
+
diet_logger.add_record(
|
| 275 |
+
member_name=diet.get("记录人", "用户"),
|
| 276 |
+
member_relation="家人",
|
| 277 |
+
date=date_str,
|
| 278 |
+
meals=diet.get("meals", {}),
|
| 279 |
+
notes=diet.get("备注", ""),
|
| 280 |
+
)
|
| 281 |
|
| 282 |
+
if extracted["recipes"]:
|
| 283 |
+
for recipe in extracted["recipes"]:
|
| 284 |
+
name = recipe.get("菜名", "")
|
| 285 |
+
if name:
|
| 286 |
+
RecipeManager.add_recipe(
|
| 287 |
+
name=name,
|
| 288 |
+
cook=recipe.get("制作人", ""),
|
| 289 |
+
difficulty=recipe.get("难度", ""),
|
| 290 |
+
ingredients=recipe.get("食材", ""),
|
| 291 |
+
notes=recipe.get("备注", ""),
|
| 292 |
+
)
|
| 293 |
+
response_parts.append(f"✅ 已收藏菜谱:{name}")
|
| 294 |
+
|
| 295 |
+
if extracted["preferences"]:
|
| 296 |
+
for pref in extracted["preferences"]:
|
| 297 |
+
PreferenceManager.add_member(
|
| 298 |
+
name=pref.get("人员", ""),
|
| 299 |
+
role="家人",
|
| 300 |
+
preferences=pref.get("内容", "") if pref.get("类型") == "偏好" else "",
|
| 301 |
+
avoid=pref.get("内容", "") if pref.get("类型") == "忌口" else "",
|
| 302 |
+
allergies=pref.get("内容", "") if pref.get("类型") == "过敏" else "",
|
| 303 |
+
)
|
| 304 |
+
response_parts.append("✅ 已保存饮食偏好")
|
| 305 |
+
|
| 306 |
+
if extracted["weights"]:
|
| 307 |
+
for w in extracted["weights"]:
|
| 308 |
+
response_parts.append(f"✅ 已记录体重:{w.get('体重', '')}kg")
|
| 309 |
+
|
| 310 |
+
if extracted["thinking"]:
|
| 311 |
+
step = extracted["thinking"].get("当前步骤", "")
|
| 312 |
+
next_step = extracted["thinking"].get("下一步", "")
|
| 313 |
+
response_parts.append(f"🤔 当前:{step} → {next_step}")
|
| 314 |
+
|
| 315 |
+
if len(response_parts) == 0:
|
| 316 |
+
response_parts.append(greeting)
|
| 317 |
+
if any(kw in message for kw in ["吃了", "想吃", "午餐", "晚餐", "早餐"]):
|
| 318 |
+
response_parts.append("我来帮你记录!下次可以直接说「我吃了...」我会自动记录。")
|
| 319 |
+
elif any(kw in message for kw in ["菜谱", "会做", "做饭"]):
|
| 320 |
+
response_parts.append("好的,我来记录家庭菜谱!")
|
| 321 |
+
else:
|
| 322 |
+
response_parts.append("有什么我可以帮你的吗?比如记录饮食、推荐食谱、或者聊聊今天的感受。")
|
| 323 |
+
else:
|
| 324 |
+
greeting = "👋 Got it! Let me help..."
|
| 325 |
+
if extracted["diets"]:
|
| 326 |
+
for diet in extracted["diets"]:
|
| 327 |
+
meals_str = ", ".join(f"{k}: {v}" for k, v in diet.get("meals", {}).items())
|
| 328 |
+
response_parts.append(f"✅ Logged diet: {meals_str}")
|
| 329 |
+
date_str = diet.get("日期", datetime.date.today().isoformat())
|
| 330 |
+
diet_logger.add_record(
|
| 331 |
+
member_name=diet.get("记录人", "User"),
|
| 332 |
+
member_relation="Family",
|
| 333 |
+
date=date_str,
|
| 334 |
+
meals=diet.get("meals", {}),
|
| 335 |
+
notes=diet.get("备注", ""),
|
| 336 |
+
)
|
| 337 |
|
| 338 |
+
if extracted["recipes"]:
|
| 339 |
+
for recipe in extracted["recipes"]:
|
| 340 |
+
name = recipe.get("菜名", "")
|
| 341 |
+
if name:
|
| 342 |
+
RecipeManager.add_recipe(
|
| 343 |
+
name=name,
|
| 344 |
+
cook=recipe.get("制作人", ""),
|
| 345 |
+
difficulty=recipe.get("难度", ""),
|
| 346 |
+
ingredients=recipe.get("食材", ""),
|
| 347 |
+
notes=recipe.get("备注", ""),
|
| 348 |
+
)
|
| 349 |
+
response_parts.append(f"✅ Saved recipe: {name}")
|
| 350 |
+
|
| 351 |
+
if extracted["preferences"]:
|
| 352 |
+
for pref in extracted["preferences"]:
|
| 353 |
+
PreferenceManager.add_member(
|
| 354 |
+
name=pref.get("人员", ""),
|
| 355 |
+
role="Family",
|
| 356 |
+
preferences=pref.get("内容", "") if pref.get("类型") == "偏好" else "",
|
| 357 |
+
avoid=pref.get("内容", "") if pref.get("类型") == "忌口" else "",
|
| 358 |
+
allergies=pref.get("内容", "") if pref.get("类型") == "过敏" else "",
|
| 359 |
)
|
| 360 |
+
response_parts.append("✅ Saved preferences")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
|
| 362 |
+
if extracted["weights"]:
|
| 363 |
+
for w in extracted["weights"]:
|
| 364 |
+
response_parts.append(f"✅ Logged weight: {w.get('体重', '')}kg")
|
| 365 |
|
| 366 |
+
if extracted["thinking"]:
|
| 367 |
+
step = extracted["thinking"].get("当前步骤", "")
|
| 368 |
+
next_step = extracted["thinking"].get("下一步", "")
|
| 369 |
+
response_parts.append(f"🤔 Current: {step} → {next_step}")
|
| 370 |
|
| 371 |
+
if len(response_parts) == 0:
|
| 372 |
+
response_parts.append(greeting)
|
| 373 |
+
response_parts.append("How can I help? Record your diet, recommend recipes, or just chat!")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
response = "\n\n".join(response_parts)
|
| 376 |
chat_history.append((message, response))
|
|
|
|
| 377 |
|
| 378 |
+
# 回到待命状态
|
| 379 |
+
idle_status = """
|
| 380 |
+
<div style="padding: 8px 16px; text-align: center;">
|
| 381 |
+
<span class="status-dot idle"></span>
|
| 382 |
+
<span style="color: #666; font-size: 14px;">⏸️ 待命中</span>
|
| 383 |
+
</div>
|
| 384 |
+
"""
|
| 385 |
+
|
| 386 |
+
return "", chat_history, idle_status
|
| 387 |
+
|
| 388 |
+
def process_audio(audio_path, auto_send, chat_history):
|
| 389 |
+
"""处理语音输入(预留 AI 语音识别接口)"""
|
| 390 |
+
if not audio_path:
|
| 391 |
+
return None, chat_history, full_duplex_status.value
|
| 392 |
+
|
| 393 |
+
# TODO: 对接 core/voice_processor.py 的 ASR
|
| 394 |
+
# 当前返回提示信息等待 AI 接入
|
| 395 |
+
active_status = """
|
| 396 |
+
<div style="padding: 8px 16px; text-align: center;">
|
| 397 |
+
<span class="status-dot active"></span>
|
| 398 |
+
<span style="color: #4CAF50; font-size: 14px;">🔊 语音识别中...</span>
|
| 399 |
+
</div>
|
| 400 |
+
"""
|
| 401 |
+
|
| 402 |
+
response = "🎤 语音已接收!(语音识别模块开发中,请暂时使用文字输入)"
|
| 403 |
+
|
| 404 |
+
if auto_send:
|
| 405 |
+
# 模拟:将音频作为消息处理
|
| 406 |
+
pass # 后续接入 ASR 后替换此逻辑
|
| 407 |
+
|
| 408 |
+
idle_status = """
|
| 409 |
+
<div style="padding: 8px 16px; text-align: center;">
|
| 410 |
+
<span class="status-dot idle"></span>
|
| 411 |
+
<span style="color: #666; font-size: 14px;">⏸️ 待命中</span>
|
| 412 |
+
</div>
|
| 413 |
+
"""
|
| 414 |
+
|
| 415 |
+
return None, chat_history, idle_status
|
| 416 |
+
|
| 417 |
+
# 绑定事件
|
| 418 |
+
send_btn.click(respond, inputs=[msg, chatbot], outputs=[msg, chatbot, full_duplex_status])
|
| 419 |
+
msg.submit(respond, inputs=[msg, chatbot], outputs=[msg, chatbot, full_duplex_status])
|
| 420 |
+
clear_btn.click(lambda: ([], full_duplex_status.value), outputs=[chatbot, full_duplex_status])
|
| 421 |
+
|
| 422 |
+
audio_input.stop_recording(
|
| 423 |
+
fn=process_audio,
|
| 424 |
+
inputs=[audio_input, auto_process, chatbot],
|
| 425 |
+
outputs=[audio_input, chatbot, full_duplex_status],
|
| 426 |
+
)
|
| 427 |
|
| 428 |
register_btn.click(
|
| 429 |
fn=lambda name, rel, audio: voiceprint_mgr.register_member(name, rel, audio),
|
| 430 |
inputs=[member_name, member_relation, audio_register],
|
| 431 |
+
outputs=[register_result],
|
| 432 |
)
|
| 433 |
identify_btn.click(
|
| 434 |
fn=lambda audio: voiceprint_mgr.identify_speaker(audio),
|
| 435 |
inputs=[audio_identify],
|
| 436 |
+
outputs=[identify_result],
|
| 437 |
)
|
| 438 |
|
| 439 |
return chatbot, msg, send_btn, clear_btn, briefing_display
|
| 440 |
|
| 441 |
|
| 442 |
+
# ============================================================
|
| 443 |
+
# Tab 3: 👨👩👧👦 家庭饮食习惯(保持原有逻辑,微调配色)
|
| 444 |
+
# ============================================================
|
| 445 |
def _build_family_tab(lang_state):
|
| 446 |
"""构建家庭饮食习惯页面(动态语言)"""
|
| 447 |
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
|
|
|
| 454 |
pref_role = gr.Dropdown(
|
| 455 |
label=t("pref_role", lang),
|
| 456 |
choices=ZH["relation_choices"] if lang == "zh" else EN["relation_choices"],
|
| 457 |
+
value=ZH["relation_choices"][0] if lang == "zh" else EN["relation_choices"][0],
|
| 458 |
+
)
|
| 459 |
+
pref_likes = gr.Textbox(
|
| 460 |
+
label=t("pref_likes", lang),
|
| 461 |
+
placeholder=t("pref_likes_placeholder", lang),
|
| 462 |
+
lines=1,
|
| 463 |
+
)
|
| 464 |
+
pref_avoid = gr.Textbox(
|
| 465 |
+
label=t("pref_avoid", lang),
|
| 466 |
+
placeholder=t("pref_avoid_placeholder", lang),
|
| 467 |
+
lines=1,
|
| 468 |
+
)
|
| 469 |
+
pref_allergy = gr.Textbox(
|
| 470 |
+
label=t("pref_allergy", lang),
|
| 471 |
+
placeholder=t("pref_allergy_placeholder", lang),
|
| 472 |
+
lines=1,
|
| 473 |
)
|
|
|
|
|
|
|
|
|
|
| 474 |
save_pref_btn = gr.Button(t("btn_save", lang), variant="primary", size="sm")
|
| 475 |
|
| 476 |
with gr.Column():
|
|
|
|
| 480 |
save_pref_btn.click(
|
| 481 |
fn=lambda n, r, p, a, al: PreferenceManager.add_member(n, r, p, a, al, ""),
|
| 482 |
inputs=[pref_name, pref_role, pref_likes, pref_avoid, pref_allergy],
|
| 483 |
+
outputs=[gr.Textbox()],
|
| 484 |
).then(fn=lambda: PreferenceManager.format_for_prompt(), outputs=pref_display)
|
| 485 |
|
| 486 |
refresh_pref_btn.click(fn=lambda: PreferenceManager.format_for_prompt(), outputs=pref_display)
|
|
|
|
| 488 |
with gr.Tab(t("tab_recipes", lang)):
|
| 489 |
with gr.Row():
|
| 490 |
with gr.Column():
|
| 491 |
+
recipe_name = gr.Textbox(
|
| 492 |
+
label=t("recipe_name", lang),
|
| 493 |
+
placeholder=t("recipe_name_placeholder", lang),
|
| 494 |
+
)
|
| 495 |
+
recipe_cook = gr.Textbox(
|
| 496 |
+
label=t("recipe_cook", lang),
|
| 497 |
+
placeholder=t("recipe_cook_placeholder", lang),
|
| 498 |
+
)
|
| 499 |
recipe_diff = gr.Dropdown(
|
| 500 |
label=t("recipe_diff", lang),
|
| 501 |
choices=ZH["recipe_diff_choices"] if lang == "zh" else EN["recipe_diff_choices"],
|
| 502 |
+
value=ZH["recipe_diff_choices"][1] if lang == "zh" else EN["recipe_diff_choices"][1],
|
| 503 |
+
)
|
| 504 |
+
recipe_ingredients = gr.Textbox(
|
| 505 |
+
label=t("recipe_ingredients", lang),
|
| 506 |
+
placeholder=t("recipe_ingredients_placeholder", lang),
|
| 507 |
+
lines=1,
|
| 508 |
)
|
|
|
|
| 509 |
save_recipe_btn = gr.Button(t("btn_add", lang), variant="primary", size="sm")
|
| 510 |
|
| 511 |
with gr.Column():
|
|
|
|
| 515 |
save_recipe_btn.click(
|
| 516 |
fn=lambda n, c, d, i: RecipeManager.add_recipe(n, c, d, i, ""),
|
| 517 |
inputs=[recipe_name, recipe_cook, recipe_diff, recipe_ingredients],
|
| 518 |
+
outputs=[gr.Textbox()],
|
| 519 |
).then(fn=lambda: RecipeManager.format_for_prompt(), outputs=recipe_display)
|
| 520 |
|
| 521 |
refresh_recipe_btn.click(fn=lambda: RecipeManager.format_for_prompt(), outputs=recipe_display)
|
|
|
|
| 526 |
memory_type = gr.Dropdown(
|
| 527 |
label=t("memory_type", lang),
|
| 528 |
choices=ZH["memory_type_choices"] if lang == "zh" else EN["memory_type_choices"],
|
| 529 |
+
value=ZH["memory_type_choices"][0] if lang == "zh" else EN["memory_type_choices"][0],
|
| 530 |
+
)
|
| 531 |
+
memory_content = gr.Textbox(
|
| 532 |
+
label=t("memory_content", lang),
|
| 533 |
+
placeholder=t("memory_content_placeholder", lang),
|
| 534 |
+
lines=2,
|
| 535 |
)
|
|
|
|
| 536 |
save_memory_btn = gr.Button(t("btn_save_memory", lang), variant="primary", size="sm")
|
| 537 |
|
| 538 |
with gr.Column():
|
|
|
|
| 551 |
refresh_memory_btn.click(fn=lambda: MemoryManager.format_for_prompt(), outputs=memory_display)
|
| 552 |
|
| 553 |
|
| 554 |
+
# ============================================================
|
| 555 |
+
# Tab 4: 📊 三天总结(保持原有逻辑)
|
| 556 |
+
# ============================================================
|
| 557 |
def _build_summary_tab(lang_state):
|
| 558 |
"""构建三天总结页面(动态语言)"""
|
| 559 |
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
|
|
|
| 563 |
trimester_select = gr.Radio(
|
| 564 |
label=t("trimester_label", lang),
|
| 565 |
choices=ZH["trimester_choices"] if lang == "zh" else EN["trimester_choices"],
|
| 566 |
+
value=ZH["trimester_choices"][1] if lang == "zh" else EN["trimester_choices"][1],
|
| 567 |
)
|
| 568 |
run_3day_btn = gr.Button(t("btn_analyze", lang), variant="primary")
|
| 569 |
|
|
|
|
| 598 |
run_3day_btn.click(
|
| 599 |
fn=run_three_day_summary,
|
| 600 |
inputs=[trimester_select],
|
| 601 |
+
outputs=[three_day_result, menu_suggestion],
|
| 602 |
)
|
| 603 |
|
| 604 |
|
| 605 |
+
# ============================================================
|
| 606 |
+
# Tab 5: 📈 营养报告(保持原有逻辑)
|
| 607 |
+
# ============================================================
|
| 608 |
def _build_report_tab(lang_state):
|
| 609 |
"""构建营养报告页面(动态语言)"""
|
| 610 |
lang = lang_state.value if hasattr(lang_state, 'value') else "zh"
|
|
|
|
| 641 |
export_md_btn.click(fn=export_report, inputs=[analysis_days], outputs=export_result)
|
| 642 |
|
| 643 |
|
| 644 |
+
# ============================================================
|
| 645 |
+
# 主入口:创建应用
|
| 646 |
+
# ============================================================
|
| 647 |
def create_app(loop=None):
|
| 648 |
+
"""创建主应用(5 Tab + 中英文切换 + 自定义 CSS)"""
|
| 649 |
+
|
| 650 |
+
with gr.Blocks(
|
| 651 |
+
title="PregoPal - 孕期陪护AI助手",
|
| 652 |
+
theme=gr.themes.Soft(
|
| 653 |
+
primary_hue="pink",
|
| 654 |
+
secondary_hue="emerald",
|
| 655 |
+
),
|
| 656 |
+
css=CUSTOM_CSS,
|
| 657 |
+
head="""
|
| 658 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 659 |
+
<style>
|
| 660 |
+
:root {
|
| 661 |
+
--card-bg: linear-gradient(135deg, #FFF5F7 0%, #FFFFFF 100%);
|
| 662 |
+
--card-border: #FCE4EC;
|
| 663 |
+
--primary-gradient: linear-gradient(135deg, #FF6B8A 0%, #FF4081 100%);
|
| 664 |
+
}
|
| 665 |
+
.home-card {
|
| 666 |
+
border-radius: 16px !important;
|
| 667 |
+
padding: 16px !important;
|
| 668 |
+
background: var(--card-bg) !important;
|
| 669 |
+
border: 1px solid var(--card-border) !important;
|
| 670 |
+
box-shadow: 0 2px 12px rgba(255, 105, 135, 0.08) !important;
|
| 671 |
+
min-height: 140px;
|
| 672 |
+
}
|
| 673 |
+
.thinking-box textarea {
|
| 674 |
+
background: linear-gradient(135deg, #F3E5F5, #FFF) !important;
|
| 675 |
+
border: 1px solid #E1BEE7 !important;
|
| 676 |
+
border-radius: 16px !important;
|
| 677 |
+
font-size: 15px !important;
|
| 678 |
+
text-align: center !important;
|
| 679 |
+
color: #7B1FA2 !important;
|
| 680 |
+
}
|
| 681 |
+
footer { display: none !important; }
|
| 682 |
+
</style>
|
| 683 |
+
""",
|
| 684 |
+
) as demo:
|
| 685 |
|
| 686 |
# ============================================================
|
| 687 |
+
# 语言状态
|
| 688 |
# ============================================================
|
| 689 |
lang_state = gr.State(value="zh")
|
| 690 |
|
|
|
|
| 693 |
# ============================================================
|
| 694 |
with gr.Row():
|
| 695 |
with gr.Column(scale=4):
|
| 696 |
+
gr.Markdown(f"""
|
| 697 |
+
<div style="text-align: left; padding: 8px 0;">
|
| 698 |
+
<h1 style="margin: 0;">🌸 PregoPal</h1>
|
| 699 |
+
<p style="color: #999; margin: 4px 0 0 0; font-size: 16px;">孕期陪护AI助手 — 温馨的家庭式伴侣</p>
|
| 700 |
+
</div>
|
| 701 |
+
""")
|
| 702 |
with gr.Column(scale=1, min_width=120):
|
| 703 |
lang_selector = gr.Radio(
|
| 704 |
label="🌐 Language",
|
| 705 |
choices=["中文", "English"],
|
| 706 |
value="中文",
|
| 707 |
+
interactive=True,
|
| 708 |
)
|
| 709 |
|
|
|
|
|
|
|
|
|
|
| 710 |
def switch_lang(choice):
|
| 711 |
+
return "zh" if choice == "中文" else "en"
|
|
|
|
| 712 |
|
| 713 |
+
lang_selector.change(fn=switch_lang, inputs=[lang_selector], outputs=[lang_state])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
|
| 715 |
# ============================================================
|
| 716 |
+
# 5 个 Tab
|
| 717 |
# ============================================================
|
| 718 |
+
with gr.Tabs():
|
| 719 |
+
with gr.Tab("🏠 首页", id="tab_home"):
|
| 720 |
+
_build_home_tab(loop, lang_state)
|
| 721 |
|
| 722 |
+
with gr.Tab(t("tab_voice", "zh"), id="tab_voice"):
|
| 723 |
+
_build_voice_tab(lang_state)
|
|
|
|
|
|
|
|
|
|
| 724 |
|
| 725 |
+
with gr.Tab(t("tab_family", "zh"), id="tab_family"):
|
| 726 |
+
_build_family_tab(lang_state)
|
|
|
|
|
|
|
|
|
|
| 727 |
|
| 728 |
+
with gr.Tab(t("tab_summary", "zh"), id="tab_summary"):
|
| 729 |
+
_build_summary_tab(lang_state)
|
| 730 |
+
|
| 731 |
+
with gr.Tab(t("tab_report", "zh"), id="tab_report"):
|
| 732 |
+
_build_report_tab(lang_state)
|
| 733 |
|
| 734 |
+
return demo
|
utils.py
CHANGED
|
@@ -216,3 +216,179 @@ def t(key: str, lang: str = "zh") -> str:
|
|
| 216 |
"""翻译函数"""
|
| 217 |
d = ZH if lang == "zh" else EN
|
| 218 |
return d.get(key, key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
"""翻译函数"""
|
| 217 |
d = ZH if lang == "zh" else EN
|
| 218 |
return d.get(key, key)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ============================================================
|
| 222 |
+
# 首页卡片数据提取
|
| 223 |
+
# ============================================================
|
| 224 |
+
|
| 225 |
+
def get_home_cards(loop=None):
|
| 226 |
+
"""从 Loop 上下文提取首页卡片数据"""
|
| 227 |
+
briefing = loop.get_briefing() if loop else {}
|
| 228 |
+
|
| 229 |
+
# 孕期阶段
|
| 230 |
+
trimester = briefing.get("trimester", "孕中期")
|
| 231 |
+
|
| 232 |
+
# 最近营养关注点
|
| 233 |
+
focus_nutrients = briefing.get("dri_analysis", {}).get("focus_nutrients", [])
|
| 234 |
+
recommended_foods = briefing.get("recommended_foods", [])
|
| 235 |
+
|
| 236 |
+
# 今日推荐菜品(取前3)
|
| 237 |
+
from modules.family_manager import RecipeManager
|
| 238 |
+
all_recipes = RecipeManager.load_all()
|
| 239 |
+
recipe_count = len(all_recipes)
|
| 240 |
+
recipe_names = [r.get("name", "") for r in all_recipes[:3]]
|
| 241 |
+
|
| 242 |
+
# 昨日饮食摘要
|
| 243 |
+
yesterday = briefing.get("yesterday_diet", {})
|
| 244 |
+
yesterday_summary = yesterday.get("summary", "暂无记录")
|
| 245 |
+
meal_count = yesterday.get("meal_count", 0)
|
| 246 |
+
|
| 247 |
+
# 体重评估
|
| 248 |
+
weight_eval = briefing.get("weight_evaluation", {})
|
| 249 |
+
weight_status = weight_eval.get("status", "暂无数据")
|
| 250 |
+
weight_trend = weight_eval.get("trend", "")
|
| 251 |
+
|
| 252 |
+
# AI 思考关键词
|
| 253 |
+
thinking_keywords = briefing.get("thinking_keywords", "")
|
| 254 |
+
|
| 255 |
+
return {
|
| 256 |
+
"trimester": trimester,
|
| 257 |
+
"focus_nutrients": focus_nutrients,
|
| 258 |
+
"recommended_foods": recommended_foods,
|
| 259 |
+
"recipe_count": recipe_count,
|
| 260 |
+
"recipe_names": recipe_names,
|
| 261 |
+
"yesterday_summary": yesterday_summary,
|
| 262 |
+
"meal_count": meal_count,
|
| 263 |
+
"weight_status": weight_status,
|
| 264 |
+
"weight_trend": weight_trend,
|
| 265 |
+
"thinking_keywords": thinking_keywords,
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ============================================================
|
| 270 |
+
# 自定义 CSS 样式
|
| 271 |
+
# ============================================================
|
| 272 |
+
|
| 273 |
+
CUSTOM_CSS = """
|
| 274 |
+
/* ============ 全局圆角与阴影 ============ */
|
| 275 |
+
.gradio-container {
|
| 276 |
+
max-width: 1200px !important;
|
| 277 |
+
margin: 0 auto !important;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/* 卡片样式 */
|
| 281 |
+
.card-container {
|
| 282 |
+
border-radius: 16px !important;
|
| 283 |
+
padding: 16px !important;
|
| 284 |
+
background: linear-gradient(135deg, #FFF5F7 0%, #FFFFFF 100%) !important;
|
| 285 |
+
border: 1px solid #FCE4EC !important;
|
| 286 |
+
box-shadow: 0 2px 12px rgba(255, 105, 135, 0.08) !important;
|
| 287 |
+
transition: all 0.3s ease;
|
| 288 |
+
}
|
| 289 |
+
.card-container:hover {
|
| 290 |
+
box-shadow: 0 4px 20px rgba(255, 105, 135, 0.15) !important;
|
| 291 |
+
transform: translateY(-1px);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/* 大圆形按钮(语音启动) */
|
| 295 |
+
.voice-main-btn {
|
| 296 |
+
width: 160px !important;
|
| 297 |
+
height: 160px !important;
|
| 298 |
+
border-radius: 50% !important;
|
| 299 |
+
background: linear-gradient(135deg, #FF6B8A 0%, #FF4081 100%) !important;
|
| 300 |
+
border: 4px solid #FFF !important;
|
| 301 |
+
box-shadow: 0 8px 32px rgba(255, 64, 129, 0.3) !important;
|
| 302 |
+
cursor: pointer !important;
|
| 303 |
+
transition: all 0.3s ease !important;
|
| 304 |
+
font-size: 20px !important;
|
| 305 |
+
color: white !important;
|
| 306 |
+
}
|
| 307 |
+
.voice-main-btn:hover {
|
| 308 |
+
transform: scale(1.05) !important;
|
| 309 |
+
box-shadow: 0 12px 40px rgba(255, 64, 129, 0.4) !important;
|
| 310 |
+
}
|
| 311 |
+
.voice-main-btn:active {
|
| 312 |
+
transform: scale(0.95) !important;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/* 状态指示灯 */
|
| 316 |
+
.status-dot {
|
| 317 |
+
width: 12px;
|
| 318 |
+
height: 12px;
|
| 319 |
+
border-radius: 50%;
|
| 320 |
+
display: inline-block;
|
| 321 |
+
margin-right: 8px;
|
| 322 |
+
}
|
| 323 |
+
.status-dot.active {
|
| 324 |
+
background: #4CAF50;
|
| 325 |
+
animation: pulse 1.5s infinite;
|
| 326 |
+
}
|
| 327 |
+
.status-dot.idle {
|
| 328 |
+
background: #BDBDBD;
|
| 329 |
+
}
|
| 330 |
+
@keyframes pulse {
|
| 331 |
+
0%, 100% { opacity: 1; }
|
| 332 |
+
50% { opacity: 0.4; }
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/* 聊天气泡 */
|
| 336 |
+
.chat-bubble {
|
| 337 |
+
border-radius: 18px !important;
|
| 338 |
+
padding: 12px 16px !important;
|
| 339 |
+
margin: 8px 0 !important;
|
| 340 |
+
max-width: 85% !important;
|
| 341 |
+
}
|
| 342 |
+
.chat-bubble.user {
|
| 343 |
+
background: #FCE4EC !important;
|
| 344 |
+
margin-left: auto !important;
|
| 345 |
+
}
|
| 346 |
+
.chat-bubble.ai {
|
| 347 |
+
background: #E8F5E9 !important;
|
| 348 |
+
margin-right: auto !important;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
/* Tab 按钮美化 */
|
| 352 |
+
.tabs > .tab-nav > button {
|
| 353 |
+
border-radius: 12px 12px 0 0 !important;
|
| 354 |
+
font-size: 15px !important;
|
| 355 |
+
padding: 12px 20px !important;
|
| 356 |
+
transition: all 0.2s ease !important;
|
| 357 |
+
}
|
| 358 |
+
.tabs > .tab-nav > button.selected {
|
| 359 |
+
background: linear-gradient(135deg, #FCE4EC, #FFF) !important;
|
| 360 |
+
font-weight: 600 !important;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
/* 按钮统一圆角 */
|
| 364 |
+
button, .gr-button {
|
| 365 |
+
border-radius: 12px !important;
|
| 366 |
+
font-weight: 500 !important;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
/* 输入框圆角 */
|
| 370 |
+
input, textarea, .gr-textbox {
|
| 371 |
+
border-radius: 12px !important;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
/* 折叠面板 */
|
| 375 |
+
.accordion {
|
| 376 |
+
border-radius: 16px !important;
|
| 377 |
+
border: 1px solid #FCE4EC !important;
|
| 378 |
+
}
|
| 379 |
+
.accordion > .label-wrap {
|
| 380 |
+
border-radius: 16px 16px 0 0 !important;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/* 响应式调整 */
|
| 384 |
+
@media (max-width: 768px) {
|
| 385 |
+
.voice-main-btn {
|
| 386 |
+
width: 120px !important;
|
| 387 |
+
height: 120px !important;
|
| 388 |
+
font-size: 16px !important;
|
| 389 |
+
}
|
| 390 |
+
.gradio-container {
|
| 391 |
+
padding: 8px !important;
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
"""
|