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:
555
frontend/src/components/chat/OrchestrationPanel.vue
Normal file
555
frontend/src/components/chat/OrchestrationPanel.vue
Normal file
@@ -0,0 +1,555 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
status: 'idle' | 'active' | 'complete' | 'error'
|
||||
insight: {
|
||||
statusTitle: string
|
||||
systemSummary: string
|
||||
jarvisNote: string
|
||||
}
|
||||
activeAgent: string | null
|
||||
visitedAgents: string[]
|
||||
events: Array<{
|
||||
id: string
|
||||
label: string
|
||||
kind: 'info' | 'tool' | 'success' | 'error'
|
||||
}>
|
||||
systemTelemetry: {
|
||||
cpu: { current: number | null; series: number[]; online: boolean }
|
||||
memory: { current: number | null; series: number[]; online: boolean }
|
||||
disk: { current: number | null; series: number[]; online: boolean }
|
||||
}
|
||||
sessionTelemetry: {
|
||||
activitySeries: number[]
|
||||
eventsCount: number
|
||||
toolCount: number
|
||||
agentCount: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const agentLabels: Record<string, string> = {
|
||||
master: 'JARVIS',
|
||||
planner: 'planner',
|
||||
executor: 'executor',
|
||||
analyst: 'analyst',
|
||||
librarian: 'librarian',
|
||||
}
|
||||
|
||||
const busAgents = ['planner', 'executor', 'analyst', 'librarian'] as const
|
||||
|
||||
function agentState(agent: string) {
|
||||
if (props.activeAgent === agent) return 'active'
|
||||
if (props.visitedAgents.includes(agent)) return 'visited'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
|
||||
if (status === 'complete') return 'COMPLETE'
|
||||
if (status === 'error') return 'ERROR'
|
||||
return 'ACTIVE'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="orchestration-panel" :class="[`is-${status}`, { visible }]">
|
||||
<div class="panel-frame">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="panel-title">JARVIS CONTROL</div>
|
||||
<div class="panel-subtitle">{{ activeAgent ? `${agentLabels[activeAgent] || activeAgent} engaged` : 'Awaiting request' }}</div>
|
||||
</div>
|
||||
<div class="panel-status" :class="status">
|
||||
<span class="status-dot"></span>
|
||||
<span>{{ statusLabel(status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-analysis hud-block">
|
||||
<div class="block-badge">THINKING LAYER</div>
|
||||
<div class="analysis-title">{{ insight.statusTitle }}</div>
|
||||
<div class="analysis-system">{{ insight.systemSummary }}</div>
|
||||
<div class="analysis-note">{{ insight.jarvisNote }}</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-bus">
|
||||
<div class="bus-node core" :class="{ active: visible }">
|
||||
<div class="node-line"></div>
|
||||
<div class="node-body">
|
||||
<div class="node-name">{{ agentLabels.master }}</div>
|
||||
<div class="node-caption">Central Router</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="agent in busAgents"
|
||||
:key="agent"
|
||||
class="bus-node"
|
||||
:class="agentState(agent)"
|
||||
>
|
||||
<div class="node-line"></div>
|
||||
<div class="node-body">
|
||||
<div class="node-name">{{ agentLabels[agent] }}</div>
|
||||
<div class="node-caption">{{ activeAgent === agent ? 'Active Task' : 'Standby' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-feed">
|
||||
<div class="feed-title">Recent Events</div>
|
||||
<div v-if="events.length === 0" class="feed-empty">Awaiting orchestration signal</div>
|
||||
<div v-else class="feed-list">
|
||||
<div v-for="event in events" :key="event.id" class="feed-item" :class="event.kind">
|
||||
<span class="feed-marker"></span>
|
||||
<span class="feed-label">{{ event.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.orchestration-panel {
|
||||
width: 340px;
|
||||
min-width: 340px;
|
||||
padding: 18px 18px 18px 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-frame {
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.18);
|
||||
background: linear-gradient(180deg, rgba(8, 14, 28, 0.94), rgba(6, 10, 20, 0.9));
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 0 20px rgba(34, 211, 238, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.16);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.panel-status.complete {
|
||||
border-color: rgba(74, 222, 128, 0.2);
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.panel-status.error {
|
||||
border-color: rgba(255, 71, 87, 0.24);
|
||||
background: rgba(255, 71, 87, 0.08);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
|
||||
.metrics-section,
|
||||
.activity-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.feed-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hud-card,
|
||||
.hud-block,
|
||||
.node-body {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud-card::before,
|
||||
.hud-block::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.03), transparent 42%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.activity-card,
|
||||
.panel-analysis {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.12);
|
||||
background: rgba(10, 16, 30, 0.72);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 0 16px rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 96px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.activity-card,
|
||||
.panel-analysis {
|
||||
min-height: 114px;
|
||||
padding: 24px 14px 14px;
|
||||
}
|
||||
|
||||
.cpu-card {
|
||||
color: #22d3ee;
|
||||
border-color: rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.mem-card {
|
||||
color: #a78bfa;
|
||||
border-color: rgba(167, 139, 250, 0.22);
|
||||
}
|
||||
|
||||
.disk-card {
|
||||
color: #4ade80;
|
||||
border-color: rgba(74, 222, 128, 0.22);
|
||||
}
|
||||
|
||||
.activity-hud-card {
|
||||
color: #f59e0b;
|
||||
border-color: rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
|
||||
.metric-card::after,
|
||||
.activity-card::after,
|
||||
.panel-analysis::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
top: 12px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, currentColor, transparent);
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.block-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(34, 211, 238, 0.42);
|
||||
}
|
||||
|
||||
.activity-badge {
|
||||
color: rgba(245, 158, 11, 0.65);
|
||||
}
|
||||
|
||||
.metric-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metric-label,
|
||||
.activity-key {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.metric-value,
|
||||
.activity-number {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
text-shadow: 0 0 12px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.activity-key {
|
||||
color: rgba(245, 158, 11, 0.72);
|
||||
}
|
||||
|
||||
.activity-number {
|
||||
color: var(--accent-amber);
|
||||
text-shadow: 0 0 12px rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
.activity-stats {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.activity-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analysis-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.analysis-system {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.analysis-note {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(34, 211, 238, 0.08);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.agent-bus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bus-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.node-line {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-line::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
bottom: -10px;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, rgba(34, 211, 238, 0.4), rgba(34, 211, 238, 0.04));
|
||||
}
|
||||
|
||||
.bus-node:last-child .node-line::before {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
.node-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 18px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(34, 211, 238, 0.24);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
|
||||
.node-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.12);
|
||||
background: rgba(12, 18, 34, 0.78);
|
||||
transition: border-color 0.24s ease, box-shadow 0.24s ease, transform 0.24s ease;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.node-caption {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.bus-node.core .node-body {
|
||||
background: linear-gradient(135deg, rgba(10, 18, 34, 0.95), rgba(22, 18, 42, 0.85));
|
||||
border-color: rgba(34, 211, 238, 0.18);
|
||||
}
|
||||
|
||||
.bus-node.core .node-name {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bus-node.active .node-body {
|
||||
border-color: rgba(34, 211, 238, 0.42);
|
||||
box-shadow: 0 0 18px rgba(34, 211, 238, 0.14);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.bus-node.active .node-name,
|
||||
.bus-node.active .node-caption {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bus-node.active .node-line::after {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bus-node.visited .node-body {
|
||||
border-color: rgba(34, 211, 238, 0.18);
|
||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.06);
|
||||
}
|
||||
|
||||
.bus-node.visited .node-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.event-feed {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(34, 211, 238, 0.1);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feed-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.feed-marker {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(34, 211, 238, 0.5);
|
||||
box-shadow: 0 0 8px rgba(34, 211, 238, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-item.tool .feed-marker {
|
||||
background: var(--accent-amber);
|
||||
box-shadow: 0 0 8px rgba(249, 168, 37, 0.24);
|
||||
}
|
||||
|
||||
.feed-item.success .feed-marker {
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 8px rgba(74, 222, 128, 0.24);
|
||||
}
|
||||
|
||||
.feed-item.error .feed-marker {
|
||||
background: var(--accent-red);
|
||||
box-shadow: 0 0 8px rgba(255, 71, 87, 0.24);
|
||||
}
|
||||
|
||||
.feed-label {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.orchestration-panel {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.orchestration-panel {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.orchestration-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hud-card,
|
||||
.node-body {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
78
frontend/src/components/chat/TelemetrySparkline.vue
Normal file
78
frontend/src/components/chat/TelemetrySparkline.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
points: number[]
|
||||
stroke?: string
|
||||
fill?: string
|
||||
grid?: boolean
|
||||
}>(), {
|
||||
grid: true,
|
||||
stroke: '#22d3ee',
|
||||
fill: 'rgba(34, 211, 238, 0.16)',
|
||||
})
|
||||
|
||||
const width = 220
|
||||
const height = 52
|
||||
const padding = 4
|
||||
|
||||
const normalizedPoints = computed(() => {
|
||||
const source = props.points.length ? props.points : [0, 0]
|
||||
const max = Math.max(...source, 100)
|
||||
const min = 0
|
||||
return source.map((value, index) => {
|
||||
const x = source.length === 1
|
||||
? width / 2
|
||||
: padding + (index * (width - padding * 2)) / (source.length - 1)
|
||||
const y = height - padding - ((value - min) / (max - min || 1)) * (height - padding * 2)
|
||||
return { x, y }
|
||||
})
|
||||
})
|
||||
|
||||
const linePath = computed(() => normalizedPoints.value
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
||||
.join(' '))
|
||||
|
||||
const areaPath = computed(() => {
|
||||
const points = normalizedPoints.value
|
||||
if (!points.length) return ''
|
||||
const start = points[0]
|
||||
const end = points[points.length - 1]
|
||||
return `${linePath.value} L ${end.x} ${height - padding} L ${start.x} ${height - padding} Z`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="sparkline" :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none">
|
||||
<path v-if="grid" class="sparkline-grid" d="M 0 13 H 220 M 0 26 H 220 M 0 39 H 220" />
|
||||
<path :d="areaPath" class="sparkline-area" :fill="fill" />
|
||||
<path :d="linePath" class="sparkline-line" :stroke="stroke" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sparkline {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sparkline-grid {
|
||||
fill: none;
|
||||
stroke: rgba(148, 163, 184, 0.12);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 5;
|
||||
}
|
||||
|
||||
.sparkline-area {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sparkline-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 6px currentColor);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user