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 +1 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_API_URL=http://localhost:9528
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import api from './index'
|
||||
|
||||
export interface ChatProgressEvent {
|
||||
stage: 'thinking' | 'planning' | 'tool' | 'responding'
|
||||
label: string
|
||||
agent?: string | null
|
||||
tool_name?: string | null
|
||||
step?: string | null
|
||||
steps?: string[]
|
||||
}
|
||||
|
||||
export interface ChatStreamChunkEvent {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatStreamMetadataEvent {
|
||||
conversation_id: string
|
||||
message_id: string
|
||||
}
|
||||
|
||||
export interface ChatStreamHandlers {
|
||||
onMetadata?: (payload: ChatStreamMetadataEvent) => void
|
||||
onProgress?: (payload: ChatProgressEvent) => void
|
||||
onChunk?: (payload: ChatStreamChunkEvent) => void
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
id: string
|
||||
name: string
|
||||
@@ -25,6 +50,23 @@ export interface Conversation {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
function parseSseBlocks(buffer: string) {
|
||||
const chunks = buffer.split('\n\n')
|
||||
const rest = chunks.pop() ?? ''
|
||||
const events = chunks
|
||||
.map((block) => {
|
||||
const lines = block.split('\n')
|
||||
const event = lines.find((line) => line.startsWith('event:'))?.slice(6).trim() || 'message'
|
||||
const data = lines
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trim())
|
||||
.join('\n')
|
||||
return { event, data }
|
||||
})
|
||||
.filter((item) => item.data)
|
||||
return { events, rest }
|
||||
}
|
||||
|
||||
export const conversationApi = {
|
||||
list() {
|
||||
return api.get<Conversation[]>('/api/conversations')
|
||||
@@ -35,18 +77,78 @@ export const conversationApi = {
|
||||
},
|
||||
|
||||
getMessages(conversationId: string) {
|
||||
return api.get<Message[]>(`/api/conversations/${conversationId}`)
|
||||
return api.get<Message[]>(`/api/conversations/${conversationId}/messages`)
|
||||
},
|
||||
|
||||
delete(conversationId: string) {
|
||||
return api.delete(`/api/conversations/${conversationId}`)
|
||||
},
|
||||
|
||||
chat(message: string, conversationId?: string, fileIds: string[] = []) {
|
||||
chat(message: string, conversationId?: string, fileIds: string[] = [], modelName?: string) {
|
||||
return api.post('/api/conversations/chat', {
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
file_ids: fileIds,
|
||||
model_name: modelName,
|
||||
})
|
||||
},
|
||||
|
||||
async chatStream(
|
||||
message: string,
|
||||
conversationId?: string,
|
||||
fileIds: string[] = [],
|
||||
modelName?: string,
|
||||
handlers: ChatStreamHandlers = {},
|
||||
) {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/conversations/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
file_ids: fileIds,
|
||||
model_name: modelName,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
let messageText = '连接失败。请检查服务状态。'
|
||||
try {
|
||||
const payload = await response.json()
|
||||
messageText = payload?.detail || payload?.error || messageText
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
throw new Error(messageText)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const { events, rest } = parseSseBlocks(buffer)
|
||||
buffer = rest
|
||||
|
||||
for (const item of events) {
|
||||
const payload = JSON.parse(item.data)
|
||||
if (item.event === 'metadata') {
|
||||
handlers.onMetadata?.(payload)
|
||||
} else if (item.event === 'progress') {
|
||||
handlers.onProgress?.(payload)
|
||||
} else if (item.event === 'chunk') {
|
||||
handlers.onChunk?.(payload)
|
||||
} else if (item.event === 'error') {
|
||||
handlers.onError?.(payload.error || '连接失败。请检查服务状态。')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,10 +9,26 @@ export interface Document {
|
||||
summary?: string
|
||||
chunk_count: number
|
||||
is_indexed: boolean
|
||||
ingestion_status?: 'uploaded' | 'parsing' | 'indexing' | 'ready' | 'warning' | 'failed'
|
||||
ingestion_error?: string | null
|
||||
indexed_at?: string | null
|
||||
parser_version?: string | null
|
||||
index_version?: string | null
|
||||
folder_id?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface DocumentChunk {
|
||||
id: string
|
||||
chunk_index: number
|
||||
content: string
|
||||
metadata_?: string | null
|
||||
}
|
||||
|
||||
export interface DocumentChunkUpdate {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
chunk_id: string
|
||||
document_id: string
|
||||
@@ -29,6 +45,9 @@ export interface UploadResponse {
|
||||
title: string
|
||||
chunk_count: number
|
||||
status: string
|
||||
ingestion_status?: 'uploaded' | 'parsing' | 'indexing' | 'ready' | 'warning' | 'failed'
|
||||
ingestion_error?: string | null
|
||||
indexed_at?: string | null
|
||||
}
|
||||
|
||||
export const documentApi = {
|
||||
@@ -54,7 +73,11 @@ export const documentApi = {
|
||||
},
|
||||
|
||||
getChunks(id: string) {
|
||||
return api.get<any[]>(`/api/documents/${id}/chunks`)
|
||||
return api.get<DocumentChunk[]>(`/api/documents/${id}/chunks`)
|
||||
},
|
||||
|
||||
updateChunk(documentId: string, chunkId: string, payload: DocumentChunkUpdate) {
|
||||
return api.put<DocumentChunk>(`/api/documents/${documentId}/chunks/${chunkId}`, payload)
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import axios from 'axios'
|
||||
|
||||
let redirectingToLogin = false
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:9527',
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
@@ -63,7 +65,13 @@ api.interceptors.response.use(
|
||||
const requestId = error.response?.headers?.['x-request-id'] || metadata?.requestId
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('access_token')
|
||||
window.location.href = '/login'
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('jarvis:auth-unauthorized'))
|
||||
if (!redirectingToLogin && window.location.pathname !== '/login') {
|
||||
redirectingToLogin = true
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
}
|
||||
debugLog('error', {
|
||||
requestId,
|
||||
|
||||
14
frontend/src/api/system.ts
Normal file
14
frontend/src/api/system.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import api from './index'
|
||||
|
||||
export interface SystemStatus {
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
disk_percent: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export const systemApi = {
|
||||
getStatus() {
|
||||
return api.get<SystemStatus>('/api/system/status')
|
||||
},
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export const navItems: NavItem[] = [
|
||||
{ name: '智能链路', path: '/agents', icon: Bot },
|
||||
{ name: '技能中心', path: '/skills', icon: Star },
|
||||
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
|
||||
{ name: '知识大脑', path: '/graph', icon: Network },
|
||||
{ name: '知识大脑', path: '/brain', icon: Network },
|
||||
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
|
||||
{ name: '任务调度', path: '/todo', icon: CheckSquare },
|
||||
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
|
||||
|
||||
@@ -15,6 +15,11 @@ const appChildren: RouteRecordRaw[] = [
|
||||
name: 'knowledge',
|
||||
component: () => import('@/pages/knowledge/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'brain',
|
||||
name: 'brain',
|
||||
component: () => import('@/pages/brain/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'graph',
|
||||
name: 'graph',
|
||||
|
||||
500
frontend/src/components/brain/GraphProjection.vue
Normal file
500
frontend/src/components/brain/GraphProjection.vue
Normal file
@@ -0,0 +1,500 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { graphApi } from '@/api/graph'
|
||||
import { Network, RefreshCw, Info, Hexagon, ExternalLink } from 'lucide-vue-next'
|
||||
import type { KGNode, KGEdge } from '@/api/graph'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
fullscreen?: boolean
|
||||
showOpenFullView?: boolean
|
||||
}>(), {
|
||||
fullscreen: false,
|
||||
showOpenFullView: false,
|
||||
})
|
||||
|
||||
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 buildStatus = ref<'idle' | 'running' | 'success' | 'error'>('idle')
|
||||
const buildTitle = ref('')
|
||||
const buildMessage = ref('')
|
||||
const projectionSubtitle = ref('Knowledge brain memory/tag projection')
|
||||
const chartRef = ref<HTMLDivElement>()
|
||||
let buildPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
person: '#f87171', concept: '#60a5fa', topic: '#a78bfa',
|
||||
task: '#fbbf24', event: '#fb923c', document: '#9ca3af', memory: '#00f5d4', tag: '#38bdf8', default: '#4b5563',
|
||||
}
|
||||
|
||||
function getColor(type: string) { return typeColors[type] || typeColors.default }
|
||||
|
||||
function clearBuildPoll() {
|
||||
if (buildPollTimer) {
|
||||
clearTimeout(buildPollTimer)
|
||||
buildPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
if (typeof detail === 'string' && detail) return detail
|
||||
return 'Unable to rebuild the brain graph projection right now.'
|
||||
}
|
||||
|
||||
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 pollGraphBuild(previousNodeCount: number, previousEdgeCount: number, attempts = 0) {
|
||||
clearBuildPoll()
|
||||
buildPollTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await graphApi.get()
|
||||
nodes.value = response.data.nodes
|
||||
edges.value = response.data.edges
|
||||
stats.value = response.data.stats
|
||||
await nextTick()
|
||||
renderChart()
|
||||
|
||||
const hasChanged = response.data.stats.node_count !== previousNodeCount || response.data.stats.edge_count !== previousEdgeCount
|
||||
if (hasChanged) {
|
||||
isBuilding.value = false
|
||||
buildStatus.value = 'success'
|
||||
buildTitle.value = 'PROJECTION REBUILT'
|
||||
buildMessage.value = `Projection updated to ${response.data.stats.node_count} nodes and ${response.data.stats.edge_count} relations.`
|
||||
clearBuildPoll()
|
||||
return
|
||||
}
|
||||
|
||||
if (attempts >= 14) {
|
||||
isBuilding.value = false
|
||||
buildStatus.value = 'running'
|
||||
buildTitle.value = 'BUILDING IN BACKGROUND'
|
||||
buildMessage.value = 'The rebuild task is still running. The brain graph projection will appear here as soon as the backend finishes processing memories and tags.'
|
||||
clearBuildPoll()
|
||||
return
|
||||
}
|
||||
|
||||
buildTitle.value = 'REBUILDING PROJECTION'
|
||||
buildMessage.value = `Processing knowledge links... ${attempts + 1}/15`
|
||||
await pollGraphBuild(previousNodeCount, previousEdgeCount, attempts + 1)
|
||||
} catch (error) {
|
||||
isBuilding.value = false
|
||||
buildStatus.value = 'error'
|
||||
buildMessage.value = getErrorMessage(error)
|
||||
clearBuildPoll()
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async function buildGraph() {
|
||||
if (isBuilding.value) return
|
||||
|
||||
isBuilding.value = true
|
||||
buildStatus.value = 'running'
|
||||
buildTitle.value = 'PROJECTION REQUEST ACCEPTED'
|
||||
buildMessage.value = 'The backend has started rebuilding your brain graph projection. Waiting for fresh memory and tag nodes...'
|
||||
const previousNodeCount = stats.value.node_count
|
||||
const previousEdgeCount = stats.value.edge_count
|
||||
|
||||
try {
|
||||
const response = await graphApi.build()
|
||||
buildTitle.value = 'PROJECTION REQUEST ACCEPTED'
|
||||
buildMessage.value = response.data?.message || 'The backend has started rebuilding your brain graph projection. Waiting for fresh memory and tag nodes...'
|
||||
await pollGraphBuild(previousNodeCount, previousEdgeCount)
|
||||
} catch (error) {
|
||||
console.error('构建失败:', error)
|
||||
isBuilding.value = false
|
||||
buildStatus.value = 'error'
|
||||
buildTitle.value = 'BUILD FAILED'
|
||||
buildMessage.value = getErrorMessage(error)
|
||||
clearBuildPoll()
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
chart.setOption({
|
||||
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.18)', 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: 10,
|
||||
},
|
||||
})),
|
||||
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.18)' },
|
||||
}
|
||||
}),
|
||||
force: { repulsion: 170, gravity: 0.04, edgeLength: [70, 220], layoutAnimation: true },
|
||||
}],
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearBuildPoll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-projection" :class="{ fullscreen }">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<div class="header-icon"><Hexagon :size="20" /></div>
|
||||
<div class="header-text">
|
||||
<h1>BRAIN GRAPH PROJECTION</h1>
|
||||
<span class="header-sub">{{ projectionSubtitle }} · {{ stats.node_count }} nodes · {{ stats.edge_count }} relations</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<RouterLink v-if="showOpenFullView" class="open-full-btn" to="/graph">
|
||||
<ExternalLink :size="14" />
|
||||
Open full view
|
||||
</RouterLink>
|
||||
<button class="build-btn" @click="buildGraph" :disabled="isBuilding">
|
||||
<RefreshCw :size="14" :class="{ spin: isBuilding }" />
|
||||
{{ isBuilding ? 'BUILDING PROJECTION...' : 'REBUILD PROJECTION' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="buildStatus !== 'idle'" class="build-alert" :class="`is-${buildStatus}`">
|
||||
<div class="build-alert-grid">
|
||||
<div class="build-alert-signal">
|
||||
<div class="signal-core"></div>
|
||||
<div class="signal-ring ring-1"></div>
|
||||
<div class="signal-ring ring-2"></div>
|
||||
</div>
|
||||
<div class="build-alert-copy">
|
||||
<div class="build-alert-title">{{ buildTitle }}</div>
|
||||
<div class="build-alert-message">{{ buildMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="main-area">
|
||||
<div class="graph-container">
|
||||
<div class="circuit-overlay">
|
||||
<span class="circuit-line line-a"></span>
|
||||
<span class="circuit-line line-b"></span>
|
||||
<span class="circuit-line line-c"></span>
|
||||
</div>
|
||||
<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 PROJECTION DATA</div>
|
||||
<div class="empty-sub">Run brain learning or rebuild the projection graph</div>
|
||||
</div>
|
||||
<div ref="chartRef" v-show="nodes.length > 0" class="chart-canvas"></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-projection {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.graph-projection.fullscreen {
|
||||
min-height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-left,
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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,
|
||||
.open-full-btn {
|
||||
display: inline-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;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
|
||||
.build-alert {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-mid);
|
||||
background: linear-gradient(135deg, rgba(8, 18, 32, 0.96), rgba(11, 27, 46, 0.92));
|
||||
}
|
||||
|
||||
.build-alert-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
min-height: 88px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.build-alert-signal { position: relative; width: 42px; height: 42px; margin-inline: auto; }
|
||||
.signal-core { position: absolute; inset: 12px; border-radius: 50%; background: currentColor; box-shadow: 0 0 18px currentColor; }
|
||||
.signal-ring { position: absolute; inset: 0; border-radius: 50%; border: 1px solid currentColor; opacity: 0.28; }
|
||||
.ring-1 { animation: pulse-ring 1.8s ease-out infinite; }
|
||||
.ring-2 { animation: pulse-ring 1.8s ease-out infinite 0.45s; }
|
||||
.build-alert-copy { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.build-alert-title { font-family: var(--font-display); font-size: 15px; letter-spacing: 0.18em; }
|
||||
.build-alert-message { font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-secondary); }
|
||||
.build-alert.is-running { color: var(--accent-cyan); }
|
||||
.build-alert.is-success { color: #7fffd4; }
|
||||
.build-alert.is-error { color: #f87171; }
|
||||
|
||||
.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: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 16px;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.graph-container,
|
||||
.entity-panel,
|
||||
.relations-section {
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-mid);
|
||||
background: linear-gradient(180deg, rgba(8, 14, 28, 0.98), rgba(6, 12, 22, 0.98));
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.circuit-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.circuit-line {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.8), transparent);
|
||||
}
|
||||
|
||||
.line-a { top: 12%; left: 8%; width: 42%; height: 1px; }
|
||||
.line-b { top: 62%; left: 18%; width: 54%; height: 1px; }
|
||||
.line-c { top: 22%; right: 12%; width: 1px; height: 48%; background: linear-gradient(180deg, transparent, rgba(0, 245, 212, 0.8), transparent); }
|
||||
|
||||
.chart-canvas { height: 720px; width: 100%; }
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
min-height: 720px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.empty-icon { position: relative; display: grid; place-items: center; width: 88px; height: 88px; color: var(--accent-cyan); }
|
||||
.e-ring { position: absolute; inset: 0; border: 1px solid rgba(0, 245, 212, 0.25); border-radius: 50%; }
|
||||
.r1 { animation: pulse-ring 2s ease-out infinite; }
|
||||
.r2 { animation: pulse-ring 2s ease-out infinite 0.6s; }
|
||||
.empty-title { font-family: var(--font-display); letter-spacing: 0.16em; color: var(--text-primary); }
|
||||
.empty-sub { font-family: var(--font-mono); font-size: 12px; }
|
||||
|
||||
.entity-panel { padding: 18px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.panel-title { display: flex; align-items: center; gap: 8px; font-family: var(--font-display); letter-spacing: 0.12em; color: var(--accent-cyan); }
|
||||
.close-panel { margin-left: auto; background: transparent; border: 0; color: var(--text-dim); font-size: 22px; cursor: pointer; }
|
||||
.entity-name { font-size: 22px; font-family: var(--font-display); }
|
||||
.entity-type-tag { width: fit-content; padding: 4px 10px; border: 1px solid; border-radius: 999px; font-family: var(--font-mono); font-size: 10px; }
|
||||
.entity-context { color: var(--text-secondary); line-height: 1.7; white-space: pre-wrap; }
|
||||
|
||||
.relations-section { padding: 16px; }
|
||||
.section-label { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em; color: var(--accent-cyan); margin-bottom: 12px; }
|
||||
.relations-scroll { display: flex; flex-direction: column; gap: 10px; }
|
||||
.rel-item { display: grid; grid-template-columns: 1fr auto 1fr; gap: 12px; align-items: center; }
|
||||
.rel-node { color: var(--text-secondary); font-family: var(--font-mono); font-size: 12px; }
|
||||
.rel-arrow { display: flex; align-items: center; gap: 8px; }
|
||||
.arrow-line { width: 48px; height: 1px; background: rgba(0, 245, 212, 0.35); }
|
||||
.rel-type-badge { color: var(--accent-cyan); font-family: var(--font-mono); font-size: 10px; }
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.85); opacity: 0.5; }
|
||||
100% { transform: scale(1.2); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.main-area { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
555
frontend/src/components/chat/OrchestrationPanel.vue
Normal file
555
frontend/src/components/chat/OrchestrationPanel.vue
Normal file
@@ -0,0 +1,555 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
status: 'idle' | 'active' | 'complete' | 'error'
|
||||
insight: {
|
||||
statusTitle: string
|
||||
systemSummary: string
|
||||
jarvisNote: string
|
||||
}
|
||||
activeAgent: string | null
|
||||
visitedAgents: string[]
|
||||
events: Array<{
|
||||
id: string
|
||||
label: string
|
||||
kind: 'info' | 'tool' | 'success' | 'error'
|
||||
}>
|
||||
systemTelemetry: {
|
||||
cpu: { current: number | null; series: number[]; online: boolean }
|
||||
memory: { current: number | null; series: number[]; online: boolean }
|
||||
disk: { current: number | null; series: number[]; online: boolean }
|
||||
}
|
||||
sessionTelemetry: {
|
||||
activitySeries: number[]
|
||||
eventsCount: number
|
||||
toolCount: number
|
||||
agentCount: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const agentLabels: Record<string, string> = {
|
||||
master: 'JARVIS',
|
||||
planner: 'planner',
|
||||
executor: 'executor',
|
||||
analyst: 'analyst',
|
||||
librarian: 'librarian',
|
||||
}
|
||||
|
||||
const busAgents = ['planner', 'executor', 'analyst', 'librarian'] as const
|
||||
|
||||
function agentState(agent: string) {
|
||||
if (props.activeAgent === agent) return 'active'
|
||||
if (props.visitedAgents.includes(agent)) return 'visited'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
|
||||
if (status === 'complete') return 'COMPLETE'
|
||||
if (status === 'error') return 'ERROR'
|
||||
return 'ACTIVE'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="orchestration-panel" :class="[`is-${status}`, { visible }]">
|
||||
<div class="panel-frame">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="panel-title">JARVIS CONTROL</div>
|
||||
<div class="panel-subtitle">{{ activeAgent ? `${agentLabels[activeAgent] || activeAgent} engaged` : 'Awaiting request' }}</div>
|
||||
</div>
|
||||
<div class="panel-status" :class="status">
|
||||
<span class="status-dot"></span>
|
||||
<span>{{ statusLabel(status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-analysis hud-block">
|
||||
<div class="block-badge">THINKING LAYER</div>
|
||||
<div class="analysis-title">{{ insight.statusTitle }}</div>
|
||||
<div class="analysis-system">{{ insight.systemSummary }}</div>
|
||||
<div class="analysis-note">{{ insight.jarvisNote }}</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-bus">
|
||||
<div class="bus-node core" :class="{ active: visible }">
|
||||
<div class="node-line"></div>
|
||||
<div class="node-body">
|
||||
<div class="node-name">{{ agentLabels.master }}</div>
|
||||
<div class="node-caption">Central Router</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="agent in busAgents"
|
||||
:key="agent"
|
||||
class="bus-node"
|
||||
:class="agentState(agent)"
|
||||
>
|
||||
<div class="node-line"></div>
|
||||
<div class="node-body">
|
||||
<div class="node-name">{{ agentLabels[agent] }}</div>
|
||||
<div class="node-caption">{{ activeAgent === agent ? 'Active Task' : 'Standby' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-feed">
|
||||
<div class="feed-title">Recent Events</div>
|
||||
<div v-if="events.length === 0" class="feed-empty">Awaiting orchestration signal</div>
|
||||
<div v-else class="feed-list">
|
||||
<div v-for="event in events" :key="event.id" class="feed-item" :class="event.kind">
|
||||
<span class="feed-marker"></span>
|
||||
<span class="feed-label">{{ event.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.orchestration-panel {
|
||||
width: 340px;
|
||||
min-width: 340px;
|
||||
padding: 18px 18px 18px 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-frame {
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.18);
|
||||
background: linear-gradient(180deg, rgba(8, 14, 28, 0.94), rgba(6, 10, 20, 0.9));
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 0 20px rgba(34, 211, 238, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.16);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.panel-status.complete {
|
||||
border-color: rgba(74, 222, 128, 0.2);
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.panel-status.error {
|
||||
border-color: rgba(255, 71, 87, 0.24);
|
||||
background: rgba(255, 71, 87, 0.08);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
|
||||
.metrics-section,
|
||||
.activity-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.feed-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hud-card,
|
||||
.hud-block,
|
||||
.node-body {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud-card::before,
|
||||
.hud-block::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.03), transparent 42%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.activity-card,
|
||||
.panel-analysis {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.12);
|
||||
background: rgba(10, 16, 30, 0.72);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 0 16px rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 96px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.activity-card,
|
||||
.panel-analysis {
|
||||
min-height: 114px;
|
||||
padding: 24px 14px 14px;
|
||||
}
|
||||
|
||||
.cpu-card {
|
||||
color: #22d3ee;
|
||||
border-color: rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.mem-card {
|
||||
color: #a78bfa;
|
||||
border-color: rgba(167, 139, 250, 0.22);
|
||||
}
|
||||
|
||||
.disk-card {
|
||||
color: #4ade80;
|
||||
border-color: rgba(74, 222, 128, 0.22);
|
||||
}
|
||||
|
||||
.activity-hud-card {
|
||||
color: #f59e0b;
|
||||
border-color: rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
|
||||
.metric-card::after,
|
||||
.activity-card::after,
|
||||
.panel-analysis::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
top: 12px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, currentColor, transparent);
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.block-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(34, 211, 238, 0.42);
|
||||
}
|
||||
|
||||
.activity-badge {
|
||||
color: rgba(245, 158, 11, 0.65);
|
||||
}
|
||||
|
||||
.metric-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metric-label,
|
||||
.activity-key {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.metric-value,
|
||||
.activity-number {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
text-shadow: 0 0 12px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.activity-key {
|
||||
color: rgba(245, 158, 11, 0.72);
|
||||
}
|
||||
|
||||
.activity-number {
|
||||
color: var(--accent-amber);
|
||||
text-shadow: 0 0 12px rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
.activity-stats {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.activity-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analysis-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.analysis-system {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.analysis-note {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(34, 211, 238, 0.08);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.agent-bus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bus-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.node-line {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-line::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
bottom: -10px;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, rgba(34, 211, 238, 0.4), rgba(34, 211, 238, 0.04));
|
||||
}
|
||||
|
||||
.bus-node:last-child .node-line::before {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
.node-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 18px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(34, 211, 238, 0.24);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
|
||||
.node-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.12);
|
||||
background: rgba(12, 18, 34, 0.78);
|
||||
transition: border-color 0.24s ease, box-shadow 0.24s ease, transform 0.24s ease;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.node-caption {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.bus-node.core .node-body {
|
||||
background: linear-gradient(135deg, rgba(10, 18, 34, 0.95), rgba(22, 18, 42, 0.85));
|
||||
border-color: rgba(34, 211, 238, 0.18);
|
||||
}
|
||||
|
||||
.bus-node.core .node-name {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bus-node.active .node-body {
|
||||
border-color: rgba(34, 211, 238, 0.42);
|
||||
box-shadow: 0 0 18px rgba(34, 211, 238, 0.14);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.bus-node.active .node-name,
|
||||
.bus-node.active .node-caption {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bus-node.active .node-line::after {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bus-node.visited .node-body {
|
||||
border-color: rgba(34, 211, 238, 0.18);
|
||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.06);
|
||||
}
|
||||
|
||||
.bus-node.visited .node-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.event-feed {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(34, 211, 238, 0.1);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feed-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.feed-marker {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(34, 211, 238, 0.5);
|
||||
box-shadow: 0 0 8px rgba(34, 211, 238, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-item.tool .feed-marker {
|
||||
background: var(--accent-amber);
|
||||
box-shadow: 0 0 8px rgba(249, 168, 37, 0.24);
|
||||
}
|
||||
|
||||
.feed-item.success .feed-marker {
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 8px rgba(74, 222, 128, 0.24);
|
||||
}
|
||||
|
||||
.feed-item.error .feed-marker {
|
||||
background: var(--accent-red);
|
||||
box-shadow: 0 0 8px rgba(255, 71, 87, 0.24);
|
||||
}
|
||||
|
||||
.feed-label {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.orchestration-panel {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.orchestration-panel {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.orchestration-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hud-card,
|
||||
.node-body {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
78
frontend/src/components/chat/TelemetrySparkline.vue
Normal file
78
frontend/src/components/chat/TelemetrySparkline.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
points: number[]
|
||||
stroke?: string
|
||||
fill?: string
|
||||
grid?: boolean
|
||||
}>(), {
|
||||
grid: true,
|
||||
stroke: '#22d3ee',
|
||||
fill: 'rgba(34, 211, 238, 0.16)',
|
||||
})
|
||||
|
||||
const width = 220
|
||||
const height = 52
|
||||
const padding = 4
|
||||
|
||||
const normalizedPoints = computed(() => {
|
||||
const source = props.points.length ? props.points : [0, 0]
|
||||
const max = Math.max(...source, 100)
|
||||
const min = 0
|
||||
return source.map((value, index) => {
|
||||
const x = source.length === 1
|
||||
? width / 2
|
||||
: padding + (index * (width - padding * 2)) / (source.length - 1)
|
||||
const y = height - padding - ((value - min) / (max - min || 1)) * (height - padding * 2)
|
||||
return { x, y }
|
||||
})
|
||||
})
|
||||
|
||||
const linePath = computed(() => normalizedPoints.value
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
||||
.join(' '))
|
||||
|
||||
const areaPath = computed(() => {
|
||||
const points = normalizedPoints.value
|
||||
if (!points.length) return ''
|
||||
const start = points[0]
|
||||
const end = points[points.length - 1]
|
||||
return `${linePath.value} L ${end.x} ${height - padding} L ${start.x} ${height - padding} Z`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="sparkline" :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none">
|
||||
<path v-if="grid" class="sparkline-grid" d="M 0 13 H 220 M 0 26 H 220 M 0 39 H 220" />
|
||||
<path :d="areaPath" class="sparkline-area" :fill="fill" />
|
||||
<path :d="linePath" class="sparkline-line" :stroke="stroke" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sparkline {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sparkline-grid {
|
||||
fill: none;
|
||||
stroke: rgba(148, 163, 184, 0.12);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 5;
|
||||
}
|
||||
|
||||
.sparkline-area {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sparkline-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 6px currentColor);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
14
frontend/src/pages/brain/brainEmbed.test.ts
Normal file
14
frontend/src/pages/brain/brainEmbed.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
18
frontend/src/pages/brain/brainRouting.test.ts
Normal file
18
frontend/src/pages/brain/brainRouting.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
7
frontend/src/pages/brain/index.vue
Normal file
7
frontend/src/pages/brain/index.vue
Normal 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>
|
||||
168
frontend/src/pages/chat/composables/useChatView.test.ts
Normal file
168
frontend/src/pages/chat/composables/useChatView.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,9 +2,14 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
let unauthorizedListenerRegistered = false
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('access_token'))
|
||||
const user = ref<{ id: string; email: string; full_name?: string } | null>(null)
|
||||
const isAuthReady = ref(false)
|
||||
const isFetchingUser = ref(false)
|
||||
let authReadyPromise: Promise<void> | null = null
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
@@ -17,28 +22,63 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
token.value = response.data.access_token
|
||||
localStorage.setItem('access_token', response.data.access_token)
|
||||
user.value = null
|
||||
isAuthReady.value = false
|
||||
await fetchUser()
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
if (!token.value) return
|
||||
if (!token.value) {
|
||||
user.value = null
|
||||
isAuthReady.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isFetchingUser.value = true
|
||||
try {
|
||||
const response = await api.get('/api/auth/me')
|
||||
user.value = response.data
|
||||
} catch {
|
||||
logout()
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status
|
||||
if (status === 401 || status === 403) {
|
||||
logout()
|
||||
} else {
|
||||
console.error('获取当前用户失败:', error)
|
||||
}
|
||||
} finally {
|
||||
isFetchingUser.value = false
|
||||
isAuthReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
isFetchingUser.value = false
|
||||
isAuthReady.value = true
|
||||
localStorage.removeItem('access_token')
|
||||
}
|
||||
|
||||
if (token.value) {
|
||||
fetchUser()
|
||||
async function ensureAuthReady() {
|
||||
if (isAuthReady.value) return
|
||||
if (!authReadyPromise) {
|
||||
authReadyPromise = fetchUser().finally(() => {
|
||||
authReadyPromise = null
|
||||
})
|
||||
}
|
||||
await authReadyPromise
|
||||
}
|
||||
|
||||
return { token, user, isAuthenticated, login, logout, fetchUser }
|
||||
if (typeof window !== 'undefined' && !unauthorizedListenerRegistered) {
|
||||
window.addEventListener('jarvis:auth-unauthorized', logout)
|
||||
unauthorizedListenerRegistered = true
|
||||
}
|
||||
|
||||
if (token.value) {
|
||||
void ensureAuthReady()
|
||||
} else {
|
||||
isAuthReady.value = true
|
||||
}
|
||||
|
||||
return { token, user, isAuthenticated, isAuthReady, isFetchingUser, login, logout, fetchUser, ensureAuthReady }
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
conversations.value = data
|
||||
}
|
||||
|
||||
function setCurrentConversation(id: string) {
|
||||
function setCurrentConversation(id: string | null) {
|
||||
currentConversationId.value = id
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
messages.value.push(msg)
|
||||
}
|
||||
|
||||
function removeMessage(id: string) {
|
||||
messages.value = messages.value.filter((msg) => msg.id !== id)
|
||||
}
|
||||
|
||||
function setMessages(data: Message[]) {
|
||||
messages.value = data
|
||||
}
|
||||
@@ -40,6 +44,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
setConversations,
|
||||
setCurrentConversation,
|
||||
addMessage,
|
||||
removeMessage,
|
||||
setMessages,
|
||||
addConversation,
|
||||
removeConversation,
|
||||
|
||||
Reference in New Issue
Block a user