LangGraphAdmin.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. <template>
  2. <div class="lg-admin">
  3. <div class="lg-top-bar">
  4. <div class="lg-stat" :class="statusClass">
  5. <span class="lg-stat-icon">{{ statusIcon }}</span>
  6. <div class="lg-stat-info">
  7. <div class="lg-stat-label">服务状态</div>
  8. <div class="lg-stat-value">{{ statusText }}</div>
  9. <div class="lg-stat-sub">{{ statusTime }}</div>
  10. </div>
  11. </div>
  12. <div class="lg-stat">
  13. <span class="lg-stat-icon">🔗</span>
  14. <div class="lg-stat-info">
  15. <div class="lg-stat-label">服务地址</div>
  16. <div class="lg-stat-value" style="font-size:12px">{{ langgraphUrl }}</div>
  17. <div class="lg-stat-sub">端口 9000 · v{{ serviceVersion }}</div>
  18. </div>
  19. </div>
  20. <div class="lg-stat">
  21. <span class="lg-stat-icon">📡</span>
  22. <div class="lg-stat-info">
  23. <div class="lg-stat-label">API 端点数</div>
  24. <div class="lg-stat-value">{{ apiCount }}</div>
  25. <div class="lg-stat-sub">个可用接口</div>
  26. </div>
  27. </div>
  28. <div class="lg-stat">
  29. <span class="lg-stat-icon">📋</span>
  30. <div class="lg-stat-info">
  31. <div class="lg-stat-label">请求日志</div>
  32. <div class="lg-stat-value">{{ totalLogs }}</div>
  33. <div class="lg-stat-sub">条历史记录</div>
  34. </div>
  35. </div>
  36. <div class="lg-actions">
  37. <el-button size="small" type="primary" @click="refreshAll" :loading="refreshing">刷新</el-button>
  38. <el-button size="small" @click="fetchLogs">拉取日志</el-button>
  39. </div>
  40. </div>
  41. <el-tabs v-model="activeTab" type="border-card" class="lg-tabs">
  42. <el-tab-pane label="概览" name="overview">
  43. <div class="lg-panel">
  44. <div class="lg-panel-title">组件健康状态</div>
  45. <el-table :data="componentList" border stripe size="small" class="lg-table">
  46. <el-table-column prop="name" label="组件" width="140" />
  47. <el-table-column prop="status" label="状态" width="100">
  48. <template slot-scope="{ row }">
  49. <el-tag :type="row.status === 'ok' ? 'success' : row.status === 'error' ? 'danger' : 'info'" size="small">
  50. {{ row.status === 'ok' ? '正常' : row.status === 'error' ? '异常' : '未知' }}
  51. </el-tag>
  52. </template>
  53. </el-table-column>
  54. <el-table-column prop="detail" label="详情" show-overflow-tooltip />
  55. </el-table>
  56. </div>
  57. <div class="lg-panel">
  58. <div class="lg-panel-title">服务配置</div>
  59. <el-descriptions :column="2" border size="small" class="lg-desc">
  60. <el-descriptions-item label="LLM 模型"><el-tag size="mini">{{ config.LLM_MODEL || '-' }}</el-tag></el-descriptions-item>
  61. <el-descriptions-item label="LLM Base URL">{{ config.LLM_BASE_URL || '-' }}</el-descriptions-item>
  62. <el-descriptions-item label="Embedding 模型"><el-tag size="mini">{{ config.EMBEDDING_MODEL || '-' }}</el-tag></el-descriptions-item>
  63. <el-descriptions-item label="Embedding URL">{{ config.EMBEDDING_BASE_URL || '-' }}</el-descriptions-item>
  64. <el-descriptions-item label="Java 后端">{{ config.JAVA_BASE_URL || '-' }}</el-descriptions-item>
  65. <el-descriptions-item label="Chroma DB">{{ config.CHROMA_DB_PATH || '-' }}</el-descriptions-item>
  66. <el-descriptions-item label="LangSmith 追踪">
  67. <el-tag :type="config.LANGCHAIN_TRACING_V2 === 'true' ? 'success' : 'info'" size="mini">
  68. {{ config.LANGCHAIN_TRACING_V2 === 'true' ? '已启用' : '未启用' }}
  69. </el-tag>
  70. </el-descriptions-item>
  71. <el-descriptions-item label="日志级别">{{ config.LOG_LEVEL || 'info' }}</el-descriptions-item>
  72. </el-descriptions>
  73. </div>
  74. </el-tab-pane>
  75. <el-tab-pane label="API 端点" name="apis">
  76. <div class="lg-toolbar">
  77. <el-input v-model="apiFilter" placeholder="筛选路径或说明..." prefix-icon="el-icon-search" size="small" clearable style="width:280px" />
  78. <el-button size="small" @click="fetchSpec">刷新</el-button>
  79. </div>
  80. <el-table :data="filteredApiEndpoints" border stripe size="small" class="lg-table lg-table-full">
  81. <el-table-column prop="method" label="方法" width="80" fixed>
  82. <template slot-scope="{ row }">
  83. <el-tag :type="row.method === 'GET' ? 'info' : 'primary'" size="mini">{{ row.method }}</el-tag>
  84. </template>
  85. </el-table-column>
  86. <el-table-column prop="path" label="路径" min-width="260" show-overflow-tooltip />
  87. <el-table-column prop="summary" label="说明" min-width="180" show-overflow-tooltip />
  88. <el-table-column label="操作" width="80" fixed="right">
  89. <template slot-scope="{ row }">
  90. <el-button v-if="row.method === 'POST'" type="text" size="mini" @click="openTestDialog(row)">测试</el-button>
  91. </template>
  92. </el-table-column>
  93. </el-table>
  94. </el-tab-pane>
  95. <el-tab-pane label="请求历史" name="logs">
  96. <div class="lg-toolbar">
  97. <el-select v-model="logFilterMethod" placeholder="方法" size="small" clearable style="width:90px;margin-right:8px">
  98. <el-option label="GET" value="GET" />
  99. <el-option label="POST" value="POST" />
  100. </el-select>
  101. <el-input v-model="logFilterPath" placeholder="路径筛选..." prefix-icon="el-icon-search" size="small" clearable style="width:200px;margin-right:8px" />
  102. <el-tag size="small" type="info">共 {{ totalLogs }} 条</el-tag>
  103. <el-button size="small" @click="fetchLogs" :loading="logLoading">刷新</el-button>
  104. </div>
  105. <el-table :data="filteredLogs" border stripe size="small" class="lg-table lg-table-full">
  106. <el-table-column prop="ts" label="时间" width="70" fixed />
  107. <el-table-column prop="method" label="方法" width="60">
  108. <template slot-scope="{ row }">
  109. <el-tag :type="row.method === 'GET' ? 'info' : 'primary'" size="mini">{{ row.method }}</el-tag>
  110. </template>
  111. </el-table-column>
  112. <el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
  113. <el-table-column prop="status" label="状态" width="60">
  114. <template slot-scope="{ row }">
  115. <el-tag :type="row.status < 400 ? 'success' : 'danger'" size="mini">{{ row.status }}</el-tag>
  116. </template>
  117. </el-table-column>
  118. <el-table-column prop="duration_ms" label="耗时" width="70">
  119. <template slot-scope="{ row }">
  120. <span :style="{ color: row.duration_ms > 5000 ? '#f56c6c' : '' }">{{ row.duration_ms }}ms</span>
  121. </template>
  122. </el-table-column>
  123. <el-table-column prop="client_ip" label="IP" width="90" show-overflow-tooltip />
  124. <el-table-column label="操作" width="60" fixed="right">
  125. <template slot-scope="{ row }">
  126. <el-button type="text" size="mini" @click="toggleLogDetail(row)">详情</el-button>
  127. </template>
  128. </el-table-column>
  129. </el-table>
  130. <el-table :data="logDetailRows" border stripe size="small" class="lg-table lg-table-full lg-detail-table" v-if="expandedLog">
  131. <el-table-column label="时间" width="90"><template slot-scope="{ row }">{{ row.ts }}</template></el-table-column>
  132. <el-table-column label="方法" width="60"><template slot-scope="{ row }">{{ row.method }}</template></el-table-column>
  133. <el-table-column label="路径"><template slot-scope="{ row }">{{ row.path }}</template></el-table-column>
  134. <el-table-column label="状态" width="70"><template slot-scope="{ row }"><el-tag :type="row.status < 400 ? 'success' : 'danger'" size="mini">{{ row.status }}</el-tag></template></el-table-column>
  135. <el-table-column label="耗时" width="80"><template slot-scope="{ row }">{{ row.duration_ms }}ms</template></el-table-column>
  136. <el-table-column label="IP" width="90"><template slot-scope="{ row }">{{ row.client_ip }}</template></el-table-column>
  137. <el-table-column label="请求体" min-width="200"><template slot-scope="{ row }"><pre class="detail-pre">{{ row.body || '(空)' }}</pre></template></el-table-column>
  138. <el-table-column label="响应体" min-width="200"><template slot-scope="{ row }"><pre class="detail-pre">{{ (row.response || '').substring(0, 2000) }}</pre></template></el-table-column>
  139. </el-table>
  140. </el-tab-pane>
  141. </el-tabs>
  142. <el-dialog :visible.sync="testDialogVisible" :title="'测试: ' + testingEndpoint.path" width="720px" :close-on-click-modal="false">
  143. <el-form size="small">
  144. <el-form-item label="请求参数">
  145. <el-input v-model="testParams" type="textarea" :rows="6" placeholder='{"user_id": 1, "query": "你好"}' />
  146. </el-form-item>
  147. <el-form-item>
  148. <el-button type="primary" @click="runTest" :loading="testing">发送请求</el-button>
  149. <el-button @click="testDialogVisible = false">关闭</el-button>
  150. </el-form-item>
  151. </el-form>
  152. <el-divider>响应结果</el-divider>
  153. <pre class="response-pre" v-if="testResponse">{{ testResponse }}</pre>
  154. <el-alert v-else type="info" :closable="false" description="点击发送请求查看响应" />
  155. </el-dialog>
  156. </div>
  157. </template>
  158. <script>
  159. const BASE = 'https://ai.etotem.com.cn'
  160. function fetchJSON(url, options) {
  161. options = options || {}
  162. var headers = options.headers || {}
  163. headers['Content-Type'] = 'application/json'
  164. return fetch(url, Object.assign({}, options, { headers: headers }))
  165. .then(function(res) {
  166. if (!res.ok) throw new Error('HTTP ' + res.status)
  167. return res.json()
  168. })
  169. }
  170. export default {
  171. name: 'LangGraphAdmin',
  172. data() {
  173. return {
  174. activeTab: 'overview',
  175. langgraphUrl: 'https://ai.etotem.com.cn',
  176. serviceVersion: '0.3.0',
  177. statusText: '检测中...',
  178. statusClass: 'status-checking',
  179. statusIcon: '🔄',
  180. statusTime: '',
  181. apiCount: 0,
  182. apiEndpoints: [],
  183. apiFilter: '',
  184. componentList: [],
  185. config: {
  186. LLM_MODEL: 'deepseek',
  187. LLM_BASE_URL: 'http://axonhub.iwintrue.com/v1',
  188. EMBEDDING_MODEL: 'BAAI/bge-large-zh-v1.5',
  189. EMBEDDING_BASE_URL: 'https://api.siliconflow.cn/v1',
  190. JAVA_BASE_URL: 'http://111.227.6.214:9082',
  191. CHROMA_DB_PATH: '/data/chroma_db',
  192. LANGCHAIN_TRACING_V2: 'true',
  193. LOG_LEVEL: 'info'
  194. },
  195. logList: [],
  196. totalLogs: 0,
  197. logFilterMethod: '',
  198. logFilterPath: '',
  199. logLoading: false,
  200. expandedLog: null,
  201. testDialogVisible: false,
  202. testingEndpoint: {},
  203. testParams: '{}',
  204. testing: false,
  205. testResponse: '',
  206. refreshing: false,
  207. _interval: null
  208. }
  209. },
  210. computed: {
  211. filteredApiEndpoints: function() {
  212. if (!this.apiFilter) return this.apiEndpoints
  213. var kw = this.apiFilter.toLowerCase()
  214. return this.apiEndpoints.filter(function(e) {
  215. return e.path.toLowerCase().indexOf(kw) >= 0 || (e.summary && e.summary.toLowerCase().indexOf(kw) >= 0)
  216. })
  217. },
  218. filteredLogs: function() {
  219. var list = this.logList
  220. if (this.logFilterMethod) {
  221. list = list.filter(function(l) { return l.method === this.logFilterMethod }, this)
  222. }
  223. if (this.logFilterPath) {
  224. var kw = this.logFilterPath.toLowerCase()
  225. list = list.filter(function(l) { return l.path.toLowerCase().indexOf(kw) >= 0 })
  226. }
  227. return list
  228. },
  229. logDetailRows: function() {
  230. return this.expandedLog ? [this.expandedLog] : []
  231. }
  232. },
  233. created: function() {
  234. this.refreshAll()
  235. this._interval = setInterval(this.refreshAll, 60000)
  236. },
  237. beforeDestroy: function() {
  238. if (this._interval) clearInterval(this._interval)
  239. },
  240. methods: {
  241. async refreshAll() {
  242. this.refreshing = true
  243. try { await Promise.all([this.fetchHealth(), this.fetchSpec()]) }
  244. finally { this.refreshing = false }
  245. },
  246. async fetchHealth() {
  247. try {
  248. var healthy = await new Promise(function(resolve) {
  249. var img = new Image()
  250. img.onload = function() { resolve(true) }
  251. img.onerror = function() { resolve(false) }
  252. img.src = BASE + '/health?t=' + Date.now()
  253. })
  254. if (healthy) { this.statusText = '运行中'; this.statusClass = 'status-healthy'; this.statusIcon = '✅' }
  255. else { this.statusText = '未响应'; this.statusClass = 'status-unhealthy'; this.statusIcon = '❌' }
  256. } catch (e) { this.statusText = '异常'; this.statusClass = 'status-unhealthy'; this.statusIcon = '❌' }
  257. this.statusTime = new Date().toLocaleTimeString()
  258. },
  259. async fetchSpec() {
  260. try {
  261. var res = await fetchJSON(BASE + '/openapi.json')
  262. var paths = res.paths || {}
  263. var endpoints = []
  264. Object.keys(paths).forEach(function(path) {
  265. var methods = paths[path]
  266. Object.keys(methods).forEach(function(method) {
  267. var info = methods[method]
  268. endpoints.push({ method: method.toUpperCase(), path: path, summary: info.summary || info.description || '' })
  269. })
  270. })
  271. endpoints.sort(function(a, b) {
  272. if (a.method !== b.method) return a.method.localeCompare(b.method)
  273. return a.path.localeCompare(b.path)
  274. })
  275. this.apiEndpoints = endpoints
  276. this.apiCount = endpoints.length
  277. this.fetchDetailedHealth()
  278. } catch (e) { console.error('获取 API 规范失败', e) }
  279. },
  280. async fetchDetailedHealth() {
  281. try {
  282. var res = await fetchJSON(BASE + '/api/v1/health')
  283. var data = res || {}
  284. var comps = []
  285. var c = data.components || {}
  286. if (c.chromadb) comps.push({ name: 'ChromaDB', status: c.chromadb.status || 'unknown', detail: (c.chromadb.detail || c.chromadb.path || '') })
  287. if (c.llm) comps.push({ name: 'LLM', status: c.llm.status || 'unknown', detail: c.llm.message || '' })
  288. if (c.java_backend) comps.push({ name: 'Java 后端', status: c.java_backend.status || 'unknown', detail: c.java_backend.detail || (c.java_backend.code ? 'HTTP ' + c.java_backend.code : '') })
  289. this.componentList = comps
  290. var llmOk = !c.llm || c.llm.status === 'ok'
  291. var chromaOk = !c.chromadb || c.chromadb.status === 'ok'
  292. if (llmOk && chromaOk) {
  293. this.statusText = '运行中'
  294. this.statusClass = 'status-healthy'
  295. this.statusIcon = '✅'
  296. } else if (!llmOk) {
  297. this.statusText = 'LLM 异常'
  298. this.statusClass = 'status-unhealthy'
  299. this.statusIcon = '❌'
  300. } else {
  301. this.statusText = '运行中'
  302. this.statusClass = 'status-degraded'
  303. this.statusIcon = '⚠️'
  304. }
  305. this.statusTime = new Date().toLocaleTimeString()
  306. } catch (e) {
  307. this.componentList = [{ name: '健康检查', status: 'error', detail: e.message }]
  308. }
  309. },
  310. async fetchLogs() {
  311. this.logLoading = true
  312. try {
  313. var res = await fetchJSON(BASE + '/api/v1/logs?limit=200')
  314. this.logList = res.logs || []
  315. this.totalLogs = res.total || 0
  316. } catch (e) {
  317. this.logList = []
  318. this.totalLogs = 0
  319. } finally {
  320. this.logLoading = false
  321. }
  322. },
  323. toggleLogDetail: function(row) {
  324. this.expandedLog = this.expandedLog === row ? null : row
  325. },
  326. openTestDialog: function(endpoint) {
  327. this.testingEndpoint = endpoint
  328. var defaults = {
  329. '/api/v1/food/recognize': JSON.stringify({ image_url: 'https://example.com/food.jpg' }, null, 2),
  330. '/api/v1/menu/generate': JSON.stringify({ selected_foods: '[{"name":"鸡肉"}]', participants: '[]', date: '2026-08-08' }, null, 2),
  331. '/api/v1/chat': JSON.stringify({ query: '你好', user_id: 1 }, null, 2),
  332. '/api/v1/health/coach': JSON.stringify({ query: '孩子最近睡眠不好', user_id: '1' }, null, 2),
  333. '/api/v1/health/coach/generate': JSON.stringify({ familyId: 1, memberIds: '1', dimensions: 'body', goal: '改善睡眠' }, null, 2)
  334. }
  335. this.testParams = defaults[endpoint.path] || '{}'
  336. this.testResponse = ''
  337. this.testDialogVisible = true
  338. },
  339. async runTest() {
  340. var self = this
  341. self.testing = true
  342. self.testResponse = ''
  343. try {
  344. var payload = {}
  345. if (self.testParams && self.testParams !== '请上传舌苔图片 (MIME 类型表单)') payload = JSON.parse(self.testParams)
  346. var res = await fetch(BASE + self.testingEndpoint.path, {
  347. method: 'POST', headers: { 'Content-Type': 'application/json' },
  348. body: JSON.stringify(payload), signal: AbortSignal.timeout(60000)
  349. })
  350. self.testResponse = JSON.stringify(await res.json(), null, 2)
  351. } catch (e) { self.testResponse = '错误: ' + e.message }
  352. finally { self.testing = false }
  353. }
  354. }
  355. }
  356. </script>
  357. <style scoped>
  358. .lg-admin {
  359. display: flex;
  360. flex-direction: column;
  361. height: calc(100vh - 60px);
  362. overflow: hidden;
  363. padding: 12px;
  364. box-sizing: border-box;
  365. }
  366. .lg-top-bar {
  367. display: flex;
  368. align-items: center;
  369. gap: 12px;
  370. flex-shrink: 0;
  371. margin-bottom: 12px;
  372. }
  373. .lg-stat {
  374. display: flex;
  375. align-items: center;
  376. gap: 10px;
  377. padding: 10px 16px;
  378. background: #fff;
  379. border-radius: 8px;
  380. box-shadow: 0 1px 4px rgba(0,0,0,0.08);
  381. border-left: 3px solid transparent;
  382. flex: 1;
  383. min-width: 0;
  384. }
  385. .lg-stat.status-healthy { border-left-color: #67c23a; }
  386. .lg-stat.status-unhealthy { border-left-color: #f56c6c; }
  387. .lg-stat.status-degraded { border-left-color: #e6a23c; }
  388. .lg-stat-icon { font-size: 22px; flex-shrink: 0; }
  389. .lg-stat-label { font-size: 11px; color: #909399; }
  390. .lg-stat-value { font-size: 16px; font-weight: bold; color: #303133; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  391. .lg-stat-sub { font-size: 11px; color: #bbb; }
  392. .lg-actions { display: flex; gap: 8px; flex-shrink: 0; }
  393. .lg-tabs {
  394. flex: 1;
  395. overflow: hidden;
  396. display: flex;
  397. flex-direction: column;
  398. }
  399. .lg-tabs >>> .el-tabs__content {
  400. flex: 1;
  401. overflow: hidden;
  402. padding: 12px;
  403. }
  404. .lg-tabs >>> .el-tab-pane {
  405. height: 100%;
  406. display: flex;
  407. flex-direction: column;
  408. overflow: hidden;
  409. }
  410. .lg-panel {
  411. background: #fff;
  412. border-radius: 8px;
  413. box-shadow: 0 1px 4px rgba(0,0,0,0.06);
  414. margin-bottom: 12px;
  415. display: flex;
  416. flex-direction: column;
  417. overflow: hidden;
  418. }
  419. .lg-panel-title {
  420. font-size: 14px;
  421. font-weight: 600;
  422. color: #303133;
  423. padding: 10px 16px;
  424. border-bottom: 1px solid #ebeef5;
  425. flex-shrink: 0;
  426. }
  427. .lg-desc {
  428. flex: 1;
  429. overflow: auto;
  430. padding: 8px 12px;
  431. }
  432. .lg-toolbar {
  433. display: flex;
  434. align-items: center;
  435. gap: 8px;
  436. margin-bottom: 8px;
  437. flex-shrink: 0;
  438. flex-wrap: wrap;
  439. }
  440. .lg-table {
  441. flex: 1;
  442. overflow: hidden;
  443. }
  444. .lg-table-full {
  445. flex: 1;
  446. min-height: 0;
  447. }
  448. .lg-table >>> .el-table__body-wrapper {
  449. overflow-y: auto;
  450. }
  451. .lg-detail-table {
  452. margin-top: 8px;
  453. flex-shrink: 0;
  454. }
  455. .detail-pre {
  456. background: #1e1e1e;
  457. color: #d4d4d4;
  458. padding: 6px 8px;
  459. border-radius: 4px;
  460. font-size: 11px;
  461. max-height: 160px;
  462. overflow-y: auto;
  463. white-space: pre-wrap;
  464. word-break: break-all;
  465. margin: 0;
  466. }
  467. .response-pre {
  468. background: #f5f7fa;
  469. padding: 12px;
  470. border-radius: 4px;
  471. font-size: 12px;
  472. max-height: 300px;
  473. overflow-y: auto;
  474. white-space: pre-wrap;
  475. word-break: break-all;
  476. }
  477. </style>