J.B-Lin commited on
Commit
0155d1a
·
1 Parent(s): 33aa89f

feat: UI 全面重构 — 首页语音启动页+简报卡片+AI思考状态、全双工 Chatbot、CSS 美化、家庭/总结/报告 Tab 微调、中英文完整支持

Browse files
Files changed (3) hide show
  1. app.py +1 -6
  2. ui/app_builder.py +486 -148
  3. 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
- 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
- with gr.Column(scale=3):
38
- chatbot = gr.Chatbot(label=t("chat_label", lang), height=400)
39
- with gr.Row():
40
- msg = gr.Textbox(
41
- label=t("chat_label", lang),
42
- placeholder=t("msg_placeholder", lang),
43
- scale=4
44
- )
45
- send_btn = gr.Button(t("btn_send", lang), variant="primary", scale=1)
46
- with gr.Row():
47
- audio_input = gr.Audio(label="🎤 " + t("voice_register", lang), type="filepath")
48
- clear_btn = gr.Button(t("btn_clear", lang), size="sm")
 
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  with gr.Column(scale=1):
51
- gr.Markdown(t("voice_title", lang))
52
- with gr.Group():
53
- audio_register = gr.Audio(label=t("voice_register", lang), type="filepath")
 
 
 
 
 
 
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
- with gr.Group():
64
- audio_identify = gr.Audio(label=t("voice_identify", lang), type="filepath")
 
 
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
- gr.Markdown(t("briefing_title", lang))
69
- briefing_display = gr.JSON(value={}, label="")
 
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 = ["👋 " + (t("btn_send", lang) if lang == "zh" else "Got it!")]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
- if extracted["diets"]:
80
- for diet in extracted["diets"]:
81
- meals_str = "、".join(
82
- f"{k}: {v}" for k, v in diet.get("meals", {}).items()
83
- )
84
- response_parts.append(f"✅ {t('btn_send', lang) if lang == 'zh' else 'Logged'}: {meals_str}")
85
- date_str = diet.get("日期", datetime.date.today().isoformat())
86
- diet_logger.add_record(
87
- member_name=diet.get("记录人", "用户"),
88
- member_relation="家人",
89
- date=date_str,
90
- meals=diet.get("meals", {}),
91
- notes=diet.get("备注", "")
92
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
- if extracted["recipes"]:
95
- for recipe in extracted["recipes"]:
96
- name = recipe.get("菜名", "")
97
- if name:
98
- result = RecipeManager.add_recipe(
99
- name=name,
100
- cook=recipe.get("制作人", ""),
101
- difficulty=recipe.get("难度", ""),
102
- ingredients=recipe.get("食材", ""),
103
- notes=recipe.get("备注", "")
 
 
 
 
 
 
 
 
 
 
 
104
  )
105
- response_parts.append(f"✅ {result}")
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
- if extracted["weights"]:
119
- for w in extracted["weights"]:
120
- response_parts.append(f"✅ {t('btn_save', lang) if lang == 'zh' else 'Logged weight'}: {w.get('体重', '')}kg")
121
 
122
- if extracted["thinking"]:
123
- step = extracted["thinking"].get("当前步骤", "")
124
- next_step = extracted["thinking"].get("下一步", "")
125
- response_parts.append(f"🤔 {t('btn_analyze', lang) if lang == 'zh' else 'Thinking'}: {step} → {next_step}")
126
 
127
- if len(response_parts) == 1:
128
- if any(kw in message for kw in ["吃了", "想吃", "午餐", "晚餐", "早餐"]):
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
- send_btn.click(respond, inputs=[msg, chatbot], outputs=[msg, chatbot])
140
- msg.submit(respond, inputs=[msg, chatbot], outputs=[msg, chatbot])
141
- clear_btn.click(lambda: None, outputs=chatbot)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=[gr.State(), register_result]
147
  )
148
  identify_btn.click(
149
  fn=lambda audio: voiceprint_mgr.identify_speaker(audio),
150
  inputs=[audio_identify],
151
- outputs=[gr.State(), identify_result]
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(label=t("recipe_name", lang), placeholder=t("recipe_name_placeholder", lang))
192
- recipe_cook = gr.Textbox(label=t("recipe_cook", lang), placeholder=t("recipe_cook_placeholder", lang))
 
 
 
 
 
 
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
- briefing = loop.get_briefing() if loop else {}
329
-
330
- with gr.Blocks(title="PregoPal - 孕期陪护AI助手", theme=gr.themes.Soft(
331
- primary_hue="pink",
332
- secondary_hue="green",
333
- )) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
  # ============================================================
336
- # 语言状态(存储在 gr.State 中)
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
- # 🌸 PregoPal - 孕期陪护AI助手
348
- ### 一个温馨的家庭式孕期AI伴侣
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
- new_lang = "zh" if choice == "中文" else "en"
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
- # 页面 1: 语音交互
374
  # ============================================================
375
- with gr.Tab(t("tab_voice", "zh"), id="tab_voice"):
376
- _build_voice_tab(lang_state)
 
377
 
378
- # ============================================================
379
- # 页面 2: 家庭饮食习惯
380
- # ============================================================
381
- with gr.Tab(t("tab_family", "zh"), id="tab_family"):
382
- _build_family_tab(lang_state)
383
 
384
- # ============================================================
385
- # 页面 3: 三天总结
386
- # ============================================================
387
- with gr.Tab(t("tab_summary", "zh"), id="tab_summary"):
388
- _build_summary_tab(lang_state)
389
 
390
- # ============================================================
391
- # 页面 4: 营养报告
392
- # ============================================================
393
- with gr.Tab(t("tab_report", "zh"), id="tab_report"):
394
- _build_report_tab(lang_state)
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
+ """