Add brain and chat workspace views
Expand the frontend with brain, graph, and chat workspace updates so the new backend orchestration and memory features have matching screens. These changes also wire the new APIs into routing and add focused view and routing tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,468 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { graphApi } from '@/api/graph'
|
||||
import { Network, RefreshCw, Info, Hexagon } from 'lucide-vue-next'
|
||||
import type { KGNode, KGEdge } from '@/api/graph'
|
||||
|
||||
const nodes = ref<KGNode[]>([])
|
||||
const edges = ref<KGEdge[]>([])
|
||||
const stats = ref({ node_count: 0, edge_count: 0 })
|
||||
const isLoading = ref(false)
|
||||
const selectedEntity = ref<KGNode | null>(null)
|
||||
const entityContext = ref('')
|
||||
const isBuilding = ref(false)
|
||||
const chartRef = ref<HTMLDivElement>()
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
person: '#f87171', concept: '#60a5fa', topic: '#a78bfa',
|
||||
task: '#fbbf24', event: '#fb923c', document: '#9ca3af', default: '#4b5563',
|
||||
}
|
||||
|
||||
function getColor(type: string) { return typeColors[type] || typeColors.default }
|
||||
|
||||
async function loadGraph() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await graphApi.get()
|
||||
nodes.value = response.data.nodes
|
||||
edges.value = response.data.edges
|
||||
stats.value = response.data.stats
|
||||
await nextTick()
|
||||
renderChart()
|
||||
} catch (e) { console.error('加载图谱失败:', e) }
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
async function buildGraph() {
|
||||
isBuilding.value = true
|
||||
try {
|
||||
await graphApi.build()
|
||||
setTimeout(() => loadGraph(), 2000)
|
||||
} catch (e) { console.error('构建失败:', e) }
|
||||
isBuilding.value = false
|
||||
}
|
||||
|
||||
async function selectEntity(node: KGNode) {
|
||||
selectedEntity.value = node
|
||||
entityContext.value = 'LOADING...'
|
||||
try {
|
||||
const response = await graphApi.getEntityContext(node.name)
|
||||
entityContext.value = response.data.context
|
||||
} catch (e) { entityContext.value = 'Failed to load context' }
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!chartRef.value) return
|
||||
// @ts-ignore
|
||||
if (!window.echarts) return
|
||||
// @ts-ignore
|
||||
const echarts = window.echarts
|
||||
const chart = echarts.init(chartRef.value, 'dark')
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(10, 15, 26, 0.95)',
|
||||
borderColor: 'rgba(0, 245, 212, 0.2)',
|
||||
textStyle: { color: '#e8f4f8', fontFamily: 'JetBrains Mono, monospace', fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
if (params.dataType === 'node') {
|
||||
return `<b style="color:#00f5d4">${params.data.name}</b><br/><span style="color:#7eb8c9">Type: ${params.data.type || 'unknown'}</span>`
|
||||
}
|
||||
return `<span style="color:#7eb8c9">${params.data.sourceName}</span> → <span style="color:#a78bfa">${params.data.relation}</span> → <span style="color:#7eb8c9">${params.data.targetName}</span>`
|
||||
},
|
||||
},
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
symbolSize: (_val: unknown, params: any) => 18 + (params.data.importance || 0.5) * 40,
|
||||
roam: true,
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 10,
|
||||
color: '#7eb8c9',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
formatter: (params: any) => params.data.name?.substring(0, 14) || '',
|
||||
},
|
||||
lineStyle: { color: 'rgba(0, 245, 212, 0.15)', width: 1.5 },
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
lineStyle: { width: 3, color: 'rgba(0, 245, 212, 0.5)' },
|
||||
},
|
||||
edgeSymbol: ['circle', 'arrow'],
|
||||
edgeSymbolSize: [4, 8],
|
||||
data: nodes.value.map(n => ({
|
||||
id: n.id, name: n.name, type: n.type,
|
||||
importance: n.importance || 0.5,
|
||||
itemStyle: {
|
||||
color: getColor(n.type),
|
||||
borderColor: getColor(n.type),
|
||||
borderWidth: 2,
|
||||
shadowColor: getColor(n.type),
|
||||
shadowBlur: 8,
|
||||
},
|
||||
})),
|
||||
links: edges.value.map(e => {
|
||||
const src = nodes.value.find(n => n.id === e.source)
|
||||
const tgt = nodes.value.find(n => n.id === e.target)
|
||||
return {
|
||||
source: e.source, target: e.target,
|
||||
sourceName: src?.name || '', targetName: tgt?.name || '',
|
||||
relation: e.relation,
|
||||
lineStyle: { color: 'rgba(0, 245, 212, 0.15)' },
|
||||
}
|
||||
}),
|
||||
force: { repulsion: 150, gravity: 0.05, edgeLength: [60, 200], layoutAnimation: true },
|
||||
}],
|
||||
}
|
||||
chart.setOption(option)
|
||||
chart.on('click', (params: any) => {
|
||||
if (params.dataType === 'node') {
|
||||
const node = nodes.value.find(n => n.id === params.data.id)
|
||||
if (node) selectEntity(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGraph()
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js'
|
||||
script.onload = () => renderChart()
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
import GraphProjection from '@/components/brain/GraphProjection.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-view">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<div class="header-icon"><Hexagon :size="20" /></div>
|
||||
<div class="header-text">
|
||||
<h1>KNOWLEDGE GRAPH</h1>
|
||||
<span class="header-sub">{{ stats.node_count }} nodes · {{ stats.edge_count }} relations</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="build-btn" @click="buildGraph" :disabled="isBuilding">
|
||||
<RefreshCw :size="14" :class="{ spin: isBuilding }" />
|
||||
{{ isBuilding ? 'BUILDING...' : 'REBUILD' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Type legend -->
|
||||
<div class="type-legend">
|
||||
<div v-for="(color, type) in typeColors" :key="type" class="legend-item">
|
||||
<div class="legend-dot" :style="{ background: color, boxShadow: `0 0 6px ${color}` }"></div>
|
||||
<span>{{ type.toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main area: chart + panel -->
|
||||
<div class="main-area">
|
||||
<div class="graph-container">
|
||||
<div v-if="nodes.length === 0 && !isLoading" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<div class="e-ring r1"></div>
|
||||
<div class="e-ring r2"></div>
|
||||
<Network :size="32" />
|
||||
</div>
|
||||
<div class="empty-title">NO GRAPH DATA</div>
|
||||
<div class="empty-sub">Upload documents and rebuild the graph</div>
|
||||
</div>
|
||||
<div ref="chartRef" v-show="nodes.length > 0" class="chart-canvas"></div>
|
||||
</div>
|
||||
|
||||
<!-- Entity panel -->
|
||||
<div class="entity-panel" v-if="selectedEntity">
|
||||
<div class="panel-title">
|
||||
<Info :size="14" />
|
||||
<span>ENTITY DETAIL</span>
|
||||
<button class="close-panel" @click="selectedEntity = null">×</button>
|
||||
</div>
|
||||
<div class="entity-name" :style="{ color: getColor(selectedEntity.type) }">
|
||||
{{ selectedEntity.name }}
|
||||
</div>
|
||||
<div class="entity-type-tag" :style="{ color: getColor(selectedEntity.type), borderColor: getColor(selectedEntity.type) + '40' }">
|
||||
{{ (selectedEntity.type || 'unknown').toUpperCase() }}
|
||||
</div>
|
||||
<div class="entity-context">{{ entityContext }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relation list -->
|
||||
<div class="relations-section" v-if="edges.length > 0">
|
||||
<div class="section-label">// RELATIONS ({{ edges.length }})</div>
|
||||
<div class="relations-scroll">
|
||||
<div v-for="edge in edges" :key="edge.id" class="rel-item">
|
||||
<span class="rel-node">{{ nodes.find(n => n.id === edge.source)?.name?.slice(0, 16) || edge.source.slice(0, 8) }}</span>
|
||||
<div class="rel-arrow">
|
||||
<div class="arrow-line"></div>
|
||||
<span class="rel-type-badge">{{ edge.relation }}</span>
|
||||
</div>
|
||||
<span class="rel-node">{{ nodes.find(n => n.id === edge.target)?.name?.slice(0, 16) || edge.target.slice(0, 8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GraphProjection fullscreen />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.header-icon { color: var(--accent-cyan); filter: drop-shadow(0 0 8px var(--accent-cyan)); }
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
|
||||
|
||||
.build-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.build-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 245, 212, 0.2);
|
||||
box-shadow: var(--glow-cyan);
|
||||
}
|
||||
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
|
||||
.type-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
position: relative;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.e-ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
opacity: 0.2;
|
||||
}
|
||||
.r1 { width: 60px; height: 60px; animation: spin 6s linear infinite; border-style: dashed; }
|
||||
.r2 { width: 40px; height: 40px; animation: spin 4s linear infinite reverse; }
|
||||
|
||||
.empty-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Entity panel */
|
||||
.entity-panel {
|
||||
width: 260px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
animation: fade-in-up 0.2s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.close-panel {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.close-panel:hover { color: var(--accent-red); }
|
||||
|
||||
.entity-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.entity-type-tag {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.entity-context {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Relations */
|
||||
.relations-section { }
|
||||
|
||||
.section-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.relations-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.rel-node { color: var(--accent-cyan); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
.rel-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
width: 20px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--border-mid), var(--accent-cyan), var(--border-mid));
|
||||
}
|
||||
|
||||
.rel-type-badge {
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent-purple);
|
||||
padding: 1px 5px;
|
||||
background: var(--accent-purple-dim);
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user