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

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

View File

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