Spaces:
Runtime error
Runtime error
J.B-Lin commited on
Commit ·
2ddf335
1
Parent(s): ec87820
feat(ui): modern UI redesign with 4 tabs, glassmorphism cards, and pure HTML nutrition dashboard
Browse files- README.md +79 -564
- app.py +1 -1
- modules/nutrition_analyzer.py +18 -8
- ui/app_builder.py +53 -142
- utils.py +363 -178
README.md
CHANGED
|
@@ -1,579 +1,94 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
**
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
app.py ← Gradio 薄入口
|
| 41 |
-
├── ui/app_builder.py ← 前端 Tab 布局(饮食记录/营养分析/家庭管理/声纹/菜品推荐/环境配置)
|
| 42 |
-
├── loop.py ← 每日自动分析循环(状态机)
|
| 43 |
-
│ ├── plugins/base.py ← 插件基类(LoopPlugin, PluginRegistry, LoopContext)
|
| 44 |
-
│ ├── plugins/family_quiz.py ← 家庭问卷插件(菜谱/体重检查)
|
| 45 |
-
│ ├── plugins/diet_summary.py ← 昨日饮食总结
|
| 46 |
-
│ ├── plugins/weight_check.py ← 体重检查插件
|
| 47 |
-
│ ├── plugins/family_memory.py← 家庭记忆处理
|
| 48 |
-
│ ├── plugins/dri_analysis.py ← DRIs 营养对比分析
|
| 49 |
-
│ ├── plugins/briefing_generator.py ← 今日简报生成
|
| 50 |
-
│ ├── plugins/three_day_summary.py ← 每三天综合总结
|
| 51 |
-
│ └── plugins/preset_writer.py ← 预设/缓存写入
|
| 52 |
-
├── modules/ ← 核心业务逻辑(纯函数/无状态)
|
| 53 |
-
│ ├── voiceprint.py ← 声纹识别
|
| 54 |
-
│ ├── meal_recommender.py ← 菜品推荐
|
| 55 |
-
│ ├── diet_extractor.py ← AI 回复数据提取
|
| 56 |
-
│ ├── diet_logger.py ← 饮食记录存储
|
| 57 |
-
│ ├── family_manager.py ← 家庭信息管理
|
| 58 |
-
│ ├── nutrition_standards.py ← 中国官方营养标准
|
| 59 |
-
│ ├── nutrition_analyzer.py ← 营养分析与可视化
|
| 60 |
-
│ └── core/ ← 核心能力层
|
| 61 |
-
│ ├── model_loader.py ← MiniCPM-o 模型加载
|
| 62 |
-
│ ├── voice_processor.py ← 语音处理
|
| 63 |
-
│ ├── vision_processor.py ← 视觉处理
|
| 64 |
-
│ └── conversation_manager.py ← 对话管理
|
| 65 |
-
├── config.py ← 全局配置(路径/常量/数据模板)
|
| 66 |
-
├── utils.py ← 工具函数(中文字体设置等)
|
| 67 |
-
└── data/ ← 持久化存储
|
| 68 |
-
├── diet_logs.json ← 结构化饮食记录 JSON
|
| 69 |
-
├── nutrition_db.json ← 营养数据库 JSON
|
| 70 |
-
├── family.json ← 家庭成员声纹 JSON
|
| 71 |
-
├── family/ ← 家庭信息 Markdown(recipes/preferences/memory)
|
| 72 |
-
├── logs/ ← 每日饮食日志 Markdown
|
| 73 |
-
├── presets/ ← 预设/缓存 + .daily_status.json
|
| 74 |
-
├── reports/ ← 营养报告 Markdown
|
| 75 |
-
├── voices/ ← 声纹音频文件
|
| 76 |
-
└── nutrition/ ← 营养标准原始文档
|
| 77 |
-
```
|
| 78 |
-
|
| 79 |
-
---
|
| 80 |
-
|
| 81 |
-
## 3. 核心模块接口说明
|
| 82 |
-
|
| 83 |
-
> **约定**:模块间通过函数调用传递数据。所有返回 dict 的接口,其 key 约定在下方列出。
|
| 84 |
-
> **修改清单**:改任何模块的输入/输出签名时,必须同步更新本 README 对应条目。
|
| 85 |
-
|
| 86 |
-
### 3.1 声纹识别 — `modules/voiceprint.py`
|
| 87 |
-
|
| 88 |
-
类:`VoiceprintManager`
|
| 89 |
-
|
| 90 |
-
| 方法 | 输入 | 输出 | 说明 |
|
| 91 |
-
|------|------|------|------|
|
| 92 |
-
| `register_member(name, relation, audio_path)` | `name: str`, `relation: str`, `audio_path: str` (Path) | `(member_info: dict\|None, msg: str)` | 注册新成员声纹,返回 `{"id","name","relation","registered_at","audio_path","features"}` |
|
| 93 |
-
| `identify_speaker(audio_path)` | `audio_path: str` (Path) | `(member_info: dict\|None, msg: str)` | 识别说话人,需声纹库非空 |
|
| 94 |
-
| `get_members_list()` | — | `str` (格式化文本) | 获取已注册成员列表 |
|
| 95 |
-
| `delete_member(member_id)` | `member_id: str` | `str` (结果消息) | 删除指定成员 |
|
| 96 |
-
|
| 97 |
-
**依赖**:
|
| 98 |
-
- `VOICE_DIR`(`data/voices/`)从 `config.py` 导入
|
| 99 |
-
- `FAMILY_FILE`(`data/family.json`)— JSON 格式 `{"members": [...], "voiceprints": {...}}`
|
| 100 |
-
- `config.VOICEPRINT_SIMILARITY_THRESHOLD`(默认 0.7)
|
| 101 |
-
|
| 102 |
-
**内部数据结构**(`data/family.json`):
|
| 103 |
-
```json
|
| 104 |
-
{
|
| 105 |
-
"members": [
|
| 106 |
-
{
|
| 107 |
-
"id": "abc12345",
|
| 108 |
-
"name": "小红",
|
| 109 |
-
"relation": "孕妇",
|
| 110 |
-
"registered_at": "2026-06-09T07:00:00",
|
| 111 |
-
"audio_path": "data/voices/abc12345.wav",
|
| 112 |
-
"features": {"mean": 0.01, "std": 0.05, "max": 0.5, "min": -0.5, "zero_crossing_rate": 0.3, "energy": 0.001, "duration": 3.5}
|
| 113 |
-
}
|
| 114 |
-
],
|
| 115 |
-
"voiceprints": {
|
| 116 |
-
"abc12345": {"mean": 0.01, ...}
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
-
```
|
| 120 |
-
|
| 121 |
-
> ⚠️ **风险等级:中** — 当前使用频谱统计特征(baseline),后续升级 Whisper encoder 需保持 `register_member`/`identify_speaker` 签名不变。
|
| 122 |
-
|
| 123 |
-
---
|
| 124 |
-
|
| 125 |
-
### 3.2 菜品推荐 — `modules/meal_recommender.py`
|
| 126 |
-
|
| 127 |
-
类:`MealRecommender`
|
| 128 |
-
|
| 129 |
-
| 方法 | 输入 | 输出 | 说明 |
|
| 130 |
-
|------|------|------|------|
|
| 131 |
-
| `get_recommendation(preference, trimester, restrictions)` | `preference: str=""`, `trimester: str="孕中期"`, `restrictions: str=""` | `dict` | 返回今日推荐食谱 |
|
| 132 |
-
| `format_meal_plan(recommendation)` | `recommendation: dict`(上一个方法的返回值) | `str` | 格式化为可读文本 |
|
| 133 |
-
|
| 134 |
-
**输出 dict schema**(`get_recommendation` 返回):
|
| 135 |
-
```json
|
| 136 |
-
{
|
| 137 |
-
"date": "2026-06-09",
|
| 138 |
-
"trimester": "孕中期",
|
| 139 |
-
"preference": "想吃清淡的",
|
| 140 |
-
"focus": "补充蛋白质、钙、铁",
|
| 141 |
-
"meals": {
|
| 142 |
-
"早餐": "小米粥+包子+煮鸡蛋",
|
| 143 |
-
"午餐": "番茄牛腩+杂粮饭+凉拌黄瓜",
|
| 144 |
-
"晚餐": "蒸蛋羹+小米粥+炒青菜",
|
| 145 |
-
"加餐": "酸奶+坚果"
|
| 146 |
-
},
|
| 147 |
-
"tips": ["🌿 孕中期建议:...", "💡 建议每天饮水..."]
|
| 148 |
-
}
|
| 149 |
-
```
|
| 150 |
-
|
| 151 |
-
**依赖**:
|
| 152 |
-
- `config.MEAL_TEMPLATES`(4 餐次 × 4 选项的食谱模板 dict)
|
| 153 |
-
- `config.TRIMESTER_ADJUSTMENTS`(各孕期阶段的 focus/avoid 建议)
|
| 154 |
-
- `config.TRIMESTER_TIPS`(各孕期阶段的饮食提示字符串)
|
| 155 |
-
|
| 156 |
-
> ⚠️ **风险等级:中** — 当前为随机模板推荐。后续对接 AI 对话推荐时需保持 `get_recommendation` 签名,内部逻辑可任意替换。
|
| 157 |
-
|
| 158 |
-
---
|
| 159 |
-
|
| 160 |
-
### 3.3 AI 对话数据提取 — `modules/diet_extractor.py`
|
| 161 |
-
|
| 162 |
-
类:`DietExtractor`(全静态方法)
|
| 163 |
-
|
| 164 |
-
| 方法 | 输入 | 输出 | 说明 |
|
| 165 |
-
|------|------|------|------|
|
| 166 |
-
| `extract_all(text)` | `text: str` (AI 回复全文) | `dict` | 正则提取所有结构化数据 |
|
| 167 |
-
| `fallback_extract_diet(text)` | `text: str` | `dict\|None` | 关键词 fallback 提取饮食 |
|
| 168 |
-
| `fallback_extract_thinking(text)` | `text: str` | `dict\|None` | 关键词 fallback 提取思考 |
|
| 169 |
-
| `robust_extract(text)` | `text: str` | `dict` | 正则优先 → fallback 兜底 |
|
| 170 |
-
|
| 171 |
-
**`extract_all` 返回 dict schema**:
|
| 172 |
-
```json
|
| 173 |
-
{
|
| 174 |
-
"diets": [
|
| 175 |
-
{
|
| 176 |
-
"meals": {"早餐": "全麦面包+鸡蛋+牛奶", "午餐": "清蒸鱼", "晚餐": "", "加餐": ""},
|
| 177 |
-
"日期": "2026-06-09",
|
| 178 |
-
"记录人": "孕妇",
|
| 179 |
-
"备注": "孕妇吃了"
|
| 180 |
-
}
|
| 181 |
-
],
|
| 182 |
-
"recipes": [{"菜名": "清蒸鱼", "制作人": "丈夫", "难度": "简单", "食材": "鱼、姜", "备注": ""}],
|
| 183 |
-
"preferences": [{"人员": "孕妇", "类型": "偏好", "内容": "爱吃酸"}],
|
| 184 |
-
"weights": [{"日期": "2026-06-09", "体重": "65", "记录人": "孕妇自己"}],
|
| 185 |
-
"memories": [{"类型": "事件", "内容": "婆婆今天来家里"}],
|
| 186 |
-
"thinking": {"当前步骤": "记录饮食", "下一步": "分析营养"} // or None
|
| 187 |
-
}
|
| 188 |
-
```
|
| 189 |
-
|
| 190 |
-
**Markdown 提取标记格式**(AI 回复中需含):
|
| 191 |
-
```
|
| 192 |
-
[EXTRACT_DIET]...[/EXTRACT_DIET]
|
| 193 |
-
[EXTRACT_RECIPE]...[/EXTRACT_RECIPE]
|
| 194 |
-
[EXTRACT_PREFERENCE]...[/EXTRACT_PREFERENCE]
|
| 195 |
-
[EXTRACT_WEIGHT]...[/EXTRACT_WEIGHT]
|
| 196 |
-
[EXTRACT_MEMORY]...[/EXTRACT_MEMORY]
|
| 197 |
-
[THINKING]...[/THINKING]
|
| 198 |
-
```
|
| 199 |
-
|
| 200 |
-
**辅助函数**:
|
| 201 |
-
- `get_extract_prompt(date_str=None) -> str` — 返回含日期占位符的 System Prompt 模板
|
| 202 |
-
|
| 203 |
-
> ✅ **风险等级:低** — 接口稳定,已存在完整测试(`tests/test_diet_extractor.py`)。
|
| 204 |
-
|
| 205 |
-
---
|
| 206 |
-
|
| 207 |
-
### 3.4 饮食记录存储 — `modules/diet_logger.py`
|
| 208 |
-
|
| 209 |
-
类:`DietLogger`
|
| 210 |
-
|
| 211 |
-
| 方法 | 输入 | 输出 | 说明 |
|
| 212 |
-
|------|------|------|------|
|
| 213 |
-
| `add_record(member_name, member_relation, date, meals, mood, notes)` | `member_name: str`, `member_relation: str`, `date: str (ISO)`, `meals: dict`, `mood: str=""`, `notes: str=""` | `(record: dict, md_path: Path)` | 添加记录 → JSON + MD 双写 |
|
| 214 |
-
| `get_recent_records(days=7)` | `days: int` | `list[dict]` | 获取近 N 天记录 |
|
| 215 |
-
| `get_all_markdown_files()` | — | `list[Path]` | 所有 MD 日志文件 |
|
| 216 |
-
| `parse_diet_record(text) — static` | `text: str` | `dict\|None` | [DIET_RECORD] 标记解析(后续 AI 版本用) |
|
| 217 |
-
|
| 218 |
-
**`add_record` 返回的 record dict**:
|
| 219 |
-
```json
|
| 220 |
-
{
|
| 221 |
-
"id": "a1b2c3d4",
|
| 222 |
-
"member_name": "小红",
|
| 223 |
-
"member_relation": "孕妇",
|
| 224 |
-
"date": "2026-06-09",
|
| 225 |
-
"meals": {"早餐": "燕麦粥+坚果", "午餐": "清蒸鱼+米饭"},
|
| 226 |
-
"mood": "挺好",
|
| 227 |
-
"notes": "今天胃口不错",
|
| 228 |
-
"extensions": {},
|
| 229 |
-
"created_at": "2026-06-09T12:00:00"
|
| 230 |
-
}
|
| 231 |
-
```
|
| 232 |
-
|
| 233 |
-
**依赖**:
|
| 234 |
-
- `config.DIET_LOG_FILE`(`data/diet_logs.json`)— JSON 格式
|
| 235 |
-
- `config.LOGS_DIR`(`data/logs/`)— Markdown 日志目录
|
| 236 |
-
- `config.DIET_LOG_SCHEMA_VERSION`(当前 "1.0")
|
| 237 |
-
|
| 238 |
-
**Markdown 日志文��格式**(`data/logs/饮食日志_YYYY-MM-DD.md`):
|
| 239 |
-
```markdown
|
| 240 |
-
# 🥗 孕期饮食日志
|
| 241 |
-
|
| 242 |
-
## 📋 基本信息
|
| 243 |
-
- **日期**: 2026-06-09
|
| 244 |
-
- **记录人**: 孕妇 - 小红
|
| 245 |
-
- **记录时间**: 2026-06-09T12:00:00
|
| 246 |
-
|
| 247 |
-
## 🍽️ 今日饮食记录
|
| 248 |
-
|
| 249 |
-
### 早餐
|
| 250 |
-
- 燕麦粥+坚果
|
| 251 |
-
|
| 252 |
-
### 午餐
|
| 253 |
-
- 清蒸鱼+米饭
|
| 254 |
-
|
| 255 |
-
---
|
| 256 |
-
*由 PregoPal 自动生成*
|
| 257 |
-
```
|
| 258 |
-
|
| 259 |
-
> ✅ **风险等级:低** — schema 已加版本号,`extensions: {}` 字段可向后兼容扩展。
|
| 260 |
-
|
| 261 |
-
---
|
| 262 |
-
|
| 263 |
-
### 3.5 家庭信息管理 — `modules/family_manager.py`
|
| 264 |
-
|
| 265 |
-
三个管理器类,全部使用 `@classmethod`(无需实例化):
|
| 266 |
-
|
| 267 |
-
#### 3.5.1 RecipeManager — 家庭菜谱
|
| 268 |
-
| 方法 | 输入 | 输出 | 说明 |
|
| 269 |
-
|------|------|------|------|
|
| 270 |
-
| `load_all()` | — | `list[dict]` | 读取所有菜谱 |
|
| 271 |
-
| `add_recipe(name, cook, difficulty, ingredients, notes)` | 五个 `str` 参数 | `str` (结果消息) | 添加菜谱 |
|
| 272 |
-
| `get_names()` | — | `list[str]` | 所有菜名 |
|
| 273 |
-
| `format_for_prompt()` | — | `str` | System Prompt 格式 |
|
| 274 |
-
|
| 275 |
-
菜谱 dict: `{"name", "cook", "difficulty", "ingredients", "notes"}`
|
| 276 |
-
|
| 277 |
-
#### 3.5.2 PreferenceManager — 饮食偏好
|
| 278 |
-
| 方法 | 输入 | 输出 | 说明 |
|
| 279 |
-
|------|------|------|------|
|
| 280 |
-
| `load_all()` | — | `list[dict]` | 所有成员偏好 |
|
| 281 |
-
| `add_member(name, role, preferences, avoid, allergies, notes)` | 六个 `str` 参数 | `str` (结果消息) | 添加/更新(覆盖同名) |
|
| 282 |
-
| `format_for_prompt()` | — | `str` | System Prompt 格式 |
|
| 283 |
-
|
| 284 |
-
偏好 dict: `{"name", "role", "preferences", "avoid", "allergies", "notes"}`
|
| 285 |
-
|
| 286 |
-
#### 3.5.3 MemoryManager — 家庭记忆
|
| 287 |
-
| 方法 | 输入 | 输出 | 说明 |
|
| 288 |
-
|------|------|------|------|
|
| 289 |
-
| `load_all()` | — | `dict` (relationships/events/daily) | 所有记忆 |
|
| 290 |
-
| `add_event(description)` | `str` | `str` (结果消息) | 添加重要事件 |
|
| 291 |
-
| `add_daily(content)` | `str` | `str` (结果消息) | 添加日常记录 |
|
| 292 |
-
| `format_for_prompt()` | — | `str` | System Prompt 格式 |
|
| 293 |
-
|
| 294 |
-
**顶层统一接口**:
|
| 295 |
-
- `load_all_family_info() -> dict` — 一次性加载所有家庭信息
|
| 296 |
-
- `format_all_for_prompt() -> str` — 全量 System Prompt 文本
|
| 297 |
-
|
| 298 |
-
**数据文件**(`data/family/`):
|
| 299 |
-
- `recipes.md` — Markdown 格式表
|
| 300 |
-
- `preferences.md` — Markdown 格式表
|
| 301 |
-
- `memory.md` — Markdown 格式表
|
| 302 |
-
|
| 303 |
-
> ✅ **风险等级:低** — 接口稳定,有完整测试(`tests/test_family_manager.py`)。更新 `add_member` 覆盖逻辑时注意保留已有数据。
|
| 304 |
-
|
| 305 |
-
---
|
| 306 |
-
|
| 307 |
-
### 3.6 营养标准 — `modules/nutrition_standards.py`
|
| 308 |
-
|
| 309 |
-
类:`BMIStandards`, `DietaryGuideStandards`, `DRIsParser`(全部只读数据+静态方法)
|
| 310 |
-
|
| 311 |
-
| 类 | 关键方法 | 说明 |
|
| 312 |
-
|----|---------|------|
|
| 313 |
-
| `BMIStandards` | `get_bmi_range(pre_preg_bmi) → dict`, `get_recommended_gain(pre_preg_bmi) → dict`, `calculate_bmi(weight_kg, height_m) → float` | 孕前 BMI 标准(中国标准) |
|
| 314 |
-
| `DietaryGuideStandards` | `get_daily_guideline(trimester) → dict`, `get_food_groups_info() → dict` | 膳食指南推荐 |
|
| 315 |
-
| `DRIsParser` | `get_dri_for_trimester(trimester) → dict`, `get_all_dris() → dict` | DRIs 2023 数据 |
|
| 316 |
-
|
| 317 |
-
**DRIsParser.get_dri_for_trimester 返回示例**:
|
| 318 |
-
```json
|
| 319 |
-
{
|
| 320 |
-
"蛋白质": {"value": 70, "unit": "g", "note": "孕中期+15g"},
|
| 321 |
-
"钙": {"value": 1000, "unit": "mg"},
|
| 322 |
-
"铁": {"value": 27, "unit": "mg"},
|
| 323 |
-
"叶酸": {"value": 600, "unit": "mcg"},
|
| 324 |
-
...
|
| 325 |
-
}
|
| 326 |
-
```
|
| 327 |
-
|
| 328 |
-
> ✅ **风险等级:低** — 只读数据类,有完整测试(`tests/test_nutrition_standards.py`)。
|
| 329 |
-
|
| 330 |
-
---
|
| 331 |
-
|
| 332 |
-
### 3.7 营养分析与可视化 — `modules/nutrition_analyzer.py`
|
| 333 |
-
|
| 334 |
-
类:`NutritionAnalyzer`
|
| 335 |
-
|
| 336 |
-
| 方法 | 输入 | 输出 | 说明 |
|
| 337 |
-
|------|------|------|------|
|
| 338 |
-
| `analyze_diet(records)` | `records: list[dict]` (diet_logger 格式) | `dict` | 营养覆盖分析 |
|
| 339 |
-
| `generate_report_chart(analysis)` | `analysis: dict`(上一个方法返回) | `matplotlib.figure.Figure` | 4 面板可视化图表 |
|
| 340 |
-
| `generate_report_text(analysis)` | `analysis: dict` | `str` | 文本格式报告 |
|
| 341 |
-
| `export_report_markdown(analysis, filename)` | `analysis: dict`, `filename: str\|None` | `Path` | 导出 Markdown 报告 |
|
| 342 |
-
|
| 343 |
-
**`analyze_diet` 返回 dict schema**:
|
| 344 |
-
```json
|
| 345 |
-
{
|
| 346 |
-
"total_days": 7,
|
| 347 |
-
"total_records": 21,
|
| 348 |
-
"meal_counts": {"早餐": 5, "午餐": 6, "晚餐": 6, "加餐": 4},
|
| 349 |
-
"food_items": ["全麦面包", "鸡蛋", "牛奶", "清蒸鱼", ...],
|
| 350 |
-
"nutrition_coverage": {
|
| 351 |
-
"叶酸": {
|
| 352 |
-
"matched_foods": ["菠菜"],
|
| 353 |
-
"covered": true,
|
| 354 |
-
"recommended_foods": ["菠菜", "西兰花", "芦笋"],
|
| 355 |
-
"benefit": "预防胎儿神经管畸形",
|
| 356 |
-
"daily_recommend": "0.4",
|
| 357 |
-
"unit": "mg"
|
| 358 |
-
},
|
| 359 |
-
...
|
| 360 |
-
},
|
| 361 |
-
"diversity_score": {"score": 75, "details": ["✅ 早餐: 6/7天 (86%)", ...]},
|
| 362 |
-
"suggestions": ["⚠️ 以下营养素摄入不足: 铁, 钙", ...]
|
| 363 |
-
}
|
| 364 |
-
```
|
| 365 |
-
|
| 366 |
-
**图表输出**(`generate_report_chart`):
|
| 367 |
-
1. 左上:营养覆盖雷达图(最多 8 种营养素)
|
| 368 |
-
2. 右上:各餐次频率柱状图
|
| 369 |
-
3. 左下:饮食多样性评分环形图
|
| 370 |
-
4. 右下:营养建议文本框
|
| 371 |
-
|
| 372 |
-
**依赖**:
|
| 373 |
-
- `config.NUTRITION_DB_FILE`(`data/nutrition_db.json`)— 可自定义
|
| 374 |
-
- `config.DEFAULT_NUTRITION_DB`(10 种营养素的内置推荐)
|
| 375 |
-
- `config.REPORTS_DIR`(`data/reports/`)
|
| 376 |
-
- `utils.setup_chinese_font()` — matplotlib 中文字体
|
| 377 |
-
|
| 378 |
-
> 🔴 **风险等级:高(待改造)** — 当前使用通用 `DEFAULT_NUTRITION_DB` 做营养覆盖分析,**尚未对接 `modules/nutrition_standards.py` 的 DRIs 数据**。改造时需:
|
| 379 |
-
> 1. 将 `_calculate_nutrition_coverage` 中的 `self.nutrition_db` 替换为 `DRIsParser.get_dri_for_trimester()`
|
| 380 |
-
> 2. 保持 `analyze_diet(records) -> dict` 签名不变
|
| 381 |
-
> 3. 保持 `nutrition_coverage` 的 dict key 不变(nutrient name 为 key)
|
| 382 |
-
|
| 383 |
-
---
|
| 384 |
-
|
| 385 |
-
### 3.8 插件基类 — `plugins/base.py`
|
| 386 |
-
|
| 387 |
-
核心类型(供所有插件和 loop 使用):
|
| 388 |
-
|
| 389 |
-
```python
|
| 390 |
-
# 阶段枚举
|
| 391 |
-
class LoopStage(Enum):
|
| 392 |
-
FAMILY_QUIZ = "family_quiz"
|
| 393 |
-
SUMMARIZE = "summarize"
|
| 394 |
-
ANALYZE = "analyze"
|
| 395 |
-
BRIEF = "brief"
|
| 396 |
-
THREE_DAY = "three_day"
|
| 397 |
-
CONSOLIDATE = "consolidate"
|
| 398 |
-
|
| 399 |
-
# 上下文容器(插件间共享)
|
| 400 |
-
@dataclass
|
| 401 |
-
class LoopContext:
|
| 402 |
-
briefing: dict = {} # 累积的简报数据
|
| 403 |
-
weight_data: dict = {}
|
| 404 |
-
diet_records: list = []
|
| 405 |
-
family_recipes: list = [] # family_manager.RecipeManager.load_all() 格式
|
| 406 |
-
family_memory: dict = {} # family_manager.MemoryManager.load_all() 格式
|
| 407 |
-
analysis_results: dict = {}
|
| 408 |
-
errors: list = []
|
| 409 |
-
|
| 410 |
-
# 插件结果
|
| 411 |
-
@dataclass
|
| 412 |
-
class PluginResult:
|
| 413 |
-
success: bool = True
|
| 414 |
-
data: dict = {}
|
| 415 |
-
message: str = ""
|
| 416 |
-
|
| 417 |
-
# 插件基类
|
| 418 |
-
class LoopPlugin(ABC):
|
| 419 |
-
@abstractmethod
|
| 420 |
-
def stage(self) -> LoopStage: ...
|
| 421 |
-
@abstractmethod
|
| 422 |
-
def name(self) -> str: ...
|
| 423 |
-
@abstractmethod
|
| 424 |
-
async def run(self, ctx: LoopContext) -> PluginResult: ...
|
| 425 |
-
```
|
| 426 |
-
|
| 427 |
-
> ✅ **风险等级:低** — 核心架构稳定。新增插件:实现 `LoopPlugin`,在 `loop.py` 的 `_register_default_plugins` 中注册即可。
|
| 428 |
-
|
| 429 |
-
---
|
| 430 |
-
|
| 431 |
-
## 4. 插件管线一览
|
| 432 |
-
|
| 433 |
-
| 插件名 | 阶段 | 文件 | 职责 |
|
| 434 |
-
|--------|------|------|------|
|
| 435 |
-
| `FamilyRecipeQuizPlugin` | `FAMILY_QUIZ` | `plugins/family_quiz.py` | 检查是否需要询问家庭菜谱 |
|
| 436 |
-
| `WeightQuizPlugin` | `FAMILY_QUIZ` | `plugins/family_quiz.py` | 检查是否需要询问体重 |
|
| 437 |
-
| `DietSummaryPlugin` | `SUMMARIZE` | `plugins/diet_summary.py` | 读取昨日饮食日志 |
|
| 438 |
-
| `WeightCheckPlugin` | `SUMMARIZE` | `plugins/weight_check.py` | 体重变化分析 |
|
| 439 |
-
| `FamilyMemoryPlugin` | `SUMMARIZE` | `plugins/family_memory.py` | 提取家庭记忆 |
|
| 440 |
-
| `DRIAnalysisPlugin` | `ANALYZE` | `plugins/dri_analysis.py` | DRIs 营养对比 |
|
| 441 |
-
| `BriefingGeneratorPlugin` | `BRIEF` | `plugins/briefing_generator.py` | 汇总生成今日简报 |
|
| 442 |
-
| `ThreeDaySummaryPlugin` | `THREE_DAY` | `plugins/three_day_summary.py` | 三天综合总结 |
|
| 443 |
-
| `PresetWriterPlugin` | `CONSOLIDATE` | `plugins/preset_writer.py` | 写入预设/缓存 |
|
| 444 |
-
|
| 445 |
-
**插件向 `ctx.briefing` 写入的 key**(`BriefingGeneratorPlugin` 最终消费):
|
| 446 |
-
```
|
| 447 |
-
ctx.briefing["trimester"] # str
|
| 448 |
-
ctx.briefing["need_ask_weight"] # bool
|
| 449 |
-
ctx.briefing["weight_quiz_message"] # str
|
| 450 |
-
ctx.briefing["weight_evaluation"] # dict
|
| 451 |
-
ctx.briefing["need_ask_recipe"] # bool
|
| 452 |
-
ctx.briefing["recipe_quiz_message"] # str
|
| 453 |
-
ctx.briefing["yesterday_diet"] # dict {"status","summary","meal_count"}
|
| 454 |
-
ctx.briefing["dri_analysis"] # dict {"focus_nutrients","summary"}
|
| 455 |
-
ctx.briefing["recommended_foods"] # list[str]
|
| 456 |
-
ctx.briefing["family_memory"] # dict
|
| 457 |
-
ctx.briefing["thinking_keywords"] # str
|
| 458 |
-
```
|
| 459 |
-
|
| 460 |
-
---
|
| 461 |
-
|
| 462 |
-
## 5. 配置模块 — `config.py`
|
| 463 |
-
|
| 464 |
-
每个 Cline 如需新增全局常量,**追加到同类型区域末尾**,并在 commit message 中注明。
|
| 465 |
-
|
| 466 |
-
| 配置区 | 主要内容 | 修改风险 |
|
| 467 |
-
|--------|---------|----------|
|
| 468 |
-
| 目录路径 | `DATA_DIR`, `VOICE_DIR`, `LOGS_DIR`, `REPORTS_DIR`, `FAMILY_FILE`, `DIET_LOG_FILE`, `NUTRITION_DB_FILE` | ⚠️ 中 |
|
| 469 |
-
| 常量枚举 | `FAMILY_ROLES`, `TRIMESTERS` | ✅ 低 |
|
| 470 |
-
| 数据模板 | `DEFAULT_NUTRITION_DB` (10种营养素), `MEAL_TEMPLATES` (4×4), `TRIMESTER_ADJUSTMENTS`, `TRIMESTER_TIPS` | ⚠️ 中 |
|
| 471 |
-
| Schema 版本 | `DIET_LOG_SCHEMA_VERSION = "1.0"` | ⚠️(改版本号前需评估向后兼容) |
|
| 472 |
-
| 声纹阈值 | `VOICEPRINT_SIMILARITY_THRESHOLD = 0.7` | ✅ 低 |
|
| 473 |
-
|
| 474 |
-
---
|
| 475 |
-
|
| 476 |
-
## 6. 数据文件格式规范
|
| 477 |
-
|
| 478 |
-
| 文件 | 格式 | Schema | 读模块 | 写模块 |
|
| 479 |
-
|------|------|--------|--------|--------|
|
| 480 |
-
| `data/diet_logs.json` | JSON | `{"schema_version": "1.0", "records": [...]}` | `diet_logger`, `nutrition_analyzer` | `diet_logger` |
|
| 481 |
-
| `data/nutrition_db.json` | JSON | `{"营养素名": {"category", "daily_recommend_mg/g/mcg", "foods": [...], "benefit"}}` | `nutrition_analyzer` | `nutrition_analyzer` (首次初始化) |
|
| 482 |
-
| `data/family.json` | JSON | `{"members": [...], "voiceprints": {...}}` | `voiceprint` | `voiceprint` |
|
| 483 |
-
| `data/family/recipes.md` | Markdown | `### 菜名\n- **制作人**: ...` | `family_manager.RecipeManager` | `family_manager.RecipeManager` |
|
| 484 |
-
| `data/family/preferences.md` | Markdown | `### 成员:姓名\n- **偏好**: ...` | `family_manager.PreferenceManager` | `family_manager.PreferenceManager` |
|
| 485 |
-
| `data/family/memory.md` | Markdown | `## 重要事件\n- **日期**: ...` | `family_manager.MemoryManager` | `family_manager.MemoryManager` |
|
| 486 |
-
| `data/logs/饮食日志_*.md` | Markdown | 见 3.4 节 | `plugins/diet_summary` | `diet_logger` |
|
| 487 |
-
| `data/reports/营养报告_*.md` | Markdown | 见 3.7 节 | 用户 | `nutrition_analyzer` |
|
| 488 |
-
| `data/presets/.daily_status.json` | JSON | `{"YYYY-MM-DD": {"summary_done": bool, "day_ended": bool}}` | `loop.DailyStatus` | `loop.DailyStatus` |
|
| 489 |
-
|
| 490 |
-
---
|
| 491 |
-
|
| 492 |
-
## 7. 并行 Cline 协作规范
|
| 493 |
-
|
| 494 |
-
### 7.1 分工建议
|
| 495 |
-
|
| 496 |
-
```
|
| 497 |
-
Cline A: Modules 强化(营养分析对接 DRIs、菜谱推荐 AI 化)
|
| 498 |
-
Cline B: UI 界面优化(Gradio 前端增强、报告模板美化)
|
| 499 |
-
Cline C: 声纹升级(Whisper encoder 替换频谱特征)
|
| 500 |
-
Cline D: 数据处理与插件增强(diet_extractor fallback、新插件)
|
| 501 |
-
```
|
| 502 |
-
|
| 503 |
-
### 7.2 关键约定
|
| 504 |
-
|
| 505 |
-
1. **改接口前先 grep**:用 `search_files` 搜索方法名找到所有调用方
|
| 506 |
-
2. **config.py 修改需沟通**:新增常量追加到同类型区域,不删改已有常量名
|
| 507 |
-
3. **数据文件向后兼容**:新增字段优先用 `extensions: {}` 或新增可选 key,不删除已有 key
|
| 508 |
-
4. **测试保持通过**:`python tests/run_tests.py` 零失败
|
| 509 |
-
5. **README 同步更新**:接口签名变更 → 更新本 README 对应章节
|
| 510 |
-
|
| 511 |
-
### 7.3 Git 工作流(强制执行)
|
| 512 |
|
| 513 |
```bash
|
| 514 |
-
#
|
| 515 |
-
|
| 516 |
|
| 517 |
-
#
|
| 518 |
-
|
| 519 |
-
git commit -m "feat: 描述做了什么"
|
| 520 |
|
| 521 |
-
#
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
# 测试通过后推送
|
| 525 |
-
python tests/run_tests.py
|
| 526 |
-
git push origin main
|
| 527 |
```
|
| 528 |
|
| 529 |
-
##
|
| 530 |
|
| 531 |
```
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
```
|
| 539 |
|
| 540 |
-
##
|
| 541 |
-
|
| 542 |
-
| 冲突类型 | 处理方式 |
|
| 543 |
-
|---------|---------|
|
| 544 |
-
| `config.py` 常量冲突 | 取并集,双方新增常量都保留 |
|
| 545 |
-
| 同函数不同实现 | 保留逻辑更完整的一方,另一方改动如果无冲突则合并到合适位置 |
|
| 546 |
-
| 数据文件(JSON/MD)冲突 | 保留两个版本共有的条目 + 各自独有条目(去重) |
|
| 547 |
-
| 测试文件冲突 | **取并集**:保留所有测试用例 |
|
| 548 |
-
| 本 README 冲突 | **取行数更长的一方**��手动整合 |
|
| 549 |
-
| `.gitignore` 冲突 | 取并集 |
|
| 550 |
|
| 551 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
|
| 553 |
-
##
|
| 554 |
-
|
| 555 |
-
```bash
|
| 556 |
-
# 运行全部测试
|
| 557 |
-
python tests/run_tests.py
|
| 558 |
-
```
|
| 559 |
-
|
| 560 |
-
当前测试覆盖:
|
| 561 |
-
- `test_diet_extractor.py` — ✅ 通过(正则解析 + fallback 提取)
|
| 562 |
-
- `test_family_manager.py` — ✅ 通过(菜谱/偏好/记忆 CRUD)
|
| 563 |
-
- `test_nutrition_standards.py` — ✅ 通过(BMI/膳食指南/DRIs 数据)
|
| 564 |
-
- `test_loop.py` — 循环执行测试
|
| 565 |
-
- `test_plugins.py` — 插件集成测试
|
| 566 |
-
|
| 567 |
-
---
|
| 568 |
-
|
| 569 |
-
## 9. Model: MiniCPM-o 4.5
|
| 570 |
-
|
| 571 |
-
```
|
| 572 |
-
Model to be Used: MiniCPM-o 4.5
|
| 573 |
-
```
|
| 574 |
|
| 575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
|
| 577 |
-
|
| 578 |
|
| 579 |
-
|
|
|
|
| 1 |
+
# 🌸 PregoPal - 孕期陪护AI助手
|
| 2 |
+
|
| 3 |
+
一个温馨的家庭式孕期AI伴侣,基于语音交互与智能分析,为孕妇提供全方位的饮食营养陪伴。
|
| 4 |
+
|
| 5 |
+
## ✨ 核心功能
|
| 6 |
+
|
| 7 |
+
### 1. 🏠 现代仪表盘首页
|
| 8 |
+
- **全双工语音交互**:大圆形语音按钮,点击即可说话
|
| 9 |
+
- **AI 实时思考**:显示AI当前的思维状态
|
| 10 |
+
- **信息卡片**:
|
| 11 |
+
- 📅 孕期阶段展示
|
| 12 |
+
- 🥗 营养关注与推荐食物
|
| 13 |
+
- 🍽️ 昨日饮食回顾
|
| 14 |
+
- 🍳 家庭菜谱概览
|
| 15 |
+
- ⚖️ 体重管理状态
|
| 16 |
+
- **最近饮食记录**:以表格形式展示近3天饮食
|
| 17 |
+
|
| 18 |
+
### 2. 👨👩👧👦 家庭饮食习惯(纯展示)
|
| 19 |
+
- **🍳 家庭菜谱**:自动收藏的菜谱卡片网格
|
| 20 |
+
- **🥗 饮食偏好**:每位家庭成员的偏好/忌口标签
|
| 21 |
+
- **📝 家庭记事**:AI记忆的时间线展示
|
| 22 |
+
- 支持手动刷新
|
| 23 |
+
|
| 24 |
+
### 3. 📊 三天营养总结
|
| 25 |
+
- 选择孕期阶段(早/中/晚期)
|
| 26 |
+
- 分析近三天饮食数据
|
| 27 |
+
- 智能菜单改进建议
|
| 28 |
+
|
| 29 |
+
### 4. 📈 营养报告(纯HTML仪表盘)
|
| 30 |
+
- **综合营养评分**:渐变进度条 + 状态标签
|
| 31 |
+
- **餐次完成率**:早餐/午餐/晚餐/加餐的柱状统计
|
| 32 |
+
- **关键营养覆盖**:各营养素百分比进度条
|
| 33 |
+
- **饮食多样性**:分数 + 食材种类统计
|
| 34 |
+
- **AI 饮食建议**:根据分析结果智能提示
|
| 35 |
+
|
| 36 |
+
### 5. 🌐 中英文切换
|
| 37 |
+
一键切换中/英文界面
|
| 38 |
+
|
| 39 |
+
## 🚀 快速启动
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
```bash
|
| 42 |
+
# 1. 安装依赖
|
| 43 |
+
pip install -r requirements.txt
|
| 44 |
|
| 45 |
+
# 2. 运行
|
| 46 |
+
python app.py
|
|
|
|
| 47 |
|
| 48 |
+
# 3. 打开浏览器访问
|
| 49 |
+
# http://127.0.0.1:7860
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
```
|
| 51 |
|
| 52 |
+
## 🏗️ 项目结构
|
| 53 |
|
| 54 |
```
|
| 55 |
+
PregoPal/
|
| 56 |
+
├── app.py # 应用入口
|
| 57 |
+
├── config.py # 全局配置
|
| 58 |
+
├── loop.py # 核心循环引擎
|
| 59 |
+
├── utils.py # 工具函数(CSS/翻译/HTML渲染)
|
| 60 |
+
├── requirements.txt # 依赖清单
|
| 61 |
+
├── ui/
|
| 62 |
+
│ └── app_builder.py # Gradio界面构建(4 Tab)
|
| 63 |
+
├── modules/
|
| 64 |
+
│ ├── voiceprint.py # 声纹识别
|
| 65 |
+
│ ├── diet_logger.py # 饮食日志
|
| 66 |
+
│ ├── diet_extractor.py # 饮食信息提取
|
| 67 |
+
│ ├── nutrition_analyzer.py # 营养分析
|
| 68 |
+
│ ├── meal_recommender.py # 菜品推荐
|
| 69 |
+
│ ├── family_manager.py # 家庭管理
|
| 70 |
+
│ └── nutrition_standards.py # 营养标准
|
| 71 |
+
├── plugins/ # Loop插件
|
| 72 |
+
├── core/ # 核心基础设施
|
| 73 |
+
└── data/ # 数据存储
|
| 74 |
```
|
| 75 |
|
| 76 |
+
## 🎨 UI 设计特色
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
+
- **Glassmorphism 毛玻璃卡片**:现代、轻盈的视觉风格
|
| 79 |
+
- **粉色主题**:温暖、舒适的孕期配色
|
| 80 |
+
- **纯HTML渲染**:营养报告无需matplotlib,实时生成
|
| 81 |
+
- **响应式布局**:桌面与移动端自适应
|
| 82 |
+
- **Tab永不折叠**:所有Tab始终可见
|
| 83 |
|
| 84 |
+
## 🔧 技术栈
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
- **前端**:Gradio + 自定义CSS/HTML
|
| 87 |
+
- **后端**:Python
|
| 88 |
+
- **语音**:声纹识别 + 全双工语音交互
|
| 89 |
+
- **AI**:大语言模型驱动分析
|
| 90 |
+
- **数据**:JSON本地存储
|
| 91 |
|
| 92 |
+
## 📝 许可
|
| 93 |
|
| 94 |
+
MIT License
|
app.py
CHANGED
|
@@ -28,7 +28,7 @@ if __name__ == "__main__":
|
|
| 28 |
# 创建并启动 Gradio 应用
|
| 29 |
demo = create_app(loop)
|
| 30 |
demo.launch(
|
| 31 |
-
server_name="
|
| 32 |
server_port=7860,
|
| 33 |
share=False,
|
| 34 |
debug=False,
|
|
|
|
| 28 |
# 创建并启动 Gradio 应用
|
| 29 |
demo = create_app(loop)
|
| 30 |
demo.launch(
|
| 31 |
+
server_name="127.0.0.1",
|
| 32 |
server_port=7860,
|
| 33 |
share=False,
|
| 34 |
debug=False,
|
modules/nutrition_analyzer.py
CHANGED
|
@@ -68,18 +68,27 @@ class NutritionAnalyzer:
|
|
| 68 |
nutrition_coverage = self._calculate_nutrition_coverage(food_items)
|
| 69 |
|
| 70 |
# 计算饮食多样性评分
|
| 71 |
-
|
| 72 |
|
| 73 |
# 生成建议
|
| 74 |
-
suggestions = self._generate_suggestions(nutrition_coverage,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
return {
|
|
|
|
| 77 |
"total_days": total_days,
|
| 78 |
"total_records": len(records),
|
| 79 |
"meal_counts": meal_counts,
|
| 80 |
"food_items": list(set(food_items)),
|
| 81 |
"nutrition_coverage": nutrition_coverage,
|
| 82 |
-
"diversity_score":
|
|
|
|
| 83 |
"suggestions": suggestions
|
| 84 |
}
|
| 85 |
|
|
@@ -192,7 +201,8 @@ class NutritionAnalyzer:
|
|
| 192 |
|
| 193 |
# 3. 饮食多样性评分仪表盘
|
| 194 |
ax3 = fig.add_subplot(2, 2, 3)
|
| 195 |
-
score = analysis
|
|
|
|
| 196 |
ax3.pie([score, 100 - score], startangle=90,
|
| 197 |
colors=['#FF6B9D', '#F0F0F0'],
|
| 198 |
wedgeprops={'width': 0.3, 'edgecolor': 'white'})
|
|
@@ -228,12 +238,12 @@ class NutritionAnalyzer:
|
|
| 228 |
f"分析周期: {analysis['total_days']} 天",
|
| 229 |
f"记录总数: {analysis['total_records']} 条",
|
| 230 |
"",
|
| 231 |
-
"饮食多样性评分: {}/100".format(analysis['diversity_score']
|
| 232 |
]
|
| 233 |
|
| 234 |
lines.append("")
|
| 235 |
lines.append("各餐次记录情况:")
|
| 236 |
-
for detail in analysis
|
| 237 |
lines.append(f" {detail}")
|
| 238 |
|
| 239 |
lines.append("")
|
|
@@ -270,12 +280,12 @@ class NutritionAnalyzer:
|
|
| 270 |
- **生成时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}
|
| 271 |
|
| 272 |
## 饮食多样性评分
|
| 273 |
-
**评分: {analysis.get('diversity_score',
|
| 274 |
|
| 275 |
| 餐次 | 记录天数 | 覆盖率 |
|
| 276 |
|------|---------|--------|
|
| 277 |
"""
|
| 278 |
-
for detail in analysis.get('
|
| 279 |
parts = detail.split(': ', 1)
|
| 280 |
if len(parts) == 2:
|
| 281 |
content += f"| {parts[0]} | {parts[1]} |\n"
|
|
|
|
| 68 |
nutrition_coverage = self._calculate_nutrition_coverage(food_items)
|
| 69 |
|
| 70 |
# 计算饮食多样性评分
|
| 71 |
+
diversity_result = self._calculate_diversity_score(meal_counts, total_days)
|
| 72 |
|
| 73 |
# 生成建议
|
| 74 |
+
suggestions = self._generate_suggestions(nutrition_coverage, diversity_result)
|
| 75 |
+
|
| 76 |
+
# 综合评分:多样性(50%) + 营养覆盖(50%)
|
| 77 |
+
diversity_num = diversity_result.get("score", 0) if isinstance(diversity_result, dict) else 0
|
| 78 |
+
covered_nutrients = sum(1 for v in nutrition_coverage.values() if v["covered"])
|
| 79 |
+
total_nutrients = max(len(nutrition_coverage), 1)
|
| 80 |
+
nutrition_score = int(covered_nutrients / total_nutrients * 100)
|
| 81 |
+
overall_score = int(diversity_num * 0.5 + nutrition_score * 0.5)
|
| 82 |
|
| 83 |
return {
|
| 84 |
+
"score": overall_score,
|
| 85 |
"total_days": total_days,
|
| 86 |
"total_records": len(records),
|
| 87 |
"meal_counts": meal_counts,
|
| 88 |
"food_items": list(set(food_items)),
|
| 89 |
"nutrition_coverage": nutrition_coverage,
|
| 90 |
+
"diversity_score": diversity_num,
|
| 91 |
+
"diversity_details": diversity_result.get("details", []),
|
| 92 |
"suggestions": suggestions
|
| 93 |
}
|
| 94 |
|
|
|
|
| 201 |
|
| 202 |
# 3. 饮食多样性评分仪表盘
|
| 203 |
ax3 = fig.add_subplot(2, 2, 3)
|
| 204 |
+
score = analysis.get("diversity_score", 0)
|
| 205 |
+
if isinstance(score, dict): score = score.get("score", 0)
|
| 206 |
ax3.pie([score, 100 - score], startangle=90,
|
| 207 |
colors=['#FF6B9D', '#F0F0F0'],
|
| 208 |
wedgeprops={'width': 0.3, 'edgecolor': 'white'})
|
|
|
|
| 238 |
f"分析周期: {analysis['total_days']} 天",
|
| 239 |
f"记录总数: {analysis['total_records']} 条",
|
| 240 |
"",
|
| 241 |
+
"饮食多样性评分: {}/100".format(analysis.get('diversity_score', 0) if not isinstance(analysis.get('diversity_score'), dict) else analysis['diversity_score'].get('score', 0)),
|
| 242 |
]
|
| 243 |
|
| 244 |
lines.append("")
|
| 245 |
lines.append("各餐次记录情况:")
|
| 246 |
+
for detail in analysis.get('diversity_details', []):
|
| 247 |
lines.append(f" {detail}")
|
| 248 |
|
| 249 |
lines.append("")
|
|
|
|
| 280 |
- **生成时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}
|
| 281 |
|
| 282 |
## 饮食多样性评分
|
| 283 |
+
**评分: {analysis.get('diversity_score', 0) if not isinstance(analysis.get('diversity_score'), dict) else analysis['diversity_score'].get('score', 0)}/100**
|
| 284 |
|
| 285 |
| 餐次 | 记录天数 | 覆盖率 |
|
| 286 |
|------|---------|--------|
|
| 287 |
"""
|
| 288 |
+
for detail in analysis.get('diversity_details', []):
|
| 289 |
parts = detail.split(': ', 1)
|
| 290 |
if len(parts) == 2:
|
| 291 |
content += f"| {parts[0]} | {parts[1]} |\n"
|
ui/app_builder.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
PregoPal - Gradio 界面构建
|
| 3 |
-
===========================
|
| 4 |
-
4 个子页面 + 中英文切换
|
| 5 |
1. 🏠 首页(语音启动按钮 + AI 思考 + 简报卡片 + 最近记录)
|
| 6 |
-
2. 👨👩👧👦 家庭饮食习惯
|
| 7 |
3. 📊 三天总结
|
| 8 |
-
4. 📈 营养报告
|
| 9 |
|
| 10 |
语言切换设计:
|
| 11 |
用唯一一个 @gr.render(inputs=[lang_state]) 包裹全部 4 个 Tab,
|
|
@@ -22,7 +22,13 @@ from modules.nutrition_analyzer import NutritionAnalyzer
|
|
| 22 |
from modules.family_manager import RecipeManager, PreferenceManager, MemoryManager
|
| 23 |
from modules.diet_extractor import DietExtractor
|
| 24 |
from modules.nutrition_standards import DRIsParser
|
| 25 |
-
from utils import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# ============================================================
|
| 28 |
# 全局实例(单例)
|
|
@@ -173,18 +179,7 @@ def _home_content(loop, lang):
|
|
| 173 |
with gr.Column(scale=2):
|
| 174 |
gr.HTML(f"""
|
| 175 |
<div style="text-align: center; padding: 24px 0;">
|
| 176 |
-
<button class="voice-main-btn"
|
| 177 |
-
width:160px;height:160px;border-radius:50%;
|
| 178 |
-
background:linear-gradient(135deg,#FF6B8A 0%,#FF4081 100%);
|
| 179 |
-
border:4px solid #FFF;
|
| 180 |
-
box-shadow:0 8px 32px rgba(255,64,129,0.3);
|
| 181 |
-
cursor:pointer;font-size:48px;color:white;
|
| 182 |
-
transition:all 0.3s ease;"
|
| 183 |
-
onmouseover="this.style.transform='scale(1.05)';this.style.boxShadow='0 12px 40px rgba(255,64,129,0.4)'"
|
| 184 |
-
onmouseout="this.style.transform='scale(1)';this.style.boxShadow='0 8px 32px rgba(255,64,129,0.3)'"
|
| 185 |
-
>
|
| 186 |
-
🎙️
|
| 187 |
-
</button>
|
| 188 |
<p style="margin-top: 8px; color: #999; font-size: 14px;">{T['tap_hint']}</p>
|
| 189 |
</div>
|
| 190 |
""")
|
|
@@ -233,107 +228,39 @@ def _home_content(loop, lang):
|
|
| 233 |
|
| 234 |
|
| 235 |
# ============================================================
|
| 236 |
-
# Tab 2: 👨👩👧👦 家庭饮食习惯
|
| 237 |
# ============================================================
|
| 238 |
def _family_content(lang):
|
| 239 |
-
"""家庭饮食习惯组件"""
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
pref_name = gr.Textbox(label=t("pref_name", lang), placeholder="小明")
|
| 245 |
-
pref_role = gr.Dropdown(
|
| 246 |
-
label=t("pref_role", lang),
|
| 247 |
-
choices=ZH["relation_choices"] if lang == "zh" else EN["relation_choices"],
|
| 248 |
-
value=ZH["relation_choices"][0] if lang == "zh" else EN["relation_choices"][0],
|
| 249 |
-
)
|
| 250 |
-
pref_likes = gr.Textbox(
|
| 251 |
-
label=t("pref_likes", lang),
|
| 252 |
-
placeholder=t("pref_likes_placeholder", lang),
|
| 253 |
-
lines=1,
|
| 254 |
-
)
|
| 255 |
-
pref_avoid = gr.Textbox(
|
| 256 |
-
label=t("pref_avoid", lang),
|
| 257 |
-
placeholder=t("pref_avoid_placeholder", lang),
|
| 258 |
-
lines=1,
|
| 259 |
-
)
|
| 260 |
-
pref_allergy = gr.Textbox(
|
| 261 |
-
label=t("pref_allergy", lang),
|
| 262 |
-
placeholder=t("pref_allergy_placeholder", lang),
|
| 263 |
-
lines=1,
|
| 264 |
-
)
|
| 265 |
-
save_pref_btn = gr.Button(t("btn_save", lang), variant="primary", size="sm")
|
| 266 |
-
with gr.Column():
|
| 267 |
-
refresh_pref_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
| 268 |
-
pref_display = gr.Textbox(label="", interactive=False, lines=12)
|
| 269 |
-
|
| 270 |
-
save_pref_btn.click(
|
| 271 |
-
fn=lambda n, r, p, a, al: PreferenceManager.add_member(n, r, p, a, al, ""),
|
| 272 |
-
inputs=[pref_name, pref_role, pref_likes, pref_avoid, pref_allergy],
|
| 273 |
-
outputs=[gr.Textbox()],
|
| 274 |
-
).then(fn=lambda: PreferenceManager.format_for_prompt(), outputs=pref_display)
|
| 275 |
-
refresh_pref_btn.click(fn=lambda: PreferenceManager.format_for_prompt(), outputs=pref_display)
|
| 276 |
|
|
|
|
| 277 |
with gr.Tab(t("tab_recipes", lang)):
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
)
|
| 293 |
-
recipe_ingredients = gr.Textbox(
|
| 294 |
-
label=t("recipe_ingredients", lang),
|
| 295 |
-
placeholder=t("recipe_ingredients_placeholder", lang),
|
| 296 |
-
lines=1,
|
| 297 |
-
)
|
| 298 |
-
save_recipe_btn = gr.Button(t("btn_add", lang), variant="primary", size="sm")
|
| 299 |
-
with gr.Column():
|
| 300 |
-
refresh_recipe_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
| 301 |
-
recipe_display = gr.Textbox(label="", interactive=False, lines=12)
|
| 302 |
-
|
| 303 |
-
save_recipe_btn.click(
|
| 304 |
-
fn=lambda n, c, d, i: RecipeManager.add_recipe(n, c, d, i, ""),
|
| 305 |
-
inputs=[recipe_name, recipe_cook, recipe_diff, recipe_ingredients],
|
| 306 |
-
outputs=[gr.Textbox()],
|
| 307 |
-
).then(fn=lambda: RecipeManager.format_for_prompt(), outputs=recipe_display)
|
| 308 |
-
refresh_recipe_btn.click(fn=lambda: RecipeManager.format_for_prompt(), outputs=recipe_display)
|
| 309 |
|
| 310 |
with gr.Tab(t("tab_memory", lang)):
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
)
|
| 318 |
-
memory_content = gr.Textbox(
|
| 319 |
-
label=t("memory_content", lang),
|
| 320 |
-
placeholder=t("memory_content_placeholder", lang),
|
| 321 |
-
lines=2,
|
| 322 |
-
)
|
| 323 |
-
save_memory_btn = gr.Button(t("btn_save_memory", lang), variant="primary", size="sm")
|
| 324 |
-
with gr.Column():
|
| 325 |
-
refresh_memory_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
| 326 |
-
memory_display = gr.Textbox(label="", interactive=False, lines=12)
|
| 327 |
-
|
| 328 |
-
def save_memory(m_type, content):
|
| 329 |
-
if m_type in ["事件", "Event"]:
|
| 330 |
-
return MemoryManager.add_event(content)
|
| 331 |
-
return MemoryManager.add_daily(f"[{m_type}] {content}")
|
| 332 |
-
|
| 333 |
-
save_memory_btn.click(
|
| 334 |
-
fn=save_memory, inputs=[memory_type, memory_content], outputs=[gr.Textbox()]
|
| 335 |
-
).then(fn=lambda: MemoryManager.format_for_prompt(), outputs=memory_display)
|
| 336 |
-
refresh_memory_btn.click(fn=lambda: MemoryManager.format_for_prompt(), outputs=memory_display)
|
| 337 |
|
| 338 |
|
| 339 |
# ============================================================
|
|
@@ -386,40 +313,30 @@ def _summary_content(lang):
|
|
| 386 |
|
| 387 |
|
| 388 |
# ============================================================
|
| 389 |
-
# Tab 4: 📈 营养报告
|
| 390 |
# ============================================================
|
| 391 |
def _report_content(lang):
|
| 392 |
-
"""营养报告组件"""
|
| 393 |
with gr.Row():
|
| 394 |
with gr.Column(scale=1):
|
| 395 |
-
analysis_days = gr.Slider(
|
|
|
|
|
|
|
|
|
|
| 396 |
generate_btn = gr.Button(t("btn_generate", lang), variant="primary")
|
| 397 |
-
with gr.Column(scale=2):
|
| 398 |
-
report_text = gr.Textbox(label=t("report_text", lang), interactive=False, lines=12)
|
| 399 |
-
|
| 400 |
-
with gr.Row():
|
| 401 |
-
report_plot = gr.Plot(label=t("report_chart", lang))
|
| 402 |
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
| 406 |
|
| 407 |
def generate_report(days):
|
| 408 |
records = diet_logger.get_recent_records(days=int(days))
|
| 409 |
analysis = nutrition_analyzer.analyze_diet(records)
|
| 410 |
-
|
| 411 |
-
chart = nutrition_analyzer.generate_report_chart(analysis)
|
| 412 |
-
return text_report, chart
|
| 413 |
|
| 414 |
-
|
| 415 |
-
records = diet_logger.get_recent_records(days=int(days))
|
| 416 |
-
analysis = nutrition_analyzer.analyze_diet(records)
|
| 417 |
-
md_path = nutrition_analyzer.export_report_markdown(analysis)
|
| 418 |
-
prefix = "导出成功" if lang == "zh" else "Exported"
|
| 419 |
-
return f"{prefix}: {md_path.name}"
|
| 420 |
-
|
| 421 |
-
generate_btn.click(fn=generate_report, inputs=[analysis_days], outputs=[report_text, report_plot])
|
| 422 |
-
export_md_btn.click(fn=export_report, inputs=[analysis_days], outputs=export_result)
|
| 423 |
|
| 424 |
|
| 425 |
# ============================================================
|
|
@@ -429,12 +346,6 @@ def _report_content(lang):
|
|
| 429 |
def create_app(loop=None):
|
| 430 |
"""
|
| 431 |
创建主应用(4 Tab + 中英文切换 + 自定义 CSS)。
|
| 432 |
-
|
| 433 |
-
设计要点:
|
| 434 |
-
- 只使用 **一个** @gr.render 包裹所有 Tab,避免多个 @gr.render
|
| 435 |
-
并发触发导致 RuntimeError(dictionary changed size during iteration)
|
| 436 |
-
- 每个 Tab 的标题通过 t() 函数实现中英文切换
|
| 437 |
-
- theme / css / head 传入 demo.launch() 以兼容 Gradio 6.0
|
| 438 |
"""
|
| 439 |
with gr.Blocks(
|
| 440 |
title="PregoPal - 孕期陪护AI助手",
|
|
|
|
| 1 |
"""
|
| 2 |
+
PregoPal - Gradio 界面构建(现代UI版)
|
| 3 |
+
======================================
|
| 4 |
+
4 个子页面 + 中英文切换:
|
| 5 |
1. 🏠 首页(语音启动按钮 + AI 思考 + 简报卡片 + 最近记录)
|
| 6 |
+
2. 👨👩👧👦 家庭饮食习惯(纯展示卡片)
|
| 7 |
3. 📊 三天总结
|
| 8 |
+
4. 📈 营养报告(纯 HTML Dashboard)
|
| 9 |
|
| 10 |
语言切换设计:
|
| 11 |
用唯一一个 @gr.render(inputs=[lang_state]) 包裹全部 4 个 Tab,
|
|
|
|
| 22 |
from modules.family_manager import RecipeManager, PreferenceManager, MemoryManager
|
| 23 |
from modules.diet_extractor import DietExtractor
|
| 24 |
from modules.nutrition_standards import DRIsParser
|
| 25 |
+
from utils import (
|
| 26 |
+
t, ZH, EN, get_home_cards, CUSTOM_CSS,
|
| 27 |
+
render_family_recipes_html,
|
| 28 |
+
render_family_preferences_html,
|
| 29 |
+
render_family_memories_html,
|
| 30 |
+
render_nutrition_report_html,
|
| 31 |
+
)
|
| 32 |
|
| 33 |
# ============================================================
|
| 34 |
# 全局实例(单例)
|
|
|
|
| 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 |
""")
|
|
|
|
| 228 |
|
| 229 |
|
| 230 |
# ============================================================
|
| 231 |
+
# Tab 2: 👨👩👧👦 家庭饮食习惯(纯展示,3 张现代卡片)
|
| 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")
|
| 244 |
+
refresh_recipe_btn.click(
|
| 245 |
+
fn=lambda: render_family_recipes_html(lang),
|
| 246 |
+
outputs=recipes_html,
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
with gr.Tab(t("tab_preferences", lang)):
|
| 250 |
+
prefs_html = gr.HTML(render_family_preferences_html(lang))
|
| 251 |
+
refresh_pref_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
| 252 |
+
refresh_pref_btn.click(
|
| 253 |
+
fn=lambda: render_family_preferences_html(lang),
|
| 254 |
+
outputs=prefs_html,
|
| 255 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
with gr.Tab(t("tab_memory", lang)):
|
| 258 |
+
mem_html = gr.HTML(render_family_memories_html(lang))
|
| 259 |
+
refresh_mem_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
| 260 |
+
refresh_mem_btn.click(
|
| 261 |
+
fn=lambda: render_family_memories_html(lang),
|
| 262 |
+
outputs=mem_html,
|
| 263 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
|
| 266 |
# ============================================================
|
|
|
|
| 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 |
# ============================================================
|
|
|
|
| 346 |
def create_app(loop=None):
|
| 347 |
"""
|
| 348 |
创建主应用(4 Tab + 中英文切换 + 自定义 CSS)。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
"""
|
| 350 |
with gr.Blocks(
|
| 351 |
title="PregoPal - 孕期陪护AI助手",
|
utils.py
CHANGED
|
@@ -17,20 +17,10 @@ from pathlib import Path
|
|
| 17 |
|
| 18 |
def setup_chinese_font():
|
| 19 |
"""尝试设置中文字体,返回使用的字体名称"""
|
| 20 |
-
# 优先使用 Windows 系统字体
|
| 21 |
font_candidates = [
|
| 22 |
-
'SimHei',
|
| 23 |
-
'
|
| 24 |
-
'
|
| 25 |
-
'KaiTi', # 楷体
|
| 26 |
-
'FangSong', # 仿宋
|
| 27 |
-
'WenQuanYi Micro Hei',
|
| 28 |
-
'Noto Sans CJK SC',
|
| 29 |
-
'Noto Sans SC',
|
| 30 |
-
'Source Han Sans SC',
|
| 31 |
-
'PingFang SC',
|
| 32 |
-
'Hiragino Sans GB',
|
| 33 |
-
'STHeiti',
|
| 34 |
]
|
| 35 |
available = [f.name for f in fm.fontManager.ttflist]
|
| 36 |
for font in font_candidates:
|
|
@@ -38,8 +28,6 @@ def setup_chinese_font():
|
|
| 38 |
plt.rcParams['font.sans-serif'] = [font, 'DejaVu Sans']
|
| 39 |
plt.rcParams['axes.unicode_minus'] = False
|
| 40 |
return font
|
| 41 |
-
|
| 42 |
-
# fallback: 尝试直接加载系统字体文件
|
| 43 |
system_fonts = [
|
| 44 |
Path("C:/Windows/Fonts/simhei.ttf"),
|
| 45 |
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
@@ -55,8 +43,6 @@ def setup_chinese_font():
|
|
| 55 |
return font_name
|
| 56 |
except Exception:
|
| 57 |
continue
|
| 58 |
-
|
| 59 |
-
# 最终 fallback
|
| 60 |
plt.rcParams['font.sans-serif'] = ['DejaVu Sans']
|
| 61 |
plt.rcParams['axes.unicode_minus'] = False
|
| 62 |
return None
|
|
@@ -66,19 +52,13 @@ def setup_chinese_font():
|
|
| 66 |
# 国际化(i18n)
|
| 67 |
# ============================================================
|
| 68 |
|
| 69 |
-
# 中文字典
|
| 70 |
ZH = {
|
| 71 |
-
# 应用标题
|
| 72 |
"app_title": "🌸 PregoPal - 孕期陪护AI助手",
|
| 73 |
"app_subtitle": "一个温馨的家庭式孕期AI伴侣",
|
| 74 |
-
|
| 75 |
-
# Tab 名称(后缀,用于 @gr.render 拼接)
|
| 76 |
"tab_home_suffix": "首页",
|
| 77 |
"tab_family_suffix": "家庭饮食习惯",
|
| 78 |
"tab_summary_suffix": "三天总结",
|
| 79 |
"tab_report_suffix": "营养报告",
|
| 80 |
-
|
| 81 |
-
# 语音交互页
|
| 82 |
"chat_label": "💬 对话",
|
| 83 |
"msg_placeholder": "例如:我今天中午吃了番茄牛腩",
|
| 84 |
"btn_send": "发送",
|
|
@@ -92,66 +72,45 @@ ZH = {
|
|
| 92 |
"btn_register": "📝 注册",
|
| 93 |
"btn_identify": "🔊 识别",
|
| 94 |
"briefing_title": "#### 📋 今日简报",
|
| 95 |
-
|
| 96 |
-
# 家庭饮食习惯页
|
| 97 |
"tab_preferences": "🥗 饮食偏好",
|
| 98 |
"tab_recipes": "🍳 家庭菜谱",
|
| 99 |
-
"tab_memory": "
|
| 100 |
-
"pref_name": "姓名",
|
| 101 |
-
"pref_role": "身份",
|
| 102 |
-
"pref_likes": "偏好",
|
| 103 |
-
"pref_likes_placeholder": "喜欢吃什么?",
|
| 104 |
-
"pref_avoid": "忌口",
|
| 105 |
-
"pref_avoid_placeholder": "不吃什么?",
|
| 106 |
-
"pref_allergy": "过敏",
|
| 107 |
-
"pref_allergy_placeholder": "对什么过敏?",
|
| 108 |
-
"btn_save": "💾 保存",
|
| 109 |
"btn_refresh": "🔄 刷新",
|
| 110 |
-
"recipe_name": "菜名",
|
| 111 |
-
"recipe_name_placeholder": "番茄牛腩",
|
| 112 |
-
"recipe_cook": "制作人",
|
| 113 |
-
"recipe_cook_placeholder": "谁会做?",
|
| 114 |
-
"recipe_diff": "难度",
|
| 115 |
-
"recipe_diff_choices": ["简单", "中等", "困难"],
|
| 116 |
-
"recipe_ingredients": "食材",
|
| 117 |
-
"recipe_ingredients_placeholder": "主要食材",
|
| 118 |
-
"btn_add": "💾 添加",
|
| 119 |
-
"memory_type": "类型",
|
| 120 |
-
"memory_type_choices": ["关系", "事件", "日常"],
|
| 121 |
-
"memory_content": "内容",
|
| 122 |
-
"memory_content_placeholder": "记录家庭关系、重要事件或日常点滴",
|
| 123 |
-
"btn_save_memory": "💾 保存",
|
| 124 |
-
|
| 125 |
-
# 三天总结页
|
| 126 |
"trimester_label": "孕期阶段",
|
| 127 |
"trimester_choices": ["孕早期", "孕中期", "孕晚期"],
|
| 128 |
"btn_analyze": "📊 运行分析",
|
| 129 |
"analysis_result": "📋 分析结果",
|
| 130 |
"menu_suggestion": "💡 菜单改进建议",
|
| 131 |
-
|
| 132 |
-
# 营养报告页
|
| 133 |
"analysis_days": "分析天数",
|
| 134 |
"btn_generate": "📊 生成报告",
|
| 135 |
"report_text": "📋 文本报告",
|
| 136 |
-
"report_chart": "📊 可视化图表",
|
| 137 |
-
"btn_export_md": "📥 导出 Markdown",
|
| 138 |
-
|
| 139 |
-
# 通用
|
| 140 |
"lang_label": "🌐 语言",
|
| 141 |
"lang_zh": "中文",
|
| 142 |
"lang_en": "English",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
}
|
| 144 |
|
| 145 |
-
# 英文字典
|
| 146 |
EN = {
|
| 147 |
"app_title": "🌸 PregoPal - Pregnancy Companion AI",
|
| 148 |
"app_subtitle": "A cozy home-oriented AI companion for expectant mothers",
|
| 149 |
-
|
| 150 |
"tab_home_suffix": "Home",
|
| 151 |
"tab_family_suffix": "Family Diet",
|
| 152 |
"tab_summary_suffix": "3-Day Summary",
|
| 153 |
"tab_report_suffix": "Nutrition Report",
|
| 154 |
-
|
| 155 |
"chat_label": "💬 Chat",
|
| 156 |
"msg_placeholder": "e.g. I had tomato beef brisket for lunch today",
|
| 157 |
"btn_send": "Send",
|
|
@@ -165,50 +124,36 @@ EN = {
|
|
| 165 |
"btn_register": "📝 Register",
|
| 166 |
"btn_identify": "🔊 Identify",
|
| 167 |
"briefing_title": "#### 📋 Today's Briefing",
|
| 168 |
-
|
| 169 |
"tab_preferences": "🥗 Preferences",
|
| 170 |
"tab_recipes": "🍳 Recipes",
|
| 171 |
-
"tab_memory": "
|
| 172 |
-
"pref_name": "Name",
|
| 173 |
-
"pref_role": "Role",
|
| 174 |
-
"pref_likes": "Likes",
|
| 175 |
-
"pref_likes_placeholder": "What do they like?",
|
| 176 |
-
"pref_avoid": "Dislikes",
|
| 177 |
-
"pref_avoid_placeholder": "What don't they eat?",
|
| 178 |
-
"pref_allergy": "Allergies",
|
| 179 |
-
"pref_allergy_placeholder": "Any allergies?",
|
| 180 |
-
"btn_save": "💾 Save",
|
| 181 |
"btn_refresh": "🔄 Refresh",
|
| 182 |
-
"recipe_name": "Dish Name",
|
| 183 |
-
"recipe_name_placeholder": "Tomato Beef Brisket",
|
| 184 |
-
"recipe_cook": "Cook",
|
| 185 |
-
"recipe_cook_placeholder": "Who makes it?",
|
| 186 |
-
"recipe_diff": "Difficulty",
|
| 187 |
-
"recipe_diff_choices": ["Easy", "Medium", "Hard"],
|
| 188 |
-
"recipe_ingredients": "Ingredients",
|
| 189 |
-
"recipe_ingredients_placeholder": "Main ingredients",
|
| 190 |
-
"btn_add": "💾 Add",
|
| 191 |
-
"memory_type": "Type",
|
| 192 |
-
"memory_type_choices": ["Relation", "Event", "Daily"],
|
| 193 |
-
"memory_content": "Content",
|
| 194 |
-
"memory_content_placeholder": "Record family relations, events or daily moments",
|
| 195 |
-
"btn_save_memory": "💾 Save",
|
| 196 |
-
|
| 197 |
"trimester_label": "Trimester",
|
| 198 |
"trimester_choices": ["1st Trimester", "2nd Trimester", "3rd Trimester"],
|
| 199 |
"btn_analyze": "📊 Analyze",
|
| 200 |
"analysis_result": "📋 Analysis Result",
|
| 201 |
"menu_suggestion": "💡 Menu Suggestions",
|
| 202 |
-
|
| 203 |
"analysis_days": "Analysis Days",
|
| 204 |
"btn_generate": "📊 Generate Report",
|
| 205 |
"report_text": "📋 Text Report",
|
| 206 |
-
"report_chart": "📊 Chart",
|
| 207 |
-
"btn_export_md": "📥 Export Markdown",
|
| 208 |
-
|
| 209 |
"lang_label": "🌐 Language",
|
| 210 |
"lang_zh": "中文",
|
| 211 |
"lang_en": "English",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
}
|
| 213 |
|
| 214 |
|
|
@@ -225,33 +170,20 @@ def t(key: str, lang: str = "zh") -> str:
|
|
| 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,
|
|
@@ -266,29 +198,333 @@ def get_home_cards(loop=None):
|
|
| 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 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
border-radius: 16px !important;
|
| 283 |
-
padding:
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
transition: all 0.3s ease;
|
| 288 |
}
|
| 289 |
-
.
|
| 290 |
-
box-shadow: 0 4px
|
| 291 |
-
transform: translateY(-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
}
|
| 293 |
|
| 294 |
/* 大圆形按钮(语音启动) */
|
|
@@ -301,8 +537,11 @@ CUSTOM_CSS = """
|
|
| 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:
|
| 305 |
color: white !important;
|
|
|
|
|
|
|
|
|
|
| 306 |
}
|
| 307 |
.voice-main-btn:hover {
|
| 308 |
transform: scale(1.05) !important;
|
|
@@ -312,83 +551,29 @@ CUSTOM_CSS = """
|
|
| 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:
|
| 389 |
}
|
| 390 |
.gradio-container {
|
| 391 |
-
padding:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
}
|
| 393 |
}
|
| 394 |
-
"""
|
|
|
|
| 17 |
|
| 18 |
def setup_chinese_font():
|
| 19 |
"""尝试设置中文字体,返回使用的字体名称"""
|
|
|
|
| 20 |
font_candidates = [
|
| 21 |
+
'SimHei', 'Microsoft YaHei', 'SimSun', 'KaiTi', 'FangSong',
|
| 22 |
+
'WenQuanYi Micro Hei', 'Noto Sans CJK SC', 'Noto Sans SC',
|
| 23 |
+
'Source Han Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'STHeiti',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
]
|
| 25 |
available = [f.name for f in fm.fontManager.ttflist]
|
| 26 |
for font in font_candidates:
|
|
|
|
| 28 |
plt.rcParams['font.sans-serif'] = [font, 'DejaVu Sans']
|
| 29 |
plt.rcParams['axes.unicode_minus'] = False
|
| 30 |
return font
|
|
|
|
|
|
|
| 31 |
system_fonts = [
|
| 32 |
Path("C:/Windows/Fonts/simhei.ttf"),
|
| 33 |
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
|
|
| 43 |
return font_name
|
| 44 |
except Exception:
|
| 45 |
continue
|
|
|
|
|
|
|
| 46 |
plt.rcParams['font.sans-serif'] = ['DejaVu Sans']
|
| 47 |
plt.rcParams['axes.unicode_minus'] = False
|
| 48 |
return None
|
|
|
|
| 52 |
# 国际化(i18n)
|
| 53 |
# ============================================================
|
| 54 |
|
|
|
|
| 55 |
ZH = {
|
|
|
|
| 56 |
"app_title": "🌸 PregoPal - 孕期陪护AI助手",
|
| 57 |
"app_subtitle": "一个温馨的家庭式孕期AI伴侣",
|
|
|
|
|
|
|
| 58 |
"tab_home_suffix": "首页",
|
| 59 |
"tab_family_suffix": "家庭饮食习惯",
|
| 60 |
"tab_summary_suffix": "三天总结",
|
| 61 |
"tab_report_suffix": "营养报告",
|
|
|
|
|
|
|
| 62 |
"chat_label": "💬 对话",
|
| 63 |
"msg_placeholder": "例如:我今天中午吃了番茄牛腩",
|
| 64 |
"btn_send": "发送",
|
|
|
|
| 72 |
"btn_register": "📝 注册",
|
| 73 |
"btn_identify": "🔊 识别",
|
| 74 |
"briefing_title": "#### 📋 今日简报",
|
|
|
|
|
|
|
| 75 |
"tab_preferences": "🥗 饮食偏好",
|
| 76 |
"tab_recipes": "🍳 家庭菜谱",
|
| 77 |
+
"tab_memory": "📝 家庭记事",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
"btn_refresh": "🔄 刷新",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
"trimester_label": "孕期阶段",
|
| 80 |
"trimester_choices": ["孕早期", "孕中期", "孕晚期"],
|
| 81 |
"btn_analyze": "📊 运行分析",
|
| 82 |
"analysis_result": "📋 分析结果",
|
| 83 |
"menu_suggestion": "💡 菜单改进建议",
|
|
|
|
|
|
|
| 84 |
"analysis_days": "分析天数",
|
| 85 |
"btn_generate": "📊 生成报告",
|
| 86 |
"report_text": "📋 文本报告",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
"lang_label": "🌐 语言",
|
| 88 |
"lang_zh": "中文",
|
| 89 |
"lang_en": "English",
|
| 90 |
+
"family_title": "👨👩👧👦 家庭饮食档案",
|
| 91 |
+
"family_subtitle": "AI 自动记录的家庭饮食习惯",
|
| 92 |
+
"no_recipes": "暂无菜谱记录 — AI 将在对话中自动学习",
|
| 93 |
+
"no_preferences": "暂无饮食偏好记录 — AI 将在对话中自动学习",
|
| 94 |
+
"no_memories": "暂无家庭记忆 — AI 将在对话中自动学习",
|
| 95 |
+
"report_title": "📈 营养分析报告",
|
| 96 |
+
"report_subtitle": "基于近期饮食数据的 AI 分析",
|
| 97 |
+
"report_overall": "综合营养评分",
|
| 98 |
+
"report_good": "良好",
|
| 99 |
+
"report_need_attention": "需关注",
|
| 100 |
+
"report_meal_completion": "餐次完成率",
|
| 101 |
+
"report_nutrient_coverage": "关键营养覆盖",
|
| 102 |
+
"report_diversity": "饮食多样性",
|
| 103 |
+
"report_suggestions": "饮食建议",
|
| 104 |
+
"report_days": "近{days}天",
|
| 105 |
}
|
| 106 |
|
|
|
|
| 107 |
EN = {
|
| 108 |
"app_title": "🌸 PregoPal - Pregnancy Companion AI",
|
| 109 |
"app_subtitle": "A cozy home-oriented AI companion for expectant mothers",
|
|
|
|
| 110 |
"tab_home_suffix": "Home",
|
| 111 |
"tab_family_suffix": "Family Diet",
|
| 112 |
"tab_summary_suffix": "3-Day Summary",
|
| 113 |
"tab_report_suffix": "Nutrition Report",
|
|
|
|
| 114 |
"chat_label": "💬 Chat",
|
| 115 |
"msg_placeholder": "e.g. I had tomato beef brisket for lunch today",
|
| 116 |
"btn_send": "Send",
|
|
|
|
| 124 |
"btn_register": "📝 Register",
|
| 125 |
"btn_identify": "🔊 Identify",
|
| 126 |
"briefing_title": "#### 📋 Today's Briefing",
|
|
|
|
| 127 |
"tab_preferences": "🥗 Preferences",
|
| 128 |
"tab_recipes": "🍳 Recipes",
|
| 129 |
+
"tab_memory": "📝 Memories",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
"btn_refresh": "🔄 Refresh",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
"trimester_label": "Trimester",
|
| 132 |
"trimester_choices": ["1st Trimester", "2nd Trimester", "3rd Trimester"],
|
| 133 |
"btn_analyze": "📊 Analyze",
|
| 134 |
"analysis_result": "📋 Analysis Result",
|
| 135 |
"menu_suggestion": "💡 Menu Suggestions",
|
|
|
|
| 136 |
"analysis_days": "Analysis Days",
|
| 137 |
"btn_generate": "📊 Generate Report",
|
| 138 |
"report_text": "📋 Text Report",
|
|
|
|
|
|
|
|
|
|
| 139 |
"lang_label": "🌐 Language",
|
| 140 |
"lang_zh": "中文",
|
| 141 |
"lang_en": "English",
|
| 142 |
+
"family_title": "👨👩👧👦 Family Diet Profile",
|
| 143 |
+
"family_subtitle": "AI-automated family diet memory",
|
| 144 |
+
"no_recipes": "No recipes yet — AI will learn from conversations",
|
| 145 |
+
"no_preferences": "No preferences yet — AI will learn from conversations",
|
| 146 |
+
"no_memories": "No memories yet — AI will learn from conversations",
|
| 147 |
+
"report_title": "📈 Nutrition Report",
|
| 148 |
+
"report_subtitle": "AI analysis based on recent diet data",
|
| 149 |
+
"report_overall": "Overall Score",
|
| 150 |
+
"report_good": "Good",
|
| 151 |
+
"report_need_attention": "Needs Attention",
|
| 152 |
+
"report_meal_completion": "Meal Completion",
|
| 153 |
+
"report_nutrient_coverage": "Key Nutrients",
|
| 154 |
+
"report_diversity": "Diet Diversity",
|
| 155 |
+
"report_suggestions": "Dietary Suggestions",
|
| 156 |
+
"report_days": "Last {days} days",
|
| 157 |
}
|
| 158 |
|
| 159 |
|
|
|
|
| 170 |
def get_home_cards(loop=None):
|
| 171 |
"""从 Loop 上下文提取首页卡片数据"""
|
| 172 |
briefing = loop.get_briefing() if loop else {}
|
|
|
|
|
|
|
| 173 |
trimester = briefing.get("trimester", "孕中期")
|
|
|
|
|
|
|
| 174 |
focus_nutrients = briefing.get("dri_analysis", {}).get("focus_nutrients", [])
|
| 175 |
recommended_foods = briefing.get("recommended_foods", [])
|
|
|
|
|
|
|
| 176 |
from modules.family_manager import RecipeManager
|
| 177 |
all_recipes = RecipeManager.load_all()
|
| 178 |
recipe_count = len(all_recipes)
|
| 179 |
recipe_names = [r.get("name", "") for r in all_recipes[:3]]
|
|
|
|
|
|
|
| 180 |
yesterday = briefing.get("yesterday_diet", {})
|
| 181 |
yesterday_summary = yesterday.get("summary", "暂无记录")
|
| 182 |
meal_count = yesterday.get("meal_count", 0)
|
|
|
|
|
|
|
| 183 |
weight_eval = briefing.get("weight_evaluation", {})
|
| 184 |
weight_status = weight_eval.get("status", "暂无数据")
|
| 185 |
weight_trend = weight_eval.get("trend", "")
|
|
|
|
|
|
|
| 186 |
thinking_keywords = briefing.get("thinking_keywords", "")
|
|
|
|
| 187 |
return {
|
| 188 |
"trimester": trimester,
|
| 189 |
"focus_nutrients": focus_nutrients,
|
|
|
|
| 198 |
}
|
| 199 |
|
| 200 |
|
| 201 |
+
# ============================================================
|
| 202 |
+
# Helper: 家庭数据 → HTML 卡片
|
| 203 |
+
# ============================================================
|
| 204 |
+
|
| 205 |
+
def render_family_recipes_html(lang="zh") -> str:
|
| 206 |
+
"""渲染家庭菜谱为现代卡片"""
|
| 207 |
+
from modules.family_manager import RecipeManager
|
| 208 |
+
recipes = RecipeManager.load_all()
|
| 209 |
+
if not recipes:
|
| 210 |
+
no = t("no_recipes", lang)
|
| 211 |
+
return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{no}</div>'
|
| 212 |
+
cards = ""
|
| 213 |
+
for r in recipes:
|
| 214 |
+
name = r.get("name", "")
|
| 215 |
+
cook = r.get("cook", "")
|
| 216 |
+
diff = r.get("difficulty", "")
|
| 217 |
+
diff_stars = {"��单": "⭐", "中等": "⭐⭐", "困难": "⭐⭐⭐", "Easy": "⭐", "Medium": "⭐⭐", "Hard": "⭐⭐⭐"}
|
| 218 |
+
stars = diff_stars.get(diff, "")
|
| 219 |
+
cards += f"""
|
| 220 |
+
<div style="background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);display:flex;flex-direction:column;gap:4px;border:1px solid #f0f0f0;transition:all 0.2s;">
|
| 221 |
+
<div style="font-weight:600;font-size:15px;color:#E91E63;">{name}</div>
|
| 222 |
+
<div style="font-size:13px;color:#888;">
|
| 223 |
+
<span>👨🍳 {cook}</span>
|
| 224 |
+
<span style="margin-left:12px;">{stars}</span>
|
| 225 |
+
</div>
|
| 226 |
+
</div>"""
|
| 227 |
+
return f"""
|
| 228 |
+
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;">
|
| 229 |
+
{cards}
|
| 230 |
+
</div>"""
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def render_family_preferences_html(lang="zh") -> str:
|
| 234 |
+
"""渲染饮食偏好为现代卡片"""
|
| 235 |
+
from modules.family_manager import PreferenceManager
|
| 236 |
+
members = PreferenceManager.load_all()
|
| 237 |
+
if not members:
|
| 238 |
+
no = t("no_preferences", lang)
|
| 239 |
+
return f'<div style="padding:24px;text-align:center;color:#aaa;font-size:15px;">{no}</div>'
|
| 240 |
+
cards = ""
|
| 241 |
+
for m in members:
|
| 242 |
+
name = m.get("name", "")
|
| 243 |
+
role = m.get("role", "")
|
| 244 |
+
pref = m.get("preferences", "")
|
| 245 |
+
avoid = m.get("avoid", "")
|
| 246 |
+
allergies = m.get("allergies", "")
|
| 247 |
+
tags = []
|
| 248 |
+
if pref: tags.append(f'<span style="background:#E8F5E9;color:#2E7D32;padding:2px 10px;border-radius:99px;font-size:12px;">✅ {pref}</span>')
|
| 249 |
+
if avoid: tags.append(f'<span style="background:#FFF3E0;color:#E65100;padding:2px 10px;border-radius:99px;font-size:12px;">❌ {avoid}</span>')
|
| 250 |
+
if allergies: tags.append(f'<span style="background:#FFEBEE;color:#C62828;padding:2px 10px;border-radius:99px;font-size:12px;">⚠ {allergies}</span>')
|
| 251 |
+
if not tags: tags.append(f'<span style="color:#bbb;font-size:12px;">暂无记录</span>')
|
| 252 |
+
role_icons = {"孕妇":"🤰","丈夫":"👨","婆婆":"👩","妈妈":"👩","爸爸":"👨","其他家人":"👤",
|
| 253 |
+
"Pregnant":"🤰","Husband":"👨","Mother-in-law":"👩","Mom":"👩","Dad":"👨","Other":"👤"}
|
| 254 |
+
icon = role_icons.get(role, "👤")
|
| 255 |
+
role_str = f" {role}" if role else ""
|
| 256 |
+
cards += f"""
|
| 257 |
+
<div style="background:#fff;border-radius:14px;padding:16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
|
| 258 |
+
<div style="font-weight:600;font-size:16px;margin-bottom:8px;">
|
| 259 |
+
{icon}<span style="margin-left:4px;">{name}{role_str}</span>
|
| 260 |
+
</div>
|
| 261 |
+
<div style="display:flex;flex-wrap:wrap;gap:6px;">
|
| 262 |
+
{"".join(tags)}
|
| 263 |
+
</div>
|
| 264 |
+
</div>"""
|
| 265 |
+
return f"""
|
| 266 |
+
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;">
|
| 267 |
+
{cards}
|
| 268 |
+
</div>"""
|
| 269 |
+
|
| 270 |
+
|
| 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}
|
| 291 |
+
</div>"""
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def render_nutrition_report_html(analysis: dict, days: int, lang="zh") -> str:
|
| 295 |
+
"""渲染营养报告为现代 Dashboard HTML"""
|
| 296 |
+
if not analysis or "error" in analysis:
|
| 297 |
+
return f'<div style="padding:40px;text-align:center;color:#aaa;font-size:16px;">{analysis.get("error","暂无数据")}</div>'
|
| 298 |
+
|
| 299 |
+
score = analysis.get("score", 0)
|
| 300 |
+
meal_counts = analysis.get("meal_counts", {})
|
| 301 |
+
total_days = analysis.get("total_days", 0)
|
| 302 |
+
nutrition_coverage = analysis.get("nutrition_coverage", {})
|
| 303 |
+
suggestions = analysis.get("suggestions", [])
|
| 304 |
+
food_items = analysis.get("food_items", [])
|
| 305 |
+
diversity_score = analysis.get("diversity_score", 0)
|
| 306 |
+
|
| 307 |
+
# Score bar color
|
| 308 |
+
bar_color = "#4CAF50" if score >= 70 else "#FF9800" if score >= 50 else "#F44336"
|
| 309 |
+
bar_status = t("report_good", lang) if score >= 70 else t("report_need_attention", lang)
|
| 310 |
+
|
| 311 |
+
# Meal completion bars
|
| 312 |
+
meal_labels = {"早餐":"🌅", "午餐":"☀️", "晚餐":"🌙", "加餐":"🍪"}
|
| 313 |
+
meal_rows = ""
|
| 314 |
+
for mt, label in {"早餐":"Breakfast","午餐":"Lunch","晚餐":"Dinner","��餐":"Snack"}.items():
|
| 315 |
+
cnt = meal_counts.get(mt, 0)
|
| 316 |
+
pct = min(100, round(cnt / max(total_days, 1) * 100))
|
| 317 |
+
icon = meal_labels.get(mt, "🍽️")
|
| 318 |
+
mcolor = "#4CAF50" if pct >= 80 else "#FF9800" if pct >= 50 else "#F44336"
|
| 319 |
+
meal_rows += f"""
|
| 320 |
+
<div style="margin-bottom:10px;">
|
| 321 |
+
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:3px;">
|
| 322 |
+
<span>{icon} {mt}</span><span style="font-weight:600;">{pct}%</span>
|
| 323 |
+
</div>
|
| 324 |
+
<div style="background:#f0f0f0;border-radius:99px;height:8px;overflow:hidden;">
|
| 325 |
+
<div style="width:{pct}%;background:{mcolor};height:100%;border-radius:99px;transition:width 0.6s;"></div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>"""
|
| 328 |
+
|
| 329 |
+
# Nutrient coverage
|
| 330 |
+
n_rows = ""
|
| 331 |
+
for nut, info in nutrition_coverage.items():
|
| 332 |
+
pct = info.get("percentage", 0) if isinstance(info, dict) else info
|
| 333 |
+
try: pct = min(100, int(pct))
|
| 334 |
+
except: pct = 0
|
| 335 |
+
ncolor = "#4CAF50" if pct >= 70 else "#FF9800" if pct >= 40 else "#F44336"
|
| 336 |
+
n_rows += f"""
|
| 337 |
+
<div style="margin-bottom:8px;">
|
| 338 |
+
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:2px;">
|
| 339 |
+
<span>{nut}</span><span style="font-weight:600;">{pct}%</span>
|
| 340 |
+
</div>
|
| 341 |
+
<div style="background:#f0f0f0;border-radius:99px;height:6px;overflow:hidden;">
|
| 342 |
+
<div style="width:{pct}%;background:{ncolor};height:100%;border-radius:99px;"></div>
|
| 343 |
+
</div>
|
| 344 |
+
</div>"""
|
| 345 |
+
|
| 346 |
+
# Suggestions
|
| 347 |
+
sug_items = ""
|
| 348 |
+
for s in suggestions[:5]:
|
| 349 |
+
color = "#F44336" if "⚠" in s or "不足" in s or "不够" in s or "缺乏" in s else "#FF9800"
|
| 350 |
+
sug_items += f'<div style="padding:6px 0;font-size:13px;color:{color};">• {s}</div>'
|
| 351 |
+
|
| 352 |
+
# Diversity
|
| 353 |
+
div_color = "#4CAF50" if diversity_score >= 60 else "#FF9800" if diversity_score >= 30 else "#F44336"
|
| 354 |
+
food_count = len(food_items)
|
| 355 |
+
food_str = "、".join(food_items[:8])
|
| 356 |
+
if len(food_items) > 8: food_str += "⋯"
|
| 357 |
+
|
| 358 |
+
days_str = t("report_days", lang).format(days=days)
|
| 359 |
+
|
| 360 |
+
return f"""
|
| 361 |
+
<div style="font-family:system-ui,-apple-system,sans-serif;max-width:100%;">
|
| 362 |
+
<!-- Header -->
|
| 363 |
+
<div style="background:linear-gradient(135deg,#FCE4EC,#FFF0F2);border-radius:18px;padding:20px 24px;margin-bottom:16px;">
|
| 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>
|
| 371 |
+
<div style="font-size:14px;color:#555;">/ 100</div>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
<div style="background:#f0f0f0;border-radius:99px;height:10px;margin-top:12px;overflow:hidden;">
|
| 375 |
+
<div style="width:{min(100,score)}%;background:linear-gradient(90deg,#F44336,#FF9800,#4CAF50);height:100%;border-radius:99px;transition:width 0.8s;"></div>
|
| 376 |
+
</div>
|
| 377 |
+
<div style="margin-top:6px;font-size:14px;color:#555;text-align:center;">{bar_status}</div>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<!-- 3 cards row -->
|
| 381 |
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px;">
|
| 382 |
+
<!-- Meal Completion -->
|
| 383 |
+
<div style="background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
|
| 384 |
+
<div style="font-weight:600;font-size:14px;margin-bottom:10px;color:#333;">🍽️ {t("report_meal_completion", lang)}</div>
|
| 385 |
+
{meal_rows}
|
| 386 |
+
</div>
|
| 387 |
+
<!-- Nutrient Coverage -->
|
| 388 |
+
<div style="background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
|
| 389 |
+
<div style="font-weight:600;font-size:14px;margin-bottom:10px;color:#333;">🥗 {t("report_nutrient_coverage", lang)}</div>
|
| 390 |
+
{n_rows if n_rows else '<div style="color:#bbb;font-size:13px;">暂无数据</div>'}
|
| 391 |
+
</div>
|
| 392 |
+
<!-- Diversity -->
|
| 393 |
+
<div style="background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 1px 6px rgba(0,0,0,0.04);border:1px solid #f0f0f0;">
|
| 394 |
+
<div style="font-weight:600;font-size:14px;margin-bottom:10px;color:#333;">🌿 {t("report_diversity", lang)}</div>
|
| 395 |
+
<div style="text-align:center;padding:8px 0;">
|
| 396 |
+
<div style="font-size:32px;font-weight:700;color:{div_color};">{diversity_score}</div>
|
| 397 |
+
<div style="font-size:13px;color:#888;">/ 100</div>
|
| 398 |
+
</div>
|
| 399 |
+
<div style="background:#f0f0f0;border-radius:99px;height:6px;overflow:hidden;margin:6px 0;">
|
| 400 |
+
<div style="width:{min(100,diversity_score)}%;background:{div_color};height:100%;border-radius:99px;"></div>
|
| 401 |
+
</div>
|
| 402 |
+
<div style="margin-top:8px;font-size:12px;color:#666;">
|
| 403 |
+
<span>{food_count} 种食材</span>
|
| 404 |
+
<div style="font-size:12px;color:#999;margin-top:2px;">{food_str}</div>
|
| 405 |
+
</div>
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
<!-- Suggestions -->
|
| 410 |
+
<div style="background:linear-gradient(135deg,#FFF8E1,#FFFDE7);border-radius:14px;padding:14px 18px;border:1px solid #FFE082;">
|
| 411 |
+
<div style="font-weight:600;font-size:14px;margin-bottom:6px;color:#E65100;">💡 {t("report_suggestions", lang)}</div>
|
| 412 |
+
{sug_items if sug_items else '<div style="font-size:13px;color:#888;">暂无建议</div>'}
|
| 413 |
+
</div>
|
| 414 |
+
</div>"""
|
| 415 |
+
|
| 416 |
+
|
| 417 |
# ============================================================
|
| 418 |
# 自定义 CSS 样式
|
| 419 |
# ============================================================
|
| 420 |
|
| 421 |
CUSTOM_CSS = """
|
| 422 |
+
/* =====================
|
| 423 |
+
PregoPal Modern UI v2
|
| 424 |
+
===================== */
|
| 425 |
+
|
| 426 |
+
/* 全局容器 */
|
| 427 |
.gradio-container {
|
| 428 |
max-width: 1200px !important;
|
| 429 |
margin: 0 auto !important;
|
| 430 |
+
padding: 4px 16px !important;
|
| 431 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif !important;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
/* 顶部标题禁止选中 */
|
| 435 |
+
h1, h2, h3 {
|
| 436 |
+
-webkit-user-select: none !important;
|
| 437 |
+
user-select: none !important;
|
| 438 |
}
|
| 439 |
|
| 440 |
+
/* ======== Modern Tab Bar (永不折叠) ======== */
|
| 441 |
+
.tabs {
|
| 442 |
+
display: flex !important;
|
| 443 |
+
flex-direction: column !important;
|
| 444 |
+
gap: 0 !important;
|
| 445 |
+
}
|
| 446 |
+
.tabs > .tab-nav {
|
| 447 |
+
display: flex !important;
|
| 448 |
+
flex-wrap: nowrap !important;
|
| 449 |
+
overflow-x: auto !important;
|
| 450 |
+
gap: 0 !important;
|
| 451 |
+
background: #f8f8fb !important;
|
| 452 |
+
border-radius: 16px 16px 0 0 !important;
|
| 453 |
+
padding: 4px 4px 0 4px !important;
|
| 454 |
+
-webkit-overflow-scrolling: touch !important;
|
| 455 |
+
}
|
| 456 |
+
.tabs > .tab-nav > button {
|
| 457 |
+
flex-shrink: 0 !important;
|
| 458 |
+
min-width: auto !important;
|
| 459 |
+
white-space: nowrap !important;
|
| 460 |
+
font-size: 14px !important;
|
| 461 |
+
font-weight: 500 !important;
|
| 462 |
+
padding: 10px 18px !important;
|
| 463 |
+
border-radius: 12px 12px 0 0 !important;
|
| 464 |
+
background: transparent !important;
|
| 465 |
+
border: none !important;
|
| 466 |
+
color: #888 !important;
|
| 467 |
+
transition: all 0.2s ease !important;
|
| 468 |
+
margin: 0 1px !important;
|
| 469 |
+
cursor: pointer !important;
|
| 470 |
+
}
|
| 471 |
+
.tabs > .tab-nav > button:hover {
|
| 472 |
+
background: rgba(233, 30, 99, 0.04) !important;
|
| 473 |
+
color: #E91E63 !important;
|
| 474 |
+
}
|
| 475 |
+
.tabs > .tab-nav > button.selected {
|
| 476 |
+
background: #fff !important;
|
| 477 |
+
color: #E91E63 !important;
|
| 478 |
+
font-weight: 600 !important;
|
| 479 |
+
box-shadow: 0 -2px 8px rgba(233, 30, 99, 0.08) !important;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
/* ======== Cards (Glassmorphism) ======== */
|
| 483 |
+
.glass-card {
|
| 484 |
+
background: rgba(255,255,255,0.75) !important;
|
| 485 |
+
backdrop-filter: blur(12px) !important;
|
| 486 |
+
-webkit-backdrop-filter: blur(12px) !important;
|
| 487 |
border-radius: 16px !important;
|
| 488 |
+
padding: 20px 24px !important;
|
| 489 |
+
border: 1px solid rgba(255,255,255,0.8) !important;
|
| 490 |
+
box-shadow: 0 2px 16px rgba(233, 30, 99, 0.06) !important;
|
| 491 |
+
transition: all 0.3s ease !important;
|
|
|
|
| 492 |
}
|
| 493 |
+
.glass-card:hover {
|
| 494 |
+
box-shadow: 0 4px 24px rgba(233, 30, 99, 0.10) !important;
|
| 495 |
+
transform: translateY(-2px);
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
/* home cards (首页卡片) */
|
| 499 |
+
.home-card {
|
| 500 |
+
border-radius: 16px !important;
|
| 501 |
+
padding: 16px 20px !important;
|
| 502 |
+
background: #fff !important;
|
| 503 |
+
border: 1px solid #f0f0f0 !important;
|
| 504 |
+
box-shadow: 0 1px 8px rgba(0,0,0,0.04) !important;
|
| 505 |
+
transition: all 0.2s ease !important;
|
| 506 |
+
}
|
| 507 |
+
.home-card:hover {
|
| 508 |
+
box-shadow: 0 4px 20px rgba(233, 30, 99, 0.08) !important;
|
| 509 |
+
}
|
| 510 |
+
.home-card h3 {
|
| 511 |
+
margin: 0 0 6px 0 !important;
|
| 512 |
+
font-size: 16px !important;
|
| 513 |
+
}
|
| 514 |
+
.home-card p {
|
| 515 |
+
margin: 0 !important;
|
| 516 |
+
font-size: 14px !important;
|
| 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 |
/* 大圆形按钮(语音启动) */
|
|
|
|
| 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;
|
|
|
|
| 551 |
transform: scale(0.95) !important;
|
| 552 |
}
|
| 553 |
|
| 554 |
+
/* 按钮统一 */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
button, .gr-button {
|
| 556 |
border-radius: 12px !important;
|
| 557 |
font-weight: 500 !important;
|
| 558 |
+
transition: all 0.2s ease !important;
|
| 559 |
}
|
|
|
|
|
|
|
| 560 |
input, textarea, .gr-textbox {
|
| 561 |
border-radius: 12px !important;
|
| 562 |
}
|
| 563 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
/* 响应式调整 */
|
| 565 |
@media (max-width: 768px) {
|
| 566 |
.voice-main-btn {
|
| 567 |
width: 120px !important;
|
| 568 |
height: 120px !important;
|
| 569 |
+
font-size: 36px !important;
|
| 570 |
}
|
| 571 |
.gradio-container {
|
| 572 |
+
padding: 4px !important;
|
| 573 |
+
}
|
| 574 |
+
.tabs > .tab-nav > button {
|
| 575 |
+
font-size: 12px !important;
|
| 576 |
+
padding: 8px 12px !important;
|
| 577 |
}
|
| 578 |
}
|
| 579 |
+
"""
|