ForcePilot/web/src/views/GraphView.vue
Wenjie Zhang e13f98f16f feat(dashboard): 新增调用统计时间序列数据接口及组件
- 在后端新增获取调用统计时间序列数据的接口,支持按模型、智能体、Token和工具分类统计
- 前端实现调用统计组件,展示不同时间范围内的调用数据
- 更新样式以提升用户体验,整合统计信息展示
- 优化仪表板布局,增强可视化效果
2025-10-07 00:15:14 +08:00

453 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="database-empty" v-if="!state.showPage">
<a-empty>
<template #description>
<span>
前往 <router-link to="/setting" style="color: var(--main-color); font-weight: bold;">设置</router-link> 页面启用知识图谱。
</span>
</template>
</a-empty>
</div>
<div class="graph-container layout-container" v-else>
<HeaderComponent
title="图数据库"
>
<template #actions>
<div class="status-wrapper">
<div class="status-indicator" :class="graphStatusClass"></div>
<span class="status-text">{{ graphStatusText }}</span>
</div>
<a-button type="default" @click="openLink('http://localhost:7474/')" :icon="h(GlobalOutlined)">
Neo4j 浏览器
</a-button>
<a-button type="primary" @click="state.showModal = true" ><UploadOutlined/> 上传文件</a-button>
<a-button v-if="unindexedCount > 0" type="primary" @click="indexNodes" :loading="state.indexing">
<SyncOutlined v-if="!state.indexing"/> 为{{ unindexedCount }}个节点添加索引
</a-button>
</template>
</HeaderComponent>
<div class="container-outter">
<GraphCanvas
ref="graphRef"
:graph-data="graphData"
:highlight-keywords="[state.searchInput]"
>
<template #top>
<div class="actions">
<div class="actions-left">
<a-input
v-model:value="state.searchInput"
placeholder="输入要查询的实体"
style="width: 300px"
@keydown.enter="onSearch"
allow-clear
>
<template #suffix>
<component :is="state.searchLoading ? LoadingOutlined : SearchOutlined" @click="onSearch" />
</template>
</a-input>
</div>
<div class="actions-right">
<a-button type="default" @click="state.showInfoModal = true" :icon="h(InfoCircleOutlined)">
说明
</a-button>
<a-input
v-model:value="sampleNodeCount"
placeholder="查询三元组数量"
style="width: 100px"
@keydown.enter="loadSampleNodes"
:loading="state.fetching"
>
<template #suffix>
<component :is="state.fetching ? LoadingOutlined : ReloadOutlined" @click="loadSampleNodes" />
</template>
</a-input>
</div>
</div>
</template>
<template #content>
<a-empty v-show="graphData.nodes.length === 0" style="padding: 4rem 0;"/>
</template>
<template #bottom>
<div class="footer">
<div class="tags">
<a-tag :bordered="false" v-for="tag in graphTags" :key="tag.key" :color="tag.type">{{ tag.text }}</a-tag>
</div>
</div>
</template>
</GraphCanvas>
</div>
<a-modal
:open="state.showModal" title="上传文件"
@ok="addDocumentByFile"
@cancel="() => state.showModal = false"
ok-text="添加到图数据库" cancel-text="取消"
:confirm-loading="state.processing">
<div class="upload">
<div class="note">
<p>上传的文件内容参考 test/data/A_Dream_of_Red_Mansions_tiny.jsonl 中的格式:</p>
</div>
<a-upload-dragger
class="upload-dragger"
v-model:fileList="fileList"
name="file"
:fileList="fileList"
:max-count="1"
action="/api/knowledge/files/upload"
:headers="getAuthHeaders()"
@change="handleFileUpload"
@drop="handleDrop"
>
<p class="ant-upload-text">点击或者把文件拖拽到这里上传</p>
<p class="ant-upload-hint">
目前仅支持上传 jsonl 文件。
</p>
</a-upload-dragger>
</div>
</a-modal>
<!-- 说明弹窗 -->
<a-modal
:open="state.showInfoModal"
title="图数据库说明"
@cancel="() => state.showInfoModal = false"
:footer="null"
width="600px"
>
<div class="info-content">
<p>本页面展示的是 Neo4j 图数据库中的知识图谱信息</p>
<p>具体展示内容包括</p>
<ul>
<li>带有 <code>Entity</code> 标签的节点</li>
<li>带有 <code>RELATION</code> 类型的关系边</li>
</ul>
<p>注意</p>
<ul>
<li>这里仅展示用户上传的实体和关系不包含知识库中自动创建的图谱</li>
<li>查询逻辑基于 <code>graphbase.py</code> 中的 <code>get_sample_nodes</code> 方法实现</li>
</ul>
<pre><code>MATCH (n:Entity)-[r]-&gt;(m:Entity)
RETURN
{id: elementId(n), name: n.name} AS h,
{type: r.type, source_id: elementId(n), target_id: elementId(m)} AS r,
{id: elementId(m), name: m.name} AS t
LIMIT $num</code></pre>
<p>如需查看完整的 Neo4j 数据库内容请使用 "Neo4j 浏览器" 按钮访问原生界面</p>
</div>
</a-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, h } from 'vue';
import { message } from 'ant-design-vue';
import { useConfigStore } from '@/stores/config';
import { UploadOutlined, SyncOutlined, GlobalOutlined, InfoCircleOutlined, SearchOutlined, ReloadOutlined, LoadingOutlined, HighlightOutlined } from '@ant-design/icons-vue';
import HeaderComponent from '@/components/HeaderComponent.vue';
import { neo4jApi } from '@/apis/graph_api';
import { useUserStore } from '@/stores/user';
import GraphCanvas from '@/components/GraphCanvas.vue';
const configStore = useConfigStore();
const cur_embed_model = computed(() => configStore.config?.embed_model);
const modelMatched = computed(() => !graphInfo?.value?.embed_model_name || graphInfo.value.embed_model_name === cur_embed_model.value)
const graphRef = ref(null)
const graphInfo = ref(null)
const fileList = ref([]);
const sampleNodeCount = ref(100);
const graphData = reactive({
nodes: [],
edges: [],
});
const state = reactive({
fetching: false,
loadingGraphInfo: false,
searchInput: '',
searchLoading: false,
showModal: false,
showInfoModal: false,
processing: false,
indexing: false,
showPage: true,
})
// 计算未索引节点数量
const unindexedCount = computed(() => {
return graphInfo.value?.unindexed_node_count || 0;
});
const loadGraphInfo = () => {
state.loadingGraphInfo = true
neo4jApi.getInfo()
.then(data => {
console.log(data)
graphInfo.value = data.data
state.loadingGraphInfo = false
})
.catch(error => {
console.error(error)
message.error(error.message || '加载图数据库信息失败')
state.loadingGraphInfo = false
})
}
const addDocumentByFile = () => {
state.processing = true
const files = fileList.value.filter(file => file.status === 'done').map(file => file.response.file_path)
neo4jApi.addEntities(files[0])
.then((data) => {
if (data.status === 'success') {
message.success(data.message);
state.showModal = false;
} else {
throw new Error(data.message);
}
})
.catch((error) => {
console.error(error)
message.error(error.message || '添加文件失败');
})
.finally(() => state.processing = false)
};
const loadSampleNodes = () => {
state.fetching = true
neo4jApi.getSampleNodes('neo4j', sampleNodeCount.value)
.then((data) => {
graphData.nodes = data.result.nodes
graphData.edges = data.result.edges
console.log(graphData)
// 初次加载后兜底刷新一次,避免容器初次可见尺寸未稳定
setTimeout(() => graphRef.value?.refreshGraph?.(), 500)
})
.catch((error) => {
console.error(error)
message.error(error.message || '加载节点失败');
})
.finally(() => state.fetching = false)
}
const onSearch = () => {
if (state.searchLoading) {
message.error('请稍后再试')
return
}
if (graphInfo?.value?.embed_model_name !== cur_embed_model.value) {
// 可选:提示模型不一致
}
if (!state.searchInput) {
message.error('请输入要查询的实体')
return
}
state.searchLoading = true
neo4jApi.queryNode(state.searchInput)
.then((data) => {
if (!data.result || !data.result.nodes || !data.result.edges) {
throw new Error('返回数据格式不正确');
}
graphData.nodes = data.result.nodes
graphData.edges = data.result.edges
if (graphData.nodes.length === 0) {
message.info('未找到相关实体')
}
console.log(data)
console.log(graphData)
graphRef.value?.refreshGraph?.()
})
.catch((error) => {
console.error('查询错误:', error);
message.error(`查询出错:${error.message || '未知错误'}`);
})
.finally(() => state.searchLoading = false)
};
onMounted(() => {
loadGraphInfo();
loadSampleNodes();
});
const handleFileUpload = (event) => {
console.log(event)
console.log(fileList.value)
}
const handleDrop = (event) => {
console.log(event)
console.log(fileList.value)
}
const graphStatusClass = computed(() => {
if (state.loadingGraphInfo) return 'loading';
return graphInfo.value?.status === 'open' ? 'open' : 'closed';
});
const graphStatusText = computed(() => {
if (state.loadingGraphInfo) return '加载中';
return graphInfo.value?.status === 'open' ? '已连接' : '已关闭';
});
// 新增:将图谱信息拆分为多条标签用于展示
const graphTags = computed(() => {
const tags = [];
const dbName = graphInfo.value?.graph_name;
const entityCount = graphInfo.value?.entity_count;
const relationCount = graphInfo.value?.relationship_count;
if (dbName) tags.push({ key: 'name', text: `图谱 ${dbName}`, type: 'blue' });
if (typeof entityCount === 'number') tags.push({ key: 'entities', text: `实体 ${graphData.nodes.length} of ${entityCount}`, type: 'success' });
if (typeof relationCount === 'number') tags.push({ key: 'relations', text: `关系 ${graphData.edges.length} of ${relationCount}`, type: 'purple' });
if (unindexedCount.value > 0) tags.push({ key: 'unindexed', text: `未索引 ${unindexedCount.value}`, type: 'warning' });
return tags;
});
// 为未索引节点添加索引
const indexNodes = () => {
// 判断 embed_model_name 是否相同
if (!modelMatched.value) {
message.error(`向量模型不匹配,无法添加索引,当前向量模型为 ${cur_embed_model.value},图数据库向量模型为 ${graphInfo.value?.embed_model_name}`)
return
}
if (state.processing) {
message.error('后台正在处理,请稍后再试')
return
}
state.indexing = true;
neo4jApi.indexEntities('neo4j')
.then(data => {
message.success(data.message || '索引添加成功');
// 刷新图谱信息
loadGraphInfo();
})
.catch(error => {
console.error(error);
message.error(error.message || '添加索引失败');
})
.finally(() => {
state.indexing = false;
});
};
const getAuthHeaders = () => {
const userStore = useUserStore();
return userStore.getAuthHeaders();
};
const openLink = (url) => {
window.open(url, '_blank')
}
</script>
<style lang="less" scoped>
@graph-header-height: 55px;
.graph-container {
padding: 0;
.header-container {
height: @graph-header-height;
}
}
.status-wrapper {
display: flex;
align-items: center;
margin-right: 16px;
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
.status-text {
margin-left: 8px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
&.loading {
background-color: #faad14;
animation: pulse 1.5s infinite ease-in-out;
}
&.open {
background-color: #52c41a;
}
&.closed {
background-color: #f5222d;
}
}
@keyframes pulse {
0% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(0.8);
opacity: 0.5;
}
}
.upload {
margin-bottom: 20px;
.upload-dragger {
margin: 0px;
}
}
.container-outter {
width: 100%;
height: calc(100vh - @graph-header-height);
overflow: hidden;
background: var(--gray-10);
.actions,
.footer {
display: flex;
justify-content: space-between;
margin: 20px 0;
padding: 0 24px;
width: 100%;
}
}
.actions {
top: 0;
.actions-left, .actions-right {
display: flex;
align-items: center;
gap: 10px;
}
:deep(.ant-input) {
padding: 2px 10px;
}
button {
height: 37px;
box-shadow: none;
}
}
</style>