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:
2026-03-22 13:48:16 +08:00
parent d2447ee635
commit 7d80a6e2ec
21 changed files with 3095 additions and 658 deletions

View File

@@ -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>