| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493 |
- <template>
- <div class="lg-admin">
- <div class="lg-top-bar">
- <div class="lg-stat" :class="statusClass">
- <span class="lg-stat-icon">{{ statusIcon }}</span>
- <div class="lg-stat-info">
- <div class="lg-stat-label">服务状态</div>
- <div class="lg-stat-value">{{ statusText }}</div>
- <div class="lg-stat-sub">{{ statusTime }}</div>
- </div>
- </div>
- <div class="lg-stat">
- <span class="lg-stat-icon">🔗</span>
- <div class="lg-stat-info">
- <div class="lg-stat-label">服务地址</div>
- <div class="lg-stat-value" style="font-size:12px">{{ langgraphUrl }}</div>
- <div class="lg-stat-sub">端口 9000 · v{{ serviceVersion }}</div>
- </div>
- </div>
- <div class="lg-stat">
- <span class="lg-stat-icon">📡</span>
- <div class="lg-stat-info">
- <div class="lg-stat-label">API 端点数</div>
- <div class="lg-stat-value">{{ apiCount }}</div>
- <div class="lg-stat-sub">个可用接口</div>
- </div>
- </div>
- <div class="lg-stat">
- <span class="lg-stat-icon">📋</span>
- <div class="lg-stat-info">
- <div class="lg-stat-label">请求日志</div>
- <div class="lg-stat-value">{{ totalLogs }}</div>
- <div class="lg-stat-sub">条历史记录</div>
- </div>
- </div>
- <div class="lg-actions">
- <el-button size="small" type="primary" @click="refreshAll" :loading="refreshing">刷新</el-button>
- <el-button size="small" @click="fetchLogs">拉取日志</el-button>
- </div>
- </div>
- <el-tabs v-model="activeTab" type="border-card" class="lg-tabs">
- <el-tab-pane label="概览" name="overview">
- <div class="lg-panel">
- <div class="lg-panel-title">组件健康状态</div>
- <el-table :data="componentList" border stripe size="small" class="lg-table">
- <el-table-column prop="name" label="组件" width="140" />
- <el-table-column prop="status" label="状态" width="100">
- <template slot-scope="{ row }">
- <el-tag :type="row.status === 'ok' ? 'success' : row.status === 'error' ? 'danger' : 'info'" size="small">
- {{ row.status === 'ok' ? '正常' : row.status === 'error' ? '异常' : '未知' }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="detail" label="详情" show-overflow-tooltip />
- </el-table>
- </div>
- <div class="lg-panel">
- <div class="lg-panel-title">服务配置</div>
- <el-descriptions :column="2" border size="small" class="lg-desc">
- <el-descriptions-item label="LLM 模型"><el-tag size="mini">{{ config.LLM_MODEL || '-' }}</el-tag></el-descriptions-item>
- <el-descriptions-item label="LLM Base URL">{{ config.LLM_BASE_URL || '-' }}</el-descriptions-item>
- <el-descriptions-item label="Embedding 模型"><el-tag size="mini">{{ config.EMBEDDING_MODEL || '-' }}</el-tag></el-descriptions-item>
- <el-descriptions-item label="Embedding URL">{{ config.EMBEDDING_BASE_URL || '-' }}</el-descriptions-item>
- <el-descriptions-item label="Java 后端">{{ config.JAVA_BASE_URL || '-' }}</el-descriptions-item>
- <el-descriptions-item label="Chroma DB">{{ config.CHROMA_DB_PATH || '-' }}</el-descriptions-item>
- <el-descriptions-item label="LangSmith 追踪">
- <el-tag :type="config.LANGCHAIN_TRACING_V2 === 'true' ? 'success' : 'info'" size="mini">
- {{ config.LANGCHAIN_TRACING_V2 === 'true' ? '已启用' : '未启用' }}
- </el-tag>
- </el-descriptions-item>
- <el-descriptions-item label="日志级别">{{ config.LOG_LEVEL || 'info' }}</el-descriptions-item>
- </el-descriptions>
- </div>
- </el-tab-pane>
- <el-tab-pane label="API 端点" name="apis">
- <div class="lg-toolbar">
- <el-input v-model="apiFilter" placeholder="筛选路径或说明..." prefix-icon="el-icon-search" size="small" clearable style="width:280px" />
- <el-button size="small" @click="fetchSpec">刷新</el-button>
- </div>
- <el-table :data="filteredApiEndpoints" border stripe size="small" class="lg-table lg-table-full">
- <el-table-column prop="method" label="方法" width="80" fixed>
- <template slot-scope="{ row }">
- <el-tag :type="row.method === 'GET' ? 'info' : 'primary'" size="mini">{{ row.method }}</el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="path" label="路径" min-width="260" show-overflow-tooltip />
- <el-table-column prop="summary" label="说明" min-width="180" show-overflow-tooltip />
- <el-table-column label="操作" width="80" fixed="right">
- <template slot-scope="{ row }">
- <el-button v-if="row.method === 'POST'" type="text" size="mini" @click="openTestDialog(row)">测试</el-button>
- </template>
- </el-table-column>
- </el-table>
- </el-tab-pane>
- <el-tab-pane label="请求历史" name="logs">
- <div class="lg-toolbar">
- <el-select v-model="logFilterMethod" placeholder="方法" size="small" clearable style="width:90px;margin-right:8px">
- <el-option label="GET" value="GET" />
- <el-option label="POST" value="POST" />
- </el-select>
- <el-input v-model="logFilterPath" placeholder="路径筛选..." prefix-icon="el-icon-search" size="small" clearable style="width:200px;margin-right:8px" />
- <el-tag size="small" type="info">共 {{ totalLogs }} 条</el-tag>
- <el-button size="small" @click="fetchLogs" :loading="logLoading">刷新</el-button>
- </div>
- <el-table :data="filteredLogs" border stripe size="small" class="lg-table lg-table-full">
- <el-table-column prop="ts" label="时间" width="70" fixed />
- <el-table-column prop="method" label="方法" width="60">
- <template slot-scope="{ row }">
- <el-tag :type="row.method === 'GET' ? 'info' : 'primary'" size="mini">{{ row.method }}</el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
- <el-table-column prop="status" label="状态" width="60">
- <template slot-scope="{ row }">
- <el-tag :type="row.status < 400 ? 'success' : 'danger'" size="mini">{{ row.status }}</el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="duration_ms" label="耗时" width="70">
- <template slot-scope="{ row }">
- <span :style="{ color: row.duration_ms > 5000 ? '#f56c6c' : '' }">{{ row.duration_ms }}ms</span>
- </template>
- </el-table-column>
- <el-table-column prop="client_ip" label="IP" width="90" show-overflow-tooltip />
- <el-table-column label="操作" width="60" fixed="right">
- <template slot-scope="{ row }">
- <el-button type="text" size="mini" @click="toggleLogDetail(row)">详情</el-button>
- </template>
- </el-table-column>
- </el-table>
- <el-table :data="logDetailRows" border stripe size="small" class="lg-table lg-table-full lg-detail-table" v-if="expandedLog">
- <el-table-column label="时间" width="90"><template slot-scope="{ row }">{{ row.ts }}</template></el-table-column>
- <el-table-column label="方法" width="60"><template slot-scope="{ row }">{{ row.method }}</template></el-table-column>
- <el-table-column label="路径"><template slot-scope="{ row }">{{ row.path }}</template></el-table-column>
- <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>
- <el-table-column label="耗时" width="80"><template slot-scope="{ row }">{{ row.duration_ms }}ms</template></el-table-column>
- <el-table-column label="IP" width="90"><template slot-scope="{ row }">{{ row.client_ip }}</template></el-table-column>
- <el-table-column label="请求体" min-width="200"><template slot-scope="{ row }"><pre class="detail-pre">{{ row.body || '(空)' }}</pre></template></el-table-column>
- <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>
- </el-table>
- </el-tab-pane>
- </el-tabs>
- <el-dialog :visible.sync="testDialogVisible" :title="'测试: ' + testingEndpoint.path" width="720px" :close-on-click-modal="false">
- <el-form size="small">
- <el-form-item label="请求参数">
- <el-input v-model="testParams" type="textarea" :rows="6" placeholder='{"user_id": 1, "query": "你好"}' />
- </el-form-item>
- <el-form-item>
- <el-button type="primary" @click="runTest" :loading="testing">发送请求</el-button>
- <el-button @click="testDialogVisible = false">关闭</el-button>
- </el-form-item>
- </el-form>
- <el-divider>响应结果</el-divider>
- <pre class="response-pre" v-if="testResponse">{{ testResponse }}</pre>
- <el-alert v-else type="info" :closable="false" description="点击发送请求查看响应" />
- </el-dialog>
- </div>
- </template>
- <script>
- const BASE = 'https://ai.etotem.com.cn'
- function fetchJSON(url, options) {
- options = options || {}
- var headers = options.headers || {}
- headers['Content-Type'] = 'application/json'
- return fetch(url, Object.assign({}, options, { headers: headers }))
- .then(function(res) {
- if (!res.ok) throw new Error('HTTP ' + res.status)
- return res.json()
- })
- }
- export default {
- name: 'LangGraphAdmin',
- data() {
- return {
- activeTab: 'overview',
- langgraphUrl: 'https://ai.etotem.com.cn',
- serviceVersion: '0.3.0',
- statusText: '检测中...',
- statusClass: 'status-checking',
- statusIcon: '🔄',
- statusTime: '',
- apiCount: 0,
- apiEndpoints: [],
- apiFilter: '',
- componentList: [],
- config: {
- LLM_MODEL: 'deepseek',
- LLM_BASE_URL: 'http://axonhub.iwintrue.com/v1',
- EMBEDDING_MODEL: 'BAAI/bge-large-zh-v1.5',
- EMBEDDING_BASE_URL: 'https://api.siliconflow.cn/v1',
- JAVA_BASE_URL: 'http://111.227.6.214:9082',
- CHROMA_DB_PATH: '/data/chroma_db',
- LANGCHAIN_TRACING_V2: 'true',
- LOG_LEVEL: 'info'
- },
- logList: [],
- totalLogs: 0,
- logFilterMethod: '',
- logFilterPath: '',
- logLoading: false,
- expandedLog: null,
- testDialogVisible: false,
- testingEndpoint: {},
- testParams: '{}',
- testing: false,
- testResponse: '',
- refreshing: false,
- _interval: null
- }
- },
- computed: {
- filteredApiEndpoints: function() {
- if (!this.apiFilter) return this.apiEndpoints
- var kw = this.apiFilter.toLowerCase()
- return this.apiEndpoints.filter(function(e) {
- return e.path.toLowerCase().indexOf(kw) >= 0 || (e.summary && e.summary.toLowerCase().indexOf(kw) >= 0)
- })
- },
- filteredLogs: function() {
- var list = this.logList
- if (this.logFilterMethod) {
- list = list.filter(function(l) { return l.method === this.logFilterMethod }, this)
- }
- if (this.logFilterPath) {
- var kw = this.logFilterPath.toLowerCase()
- list = list.filter(function(l) { return l.path.toLowerCase().indexOf(kw) >= 0 })
- }
- return list
- },
- logDetailRows: function() {
- return this.expandedLog ? [this.expandedLog] : []
- }
- },
- created: function() {
- this.refreshAll()
- this._interval = setInterval(this.refreshAll, 60000)
- },
- beforeDestroy: function() {
- if (this._interval) clearInterval(this._interval)
- },
- methods: {
- async refreshAll() {
- this.refreshing = true
- try { await Promise.all([this.fetchHealth(), this.fetchSpec()]) }
- finally { this.refreshing = false }
- },
- async fetchHealth() {
- try {
- var healthy = await new Promise(function(resolve) {
- var img = new Image()
- img.onload = function() { resolve(true) }
- img.onerror = function() { resolve(false) }
- img.src = BASE + '/health?t=' + Date.now()
- })
- if (healthy) { this.statusText = '运行中'; this.statusClass = 'status-healthy'; this.statusIcon = '✅' }
- else { this.statusText = '未响应'; this.statusClass = 'status-unhealthy'; this.statusIcon = '❌' }
- } catch (e) { this.statusText = '异常'; this.statusClass = 'status-unhealthy'; this.statusIcon = '❌' }
- this.statusTime = new Date().toLocaleTimeString()
- },
- async fetchSpec() {
- try {
- var res = await fetchJSON(BASE + '/openapi.json')
- var paths = res.paths || {}
- var endpoints = []
- Object.keys(paths).forEach(function(path) {
- var methods = paths[path]
- Object.keys(methods).forEach(function(method) {
- var info = methods[method]
- endpoints.push({ method: method.toUpperCase(), path: path, summary: info.summary || info.description || '' })
- })
- })
- endpoints.sort(function(a, b) {
- if (a.method !== b.method) return a.method.localeCompare(b.method)
- return a.path.localeCompare(b.path)
- })
- this.apiEndpoints = endpoints
- this.apiCount = endpoints.length
- this.fetchDetailedHealth()
- } catch (e) { console.error('获取 API 规范失败', e) }
- },
- async fetchDetailedHealth() {
- try {
- var res = await fetchJSON(BASE + '/api/v1/health')
- var data = res || {}
- var comps = []
- var c = data.components || {}
- if (c.chromadb) comps.push({ name: 'ChromaDB', status: c.chromadb.status || 'unknown', detail: (c.chromadb.detail || c.chromadb.path || '') })
- if (c.llm) 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.detail || (c.java_backend.code ? 'HTTP ' + c.java_backend.code : '') })
- this.componentList = comps
- 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 (!llmOk) {
- this.statusText = 'LLM 异常'
- this.statusClass = 'status-unhealthy'
- this.statusIcon = '❌'
- } else {
- this.statusText = '运行中'
- this.statusClass = 'status-degraded'
- this.statusIcon = '⚠️'
- }
- this.statusTime = new Date().toLocaleTimeString()
- } catch (e) {
- this.componentList = [{ name: '健康检查', status: 'error', detail: e.message }]
- }
- },
- async fetchLogs() {
- this.logLoading = true
- try {
- var res = await fetchJSON(BASE + '/api/v1/logs?limit=200')
- this.logList = res.logs || []
- this.totalLogs = res.total || 0
- } catch (e) {
- this.logList = []
- this.totalLogs = 0
- } finally {
- this.logLoading = false
- }
- },
- toggleLogDetail: function(row) {
- this.expandedLog = this.expandedLog === row ? null : row
- },
- openTestDialog: function(endpoint) {
- this.testingEndpoint = endpoint
- var defaults = {
- '/api/v1/food/recognize': JSON.stringify({ image_url: 'https://example.com/food.jpg' }, null, 2),
- '/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/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 = ''
- this.testDialogVisible = true
- },
- async runTest() {
- var self = this
- self.testing = true
- self.testResponse = ''
- try {
- var payload = {}
- if (self.testParams && self.testParams !== '请上传舌苔图片 (MIME 类型表单)') payload = JSON.parse(self.testParams)
- var res = await fetch(BASE + self.testingEndpoint.path, {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload), signal: AbortSignal.timeout(60000)
- })
- self.testResponse = JSON.stringify(await res.json(), null, 2)
- } catch (e) { self.testResponse = '错误: ' + e.message }
- finally { self.testing = false }
- }
- }
- }
- </script>
- <style scoped>
- .lg-admin {
- display: flex;
- flex-direction: column;
- height: calc(100vh - 60px);
- overflow: hidden;
- padding: 12px;
- box-sizing: border-box;
- }
- .lg-top-bar {
- display: flex;
- align-items: center;
- gap: 12px;
- flex-shrink: 0;
- margin-bottom: 12px;
- }
- .lg-stat {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 16px;
- background: #fff;
- border-radius: 8px;
- box-shadow: 0 1px 4px rgba(0,0,0,0.08);
- border-left: 3px solid transparent;
- flex: 1;
- min-width: 0;
- }
- .lg-stat.status-healthy { border-left-color: #67c23a; }
- .lg-stat.status-unhealthy { border-left-color: #f56c6c; }
- .lg-stat.status-degraded { border-left-color: #e6a23c; }
- .lg-stat-icon { font-size: 22px; flex-shrink: 0; }
- .lg-stat-label { font-size: 11px; color: #909399; }
- .lg-stat-value { font-size: 16px; font-weight: bold; color: #303133; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
- .lg-stat-sub { font-size: 11px; color: #bbb; }
- .lg-actions { display: flex; gap: 8px; flex-shrink: 0; }
- .lg-tabs {
- flex: 1;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- }
- .lg-tabs >>> .el-tabs__content {
- flex: 1;
- overflow: hidden;
- padding: 12px;
- }
- .lg-tabs >>> .el-tab-pane {
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .lg-panel {
- background: #fff;
- border-radius: 8px;
- box-shadow: 0 1px 4px rgba(0,0,0,0.06);
- margin-bottom: 12px;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .lg-panel-title {
- font-size: 14px;
- font-weight: 600;
- color: #303133;
- padding: 10px 16px;
- border-bottom: 1px solid #ebeef5;
- flex-shrink: 0;
- }
- .lg-desc {
- flex: 1;
- overflow: auto;
- padding: 8px 12px;
- }
- .lg-toolbar {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 8px;
- flex-shrink: 0;
- flex-wrap: wrap;
- }
- .lg-table {
- flex: 1;
- overflow: hidden;
- }
- .lg-table-full {
- flex: 1;
- min-height: 0;
- }
- .lg-table >>> .el-table__body-wrapper {
- overflow-y: auto;
- }
- .lg-detail-table {
- margin-top: 8px;
- flex-shrink: 0;
- }
- .detail-pre {
- background: #1e1e1e;
- color: #d4d4d4;
- padding: 6px 8px;
- border-radius: 4px;
- font-size: 11px;
- max-height: 160px;
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-all;
- margin: 0;
- }
- .response-pre {
- background: #f5f7fa;
- padding: 12px;
- border-radius: 4px;
- font-size: 12px;
- max-height: 300px;
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-all;
- }
- </style>
|