WikiGraph.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. <template>
  2. <div class="wiki-graph">
  3. <div class="wiki-graph__header">
  4. <div>
  5. <div class="wiki-graph__title">知识图谱</div>
  6. <div class="wiki-graph__desc">基于 Wiki 页面与关系边构建的图谱视图,可拖拽、缩放。</div>
  7. <div class="wiki-graph__summary">
  8. <div v-for="item in graphSummary" :key="item.label" class="wiki-graph-stat">
  9. <span class="wiki-graph-stat__label">{{ item.label }}</span>
  10. <span class="wiki-graph-stat__value">{{ item.value }}</span>
  11. </div>
  12. </div>
  13. </div>
  14. <div class="wiki-graph__legend">
  15. <div v-for="item in legendItems" :key="item.type" class="wiki-graph-legend">
  16. <span class="wiki-graph-legend__dot" :style="{ backgroundColor: item.color }"></span>
  17. <span>{{ item.label }}</span>
  18. </div>
  19. </div>
  20. </div>
  21. <div ref="graphContainerRef" class="wiki-graph__canvas" v-loading="graphLoading"></div>
  22. </div>
  23. </template>
  24. <script setup lang="ts">
  25. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  26. import * as d3 from 'd3'
  27. import { wiki } from '@repo/api-service'
  28. import type { KnowledgeBaseItem } from './types'
  29. type WikiPageType = 'summary' | 'entity' | 'concept' | 'index' | 'log' | string
  30. interface WikiGraphNode {
  31. id: string
  32. title: string
  33. slug: string
  34. page_type: WikiPageType
  35. linkCount: number
  36. }
  37. interface WikiGraphEdge {
  38. id: string
  39. source: string
  40. target: string
  41. weight: number
  42. }
  43. interface WikiStats {
  44. total_links: number
  45. }
  46. interface ForceNode extends WikiGraphNode {
  47. fx?: number | null
  48. fy?: number | null
  49. x?: number
  50. y?: number
  51. }
  52. interface ForceLink {
  53. id: string
  54. source: string | ForceNode
  55. target: string | ForceNode
  56. weight: number
  57. }
  58. const props = defineProps<{
  59. currentBase: KnowledgeBaseItem
  60. }>()
  61. const graphContainerRef = ref<HTMLDivElement>()
  62. const graphLoading = ref(false)
  63. const selectedSlug = ref('')
  64. const stats = ref<WikiStats>({
  65. total_links: 0
  66. })
  67. const graphData = ref<{ nodes: WikiGraphNode[]; edges: WikiGraphEdge[] }>({
  68. nodes: [],
  69. edges: []
  70. })
  71. const legendItems = [
  72. { type: 'summary', label: '摘要', color: '#1d6ff2' },
  73. { type: 'entity', label: '实体', color: '#2cb67d' },
  74. { type: 'concept', label: '概念', color: '#f08c24' },
  75. { type: 'index', label: '索引', color: '#3fa7ff' },
  76. { type: 'log', label: '日志', color: '#eb5757' }
  77. ]
  78. const graphSummary = computed(() => {
  79. const duplicateCount =
  80. stats.value.total_links > graphData.value.edges.length
  81. ? stats.value.total_links - graphData.value.edges.length
  82. : 0
  83. return [
  84. { label: '节点数', value: graphData.value.nodes.length },
  85. { label: '关系数', value: graphData.value.edges.length },
  86. { label: '合并重复边', value: duplicateCount }
  87. ]
  88. })
  89. let simulation: any = null
  90. function getNodeColor(type: WikiPageType) {
  91. switch (type) {
  92. case 'summary':
  93. return '#1d6ff2'
  94. case 'entity':
  95. return '#2cb67d'
  96. case 'concept':
  97. return '#f08c24'
  98. case 'index':
  99. return '#3fa7ff'
  100. case 'log':
  101. return '#eb5757'
  102. default:
  103. return '#9aa4b2'
  104. }
  105. }
  106. function truncateLabel(label: string, max = 14) {
  107. return label.length > max ? `${label.slice(0, max)}...` : label
  108. }
  109. async function loadStats() {
  110. if (!props.currentBase.id) return
  111. const res = await wiki.postAiWikiStats({ knowledge_base_id: props.currentBase.id })
  112. if (res?.isSuccess) {
  113. stats.value = {
  114. total_links: res.result?.total_links || 0
  115. }
  116. }
  117. }
  118. async function loadGraph() {
  119. if (!props.currentBase.id) return
  120. graphLoading.value = true
  121. try {
  122. const res = await wiki.postAiWikiGraph({ knowledge_base_id: props.currentBase.id })
  123. if (res?.isSuccess) {
  124. const nodes = res.result?.nodes || []
  125. const nodeSlugSet = new Set(nodes.map((item) => item.slug).filter(Boolean))
  126. const edgeMap = new Map<string, WikiGraphEdge>()
  127. for (const item of res.result?.edges || []) {
  128. if (!item.source || !item.target) continue
  129. if (item.source === item.target) continue
  130. if (!nodeSlugSet.has(item.source) || !nodeSlugSet.has(item.target)) continue
  131. const edgeKey = `${item.source}__${item.target}`
  132. const current = edgeMap.get(edgeKey)
  133. if (current) {
  134. current.weight += 1
  135. continue
  136. }
  137. edgeMap.set(edgeKey, {
  138. id: item.id,
  139. source: item.source,
  140. target: item.target,
  141. weight: 1
  142. })
  143. }
  144. graphData.value = {
  145. nodes: nodes.map((item) => ({
  146. id: item.slug,
  147. title: item.title,
  148. slug: item.slug,
  149. page_type: item.page_type,
  150. linkCount: item.linkCount
  151. })),
  152. edges: Array.from(edgeMap.values())
  153. }
  154. await nextTick()
  155. renderGraph()
  156. }
  157. } finally {
  158. graphLoading.value = false
  159. }
  160. }
  161. function highlightSelectedNode(slug = '') {
  162. const root = graphContainerRef.value
  163. if (!root) return
  164. const relationSet = new Set<string>()
  165. if (slug) {
  166. for (const edge of graphData.value.edges) {
  167. if (edge.source === slug) relationSet.add(edge.target)
  168. if (edge.target === slug) relationSet.add(edge.source)
  169. }
  170. }
  171. root.querySelectorAll<SVGCircleElement>('.wiki-node-circle').forEach((node) => {
  172. const isActive = node.dataset.slug === slug
  173. const isRelated = relationSet.has(node.dataset.slug || '')
  174. node.setAttribute(
  175. 'stroke',
  176. isActive ? '#ffffff' : isRelated ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.78)'
  177. )
  178. node.setAttribute('stroke-width', isActive ? '3.5' : isRelated ? '2.2' : '1.5')
  179. node.setAttribute('opacity', slug && !isActive && !isRelated ? '0.35' : '1')
  180. })
  181. root.querySelectorAll<SVGLineElement>('.wiki-link-line').forEach((line) => {
  182. const source = line.dataset.source || ''
  183. const target = line.dataset.target || ''
  184. const isActive = !!slug && (source === slug || target === slug)
  185. line.setAttribute('stroke', isActive ? 'rgba(255,255,255,0.68)' : 'rgba(255,255,255,0.22)')
  186. line.setAttribute('opacity', slug && !isActive ? '0.18' : '1')
  187. })
  188. root.querySelectorAll<SVGTextElement>('.wiki-node-label').forEach((label) => {
  189. const isActive = label.dataset.slug === slug
  190. const isRelated = relationSet.has(label.dataset.slug || '')
  191. label.setAttribute('opacity', slug && !isActive && !isRelated ? '0.38' : '1')
  192. label.setAttribute('font-weight', isActive ? '700' : isRelated ? '600' : '500')
  193. })
  194. }
  195. function renderGraph() {
  196. const container = graphContainerRef.value
  197. if (!container) return
  198. simulation?.stop()
  199. container.innerHTML = ''
  200. const width = Math.max(container.clientWidth, 720)
  201. const height = Math.max(container.clientHeight, 520)
  202. const nodes: ForceNode[] = graphData.value.nodes.map((item) => ({ ...item }))
  203. const links: ForceLink[] = graphData.value.edges.map((item) => ({ ...item }))
  204. const svg = d3
  205. .select(container)
  206. .append('svg')
  207. .attr('width', width)
  208. .attr('height', height)
  209. .attr('viewBox', `0 0 ${width} ${height}`)
  210. const defs = svg.append('defs')
  211. defs
  212. .append('marker')
  213. .attr('id', 'wiki-arrow')
  214. .attr('viewBox', '0 -5 10 10')
  215. .attr('refX', 15)
  216. .attr('refY', 0)
  217. .attr('markerWidth', 4.5)
  218. .attr('markerHeight', 4.5)
  219. .attr('orient', 'auto')
  220. .append('path')
  221. .attr('d', 'M0,-5L10,0L0,5')
  222. .attr('fill', 'rgba(255,255,255,0.65)')
  223. const root = svg.append('g')
  224. svg.call(
  225. d3
  226. .zoom()
  227. .scaleExtent([0.4, 2.5])
  228. .on('zoom', (event: any) => {
  229. root.attr('transform', event.transform)
  230. })
  231. )
  232. const link = root
  233. .append('g')
  234. .selectAll('line')
  235. .data(links)
  236. .enter()
  237. .append('line')
  238. .attr('class', 'wiki-link-line')
  239. .attr('data-source', (d: ForceLink) => `${d.source}`)
  240. .attr('data-target', (d: ForceLink) => `${d.target}`)
  241. .attr('stroke', 'rgba(255,255,255,0.22)')
  242. .attr('stroke-width', (d: ForceLink) => 0.75 + Math.min(1.8, d.weight * 0.24))
  243. .attr('marker-end', 'url(#wiki-arrow)')
  244. const nodeGroup = root
  245. .append('g')
  246. .selectAll('g')
  247. .data(nodes)
  248. .enter()
  249. .append('g')
  250. .style('cursor', 'pointer')
  251. .on('click', (_event: any, datum: ForceNode) => {
  252. selectedSlug.value = datum.slug
  253. highlightSelectedNode(datum.slug)
  254. })
  255. nodeGroup
  256. .append('circle')
  257. .attr('class', 'wiki-node-circle')
  258. .attr('data-slug', (d: ForceNode) => d.slug)
  259. .attr('r', (d: ForceNode) => Math.max(8, Math.min(18, 8 + d.linkCount * 0.8)))
  260. .attr('fill', (d: ForceNode) => getNodeColor(d.page_type))
  261. .attr('stroke', 'rgba(255,255,255,0.9)')
  262. .attr('stroke-width', 1.5)
  263. nodeGroup
  264. .append('text')
  265. .attr('class', 'wiki-node-label')
  266. .attr('data-slug', (d: ForceNode) => d.slug)
  267. .text((d: ForceNode) => truncateLabel(d.title))
  268. .attr('fill', '#e5e7eb')
  269. .attr('font-size', 11)
  270. .attr('text-anchor', 'middle')
  271. .attr('font-weight', 500)
  272. .attr('dy', 24)
  273. simulation = d3
  274. .forceSimulation(nodes)
  275. .force(
  276. 'link',
  277. d3
  278. .forceLink(links as any)
  279. .id((d: any) => d.id)
  280. .distance((d: ForceLink) => {
  281. const sourceCount = (d.source as ForceNode).linkCount || 0
  282. const targetCount = (d.target as ForceNode).linkCount || 0
  283. const density = Math.max(sourceCount, targetCount)
  284. return 44 + Math.min(48, density * 2) - Math.min(18, d.weight * 2)
  285. })
  286. .strength((d: ForceLink) => Math.min(0.95, 0.22 + d.weight * 0.12))
  287. )
  288. .force('charge', d3.forceManyBody().strength(-160))
  289. .force('center', d3.forceCenter(width / 2, height / 2))
  290. .force(
  291. 'collision',
  292. d3.forceCollide().radius((d: any) => Math.max(16, 10 + d.linkCount * 0.8))
  293. )
  294. .force('x', d3.forceX(width / 2).strength(0.04))
  295. .force('y', d3.forceY(height / 2).strength(0.04))
  296. .on('tick', () => {
  297. link
  298. .attr('x1', (d: ForceLink) => (d.source as ForceNode).x || 0)
  299. .attr('y1', (d: ForceLink) => (d.source as ForceNode).y || 0)
  300. .attr('x2', (d: ForceLink) => (d.target as ForceNode).x || 0)
  301. .attr('y2', (d: ForceLink) => (d.target as ForceNode).y || 0)
  302. nodeGroup.attr('transform', (d: ForceNode) => `translate(${d.x || 0},${d.y || 0})`)
  303. })
  304. nodeGroup.call(
  305. d3
  306. .drag()
  307. .on('start', (event: any, d: ForceNode) => {
  308. if (!event.active) simulation?.alphaTarget(0.3).restart()
  309. d.fx = d.x
  310. d.fy = d.y
  311. })
  312. .on('drag', (event: any, d: ForceNode) => {
  313. d.fx = event.x
  314. d.fy = event.y
  315. })
  316. .on('end', (event: any, d: ForceNode) => {
  317. if (!event.active) simulation?.alphaTarget(0)
  318. d.fx = null
  319. d.fy = null
  320. })
  321. )
  322. highlightSelectedNode(selectedSlug.value)
  323. }
  324. async function loadAll() {
  325. if (!props.currentBase.id) return
  326. await loadStats()
  327. await loadGraph()
  328. }
  329. watch(
  330. () => props.currentBase.id,
  331. () => {
  332. selectedSlug.value = ''
  333. graphData.value = { nodes: [], edges: [] }
  334. stats.value = { total_links: 0 }
  335. if (props.currentBase.id) {
  336. void loadAll()
  337. }
  338. },
  339. { immediate: true }
  340. )
  341. onMounted(() => {
  342. window.addEventListener('resize', renderGraph)
  343. })
  344. onBeforeUnmount(() => {
  345. window.removeEventListener('resize', renderGraph)
  346. simulation?.stop()
  347. })
  348. </script>
  349. <style scoped lang="less">
  350. .wiki-graph {
  351. height: calc(100vh - 250px);
  352. min-height: 520px;
  353. padding: 12px;
  354. border: 1px solid var(--border-light);
  355. border-radius: 10px;
  356. background: var(--bg-base);
  357. box-sizing: border-box;
  358. }
  359. .wiki-graph__header {
  360. display: flex;
  361. align-items: flex-start;
  362. justify-content: space-between;
  363. gap: 12px;
  364. margin-bottom: 10px;
  365. }
  366. .wiki-graph__title {
  367. font-size: 15px;
  368. font-weight: 700;
  369. color: var(--text-primary);
  370. }
  371. .wiki-graph__desc {
  372. margin-top: 4px;
  373. font-size: 12px;
  374. color: var(--text-secondary);
  375. }
  376. .wiki-graph__summary {
  377. display: flex;
  378. flex-wrap: wrap;
  379. gap: 10px;
  380. margin-top: 12px;
  381. }
  382. .wiki-graph-stat {
  383. display: inline-flex;
  384. align-items: center;
  385. gap: 8px;
  386. padding: 6px 10px;
  387. border: 1px solid var(--border-light);
  388. border-radius: 999px;
  389. background: var(--bg-container);
  390. }
  391. .wiki-graph-stat__label {
  392. font-size: 12px;
  393. color: var(--text-secondary);
  394. }
  395. .wiki-graph-stat__value {
  396. font-size: 12px;
  397. font-weight: 700;
  398. color: var(--text-primary);
  399. }
  400. .wiki-graph__legend {
  401. display: flex;
  402. flex-wrap: wrap;
  403. gap: 12px;
  404. }
  405. .wiki-graph-legend {
  406. display: inline-flex;
  407. align-items: center;
  408. gap: 6px;
  409. font-size: 12px;
  410. color: var(--text-secondary);
  411. }
  412. .wiki-graph-legend__dot {
  413. width: 10px;
  414. height: 10px;
  415. border-radius: 999px;
  416. }
  417. .wiki-graph__canvas {
  418. height: calc(100% - 78px);
  419. min-height: 460px;
  420. border-radius: 12px;
  421. background:
  422. radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 42%),
  423. linear-gradient(180deg, #272727 0%, #202020 100%);
  424. overflow: hidden;
  425. }
  426. @media (max-width: 960px) {
  427. .wiki-graph {
  428. height: auto;
  429. min-height: 0;
  430. }
  431. .wiki-graph__header {
  432. flex-direction: column;
  433. }
  434. .wiki-graph__canvas {
  435. height: 420px;
  436. min-height: 420px;
  437. }
  438. }
  439. </style>