ForcePilot/web/src/views/GraphView.vue
Wenjie Zhang 92518e5bbc fix(GraphView): 修复文件上传和模态框关闭逻辑
添加文件上传状态验证和错误处理
修复模态框关闭时未清空文件列表的问题
统一搜索框的占位文本
移除不再使用的说明弹窗组件
2025-12-20 14:51:02 +08:00

687 lines
19 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>
点击右上角用户头像中的"系统设置"来启用知识图谱。
</span>
</template>
</a-empty>
</div>
<div class="graph-container layout-container" v-else>
<HeaderComponent
title="图数据库"
>
<template #actions>
<div class="db-selector">
<div class="status-wrapper">
<div class="status-indicator" :class="graphStatusClass"></div>
<span class="status-text">{{ graphStatusText }}</span>
</div>
<span class="label">知识库: </span>
<a-select
v-model:value="state.selectedDbId"
style="width: 200px"
:options="state.dbOptions"
@change="handleDbChange"
:loading="state.loadingDatabases"
mode="combobox"
placeholder="选择或输入KB ID"
/>
</div>
<!-- <a-button type="default" @click="openLink('http://localhost:7474/')" :icon="h(GlobalOutlined)">
Neo4j 浏览器
</a-button> -->
<a-button
v-if="isNeo4j"
type="primary"
@click="state.showModal = true" ><UploadOutlined/> 上传文件</a-button>
<a-button
v-else
type="primary"
@click="state.showUploadTipModal = 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="graph.graphData"
:highlight-keywords="[state.searchInput]"
@node-click="graph.handleNodeClick"
@edge-click="graph.handleEdgeClick"
@canvas-click="graph.handleCanvasClick"
>
<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>
<a-input
v-model:value="sampleNodeCount"
placeholder="查询数量"
style="width: 100px"
@keydown.enter="loadSampleNodes"
:loading="graph.fetching"
>
<template #suffix>
<component :is="graph.fetching ? LoadingOutlined : ReloadOutlined" @click="loadSampleNodes" />
</template>
</a-input>
</div>
<div class="actions-right">
<a-button type="default" @click="exportGraphData" :icon="h(ExportOutlined)">
导出数据
</a-button>
<a-button type="default" @click="state.showInfoModal = true" :icon="h(InfoCircleOutlined)">
说明
</a-button>
</div>
</div>
</template>
<template #content>
<a-empty v-show="graph.graphData.nodes.length === 0" style="padding: 4rem 0;"/>
</template>
<template #bottom>
<div class="footer">
<GraphInfoPanel
v-if="isNeo4j"
:graph-info="graphInfo"
:graph-data="graph.graphData"
:unindexed-count="unindexedCount"
:model-matched="modelMatched"
@index-nodes="indexNodes"
/>
<LightRAGInfoPanel
v-else
:stats="state.lightragStats"
:graph-data="graph.graphData"
:database-name="getDatabaseName()"
/>
</div>
</template>
</GraphCanvas>
<!-- 详情浮动卡片 -->
<GraphDetailPanel
:visible="graph.showDetailDrawer"
:item="graph.selectedItem"
:type="graph.selectedItemType"
:nodes="graph.graphData.nodes"
@close="graph.handleCanvasClick"
style="width: 380px;"
/>
</div>
<a-modal
:open="state.showModal" title="上传文件"
@ok="addDocumentByFile"
@cancel="handleModalCancel"
ok-text="添加到图数据库" cancel-text="取消"
:confirm-loading="state.processing"
:ok-button-props="{ disabled: !hasValidFile }">
<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"
accept=".jsonl"
action="/api/knowledge/files/upload?allow_jsonl=true"
: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.showUploadTipModal"
title="文件上传说明"
@cancel="() => state.showUploadTipModal = false"
:footer="null"
width="500px"
>
<div class="upload-tip-content">
<a-alert
:message="getUploadTipMessage()"
type="info"
show-icon
style="margin-bottom: 16px;"
/>
<div v-if="!isNeo4j" class="upload-tip-actions">
<p>如需上传文档到当前选中的知识库,请前往对应的知识库详情页面进行操作:</p>
<div class="action-buttons">
<a-button type="primary" @click="goToDatabasePage">
<DatabaseOutlined/> 前往知识库详情页
</a-button>
</div>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, h } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { useConfigStore } from '@/stores/config';
import { UploadOutlined, SyncOutlined, GlobalOutlined, InfoCircleOutlined, SearchOutlined, ReloadOutlined, LoadingOutlined, HighlightOutlined, DatabaseOutlined, ExportOutlined } from '@ant-design/icons-vue';
import HeaderComponent from '@/components/HeaderComponent.vue';
import { neo4jApi, unifiedApi } from '@/apis/graph_api';
import { useUserStore } from '@/stores/user';
import GraphCanvas from '@/components/GraphCanvas.vue';
import GraphInfoPanel from '@/components/GraphInfoPanel.vue';
import LightRAGInfoPanel from '@/components/LightRAGInfoPanel.vue';
import GraphDetailPanel from '@/components/GraphDetailPanel.vue';
import UploadModal from '@/components/FileUploadModal.vue';
import { useGraph } from '@/composables/useGraph';
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 router = useRouter();
const graphRef = ref(null)
const graphInfo = ref(null)
const fileList = ref([]);
const sampleNodeCount = ref(100);
const graph = reactive(useGraph(graphRef));
const state = reactive({
loadingGraphInfo: false,
loadingDatabases: false,
searchInput: '',
searchLoading: false,
showModal: false,
showInfoModal: false,
showUploadTipModal: false,
processing: false,
indexing: false,
showPage: true,
selectedDbId: 'neo4j',
dbOptions: [],
lightragStats: null,
})
const isNeo4j = computed(() => {
// 当 selectedDbId 是 'neo4j' 时,或者以 'kb_' 开头时,我们认为它是 Neo4j 驱动的
// 但是对于 kb_ 开头的,我们可能想要使用 LightRAGInfoPanel 的展示风格(因为它有 stats
// 或者复用 GraphInfoPanel。
// GraphInfoPanel 是为 'neo4j' 全局图设计的,包含了 model matching 检查等。
// KBs (kb_*) 使用 Neo4j 存储,但逻辑上更接近 LightRAG 的"知识库"概念。
// 为了让 LightRAGInfoPanel 能够展示 stats (get_stats 已经更新支持 kb_),
// 我们这里让 kb_ ID 返回 false这样会进入 LightRAGInfoPanel 分支。
return state.selectedDbId === 'neo4j';
});
// 检查是否有有效的已上传文件
const hasValidFile = computed(() => {
return fileList.value.some(file => file.status === 'done' && file.response?.file_path);
});
// 计算未索引节点数量
const unindexedCount = computed(() => {
return graphInfo.value?.unindexed_node_count || 0;
});
const loadDatabases = async () => {
state.loadingDatabases = true;
try {
const res = await unifiedApi.getGraphs();
if (res.success && res.data) {
state.dbOptions = res.data.map(db => ({
label: `${db.name} (${db.type})`,
value: db.id,
type: db.type
}));
// If no selection or invalid selection, select first
if (!state.selectedDbId || !state.dbOptions.find(o => o.value === state.selectedDbId)) {
if (state.dbOptions.length > 0) {
state.selectedDbId = state.dbOptions[0].value;
}
}
}
} catch (error) {
console.error('Failed to load databases:', error);
} finally {
state.loadingDatabases = false;
}
};
const handleDbChange = () => {
// Clear current data
graph.clearGraph();
state.searchInput = '';
state.lightragStats = null;
if (isNeo4j.value) {
loadGraphInfo();
} else {
// Also load stats for LightRAG or KB
loadLightRAGStats();
}
loadSampleNodes();
};
const loadLightRAGStats = () => {
unifiedApi.getStats(state.selectedDbId).then(res => {
if(res.success) {
state.lightragStats = res.data;
}
}).catch(e => console.error(e));
};
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 = () => {
// 使用计算属性验证文件
if (!hasValidFile.value) {
message.error('请先等待文件上传完成')
return
}
state.processing = true
// 获取已上传的文件路径
const uploadedFile = fileList.value.find(file => file.status === 'done' && file.response?.file_path);
const filePath = uploadedFile?.response?.file_path;
// 再次验证文件路径
if (!filePath) {
message.error('文件路径获取失败,请重新上传文件')
state.processing = false
return
}
neo4jApi.addEntities(filePath)
.then((data) => {
if (data.status === 'success') {
message.success(data.message);
state.showModal = false;
// 清空文件列表
fileList.value = [];
// 刷新图谱数据
setTimeout(() => {
loadGraphInfo();
loadSampleNodes();
}, 500);
} else {
throw new Error(data.message);
}
})
.catch((error) => {
console.error(error)
message.error(error.message || '添加文件失败');
})
.finally(() => state.processing = false)
};
const loadSampleNodes = () => {
graph.fetching = true
unifiedApi.getSubgraph({
db_id: state.selectedDbId,
node_label: '*',
max_nodes: sampleNodeCount.value
})
.then((data) => {
// Normalize data structure if needed
const result = data.data;
graph.updateGraphData(result.nodes, result.edges);
console.log(graph.graphData)
})
.catch((error) => {
console.error(error)
message.error(error.message || '加载节点失败');
})
.finally(() => graph.fetching = false)
}
const onSearch = () => {
if (state.searchLoading) {
message.error('请稍后再试')
return
}
if (isNeo4j.value && graphInfo?.value?.embed_model_name !== cur_embed_model.value) {
// 可选:提示模型不一致
}
state.searchLoading = true
unifiedApi.getSubgraph({
db_id: state.selectedDbId,
node_label: state.searchInput || '*',
max_nodes: sampleNodeCount.value
})
.then((data) => {
const result = data.data;
if (!result || !result.nodes || !result.edges) {
throw new Error('返回数据格式不正确');
}
graph.updateGraphData(result.nodes, result.edges);
if (graph.graphData.nodes.length === 0) {
message.info('未找到相关实体')
}
console.log(data)
console.log(graph.graphData)
})
.catch((error) => {
console.error('查询错误:', error);
message.error(`查询出错:${error.message || '未知错误'}`);
})
.finally(() => state.searchLoading = false)
};
onMounted(async () => {
await loadDatabases();
loadGraphInfo(); // Load default (Neo4j) info
loadSampleNodes();
});
const handleFileUpload = ({ file, fileList: newFileList }) => {
// 更新文件列表
fileList.value = newFileList;
// 如果上传失败,显示错误信息
if (file.status === 'error') {
message.error(`文件上传失败: ${file.name}`);
}
// 如果上传成功,显示成功信息
if (file.status === 'done' && file.response?.file_path) {
message.success(`文件上传成功: ${file.name}`);
}
console.log('File upload status:', file.status, file.name);
console.log('File list:', fileList.value);
}
const handleDrop = (event) => {
console.log(event)
console.log(fileList.value)
}
const handleModalCancel = () => {
state.showModal = false;
// 重置文件列表
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 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 exportGraphData = () => {
const dataStr = JSON.stringify({
nodes: graph.graphData.nodes,
edges: graph.graphData.edges,
graphInfo: isNeo4j.value ? graphInfo.value : state.lightragStats,
source: state.selectedDbId,
exportTime: new Date().toISOString()
}, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `graph-data-${state.selectedDbId}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('图谱数据已导出');
};
const getAuthHeaders = () => {
const userStore = useUserStore();
return userStore.getAuthHeaders();
};
const openLink = (url) => {
window.open(url, '_blank')
}
const getDatabaseName = () => {
const selectedDb = state.dbOptions.find(db => db.value === state.selectedDbId);
return selectedDb ? selectedDb.label : state.selectedDbId;
};
const getUploadTipMessage = () => {
if (isNeo4j.value) {
return 'Neo4j 图数据库支持通过上传 JSONL 格式文件直接导入实体和关系数据。';
} else {
const selectedDb = state.dbOptions.find(db => db.value === state.selectedDbId);
const dbType = selectedDb?.type || '未知';
const dbName = selectedDb?.label || getDatabaseName();
return `当前选择的是 ${dbType.toUpperCase()} 类型的知识库"${dbName}",该类型知识库需要在文档知识库页面上传文档,系统会自动从中提取知识图谱。`;
}
};
const goToDatabasePage = () => {
state.showUploadTipModal = false;
// 如果不是 Neo4j需要找到对应的知识库 ID 并跳转
if (!isNeo4j.value) {
const selectedDb = state.dbOptions.find(db => db.value === state.selectedDbId);
if (selectedDb && selectedDb.type !== 'neo4j') {
// 跳转到对应的知识库详情页面
router.push(`/database/${state.selectedDbId}`);
} else {
// 如果找不到对应的数据库,跳转到数据库列表页面
router.push('/database');
}
}
};
</script>
<style lang="less" scoped>
@graph-header-height: 55px;
.graph-container {
padding: 0;
background-color: var(--gray-0);
.header-container {
height: @graph-header-height;
}
}
.db-selector {
display: flex;
align-items: center;
margin-right: 20px;
.label {
font-size: 14px;
margin-right: 8px;
}
}
.status-wrapper {
display: flex;
align-items: center;
margin-right: 16px;
font-size: 14px;
color: var(--color-text-secondary);
}
.status-text {
margin-left: 8px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
&.loading {
background-color: var(--color-warning-500);
animation: pulse 1.5s infinite ease-in-out;
}
&.open {
background-color: var(--color-success-500);
}
&.closed {
background-color: var(--color-error-500);
}
}
@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%;
}
.tags {
display: flex;
gap: 8px;
}
}
.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;
}
}
.upload-tip-content {
.upload-tip-actions {
p {
margin-bottom: 16px;
color: var(--color-text-secondary);
}
}
.action-buttons {
display: flex;
justify-content: center;
margin-top: 20px;
}
}
</style>