Add brain and chat workspace views

Expand the frontend with brain, graph, and chat workspace updates so the
new backend orchestration and memory features have matching screens.
These changes also wire the new APIs into routing and add focused view
and routing tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 13:48:16 +08:00
parent d2447ee635
commit 7d80a6e2ec
21 changed files with 3095 additions and 658 deletions

View File

@@ -1,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 || '连接失败。请检查服务状态。')
}
}
}
},
}

View File

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

View File

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

View 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')
},
}