浏览代码

fix: LangGraph管理页改造 + 新增health/coach/generate端点

管理页:
- 移除功能测试面板(quickTests)
- API端点列表高度 500→700px
- 日志面板高度 250→600px
- 状态判定改为只看LLM/ChromaDB,Java后端因内外网隔离不作为降级依据
- 刷新间隔从30s改为60s

后端:
- adapter.py 新增 POST /api/v1/health/coach/generate 端点
- 接收 {familyId, memberIds, dimensions, goal},调用 health_coach_graph
- 修复 Java 后端 HealthPlanServiceImpl 调用端点 404 问题

部署:
- 新增 deploy.sh 脚本(rsync同步 + docker-compose重启 + 健康检查)
iwt 1 月之前
父节点
当前提交
2f685d225a

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

@@ -37,6 +37,13 @@ class DifyAnalysisRequest(BaseModel):
     messages: list[DifyMessage] = []
     inputs: dict = {}
 
+class HealthCoachGenerateRequest(BaseModel):
+    """Java 后端健康方案生成专用请求体"""
+    family_id: Optional[int] = None
+    member_ids: Optional[str] = None
+    dimensions: Optional[str] = None
+    goal: str = ""
+
 class DifyChoice(BaseModel):
     index: int
     message: dict
@@ -275,3 +282,39 @@ async def health_butler(req: DifyChatRequest):
         ),
         metadata=metadata,
     )
+
+
+@router.post("/health/coach/generate")
+async def health_coach_generate(req: HealthCoachGenerateRequest):
+    """Java 后端健康方案生成端点 — 接收 familyId/memberIds/dimensions/goal,返回方案文本"""
+    graph = create_health_coach_graph()
+    goal = req.goal or "生成健康方案"
+    family_id = req.family_id
+    member_ids = req.member_ids
+    dimensions = req.dimensions
+
+    # 构造用户查询:将目标和维度信息注入 prompt
+    query_parts = [goal]
+    if dimensions:
+        query_parts.append(f"重点关注维度:{dimensions}")
+    if member_ids:
+        query_parts.append(f"目标成员IDs:{member_ids}")
+    query = ";".join(query_parts)
+
+    initial_state = {
+        "query": query,
+        "user_id": family_id or 0,
+        "child_id": None,
+        "conversation_id": None,
+        "context": {
+            "family_id": family_id,
+            "member_ids": member_ids,
+            "dimensions": dimensions,
+        },
+        "answer": None,
+        "sources": [],
+        "memory_messages": None,
+    }
+    result = await graph.ainvoke(initial_state, {})
+    answer = result.get("answer") or ""
+    return answer

+ 54 - 0
cfc-langgraph/deploy.sh

@@ -0,0 +1,54 @@
+#!/bin/bash
+# LangGraph 部署脚本
+# 用法: ./deploy.sh [生产|测试]
+# 会将本地 cfc-langgraph/ 代码同步到远程服务器并重启服务
+#
+# 远程服务器: ai.etotem.com.cn (111.228.6.214)
+# 部署路径: /opt/sc-data/cfc/cfc-langgraph/
+
+set -e
+
+ENV="${1:-prod}"
+REMOTE_USER="root"
+REMOTE_HOST="111.228.6.214"
+REMOTE_PATH="/opt/sc-data/cfc/cfc-langgraph"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "=== LangGraph Deploy ($ENV) ==="
+echo "本地: $SCRIPT_DIR"
+echo "远程: $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH"
+
+# 同步代码(排除虚拟环境和缓存)
+echo "[1/3] 同步代码..."
+rsync -av --delete \
+  --exclude='.venv' \
+  --exclude='__pycache__' \
+  --exclude='*.pyc' \
+  --exclude='.env' \
+  --exclude='.env.production' \
+  --exclude='data/' \
+  --exclude='node_modules' \
+  --exclude='.git/' \
+  "$SCRIPT_DIR/" \
+  "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
+
+# 重启服务
+echo "[2/3] 重启 LangGraph 服务..."
+ssh "${REMOTE_USER}@${REMOTE_HOST}" "
+  cd ${REMOTE_PATH} && \
+  docker-compose restart langgraph-svc && \
+  sleep 3 && \
+  docker-compose ps langgraph-svc
+"
+
+# 健康检查
+echo "[3/3] 健康检查..."
+sleep 5
+if curl -sf "https://ai.etotem.com.cn/health" > /dev/null; then
+  echo "✅ 部署成功!服务地址: https://ai.etotem.com.cn"
+  curl -s "https://ai.etotem.com.cn/api/v1/health" | python3 -m json.tool 2>/dev/null || true
+else
+  echo "❌ 健康检查失败,请查看日志: ssh ${REMOTE_USER}@${REMOTE_HOST} 'docker-compose -f ${REMOTE_PATH}/docker-compose.yml logs langgraph-svc --tail=50'"
+  exit 1
+fi

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-37fef220b77a0e8bf0a47760f0eda73555fc875a
+9a89ebe42cb956b30b596c5ddcf26a09205e8799

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.931",
+  "version": "1.0.932",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.931",
+      "version": "1.0.932",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 19 - 156
cfc-web/src/views/admin/LangGraphAdmin.vue

@@ -39,7 +39,6 @@
             <div class="status-label">刷新操作</div>
             <div style="margin-top:6px">
               <el-button size="mini" type="primary" @click="refreshAll" :loading="refreshing">刷新</el-button>
-              <el-button size="mini" @click="testAllHealth">全部检测</el-button>
             </div>
           </div>
         </el-card>
@@ -71,7 +70,7 @@
         <span>📡 API 端点列表 ({{ apiEndpoints.length }})</span>
         <el-input v-model="apiFilter" placeholder="筛选端点..." size="mini" prefix-icon="el-icon-search" style="width:200px" clearable />
       </div>
-      <el-table :data="filteredApiEndpoints" border stripe size="small" max-height="500">
+      <el-table :data="filteredApiEndpoints" border stripe size="small" max-height="700">
         <el-table-column prop="method" label="方法" width="80">
           <template slot-scope="{ row }">
             <el-tag :type="row.method === 'GET' ? 'info' : 'primary'" size="mini">{{ row.method }}</el-tag>
@@ -87,29 +86,6 @@
       </el-table>
     </el-card>
 
-    <!-- 快速测试面板 -->
-    <el-card class="test-card" shadow="never">
-      <div slot="header" class="card-header">
-        <span>🧪 功能测试</span>
-        <el-button size="mini" type="success" @click="runQuickTests" :loading="quickTesting">一键测试</el-button>
-      </div>
-      <el-row :gutter="12">
-        <el-col :span="6" v-for="t in quickTests" :key="t.key">
-          <el-card shadow="hover" class="test-item" :class="'test-' + t.result">
-            <div class="test-item-header">
-              <span class="test-name">{{ t.name }}</span>
-              <el-tag :type="t.result === 'success' ? 'success' : t.result === 'error' ? 'danger' : 'info'" size="mini">
-                {{ t.result === 'success' ? '✓' : t.result === 'error' ? '✗' : '—' }}
-              </el-tag>
-            </div>
-            <div class="test-item-desc">{{ t.desc }}</div>
-            <div v-if="t.result === 'error'" class="test-item-error">{{ t.errorMsg }}</div>
-            <div v-if="t.result === 'success'" class="test-item-time">{{ t.time }}ms</div>
-          </el-card>
-        </el-col>
-      </el-row>
-    </el-card>
-
     <!-- 配置面板 -->
     <el-card class="config-card" shadow="never">
       <div slot="header" class="card-header">
@@ -232,7 +208,6 @@ export default {
       apiFilter: '',
       componentList: [],
       refreshing: false,
-      quickTesting: false,
       config: {
         LLM_MODEL: 'deepseek',
         LLM_BASE_URL: 'http://axonhub.iwintrue.com/v1',
@@ -261,20 +236,12 @@ export default {
       return self.apiEndpoints.filter(function(e) {
         return e.path.toLowerCase().indexOf(kw) >= 0 || (e.summary && e.summary.toLowerCase().indexOf(kw) >= 0)
       })
-    },
-    quickTests: function() {
-      return [
-        { key: 'food', name: '食材识别', desc: 'POST /api/v1/food/recognize', result: 'pending', time: 0, errorMsg: '' },
-        { key: 'menu', name: '菜单生成', desc: 'POST /api/v1/menu/generate', result: 'pending', time: 0, errorMsg: '' },
-        { key: 'chat', name: 'AI 对话', desc: 'POST /api/v1/chat', result: 'pending', time: 0, errorMsg: '' },
-        { key: 'questionnaire', name: '问卷生成', desc: 'POST /api/v1/questionnaire/generate', result: 'pending', time: 0, errorMsg: '' }
-      ]
     }
   },
   created: function() {
     this.refreshAll()
     this.fetchLogs()
-    this._interval = setInterval(this.refreshAll, 30000)
+    this._interval = setInterval(this.refreshAll, 60000)
   },
   beforeDestroy: function() {
     if (this._interval) clearInterval(this._interval)
@@ -353,23 +320,26 @@ export default {
           comps.push({ name: 'LLM', status: c.llm.status || 'unknown', detail: c.llm.message || '' })
         }
         if (c.java_backend) {
-          comps.push({ name: 'Java 后端', status: c.java_backend.status || 'unknown', detail: c.java_backend.code ? 'HTTP ' + c.java_backend.code : '' })
+          comps.push({ name: 'Java 后端', status: c.java_backend.status || 'unknown', detail: c.java_backend.code ? 'HTTP ' + c.java_backend.code : (c.java_backend.message || '') })
         }
         this.componentList = comps
-        var allOk = comps.every(function(x) { return x.status === 'ok' })
-        var hasError = comps.some(function(x) { return x.status === 'error' })
-        if (allOk) {
+
+        // 状态判定:仅当 LLM 异常时才显示异常
+        // Java 后端通过 Docker 容器访问时可能网络不通(内外网 IP 差异),不作为整体状态依据
+        var llmOk = !c.llm || c.llm.status === 'ok'
+        var chromaOk = !c.chromadb || c.chromadb.status === 'ok'
+        if (llmOk && chromaOk) {
           this.statusText = '运行中'
           this.statusClass = 'status-healthy'
           this.statusIcon = '✅'
-        } else if (hasError) {
-          this.statusText = '部分异常'
+        } else if (!llmOk) {
+          this.statusText = 'LLM 异常'
+          this.statusClass = 'status-unhealthy'
+          this.statusIcon = '❌'
+        } else {
+          this.statusText = '运行中(部分检测跳过)'
           this.statusClass = 'status-degraded'
           this.statusIcon = '⚠️'
-        } else {
-          this.statusText = '检测中'
-          this.statusClass = 'status-checking'
-          this.statusIcon = '🔄'
         }
         this.statusTime = new Date().toLocaleTimeString()
       } catch (e) {
@@ -395,7 +365,9 @@ export default {
         '/api/v1/menu/generate': JSON.stringify({ selected_foods: '[{"name":"鸡肉"}]', participants: '[]', date: '2026-08-08' }, null, 2),
         '/api/v1/chat': JSON.stringify({ query: '你好', user_id: 1 }, null, 2),
         '/api/v1/recommend': JSON.stringify({ user_id: 1, query: '推荐早餐', limit: 5 }, null, 2),
-        '/api/v1/questionnaire/generate': JSON.stringify({ member_name: '小明', relationship_type: 'parent' }, null, 2)
+        '/api/v1/questionnaire/generate': JSON.stringify({ member_name: '小明', relationship_type: 'parent' }, null, 2),
+        '/api/v1/health/coach': JSON.stringify({ query: '孩子最近睡眠不好', user_id: '1' }, null, 2),
+        '/api/v1/health/coach/generate': JSON.stringify({ familyId: 1, memberIds: '1', dimensions: 'body', goal: '改善睡眠' }, null, 2)
       }
       this.testParams = defaults[endpoint.path] || '{}'
       this.testResponse = ''
@@ -425,78 +397,6 @@ export default {
         self.testing = false
       }
     },
-    async runQuickTests() {
-      var self = this
-      self.quickTesting = true
-      var tests = self.quickTests.slice()
-      var results = { food: tests[0], menu: tests[1], chat: tests[2], questionnaire: tests[3] }
-
-      var t0 = Date.now()
-      try {
-        var r1 = await fetch(BASE + '/api/v1/food/recognize', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ image_url: 'https://example.com/food.jpg' })
-        })
-        await r1.json()
-        results.food.result = 'success'
-        results.food.time = Date.now() - t0
-      } catch (e) {
-        results.food.result = 'error'
-        results.food.errorMsg = e.message
-      }
-
-      t0 = Date.now()
-      try {
-        var r2 = await fetch(BASE + '/api/v1/menu/generate', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ selected_foods: '[{"name":"鸡肉"}]', participants: '[]', date: '2026-08-08' })
-        })
-        await r2.json()
-        results.menu.result = 'success'
-        results.menu.time = Date.now() - t0
-      } catch (e) {
-        results.menu.result = 'error'
-        results.menu.errorMsg = e.message
-      }
-
-      t0 = Date.now()
-      try {
-        var r3 = await fetch(BASE + '/api/v1/chat', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ query: '你好', user_id: 1 })
-        })
-        await r3.json()
-        results.chat.result = 'success'
-        results.chat.time = Date.now() - t0
-      } catch (e) {
-        results.chat.result = 'error'
-        results.chat.errorMsg = e.message
-      }
-
-      t0 = Date.now()
-      try {
-        var r4 = await fetch(BASE + '/api/v1/questionnaire/generate', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ member_name: '小明', relationship_type: 'parent' })
-        })
-        await r4.json()
-        results.questionnaire.result = 'success'
-        results.questionnaire.time = Date.now() - t0
-      } catch (e) {
-        results.questionnaire.result = 'error'
-        results.questionnaire.errorMsg = e.message
-      }
-
-      tests[0] = results.food
-      tests[1] = results.menu
-      tests[2] = results.chat
-      tests[3] = results.questionnaire
-      self.quickTesting = false
-    },
     async fetchLogs() {
       this.logLoading = true
       try {
@@ -520,10 +420,6 @@ export default {
     clearLogs: function() {
       this.logList = []
     },
-    testAllHealth: function() {
-      this.fetchHealth()
-      this.fetchDetailedHealth()
-    },
     getLogLevel: function(log) {
       if (log.level === 'ERROR' || log.level === 'error') return 'error'
       if (log.level === 'WARNING' || log.level === 'WARN') return 'warning'
@@ -581,39 +477,6 @@ export default {
   justify-content: space-between;
   align-items: center;
 }
-.test-item {
-  cursor: default;
-  transition: all 0.3s;
-}
-.test-item:hover {
-  transform: translateY(-2px);
-}
-.test-item-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 8px;
-}
-.test-name {
-  font-weight: 600;
-  font-size: 14px;
-  color: #303133;
-}
-.test-item-desc {
-  font-size: 12px;
-  color: #909399;
-  margin-bottom: 6px;
-}
-.test-item-error {
-  font-size: 11px;
-  color: #f56c6c;
-  margin-top: 4px;
-}
-.test-item-time {
-  font-size: 11px;
-  color: #67c23a;
-  margin-top: 4px;
-}
 .response-pre {
   background: #f5f7fa;
   padding: 12px;
@@ -625,7 +488,7 @@ export default {
   word-break: break-all;
 }
 .log-container {
-  max-height: 250px;
+  max-height: 600px;
   overflow-y: auto;
   background: #1e1e1e;
   border-radius: 4px;