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

View 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>