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,6 +1,20 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { watch } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import SidebarNav from '@/components/SidebarNav.vue'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
watch(
() => auth.isAuthenticated,
(isAuthenticated) => {
if (!isAuthenticated && router.currentRoute.value.path !== '/login') {
void router.replace('/login')
}
},
)
</script>
<template>

View File

@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
describe('brain graph embedding', () => {
it('renders the reusable graph projection component directly instead of an iframe shell', () => {
const brainPage = readFileSync(path.resolve(__dirname, './index.vue'), 'utf-8')
expect(brainPage).toContain('GraphProjection')
expect(brainPage).not.toContain('<iframe')
expect(brainPage).not.toContain('src="/graph"')
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { navItems } from '@/app/navigation/nav'
import { appChildren } from '@/app/router/routes'
describe('brain routing', () => {
it('points the knowledge brain nav item to /brain', () => {
const item = navItems.find((entry) => entry.name === '知识大脑')
expect(item?.path).toBe('/brain')
})
it('registers a brain page route', () => {
const route = appChildren.find((entry) => entry.name === 'brain')
expect(route?.path).toBe('brain')
})
})

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import GraphProjection from '@/components/brain/GraphProjection.vue'
</script>
<template>
<GraphProjection fullscreen :show-open-full-view="false" />
</template>

View File

@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
const mocks = vi.hoisted(() => ({
chatStream: vi.fn(),
list: vi.fn(),
getMessages: vi.fn(),
deleteConversation: vi.fn(),
settingsGet: vi.fn(),
upload: vi.fn(),
systemStatusGet: vi.fn(),
}))
vi.mock('@/api/conversation', () => ({
conversationApi: {
chatStream: mocks.chatStream,
list: mocks.list,
getMessages: mocks.getMessages,
delete: mocks.deleteConversation,
},
}))
vi.mock('@/api/settings', () => ({
settingsApi: {
get: mocks.settingsGet,
},
}))
vi.mock('@/api/system', () => ({
systemApi: {
getStatus: mocks.systemStatusGet,
},
}))
vi.mock('@/api/document', () => ({
documentApi: {
upload: mocks.upload,
},
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
ensureAuthReady: vi.fn().mockResolvedValue(undefined),
isAuthenticated: true,
user: { id: 'user-1', email: 'test@example.com' },
}),
}))
import { useChatView } from './useChatView'
describe('useChatView orchestration state', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
vi.clearAllMocks()
mocks.list.mockResolvedValue({ data: [] })
mocks.getMessages.mockResolvedValue({ data: [] })
mocks.deleteConversation.mockResolvedValue(undefined)
mocks.settingsGet.mockResolvedValue({
data: {
llm_config: {
chat: [
{
name: 'Jarvis Chat',
provider: 'openai',
model: 'gpt-test',
base_url: '',
api_key: '',
enabled: true,
},
],
vlm: [],
},
},
})
mocks.upload.mockResolvedValue({ data: { id: 'file-1' } })
mocks.systemStatusGet.mockResolvedValue({
data: {
cpu_percent: 21,
memory_percent: 48,
disk_percent: 63,
timestamp: '2026-03-22T02:40:00Z',
},
})
})
it('derives telemetry state from real system status and session events without persisting it to message history', async () => {
mocks.chatStream.mockImplementation(async (_message, _conversationId, _fileIds, _modelName, handlers) => {
handlers.onMetadata?.({ conversation_id: 'conv-1', message_id: 'msg-1' })
handlers.onProgress?.({
stage: 'tool',
label: 'Jarvis 正在调用工具',
agent: 'executor',
tool_name: 'search_knowledge',
step: '调用工具 search_knowledge',
steps: ['理解问题', '检索知识'],
})
handlers.onChunk?.({ content: '最终回复' })
})
const view = useChatView()
await nextTick()
await Promise.resolve()
expect(mocks.systemStatusGet).toHaveBeenCalledTimes(1)
expect(view.systemTelemetry.value.cpu.current).toBe(21)
expect(view.systemTelemetry.value.memory.current).toBe(48)
expect(view.systemTelemetry.value.disk.current).toBe(63)
expect(view.systemTelemetry.value.cpu.series.at(-1)).toBe(21)
view.inputMessage.value = '测试问题'
const promise = view.sendMessage()
await Promise.resolve()
expect(view.sessionTelemetry.value.eventsCount).toBe(2)
expect(view.sessionTelemetry.value.toolCount).toBe(1)
expect(view.sessionTelemetry.value.agentCount).toBe(1)
expect(view.sessionTelemetry.value.activitySeries.some((point) => point > 0)).toBe(true)
expect(view.store.messages).toHaveLength(1)
await promise
expect(view.store.messages).toHaveLength(2)
expect(view.store.messages.every((message) => !('activitySeries' in message))).toBe(true)
})
it('keeps the orchestration panel persistent and confines thinking state to the side panel', async () => {
mocks.chatStream.mockImplementation(async (_message, _conversationId, _fileIds, _modelName, handlers) => {
handlers.onMetadata?.({ conversation_id: 'conv-1', message_id: 'msg-1' })
handlers.onProgress?.({
stage: 'planning',
label: 'Jarvis 正在拆解步骤',
agent: 'planner',
tool_name: null,
step: '正在分配任务',
steps: ['理解问题', '分配 planner'],
})
handlers.onChunk?.({ content: '最终回复' })
})
const view = useChatView()
view.inputMessage.value = '测试问题'
const promise = view.sendMessage()
await Promise.resolve()
expect(view.store.messages).toHaveLength(1)
expect(view.store.messages[0].role).toBe('user')
expect(view.isTyping.value).toBe(true)
expect(view.orchestrationPanelVisible.value).toBe(true)
expect(view.orchestrationStatus.value).toBe('active')
expect(view.activeAgent.value).toBe('planner')
expect(view.visitedAgents.value).toContain('planner')
expect(view.orchestrationEventFeed.value.map((item) => item.label)).toContain('正在分配任务')
await promise
expect(view.store.messages).toHaveLength(2)
expect(view.store.messages[1].role).toBe('assistant')
expect(view.store.messages[1].content).toBe('最终回复')
expect(view.orchestrationPanelVisible.value).toBe(true)
expect(view.orchestrationStatus.value).toBe('complete')
expect(view.orchestrationEventFeed.value.at(-1)?.label).toBe('响应已生成')
expect(view.activeAgent.value).toBe('planner')
expect(view.store.messages).toHaveLength(2)
})
})

View File

@@ -1,7 +1,14 @@
import { nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { conversationApi, type Message } from '@/api/conversation'
import { useAuthStore } from '@/stores/auth'
import {
conversationApi,
type ChatProgressEvent,
type Message,
} from '@/api/conversation'
import { documentApi } from '@/api/document'
import { settingsApi, type LLMModelConfig } from '@/api/settings'
import { systemApi } from '@/api/system'
export interface SelectedFile {
id: string
@@ -14,8 +21,51 @@ interface MessageWithAttachments extends Message {
attachments?: SelectedFile[]
}
interface ThinkingState {
stage: ChatProgressEvent['stage']
label: string
agent?: string | null
toolName?: string | null
step?: string | null
steps: string[]
}
interface OrchestrationEventItem {
id: string
label: string
kind: 'info' | 'tool' | 'success' | 'error'
}
interface OrchestrationInsight {
statusTitle: string
systemSummary: string
jarvisNote: string
}
interface TelemetryMetricState {
current: number | null
series: number[]
online: boolean
}
interface SystemTelemetryState {
cpu: TelemetryMetricState
memory: TelemetryMetricState
disk: TelemetryMetricState
}
interface SessionTelemetryState {
activitySeries: number[]
eventsCount: number
toolCount: number
agentCount: number
}
type OrchestrationStatus = 'idle' | 'active' | 'complete' | 'error'
export function useChatView() {
const store = useConversationStore()
const auth = useAuthStore()
const inputMessage = ref('')
const isSending = ref(false)
const chatContainer = ref<HTMLElement>()
@@ -24,18 +74,222 @@ export function useChatView() {
const fileInputRef = ref<HTMLInputElement>()
const showEmojiPicker = ref(false)
const selectedFiles = ref<SelectedFile[]>([])
const chatModels = ref<LLMModelConfig[]>([])
const selectedModelName = ref('')
const isLoadingModels = ref(false)
const conversationsError = ref('')
const currentSelectionRequestId = ref(0)
const thinkingState = ref<ThinkingState | null>(null)
const orchestrationPanelVisible = ref(true)
const orchestrationStatus = ref<OrchestrationStatus>('idle')
const orchestrationInsight = ref<OrchestrationInsight>({
statusTitle: 'STANDBY',
systemSummary: '等待请求接入',
jarvisNote: '系统在线。希望下一项任务别太无聊。',
})
const activeAgent = ref<string | null>(null)
const visitedAgents = ref<string[]>([])
const orchestrationEventFeed = ref<OrchestrationEventItem[]>([])
const systemTelemetry = ref<SystemTelemetryState>({
cpu: { current: null, series: [], online: false },
memory: { current: null, series: [], online: false },
disk: { current: null, series: [], online: false },
})
const sessionTelemetry = ref<SessionTelemetryState>({
activitySeries: [],
eventsCount: 0,
toolCount: 0,
agentCount: 0,
})
const selectedModel = computed(() => chatModels.value.find((model) => model.name === selectedModelName.value) ?? null)
let systemTelemetryTimer: ReturnType<typeof setInterval> | null = null
let sessionTelemetryTimer: ReturnType<typeof setInterval> | null = null
function resetOrchestrationState() {
orchestrationPanelVisible.value = true
orchestrationStatus.value = 'idle'
orchestrationInsight.value = {
statusTitle: 'STANDBY',
systemSummary: '等待请求接入',
jarvisNote: '系统在线。希望下一项任务别太无聊。',
}
activeAgent.value = null
visitedAgents.value = []
orchestrationEventFeed.value = []
sessionTelemetry.value = {
activitySeries: [],
eventsCount: 0,
toolCount: 0,
agentCount: 0,
}
}
function appendTelemetryPoint(series: number[], value: number, limit = 24) {
return [...series, value].slice(-limit)
}
function markSessionActivity(value: number, options?: { tool?: boolean; agent?: string | null }) {
sessionTelemetry.value = {
activitySeries: appendTelemetryPoint(sessionTelemetry.value.activitySeries, value),
eventsCount: sessionTelemetry.value.eventsCount + 1,
toolCount: sessionTelemetry.value.toolCount + (options?.tool ? 1 : 0),
agentCount: options?.agent && !visitedAgents.value.includes(options.agent)
? sessionTelemetry.value.agentCount + 1
: sessionTelemetry.value.agentCount,
}
}
function updateSystemTelemetry(metric: keyof SystemTelemetryState, value: number | null, online: boolean) {
const current = systemTelemetry.value[metric]
systemTelemetry.value = {
...systemTelemetry.value,
[metric]: {
current: value,
online,
series: value === null ? current.series : appendTelemetryPoint(current.series, value),
},
}
}
async function loadSystemStatus() {
try {
const response = await systemApi.getStatus()
updateSystemTelemetry('cpu', response.data.cpu_percent, true)
updateSystemTelemetry('memory', response.data.memory_percent, true)
updateSystemTelemetry('disk', response.data.disk_percent, true)
} catch (error) {
console.error('加载系统状态失败:', error)
updateSystemTelemetry('cpu', systemTelemetry.value.cpu.current, false)
updateSystemTelemetry('memory', systemTelemetry.value.memory.current, false)
updateSystemTelemetry('disk', systemTelemetry.value.disk.current, false)
}
}
function startSystemTelemetryPolling() {
if (systemTelemetryTimer) clearInterval(systemTelemetryTimer)
systemTelemetryTimer = setInterval(() => {
void loadSystemStatus()
}, 2000)
}
function startSessionTelemetryDecay() {
if (sessionTelemetryTimer) clearInterval(sessionTelemetryTimer)
sessionTelemetryTimer = setInterval(() => {
const lastValue = sessionTelemetry.value.activitySeries.at(-1) ?? 0
const nextValue = Math.max(0, Math.round(lastValue * 0.72) - 2)
sessionTelemetry.value = {
...sessionTelemetry.value,
activitySeries: appendTelemetryPoint(sessionTelemetry.value.activitySeries, nextValue),
}
}, 1200)
}
function pushOrchestrationEvent(label: string, kind: OrchestrationEventItem['kind']) {
const normalized = label.trim()
if (!normalized) return
if (orchestrationEventFeed.value.at(-1)?.label === normalized) return
orchestrationEventFeed.value = [
...orchestrationEventFeed.value,
{
id: `${Date.now()}-${orchestrationEventFeed.value.length}`,
label: normalized,
kind,
},
].slice(-5)
}
function buildOrchestrationInsight(payload: ChatProgressEvent): OrchestrationInsight {
if (payload.stage === 'thinking') {
return {
statusTitle: 'ANALYSIS',
systemSummary: payload.agent ? '正在解析请求并评估处理路径' : '正在解析请求意图',
jarvisNote: payload.steps?.length
? '这件事比表面复杂一点,我宁可先把结构看清。'
: '这个请求不算棘手,先让我确认范围。',
}
}
if (payload.stage === 'planning') {
return {
statusTitle: 'ROUTING',
systemSummary: payload.agent === 'planner' ? '已路由至 planner正在拆解任务' : '正在规划执行链路',
jarvisNote: payload.steps?.length
? '问题有几层关系,按顺序拆开会体面很多。'
: '这一步需要一点秩序感。',
}
}
if (payload.stage === 'tool') {
return {
statusTitle: 'EXECUTION',
systemSummary: payload.tool_name ? `正在调用工具 · ${payload.tool_name}` : '正在执行操作',
jarvisNote: payload.tool_name
? '工具链已接通。希望它今天愿意配合。'
: '执行阶段开始了,接下来看看链路表现。',
}
}
return {
statusTitle: 'SYNTHESIS',
systemSummary: payload.agent === 'analyst' ? 'analyst 正在整理结果' : '结果已收集,正在整理回答',
jarvisNote: '信息已经够用了,我来把它变得清晰一点。',
}
}
function applyProgressToOrchestration(payload: ChatProgressEvent) {
orchestrationPanelVisible.value = true
orchestrationStatus.value = 'active'
orchestrationInsight.value = buildOrchestrationInsight(payload)
const isNewAgent = Boolean(payload.agent && !visitedAgents.value.includes(payload.agent))
activeAgent.value = payload.agent || null
markSessionActivity(payload.tool_name ? 92 : 68, {
tool: Boolean(payload.tool_name),
agent: isNewAgent ? payload.agent || null : null,
})
if (payload.agent && isNewAgent) {
visitedAgents.value = [...visitedAgents.value, payload.agent]
}
if (payload.step) {
pushOrchestrationEvent(payload.step, payload.tool_name ? 'tool' : 'info')
} else if (payload.tool_name) {
pushOrchestrationEvent(`调用工具 · ${payload.tool_name}`, 'tool')
} else {
pushOrchestrationEvent(payload.label, 'info')
}
}
function finalizeOrchestration(status: Exclude<OrchestrationStatus, 'idle' | 'active'>, finalLabel: string) {
orchestrationPanelVisible.value = true
orchestrationStatus.value = status
orchestrationInsight.value = status === 'error'
? {
statusTitle: 'ERROR',
systemSummary: '执行中断,等待进一步处理',
jarvisNote: '结果不理想,不过问题已经开始显形。',
}
: {
statusTitle: 'COMPLETE',
systemSummary: '执行完成,结果已生成',
jarvisNote: '很好,问题已经收束。',
}
pushOrchestrationEvent(finalLabel, status === 'error' ? 'error' : 'success')
}
async function sendMessage() {
if (!inputMessage.value.trim() || isSending.value) return
resetOrchestrationState()
isSending.value = true
isTyping.value = true
const text = inputMessage.value.trim()
const attachments = [...selectedFiles.value]
const tempMessageId = `temp-${Date.now()}`
const previousConversationId = store.currentConversationId
inputMessage.value = ''
store.addMessage({
id: `temp-${Date.now()}`,
id: tempMessageId,
role: 'user',
content: text,
created_at: new Date().toISOString(),
@@ -45,28 +299,74 @@ export function useChatView() {
await nextTick()
scrollToBottom()
let finalConversationId = previousConversationId
let finalMessageId = ''
let finalContent = ''
let streamError = ''
try {
const response = await conversationApi.chat(text, store.currentConversationId || undefined, attachments.map((file) => file.id))
await conversationApi.chatStream(
text,
previousConversationId ?? undefined,
attachments.map((file) => file.id),
selectedModelName.value || undefined,
{
onMetadata(payload) {
finalConversationId = payload.conversation_id
finalMessageId = payload.message_id
if (previousConversationId === null) {
store.setCurrentConversation(payload.conversation_id)
}
},
onProgress(payload) {
thinkingState.value = {
stage: payload.stage,
label: payload.label,
agent: payload.agent,
toolName: payload.tool_name,
step: payload.step,
steps: payload.steps || [],
}
applyProgressToOrchestration(payload)
},
onChunk(payload) {
finalContent += payload.content
markSessionActivity(54)
},
onError(message) {
streamError = message
},
},
)
if (streamError) {
throw new Error(streamError)
}
selectedFiles.value = []
isTyping.value = false
store.addMessage({
id: response.data.message_id,
id: finalMessageId || `assistant-${Date.now()}`,
role: 'assistant',
content: response.data.content,
model: response.data.agent_name,
content: finalContent || '抱歉,我暂时没有生成可用回复。',
model: selectedModelName.value || selectedModel.value?.name || 'jarvis',
created_at: new Date().toISOString(),
})
if (!store.currentConversationId) {
store.setCurrentConversation(response.data.conversation_id)
await loadConversations()
if (finalConversationId && previousConversationId === null) {
store.setCurrentConversation(finalConversationId)
}
} catch (error) {
finalizeOrchestration('complete', '响应已生成')
await loadConversations()
} catch (error: any) {
isTyping.value = false
console.error('发送失败:', error)
const content = error?.message || '抱歉,连接失败。请检查服务状态。'
finalizeOrchestration('error', content)
store.removeMessage(tempMessageId)
store.addMessage({
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉,连接失败。请检查服务状态。',
content,
created_at: new Date().toISOString(),
})
}
@@ -77,29 +377,65 @@ export function useChatView() {
}
async function loadConversations() {
conversationsError.value = ''
try {
const response = await conversationApi.list()
store.setConversations(response.data)
} catch (error) {
conversationsError.value = '加载会话失败,请稍后重试'
console.error('加载对话列表失败:', error)
}
}
async function loadChatModels() {
isLoadingModels.value = true
try {
const response = await settingsApi.get()
const chatModelsList = (response.data.llm_config?.chat || []).filter((model) => model.enabled)
const vlmModels = (response.data.llm_config?.vlm || []).filter((model) => model.enabled)
// 合并 chat 和 vlm 模型
chatModels.value = [...chatModelsList, ...vlmModels]
if (!selectedModelName.value || !chatModels.value.some((model) => model.name === selectedModelName.value)) {
selectedModelName.value = chatModels.value[0]?.name || ''
}
} catch (error) {
console.error('加载聊天模型失败:', error)
chatModels.value = []
selectedModelName.value = ''
} finally {
isLoadingModels.value = false
}
}
async function selectConversation(id: string) {
resetOrchestrationState()
thinkingState.value = null
isTyping.value = false
const requestId = currentSelectionRequestId.value + 1
currentSelectionRequestId.value = requestId
store.setCurrentConversation(id)
store.setMessages([])
try {
const response = await conversationApi.getMessages(id)
if (currentSelectionRequestId.value !== requestId || store.currentConversationId !== id) {
return
}
store.setMessages(response.data)
await nextTick()
scrollToBottom()
} catch (error) {
if (currentSelectionRequestId.value === requestId) {
store.setMessages([])
}
console.error('加载消息失败:', error)
}
}
function newConversation() {
store.setCurrentConversation('')
resetOrchestrationState()
thinkingState.value = null
isTyping.value = false
store.setCurrentConversation(null)
store.setMessages([])
inputRef.value?.focus()
}
@@ -110,7 +446,7 @@ export function useChatView() {
await conversationApi.delete(id)
store.removeConversation(id)
if (store.currentConversationId === id) {
store.setCurrentConversation('')
store.setCurrentConversation(null)
store.setMessages([])
}
} catch (error) {
@@ -181,11 +517,23 @@ export function useChatView() {
fileInputRef.value?.click()
}
onMounted(() => {
loadConversations()
void loadSystemStatus()
startSystemTelemetryPolling()
startSessionTelemetryDecay()
onMounted(async () => {
await auth.ensureAuthReady()
if (auth.isAuthenticated && auth.user) {
await Promise.all([loadConversations(), loadChatModels(), loadSystemStatus()])
}
inputRef.value?.focus()
})
onUnmounted(() => {
if (systemTelemetryTimer) clearInterval(systemTelemetryTimer)
if (sessionTelemetryTimer) clearInterval(sessionTelemetryTimer)
})
return {
store,
inputMessage,
@@ -196,6 +544,20 @@ export function useChatView() {
fileInputRef,
showEmojiPicker,
selectedFiles,
chatModels,
selectedModelName,
selectedModel,
isLoadingModels,
conversationsError,
thinkingState,
orchestrationPanelVisible,
orchestrationStatus,
orchestrationInsight,
activeAgent,
visitedAgents,
orchestrationEventFeed,
systemTelemetry,
sessionTelemetry,
sendMessage,
selectConversation,
newConversation,

File diff suppressed because it is too large Load Diff

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>

View File

@@ -15,8 +15,9 @@ import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView
const {
documents,
currentFolderId,
isLoadingDocuments,
uploadError,
uploadSuccess,
highlightedDocumentId,
uploadInput,
showNewFolderDialog,
newFolderName,
@@ -29,6 +30,12 @@ const {
activeDocument,
activeDocumentContent,
isLoadingDocumentContent,
activeDocumentChunks,
isLoadingDocumentChunks,
chunkDrafts,
chunkEditing,
chunkSaving,
chunkEditError,
isRoot,
visibleFolders,
breadcrumbs,
@@ -47,9 +54,13 @@ const {
deleteFolder,
openDocument,
closeDocumentDialog,
startChunkEdit,
cancelChunkEdit,
saveChunkEdit,
getFileTypeColor,
formatFileSize,
formatDate,
getStatusLabel,
} = useKnowledgeView()
</script>
@@ -78,7 +89,7 @@ const {
ref="uploadInput"
type="file"
class="hidden-upload"
accept=".pdf,.md,.txt,.docx"
accept=".pdf,.md,.txt,.doc,.docx,.csv,.xlsx"
@change="handleUpload"
/>
@@ -108,10 +119,8 @@ const {
<div v-if="uploadError" class="upload-error">
{{ uploadError }}
</div>
<div v-if="isLoadingDocuments" class="loading-strip">
<Loader :size="14" class="spin" />
<span>正在同步当前目录...</span>
<div v-if="uploadSuccess" class="upload-success">
{{ uploadSuccess }}
</div>
<div class="explorer-grid">
@@ -158,6 +167,7 @@ const {
v-for="doc in documents"
:key="doc.id"
class="explorer-tile file-tile"
:class="{ 'upload-highlight': highlightedDocumentId === doc.id }"
@click="openDocument(doc)"
>
<div class="tile-frame"></div>
@@ -174,9 +184,17 @@ const {
</div>
<div class="tile-name">{{ doc.title }}</div>
<div class="tile-meta">{{ formatFileSize(doc.file_size) }} · {{ formatDate(doc.created_at) }}</div>
<div class="tile-status" :class="doc.is_indexed ? 'indexed' : 'pending'">
{{ doc.is_indexed ? 'INDEXED' : 'INDEXING...' }}
<div class="tile-status-row">
<div class="tile-status" :class="(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded')).toLowerCase()">
{{ getStatusLabel(doc.ingestion_status, doc.is_indexed) }}
</div>
<Loader
v-if="['uploaded', 'parsing', 'indexing'].includes(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded'))"
:size="12"
class="spin tile-inline-loader"
/>
</div>
<div v-if="doc.ingestion_error" class="tile-warning">{{ doc.ingestion_error }}</div>
</button>
</template>
@@ -294,15 +312,84 @@ const {
{{ activeDocument.file_type.toUpperCase() }}
</span>
<span>{{ formatFileSize(activeDocument.file_size) }}</span>
<span>{{ activeDocument.chunk_count }} chunks</span>
<span class="doc-chunk-count">{{ activeDocument.chunk_count }} 个知识切片</span>
<span class="doc-status-pill">{{ getStatusLabel(activeDocument.ingestion_status, activeDocument.is_indexed) }}</span>
</div>
<div v-if="activeDocument.ingestion_error" class="upload-error">
{{ activeDocument.ingestion_error }}
</div>
<div class="document-preview">
<div v-if="isLoadingDocumentContent" class="preview-loading">
<Loader :size="16" class="spin" />
<span>加载文档内容中...</span>
<div class="document-content-grid">
<div class="document-preview">
<div v-if="isLoadingDocumentContent" class="preview-loading">
<Loader :size="16" class="spin" />
<span>加载文档内容中...</span>
</div>
<pre v-else>{{ activeDocumentContent || '暂无可预览内容' }}</pre>
</div>
<div class="chunk-panel">
<div class="chunk-panel-header">
<div>
<div class="chunk-panel-title">知识切片</div>
<div class="chunk-panel-subtitle">当前已加载 {{ activeDocumentChunks.length }} / {{ activeDocument.chunk_count }} 个切片</div>
</div>
<span class="chunk-count-badge">{{ activeDocumentChunks.length }}</span>
</div>
<div v-if="isLoadingDocumentChunks" class="chunk-loading-state">
<div class="chunk-loading-bar"></div>
<span>正在读取切片内容...</span>
</div>
<div v-else-if="activeDocumentChunks.length" class="chunk-list">
<div
v-for="chunk in activeDocumentChunks"
:key="chunk.id"
class="chunk-card"
:class="{ editing: chunkEditing[chunk.id] }"
>
<div class="chunk-card-header">
<div class="chunk-card-meta">
<span class="chunk-index">切片 #{{ chunk.chunk_index + 1 }}</span>
<span class="chunk-size">{{ chunk.content.length }} 字符</span>
<span v-if="chunk.metadata_" class="chunk-meta-raw">{{ chunk.metadata_ }}</span>
</div>
<button
v-if="!chunkEditing[chunk.id]"
class="btn"
@click="startChunkEdit(chunk)"
>
编辑
</button>
</div>
<pre v-if="!chunkEditing[chunk.id]" class="chunk-content">{{ chunk.content }}</pre>
<div v-else class="chunk-edit-form">
<textarea
v-model="chunkDrafts[chunk.id]"
class="chunk-textarea"
rows="7"
></textarea>
<div v-if="chunkEditError[chunk.id]" class="upload-error chunk-error">
{{ chunkEditError[chunk.id] }}
</div>
<div class="chunk-actions">
<button class="btn" :disabled="chunkSaving[chunk.id]" @click="cancelChunkEdit(chunk.id)">
取消
</button>
<button class="btn primary" :disabled="chunkSaving[chunk.id]" @click="saveChunkEdit(chunk.id)">
<Loader v-if="chunkSaving[chunk.id]" :size="12" class="spin" />
{{ chunkSaving[chunk.id] ? '保存中' : '保存' }}
</button>
</div>
</div>
</div>
</div>
<div v-else class="chunk-empty">暂无切片数据</div>
</div>
<pre v-else>{{ activeDocumentContent || '暂无可预览内容' }}</pre>
</div>
</div>
</div>
@@ -439,7 +526,7 @@ h1 {
}
.upload-error,
.loading-strip {
.upload-success {
display: inline-flex;
align-items: center;
gap: 8px;
@@ -455,10 +542,10 @@ h1 {
border: 1px solid rgba(255, 71, 87, 0.2);
}
.loading-strip {
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.06);
border: 1px solid var(--border-dim);
.upload-success {
color: var(--accent-green);
background: rgba(52, 211, 153, 0.08);
border: 1px solid rgba(52, 211, 153, 0.24);
}
.explorer-grid {
@@ -491,6 +578,11 @@ h1 {
transform: translateY(-2px);
}
.upload-highlight {
border-color: rgba(52, 211, 153, 0.8);
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.35), 0 0 28px rgba(52, 211, 153, 0.25);
}
.tile-frame {
position: absolute;
inset: 10px;
@@ -792,21 +884,68 @@ h1 {
line-height: 1.5;
}
.tile-status {
.tile-status-row {
margin-top: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.tile-status {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.12em;
}
.indexed {
.tile-inline-loader {
color: var(--accent-cyan);
}
.ready {
color: var(--accent-green);
}
.pending {
.uploaded,
.parsing,
.indexing {
color: var(--accent-amber);
}
.warning {
color: var(--accent-purple);
}
.failed {
color: var(--accent-red);
}
.tile-warning {
width: 100%;
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.4;
word-break: break-word;
}
.doc-chunk-count {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.08em;
}
.doc-status-pill {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.08);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
}
.empty-state {
grid-column: 1 / -1;
min-height: 440px;
@@ -1064,6 +1203,15 @@ h1 {
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.folder-tile:hover .folder-glyph,
.folder-tile:hover .empty-folder-chamber {
animation-duration: 2.3s;
@@ -1246,7 +1394,14 @@ h1 {
margin-bottom: 14px;
}
.document-preview {
.document-content-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: 16px;
}
.document-preview,
.chunk-panel {
min-height: 360px;
max-height: 62vh;
overflow: auto;
@@ -1265,6 +1420,167 @@ h1 {
line-height: 1.7;
}
.chunk-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.chunk-panel-title {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.14em;
}
.chunk-panel-subtitle {
margin-top: 4px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
}
.chunk-count-badge {
min-width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(0, 245, 212, 0.2);
background: rgba(0, 245, 212, 0.08);
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 12px;
}
.chunk-loading-state {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
.chunk-loading-bar {
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.15), rgba(124, 230, 255, 0.45), rgba(0, 245, 212, 0.15));
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
.chunk-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.chunk-card {
border: 1px solid var(--border-dim);
border-radius: 12px;
background: rgba(8, 15, 24, 0.82);
padding: 12px;
}
.chunk-card.editing {
border-color: rgba(0, 245, 212, 0.4);
box-shadow: inset 0 0 0 1px rgba(0, 245, 212, 0.08);
}
.chunk-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.chunk-card-meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.chunk-index {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
}
.chunk-size {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
}
.chunk-meta-raw {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.5;
word-break: break-word;
}
.chunk-content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.chunk-edit-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.chunk-textarea {
width: 100%;
min-height: 150px;
resize: vertical;
background: rgba(0, 9, 19, 0.9);
border: 1px solid var(--border-dim);
border-radius: 10px;
color: var(--text-primary);
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
box-sizing: border-box;
}
.chunk-textarea:focus {
border-color: var(--accent-cyan);
outline: none;
}
.chunk-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.chunk-error {
width: 100%;
}
.chunk-empty {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
.preview-loading {
display: flex;
align-items: center;
@@ -1332,5 +1648,9 @@ h1 {
display: flex;
justify-content: flex-start;
}
.document-content-grid {
grid-template-columns: 1fr;
}
}
</style>