J.B-Lin commited on
Commit
3e28c81
·
1 Parent(s): bfe4514

chore: 回退语音按钮为纯HTML圆形按钮 + 修复思考框/嵌套边框/营养报告布局

Browse files
Files changed (2) hide show
  1. ui/app_builder.py +91 -33
  2. utils.py +48 -26
ui/app_builder.py CHANGED
@@ -14,6 +14,7 @@ PregoPal - Gradio 界面构建(现代UI版)
14
 
15
  import datetime
16
  import gradio as gr
 
17
 
18
  from modules.voiceprint import VoiceprintManager
19
  from modules.meal_recommender import MealRecommender
@@ -39,6 +40,30 @@ diet_logger = DietLogger()
39
  nutrition_analyzer = NutritionAnalyzer()
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  # ============================================================
43
  # 工具:首页语言文本
44
  # ============================================================
@@ -49,7 +74,7 @@ def _home_texts(lang):
49
  "title": "# 🌸 欢迎来到 PregoPal",
50
  "subtitle": "### 你的孕期AI伴侣,时刻陪伴在你身边",
51
  "tap_speak": "🎙️ 点击说话",
52
- "tap_hint": "全双工语音交互,边说边听",
53
  "trimester_label": "孕期阶段",
54
  "nutrition_focus": "营养关注",
55
  "today_diet": "昨日饮食",
@@ -57,6 +82,7 @@ def _home_texts(lang):
57
  "weight_label": "体重管理",
58
  "thinking_label": "🤔 AI 思考中...",
59
  "thinking_placeholder": "等待对话中...",
 
60
  "recent_title": "最近的饮食记录",
61
  "meal_count_unit": "餐",
62
  "no_data": "暂无数据",
@@ -69,7 +95,7 @@ def _home_texts(lang):
69
  "title": "# 🌸 Welcome to PregoPal",
70
  "subtitle": "### Your AI pregnancy companion, always by your side",
71
  "tap_speak": "🎙️ Tap to Speak",
72
- "tap_hint": "Full-duplex voice interaction",
73
  "trimester_label": "Trimester",
74
  "nutrition_focus": "Nutrition Focus",
75
  "today_diet": "Yesterday's Diet",
@@ -77,6 +103,7 @@ def _home_texts(lang):
77
  "weight_label": "Weight",
78
  "thinking_label": "🤔 AI Thinking...",
79
  "thinking_placeholder": "Waiting for conversation...",
 
80
  "recent_title": "Recent Diet Records",
81
  "meal_count_unit": " meals",
82
  "no_data": "No data",
@@ -163,7 +190,7 @@ def _home_content(loop, lang):
163
  if cards["weight_trend"]:
164
  weight_display += f" | {cards['weight_trend']}"
165
 
166
- thinking_text = cards["thinking_keywords"] if cards["thinking_keywords"] else T["thinking_placeholder"]
167
 
168
  records = diet_logger.get_recent_records(days=3)
169
  recent_html = _render_recent_records_html(records, lang)
@@ -172,28 +199,38 @@ def _home_content(loop, lang):
172
  gr.Markdown(T["title"])
173
  gr.Markdown(T["subtitle"])
174
 
175
- # 语音启动按钮(居中大圆形
176
  with gr.Row():
177
  with gr.Column(scale=1):
178
  pass
179
  with gr.Column(scale=2):
180
  gr.HTML(f"""
181
  <div style="text-align: center; padding: 24px 0;">
182
- <button class="voice-main-btn"></button>
 
 
 
 
 
 
 
 
 
 
 
183
  <p style="margin-top: 8px; color: #999; font-size: 14px;">{T['tap_hint']}</p>
184
  </div>
185
  """)
186
  with gr.Column(scale=1):
187
  pass
188
 
189
- # AI 思考状态
190
- gr.Textbox(
191
- value=f"{T['thinking_label']}\n{thinking_text}",
192
- label="",
193
- interactive=False,
194
- lines=2,
195
- elem_classes=["thinking-box"],
196
- )
197
 
198
  # 卡片行 1
199
  with gr.Row():
@@ -232,12 +269,12 @@ def _home_content(loop, lang):
232
  # ============================================================
233
  def _family_content(lang):
234
  """家庭饮食习惯组件 — 纯展示模式"""
235
- # Refresh state to force re-render
236
  with gr.Column(elem_classes=["glass-card"]):
237
  gr.Markdown(f"## {t('family_title', lang)}")
238
  gr.Markdown(f"*{t('family_subtitle', lang)}*")
239
 
240
- with gr.Tabs():
 
241
  with gr.Tab(t("tab_recipes", lang)):
242
  recipes_html = gr.HTML(render_family_recipes_html(lang))
243
  refresh_recipe_btn = gr.Button(t("btn_refresh", lang), size="sm")
@@ -313,30 +350,47 @@ def _summary_content(lang):
313
 
314
 
315
  # ============================================================
316
- # Tab 4: 📈 营养报告(纯 HTML Dashboard,无 matplotlib 图片
317
  # ============================================================
318
  def _report_content(lang):
319
- """营养报告组件 — 纯 HTML 可视化"""
320
- with gr.Row():
321
- with gr.Column(scale=1):
322
- analysis_days = gr.Slider(
323
- label=t("analysis_days", lang),
324
- minimum=1, maximum=30, value=7, step=1,
325
- )
326
- generate_btn = gr.Button(t("btn_generate", lang), variant="primary")
 
 
 
327
 
328
- with gr.Column(scale=3):
329
- report_dashboard = gr.HTML(
330
- f'<div style="padding:40px;text-align:center;color:#bbb;font-size:16px;">'
331
- f'点击左侧「生成报告」按钮查看分析</div>'
332
- )
333
 
334
  def generate_report(days):
335
  records = diet_logger.get_recent_records(days=int(days))
336
  analysis = nutrition_analyzer.analyze_diet(records)
337
  return render_nutrition_report_html(analysis, days=int(days), lang=lang)
338
 
339
- generate_btn.click(fn=generate_report, inputs=[analysis_days], outputs=report_dashboard)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
 
342
  # ============================================================
@@ -354,14 +408,18 @@ def create_app(loop=None):
354
  # 语言状态
355
  lang_state = gr.State(value="zh")
356
 
357
- # 顶部标题 + 语言切换
358
  with gr.Row():
359
  with gr.Column(scale=4):
360
  gr.HTML("""
361
  <div style="text-align: left; padding: 8px 0;">
362
- <h1 style="margin: 0; font-size: 28px;">🌸 PregoPal</h1>
363
- <p style="color: #999; margin: 4px 0 0 0; font-size: 16px;">孕期陪护AI助手 温馨的家庭式伴侣</p>
 
 
 
364
  </div>
 
365
  """)
366
  with gr.Column(scale=1, min_width=120):
367
  lang_selector = gr.Radio(
 
14
 
15
  import datetime
16
  import gradio as gr
17
+ import time
18
 
19
  from modules.voiceprint import VoiceprintManager
20
  from modules.meal_recommender import MealRecommender
 
40
  nutrition_analyzer = NutritionAnalyzer()
41
 
42
 
43
+ # ============================================================
44
+ # 语音会话入口(pass 占位,待 Llama 模型接入)
45
+ # ============================================================
46
+ async def start_voice_session(audio_data: str, lang: str = "zh") -> dict:
47
+ """
48
+ 语音会话入口(当前 pass 占位,待接入 Llama 大模型)
49
+
50
+ Args:
51
+ audio_data: Gradio Audio 组件返回的文件路径 (str)
52
+ lang: 语言标识 "zh" / "en"
53
+
54
+ Returns:
55
+ dict: {
56
+ "text": str, # ASR 转写文本
57
+ "response": str, # AI 回复文本
58
+ "speaker": str, # 识别出的说话人
59
+ "audio_response": bytes | None # TTS 音频(可选)
60
+ }
61
+ """
62
+ # TODO: 接入 Llama 模型后替换此函数体
63
+ # 当前 pass 占位,返回空结果避免报错
64
+ pass
65
+
66
+
67
  # ============================================================
68
  # 工具:首页语言文本
69
  # ============================================================
 
74
  "title": "# 🌸 欢迎来到 PregoPal",
75
  "subtitle": "### 你的孕期AI伴侣,时刻陪伴在你身边",
76
  "tap_speak": "🎙️ 点击说话",
77
+ "tap_hint": "🎤 点击开始聊天",
78
  "trimester_label": "孕期阶段",
79
  "nutrition_focus": "营养关注",
80
  "today_diet": "昨日饮食",
 
82
  "weight_label": "体重管理",
83
  "thinking_label": "🤔 AI 思考中...",
84
  "thinking_placeholder": "等待对话中...",
85
+ "thinking_waiting": "等待对话中...",
86
  "recent_title": "最近的饮食记录",
87
  "meal_count_unit": "餐",
88
  "no_data": "暂无数据",
 
95
  "title": "# 🌸 Welcome to PregoPal",
96
  "subtitle": "### Your AI pregnancy companion, always by your side",
97
  "tap_speak": "🎙️ Tap to Speak",
98
+ "tap_hint": "🎤 Tap to talk",
99
  "trimester_label": "Trimester",
100
  "nutrition_focus": "Nutrition Focus",
101
  "today_diet": "Yesterday's Diet",
 
103
  "weight_label": "Weight",
104
  "thinking_label": "🤔 AI Thinking...",
105
  "thinking_placeholder": "Waiting for conversation...",
106
+ "thinking_waiting": "Waiting for conversation...",
107
  "recent_title": "Recent Diet Records",
108
  "meal_count_unit": " meals",
109
  "no_data": "No data",
 
190
  if cards["weight_trend"]:
191
  weight_display += f" | {cards['weight_trend']}"
192
 
193
+ thinking_text = cards["thinking_keywords"] if cards["thinking_keywords"] else T["thinking_waiting"]
194
 
195
  records = diet_logger.get_recent_records(days=3)
196
  recent_html = _render_recent_records_html(records, lang)
 
199
  gr.Markdown(T["title"])
200
  gr.Markdown(T["subtitle"])
201
 
202
+ # 语音启动 — 粉色渐变圆形按钮(全双工语音交互入口
203
  with gr.Row():
204
  with gr.Column(scale=1):
205
  pass
206
  with gr.Column(scale=2):
207
  gr.HTML(f"""
208
  <div style="text-align: center; padding: 24px 0;">
209
+ <button class="voice-main-btn" style="
210
+ width:160px;height:160px;border-radius:50%;
211
+ background:linear-gradient(135deg,#FF6B8A 0%,#FF4081 100%);
212
+ border:4px solid #FFF;
213
+ box-shadow:0 8px 32px rgba(255,64,129,0.3);
214
+ cursor:pointer;font-size:48px;color:white;
215
+ transition:all 0.3s ease;"
216
+ onmouseover="this.style.transform='scale(1.05)';this.style.boxShadow='0 12px 40px rgba(255,64,129,0.4)'"
217
+ onmouseout="this.style.transform='scale(1)';this.style.boxShadow='0 8px 32px rgba(255,64,129,0.3)'"
218
+ >
219
+ 🎙️
220
+ </button>
221
  <p style="margin-top: 8px; color: #999; font-size: 14px;">{T['tap_hint']}</p>
222
  </div>
223
  """)
224
  with gr.Column(scale=1):
225
  pass
226
 
227
+ # AI 思考状态 — 纯 HTML(无 Gradio textarea 灰色外壳)
228
+ gr.HTML(f"""
229
+ <div class="thinking-box">
230
+ <div style="font-weight:600;color:#6A1B9A;">{T['thinking_label']}</div>
231
+ <div style="color:#888;font-size:14px;margin-top:4px;">{thinking_text}</div>
232
+ </div>
233
+ """)
 
234
 
235
  # 卡片行 1
236
  with gr.Row():
 
269
  # ============================================================
270
  def _family_content(lang):
271
  """家庭饮食习惯组件 — 纯展示模式"""
 
272
  with gr.Column(elem_classes=["glass-card"]):
273
  gr.Markdown(f"## {t('family_title', lang)}")
274
  gr.Markdown(f"*{t('family_subtitle', lang)}*")
275
 
276
+ # 默认选中第一个 Tab(家庭菜谱)
277
+ with gr.Tabs(selected=0):
278
  with gr.Tab(t("tab_recipes", lang)):
279
  recipes_html = gr.HTML(render_family_recipes_html(lang))
280
  refresh_recipe_btn = gr.Button(t("btn_refresh", lang), size="sm")
 
350
 
351
 
352
  # ============================================================
353
+ # Tab 4: 📈 营养报告(纯 HTML Dashboard,自动生成
354
  # ============================================================
355
  def _report_content(lang):
356
+ """营养报告组件 — 纯 HTML 可视化,Slider 防抖自动生成"""
357
+ # 防抖时间戳
358
+ _last_change = [0.0]
359
+
360
+ with gr.Column():
361
+ # Slider 全宽
362
+ analysis_days = gr.Slider(
363
+ label=t("analysis_days", lang),
364
+ minimum=1, maximum=30, value=7, step=1,
365
+ interactive=True,
366
+ )
367
 
368
+ # 报告全宽
369
+ report_dashboard = gr.HTML(
370
+ f'<div style="padding:40px;text-align:center;color:#bbb;font-size:16px;">'
371
+ f'调整上方天数自动生成报告</div>'
372
+ )
373
 
374
  def generate_report(days):
375
  records = diet_logger.get_recent_records(days=int(days))
376
  analysis = nutrition_analyzer.analyze_diet(records)
377
  return render_nutrition_report_html(analysis, days=int(days), lang=lang)
378
 
379
+ def on_slider_change(days):
380
+ """Slider 变化时防抖 0.8s 后生成报告"""
381
+ _last_change[0] = time.time()
382
+ # wait for debounce window
383
+ time.sleep(0.8)
384
+ # if another change happened during sleep, skip
385
+ if time.time() - _last_change[0] < 0.9:
386
+ return generate_report(days)
387
+ return gr.skip()
388
+
389
+ analysis_days.change(
390
+ fn=on_slider_change,
391
+ inputs=[analysis_days],
392
+ outputs=[report_dashboard],
393
+ )
394
 
395
 
396
  # ============================================================
 
408
  # 语言状态
409
  lang_state = gr.State(value="zh")
410
 
411
+ # 顶部标题 + 语言切换(含英文副标题 + Google Fonts 趣味字体)
412
  with gr.Row():
413
  with gr.Column(scale=4):
414
  gr.HTML("""
415
  <div style="text-align: left; padding: 8px 0;">
416
+ <h1 style="margin: 0; font-size: 28px; font-family: 'Nunito', 'Quicksand', system-ui, sans-serif;">🌸 PregoPal</h1>
417
+ <p style="color: #999; margin: 4px 0 0 0; font-size: 16px; font-family: 'Nunito', 'Quicksand', system-ui, sans-serif;">
418
+ 孕期陪护AI助手 — 温馨的家庭式伴侣
419
+ <br><span style="font-size: 14px; color: #bbb;">Pregnancy Companion AI — Your Cozy Family Partner</span>
420
+ </p>
421
  </div>
422
+ <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet">
423
  """)
424
  with gr.Column(scale=1, min_width=120):
425
  lang_selector = gr.Radio(
utils.py CHANGED
@@ -271,20 +271,35 @@ def render_family_preferences_html(lang="zh") -> str:
271
  def render_family_memories_html(lang="zh") -> str:
272
  """渲染家庭记忆为时间线"""
273
  from modules.family_manager import MemoryManager
274
- memories = MemoryManager.load_all()
275
- if not memories:
 
276
  no = t("no_memories", lang)
277
  return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{no}</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  items = ""
279
- for m in memories[-10:]:
280
- date = m.get("date", "")[-5:] if m.get("date") else ""
281
- content = m.get("content", "") or m.get("title", "") or m.get("event", "")
282
  if not content: continue
283
  items += f"""
284
  <div style="display:flex;gap:12px;padding:8px 0;">
285
- <div style="min-width:50px;font-size:13px;color:#E91E63;font-weight:500;">{date}</div>
286
  <div style="flex:1;background:#fff;border-radius:10px;padding:10px 14px;border:1px solid #f0f0f0;font-size:14px;color:#333;">{content}</div>
287
  </div>"""
 
 
288
  return f"""
289
  <div style="max-height:320px;overflow-y:auto;padding:4px 0;">
290
  {items}
@@ -364,7 +379,7 @@ def render_nutrition_report_html(analysis: dict, days: int, lang="zh") -> str:
364
  <div style="display:flex;justify-content:space-between;align-items:center;">
365
  <div>
366
  <div style="font-size:20px;font-weight:700;color:#880E4F;">📊 {t("report_overall", lang)}</div>
367
- <div style="color:#888;font-size:13px;margin-top:4px;">{days_str} · {total_days}t("report_days","").split("近")[0] if lang=="zh" else ""</div>
368
  </div>
369
  <div style="text-align:right;">
370
  <div style="font-size:36px;font-weight:800;color:{bar_color};">{score}</div>
@@ -517,32 +532,17 @@ h1, h2, h3 {
517
  color: #555 !important;
518
  }
519
 
520
- /* AI Thinking Box */
521
- .thinking-box textarea {
522
  background: linear-gradient(135deg, #F3E5F5, #FFF) !important;
523
  border: 1px solid #E1BEE7 !important;
524
  border-radius: 12px !important;
 
525
  font-size: 14px !important;
526
  color: #6A1B9A !important;
527
- padding: 12px 16px !important;
528
  }
529
 
530
- /* 圆形按钮(语音启动) */
531
- .voice-main-btn {
532
- width: 160px !important;
533
- height: 160px !important;
534
- border-radius: 50% !important;
535
- background: linear-gradient(135deg, #FF6B8A 0%, #FF4081 100%) !important;
536
- border: 4px solid #FFF !important;
537
- box-shadow: 0 8px 32px rgba(255, 64, 129, 0.3) !important;
538
- cursor: pointer !important;
539
- transition: all 0.3s ease !important;
540
- font-size: 48px !important;
541
- color: white !important;
542
- display: inline-flex !important;
543
- align-items: center !important;
544
- justify-content: center !important;
545
- }
546
  .voice-main-btn:hover {
547
  transform: scale(1.05) !important;
548
  box-shadow: 0 12px 40px rgba(255, 64, 129, 0.4) !important;
@@ -561,6 +561,28 @@ input, textarea, .gr-textbox {
561
  border-radius: 12px !important;
562
  }
563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  /* 响应式调整 */
565
  @media (max-width: 768px) {
566
  .voice-main-btn {
 
271
  def render_family_memories_html(lang="zh") -> str:
272
  """渲染家庭记忆为时间线"""
273
  from modules.family_manager import MemoryManager
274
+ import re
275
+ data = MemoryManager.load_all()
276
+ if not any(data.values()):
277
  no = t("no_memories", lang)
278
  return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{no}</div>'
279
+ # 将 dict 展平为 (date, content) 列表
280
+ flat = []
281
+ for section_key, label in [("relationships", "关系"), ("events", "事件"), ("daily", "日常")]:
282
+ for line in data.get(section_key, []):
283
+ m = re.match(r'-\s*\*{0,2}(\d{4}-\d{2}-\d{2})\*{0,2}:?\s*(.+)', line)
284
+ if m:
285
+ flat.append((m.group(1), m.group(2)))
286
+ else:
287
+ # 无日期的行(如关系描述),用空日期
288
+ clean = re.sub(r'^-\s*\*{0,2}(.*?)\*{0,2}:?\s*', r'\1: ', line).strip()
289
+ flat.append(("", clean))
290
+ # 按日期排序(最新的在前),取最近10条
291
+ flat.sort(key=lambda x: x[0] if x[0] else "0000", reverse=True)
292
  items = ""
293
+ for date_str, content in flat[:10]:
294
+ disp_date = date_str[-5:] if date_str else ""
 
295
  if not content: continue
296
  items += f"""
297
  <div style="display:flex;gap:12px;padding:8px 0;">
298
+ <div style="min-width:50px;font-size:13px;color:#E91E63;font-weight:500;">{disp_date}</div>
299
  <div style="flex:1;background:#fff;border-radius:10px;padding:10px 14px;border:1px solid #f0f0f0;font-size:14px;color:#333;">{content}</div>
300
  </div>"""
301
+ if not items:
302
+ return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{t("no_memories", lang)}</div>'
303
  return f"""
304
  <div style="max-height:320px;overflow-y:auto;padding:4px 0;">
305
  {items}
 
379
  <div style="display:flex;justify-content:space-between;align-items:center;">
380
  <div>
381
  <div style="font-size:20px;font-weight:700;color:#880E4F;">📊 {t("report_overall", lang)}</div>
382
+ <div style="color:#888;font-size:13px;margin-top:4px;">{days_str} · {total_days} </div>
383
  </div>
384
  <div style="text-align:right;">
385
  <div style="font-size:36px;font-weight:800;color:{bar_color};">{score}</div>
 
532
  color: #555 !important;
533
  }
534
 
535
+ /* AI Thinking Box — div 版本(无 textarea 外壳) */
536
+ .thinking-box {
537
  background: linear-gradient(135deg, #F3E5F5, #FFF) !important;
538
  border: 1px solid #E1BEE7 !important;
539
  border-radius: 12px !important;
540
+ padding: 12px 16px !important;
541
  font-size: 14px !important;
542
  color: #6A1B9A !important;
 
543
  }
544
 
545
+ /* 语音按钮 — 纯 HTML 圆形按钮 hover/active 效果主样式在 inline style 中) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  .voice-main-btn:hover {
547
  transform: scale(1.05) !important;
548
  box-shadow: 0 12px 40px rgba(255, 64, 129, 0.4) !important;
 
561
  border-radius: 12px !important;
562
  }
563
 
564
+ /* 消除 Gradio Group 默认嵌套边框 */
565
+ .gr-group, .gr-box {
566
+ border: none !important;
567
+ box-shadow: none !important;
568
+ }
569
+ .gr-group > .gr-box,
570
+ .gr-box > .gr-box {
571
+ border: none !important;
572
+ box-shadow: none !important;
573
+ }
574
+
575
+ /* 消除 Trimester 卡片 group 的双层边框 */
576
+ .card-trimester.gr-group,
577
+ .card-trimester .gr-box {
578
+ border: 1px solid #f0f0f0 !important;
579
+ border-radius: 16px !important;
580
+ box-shadow: 0 1px 8px rgba(0,0,0,0.04) !important;
581
+ }
582
+ .card-trimester.gr-group .gr-box {
583
+ border: none !important;
584
+ }
585
+
586
  /* 响应式调整 */
587
  @media (max-width: 768px) {
588
  .voice-main-btn {