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,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>
|
||||
|
||||
Reference in New Issue
Block a user