1162 lines
32 KiB
Vue
1162 lines
32 KiB
Vue
<template>
|
||
<div class="graph-section" v-if="isGraphSupported">
|
||
<div class="graph-container-compact">
|
||
<div v-if="!isGraphSupported" class="graph-disabled">
|
||
<div class="disabled-content">
|
||
<h4>知识图谱不可用</h4>
|
||
<p>当前知识库类型 "{{ kbTypeLabel }}" 不支持知识图谱功能。</p>
|
||
<p>只有 Milvus 类型的知识库支持知识图谱。</p>
|
||
</div>
|
||
</div>
|
||
<div v-else class="graph-wrapper">
|
||
<GraphCanvas
|
||
ref="graphRef"
|
||
:graph-data="graph.graphData"
|
||
@node-click="graph.handleNodeClick"
|
||
@edge-click="graph.handleEdgeClick"
|
||
@canvas-click="graph.handleCanvasClick"
|
||
>
|
||
<template #top>
|
||
<div class="compact-actions">
|
||
<div class="actions-left">
|
||
<a-input
|
||
v-model:value="searchInput"
|
||
placeholder="搜索实体"
|
||
style="width: 240px"
|
||
@keydown.enter="onSearch"
|
||
allow-clear
|
||
>
|
||
<template #suffix>
|
||
<component
|
||
:is="graph.fetching ? Loader2 : Search"
|
||
:size="14"
|
||
class="search-suffix-icon"
|
||
@click="onSearch"
|
||
/>
|
||
</template>
|
||
</a-input>
|
||
<a-button class="action-btn" @click="loadGraph" title="刷新">
|
||
<RefreshCw :size="16" :class="{ spin: graph.fetching }" />
|
||
</a-button>
|
||
</div>
|
||
<div class="actions-right">
|
||
<a-button
|
||
v-if="isMilvus"
|
||
class="action-btn index-action-btn"
|
||
:class="{ 'has-index-label': hasPendingGraphChunks }"
|
||
@click="toggleBuildPanel"
|
||
:title="graphIndexButtonTitle"
|
||
:aria-label="graphIndexButtonTitle"
|
||
>
|
||
<Database :size="16" />
|
||
<span v-if="hasPendingGraphChunks" class="index-status-label"
|
||
>{{ pendingGraphChunks }} 待索引</span
|
||
>
|
||
<span
|
||
v-if="graphIndexDotStatus"
|
||
class="status-dot"
|
||
:class="`status-dot--${graphIndexDotStatus}`"
|
||
></span>
|
||
</a-button>
|
||
<a-button class="action-btn" @click="toggleSettingsPanel" title="设置">
|
||
<Settings :size="16" />
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</GraphCanvas>
|
||
<ResourceEmptyState
|
||
v-if="showGraphConfigEmpty"
|
||
class="graph-empty-state"
|
||
title="暂无知识图谱"
|
||
description="配置抽取器后,才能从当前知识库构建实体与关系。"
|
||
:icon="Network"
|
||
full-height
|
||
>
|
||
<template #actions>
|
||
<a-button type="primary" class="lucide-icon-btn" @click="openGraphConfig">
|
||
<Settings :size="16" />
|
||
配置抽取器
|
||
</a-button>
|
||
</template>
|
||
</ResourceEmptyState>
|
||
<ResourceEmptyState
|
||
v-else-if="showGraphDataEmpty"
|
||
class="graph-empty-state"
|
||
:title="graphDataEmptyTitle"
|
||
:description="graphDataEmptyDescription"
|
||
:icon="Network"
|
||
full-height
|
||
>
|
||
<template #actions>
|
||
<a-button v-if="searchInput.trim()" class="lucide-icon-btn" @click="clearGraphSearch">
|
||
<Search :size="16" />
|
||
清空搜索
|
||
</a-button>
|
||
<a-button
|
||
v-else-if="hasPendingGraphChunks && !isBuildActive"
|
||
type="primary"
|
||
class="lucide-icon-btn"
|
||
@click="startGraphBuild"
|
||
>
|
||
<Database :size="16" />
|
||
开始索引
|
||
</a-button>
|
||
<a-button v-else class="lucide-icon-btn" @click="loadGraph">
|
||
<RefreshCw :size="16" :class="{ spin: graph.fetching }" />
|
||
刷新图谱
|
||
</a-button>
|
||
</template>
|
||
</ResourceEmptyState>
|
||
|
||
<!-- 详情浮动卡片 -->
|
||
<GraphDetailPanel
|
||
:visible="graph.showDetailDrawer"
|
||
:item="graph.selectedItem"
|
||
:type="graph.selectedItemType"
|
||
@close="graph.handleCanvasClick"
|
||
/>
|
||
|
||
<!-- 设置浮动面板 -->
|
||
<transition name="slide-fade">
|
||
<div v-if="showSettings" class="floating-panel settings-panel">
|
||
<div class="panel-header">
|
||
<span class="panel-title">图谱设置</span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<a-form layout="vertical">
|
||
<a-form-item label="最大节点数 (limit)">
|
||
<a-input-number
|
||
v-model:value="subgraphParams.maxNodes"
|
||
:min="10"
|
||
:max="1000"
|
||
:step="10"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="搜索深度 (depth)">
|
||
<a-input-number
|
||
v-model:value="subgraphParams.maxDepth"
|
||
:min="1"
|
||
:max="5"
|
||
:step="1"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="排除 Chunk 节点">
|
||
<a-switch v-model:checked="subgraphParams.excludeChunk" />
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<a-button type="primary" @click="applySettings" style="width: 100%">
|
||
应用
|
||
</a-button>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- 索引管理浮动面板 -->
|
||
<transition name="slide-fade">
|
||
<div v-if="isMilvus && showBuildPanel" class="floating-panel build-panel">
|
||
<div class="panel-header">
|
||
<span class="panel-title">索引管理</span>
|
||
<a-button
|
||
size="small"
|
||
type="text"
|
||
:disabled="graphBuildLoading"
|
||
@click="loadGraphBuildStatus"
|
||
class="panel-refresh-btn"
|
||
>
|
||
<RefreshCw :size="14" :class="{ spin: graphBuildLoading }" />
|
||
</a-button>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="status-row">
|
||
<span class="status-label">状态</span>
|
||
<a-tag v-if="isBuildActive" color="blue" size="small">构建中</a-tag>
|
||
<a-tag v-else-if="isBuildFailed" color="red" size="small">构建失败</a-tag>
|
||
<a-tag v-else-if="graphBuildStatus?.locked" color="green" size="small"
|
||
>已配置</a-tag
|
||
>
|
||
<a-tag v-else color="orange" size="small">未配置</a-tag>
|
||
</div>
|
||
<a-progress
|
||
v-if="isBuildActive"
|
||
:percent="graphBuildStatus?.build_task_progress ?? 0"
|
||
:stroke-color="{ '0%': '#108ee9', '100%': '#87d068' }"
|
||
size="small"
|
||
style="margin-bottom: 10px"
|
||
/>
|
||
<div class="stats-grid">
|
||
<div class="stat-item">
|
||
<span class="stat-value">{{ graphBuildStatus?.total_chunks ?? '-' }}</span>
|
||
<span class="stat-label">总 Chunk</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value">{{ graphBuildStatus?.pending_chunks ?? '-' }}</span>
|
||
<span class="stat-label">待构建</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value">{{ graphBuildStatus?.indexed_chunks ?? '-' }}</span>
|
||
<span class="stat-label">已构建</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value">{{ graphBuildStatus?.entity_count ?? '-' }}</span>
|
||
<span class="stat-label">实体</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value">{{ graphBuildStatus?.relationship_count ?? '-' }}</span>
|
||
<span class="stat-label">关系</span>
|
||
</div>
|
||
</div>
|
||
<div class="build-actions">
|
||
<a-button
|
||
v-if="!graphBuildStatus?.locked"
|
||
type="primary"
|
||
block
|
||
@click="openGraphConfig"
|
||
>
|
||
配置抽取器
|
||
</a-button>
|
||
<a-button v-else-if="isBuildActive" type="primary" block disabled>
|
||
构建中 {{ graphBuildStatus?.build_task_progress ?? 0 }}%
|
||
</a-button>
|
||
<a-button
|
||
v-else-if="isBuildFailed"
|
||
type="primary"
|
||
block
|
||
:disabled="!graphBuildStatus?.pending_chunks"
|
||
@click="startGraphBuild"
|
||
>
|
||
重试索引
|
||
</a-button>
|
||
<a-button
|
||
v-else
|
||
type="primary"
|
||
block
|
||
:disabled="!graphBuildStatus?.pending_chunks"
|
||
@click="startGraphBuild"
|
||
>
|
||
开始索引
|
||
</a-button>
|
||
<div class="actions-secondary">
|
||
<a-button
|
||
v-if="graphBuildStatus?.locked && !isBuildActive"
|
||
size="small"
|
||
type="text"
|
||
@click="openGraphConfig"
|
||
>
|
||
修改配置
|
||
</a-button>
|
||
<a-button
|
||
size="small"
|
||
type="text"
|
||
danger
|
||
v-if="graphBuildStatus?.locked && !isBuildActive"
|
||
@click="confirmResetGraph"
|
||
>重置</a-button
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</div>
|
||
|
||
<a-modal
|
||
v-model:open="showGraphConfig"
|
||
:title="graphConfigTitle"
|
||
width="640px"
|
||
@ok="configureGraphBuild"
|
||
>
|
||
<a-form layout="vertical">
|
||
<a-alert
|
||
v-if="isEditingGraphConfig"
|
||
class="config-warning"
|
||
type="warning"
|
||
show-icon
|
||
message="修改配置仅影响后续构建;已构建的图谱不会自动重算,如需一致请重置后重新抽取。抽取器类型创建后不可修改。"
|
||
/>
|
||
<a-form-item label="抽取器类型">
|
||
<div class="extractor-type-cards" role="radiogroup" aria-label="抽取器类型">
|
||
<div
|
||
v-for="option in extractorTypeOptions"
|
||
:key="option.value"
|
||
class="extractor-type-card"
|
||
:class="{
|
||
active: graphConfigForm.extractor_type === option.value,
|
||
disabled: isEditingGraphConfig || option.disabled
|
||
}"
|
||
role="radio"
|
||
:aria-checked="graphConfigForm.extractor_type === option.value"
|
||
:aria-disabled="isEditingGraphConfig || option.disabled"
|
||
:tabindex="isEditingGraphConfig || option.disabled ? -1 : 0"
|
||
@click="selectExtractorType(option)"
|
||
@keydown.enter.prevent="selectExtractorType(option)"
|
||
@keydown.space.prevent="selectExtractorType(option)"
|
||
>
|
||
<div class="card-header">
|
||
<component :is="option.icon" class="type-icon" />
|
||
<span class="type-title">{{ option.label }}</span>
|
||
</div>
|
||
<div class="card-description">{{ option.description }}</div>
|
||
<div v-if="option.helper" class="card-helper" :class="{ warning: option.disabled }">
|
||
{{ option.helper }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="模型">
|
||
<ModelSelectorComponent
|
||
:model_spec="graphConfigForm.model_spec"
|
||
placeholder="选择抽取模型"
|
||
@select-model="(spec) => (graphConfigForm.model_spec = spec)"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="Schema">
|
||
<a-textarea
|
||
v-model:value="graphConfigForm.schema"
|
||
:rows="6"
|
||
placeholder="描述实体类型、关系类型和属性约束。后端会把 Schema 拼接到固定抽取 Prompt 中。"
|
||
/>
|
||
</a-form-item>
|
||
<div class="form-grid two-columns">
|
||
<a-form-item label="并发队列数">
|
||
<a-input-number
|
||
v-model:value="graphConfigForm.concurrency_count"
|
||
:min="1"
|
||
:max="1000"
|
||
:step="1"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="模型参数 JSON">
|
||
<a-input
|
||
v-model:value="graphConfigForm.model_params_text"
|
||
placeholder='例如 {"temperature":0.1}'
|
||
/>
|
||
</a-form-item>
|
||
</div>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, nextTick, onUnmounted, reactive } from 'vue'
|
||
import { useDatabaseStore } from '@/stores/database'
|
||
import { useTaskerStore } from '@/stores/tasker'
|
||
import { useConfigStore } from '@/stores/config'
|
||
import {
|
||
RefreshCw,
|
||
Settings,
|
||
Search,
|
||
Loader2,
|
||
Database,
|
||
Network,
|
||
BrainCircuit,
|
||
ScanText
|
||
} from 'lucide-vue-next'
|
||
import GraphCanvas from '@/components/GraphCanvas.vue'
|
||
import GraphDetailPanel from '@/components/GraphDetailPanel.vue'
|
||
import ResourceEmptyState from '@/components/shared/ResourceEmptyState.vue'
|
||
import { getKbTypeLabel } from '@/utils/kb_utils'
|
||
import { unifiedApi } from '@/apis/graph_api'
|
||
import { graphBuildApi } from '@/apis/knowledge_api'
|
||
import { Modal, message } from 'ant-design-vue'
|
||
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
|
||
import { useGraph } from '@/composables/useGraph'
|
||
|
||
const GRAPH_BUILD_TASK_TYPE = 'knowledge_graph_index'
|
||
const MILVUS_KB_TYPE = 'milvus'
|
||
const GRAPH_SUPPORTED_KB_TYPES = new Set([MILVUS_KB_TYPE])
|
||
|
||
const props = defineProps({
|
||
active: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
})
|
||
|
||
const store = useDatabaseStore()
|
||
const taskerStore = useTaskerStore()
|
||
const configStore = useConfigStore()
|
||
|
||
const kbId = computed(() => store.kbId)
|
||
const kbType = computed(() => store.database.kb_type)
|
||
const kbTypeLabel = computed(() => getKbTypeLabel(kbType.value || 'milvus'))
|
||
const isMilvus = computed(() => kbType.value?.toLowerCase() === MILVUS_KB_TYPE)
|
||
|
||
const graphRef = ref(null)
|
||
const showSettings = ref(false)
|
||
const showBuildPanel = ref(false)
|
||
const subgraphParams = reactive({
|
||
maxNodes: 100,
|
||
maxDepth: 2,
|
||
excludeChunk: true
|
||
})
|
||
const searchInput = ref('')
|
||
const graphBuildStatus = ref(null)
|
||
const graphBuildLoading = ref(false)
|
||
const showGraphConfig = ref(false)
|
||
let buildStatusPollTimer = null
|
||
|
||
const extractorTypeOptions = [
|
||
{
|
||
value: 'llm',
|
||
label: 'LLM',
|
||
description: '使用大模型按 Schema 抽取实体和关系',
|
||
helper: '当前唯一支持的图谱抽取方式',
|
||
icon: BrainCircuit,
|
||
disabled: false
|
||
},
|
||
{
|
||
value: 'more',
|
||
label: '更多',
|
||
description: '更多抽取方式正在拓展中',
|
||
helper: '拓展中',
|
||
icon: ScanText,
|
||
disabled: true
|
||
}
|
||
]
|
||
|
||
const isBuildActive = computed(() => {
|
||
const s = graphBuildStatus.value?.build_task_status
|
||
return s === 'pending' || s === 'running'
|
||
})
|
||
|
||
const isBuildFailed = computed(() => {
|
||
return graphBuildStatus.value?.build_task_status === 'failed'
|
||
})
|
||
|
||
const pendingGraphChunks = computed(() => {
|
||
return Number(graphBuildStatus.value?.pending_chunks ?? 0)
|
||
})
|
||
|
||
const hasPendingGraphChunks = computed(() => pendingGraphChunks.value > 0)
|
||
|
||
const isGraphIndexComplete = computed(() => {
|
||
return (
|
||
Boolean(graphBuildStatus.value?.locked) &&
|
||
!isBuildActive.value &&
|
||
pendingGraphChunks.value === 0
|
||
)
|
||
})
|
||
|
||
const graphIndexDotStatus = computed(() => {
|
||
if (isBuildActive.value) return 'active'
|
||
if (hasPendingGraphChunks.value) return 'pending'
|
||
if (isGraphIndexComplete.value) return 'complete'
|
||
return ''
|
||
})
|
||
|
||
const graphIndexButtonTitle = computed(() => {
|
||
if (hasPendingGraphChunks.value) return `索引管理,${pendingGraphChunks.value} 待索引`
|
||
if (isGraphIndexComplete.value) return '索引管理,已全部索引'
|
||
if (isBuildActive.value) return '索引管理,索引中'
|
||
return '索引管理'
|
||
})
|
||
|
||
const toggleBuildPanel = () => {
|
||
showBuildPanel.value = !showBuildPanel.value
|
||
showSettings.value = false
|
||
}
|
||
|
||
const toggleSettingsPanel = () => {
|
||
showSettings.value = !showSettings.value
|
||
showBuildPanel.value = false
|
||
}
|
||
|
||
const isEditingGraphConfig = computed(() => Boolean(graphBuildStatus.value?.locked))
|
||
|
||
const graphConfigTitle = computed(() =>
|
||
isEditingGraphConfig.value ? '修改图谱抽取配置' : '配置图谱抽取器'
|
||
)
|
||
|
||
const stopBuildStatusPoll = () => {
|
||
if (buildStatusPollTimer) {
|
||
clearInterval(buildStatusPollTimer)
|
||
buildStatusPollTimer = null
|
||
}
|
||
}
|
||
|
||
const startBuildStatusPoll = () => {
|
||
stopBuildStatusPoll()
|
||
buildStatusPollTimer = setInterval(() => {
|
||
loadGraphBuildStatus()
|
||
}, 5000)
|
||
}
|
||
|
||
watch(
|
||
isBuildActive,
|
||
(active) => {
|
||
if (active) {
|
||
startBuildStatusPoll()
|
||
} else {
|
||
stopBuildStatusPoll()
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
const graphConfigForm = reactive({
|
||
extractor_type: 'llm',
|
||
model_spec: '',
|
||
schema: '',
|
||
concurrency_count: 50,
|
||
model_params_text: ''
|
||
})
|
||
|
||
const graph = reactive(useGraph(graphRef))
|
||
const graphLoaded = ref(false)
|
||
|
||
// 计算属性:是否支持知识图谱
|
||
const isGraphSupported = computed(() => GRAPH_SUPPORTED_KB_TYPES.has(kbType.value?.toLowerCase()))
|
||
const hasGraphNodes = computed(() => graph.graphData.nodes.length > 0)
|
||
const showGraphConfigEmpty = computed(
|
||
() => isMilvus.value && !graphBuildStatus.value?.locked && !graphBuildLoading.value
|
||
)
|
||
const showGraphDataEmpty = computed(
|
||
() =>
|
||
isMilvus.value &&
|
||
Boolean(graphBuildStatus.value?.locked) &&
|
||
graphLoaded.value &&
|
||
!graph.fetching &&
|
||
!hasGraphNodes.value
|
||
)
|
||
const graphDataEmptyTitle = computed(() =>
|
||
searchInput.value.trim() ? '未找到匹配实体' : '暂无知识图谱'
|
||
)
|
||
const graphDataEmptyDescription = computed(() => {
|
||
if (searchInput.value.trim()) return '换个关键词或调整图谱设置后再搜索。'
|
||
if (isBuildActive.value) return '图谱索引正在运行,完成后会展示实体与关系。'
|
||
if (hasPendingGraphChunks.value) return '当前还有待索引 Chunk,完成索引后会展示实体与关系。'
|
||
return '当前知识库还没有可展示的实体与关系。'
|
||
})
|
||
|
||
let pendingLoadTimer = null
|
||
let graphStatusRequestSeq = 0
|
||
let graphLoadRequestSeq = 0
|
||
|
||
const getErrorDetail = (e, fallback) => {
|
||
return e?.response?.data?.detail || e?.response?.data?.message || e?.message || fallback
|
||
}
|
||
|
||
const loadGraphBuildStatus = async () => {
|
||
if (!kbId.value || !isMilvus.value) return
|
||
const requestSeq = ++graphStatusRequestSeq
|
||
const currentDatabaseId = kbId.value
|
||
graphBuildLoading.value = true
|
||
try {
|
||
const status = await graphBuildApi.getStatus(currentDatabaseId)
|
||
if (requestSeq === graphStatusRequestSeq && currentDatabaseId === kbId.value) {
|
||
graphBuildStatus.value = status
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load graph build status:', e)
|
||
message.error('加载图谱构建状态失败')
|
||
} finally {
|
||
if (requestSeq === graphStatusRequestSeq) {
|
||
graphBuildLoading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const parseModelParams = () => {
|
||
const text = graphConfigForm.model_params_text.trim()
|
||
if (!text) return {}
|
||
let params
|
||
try {
|
||
params = JSON.parse(text)
|
||
} catch {
|
||
throw new Error('模型参数必须是合法 JSON 对象')
|
||
}
|
||
if (!params || Array.isArray(params) || typeof params !== 'object') {
|
||
throw new Error('模型参数必须是 JSON 对象')
|
||
}
|
||
return params
|
||
}
|
||
|
||
const fillGraphConfigForm = () => {
|
||
const config = graphBuildStatus.value?.config
|
||
const options = config?.extractor_options || {}
|
||
graphConfigForm.extractor_type = 'llm'
|
||
graphConfigForm.model_spec = options.model_spec || configStore.config?.default_model || ''
|
||
graphConfigForm.schema = options.schema || ''
|
||
graphConfigForm.concurrency_count = Number(options.concurrency_count || 50)
|
||
graphConfigForm.model_params_text = options.model_params
|
||
? JSON.stringify(options.model_params)
|
||
: ''
|
||
}
|
||
|
||
const openGraphConfig = () => {
|
||
fillGraphConfigForm()
|
||
showGraphConfig.value = true
|
||
}
|
||
|
||
const selectExtractorType = (option) => {
|
||
if (isEditingGraphConfig.value || option.disabled) return
|
||
graphConfigForm.extractor_type = option.value
|
||
}
|
||
|
||
const buildExtractorOptions = () => {
|
||
return {
|
||
model_spec: graphConfigForm.model_spec,
|
||
schema: graphConfigForm.schema.trim(),
|
||
concurrency_count: graphConfigForm.concurrency_count || 50,
|
||
model_params: parseModelParams()
|
||
}
|
||
}
|
||
|
||
const configureGraphBuild = async () => {
|
||
try {
|
||
document.activeElement?.blur()
|
||
await nextTick()
|
||
await graphBuildApi.configure(kbId.value, {
|
||
extractor_type: 'llm',
|
||
extractor_options: buildExtractorOptions()
|
||
})
|
||
message.success(isEditingGraphConfig.value ? '图谱抽取配置已更新' : '图谱抽取配置已保存')
|
||
showGraphConfig.value = false
|
||
await loadGraphBuildStatus()
|
||
} catch (e) {
|
||
console.error('Failed to configure graph build:', e)
|
||
message.error(getErrorDetail(e, '配置图谱抽取失败'))
|
||
}
|
||
}
|
||
|
||
const startGraphBuild = async () => {
|
||
try {
|
||
const data = await graphBuildApi.startIndex(kbId.value, 20)
|
||
message.success(data.message || '图谱构建任务已提交')
|
||
if (data.task_id) {
|
||
taskerStore.registerQueuedTask({
|
||
task_id: data.task_id,
|
||
name: `图谱构建 (${kbId.value})`,
|
||
task_type: GRAPH_BUILD_TASK_TYPE,
|
||
message: data.message,
|
||
payload: { kb_id: kbId.value }
|
||
})
|
||
}
|
||
await loadGraphBuildStatus()
|
||
} catch (e) {
|
||
console.error('Failed to start graph build:', e)
|
||
message.error(getErrorDetail(e, '提交图谱构建任务失败'))
|
||
}
|
||
}
|
||
|
||
const confirmResetGraph = () => {
|
||
Modal.confirm({
|
||
title: '清空并重建图谱',
|
||
content: '将删除该知识库在 Neo4j 中的图谱,重置 Chunk 图谱状态,并清空抽取结果与配置。',
|
||
okText: '确认重置',
|
||
cancelText: '取消',
|
||
onOk: resetGraphBuild
|
||
})
|
||
}
|
||
|
||
const resetGraphBuild = async () => {
|
||
try {
|
||
await graphBuildApi.reset(kbId.value, {
|
||
clear_extraction_result: true,
|
||
clear_config: true
|
||
})
|
||
message.success('图谱构建状态已重置')
|
||
graphLoaded.value = false
|
||
graph.clearGraph()
|
||
await loadGraphBuildStatus()
|
||
} catch (e) {
|
||
console.error('Failed to reset graph build:', e)
|
||
message.error(getErrorDetail(e, '重置图谱构建状态失败'))
|
||
}
|
||
}
|
||
|
||
const loadGraph = async () => {
|
||
if (!kbId.value || !isGraphSupported.value) return
|
||
|
||
const requestSeq = ++graphLoadRequestSeq
|
||
const currentDatabaseId = kbId.value
|
||
graph.fetching = true
|
||
if (!hasGraphNodes.value) {
|
||
graphLoaded.value = false
|
||
}
|
||
try {
|
||
const res = await unifiedApi.getSubgraph({
|
||
kb_id: currentDatabaseId,
|
||
node_label: searchInput.value || '*',
|
||
max_nodes: subgraphParams.maxNodes,
|
||
max_depth: subgraphParams.maxDepth,
|
||
exclude_chunk: subgraphParams.excludeChunk
|
||
})
|
||
|
||
if (
|
||
requestSeq === graphLoadRequestSeq &&
|
||
currentDatabaseId === kbId.value &&
|
||
res.success &&
|
||
res.data
|
||
) {
|
||
graph.updateGraphData(res.data.nodes, res.data.edges)
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load graph:', e)
|
||
message.error('加载图谱失败')
|
||
} finally {
|
||
if (requestSeq === graphLoadRequestSeq) {
|
||
graph.fetching = false
|
||
graphLoaded.value = true
|
||
}
|
||
}
|
||
}
|
||
|
||
const applySettings = () => {
|
||
showSettings.value = false
|
||
loadGraph()
|
||
}
|
||
|
||
const onSearch = () => {
|
||
loadGraph()
|
||
}
|
||
|
||
const clearGraphSearch = () => {
|
||
searchInput.value = ''
|
||
loadGraph()
|
||
}
|
||
|
||
const scheduleGraphLoad = (delay = 200) => {
|
||
if (!props.active || !isGraphSupported.value || !kbId.value) {
|
||
return
|
||
}
|
||
|
||
if (pendingLoadTimer) {
|
||
clearTimeout(pendingLoadTimer)
|
||
}
|
||
pendingLoadTimer = setTimeout(async () => {
|
||
pendingLoadTimer = null
|
||
await nextTick()
|
||
if (props.active && isGraphSupported.value && kbId.value) {
|
||
await loadGraph()
|
||
}
|
||
}, delay)
|
||
}
|
||
|
||
watch(
|
||
() => props.active,
|
||
(active) => {
|
||
if (active) {
|
||
if (isMilvus.value) {
|
||
loadGraphBuildStatus()
|
||
}
|
||
scheduleGraphLoad()
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(kbId, () => {
|
||
graphStatusRequestSeq += 1
|
||
graphLoadRequestSeq += 1
|
||
graphLoaded.value = false
|
||
graph.clearGraph()
|
||
graphBuildStatus.value = null
|
||
if (isMilvus.value) {
|
||
loadGraphBuildStatus()
|
||
}
|
||
if (isGraphSupported.value) {
|
||
scheduleGraphLoad(300)
|
||
}
|
||
})
|
||
|
||
watch(isGraphSupported, (supported) => {
|
||
if (!supported) {
|
||
graphLoaded.value = false
|
||
graph.clearGraph()
|
||
graphBuildStatus.value = null
|
||
return
|
||
}
|
||
if (isMilvus.value) {
|
||
loadGraphBuildStatus()
|
||
}
|
||
scheduleGraphLoad(200)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (pendingLoadTimer) {
|
||
clearTimeout(pendingLoadTimer)
|
||
pendingLoadTimer = null
|
||
}
|
||
stopBuildStatusPoll()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.graph-section {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
position: relative;
|
||
user-select: none;
|
||
}
|
||
|
||
.graph-container-compact {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.graph-wrapper {
|
||
height: 100%;
|
||
width: 100%;
|
||
position: relative;
|
||
}
|
||
|
||
.graph-empty-state {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 30;
|
||
pointer-events: none;
|
||
|
||
:deep(.resource-empty-state__actions) {
|
||
pointer-events: auto;
|
||
}
|
||
}
|
||
|
||
.compact-actions {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
right: 10px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
pointer-events: none; /* Let clicks pass through empty areas */
|
||
|
||
.actions-left,
|
||
.actions-right {
|
||
pointer-events: auto; /* Re-enable clicks for buttons/inputs */
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
background: var(--color-trans-light);
|
||
backdrop-filter: blur(12px);
|
||
padding: 2px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 4px 0px var(--shadow-2);
|
||
border: 1px solid var(--gray-100);
|
||
}
|
||
|
||
:deep(.ant-input-affix-wrapper) {
|
||
padding: 4px 11px;
|
||
border-radius: 6px;
|
||
border-color: transparent;
|
||
box-shadow: none;
|
||
background: var(--color-trans-light);
|
||
|
||
&:hover,
|
||
&:focus,
|
||
&-focused {
|
||
background: var(--main-0);
|
||
border-color: var(--primary-color);
|
||
}
|
||
|
||
input {
|
||
background: transparent;
|
||
}
|
||
}
|
||
|
||
.action-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
padding: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--gray-600);
|
||
border-radius: 6px;
|
||
box-shadow: none;
|
||
position: relative;
|
||
|
||
&:hover {
|
||
background: var(--shadow-1);
|
||
color: var(--primary-color);
|
||
}
|
||
}
|
||
|
||
.index-action-btn {
|
||
gap: 6px;
|
||
overflow: visible;
|
||
|
||
&.has-index-label {
|
||
width: auto;
|
||
min-width: 84px;
|
||
padding: 0 22px 0 8px;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.index-status-label {
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
color: var(--gray-700);
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.status-dot {
|
||
position: absolute;
|
||
bottom: 4px;
|
||
right: 4px;
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 0 1px var(--color-trans-light);
|
||
}
|
||
|
||
.status-dot--pending {
|
||
background: var(--color-warning-500);
|
||
}
|
||
|
||
.status-dot--active {
|
||
background: var(--color-warning-500);
|
||
animation: blink 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
.status-dot--complete {
|
||
background: var(--color-success-500);
|
||
}
|
||
|
||
.search-suffix-icon {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.spin {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
}
|
||
|
||
@keyframes blink {
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.2;
|
||
}
|
||
}
|
||
|
||
.graph-disabled {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 100%;
|
||
}
|
||
|
||
.disabled-content {
|
||
text-align: center;
|
||
color: var(--gray-400);
|
||
|
||
h4 {
|
||
margin-bottom: 8px;
|
||
}
|
||
}
|
||
|
||
.floating-panel {
|
||
position: absolute;
|
||
top: 60px;
|
||
right: 10px;
|
||
width: 300px;
|
||
max-height: calc(100% - 60px);
|
||
overflow-y: auto;
|
||
z-index: 100;
|
||
background: var(--color-trans-light);
|
||
backdrop-filter: blur(12px);
|
||
-webkit-backdrop-filter: blur(12px);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--gray-100);
|
||
box-shadow: 0 0 4px 0px var(--shadow-2);
|
||
font-size: 13px;
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid var(--gray-200);
|
||
|
||
.panel-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--gray-1000);
|
||
}
|
||
|
||
.panel-refresh-btn {
|
||
padding: 2px 6px;
|
||
}
|
||
}
|
||
|
||
.panel-body {
|
||
padding: 10px 14px;
|
||
}
|
||
}
|
||
|
||
.build-panel {
|
||
.status-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
|
||
.status-label {
|
||
color: var(--gray-600);
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.stat-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 6px 4px;
|
||
border-radius: 4px;
|
||
background: var(--gray-50);
|
||
|
||
.stat-value {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--gray-1000);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 11px;
|
||
color: var(--gray-500);
|
||
margin-top: 2px;
|
||
}
|
||
}
|
||
|
||
.build-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.actions-secondary {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
}
|
||
}
|
||
|
||
.config-warning {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.extractor-type-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
|
||
.extractor-type-card {
|
||
border: 1px solid var(--gray-150);
|
||
border-radius: 8px;
|
||
padding: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
background: var(--gray-0);
|
||
|
||
&:hover {
|
||
border-color: var(--main-color);
|
||
}
|
||
|
||
&.active {
|
||
border-color: var(--main-color);
|
||
background: var(--main-10);
|
||
box-shadow: 0 0 0 1px var(--main-20);
|
||
|
||
.type-icon {
|
||
color: var(--main-color);
|
||
}
|
||
}
|
||
|
||
&.disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.72;
|
||
background: var(--gray-50);
|
||
|
||
&:hover {
|
||
border-color: var(--gray-150);
|
||
}
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.type-icon {
|
||
width: 20px;
|
||
height: 20px;
|
||
color: var(--main-color);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.type-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--gray-800);
|
||
}
|
||
|
||
.card-description {
|
||
font-size: 13px;
|
||
color: var(--gray-600);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.card-helper {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: var(--gray-500);
|
||
|
||
&.warning {
|
||
color: var(--color-warning-500);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.form-grid.two-columns {
|
||
display: grid;
|
||
grid-template-columns: 180px 1fr;
|
||
gap: 12px;
|
||
|
||
@media (max-width: 640px) {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.slide-fade-enter-active {
|
||
transition: all 0.25s ease-out;
|
||
}
|
||
|
||
.slide-fade-leave-active {
|
||
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
||
}
|
||
|
||
.slide-fade-enter-from,
|
||
.slide-fade-leave-to {
|
||
transform: translateX(20px);
|
||
opacity: 0;
|
||
}
|
||
</style>
|