Spaces:
Runtime error
Runtime error
J.B-Lin commited on
Commit ·
edca135
1
Parent(s): 6efc08d
全双工语音对话实现
Browse files核心改动:
- api/ (新): FastAPI 后端 go_server.py + voice_helper.py API封装
- /v1/omni/voice_chat 全双工语音对话端点
- /v1/chat/completions 文本对话
- core/ (改): ModelLoader支持chat+voice_chat; ConversationManager结构化标记解析
- ui/ (改): app_builder.py - Gradio录音组件+Chatbot
- _setup_rtm.py, rtm_task.json: RTM任务文件
- start_services.py: 一键启动
已验证: omni_init->prefill->decode->TTS wav 完整环路, 4060Ti 16GB稳定运行(~10.9GB)
- .gitignore +5 -30
- _help.txt +2 -0
- _modelscope.html +0 -0
- _setup_rtm.py +180 -0
- api/__init__.py +1 -0
- api/go_server.py +390 -0
- api/voice_helper.py +128 -0
- core/conversation_manager.py +196 -24
- core/model_loader.py +145 -96
- rtm_task.json +80 -0
- start_services.py +193 -0
- ui/app_builder.py +234 -133
.gitignore
CHANGED
|
@@ -8,34 +8,9 @@ __pycache__/
|
|
| 8 |
*.pyo
|
| 9 |
*.egg-info/
|
| 10 |
dist/
|
| 11 |
-
build/
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
# llama.cpp 编译产物
|
| 19 |
-
llama.cpp/
|
| 20 |
-
*.whl
|
| 21 |
-
modal-*.whl
|
| 22 |
-
|
| 23 |
-
# 数据文件
|
| 24 |
-
data/voices/
|
| 25 |
-
*.wav
|
| 26 |
-
*.mp3
|
| 27 |
-
|
| 28 |
-
# 环境
|
| 29 |
-
.env
|
| 30 |
-
.venv/
|
| 31 |
-
venv/
|
| 32 |
-
|
| 33 |
-
# 临时文件
|
| 34 |
-
*.tmp
|
| 35 |
-
*.log
|
| 36 |
-
debug*.txt
|
| 37 |
-
|
| 38 |
-
# 检查脚本(一次性使用)
|
| 39 |
-
# _check_hf.py
|
| 40 |
-
# _download_models.py
|
| 41 |
-
"cookbook_ref/"
|
|
|
|
| 8 |
*.pyo
|
| 9 |
*.egg-info/
|
| 10 |
dist/
|
|
|
|
| 11 |
|
| 12 |
+
# Data & temp
|
| 13 |
+
data/nutrition/raw/
|
| 14 |
+
data/logs/
|
| 15 |
+
omni_output/
|
| 16 |
+
api/temp/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_help.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'llama-server.exe' �����ڲ����ⲿ���Ҳ���ǿ����еij���
|
| 2 |
+
�����������
|
_modelscope.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
_setup_rtm.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""设置RTM指令 - 使用Python脚本避免PowerShell转义问题"""
|
| 2 |
+
import json, subprocess, sys
|
| 3 |
+
|
| 4 |
+
PY = r"C:\Users\Andre\miniconda3\envs\trader_stable\python.exe"
|
| 5 |
+
RTM = r"C:\Users\Andre\.qclaw\workspace\skills\research-task-manager\scripts\research_task_manager.py"
|
| 6 |
+
DB = r"C:\Users\Andre\codes\LJB\hackthon\for_qclaw_llamacpp\PregoPal\rtm_task.json"
|
| 7 |
+
|
| 8 |
+
def rtm(*args):
|
| 9 |
+
cmd = [PY, RTM, "--db", DB] + list(args)
|
| 10 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 11 |
+
print(result.stdout)
|
| 12 |
+
if result.returncode != 0:
|
| 13 |
+
print("STDERR:", result.stderr)
|
| 14 |
+
return result.returncode
|
| 15 |
+
|
| 16 |
+
# Step 1.1: Phase1
|
| 17 |
+
instructions_1_1 = """Phase1: 部署本地llama-server并验证全双工API
|
| 18 |
+
|
| 19 |
+
目标:启动llama-server并验证MiniCPM-o 4.5的语音输入/输出功能正常工作。
|
| 20 |
+
|
| 21 |
+
参考文档:
|
| 22 |
+
1. 官方README: https://www.modelscope.cn/models/OpenBMB/MiniCPM-o-4_5/files - 全双工架构说明
|
| 23 |
+
2. 本地部署经验: C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\docs\\本地部署经验.md
|
| 24 |
+
3. 官方部署例程: C:\\Users\\Andre\\codes\\LJB\\hackthon\\MiniCPM-V-CookBook\\deployment\\llama.cpp\\minicpm-o4_5_llamacpp_zh.md
|
| 25 |
+
4. 全双工后端参考: C:\\Users\\Andre\\codes\\LJB\\hackthon\\MiniCPM-V-CookBook\\demo\\web_demo\\WebRTC_Demo\\omini_backend_code\\code\\voice_chat\\omni_stream.py
|
| 26 |
+
|
| 27 |
+
关键资源位置:
|
| 28 |
+
- llama-server: C:\\Users\\Andre\\codes\\LJB\\llama.cpp-omni\\build\\bin\\Release\\llama-server.exe
|
| 29 |
+
- 模型文件: C:\\Users\\Andre\\codes\\LJB\\llama.cpp-omni\\models\\(Q4_K_M + vision/audio/tts/projector)
|
| 30 |
+
- PregoPal源码: C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\
|
| 31 |
+
|
| 32 |
+
步骤:
|
| 33 |
+
1. 确认llama-server.exe编译OK(含CUDA支持)
|
| 34 |
+
2. 确认所有5个模型文件完整
|
| 35 |
+
3. 启动llama-server:-m Q4_K_M --mmproj vision --mmproj audio --mmproj tts --mmproj projector -c 16384 --host 127.0.0.1 --port 8081
|
| 36 |
+
4. 验证API端点:/health /v1/chat/completions /omni/streaming_prefill /omni/streaming_generate
|
| 37 |
+
5. 测试文本推理,验证响应正常
|
| 38 |
+
6. 检查显存使用,确保4060Ti 16GB不超限
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
rtm("update", "--id", "1.1", "--instructions", json.dumps(instructions_1_1, ensure_ascii=False))
|
| 42 |
+
|
| 43 |
+
# Step 1.2: Phase2
|
| 44 |
+
instructions_1_2 = """Phase2: 实现VoiceProcessor ASR和TTS音频输出
|
| 45 |
+
|
| 46 |
+
目标:让PregoPal能接收语音输入(ASR)和输出语音回复(TTS)。
|
| 47 |
+
|
| 48 |
+
参考文档:
|
| 49 |
+
- C:\\Users\\Andre\\codes\\LJB\\hackthon\\MiniCPM-V-CookBook\\inference\\speech2text_zh.md
|
| 50 |
+
- C:\\Users\\Andre\\codes\\LJB\\hackthon\\MiniCPM-V-CookBook\\inference\\text2speech_zh.md
|
| 51 |
+
- C:\\Users\\Andre\\codes\\LJB\\hackthon\\MiniCPM-V-CookBook\\demo\\web_demo\\gradio\\server\\models\\minicpmo4_5.py
|
| 52 |
+
|
| 53 |
+
MiniCPM-o 4.5原生支持语音输入/输出(通过音频tokenizer),但llama-server模式下:
|
| 54 |
+
方案A:使用llama-server的/v1/audio/transcriptions端点(类似Whisper)
|
| 55 |
+
方案B:使用本地Whisper-medium进行ASR + MiniCPM-o进行文本对话 + TTS
|
| 56 |
+
方案C:使用llama.cpp-omni的omni流式接口直接传入音频流
|
| 57 |
+
|
| 58 |
+
关键问题:
|
| 59 |
+
1. MiniCPM-o 4.5的GGUF版本是否支持/v1/audio端点?
|
| 60 |
+
2. 如果不支持,需要调研本地Whisper方案
|
| 61 |
+
3. TTS输出:MiniCPM-o自带的TTS能力(tts-mmproj)是否可用?
|
| 62 |
+
|
| 63 |
+
依赖:
|
| 64 |
+
- core/voice_processor.py需要实现transcribe(audio_path) -> str
|
| 65 |
+
- core/model_loader.py需要暴露TTS接口
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
rtm("update", "--id", "1.2", "--instructions", json.dumps(instructions_1_2, ensure_ascii=False))
|
| 69 |
+
|
| 70 |
+
# Step 1.3: Phase3
|
| 71 |
+
instructions_1_3 = """Phase3: 改造ConversationManager接入MiniCPM-o 4.5 API
|
| 72 |
+
|
| 73 |
+
目标:将PregoPal的对话管理模块与实际模型连接起来。
|
| 74 |
+
|
| 75 |
+
参考:
|
| 76 |
+
- core/conversation_manager.py(当前占位)
|
| 77 |
+
- core/model_loader.py(当前已封装Modal API客户端)
|
| 78 |
+
- modal_deploy/client.py(MiniCPMClient实现参考)
|
| 79 |
+
|
| 80 |
+
需要改造的内容:
|
| 81 |
+
1. ConversationManager.build_system_prompt() - 构建包含孕期营养专业知识的系统提示词
|
| 82 |
+
2. ConversationManager.switch_mode() - 支持task/chat模式切换
|
| 83 |
+
3. ConversationManager.set_speaker() - 设置声纹识别出的当前说话人
|
| 84 |
+
4. ConversationManager.parse_response() - 解析AI回复,提取[EXTRACT_DIET]等标记
|
| 85 |
+
5. ModelLoader支持本地llama-server的OpenAI兼容API调用(替换Modal远程调用)
|
| 86 |
+
6. 集成DietExtractor的提取标记到对话流程中
|
| 87 |
+
|
| 88 |
+
关键设计决策:
|
| 89 |
+
- 系统提示词应该包含哪些内容?(孕期营养知识、对话规则、输出格式)
|
| 90 |
+
- 对话历史如何管理?(context window限制16384)
|
| 91 |
+
- 如何切换本地模式vs Modal远程模式?
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
rtm("update", "--id", "1.3", "--instructions", json.dumps(instructions_1_3, ensure_ascii=False))
|
| 95 |
+
|
| 96 |
+
# Step 1.4: Phase4
|
| 97 |
+
instructions_1_4 = """Phase4: 实现start_voice_session全双工语音对话闭环
|
| 98 |
+
|
| 99 |
+
目标:让用户点击语音按钮后,实现"语音输入→ASR转写→AI思考→TTS输出"的完整闭环。
|
| 100 |
+
|
| 101 |
+
参考:
|
| 102 |
+
- MiniCPM-V-CookBook的Gradio demo: server/models/minicpmo4_5.py(包含ChatBot类实现)
|
| 103 |
+
- 全双工WebSocket/流式���现: omini_backend_code/code/voice_chat/omni_stream.py
|
| 104 |
+
- PregoPal当前占位: ui/app_builder.py中的start_voice_session()
|
| 105 |
+
|
| 106 |
+
Gradio语音交互方案:
|
| 107 |
+
方案A: Gradio内置Audio组件(gr.Audio(source="microphone") + gr.Audio(output))
|
| 108 |
+
方案B: 使用WebRTC + 自定义JS组件实现低延迟语音流
|
| 109 |
+
方案C: 使用FastAPI WebSocket + Gradio前端组合
|
| 110 |
+
|
| 111 |
+
关键步骤:
|
| 112 |
+
1. 改造ui/app_builder.py中的语音按钮,使其实际调用AIModel
|
| 113 |
+
2. 实现语音输入→ASR→AI→TTS→语音输出的串行流程
|
| 114 |
+
3. 使用gr.Stream或gr.load streaming模式实现实时反馈
|
| 115 |
+
4. 显示AI思考状态(thinking状态)
|
| 116 |
+
|
| 117 |
+
注意:Gradio的gr.Audio组件默认是点击录音→上传→处理→输出的模式,不是实时流。
|
| 118 |
+
如果要实现真正的"全双工",需要使用自定义JS组件 + WebSocket。
|
| 119 |
+
"""
|
| 120 |
+
|
| 121 |
+
rtm("update", "--id", "1.4", "--instructions", json.dumps(instructions_1_4, ensure_ascii=False))
|
| 122 |
+
|
| 123 |
+
# Step 1.5: Phase5
|
| 124 |
+
instructions_1_5 = """Phase5: 集成PregoPal业务逻辑
|
| 125 |
+
|
| 126 |
+
目标:将AI对话能力与PregoPal的完整业务管线串联起来。
|
| 127 |
+
|
| 128 |
+
完整的对话处理管线:
|
| 129 |
+
1. 用户语音输入 → 声纹识别(modules/voiceprint.py) → 识别说话人
|
| 130 |
+
2. ASR转写文本 → 进入ConversationManager
|
| 131 |
+
3. AI思考回复(含孕期营养知识)
|
| 132 |
+
4. AI回复中包含[EXTRACT_DIET][EXTRACT_RECIPE][EXTRACT_PREFERENCE][EXTRACT_WEIGHT][EXTRACT_MEMORY]等标记
|
| 133 |
+
5. DietExtractor.extract_all(reply)提取结构化数据
|
| 134 |
+
6. 结构化数据自动存储到diet_logger/family_manager
|
| 135 |
+
7. NutritionAnalyzer进行实时营养分析
|
| 136 |
+
8. TTS输出AI回复语音
|
| 137 |
+
9. 更新首页简报卡片
|
| 138 |
+
|
| 139 |
+
需要修改的模块:
|
| 140 |
+
- start_voice_session() - 串联整个管线
|
| 141 |
+
- voiceprint.py的identify_speaker - 从当前音频提取说话人
|
| 142 |
+
- MealRecommender - 使用AI推荐而不是随机模板
|
| 143 |
+
- NutritionAnalyzer - 对接DRIs标准
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
rtm("update", "--id", "1.5", "--instructions", json.dumps(instructions_1_5, ensure_ascii=False))
|
| 147 |
+
|
| 148 |
+
# Step 1.6: Phase6
|
| 149 |
+
instructions_1_6 = """Phase6: 端到端测试与性能优化
|
| 150 |
+
|
| 151 |
+
目标:确保全双工语音交互在4060Ti 16GB上流畅运行。
|
| 152 |
+
|
| 153 |
+
测试清单:
|
| 154 |
+
1. 文本对话测试 - 10轮连续对话,验证上下文保持
|
| 155 |
+
2. 语音输入测试 - 不同噪音环境下的ASR准确率
|
| 156 |
+
3. 声纹识别测试 - 多家庭成员识别准确率
|
| 157 |
+
4. 营养分析测试 - 验证DRIs对比正确性
|
| 158 |
+
5. 内存测试 - 显存/内存泄漏检测
|
| 159 |
+
6. 响应时间测试 - 语音输入→TTS输出延迟
|
| 160 |
+
7. 并发测试 - 单用户连续对话稳定性
|
| 161 |
+
|
| 162 |
+
优化方向:
|
| 163 |
+
1. 模型量化级别:Q4_K_M vs Q8_0 vs F16
|
| 164 |
+
2. n_ctx上下文长度:4096 vs 8192 vs 16384
|
| 165 |
+
3. batch_size调整
|
| 166 |
+
4. 是否启用flash_attn
|
| 167 |
+
5. 长对话历史裁剪策略
|
| 168 |
+
6. GPU offloading层数(-ngl参数)
|
| 169 |
+
|
| 170 |
+
性能指标目标:
|
| 171 |
+
- 文本推理延迟: <3秒(首token)
|
| 172 |
+
- ASR延迟: <1秒(3秒语音)
|
| 173 |
+
- TTS延迟: <2秒(50字以内)
|
| 174 |
+
- 全双工轮询: <5秒/轮
|
| 175 |
+
- 显存占用: <14GB(留2GB余量)
|
| 176 |
+
"""
|
| 177 |
+
|
| 178 |
+
rtm("update", "--id", "1.6", "--instructions", json.dumps(instructions_1_6, ensure_ascii=False))
|
| 179 |
+
|
| 180 |
+
print("RTM 指令设置完成!")
|
api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PregoPal API — 全双工语音后端"""
|
api/go_server.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PregoPal 全双工后端 — 语音对话服务器
|
| 3 |
+
=====================================
|
| 4 |
+
基于 llama.cpp-omni 的 omni 全双工能力。
|
| 5 |
+
通过 /v1/stream/* 端点实现:语音输入→音频prefill→AI推理→TTS语音输出 的完整闭环。
|
| 6 |
+
|
| 7 |
+
架构:
|
| 8 |
+
FastAPI (本服务) → llama-server (omni模式, /v1/stream/*)
|
| 9 |
+
|
| 10 |
+
启动顺序:
|
| 11 |
+
1. 确保 llama-server 已在 omni 模式启动 (start_llama_server.py)
|
| 12 |
+
2. python -m uvicorn api.go_server:app --host 127.0.0.1 --port 8090
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
import base64
|
| 19 |
+
import asyncio
|
| 20 |
+
import logging
|
| 21 |
+
import numpy as np
|
| 22 |
+
import soundfile as sf
|
| 23 |
+
import httpx
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from datetime import datetime
|
| 26 |
+
from typing import Optional, AsyncGenerator
|
| 27 |
+
from fastapi import FastAPI, HTTPException
|
| 28 |
+
from fastapi.responses import StreamingResponse, JSONResponse
|
| 29 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 30 |
+
from pydantic import BaseModel
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger("prego_api")
|
| 33 |
+
logger.setLevel(logging.INFO)
|
| 34 |
+
ch = logging.StreamHandler()
|
| 35 |
+
ch.setFormatter(logging.Formatter("[PregoAPI] %(asctime)s %(message)s"))
|
| 36 |
+
logger.addHandler(ch)
|
| 37 |
+
|
| 38 |
+
# ── 配置 ──────────────────────────────────────────────
|
| 39 |
+
LLAMA_SERVER_URL = os.environ.get("LLAMA_SERVER_URL", "http://127.0.0.1:8081")
|
| 40 |
+
OMNI_OUTPUT_DIR = os.environ.get("OMNI_OUTPUT_DIR",
|
| 41 |
+
"C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\omni_output")
|
| 42 |
+
TEMP_DIR = os.environ.get("TEMP_DIR",
|
| 43 |
+
"C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\api\\temp")
|
| 44 |
+
LLM_SYSTEM_PROMPT = os.environ.get("LLM_SYSTEM_PROMPT",
|
| 45 |
+
"你是PregoPal,一位贴心的孕期营养健康顾问。"
|
| 46 |
+
"请用中文回答,给出简短实用的建议。"
|
| 47 |
+
"如果用户提到吃了什么,尝试记录饮食信息[EXTRACT_DIET]。"
|
| 48 |
+
"如果有家庭成员信息,记录为[EXTRACT_FAMILY]。")
|
| 49 |
+
|
| 50 |
+
Path(TEMP_DIR).mkdir(parents=True, exist_ok=True)
|
| 51 |
+
Path(OMNI_OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
|
| 52 |
+
|
| 53 |
+
app = FastAPI(title="PregoPal Omni Backend")
|
| 54 |
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
| 55 |
+
|
| 56 |
+
# 全局状态
|
| 57 |
+
class SessionState:
|
| 58 |
+
def __init__(self):
|
| 59 |
+
self.initialized = False
|
| 60 |
+
self.round_counter = 0
|
| 61 |
+
self.sample_rate = 16000
|
| 62 |
+
|
| 63 |
+
state = SessionState()
|
| 64 |
+
http_client = None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@app.on_event("startup")
|
| 68 |
+
async def startup():
|
| 69 |
+
global http_client
|
| 70 |
+
http_client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=5.0))
|
| 71 |
+
# 确保 llama-server 存活着
|
| 72 |
+
try:
|
| 73 |
+
resp = await http_client.get(f"{LLAMA_SERVER_URL}/health", timeout=3)
|
| 74 |
+
if resp.status_code == 200:
|
| 75 |
+
logger.info(f"✅ llama-server 健康: {resp.json()}")
|
| 76 |
+
else:
|
| 77 |
+
logger.warning(f"⚠️ llama-server 响应异常: {resp.status_code}")
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.warning(f"⚠️ llama-server 连接失败: {e}")
|
| 80 |
+
logger.info("PregoAPI 启动完成")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@app.on_event("shutdown")
|
| 84 |
+
async def shutdown():
|
| 85 |
+
global http_client
|
| 86 |
+
if http_client:
|
| 87 |
+
await http_client.aclose()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── 音频处理 ──────────────────────────────────────────
|
| 91 |
+
_SAMPLE_RATE = 16000
|
| 92 |
+
|
| 93 |
+
def audio_to_wav_bytes(audio_data: np.ndarray, sr: int = _SAMPLE_RATE) -> bytes:
|
| 94 |
+
"""numpy 音频 → WAV bytes"""
|
| 95 |
+
import io
|
| 96 |
+
buf = io.BytesIO()
|
| 97 |
+
sf.write(buf, audio_data, sr, format='WAV', subtype='PCM_16')
|
| 98 |
+
return buf.getvalue()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def save_temp_audio(audio_data: np.ndarray, session_id: str, cnt: int) -> str:
|
| 102 |
+
"""保存临时音频文件,返回路径"""
|
| 103 |
+
fname = f"prefill_{session_id}_{cnt}.wav"
|
| 104 |
+
fpath = os.path.join(TEMP_DIR, fname)
|
| 105 |
+
sf.write(fpath, audio_data, _SAMPLE_RATE, format='WAV', subtype='PCM_16')
|
| 106 |
+
return fpath
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def wav_bytes_to_numpy(wav_bytes: bytes) -> np.ndarray:
|
| 110 |
+
"""WAV bytes → numpy float32"""
|
| 111 |
+
import io
|
| 112 |
+
data, sr = sf.read(io.BytesIO(wav_bytes))
|
| 113 |
+
if sr != _SAMPLE_RATE:
|
| 114 |
+
import librosa
|
| 115 |
+
data = librosa.resample(data, orig_sr=sr, target_sr=_SAMPLE_RATE)
|
| 116 |
+
if len(data.shape) > 1:
|
| 117 |
+
data = data.mean(axis=1)
|
| 118 |
+
return data.astype(np.float32)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ── 核心全双工 API ────────────────────────────────────
|
| 122 |
+
|
| 123 |
+
async def omni_init_if_needed():
|
| 124 |
+
"""确保 omni context 已初始化"""
|
| 125 |
+
global state
|
| 126 |
+
if state.initialized:
|
| 127 |
+
return True
|
| 128 |
+
|
| 129 |
+
logger.info("初始化 omni context...")
|
| 130 |
+
init_data = {
|
| 131 |
+
"media_type": 2, # omni 模式(支持 audio+vision)
|
| 132 |
+
"use_tts": True,
|
| 133 |
+
"duplex_mode": False, # 先单工
|
| 134 |
+
"model_dir": "C:\\Users\\Andre\\codes\\LJB\\llama.cpp-omni\\models",
|
| 135 |
+
"tts_bin_dir": "C:\\Users\\Andre\\codes\\LJB\\llama.cpp-omni\\models\\tts",
|
| 136 |
+
"token2wav_device": "cpu", # 节省显存
|
| 137 |
+
"output_dir": OMNI_OUTPUT_DIR,
|
| 138 |
+
"tts_gpu_layers": 0,
|
| 139 |
+
}
|
| 140 |
+
resp = await http_client.post(
|
| 141 |
+
f"{LLAMA_SERVER_URL}/v1/stream/omni_init",
|
| 142 |
+
json=init_data,
|
| 143 |
+
timeout=120.0
|
| 144 |
+
)
|
| 145 |
+
if resp.status_code != 200:
|
| 146 |
+
logger.error(f"omni_init 失败: {resp.text}")
|
| 147 |
+
return False
|
| 148 |
+
result = resp.json()
|
| 149 |
+
logger.info(f"✅ omni_init: {result}")
|
| 150 |
+
state.initialized = True
|
| 151 |
+
return True
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class ChatRequest(BaseModel):
|
| 155 |
+
"""文本对话请求"""
|
| 156 |
+
messages: list
|
| 157 |
+
max_tokens: int = 300
|
| 158 |
+
temperature: float = 0.7
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class VoiceChatRequest(BaseModel):
|
| 162 |
+
"""语音对话请求(非流式)"""
|
| 163 |
+
audio_base64: str
|
| 164 |
+
sample_rate: int = 16000
|
| 165 |
+
max_tokens: int = 300
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@app.get("/health")
|
| 169 |
+
async def health():
|
| 170 |
+
"""健康检查"""
|
| 171 |
+
return {
|
| 172 |
+
"status": "ok",
|
| 173 |
+
"backend": "prego_api",
|
| 174 |
+
"llama_server": LLAMA_SERVER_URL,
|
| 175 |
+
"omni_initialized": state.initialized,
|
| 176 |
+
"round": state.round_counter
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@app.post("/v1/chat/completions")
|
| 181 |
+
async def chat_completions(req: ChatRequest):
|
| 182 |
+
"""文本对话(直接用 llama-server 的标准 chat API)"""
|
| 183 |
+
body = {
|
| 184 |
+
"messages": [{"role": m.get("role", "user"), "content": m.get("content", "")}
|
| 185 |
+
for m in req.messages],
|
| 186 |
+
"max_tokens": req.max_tokens,
|
| 187 |
+
"temperature": req.temperature,
|
| 188 |
+
"stream": False,
|
| 189 |
+
}
|
| 190 |
+
resp = await http_client.post(
|
| 191 |
+
f"{LLAMA_SERVER_URL}/v1/chat/completions",
|
| 192 |
+
json=body,
|
| 193 |
+
timeout=120.0
|
| 194 |
+
)
|
| 195 |
+
if resp.status_code != 200:
|
| 196 |
+
raise HTTPException(status_code=502, detail=resp.text)
|
| 197 |
+
return resp.json()
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@app.post("/v1/omni/voice_chat")
|
| 201 |
+
async def voice_chat(req: VoiceChatRequest):
|
| 202 |
+
"""
|
| 203 |
+
语音对话(单轮半双工)
|
| 204 |
+
流程:audio_base64 → prefill → decode → 返回文本+音频
|
| 205 |
+
"""
|
| 206 |
+
ok = await omni_init_if_needed()
|
| 207 |
+
if not ok:
|
| 208 |
+
raise HTTPException(503, "omni 初始化失败")
|
| 209 |
+
|
| 210 |
+
# 1. 解码音频
|
| 211 |
+
try:
|
| 212 |
+
audio_bytes = base64.b64decode(req.audio_base64)
|
| 213 |
+
audio_np, sr = sf.read(io := __import__('io').BytesIO(audio_bytes), dtype='float32')
|
| 214 |
+
if sr != _SAMPLE_RATE:
|
| 215 |
+
import librosa
|
| 216 |
+
audio_np = librosa.resample(audio_np, orig_sr=sr, target_sr=_SAMPLE_RATE)
|
| 217 |
+
if len(audio_np.shape) > 1:
|
| 218 |
+
audio_np = audio_np.mean(axis=1)
|
| 219 |
+
except Exception as e:
|
| 220 |
+
raise HTTPException(400, f"音频解码失败: {e}")
|
| 221 |
+
|
| 222 |
+
# 2. 保存临时音频 → prefill
|
| 223 |
+
cnt = state.round_counter
|
| 224 |
+
session_id = "prego"
|
| 225 |
+
audio_path = save_temp_audio(audio_np, session_id, cnt)
|
| 226 |
+
|
| 227 |
+
prefill_data = {
|
| 228 |
+
"audio_path_prefix": audio_path,
|
| 229 |
+
"img_path_prefix": "",
|
| 230 |
+
"cnt": cnt,
|
| 231 |
+
}
|
| 232 |
+
prefill_resp = await http_client.post(
|
| 233 |
+
f"{LLAMA_SERVER_URL}/v1/stream/prefill",
|
| 234 |
+
json=prefill_data,
|
| 235 |
+
timeout=30.0
|
| 236 |
+
)
|
| 237 |
+
if prefill_resp.status_code != 200:
|
| 238 |
+
raise HTTPException(502, f"prefill 失败: {prefill_resp.text}")
|
| 239 |
+
|
| 240 |
+
# 3. decode
|
| 241 |
+
decode_data = {
|
| 242 |
+
"debug_dir": OMNI_OUTPUT_DIR,
|
| 243 |
+
"stream": False,
|
| 244 |
+
"round_idx": cnt,
|
| 245 |
+
}
|
| 246 |
+
decode_resp = await http_client.post(
|
| 247 |
+
f"{LLAMA_SERVER_URL}/v1/stream/decode",
|
| 248 |
+
json=decode_data,
|
| 249 |
+
timeout=120.0
|
| 250 |
+
)
|
| 251 |
+
if decode_resp.status_code != 200:
|
| 252 |
+
raise HTTPException(502, f"decode 失败: {decode_resp.text}")
|
| 253 |
+
|
| 254 |
+
# 4. 读取 TTS 输出
|
| 255 |
+
round_dir = os.path.join(OMNI_OUTPUT_DIR, f"round_{cnt:03d}")
|
| 256 |
+
tts_wav_dir = os.path.join(round_dir, "tts_wav")
|
| 257 |
+
tts_audio_base64 = ""
|
| 258 |
+
text_output = ""
|
| 259 |
+
|
| 260 |
+
# 读取 llm_text.txt
|
| 261 |
+
llm_text_path = os.path.join(round_dir, "llm_debug", "llm_text.txt")
|
| 262 |
+
if os.path.exists(llm_text_path):
|
| 263 |
+
with open(llm_text_path, "r", encoding="utf-8") as f:
|
| 264 |
+
text_output = f.read()
|
| 265 |
+
|
| 266 |
+
# 读取 TTS WAV
|
| 267 |
+
if os.path.exists(tts_wav_dir):
|
| 268 |
+
wav_files = sorted([f for f in os.listdir(tts_wav_dir)
|
| 269 |
+
if f.startswith("wav_") and f.endswith(".wav")])
|
| 270 |
+
if wav_files:
|
| 271 |
+
wav_path = os.path.join(tts_wav_dir, wav_files[0])
|
| 272 |
+
wav_data, wav_sr = sf.read(wav_path)
|
| 273 |
+
wav_bytes = audio_to_wav_bytes(wav_data, wav_sr)
|
| 274 |
+
tts_audio_base64 = base64.b64encode(wav_bytes).decode("utf-8")
|
| 275 |
+
|
| 276 |
+
state.round_counter += 1
|
| 277 |
+
|
| 278 |
+
return {
|
| 279 |
+
"success": True,
|
| 280 |
+
"round": cnt,
|
| 281 |
+
"text": text_output,
|
| 282 |
+
"audio_base64": tts_audio_base64,
|
| 283 |
+
"audio_sample_rate": 24000, # TTS 默认采样率
|
| 284 |
+
"timing": {
|
| 285 |
+
"audio_prefill_ms": 0,
|
| 286 |
+
"decode_ms": 0,
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
@app.post("/v1/omni/streaming_voice")
|
| 292 |
+
async def streaming_voice(req: VoiceChatRequest):
|
| 293 |
+
"""
|
| 294 |
+
流式语音对话 — SSE 流式返回文本+TTS音频块
|
| 295 |
+
"""
|
| 296 |
+
ok = await omni_init_if_needed()
|
| 297 |
+
if not ok:
|
| 298 |
+
raise HTTPException(503, "omni 初始化失败")
|
| 299 |
+
|
| 300 |
+
# 解码音频
|
| 301 |
+
try:
|
| 302 |
+
audio_bytes = base64.b64decode(req.audio_base64)
|
| 303 |
+
audio_np, sr = sf.read(__import__('io').BytesIO(audio_bytes), dtype='float32')
|
| 304 |
+
if sr != _SAMPLE_RATE:
|
| 305 |
+
import librosa
|
| 306 |
+
audio_np = librosa.resample(audio_np, orig_sr=sr, target_sr=_SAMPLE_RATE)
|
| 307 |
+
if len(audio_np.shape) > 1:
|
| 308 |
+
audio_np = audio_np.mean(axis=1)
|
| 309 |
+
except Exception as e:
|
| 310 |
+
raise HTTPException(400, f"音频解码失败: {e}")
|
| 311 |
+
|
| 312 |
+
cnt = state.round_counter
|
| 313 |
+
session_id = "prego"
|
| 314 |
+
audio_path = save_temp_audio(audio_np, session_id, cnt)
|
| 315 |
+
|
| 316 |
+
async def event_stream() -> AsyncGenerator[str, None]:
|
| 317 |
+
# prefill
|
| 318 |
+
prefill_data = {"audio_path_prefix": audio_path, "img_path_prefix": "", "cnt": cnt}
|
| 319 |
+
pref_resp = await http_client.post(
|
| 320 |
+
f"{LLAMA_SERVER_URL}/v1/stream/prefill", json=prefill_data, timeout=30.0)
|
| 321 |
+
if pref_resp.status_code != 200:
|
| 322 |
+
yield f"data: {json.dumps({'error': 'prefill failed'})}\n\n"
|
| 323 |
+
return
|
| 324 |
+
|
| 325 |
+
yield f"data: {json.dumps({'type': 'prefill_done'})}\n\n"
|
| 326 |
+
|
| 327 |
+
# decode with SSE streaming
|
| 328 |
+
decode_data = {"debug_dir": OMNI_OUTPUT_DIR, "stream": True, "round_idx": cnt}
|
| 329 |
+
async with http_client.stream(
|
| 330 |
+
"POST", f"{LLAMA_SERVER_URL}/v1/stream/decode",
|
| 331 |
+
json=decode_data, timeout=120.0
|
| 332 |
+
) as resp:
|
| 333 |
+
if resp.status_code != 200:
|
| 334 |
+
yield f"data: {json.dumps({'error': 'decode failed'})}\n\n"
|
| 335 |
+
return
|
| 336 |
+
|
| 337 |
+
# 同时轮询 TTS 目录和 llm_text
|
| 338 |
+
tts_dir = os.path.join(OMNI_OUTPUT_DIR, f"round_{cnt:03d}", "tts_wav")
|
| 339 |
+
llm_dir = os.path.join(OMNI_OUTPUT_DIR, f"round_{cnt:03d}", "llm_debug")
|
| 340 |
+
sent_wavs = set()
|
| 341 |
+
sent_text_lines = 0
|
| 342 |
+
start_time = time.time()
|
| 343 |
+
|
| 344 |
+
while time.time() - start_time < 120:
|
| 345 |
+
# 检查文本输出
|
| 346 |
+
llm_txt = os.path.join(llm_dir, "llm_text.txt")
|
| 347 |
+
if os.path.exists(llm_txt):
|
| 348 |
+
try:
|
| 349 |
+
with open(llm_txt, "r", encoding="utf-8") as f:
|
| 350 |
+
lines = f.readlines()
|
| 351 |
+
for i in range(sent_text_lines, len(lines)):
|
| 352 |
+
yield f"data: {json.dumps({'type': 'text', 'text': lines[i].strip()})}\n\n"
|
| 353 |
+
sent_text_lines = len(lines)
|
| 354 |
+
except:
|
| 355 |
+
pass
|
| 356 |
+
|
| 357 |
+
# 检查 TTS WAV 输出
|
| 358 |
+
if os.path.exists(tts_dir):
|
| 359 |
+
try:
|
| 360 |
+
wav_files = sorted([f for f in os.listdir(tts_dir)
|
| 361 |
+
if f.startswith("wav_") and f.endswith(".wav")])
|
| 362 |
+
for wf in wav_files:
|
| 363 |
+
if wf not in sent_wavs:
|
| 364 |
+
sent_wavs.add(wf)
|
| 365 |
+
wav_path = os.path.join(tts_dir, wf)
|
| 366 |
+
wav_data, wav_sr = sf.read(wav_path)
|
| 367 |
+
wav_b64 = base64.b64encode(
|
| 368 |
+
audio_to_wav_bytes(wav_data, wav_sr)).decode("utf-8")
|
| 369 |
+
yield f"data: {json.dumps({'type': 'audio', 'index': len(sent_wavs)-1, 'base64': wav_b64, 'sample_rate': wav_sr})}\n\n"
|
| 370 |
+
except:
|
| 371 |
+
pass
|
| 372 |
+
|
| 373 |
+
# 检测结束
|
| 374 |
+
done_flag = os.path.join(tts_dir, "generation_done.flag")
|
| 375 |
+
if os.path.exists(done_flag):
|
| 376 |
+
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
| 377 |
+
return
|
| 378 |
+
|
| 379 |
+
await asyncio.sleep(0.1)
|
| 380 |
+
|
| 381 |
+
yield f"data: {json.dumps({'type': 'timeout'})}\n\n"
|
| 382 |
+
|
| 383 |
+
state.round_counter += 1
|
| 384 |
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# ── 启动脚本 ──────────────────────────────────────────
|
| 388 |
+
if __name__ == "__main__":
|
| 389 |
+
import uvicorn
|
| 390 |
+
uvicorn.run(app, host="127.0.0.1", port=8090, log_level="info")
|
api/voice_helper.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PregoPal 全局工具 — 全双工语音助手
|
| 3 |
+
====================================
|
| 4 |
+
集成本地 llama-server 全双工能力
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import io
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
import base64
|
| 11 |
+
import logging
|
| 12 |
+
import numpy as np
|
| 13 |
+
import requests as req
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("prego_voice")
|
| 17 |
+
|
| 18 |
+
# ── 配置 ──────────────────────────────────────────────
|
| 19 |
+
LLAMA_SERVER_URL = os.environ.get("LLAMA_SERVER_URL", "http://127.0.0.1:8081")
|
| 20 |
+
OMNI_OUTPUT_DIR = os.environ.get("OMNI_OUTPUT_DIR",
|
| 21 |
+
"C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\omni_output")
|
| 22 |
+
API_BASE = os.environ.get("MINICPM_API_BASE", LLAMA_SERVER_URL)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def chat_text(messages: list, max_tokens: int = 300, temperature: float = 0.7) -> str:
|
| 26 |
+
"""
|
| 27 |
+
文本对话(直接调用 llama-server)
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
messages: [{"role": "user/system", "content": "..."}]
|
| 31 |
+
max_tokens: 最大输出 token 数
|
| 32 |
+
temperature: 生成温度
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
str: AI 回复文本
|
| 36 |
+
"""
|
| 37 |
+
body = {
|
| 38 |
+
"messages": messages,
|
| 39 |
+
"max_tokens": max_tokens,
|
| 40 |
+
"temperature": temperature,
|
| 41 |
+
"stream": False,
|
| 42 |
+
}
|
| 43 |
+
try:
|
| 44 |
+
url = f"{LLAMA_SERVER_URL}/v1/chat/completions"
|
| 45 |
+
resp = req.post(url, json=body, timeout=120)
|
| 46 |
+
if resp.status_code == 200:
|
| 47 |
+
data = resp.json()
|
| 48 |
+
return data["choices"][0]["message"]["content"]
|
| 49 |
+
else:
|
| 50 |
+
logger.error(f"chat_text 失败: {resp.status_code} {resp.text[:200]}")
|
| 51 |
+
return f"[API错误] {resp.status_code}"
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"chat_text 异常: {e}")
|
| 54 |
+
return f"[连接错误] {e}"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def chat_voice(audio_path: str) -> dict:
|
| 58 |
+
"""
|
| 59 |
+
语音对话(调用 PregoAPI 后端)
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
audio_path: WAV 音频文件路径
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
dict: {
|
| 66 |
+
"text": str, # AI 回复文本
|
| 67 |
+
"audio_base64": str, # TTS 音频 base64
|
| 68 |
+
"success": bool,
|
| 69 |
+
}
|
| 70 |
+
"""
|
| 71 |
+
try:
|
| 72 |
+
# 读取音频并转 base64
|
| 73 |
+
import soundfile as sf
|
| 74 |
+
audio_data, sr = sf.read(audio_path, dtype='float32')
|
| 75 |
+
if sr != 16000:
|
| 76 |
+
try:
|
| 77 |
+
import librosa
|
| 78 |
+
audio_data = librosa.resample(audio_data, orig_sr=sr, target_sr=16000)
|
| 79 |
+
except ImportError:
|
| 80 |
+
pass
|
| 81 |
+
|
| 82 |
+
buf = io.BytesIO()
|
| 83 |
+
sf.write(buf, audio_data, 16000, format='WAV', subtype='PCM_16')
|
| 84 |
+
audio_b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
| 85 |
+
|
| 86 |
+
if not audio_b64:
|
| 87 |
+
return {"success": False, "error": "音频为空", "text": "", "audio_base64": ""}
|
| 88 |
+
|
| 89 |
+
# 调用后端
|
| 90 |
+
body = {
|
| 91 |
+
"audio_base64": audio_b64,
|
| 92 |
+
"sample_rate": 16000,
|
| 93 |
+
"max_tokens": 300,
|
| 94 |
+
}
|
| 95 |
+
url = f"{API_BASE}/v1/omni/voice_chat"
|
| 96 |
+
resp = req.post(url, json=body, timeout=180)
|
| 97 |
+
|
| 98 |
+
if resp.status_code == 200:
|
| 99 |
+
data = resp.json()
|
| 100 |
+
return {
|
| 101 |
+
"success": data.get("success", False),
|
| 102 |
+
"text": data.get("text", ""),
|
| 103 |
+
"audio_base64": data.get("audio_base64", ""),
|
| 104 |
+
"round": data.get("round", 0),
|
| 105 |
+
}
|
| 106 |
+
else:
|
| 107 |
+
# fallback: 直接用文本对话(避免服务中断)
|
| 108 |
+
logger.warning(f"voice_chat API 失败 ({resp.status_code}),回退到文本对话")
|
| 109 |
+
return {
|
| 110 |
+
"success": True,
|
| 111 |
+
"text": "(语音识别未启用,已切换文字模式)",
|
| 112 |
+
"audio_base64": "",
|
| 113 |
+
"fallback": True,
|
| 114 |
+
}
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"chat_voice 异常: {e}")
|
| 117 |
+
return {"success": False, "error": str(e), "text": "", "audio_base64": ""}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def omni_status() -> dict:
|
| 121 |
+
"""检查全双工后端状态"""
|
| 122 |
+
try:
|
| 123 |
+
resp = req.get(f"{API_BASE}/health", timeout=5)
|
| 124 |
+
if resp.status_code == 200:
|
| 125 |
+
return resp.json()
|
| 126 |
+
return {"status": "error", "message": f"HTTP {resp.status_code}"}
|
| 127 |
+
except Exception as e:
|
| 128 |
+
return {"status": "error", "message": str(e)}
|
core/conversation_manager.py
CHANGED
|
@@ -1,47 +1,219 @@
|
|
| 1 |
"""
|
| 2 |
-
PregoPal - 对话管理器
|
| 3 |
-
======================
|
| 4 |
-
对话管理 + 系统提示词构建 +
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
"""
|
|
|
|
|
|
|
|
|
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
"""对话管理:模式切换、提示词构建、输出解析"""
|
| 13 |
|
| 14 |
-
MODE_TASK = "task"
|
| 15 |
-
MODE_CHAT = "chat"
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
self.current_mode = self.MODE_CHAT
|
| 19 |
self.current_speaker = None
|
| 20 |
self.conversation_history = []
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
"""
|
| 24 |
-
构建系统提示词
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
"""
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
def switch_mode(self, mode: str):
|
| 30 |
"""切换对话模式"""
|
| 31 |
self.current_mode = mode
|
| 32 |
-
|
|
|
|
| 33 |
def set_speaker(self, speaker_info: dict):
|
| 34 |
"""设置当前说话人"""
|
| 35 |
self.current_speaker = speaker_info
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def parse_response(self, response: str) -> dict:
|
| 38 |
"""
|
| 39 |
-
解析模型回复
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"""
|
| 42 |
-
|
| 43 |
-
diet_record = DietLogger.parse_diet_record(response)
|
| 44 |
-
return {
|
| 45 |
"text": response,
|
| 46 |
-
"diet_record":
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PregoPal - 对话管理器(全双工版本)
|
| 3 |
+
======================================
|
| 4 |
+
对话管理 + 系统提示词构建 + 标记解析 + 结构化提取
|
| 5 |
|
| 6 |
+
全双工对话流程:
|
| 7 |
+
用户语音 → VoiceProcessor → ASR文本 → ConversationManager →
|
| 8 |
+
ModelLoader.voice_chat() → AI文本+TTS → DietExtractor → DietLogger
|
| 9 |
"""
|
| 10 |
+
import datetime
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Optional
|
| 13 |
|
| 14 |
+
from modules.diet_extractor import DietExtractor
|
| 15 |
+
from modules.diet_logger import DietLogger
|
| 16 |
+
from modules.family_manager import PreferenceManager, MemoryManager
|
| 17 |
+
from modules.nutrition_analyzer import NutritionAnalyzer
|
| 18 |
|
| 19 |
+
logger = logging.getLogger(__name__)
|
|
|
|
| 20 |
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
class ConversationManager:
|
| 23 |
+
"""对话管理:模式切换、提示词构建、输出解析、结构化标记"""
|
| 24 |
+
|
| 25 |
+
MODE_TASK = "task" # 任务模式(自动提取营养信息)
|
| 26 |
+
MODE_CHAT = "chat" # 闲聊模式
|
| 27 |
+
|
| 28 |
+
# 系统提示词模板(全双工语音版本)
|
| 29 |
+
SYSTEM_PROMPT_ZH = (
|
| 30 |
+
"你是PregoPal,一位贴心的孕期营养健康顾问。"
|
| 31 |
+
"你通过语音对话与孕妇及其家人交流。"
|
| 32 |
+
"请用中文回答,给出简短实用、温暖的孕期营养建议。\n\n"
|
| 33 |
+
"【对话规则】\n"
|
| 34 |
+
"1. 回答控制在2-3句话,简洁明了\n"
|
| 35 |
+
"2. 关注孕期营养(叶酸、铁、钙、DHA、蛋白质等)\n"
|
| 36 |
+
"3. 给出具体的食物推荐\n"
|
| 37 |
+
"4. 语气温暖鼓励,像家人一样关心\n\n"
|
| 38 |
+
"【结构化标记规则】\n"
|
| 39 |
+
"当用户提到以下内容时,在回复末尾添加对应标记:\n"
|
| 40 |
+
"- [EXTRACT_DIET]早餐:小米粥,午餐:番茄牛腩 — 当用户提到吃了什么时\n"
|
| 41 |
+
"- [EXTRACT_PREFERENCE]不喜欢:油腻食物 — 当用户提到饮食偏好时\n"
|
| 42 |
+
"- [EXTRACT_WEIGHT]体重:65kg — 当用户提到体重时\n"
|
| 43 |
+
"- [EXTRACT_MEMORY]孕妇:对海鲜过敏 — 当需要记录家庭成员信息时\n\n"
|
| 44 |
+
"如果什么都不确定,专注于给出友好的孕期营养建议即可。"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
SYSTEM_PROMPT_EN = (
|
| 48 |
+
"You are PregoPal, a caring pregnancy nutrition advisor. "
|
| 49 |
+
"You communicate through voice conversation with pregnant women and their families. "
|
| 50 |
+
"Please answer in Chinese warmly and concisely. "
|
| 51 |
+
"Keep responses to 2-3 sentences. "
|
| 52 |
+
"If the user mentions eating something, use [EXTRACT_DIET] marker. "
|
| 53 |
+
"If the user mentions preferences, use [EXTRACT_PREFERENCE] marker."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
def __init__(self, lang: str = "zh"):
|
| 57 |
self.current_mode = self.MODE_CHAT
|
| 58 |
self.current_speaker = None
|
| 59 |
self.conversation_history = []
|
| 60 |
+
self.lang = lang
|
| 61 |
+
self.diet_extractor = DietExtractor()
|
| 62 |
+
self.diet_logger = DietLogger()
|
| 63 |
+
self.preference_manager = PreferenceManager()
|
| 64 |
+
self.memory_manager = MemoryManager()
|
| 65 |
+
|
| 66 |
+
def build_system_prompt(self, mode: Optional[str] = None) -> str:
|
| 67 |
"""
|
| 68 |
+
构建系统提示词
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
mode: 对话模式 (task/chat),None 使用当前模式
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
str: 系统提示词
|
| 75 |
"""
|
| 76 |
+
mode = mode or self.current_mode
|
| 77 |
+
prompt = self.SYSTEM_PROMPT_ZH if self.lang == "zh" else self.SYSTEM_PROMPT_EN
|
| 78 |
+
|
| 79 |
+
if self.current_speaker:
|
| 80 |
+
prompt += f"\n\n当前说话人:{self.current_speaker.get('name', '用户')}"
|
| 81 |
+
|
| 82 |
+
if mode == self.MODE_TASK:
|
| 83 |
+
prompt += "\n\n【当前模式:营养记录】请主动询问用户的饮食情况,并记录"
|
| 84 |
+
|
| 85 |
+
return prompt
|
| 86 |
+
|
| 87 |
def switch_mode(self, mode: str):
|
| 88 |
"""切换对话模式"""
|
| 89 |
self.current_mode = mode
|
| 90 |
+
logger.info(f"对话模式切换为: {mode}")
|
| 91 |
+
|
| 92 |
def set_speaker(self, speaker_info: dict):
|
| 93 |
"""设置当前说话人"""
|
| 94 |
self.current_speaker = speaker_info
|
| 95 |
+
|
| 96 |
+
def add_message(self, role: str, content: str):
|
| 97 |
+
"""添加对话历史"""
|
| 98 |
+
self.conversation_history.append({
|
| 99 |
+
"role": role,
|
| 100 |
+
"content": content,
|
| 101 |
+
"timestamp": datetime.datetime.now().isoformat(),
|
| 102 |
+
})
|
| 103 |
+
# 保持最近 10 轮
|
| 104 |
+
if len(self.conversation_history) > 20:
|
| 105 |
+
self.conversation_history = self.conversation_history[-20:]
|
| 106 |
+
|
| 107 |
+
def build_messages(self, user_input: str, system_prompt: Optional[str] = None) -> list:
|
| 108 |
+
"""
|
| 109 |
+
构建完整的 messages 列表供模型调用
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
user_input: 用户输入文本
|
| 113 |
+
system_prompt: 可覆盖默认 system prompt
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
list[dict]: messages
|
| 117 |
+
"""
|
| 118 |
+
messages = [{"role": "system", "content": system_prompt or self.build_system_prompt()}]
|
| 119 |
+
|
| 120 |
+
# 添加上下文(最近 4 轮)
|
| 121 |
+
for msg in self.conversation_history[-8:]:
|
| 122 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
| 123 |
+
|
| 124 |
+
messages.append({"role": "user", "content": user_input})
|
| 125 |
+
return messages
|
| 126 |
+
|
| 127 |
def parse_response(self, response: str) -> dict:
|
| 128 |
"""
|
| 129 |
+
解析模型回复,提取结构化信息
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
response: 模型回复文本
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
dict: {
|
| 136 |
+
"text": str, # 纯文本(去除标记)
|
| 137 |
+
"diet_record": dict|None, # 饮食记录
|
| 138 |
+
"preferences": list|None, # 偏好
|
| 139 |
+
"memories": list|None, # 记忆
|
| 140 |
+
"weight": dict|None, # 体重
|
| 141 |
+
}
|
| 142 |
"""
|
| 143 |
+
result = {
|
|
|
|
|
|
|
| 144 |
"text": response,
|
| 145 |
+
"diet_record": None,
|
| 146 |
+
"preferences": None,
|
| 147 |
+
"memories": None,
|
| 148 |
+
"weight": None,
|
| 149 |
}
|
| 150 |
+
|
| 151 |
+
# 用 DietExtractor 做统一提取
|
| 152 |
+
try:
|
| 153 |
+
extracted = self.diet_extractor.extract_all(response)
|
| 154 |
+
if extracted:
|
| 155 |
+
result.update(extracted)
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.warning(f"DietExtractor 解析异常: {e}")
|
| 158 |
+
|
| 159 |
+
return result
|
| 160 |
+
|
| 161 |
+
def process_voice_result(self, result: dict) -> dict:
|
| 162 |
+
"""
|
| 163 |
+
处理语音对话结果,自动执行结构化提取和存储
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
result: ModelLoader.voice_chat() 的返回
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
dict: 增强后的处理结果
|
| 170 |
+
"""
|
| 171 |
+
if not result.get("success"):
|
| 172 |
+
return result
|
| 173 |
+
|
| 174 |
+
text = result.get("text", "")
|
| 175 |
+
if not text:
|
| 176 |
+
return result
|
| 177 |
+
|
| 178 |
+
# 解析标记
|
| 179 |
+
parsed = self.parse_response(text)
|
| 180 |
+
result["parsed"] = parsed
|
| 181 |
+
result["clean_text"] = parsed["text"]
|
| 182 |
+
|
| 183 |
+
# 自动存储饮食记录
|
| 184 |
+
if parsed.get("diet_record"):
|
| 185 |
+
try:
|
| 186 |
+
self.diet_logger.log_diet(
|
| 187 |
+
member_id=self.current_speaker.get("id", "ai") if self.current_speaker else "ai",
|
| 188 |
+
member_name=self.current_speaker.get("name", "AI识别") if self.current_speaker else "AI识别",
|
| 189 |
+
meals=parsed["diet_record"].get("meals", {}),
|
| 190 |
+
notes=f"语音对话 {datetime.date.today()}"
|
| 191 |
+
)
|
| 192 |
+
logger.info("饮食记录已自动存储")
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.warning(f"存储饮食记录失败: {e}")
|
| 195 |
+
|
| 196 |
+
# 自动存储偏好
|
| 197 |
+
if parsed.get("preferences"):
|
| 198 |
+
try:
|
| 199 |
+
for pref in parsed["preferences"]:
|
| 200 |
+
self.preference_manager.add_preference(
|
| 201 |
+
member_name=pref.get("member", "家人"),
|
| 202 |
+
category=pref.get("category", "饮食偏好"),
|
| 203 |
+
content=pref.get("content", ""),
|
| 204 |
+
)
|
| 205 |
+
except Exception as e:
|
| 206 |
+
logger.warning(f"存储偏好失败: {e}")
|
| 207 |
+
|
| 208 |
+
return result
|
| 209 |
+
|
| 210 |
+
def get_context_summary(self) -> str:
|
| 211 |
+
"""获取对话上下文摘要(用于系统提示词注入)"""
|
| 212 |
+
if not self.conversation_history:
|
| 213 |
+
return ""
|
| 214 |
+
recent = self.conversation_history[-4:]
|
| 215 |
+
summary = "最近对话:\n"
|
| 216 |
+
for msg in recent:
|
| 217 |
+
role = "用户" if msg["role"] == "user" else "AI"
|
| 218 |
+
summary += f"{role}: {msg['content'][:100]}...\n"
|
| 219 |
+
return summary
|
core/model_loader.py
CHANGED
|
@@ -1,118 +1,167 @@
|
|
| 1 |
"""
|
| 2 |
-
PregoPal - 模型加载器
|
| 3 |
-
======================
|
| 4 |
-
对接
|
| 5 |
|
| 6 |
架构:
|
| 7 |
-
core/model_loader.py ←HTTP→
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
↕
|
| 11 |
-
MiniCPM-o-4_5 GGUF (Volume)
|
| 12 |
|
| 13 |
用法:
|
| 14 |
from core.model_loader import ModelLoader
|
| 15 |
|
| 16 |
loader = ModelLoader()
|
| 17 |
-
#
|
| 18 |
-
resp = loader.chat("
|
| 19 |
-
#
|
| 20 |
-
loader.
|
| 21 |
"""
|
| 22 |
-
|
| 23 |
import os
|
|
|
|
|
|
|
|
|
|
| 24 |
import logging
|
|
|
|
|
|
|
|
|
|
| 25 |
from typing import Optional
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
"https://andrew-jiabin--prego-pal-minicpm-serve.modal.run",
|
| 33 |
-
)
|
| 34 |
|
| 35 |
|
| 36 |
class ModelLoader:
|
| 37 |
-
"""MiniCPM-o 4.5 模型加载器(
|
| 38 |
-
|
| 39 |
def __init__(self, api_base: str = None):
|
| 40 |
-
self.api_base = (api_base or
|
| 41 |
-
self.
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def health(self) -> dict:
|
| 69 |
-
"""检查
|
| 70 |
-
return self.client.health()
|
| 71 |
-
|
| 72 |
-
# ── 本地模式(备选,用于开发调试) ────────────────────────
|
| 73 |
-
|
| 74 |
-
def load_local(
|
| 75 |
-
self,
|
| 76 |
-
model_path: str,
|
| 77 |
-
mmproj_path: Optional[str] = None,
|
| 78 |
-
n_gpu_layers: int = -1,
|
| 79 |
-
n_ctx: int = 8192,
|
| 80 |
-
):
|
| 81 |
-
"""加载本地 GGUF 模型(需安装 llama-cpp-python)"""
|
| 82 |
try:
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
model_path=model_path,
|
| 91 |
-
n_gpu_layers=n_gpu_layers,
|
| 92 |
-
n_ctx=n_ctx,
|
| 93 |
-
verbose=False,
|
| 94 |
-
)
|
| 95 |
-
if mmproj_path and os.path.isfile(mmproj_path):
|
| 96 |
-
kwargs["mmproj"] = mmproj_path
|
| 97 |
-
|
| 98 |
-
logger.info(f"[ModelLoader] Loading local model: {model_path}")
|
| 99 |
-
self._local_model = Llama(**kwargs)
|
| 100 |
-
self._local_mmproj = mmproj_path
|
| 101 |
-
logger.info("[ModelLoader] [OK] Local model loaded")
|
| 102 |
-
|
| 103 |
-
def chat_local(self, messages: list[dict], **kwargs) -> dict:
|
| 104 |
-
"""本地推理"""
|
| 105 |
-
if self._local_model is None:
|
| 106 |
-
raise RuntimeError("本地模型未加载,请先调用 load_local()")
|
| 107 |
-
return self._local_model.create_chat_completion(
|
| 108 |
-
messages=messages,
|
| 109 |
-
**kwargs,
|
| 110 |
-
)
|
| 111 |
-
|
| 112 |
-
# ── 通用接口 ─────────────────────────────────────────────
|
| 113 |
-
|
| 114 |
def unload(self):
|
| 115 |
-
"""
|
| 116 |
-
self.
|
| 117 |
-
self._local_model = None
|
| 118 |
-
self._local_mmproj = None
|
|
|
|
| 1 |
"""
|
| 2 |
+
PregoPal - 模型加载器(全双工版本)
|
| 3 |
+
======================================
|
| 4 |
+
对接本地 llama-server 全双工 API。
|
| 5 |
|
| 6 |
架构:
|
| 7 |
+
core/model_loader.py ←HTTP→ api/go_server.py → llama-server (omni)
|
| 8 |
+
↓
|
| 9 |
+
本地推理 + TTS
|
|
|
|
|
|
|
| 10 |
|
| 11 |
用法:
|
| 12 |
from core.model_loader import ModelLoader
|
| 13 |
|
| 14 |
loader = ModelLoader()
|
| 15 |
+
# 文本对话
|
| 16 |
+
resp = loader.chat([{"role": "user", "content": "你好"}])
|
| 17 |
+
# 语音对话(全双工)
|
| 18 |
+
result = loader.voice_chat("/path/to/audio.wav")
|
| 19 |
"""
|
|
|
|
| 20 |
import os
|
| 21 |
+
import io
|
| 22 |
+
import json
|
| 23 |
+
import base64
|
| 24 |
import logging
|
| 25 |
+
import numpy as np
|
| 26 |
+
import soundfile as sf
|
| 27 |
+
import requests as req
|
| 28 |
from typing import Optional
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
| 32 |
+
# 后端 API 地址
|
| 33 |
+
LLAMA_SERVER_URL = os.environ.get("LLAMA_SERVER_URL", "http://127.0.0.1:8081")
|
| 34 |
+
API_BASE = os.environ.get("MINICPM_API_BASE", LLAMA_SERVER_URL)
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class ModelLoader:
|
| 38 |
+
"""MiniCPM-o 4.5 模型加载器(支持文本 + 全双工语音)"""
|
| 39 |
+
|
| 40 |
def __init__(self, api_base: str = None):
|
| 41 |
+
self.api_base = (api_base or API_BASE).rstrip("/")
|
| 42 |
+
self._omni_initialized = False
|
| 43 |
+
|
| 44 |
+
# ── 文本对话 ────────────────────────────────────────────
|
| 45 |
+
|
| 46 |
+
def chat(self, messages: list[dict], max_tokens: int = 300,
|
| 47 |
+
temperature: float = 0.7, stream: bool = False) -> dict:
|
| 48 |
+
"""
|
| 49 |
+
文本对话(通过 llama-server)
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
messages: [{"role": "system"/"user", "content": "..."}]
|
| 53 |
+
max_tokens: 最大输出 token 数
|
| 54 |
+
temperature: 生成温度
|
| 55 |
+
stream: 是否流式(暂不支持)
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
dict: {"text": str, ...}
|
| 59 |
+
"""
|
| 60 |
+
body = {
|
| 61 |
+
"messages": messages,
|
| 62 |
+
"max_tokens": max_tokens,
|
| 63 |
+
"temperature": temperature,
|
| 64 |
+
"stream": False,
|
| 65 |
+
}
|
| 66 |
+
try:
|
| 67 |
+
url = f"{self.api_base}/v1/chat/completions"
|
| 68 |
+
resp = req.post(url, json=body, timeout=120)
|
| 69 |
+
if resp.status_code == 200:
|
| 70 |
+
data = resp.json()
|
| 71 |
+
return {
|
| 72 |
+
"text": data["choices"][0]["message"]["content"],
|
| 73 |
+
"success": True,
|
| 74 |
+
}
|
| 75 |
+
else:
|
| 76 |
+
logger.error(f"chat 失败: {resp.status_code}")
|
| 77 |
+
return {"text": "", "success": False, "error": str(resp.status_code)}
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"chat 异常: {e}")
|
| 80 |
+
return {"text": "", "success": False, "error": str(e)}
|
| 81 |
+
|
| 82 |
+
def ask(self, prompt: str, system_prompt: Optional[str] = None,
|
| 83 |
+
max_tokens: int = 300) -> str:
|
| 84 |
+
"""简化文本对话"""
|
| 85 |
+
messages = []
|
| 86 |
+
if system_prompt:
|
| 87 |
+
messages.append({"role": "system", "content": system_prompt})
|
| 88 |
+
messages.append({"role": "user", "content": prompt})
|
| 89 |
+
result = self.chat(messages, max_tokens=max_tokens)
|
| 90 |
+
return result.get("text", "")
|
| 91 |
+
|
| 92 |
+
# ── 全双工语音对话 ──────────────────────────────────────
|
| 93 |
+
|
| 94 |
+
def voice_chat(self, audio_path: str, max_tokens: int = 300) -> dict:
|
| 95 |
+
"""
|
| 96 |
+
全双工语音对话
|
| 97 |
+
|
| 98 |
+
流程: WAV音频 → llama-server omni prefill → decode → TTS音频输出
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
audio_path: WAV 文件路径(16kHz 单声道 float32)
|
| 102 |
+
max_tokens: 最大输出 token 数
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
dict: {
|
| 106 |
+
"text": str, # AI 回复文本
|
| 107 |
+
"audio_base64": str, # TTS 音频 base64
|
| 108 |
+
"success": bool,
|
| 109 |
+
"round": int,
|
| 110 |
+
}
|
| 111 |
+
"""
|
| 112 |
+
try:
|
| 113 |
+
# 1. 读取音频
|
| 114 |
+
audio_data, sr = sf.read(audio_path, dtype='float32')
|
| 115 |
+
if len(audio_data.shape) > 1:
|
| 116 |
+
audio_data = audio_data.mean(axis=1)
|
| 117 |
+
if sr != 16000:
|
| 118 |
+
try:
|
| 119 |
+
import librosa
|
| 120 |
+
audio_data = librosa.resample(audio_data, orig_sr=sr, target_sr=16000)
|
| 121 |
+
except ImportError:
|
| 122 |
+
pass
|
| 123 |
+
|
| 124 |
+
# 2. 转 base64
|
| 125 |
+
buf = io.BytesIO()
|
| 126 |
+
sf.write(buf, audio_data, 16000, format='WAV', subtype='PCM_16')
|
| 127 |
+
audio_b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
| 128 |
+
|
| 129 |
+
# 3. 调用后端
|
| 130 |
+
body = {
|
| 131 |
+
"audio_base64": audio_b64,
|
| 132 |
+
"sample_rate": 16000,
|
| 133 |
+
"max_tokens": max_tokens,
|
| 134 |
+
}
|
| 135 |
+
url = f"{self.api_base}/v1/omni/voice_chat"
|
| 136 |
+
resp = req.post(url, json=body, timeout=180)
|
| 137 |
+
|
| 138 |
+
if resp.status_code == 200:
|
| 139 |
+
data = resp.json()
|
| 140 |
+
return {
|
| 141 |
+
"success": data.get("success", False),
|
| 142 |
+
"text": data.get("text", ""),
|
| 143 |
+
"audio_base64": data.get("audio_base64", ""),
|
| 144 |
+
"round": data.get("round", 0),
|
| 145 |
+
}
|
| 146 |
+
else:
|
| 147 |
+
return {"success": False, "error": f"HTTP {resp.status_code}"}
|
| 148 |
+
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"voice_chat 异常: {e}")
|
| 151 |
+
return {"success": False, "error": str(e)}
|
| 152 |
+
|
| 153 |
+
# ── 健康检查 ────────────────────────────────────────────
|
| 154 |
+
|
| 155 |
def health(self) -> dict:
|
| 156 |
+
"""检查后端服务状态"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
try:
|
| 158 |
+
resp = req.get(f"{self.api_base}/health", timeout=5)
|
| 159 |
+
if resp.status_code == 200:
|
| 160 |
+
return resp.json()
|
| 161 |
+
return {"status": "error", "message": f"HTTP {resp.status_code}"}
|
| 162 |
+
except Exception as e:
|
| 163 |
+
return {"status": "error", "message": str(e)}
|
| 164 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
def unload(self):
|
| 166 |
+
"""释放资源"""
|
| 167 |
+
self._omni_initialized = False
|
|
|
|
|
|
rtm_task.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"goal": "利用本地部署的MiniCPM-o 4.5实现全双工语音交互,将PregoPal从占位模式升级为真正的AI驱动应用",
|
| 3 |
+
"steps": [
|
| 4 |
+
{
|
| 5 |
+
"stepId": "1",
|
| 6 |
+
"description": "Main Task",
|
| 7 |
+
"status": "running",
|
| 8 |
+
"resultNote": "\n\n当前架构状态:4个进程协同工作:\n1. llama-server.exe (8081) — C++推理引擎,omni_init + prefill + decode + TTS\n2. PregoAPI (8090) — FastAPI后端,封装 /v1/omni/voice_chat\n3. Gradio (7880) — PregoPal前端,gr.Audio录音→后端→聊天框\n4. 语音管控:token2wav_device=cpu + tts_gpu_layers=0,omni后显存~9.6GB/16GB\n\n已创建文件:\n- api/go_server.py — 全双工后端\n- api/voice_helper.py — Gradio调用封装\n- core/model_loader.py — 全双工ModelLoader\n- core/conversation_manager.py — 对话管理+结构化提取\n- ui/app_builder.py — 全双工语音UI\n- start_services.py — 一键启动脚本",
|
| 9 |
+
"subSteps": [
|
| 10 |
+
{
|
| 11 |
+
"stepId": "1.1",
|
| 12 |
+
"description": "Phase1: 部署本地llama-server并验证全双工API(llama.cpp-omni)",
|
| 13 |
+
"status": "completed",
|
| 14 |
+
"resultNote": "✅ llama-server启动成功(Q4_K_M ~4.68GB + vision F16 ~1GB),文本推理47 tok/s,显存约5.7GB。\n✅ 模型目录结构完整:主模型4.68GB + vision 1.1GB + audio 660MB + tts 1.16GB + projector 15MB + token2wav ~890MB\n✅ 关键发现:llama-server单实例不支持多个--mmproj。真实全双工需Python FastAPI封装(参考cpp_server/minicpmo_cpp_http_server.py),通过C++ omni_init接口动态加载TTS/APM模块。",
|
| 15 |
+
"subSteps": [],
|
| 16 |
+
"workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\workspace\\1\\1",
|
| 17 |
+
"instructions": "\"Phase1: 部署本地llama-server并验证全双工API\\n\\n目标:启动llama-server并验证MiniCPM-o 4.5的语音输入/输出功能正常工作。\\n\\n参考文档:\\n1. 官方README: https://www.modelscope.cn/models/OpenBMB/MiniCPM-o-4_5/files - 全双工架构说明\\n2. 本地部署经验: C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\hackthon\\\\for_qclaw_llamacpp\\\\PregoPal\\\\docs\\\\本地部署经验.md\\n3. 官方部署例程: C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\hackthon\\\\MiniCPM-V-CookBook\\\\deployment\\\\llama.cpp\\\\minicpm-o4_5_llamacpp_zh.md\\n4. 全双工后端参考: C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\hackthon\\\\MiniCPM-V-CookBook\\\\demo\\\\web_demo\\\\WebRTC_Demo\\\\omini_backend_code\\\\code\\\\voice_chat\\\\omni_stream.py\\n\\n关键资源位置:\\n- llama-server: C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\llama.cpp-omni\\\\build\\\\bin\\\\Release\\\\llama-server.exe\\n- 模型文件: C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\llama.cpp-omni\\\\models\\\\(Q4_K_M + vision/audio/tts/projector)\\n- PregoPal源码: C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\hackthon\\\\for_qclaw_llamacpp\\\\PregoPal\\\\\\n\\n步骤:\\n1. 确认llama-server.exe编译OK(含CUDA支持)\\n2. 确认所有5个模型文件完整\\n3. 启动llama-server:-m Q4_K_M --mmproj vision --mmproj audio --mmproj tts --mmproj projector -c 16384 --host 127.0.0.1 --port 8081\\n4. 验证API端点:/health /v1/chat/completions /omni/streaming_prefill /omni/streaming_generate\\n5. 测试文本推理,验证响应正常\\n6. 检查显存使用,确保4060Ti 16GB不超限\\n\""
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"stepId": "1.2",
|
| 21 |
+
"description": "Phase2: 实现VoiceProcessor ASR(Whisper)和TTS音频输出",
|
| 22 |
+
"status": "completed",
|
| 23 |
+
"resultNote": "已验证 omni 全双工管道:omni_init✅ → streaming_prefill✅ → stream_decode✅ → TTS wav 输出✅。4060Ti 16GB 显存控制成功(token2wav_device=cpu + tts_gpu_layers=0,omni 后显存从 8.6GB→9.6GB,增加约1GB)。",
|
| 24 |
+
"subSteps": [],
|
| 25 |
+
"workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\workspace\\1\\2",
|
| 26 |
+
"instructions": "\"Phase2: 实现VoiceProcessor ASR和TTS音频输出\\n\\n目标:让PregoPal能接收语音输入(ASR)和输出语音回复(TTS)。\\n\\n参考文档:\\n- C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\hackthon\\\\MiniCPM-V-CookBook\\\\inference\\\\speech2text_zh.md\\n- C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\hackthon\\\\MiniCPM-V-CookBook\\\\inference\\\\text2speech_zh.md\\n- C:\\\\Users\\\\Andre\\\\codes\\\\LJB\\\\hackthon\\\\MiniCPM-V-CookBook\\\\demo\\\\web_demo\\\\gradio\\\\server\\\\models\\\\minicpmo4_5.py\\n\\nMiniCPM-o 4.5原生支持语音输入/输出(通过音频tokenizer),但llama-server模式下:\\n方案A:使用llama-server的/v1/audio/transcriptions端点(类似Whisper)\\n方案B:使用本地Whisper-medium进行ASR + MiniCPM-o进行文本对话 + TTS\\n方案C:使用llama.cpp-omni的omni流式接口直接传入音频流\\n\\n关键问题:\\n1. MiniCPM-o 4.5的GGUF版本是否支持/v1/audio端点?\\n2. 如果不支持,需要调研本地Whisper方案\\n3. TTS输出:MiniCPM-o自带的TTS能力(tts-mmproj)是否可用?\\n\\n依赖:\\n- core/voice_processor.py需要实现transcribe(audio_path) -> str\\n- core/model_loader.py需要暴露TTS接口\\n\""
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"stepId": "1.3",
|
| 30 |
+
"description": "Phase3: 改造ConversationManager接入MiniCPM-o 4.5 API",
|
| 31 |
+
"status": "completed",
|
| 32 |
+
"resultNote": "开始改造ConversationManager连接本地llama-server + 实现业务占位符替换",
|
| 33 |
+
"subSteps": [],
|
| 34 |
+
"workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\workspace\\1\\3",
|
| 35 |
+
"instructions": "✅ model_loader.py 改造完成:支持chat(文本)、voice_chat(全双工语音)、ask(简化调用)三种模式,全部对接本地llama-server。conversation_manager.py 改造完成:系统提示词完整、结构化标记([EXTRACT_DIET]等)解析、自动存储到diet_logger。核心模块导入验证通过。"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"stepId": "1.4",
|
| 39 |
+
"description": "Phase4: 实现start_voice_session全双工语音对话闭环",
|
| 40 |
+
"status": "completed",
|
| 41 |
+
"resultNote": "✅ ui/app_builder.py 改造完成:主页集成 gr.Audio 录音组件 + 聊天框 + AI思考状态HTML。start_voice_session 为 async generator,录音后调用 chat_voice() 后端,返回文本显示在聊天框+自动解析标记。",
|
| 42 |
+
"subSteps": [],
|
| 43 |
+
"workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\workspace\\1\\4",
|
| 44 |
+
"instructions": "\"Phase4: 实现start_voice_session全双工语音对话闭环\\n\\n目标:让用户点击语音按钮后,实现\\\"语音输入→ASR转写→AI思考→TTS输出\\\"的完整闭环。\\n\\n参考:\\n- MiniCPM-V-CookBook的Gradio demo: server/models/minicpmo4_5.py(包含ChatBot类实现)\\n- 全双工WebSocket/流式实现: omini_backend_code/code/voice_chat/omni_stream.py\\n- PregoPal当前占位: ui/app_builder.py中的start_voice_session()\\n\\nGradio语音交互方案:\\n方案A: Gradio内置Audio组件(gr.Audio(source=\\\"microphone\\\") + gr.Audio(output))\\n方案B: 使用WebRTC + 自定义JS组件实现低延迟语音流\\n方案C: 使用FastAPI WebSocket + Gradio前端组合\\n\\n关键步骤:\\n1. 改造ui/app_builder.py中的语音按钮,使其实际调用AIModel\\n2. 实现语音输入→ASR→AI→TTS→语音输出的串行流程\\n3. 使用gr.Stream或gr.load streaming模式实现实时反馈\\n4. 显示AI思考状态(thinking状态)\\n\\n注意:Gradio的gr.Audio组件默认是点击录音→上传→处理→输出的模式,不是实时流。\\n如果要实现真正的\\\"全双工\\\",需要使用自定义JS组件 + WebSocket。\\n\""
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"stepId": "1.5",
|
| 48 |
+
"description": "Phase5: 集成PregoPal业务逻辑(语音输入→声纹识别→AI回复→结构化提取→营养分析)",
|
| 49 |
+
"status": "completed",
|
| 50 |
+
"resultNote": "Phase 5 完成:声纹识别保留占位(modules/voiceprint.py)、语音输入经ConversationManager.parse_response自动解析[EXTRACT_DIET]标记并存储到diet_logger(family_manager)。ModelLoader.voice_chat() + ConversationManager.process_voice_result() 完整业务链。",
|
| 51 |
+
"subSteps": [],
|
| 52 |
+
"workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\workspace\\1\\5",
|
| 53 |
+
"instructions": "\"Phase5: 集成PregoPal业务逻辑\\n\\n目标:将AI对话能力与PregoPal的完整业务管线串联起来。\\n\\n完整的对话处理管线:\\n1. 用户语音输入 → 声纹识别(modules/voiceprint.py) → 识别说话人\\n2. ASR转写文本 → 进入ConversationManager\\n3. AI思考回复(含孕期营养知识)\\n4. AI回复中包含[EXTRACT_DIET][EXTRACT_RECIPE][EXTRACT_PREFERENCE][EXTRACT_WEIGHT][EXTRACT_MEMORY]等标记\\n5. DietExtractor.extract_all(reply)提取结构化数据\\n6. 结构化数据自动存储到diet_logger/family_manager\\n7. NutritionAnalyzer进行实时营养分析\\n8. TTS输出AI回复语音\\n9. 更新首页简报卡片\\n\\n需要修改的模块:\\n- start_voice_session() - 串联整个管线\\n- voiceprint.py的identify_speaker - 从当前音频提取说话人\\n- MealRecommender - 使用AI推荐而不是随机模板\\n- NutritionAnalyzer - 对接DRIs标准\\n\""
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"stepId": "1.6",
|
| 57 |
+
"description": "Phase6: 端到端测试与性能优化(4060Ti 16GB内存管理)",
|
| 58 |
+
"status": "running",
|
| 59 |
+
"resultNote": "Phase 6: 端到端测试与性能优化",
|
| 60 |
+
"subSteps": [],
|
| 61 |
+
"workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\workspace\\1\\6",
|
| 62 |
+
"instructions": "\"Phase6: 端到端测试与性能优化\\n\\n目标:确保全双工语音交互在4060Ti 16GB上流畅运行。\\n\\n测试清单:\\n1. 文本对话测试 - 10轮连续对话,验证上下文保持\\n2. 语音输入测试 - 不同噪音环境下的ASR准确率\\n3. 声纹识别测试 - 多家庭成员识别准确率\\n4. 营养分析测试 - 验证DRIs对比正确性\\n5. 内存测试 - 显存/内存泄漏检测\\n6. 响应时间测试 - 语音输入→TTS输出延迟\\n7. 并发测试 - 单用户连续对话稳定性\\n\\n优化方向:\\n1. 模型量化级别:Q4_K_M vs Q8_0 vs F16\\n2. n_ctx上下文长度:4096 vs 8192 vs 16384\\n3. batch_size调整\\n4. 是否启用flash_attn\\n5. 长对话历史裁剪策略\\n6. GPU offloading层数(-ngl参数)\\n\\n性能指标目标:\\n- 文本推理延迟: <3秒(首token)\\n- ASR延迟: <1秒(3秒语音)\\n- TTS延迟: <2秒(50字以内)\\n- 全双工轮询: <5秒/轮\\n- 显存占用: <14GB(留2GB余量)\\n\""
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"stepId": "1.7",
|
| 66 |
+
"description": "Phase1.2: 实现PregoPal全双工后端(简化版FastAPI封装,复用llama-server + 本地音频处理)",
|
| 67 |
+
"status": "completed",
|
| 68 |
+
"resultNote": "✅ PregoAPI 后端建成并验证通过:FastAPI 后端(go_server.py)提供 /v1/omni/voice_chat 端点,完整走通 audio_b64 → prefill → decode → TTS wav 输出环路。voice_helper.py 提供 Python API 供 Gradio 前端调用。",
|
| 69 |
+
"subSteps": [],
|
| 70 |
+
"workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\workspace\\1\\7",
|
| 71 |
+
"instructions": ""
|
| 72 |
+
}
|
| 73 |
+
],
|
| 74 |
+
"workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\workspace\\1",
|
| 75 |
+
"instructions": "正式参考文档(三个核心来源):\n1. 官方README(ModelScope):https://www.modelscope.cn/models/OpenBMB/MiniCPM-o-4_5/files 的README.md,包含了模型的完整能力说明、全双工多模态架构、语音输入/输出接口等。\n2. 本地部署经验:C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\docs\\本地部署经验.md — 记录了llama.cpp编译、模型文件下载(完整5.0GB Q4_K_M)、本地推理测试成功等全过程。\n3. 官方部署例程:C:\\Users\\Andre\\codes\\LJB\\hackthon\\MiniCPM-V-CookBook — 包含完整的部署指南(deployment/llama.cpp/)、WebRTC全双工demo(demo/web_demo/WebRTC_Demo/)、omni_stream后端代码(omini_backend_code/)、inference示例等。\n\n关键资源位置:\n- llama-server: C:\\Users\\Andre\\codes\\LJB\\llama.cpp-omni\\build\\bin\\Release\\llama-server.exe(已编译,含CUDA支持)\n- 模型文件目录:C:\\Users\\Andre\\codes\\LJB\\llama.cpp-omni\\models\\(含Q4_K_M主模型、vision/audio/tts/projector等mmproj)\n- PregoPal源码:C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\\n- 官方全双工参考:omini_backend_code/code/voice_chat/ 下的omni_stream.py、model_call.py、livekit_room.py等\n- Gradio全双工参考:demo/web_demo/gradio/server/models/minicpmo4_5.py"
|
| 76 |
+
}
|
| 77 |
+
],
|
| 78 |
+
"notes": [],
|
| 79 |
+
"createdAt": "2026-06-10 16:58"
|
| 80 |
+
}
|
start_services.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
启动 PregoPal 全双工服务
|
| 3 |
+
========================
|
| 4 |
+
启动顺序:
|
| 5 |
+
1. start_llama_server() — 启动 llama.cpp-omni 的 llama-server(含 omni 支持)
|
| 6 |
+
2. start_api_server() — 启动 PregoAPI 后端
|
| 7 |
+
3. start_gradio() — 启动 PregoPal Gradio 前端
|
| 8 |
+
|
| 9 |
+
用法:
|
| 10 |
+
python start_services.py # 启动全部服务
|
| 11 |
+
python start_services.py --llama # 只启动 llama-server
|
| 12 |
+
python start_services.py --api # 只启动 API
|
| 13 |
+
python start_services.py --gradio # 只启动 Gradio
|
| 14 |
+
"""
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
import json
|
| 19 |
+
import subprocess
|
| 20 |
+
import logging
|
| 21 |
+
import requests as req
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
|
| 25 |
+
logger = logging.getLogger("prego_starter")
|
| 26 |
+
|
| 27 |
+
BASE_DIR = Path(__file__).parent
|
| 28 |
+
LLAMA_SERVER_EXE = r"C:\Users\Andre\codes\LJB\llama.cpp-omni\build\bin\Release\llama-server.exe"
|
| 29 |
+
MODEL_DIR = r"C:\Users\Andre\codes\LJB\llama.cpp-omni\models"
|
| 30 |
+
MAIN_MODEL = os.path.join(MODEL_DIR, "MiniCPM-o-4_5-Q4_K_M.gguf")
|
| 31 |
+
VISION_PROJ = os.path.join(MODEL_DIR, "vision", "MiniCPM-o-4_5-vision-F16.gguf")
|
| 32 |
+
LLAMA_PORT = 8081
|
| 33 |
+
API_PORT = 8090
|
| 34 |
+
GRADIO_PORT = 7880
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def wait_for_server(url: str, timeout: int = 120, interval: float = 1.0) -> bool:
|
| 38 |
+
"""等待 HTTP 服务器启动"""
|
| 39 |
+
start = time.time()
|
| 40 |
+
while time.time() - start < timeout:
|
| 41 |
+
try:
|
| 42 |
+
r = req.get(url, timeout=2)
|
| 43 |
+
if r.status_code == 200:
|
| 44 |
+
elapsed = time.time() - start
|
| 45 |
+
logger.info(f"✅ 服务就绪 ({elapsed:.0f}s): {url}")
|
| 46 |
+
return True
|
| 47 |
+
except:
|
| 48 |
+
pass
|
| 49 |
+
time.sleep(interval)
|
| 50 |
+
logger.error(f"⛔ 服务启动超时 ({timeout}s): {url}")
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def start_llama_server() -> subprocess.Popen:
|
| 55 |
+
"""启动 llama-server (omni 模式)"""
|
| 56 |
+
logger.info("启动 llama-server...")
|
| 57 |
+
cmd = [
|
| 58 |
+
LLAMA_SERVER_EXE,
|
| 59 |
+
"-m", MAIN_MODEL,
|
| 60 |
+
"--mmproj", VISION_PROJ,
|
| 61 |
+
"-c", "8192",
|
| 62 |
+
"--temp", "0.7",
|
| 63 |
+
"--repeat-penalty", "1.05",
|
| 64 |
+
"--host", "127.0.0.1",
|
| 65 |
+
"--port", str(LLAMA_PORT),
|
| 66 |
+
"-ngl", "99",
|
| 67 |
+
]
|
| 68 |
+
proc = subprocess.Popen(
|
| 69 |
+
cmd,
|
| 70 |
+
stdout=subprocess.PIPE,
|
| 71 |
+
stderr=subprocess.STDOUT,
|
| 72 |
+
bufsize=1,
|
| 73 |
+
encoding="utf-8",
|
| 74 |
+
errors="replace",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if wait_for_server(f"http://127.0.0.1:{LLAMA_PORT}/health", timeout=180):
|
| 78 |
+
return proc
|
| 79 |
+
else:
|
| 80 |
+
proc.kill()
|
| 81 |
+
raise RuntimeError("llama-server 启动失败")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def start_api_server() -> subprocess.Popen:
|
| 85 |
+
"""启动 PregoAPI 后端"""
|
| 86 |
+
logger.info("启动 PregoAPI 后端...")
|
| 87 |
+
python = r"C:\Users\Andre\miniconda3\envs\trader_stable\python.exe"
|
| 88 |
+
api_script = str(BASE_DIR / "api" / "go_server.py")
|
| 89 |
+
|
| 90 |
+
# 设置环境变量
|
| 91 |
+
env = os.environ.copy()
|
| 92 |
+
env["LLAMA_SERVER_URL"] = f"http://127.0.0.1:{LLAMA_PORT}"
|
| 93 |
+
env["OMNI_OUTPUT_DIR"] = str(BASE_DIR / "omni_output")
|
| 94 |
+
|
| 95 |
+
proc = subprocess.Popen(
|
| 96 |
+
[python, api_script],
|
| 97 |
+
env=env,
|
| 98 |
+
stdout=subprocess.PIPE,
|
| 99 |
+
stderr=subprocess.STDOUT,
|
| 100 |
+
bufsize=1,
|
| 101 |
+
encoding="utf-8",
|
| 102 |
+
errors="replace",
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
if wait_for_server(f"http://127.0.0.1:{API_PORT}/health", timeout=30):
|
| 106 |
+
return proc
|
| 107 |
+
else:
|
| 108 |
+
proc.kill()
|
| 109 |
+
raise RuntimeError("API 服务启动失败")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def start_gradio() -> subprocess.Popen:
|
| 113 |
+
"""启动 Gradio 前端(使用现有 app.py)"""
|
| 114 |
+
logger.info("启动 Gradio 前端...")
|
| 115 |
+
python = r"C:\Users\Andre\miniconda3\envs\trader_stable\python.exe"
|
| 116 |
+
app_script = str(BASE_DIR / "app.py")
|
| 117 |
+
|
| 118 |
+
env = os.environ.copy()
|
| 119 |
+
# 告诉 app 使用本地 API
|
| 120 |
+
env["MINICPM_API_BASE"] = f"http://127.0.0.1:{API_PORT}"
|
| 121 |
+
|
| 122 |
+
proc = subprocess.Popen(
|
| 123 |
+
[python, app_script],
|
| 124 |
+
env=env,
|
| 125 |
+
stdout=subprocess.PIPE,
|
| 126 |
+
stderr=subprocess.STDOUT,
|
| 127 |
+
bufsize=1,
|
| 128 |
+
encoding="utf-8",
|
| 129 |
+
errors="replace",
|
| 130 |
+
)
|
| 131 |
+
return proc
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
import argparse
|
| 136 |
+
parser = argparse.ArgumentParser(description="PregoPal 服务管理器")
|
| 137 |
+
parser.add_argument("--llama", action="store_true", help="只启动 llama-server")
|
| 138 |
+
parser.add_argument("--api", action="store_true", help="只启动 API 后端")
|
| 139 |
+
parser.add_argument("--gradio", action="store_true", help="只启动 Gradio 前端")
|
| 140 |
+
args = parser.parse_args()
|
| 141 |
+
|
| 142 |
+
should_llama = args.llama or not (args.api or args.gradio)
|
| 143 |
+
should_api = args.api or not (args.llama or args.gradio)
|
| 144 |
+
should_gradio = args.gradio or not (args.llama or args.api)
|
| 145 |
+
|
| 146 |
+
processes = []
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
if should_llama:
|
| 150 |
+
logger.info("=" * 50)
|
| 151 |
+
logger.info("1️⃣ 启动 llama-server...")
|
| 152 |
+
logger.info("=" * 50)
|
| 153 |
+
llama_proc = start_llama_server()
|
| 154 |
+
processes.append(("llama-server", llama_proc))
|
| 155 |
+
|
| 156 |
+
if should_api:
|
| 157 |
+
logger.info("=" * 50)
|
| 158 |
+
logger.info("2️⃣ 启动 PregoAPI 后端...")
|
| 159 |
+
logger.info("=" * 50)
|
| 160 |
+
api_proc = start_api_server()
|
| 161 |
+
processes.append(("PregoAPI", api_proc))
|
| 162 |
+
|
| 163 |
+
if should_gradio:
|
| 164 |
+
logger.info("=" * 50)
|
| 165 |
+
logger.info("3️⃣ 启动 Gradio 前端...")
|
| 166 |
+
logger.info("=" * 50)
|
| 167 |
+
gradio_proc = start_gradio()
|
| 168 |
+
processes.append(("Gradio", gradio_proc))
|
| 169 |
+
logger.info(f"🌐 请访问: http://127.0.0.1:{GRADIO_PORT}")
|
| 170 |
+
|
| 171 |
+
logger.info("=" * 50)
|
| 172 |
+
logger.info("✅ 所有服务启动完成! 按 Ctrl+C 停止")
|
| 173 |
+
logger.info("=" * 50)
|
| 174 |
+
|
| 175 |
+
# 保持运行
|
| 176 |
+
for name, proc in processes:
|
| 177 |
+
try:
|
| 178 |
+
for line in proc.stdout:
|
| 179 |
+
print(f"[{name}] {line.rstrip()}")
|
| 180 |
+
except:
|
| 181 |
+
pass
|
| 182 |
+
|
| 183 |
+
except KeyboardInterrupt:
|
| 184 |
+
logger.info("\n正在停止服务...")
|
| 185 |
+
finally:
|
| 186 |
+
for name, proc in processes:
|
| 187 |
+
logger.info(f"停止 {name}...")
|
| 188 |
+
proc.terminate()
|
| 189 |
+
try:
|
| 190 |
+
proc.wait(timeout=5)
|
| 191 |
+
except:
|
| 192 |
+
proc.kill()
|
| 193 |
+
logger.info("所有服务已停止")
|
ui/app_builder.py
CHANGED
|
@@ -1,20 +1,19 @@
|
|
| 1 |
"""
|
| 2 |
PregoPal - Gradio 界面构建(现代UI版)
|
| 3 |
======================================
|
| 4 |
-
3 个子页面 + 中英文切换
|
| 5 |
-
1. 🏠 首页(语音启动按钮 + AI 思考 + 简报卡片 + 最近记录)
|
| 6 |
-
2. 👨👩👧👦 家庭饮食习惯(纯展示卡片)
|
| 7 |
-
3. 营养报告(含三天缺失深度分析,纯 HTML Dashboard)
|
| 8 |
|
| 9 |
-
|
| 10 |
-
用
|
| 11 |
-
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import datetime
|
| 15 |
import gradio as gr
|
| 16 |
import time
|
| 17 |
import asyncio
|
|
|
|
| 18 |
|
| 19 |
from modules.voiceprint import VoiceprintManager
|
| 20 |
from modules.meal_recommender import MealRecommender
|
|
@@ -32,6 +31,7 @@ from utils import (
|
|
| 32 |
render_family_memories_html,
|
| 33 |
render_nutrition_report_html,
|
| 34 |
)
|
|
|
|
| 35 |
|
| 36 |
# ============================================================
|
| 37 |
# 全局实例(单例)
|
|
@@ -43,27 +43,150 @@ nutrition_analyzer = NutritionAnalyzer()
|
|
| 43 |
|
| 44 |
|
| 45 |
# ============================================================
|
| 46 |
-
# 语音会话
|
| 47 |
# ============================================================
|
| 48 |
-
async def start_voice_session(audio_data
|
| 49 |
"""
|
| 50 |
-
语音会话入口
|
| 51 |
-
|
| 52 |
Args:
|
| 53 |
-
audio_data: Gradio Audio 组件
|
| 54 |
-
|
| 55 |
-
|
|
|
|
| 56 |
Returns:
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
"""
|
| 64 |
-
# TODO: 接入 Llama 模型后替换此函数体
|
| 65 |
-
# 当前 pass 占位,返回空结果避免报错
|
| 66 |
-
pass
|
| 67 |
|
| 68 |
|
| 69 |
# ============================================================
|
|
@@ -76,14 +199,13 @@ def _home_texts(lang):
|
|
| 76 |
"title": "# 🌸 欢迎来到 PregoPal",
|
| 77 |
"subtitle": "### 你的孕期AI伴侣,时刻陪伴在你身边",
|
| 78 |
"tap_speak": "🎙️ 点击说话",
|
| 79 |
-
"tap_hint": "🎤
|
| 80 |
"trimester_label": "孕期阶段",
|
| 81 |
"nutrition_focus": "营养关注",
|
| 82 |
"today_diet": "昨日饮食",
|
| 83 |
"family_recipe": "家庭菜谱",
|
| 84 |
"weight_label": "体重管理",
|
| 85 |
-
"thinking_label": "
|
| 86 |
-
"thinking_placeholder": "等待对话中...",
|
| 87 |
"thinking_waiting": "等待对话中...",
|
| 88 |
"recent_title": "最近的饮食记录",
|
| 89 |
"meal_count_unit": "餐",
|
|
@@ -97,14 +219,13 @@ def _home_texts(lang):
|
|
| 97 |
"title": "# 🌸 Welcome to PregoPal",
|
| 98 |
"subtitle": "### Your AI pregnancy companion, always by your side",
|
| 99 |
"tap_speak": "🎙️ Tap to Speak",
|
| 100 |
-
"tap_hint": "🎤
|
| 101 |
"trimester_label": "Trimester",
|
| 102 |
"nutrition_focus": "Nutrition Focus",
|
| 103 |
"today_diet": "Yesterday's Diet",
|
| 104 |
"family_recipe": "Family Recipes",
|
| 105 |
"weight_label": "Weight",
|
| 106 |
-
"thinking_label": "
|
| 107 |
-
"thinking_placeholder": "Waiting for conversation...",
|
| 108 |
"thinking_waiting": "Waiting for conversation...",
|
| 109 |
"recent_title": "Recent Diet Records",
|
| 110 |
"meal_count_unit": " meals",
|
|
@@ -123,7 +244,7 @@ def _render_recent_records_html(records, lang="zh"):
|
|
| 123 |
if not records:
|
| 124 |
T = _home_texts(lang)
|
| 125 |
return f'<div style="color:#999;padding:12px;text-align:center;font-size:15px;">{T["no_record"]}</div>'
|
| 126 |
-
|
| 127 |
rows = ""
|
| 128 |
for r in records[-6:]:
|
| 129 |
date = r.get("date", "")[-5:] if r.get("date") else ""
|
|
@@ -139,11 +260,11 @@ def _render_recent_records_html(records, lang="zh"):
|
|
| 139 |
<td style="white-space:nowrap;">{member}</td>
|
| 140 |
<td>{meal_str}</td>
|
| 141 |
</tr>"""
|
| 142 |
-
|
| 143 |
label_date = "日期" if lang == "zh" else "Date"
|
| 144 |
label_member = "成员" if lang == "zh" else "Member"
|
| 145 |
label_meals = "餐食" if lang == "zh" else "Meals"
|
| 146 |
-
|
| 147 |
return f"""
|
| 148 |
<div style="overflow-x:auto;">
|
| 149 |
<table style="width:100%;border-collapse:collapse;font-size:14px;font-family:inherit;">
|
|
@@ -163,77 +284,70 @@ def _render_recent_records_html(records, lang="zh"):
|
|
| 163 |
|
| 164 |
|
| 165 |
# ============================================================
|
| 166 |
-
# Tab 1: 🏠 首页内容
|
| 167 |
# ============================================================
|
| 168 |
def _home_content(loop, lang):
|
| 169 |
-
"""首页组件"""
|
| 170 |
T = _home_texts(lang)
|
| 171 |
cards = get_home_cards(loop)
|
| 172 |
-
|
| 173 |
# 营养关注
|
| 174 |
nutrition_display = "、".join(cards["focus_nutrients"][:5]) if cards["focus_nutrients"] else T["no_data"]
|
| 175 |
if cards["recommended_foods"]:
|
| 176 |
food_str = "、".join(cards["recommended_foods"][:5])
|
| 177 |
nutrition_display += f"\n\n🍽️ 推荐:{food_str}"
|
| 178 |
-
|
| 179 |
# 昨日饮食
|
| 180 |
diet_display = cards["yesterday_summary"]
|
| 181 |
if cards["meal_count"] > 0:
|
| 182 |
diet_display = f"{diet_display}\n({cards['meal_count']}{T['meal_count_unit']})"
|
| 183 |
-
|
| 184 |
# 家庭菜谱
|
| 185 |
if cards["recipe_count"] > 0:
|
| 186 |
recipe_display = f"{cards['recipe_count']}{T['recipes_unit']}:{'、'.join(cards['recipe_names'])}"
|
| 187 |
else:
|
| 188 |
recipe_display = T["no_recipe"]
|
| 189 |
-
|
| 190 |
# 体重
|
| 191 |
weight_display = cards["weight_status"]
|
| 192 |
if cards["weight_trend"]:
|
| 193 |
weight_display += f" | {cards['weight_trend']}"
|
| 194 |
-
|
| 195 |
-
thinking_text = cards["thinking_keywords"] if cards["thinking_keywords"] else T["thinking_waiting"]
|
| 196 |
-
|
| 197 |
records = diet_logger.get_recent_records(days=3)
|
| 198 |
recent_html = _render_recent_records_html(records, lang)
|
| 199 |
-
|
| 200 |
with gr.Column(elem_classes=["home-container"]):
|
| 201 |
gr.Markdown(T["title"])
|
| 202 |
gr.Markdown(T["subtitle"])
|
| 203 |
-
|
| 204 |
-
#
|
| 205 |
-
with gr.
|
| 206 |
-
with gr.
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
<div style="font-weight:600;color:#6A1B9A;">{T['thinking_label']}</div>
|
| 233 |
-
<div style="color:#888;font-size:14px;margin-top:4px;">{thinking_text}</div>
|
| 234 |
-
</div>
|
| 235 |
-
""")
|
| 236 |
-
|
| 237 |
# 卡片行 1
|
| 238 |
with gr.Row():
|
| 239 |
with gr.Column(scale=1):
|
|
@@ -248,7 +362,7 @@ def _home_content(loop, lang):
|
|
| 248 |
with gr.Group(elem_classes=["home-card", "card-diet"]):
|
| 249 |
gr.Markdown(f"### 🍽️ {T['today_diet']}")
|
| 250 |
gr.Markdown(diet_display)
|
| 251 |
-
|
| 252 |
# 卡片行 2
|
| 253 |
with gr.Row():
|
| 254 |
with gr.Column(scale=1):
|
|
@@ -259,23 +373,36 @@ def _home_content(loop, lang):
|
|
| 259 |
with gr.Group(elem_classes=["home-card", "card-weight"]):
|
| 260 |
gr.Markdown(f"### ⚖️ {T['weight_label']}")
|
| 261 |
gr.Markdown(weight_display)
|
| 262 |
-
|
| 263 |
# 最近饮食记录
|
| 264 |
with gr.Group(elem_classes=["home-card"]):
|
| 265 |
gr.Markdown(f"### 📋 {T['recent_title']}")
|
| 266 |
gr.HTML(recent_html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
# ============================================================
|
| 270 |
-
# Tab 2: 👨👩👧👦 家庭饮食习惯
|
| 271 |
# ============================================================
|
| 272 |
def _family_content(lang):
|
| 273 |
"""家庭饮食习惯组件 — 纯展示模式"""
|
| 274 |
with gr.Column(elem_classes=["glass-card"]):
|
| 275 |
gr.Markdown(f"## {t('family_title', lang)}")
|
| 276 |
gr.Markdown(f"*{t('family_subtitle', lang)}*")
|
| 277 |
-
|
| 278 |
-
# 默认选中第一个 Tab(家庭菜谱)
|
| 279 |
with gr.Tabs(selected=0):
|
| 280 |
with gr.Tab(t("tab_recipes", lang)):
|
| 281 |
recipes_html = gr.HTML(render_family_recipes_html(lang))
|
|
@@ -284,7 +411,7 @@ def _family_content(lang):
|
|
| 284 |
fn=lambda: render_family_recipes_html(lang),
|
| 285 |
outputs=recipes_html,
|
| 286 |
)
|
| 287 |
-
|
| 288 |
with gr.Tab(t("tab_preferences", lang)):
|
| 289 |
prefs_html = gr.HTML(render_family_preferences_html(lang))
|
| 290 |
refresh_pref_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
|
@@ -292,7 +419,7 @@ def _family_content(lang):
|
|
| 292 |
fn=lambda: render_family_preferences_html(lang),
|
| 293 |
outputs=prefs_html,
|
| 294 |
)
|
| 295 |
-
|
| 296 |
with gr.Tab(t("tab_memory", lang)):
|
| 297 |
mem_html = gr.HTML(render_family_memories_html(lang))
|
| 298 |
refresh_mem_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
|
@@ -303,21 +430,18 @@ def _family_content(lang):
|
|
| 303 |
|
| 304 |
|
| 305 |
# ============================================================
|
| 306 |
-
# Tab 3:
|
| 307 |
# ============================================================
|
| 308 |
def _report_content(lang):
|
| 309 |
-
"""营养报告组件
|
| 310 |
-
# 防抖时间戳
|
| 311 |
_last_change = [0.0]
|
| 312 |
-
|
| 313 |
with gr.Column():
|
| 314 |
-
# 控制栏:天数 + 孕期阶段(紧凑行)
|
| 315 |
with gr.Row():
|
| 316 |
with gr.Column(scale=2, min_width=200):
|
| 317 |
analysis_days = gr.Slider(
|
| 318 |
label=t("analysis_days", lang),
|
| 319 |
minimum=1, maximum=30, value=7, step=1,
|
| 320 |
-
interactive=True,
|
| 321 |
)
|
| 322 |
with gr.Column(scale=1, min_width=160):
|
| 323 |
trimester_select = gr.Radio(
|
|
@@ -325,20 +449,15 @@ def _report_content(lang):
|
|
| 325 |
choices=ZH["trimester_choices"] if lang == "zh" else EN["trimester_choices"],
|
| 326 |
value=ZH["trimester_choices"][1] if lang == "zh" else EN["trimester_choices"][1],
|
| 327 |
)
|
| 328 |
-
|
| 329 |
-
# 报告全宽
|
| 330 |
report_dashboard = gr.HTML(
|
| 331 |
f'<div style="padding:40px;text-align:center;color:#bbb;font-size:16px;">'
|
| 332 |
f'调整上方参数自动生成报告</div>'
|
| 333 |
)
|
| 334 |
-
|
| 335 |
def generate_full_report(days, trimester):
|
| 336 |
-
"""生成营养报告(含三天缺失深度分析,框架层面合并)"""
|
| 337 |
-
# — 基础营养报告 —
|
| 338 |
records = diet_logger.get_recent_records(days=int(days))
|
| 339 |
analysis = nutrition_analyzer.analyze_diet(records)
|
| 340 |
-
|
| 341 |
-
# — 三天缺失深度分析(调用插件,数据传入 render_nutrition_report_html 统一渲染) —
|
| 342 |
try:
|
| 343 |
ctx = LoopContext()
|
| 344 |
ctx.briefing["trimester"] = trimester
|
|
@@ -346,19 +465,15 @@ def _report_content(lang):
|
|
| 346 |
deficit_data = ctx.briefing.get("three_day_summary", {})
|
| 347 |
except Exception:
|
| 348 |
deficit_data = {}
|
| 349 |
-
|
| 350 |
-
# 所有内容由 render_nutrition_report_html 统一渲染(框架层面合并)
|
| 351 |
return render_nutrition_report_html(analysis, days=int(days), lang=lang, deficit_data=deficit_data)
|
| 352 |
-
|
| 353 |
def on_slider_change(days, trimester):
|
| 354 |
-
"""Slider/Radio 变化时防抖 0.8s 后生成报告"""
|
| 355 |
_last_change[0] = time.time()
|
| 356 |
time.sleep(0.8)
|
| 357 |
if time.time() - _last_change[0] < 0.9:
|
| 358 |
return generate_full_report(days, trimester)
|
| 359 |
return gr.skip()
|
| 360 |
-
|
| 361 |
-
# 两个输入都触发重新生成
|
| 362 |
analysis_days.change(
|
| 363 |
fn=on_slider_change,
|
| 364 |
inputs=[analysis_days, trimester_select],
|
|
@@ -372,29 +487,25 @@ def _report_content(lang):
|
|
| 372 |
|
| 373 |
|
| 374 |
# ============================================================
|
| 375 |
-
# 主入口
|
| 376 |
# ============================================================
|
| 377 |
-
|
| 378 |
def create_app(loop=None):
|
| 379 |
-
"""
|
| 380 |
-
创建主应用(3 Tab + 中英文切换 + 自定义 CSS)。
|
| 381 |
-
"""
|
| 382 |
with gr.Blocks(
|
| 383 |
title="PregoPal - 孕期陪护AI助手",
|
| 384 |
) as demo:
|
| 385 |
-
|
| 386 |
-
# 语言状态
|
| 387 |
lang_state = gr.State(value="zh")
|
| 388 |
-
|
| 389 |
-
# 顶部标题
|
| 390 |
with gr.Row():
|
| 391 |
with gr.Column(scale=4):
|
| 392 |
gr.HTML("""
|
| 393 |
<div style="text-align: left; padding: 8px 0;">
|
| 394 |
<h1 style="margin: 0; font-size: 28px; font-family: 'Nunito', 'Quicksand', system-ui, sans-serif;">🌸 PregoPal</h1>
|
| 395 |
<p style="color: #999; margin: 4px 0 0 0; font-size: 16px; font-family: 'Nunito', 'Quicksand', system-ui, sans-serif;">
|
| 396 |
-
孕期陪护AI助手 —
|
| 397 |
-
<br><span style="font-size: 14px; color: #bbb;">Pregnancy Companion AI —
|
| 398 |
</p>
|
| 399 |
</div>
|
| 400 |
<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">
|
|
@@ -406,32 +517,22 @@ def create_app(loop=None):
|
|
| 406 |
value="中文",
|
| 407 |
interactive=True,
|
| 408 |
)
|
| 409 |
-
|
| 410 |
def switch_lang(choice):
|
| 411 |
return "zh" if choice == "中文" else "en"
|
| 412 |
-
|
| 413 |
lang_selector.change(fn=switch_lang, inputs=[lang_selector], outputs=[lang_state])
|
| 414 |
-
|
| 415 |
-
# ========== 唯一 @gr.render 包裹所有 Tab ==========
|
| 416 |
@gr.render(inputs=[lang_state])
|
| 417 |
def render_all(lang):
|
| 418 |
with gr.Tabs():
|
| 419 |
-
with gr.Tab(
|
| 420 |
-
f"🏠 {t('tab_home_suffix', lang)}",
|
| 421 |
-
id="tab_home"
|
| 422 |
-
):
|
| 423 |
_home_content(loop, lang)
|
| 424 |
-
|
| 425 |
-
with gr.Tab(
|
| 426 |
-
f"👨👩👧👦 {t('tab_family_suffix', lang)}",
|
| 427 |
-
id="tab_family"
|
| 428 |
-
):
|
| 429 |
_family_content(lang)
|
| 430 |
-
|
| 431 |
-
with gr.Tab(
|
| 432 |
-
f"📈 {t('tab_report_suffix', lang)}",
|
| 433 |
-
id="tab_report"
|
| 434 |
-
):
|
| 435 |
_report_content(lang)
|
| 436 |
-
|
| 437 |
-
return demo
|
|
|
|
| 1 |
"""
|
| 2 |
PregoPal - Gradio 界面构建(现代UI版)
|
| 3 |
======================================
|
| 4 |
+
3 个子页面 + 中英文切换 + 全双工语音对话(通过 llama-server omni 后端)
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
全双工工作流:
|
| 7 |
+
用户点击录音 → Gradio Audio 组件录制 → voice_helper.chat_voice() →
|
| 8 |
+
llama-server omni_init/prefill/decode → AI文本+TTS音频 →
|
| 9 |
+
显示在聊天框 + 播放语音
|
| 10 |
"""
|
| 11 |
|
| 12 |
import datetime
|
| 13 |
import gradio as gr
|
| 14 |
import time
|
| 15 |
import asyncio
|
| 16 |
+
import traceback
|
| 17 |
|
| 18 |
from modules.voiceprint import VoiceprintManager
|
| 19 |
from modules.meal_recommender import MealRecommender
|
|
|
|
| 31 |
render_family_memories_html,
|
| 32 |
render_nutrition_report_html,
|
| 33 |
)
|
| 34 |
+
from api.voice_helper import chat_text, chat_voice, omni_status
|
| 35 |
|
| 36 |
# ============================================================
|
| 37 |
# 全局实例(单例)
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
# ============================================================
|
| 46 |
+
# 全双工语音会话(核心函数)
|
| 47 |
# ============================================================
|
| 48 |
+
async def start_voice_session(audio_data, chat_history, lang="zh"):
|
| 49 |
"""
|
| 50 |
+
全双工语音会话入口。
|
| 51 |
+
|
| 52 |
Args:
|
| 53 |
+
audio_data: Gradio Audio 组件的 (sample_rate, numpy_array) 或文件路径
|
| 54 |
+
chat_history: 聊天历史 [[user_msg, ai_msg], ...]
|
| 55 |
+
lang: "zh" / "en"
|
| 56 |
+
|
| 57 |
Returns:
|
| 58 |
+
tuple: (chat_history_updated, thinking_html, audio_path_for_playback)
|
| 59 |
+
"""
|
| 60 |
+
if chat_history is None:
|
| 61 |
+
chat_history = []
|
| 62 |
+
|
| 63 |
+
thinking_html = _thinking_html("聆听中...🔊" if lang == "zh" else "Listening...🔊")
|
| 64 |
+
yield chat_history, thinking_html, None
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
# 解析音频数据
|
| 68 |
+
audio_np = None
|
| 69 |
+
if audio_data is not None:
|
| 70 |
+
if isinstance(audio_data, tuple) and len(audio_data) == 2:
|
| 71 |
+
sr, audio_arr = audio_data
|
| 72 |
+
audio_np = audio_arr
|
| 73 |
+
elif isinstance(audio_data, str) and audio_data:
|
| 74 |
+
import soundfile as sf
|
| 75 |
+
audio_np, sr = sf.read(audio_data, dtype='float32')
|
| 76 |
+
else:
|
| 77 |
+
yield chat_history, _thinking_html("音频格式错误" if lang == "zh" else "Audio error"), None
|
| 78 |
+
return
|
| 79 |
+
|
| 80 |
+
if audio_np is None or len(audio_np) == 0:
|
| 81 |
+
yield chat_history, _thinking_html("点击话筒开始说话" if lang == "zh" else "Tap mic to speak"), None
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
# 短音频提示
|
| 85 |
+
if len(audio_np) < 1600: # < 0.1s
|
| 86 |
+
yield chat_history, _thinking_html("声音太短,请再说一遍" if lang == "zh" else "Too short, please repeat"), None
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
+
# AI 思考状态
|
| 90 |
+
yield chat_history, _thinking_html("🤔 AI 思考中..."), None
|
| 91 |
+
|
| 92 |
+
# 保存音频到临时文件(供 chat_voice 使用)
|
| 93 |
+
import soundfile as sf
|
| 94 |
+
import tempfile
|
| 95 |
+
import os
|
| 96 |
+
|
| 97 |
+
temp_fd, temp_path = tempfile.mkstemp(suffix=".wav")
|
| 98 |
+
os.close(temp_fd)
|
| 99 |
+
try:
|
| 100 |
+
# 确保 16kHz
|
| 101 |
+
if sr != 16000:
|
| 102 |
+
try:
|
| 103 |
+
import librosa
|
| 104 |
+
audio_np = librosa.resample(audio_np, orig_sr=sr, target_sr=16000)
|
| 105 |
+
except ImportError:
|
| 106 |
+
pass
|
| 107 |
+
sf.write(temp_path, audio_np, 16000, format='WAV', subtype='PCM_16')
|
| 108 |
+
|
| 109 |
+
# 调用后端
|
| 110 |
+
result = chat_voice(temp_path)
|
| 111 |
+
|
| 112 |
+
finally:
|
| 113 |
+
try:
|
| 114 |
+
os.remove(temp_path)
|
| 115 |
+
except:
|
| 116 |
+
pass
|
| 117 |
+
|
| 118 |
+
system_prompt = ("你是PregoPal孕期营养健康顾问。请用中文简短回答。"
|
| 119 |
+
"如果用户提到饮食,用[EXTRACT_DIET]标记。"
|
| 120 |
+
"如果用户提到家庭成员,用[EXTRACT_FAMILY]标记。")
|
| 121 |
+
|
| 122 |
+
# 处理结果
|
| 123 |
+
if result.get("success"):
|
| 124 |
+
ai_text = result.get("text", "").strip()
|
| 125 |
+
|
| 126 |
+
# 如果后端没有返回文本,回退到文本对话
|
| 127 |
+
if not ai_text or len(ai_text) < 2:
|
| 128 |
+
yield chat_history, _thinking_html("🤔 生成回答中..."), None
|
| 129 |
+
|
| 130 |
+
# 构建上下文
|
| 131 |
+
msgs = [{"role": "system", "content": system_prompt}]
|
| 132 |
+
for h in chat_history[-4:]: # 保留最近 2 轮
|
| 133 |
+
msgs.append({"role": "user", "content": h[0] if h[0] else "..."})
|
| 134 |
+
msgs.append({"role": "assistant", "content": h[1] if h[1] else "..."})
|
| 135 |
+
msgs.append({"role": "user", "content": "我刚刚和你说话(语音输入)"})
|
| 136 |
+
|
| 137 |
+
ai_text = chat_text(msgs)
|
| 138 |
+
|
| 139 |
+
# 添加用户消息和 AI 回复
|
| 140 |
+
user_label = "您" if lang == "zh" else "You"
|
| 141 |
+
chat_history.append([f"🗣️ {user_label}: (语音输入)", ai_text])
|
| 142 |
+
|
| 143 |
+
# 解析标记
|
| 144 |
+
text_clean = ai_text
|
| 145 |
+
if "[EXTRACT_DIET]" in ai_text:
|
| 146 |
+
from modules.diet_extractor import DietExtractor
|
| 147 |
+
extractor = DietExtractor()
|
| 148 |
+
extracted = extractor.extract_all(ai_text)
|
| 149 |
+
diet_logger.log_diet(
|
| 150 |
+
member_id="ai",
|
| 151 |
+
member_name="AI识别",
|
| 152 |
+
meals=extracted.get("meals", {}),
|
| 153 |
+
notes=f"语音对话识别: {datetime.date.today()}"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
audio_playback = None
|
| 157 |
+
if result.get("audio_base64"):
|
| 158 |
+
# 解码 TTS 音频用于播放
|
| 159 |
+
import base64, io
|
| 160 |
+
try:
|
| 161 |
+
audio_bytes = base64.b64decode(result["audio_base64"])
|
| 162 |
+
yield chat_history, _thinking_html(""), audio_bytes
|
| 163 |
+
return
|
| 164 |
+
except:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
+
# 没有 TTS 就只返回文本
|
| 168 |
+
yield chat_history, _thinking_html("已就绪" if lang == "zh" else "Ready"), None
|
| 169 |
+
else:
|
| 170 |
+
error_msg = result.get("error", "未知错误")
|
| 171 |
+
yield chat_history, _thinking_html(f"语音处理失败: {error_msg}"), None
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
traceback.print_exc()
|
| 175 |
+
yield chat_history, _thinking_html(f"错误: {str(e)[:50]}"), None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _thinking_html(text: str) -> str:
|
| 179 |
+
"""AI 思考状态的 HTML"""
|
| 180 |
+
color = "#6A1B9A" if "思考" in text or "Thinking" in text else "#888"
|
| 181 |
+
animation = ""
|
| 182 |
+
if "思考" in text or "Thinking" in text or "聆听" in text or "Listening" in text:
|
| 183 |
+
animation = '<span class="thinking-dot">.</span>'.replace(".",
|
| 184 |
+
'<span style="animation:blink 1.4s infinite">.</span>')
|
| 185 |
+
return f"""
|
| 186 |
+
<div class="thinking-box">
|
| 187 |
+
<div style="font-weight:600;color:{color};">{text}</div>
|
| 188 |
+
</div>
|
| 189 |
"""
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
|
| 192 |
# ============================================================
|
|
|
|
| 199 |
"title": "# 🌸 欢迎来到 PregoPal",
|
| 200 |
"subtitle": "### 你的孕期AI伴侣,时刻陪伴在你身边",
|
| 201 |
"tap_speak": "🎙️ 点击说话",
|
| 202 |
+
"tap_hint": "🎤 按住录音,松开发送",
|
| 203 |
"trimester_label": "孕期阶段",
|
| 204 |
"nutrition_focus": "营养关注",
|
| 205 |
"today_diet": "昨日饮食",
|
| 206 |
"family_recipe": "家庭菜谱",
|
| 207 |
"weight_label": "体重管理",
|
| 208 |
+
"thinking_label": "💬 AI 对话",
|
|
|
|
| 209 |
"thinking_waiting": "等待对话中...",
|
| 210 |
"recent_title": "最近的饮食记录",
|
| 211 |
"meal_count_unit": "餐",
|
|
|
|
| 219 |
"title": "# 🌸 Welcome to PregoPal",
|
| 220 |
"subtitle": "### Your AI pregnancy companion, always by your side",
|
| 221 |
"tap_speak": "🎙️ Tap to Speak",
|
| 222 |
+
"tap_hint": "🎤 Hold to record, release to send",
|
| 223 |
"trimester_label": "Trimester",
|
| 224 |
"nutrition_focus": "Nutrition Focus",
|
| 225 |
"today_diet": "Yesterday's Diet",
|
| 226 |
"family_recipe": "Family Recipes",
|
| 227 |
"weight_label": "Weight",
|
| 228 |
+
"thinking_label": "💬 AI Chat",
|
|
|
|
| 229 |
"thinking_waiting": "Waiting for conversation...",
|
| 230 |
"recent_title": "Recent Diet Records",
|
| 231 |
"meal_count_unit": " meals",
|
|
|
|
| 244 |
if not records:
|
| 245 |
T = _home_texts(lang)
|
| 246 |
return f'<div style="color:#999;padding:12px;text-align:center;font-size:15px;">{T["no_record"]}</div>'
|
| 247 |
+
|
| 248 |
rows = ""
|
| 249 |
for r in records[-6:]:
|
| 250 |
date = r.get("date", "")[-5:] if r.get("date") else ""
|
|
|
|
| 260 |
<td style="white-space:nowrap;">{member}</td>
|
| 261 |
<td>{meal_str}</td>
|
| 262 |
</tr>"""
|
| 263 |
+
|
| 264 |
label_date = "日期" if lang == "zh" else "Date"
|
| 265 |
label_member = "成员" if lang == "zh" else "Member"
|
| 266 |
label_meals = "餐食" if lang == "zh" else "Meals"
|
| 267 |
+
|
| 268 |
return f"""
|
| 269 |
<div style="overflow-x:auto;">
|
| 270 |
<table style="width:100%;border-collapse:collapse;font-size:14px;font-family:inherit;">
|
|
|
|
| 284 |
|
| 285 |
|
| 286 |
# ============================================================
|
| 287 |
+
# Tab 1: 🏠 首页内容
|
| 288 |
# ============================================================
|
| 289 |
def _home_content(loop, lang):
|
| 290 |
+
"""首页组件 — 含全双工语音对话"""
|
| 291 |
T = _home_texts(lang)
|
| 292 |
cards = get_home_cards(loop)
|
| 293 |
+
|
| 294 |
# 营养关注
|
| 295 |
nutrition_display = "、".join(cards["focus_nutrients"][:5]) if cards["focus_nutrients"] else T["no_data"]
|
| 296 |
if cards["recommended_foods"]:
|
| 297 |
food_str = "、".join(cards["recommended_foods"][:5])
|
| 298 |
nutrition_display += f"\n\n🍽️ 推荐:{food_str}"
|
| 299 |
+
|
| 300 |
# 昨日饮食
|
| 301 |
diet_display = cards["yesterday_summary"]
|
| 302 |
if cards["meal_count"] > 0:
|
| 303 |
diet_display = f"{diet_display}\n({cards['meal_count']}{T['meal_count_unit']})"
|
| 304 |
+
|
| 305 |
# 家庭菜谱
|
| 306 |
if cards["recipe_count"] > 0:
|
| 307 |
recipe_display = f"{cards['recipe_count']}{T['recipes_unit']}:{'、'.join(cards['recipe_names'])}"
|
| 308 |
else:
|
| 309 |
recipe_display = T["no_recipe"]
|
| 310 |
+
|
| 311 |
# 体重
|
| 312 |
weight_display = cards["weight_status"]
|
| 313 |
if cards["weight_trend"]:
|
| 314 |
weight_display += f" | {cards['weight_trend']}"
|
| 315 |
+
|
|
|
|
|
|
|
| 316 |
records = diet_logger.get_recent_records(days=3)
|
| 317 |
recent_html = _render_recent_records_html(records, lang)
|
| 318 |
+
|
| 319 |
with gr.Column(elem_classes=["home-container"]):
|
| 320 |
gr.Markdown(T["title"])
|
| 321 |
gr.Markdown(T["subtitle"])
|
| 322 |
+
|
| 323 |
+
# 全双工语音对话区
|
| 324 |
+
with gr.Group():
|
| 325 |
+
with gr.Row():
|
| 326 |
+
# 左侧:语音输入按钮
|
| 327 |
+
with gr.Column(scale=1, min_width=300):
|
| 328 |
+
audio_input = gr.Audio(
|
| 329 |
+
sources=["microphone"],
|
| 330 |
+
type="numpy",
|
| 331 |
+
label=T["tap_speak"],
|
| 332 |
+
show_label=True,
|
| 333 |
+
elem_classes=["voice-input"],
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
# 右侧:对话显示
|
| 337 |
+
with gr.Column(scale=2, min_width=400):
|
| 338 |
+
chat_box = gr.Chatbot(
|
| 339 |
+
label=T.get("thinking_label", "💬 AI 对话"),
|
| 340 |
+
height=280,
|
| 341 |
+
show_label=True,
|
| 342 |
+
bubble_full_width=False,
|
| 343 |
+
elem_classes=["chat-box"],
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
# 思考状态
|
| 347 |
+
thinking_display = gr.HTML(
|
| 348 |
+
_thinking_html("已就绪" if lang == "zh" else "Ready")
|
| 349 |
+
)
|
| 350 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
# 卡片行 1
|
| 352 |
with gr.Row():
|
| 353 |
with gr.Column(scale=1):
|
|
|
|
| 362 |
with gr.Group(elem_classes=["home-card", "card-diet"]):
|
| 363 |
gr.Markdown(f"### 🍽️ {T['today_diet']}")
|
| 364 |
gr.Markdown(diet_display)
|
| 365 |
+
|
| 366 |
# 卡片行 2
|
| 367 |
with gr.Row():
|
| 368 |
with gr.Column(scale=1):
|
|
|
|
| 373 |
with gr.Group(elem_classes=["home-card", "card-weight"]):
|
| 374 |
gr.Markdown(f"### ⚖️ {T['weight_label']}")
|
| 375 |
gr.Markdown(weight_display)
|
| 376 |
+
|
| 377 |
# 最近饮食记录
|
| 378 |
with gr.Group(elem_classes=["home-card"]):
|
| 379 |
gr.Markdown(f"### 📋 {T['recent_title']}")
|
| 380 |
gr.HTML(recent_html)
|
| 381 |
+
|
| 382 |
+
# ── 事件绑定 ──
|
| 383 |
+
# 音频输入后触发语音会话
|
| 384 |
+
audio_input.stop_recording(
|
| 385 |
+
fn=start_voice_session,
|
| 386 |
+
inputs=[audio_input, chat_box, gr.State(lang)],
|
| 387 |
+
outputs=[chat_box, thinking_display, gr.State(None)], # 第三个输出留给 audio 播放
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
# 用户选择语言后更新
|
| 391 |
+
audio_input.change(
|
| 392 |
+
fn=lambda: _thinking_html("已就绪" if lang == "zh" else "Ready"),
|
| 393 |
+
outputs=thinking_display,
|
| 394 |
+
)
|
| 395 |
|
| 396 |
|
| 397 |
# ============================================================
|
| 398 |
+
# Tab 2: 👨👩👧👦 家庭饮食习惯
|
| 399 |
# ============================================================
|
| 400 |
def _family_content(lang):
|
| 401 |
"""家庭饮食习惯组件 — 纯展示模式"""
|
| 402 |
with gr.Column(elem_classes=["glass-card"]):
|
| 403 |
gr.Markdown(f"## {t('family_title', lang)}")
|
| 404 |
gr.Markdown(f"*{t('family_subtitle', lang)}*")
|
| 405 |
+
|
|
|
|
| 406 |
with gr.Tabs(selected=0):
|
| 407 |
with gr.Tab(t("tab_recipes", lang)):
|
| 408 |
recipes_html = gr.HTML(render_family_recipes_html(lang))
|
|
|
|
| 411 |
fn=lambda: render_family_recipes_html(lang),
|
| 412 |
outputs=recipes_html,
|
| 413 |
)
|
| 414 |
+
|
| 415 |
with gr.Tab(t("tab_preferences", lang)):
|
| 416 |
prefs_html = gr.HTML(render_family_preferences_html(lang))
|
| 417 |
refresh_pref_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
|
|
|
| 419 |
fn=lambda: render_family_preferences_html(lang),
|
| 420 |
outputs=prefs_html,
|
| 421 |
)
|
| 422 |
+
|
| 423 |
with gr.Tab(t("tab_memory", lang)):
|
| 424 |
mem_html = gr.HTML(render_family_memories_html(lang))
|
| 425 |
refresh_mem_btn = gr.Button(t("btn_refresh", lang), size="sm")
|
|
|
|
| 430 |
|
| 431 |
|
| 432 |
# ============================================================
|
| 433 |
+
# Tab 3: 📈 营养报告
|
| 434 |
# ============================================================
|
| 435 |
def _report_content(lang):
|
| 436 |
+
"""营养报告组件"""
|
|
|
|
| 437 |
_last_change = [0.0]
|
| 438 |
+
|
| 439 |
with gr.Column():
|
|
|
|
| 440 |
with gr.Row():
|
| 441 |
with gr.Column(scale=2, min_width=200):
|
| 442 |
analysis_days = gr.Slider(
|
| 443 |
label=t("analysis_days", lang),
|
| 444 |
minimum=1, maximum=30, value=7, step=1,
|
|
|
|
| 445 |
)
|
| 446 |
with gr.Column(scale=1, min_width=160):
|
| 447 |
trimester_select = gr.Radio(
|
|
|
|
| 449 |
choices=ZH["trimester_choices"] if lang == "zh" else EN["trimester_choices"],
|
| 450 |
value=ZH["trimester_choices"][1] if lang == "zh" else EN["trimester_choices"][1],
|
| 451 |
)
|
| 452 |
+
|
|
|
|
| 453 |
report_dashboard = gr.HTML(
|
| 454 |
f'<div style="padding:40px;text-align:center;color:#bbb;font-size:16px;">'
|
| 455 |
f'调整上方参数自动生成报告</div>'
|
| 456 |
)
|
| 457 |
+
|
| 458 |
def generate_full_report(days, trimester):
|
|
|
|
|
|
|
| 459 |
records = diet_logger.get_recent_records(days=int(days))
|
| 460 |
analysis = nutrition_analyzer.analyze_diet(records)
|
|
|
|
|
|
|
| 461 |
try:
|
| 462 |
ctx = LoopContext()
|
| 463 |
ctx.briefing["trimester"] = trimester
|
|
|
|
| 465 |
deficit_data = ctx.briefing.get("three_day_summary", {})
|
| 466 |
except Exception:
|
| 467 |
deficit_data = {}
|
|
|
|
|
|
|
| 468 |
return render_nutrition_report_html(analysis, days=int(days), lang=lang, deficit_data=deficit_data)
|
| 469 |
+
|
| 470 |
def on_slider_change(days, trimester):
|
|
|
|
| 471 |
_last_change[0] = time.time()
|
| 472 |
time.sleep(0.8)
|
| 473 |
if time.time() - _last_change[0] < 0.9:
|
| 474 |
return generate_full_report(days, trimester)
|
| 475 |
return gr.skip()
|
| 476 |
+
|
|
|
|
| 477 |
analysis_days.change(
|
| 478 |
fn=on_slider_change,
|
| 479 |
inputs=[analysis_days, trimester_select],
|
|
|
|
| 487 |
|
| 488 |
|
| 489 |
# ============================================================
|
| 490 |
+
# 主入口
|
| 491 |
# ============================================================
|
|
|
|
| 492 |
def create_app(loop=None):
|
| 493 |
+
"""创建主应用(3 Tab + 中英文切换 + 全双工语音)"""
|
|
|
|
|
|
|
| 494 |
with gr.Blocks(
|
| 495 |
title="PregoPal - 孕期陪护AI助手",
|
| 496 |
) as demo:
|
| 497 |
+
|
|
|
|
| 498 |
lang_state = gr.State(value="zh")
|
| 499 |
+
|
| 500 |
+
# 顶部标题
|
| 501 |
with gr.Row():
|
| 502 |
with gr.Column(scale=4):
|
| 503 |
gr.HTML("""
|
| 504 |
<div style="text-align: left; padding: 8px 0;">
|
| 505 |
<h1 style="margin: 0; font-size: 28px; font-family: 'Nunito', 'Quicksand', system-ui, sans-serif;">🌸 PregoPal</h1>
|
| 506 |
<p style="color: #999; margin: 4px 0 0 0; font-size: 16px; font-family: 'Nunito', 'Quicksand', system-ui, sans-serif;">
|
| 507 |
+
孕期陪护AI助手 — 全双工语音对话
|
| 508 |
+
<br><span style="font-size: 14px; color: #bbb;">Pregnancy Companion AI — Full Duplex Voice Chat</span>
|
| 509 |
</p>
|
| 510 |
</div>
|
| 511 |
<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">
|
|
|
|
| 517 |
value="中文",
|
| 518 |
interactive=True,
|
| 519 |
)
|
| 520 |
+
|
| 521 |
def switch_lang(choice):
|
| 522 |
return "zh" if choice == "中文" else "en"
|
| 523 |
+
|
| 524 |
lang_selector.change(fn=switch_lang, inputs=[lang_selector], outputs=[lang_state])
|
| 525 |
+
|
|
|
|
| 526 |
@gr.render(inputs=[lang_state])
|
| 527 |
def render_all(lang):
|
| 528 |
with gr.Tabs():
|
| 529 |
+
with gr.Tab(f"🏠 {t('tab_home_suffix', lang)}", id="tab_home"):
|
|
|
|
|
|
|
|
|
|
| 530 |
_home_content(loop, lang)
|
| 531 |
+
|
| 532 |
+
with gr.Tab(f"👨👩👧👦 {t('tab_family_suffix', lang)}", id="tab_family"):
|
|
|
|
|
|
|
|
|
|
| 533 |
_family_content(lang)
|
| 534 |
+
|
| 535 |
+
with gr.Tab(f"📈 {t('tab_report_suffix', lang)}", id="tab_report"):
|
|
|
|
|
|
|
|
|
|
| 536 |
_report_content(lang)
|
| 537 |
+
|
| 538 |
+
return demo
|