Преглед изворни кода

feat(health-butler): 完成 Task 5 AI健康管家MVP

- cfc-langgraph: 新增 POST /api/v1/health/butler 端点 (adapter.py)
  调用 health_butler_graph,支持知识库检索+个性化建议+任务生成
- cfc-frontend: 新增 aiSendHealthButlerMessage() (api.js)
  输入 childId/familyId,传给 LangGraph /health/butler 端点
- cfc-frontend: mode='butler' 完整支持 chat.vue
  isButler computed / 导航标题 / 欢迎语+3个快速问题 / 🏥头像 / sendMessage分支
- cfc-frontend: 身体页功能列表新增「🏥 健康管家」入口
  导航到 /pages/ai/chat?mode=butler&fromPage=pages/body/index
Xiaogang Liao пре 3 месеци
родитељ
комит
593064f370

+ 34 - 5
cfc-frontend/pages/ai/chat.vue

@@ -5,7 +5,7 @@
       <view class="nav-back" @click="goBack">
         <text class="nav-back-icon">‹</text>
       </view>
-      <text class="nav-title">{{ isNutrition ? '精准营养助手' : (isHealthCoach ? 'AI 健康教练' : 'AI 家庭助手') }}</text>
+      <text class="nav-title">{{ isNutrition ? '精准营养助手' : (isHealthCoach ? 'AI 健康教练' : (isButler ? 'AI 健康管家' : 'AI 家庭助手')) }}</text>
       <view class="nav-actions">
         <view class="mascot-badge" v-if="mascotCode && !isNutrition" @click="showConversations = !showConversations">
           <text class="mascot-badge-icon">{{ mascotIcon }}</text>
@@ -39,7 +39,7 @@
       <view class="message-list-inner">
         <view class="welcome-card" v-if="messages.length === 0 && !sending">
           <view class="welcome-icon">{{ mascotIcon || (isNutrition ? '🥗' : (isHealthCoach ? '💡' : '🤖')) }}</view>
-          <text class="welcome-title">{{ isNutrition ? '你好!我是精准营养助手' : (isHealthCoach ? '你好!我是AI健康教练' : ('你好!我是' + (mascotName || '家庭助手'))) }}</text>
+          <text class="welcome-title">{{ isNutrition ? '你好!我是精准营养助手' : (isHealthCoach ? '你好!我是AI健康教练' : (isButler ? '你好!我是AI健康管家' : ('你好!我是' + (mascotName || '家庭助手')))) }}</text>
           <text class="welcome-desc">{{ welcomeSubtitle }}</text>
           <view class="welcome-suggestions">
             <view class="welcome-suggestion" v-if="!isNutrition && !isHealthCoach" @click="quickQuestion('小明最近任务完成怎么样?')">
@@ -74,6 +74,18 @@
               <text class="suggestion-icon">🔍</text>
               <text class="suggestion-text">怎么知道孩子是什么体质?</text>
             </view>
+            <view class="welcome-suggestion" v-if="isButler" @click="quickQuestion('我家孩子肠道菌群怎么样?')">
+              <text class="suggestion-icon">🦠</text>
+              <text class="suggestion-text">我家孩子肠道菌群怎么样?</text>
+            </view>
+            <view class="welcome-suggestion" v-if="isButler" @click="quickQuestion('怎么改善孩子挑食问题?')">
+              <text class="suggestion-icon">🥗</text>
+              <text class="suggestion-text">怎么改善孩子挑食问题?</text>
+            </view>
+            <view class="welcome-suggestion" v-if="isButler" @click="quickQuestion('需要补充什么营养素?')">
+              <text class="suggestion-icon">💊</text>
+              <text class="suggestion-text">需要补充什么营养素?</text>
+            </view>
           </view>
         </view>
 
@@ -84,7 +96,7 @@
           </view>
           <!-- AI 回复 -->
           <view class="message message-ai" v-if="msg.answer">
-<view class="message-avatar">{{ isHealthCoach ? '🎯' : (mascotIcon || '🤖') }}</view>
+<view class="message-avatar">{{ isHealthCoach ? '🎯' : (isButler ? '🏥' : (mascotIcon || '🤖')) }}</view>
             <view class="message-bubble ai-bubble">
               <text class="ai-answer-text">{{ msg.answer }}</text>
         <!-- 推荐卡片 -->
@@ -140,7 +152,7 @@
 </template>
 
 <script>
-import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage, aiSendHealthCoachMessage } from '../../utils/api.js'
+import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage, aiSendHealthCoachMessage, aiSendHealthButlerMessage } from '../../utils/api.js'
 import BaseEmpty from '../../components/BaseEmpty.vue'
 import AITaskCard from '../../components/AITaskCard.vue'
 
@@ -173,6 +185,9 @@ export default {
     isHealthCoach: function() {
       return this.mode === 'health'
     },
+    isButler: function() {
+      return this.mode === 'butler'
+    },
     welcomeSubtitle: function() {
       if (this.isNutrition) {
         return '我可以基于菌群检测报告,为你提供个性化的营养建议和推荐'
@@ -180,6 +195,9 @@ export default {
       if (this.isHealthCoach) {
         return '陪伴孩子健康成长,用提问帮你找到答案'
       }
+      if (this.isButler) {
+        return '专属健康管家,基于知识库提供个性化健康建议'
+      }
       if (this.fromPage) {
         var pageNames = {
           'pages/body/index': '身',
@@ -327,8 +345,19 @@ export default {
           lastMsg.recommendations = data.recommendations || []
           lastMsg.tasks = data.tasks || []
         }
+      } else if (this.mode === 'butler') {
+        // 传递 childId 让健康管家能加载家庭上下文
+        var butlerParams = Object.assign({}, params)
+        butlerParams.childId = uni.getStorageSync('currentChildId') || null
+        res = await aiSendHealthButlerMessage(butlerParams)
+        data = res.data || {}
+        const butlerLastMsg = this.messages[this.messages.length - 1]
+        if (butlerLastMsg) {
+          butlerLastMsg.answer = data.answer || ''
+          butlerLastMsg.recommendations = data.recommendations || []
+          butlerLastMsg.tasks = data.tasks || []
+        }
       } else {
-        res = await aiSendMessage(params)
         data = res.data || {}
         const lastMsg = this.messages[this.messages.length - 1]
         if (lastMsg) {

+ 2 - 1
cfc-frontend/pages/body/index.vue

@@ -201,7 +201,8 @@ export default {
         { icon: '\u{1F957}', label: '饮食', needLogin: true, page: '/pages/body/meal-record' },
         { icon: '\u{1F634}', label: '作息', needLogin: true, page: '/pages/body/sleep-record' },
         { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '/pages/health/meditation-index' },
-        { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: '/pages/health/report-upload?reportType=tongue' }
+        { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: '/pages/health/report-upload?reportType=tongue' },
+        { icon: '\u{1F3E5}', label: '健康管家', needLogin: true, page: '/pages/ai/chat?mode=butler&fromPage=pages/body/index' }
       ]
     }
   },

+ 45 - 0
cfc-frontend/utils/api.js

@@ -1186,6 +1186,51 @@ export const aiSendHealthCoachMessage = (data) => {
   })
 }
 
+// AI 健康管家(健康知识库检索 + 个性化建议 + 任务生成)
+export const aiSendHealthButlerMessage = (data) => {
+  var query = data && data.query ? data.query : ''
+  var conversationId = data && data.conversationId ? data.conversationId : ''
+  var userId = uni.getStorageSync('userId') || ''
+  var childId = data && data.childId ? data.childId : null
+  var familyId = data && data.familyId ? data.familyId : null
+  var langgraphBody = {
+    query: query,
+    user: userId,
+    user_id: userId,
+    conversation_id: conversationId,
+    response_mode: 'blocking',
+    bot_name: 'AI健康管家'
+  }
+  var inputs = {}
+  if (childId) { inputs.child_id = childId }
+  if (familyId) { inputs.family_id = familyId }
+  if (data && data.reportId) { inputs.report_id = data.reportId }
+  if (data && data.focus) { inputs.focus = data.focus }
+  if (Object.keys(inputs).length > 0) { langgraphBody.inputs = inputs }
+  return langgraphRequest({
+    langgraphPath: '/api/v1/health/butler',
+    langgraphBody: langgraphBody,
+    difyFallbackPath: '/api/ai/chat/send',
+    difyFallbackData: data,
+    transform: function(lgRes) {
+      var content = ''
+      if (lgRes && lgRes.choices && lgRes.choices.length > 0) {
+        var msg = lgRes.choices[0].message
+        if (msg && msg.content) { content = msg.content }
+      }
+      var tasks = []
+      var metadata = lgRes && lgRes.metadata
+      if (metadata && metadata.tasks) { tasks = metadata.tasks }
+      return {
+        answer: content,
+        conversationId: conversationId,
+        recommendations: [],
+        tasks: tasks
+      }
+    }
+  })
+}
+
 // ===================== 幸福邻里部落 - 服务商模块 =====================
 
 // 提交服务商入驻申请

+ 66 - 0
cfc-langgraph/app/api/adapter.py

@@ -4,6 +4,7 @@ from typing import Optional
 from app.graphs.chat_graph import create_chat_graph
 from app.graphs.analysis_graph import create_analysis_graph
 from app.graphs.health_coach_graph import create_health_coach_graph
+from app.graphs.health_butler_graph import create_health_butler_graph
 import uuid
 import time
 
@@ -209,3 +210,68 @@ async def health_coach(req: DifyChatRequest):
         ),
         metadata=metadata,
     )
+
+
+@router.post("/health/butler", response_model=DifyResponse)
+async def health_butler(req: DifyChatRequest):
+    """AI 健康管家 — 基于健康知识库检索 + 个性化建议 + 任务生成"""
+    query = _extract_query(req)
+    if not query:
+        raise HTTPException(status_code=400, detail="query 为空")
+
+    graph = create_health_butler_graph()
+
+    # 从 inputs 中提取健康管家需要的上下文
+    inputs = req.inputs or {}
+    family_id = inputs.get("family_id")
+    child_id = inputs.get("child_id")
+    report_id = inputs.get("report_id")
+    focus = inputs.get("focus")
+
+    # conversation_id 用于 checkpointer thread
+    thread_id = req.conversation_id or f"butler_{req.user_id or 'anon'}"
+
+    initial_state = {
+        "query": query,
+        "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
+        "conversation_id": thread_id,
+        "family_id": int(family_id) if family_id else None,
+        "child_id": int(child_id) if child_id else None,
+        "report_id": int(report_id) if report_id else None,
+        "focus": focus,
+        "kb_context": None,
+        "knowledge_results": [],
+        "answer": None,
+        "tasks": [],
+        "sources": [],
+        "messages": None,
+    }
+    config = {
+        "configurable": {"thread_id": thread_id},
+    }
+    result = await graph.ainvoke(initial_state, config)
+    answer = result.get("answer") or ""
+
+    # 从回答中提取 TASK 标记
+    tasks = result.get("tasks") or []
+    sources = result.get("sources") or []
+    metadata = {"tasks": tasks, "sources": sources}
+
+    return DifyResponse(
+        id=f"butler-{uuid.uuid4().hex[:24]}",
+        created=_now_ts(),
+        model="langgraph-health-butler",
+        choices=[
+            DifyChoice(
+                index=0,
+                message={"role": "assistant", "content": answer},
+                finish_reason="stop",
+            )
+        ],
+        usage=DifyUsage(
+            prompt_tokens=len(query.split()),
+            completion_tokens=len(answer.split()),
+            total_tokens=len(query.split()) + len(answer.split()),
+        ),
+        metadata=metadata,
+    )