feat: enhance agent orchestration, knowledge flow and UI refinements

This commit is contained in:
2026-03-29 20:31:13 +08:00
parent d85cb9cf35
commit e0fe3ca623
301 changed files with 1197804 additions and 7863 deletions

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { X, Loader, Terminal, ShieldCheck, Activity } from 'lucide-vue-next'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import './KnowledgeHud.css'
import { watch } from 'vue'
const props = defineProps<{
doc: any
}>()
const emit = defineEmits(['close'])
const {
activeDocumentContent,
isLoadingDocumentContent,
openDocument,
getFileTypeColor,
formatFileSize,
} = useKnowledgeView()
watch(() => props.doc, (newDoc) => {
if (newDoc) {
openDocument(newDoc)
}
}, { immediate: true })
</script>
<template>
<div class="knowledge-hud-preview" @click.self="emit('close')">
<div class="hud-frame-jarvis">
<!-- Outer scan decoration -->
<div class="tech-corner tl"></div>
<div class="tech-corner tr"></div>
<div class="tech-corner bl"></div>
<div class="tech-corner br"></div>
<header class="hud-header-jarvis">
<div class="header-left-tech">
<Terminal :size="16" class="glow-cyan" />
<div class="title-stack">
<span class="sub-kicker">SECURE_DATA_PROJECTOR // V3.0</span>
<h3 class="main-title-jarvis">{{ props.doc?.title.toUpperCase() }}</h3>
</div>
</div>
<div class="header-right-tech">
<div class="meta-pillar">
<span class="meta-label">STATUS</span>
<span class="meta-val cyan">ENCRYPTED</span>
</div>
<div class="meta-pillar">
<span class="meta-label">TYPE</span>
<span class="meta-val amber">{{ props.doc?.file_type.toUpperCase() }}</span>
</div>
<button class="close-jarvis-btn" @click="emit('close')">ABORT_X</button>
</div>
</header>
<div class="hud-body-jarvis slim-scroll">
<div v-if="isLoadingDocumentContent" class="decoding-screen">
<Activity :size="40" class="pulse-tech" />
<span class="decoding-text">ANALYZING_BYTESTREAM...</span>
<div class="progress-bar-tech"><div class="progress-fill"></div></div>
</div>
<div v-else class="content-matrix-wrap">
<div class="line-numbers">
<div v-for="n in 20" :key="n" class="ln">{{ (n * 100).toString(16).toUpperCase() }}</div>
</div>
<pre class="preview-content-jarvis">{{ activeDocumentContent || '--- NO_DATA_STREAMS_DETECTED ---' }}</pre>
</div>
</div>
<footer class="hud-footer-jarvis">
<div class="footer-left">
<ShieldCheck :size="12" />
<span>AUTHORIZED_ACCESS_ONLY // UID_0x88219</span>
</div>
<div class="footer-right">
<span>PARSING_COMPLETE</span>
<div class="bit-dot"></div>
</div>
</footer>
</div>
</div>
</template>
<style scoped>
.hud-header-jarvis { padding: 30px; display: flex; justify-content: space-between; align-items: flex-start; background: rgba(0, 245, 212, 0.05); border-bottom: 1px solid rgba(0, 245, 212, 0.2); }
.header-left-tech { display: flex; gap: 20px; align-items: center; }
.title-stack { display: flex; flex-direction: column; gap: 4px; }
.sub-kicker { font-family: var(--font-mono); font-size: 9px; color: var(--jarvis-cyan); letter-spacing: 0.3em; }
.main-title-jarvis { font-family: var(--font-display); font-size: 26px; color: #fff; margin: 0; text-shadow: 0 0 20px rgba(0, 245, 212, 0.4); }
.header-right-tech { display: flex; gap: 30px; align-items: center; }
.meta-pillar { display: flex; flex-direction: column; gap: 4px; }
.meta-label { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); }
.meta-val { font-family: var(--font-display); font-size: 11px; font-weight: bold; }
.meta-val.cyan { color: var(--jarvis-cyan); }
.meta-val.amber { color: var(--jarvis-amber); }
.close-jarvis-btn { background: var(--jarvis-amber); color: #000; border: none; font-family: var(--font-display); font-size: 12px; font-weight: 900; padding: 12px 25px; cursor: pointer; transition: 0.3s; }
.close-jarvis-btn:hover { background: #fff; box-shadow: 0 0 20px #fff; }
.hud-body-jarvis { flex: 1; position: relative; overflow-y: auto; background: rgba(0,0,0,0.4); }
.content-matrix-wrap { display: flex; height: 100%; }
.line-numbers { padding: 30px 15px; border-right: 1px solid rgba(255,255,255,0.05); background: rgba(0,0,0,0.2); }
.ln { font-family: var(--font-mono); font-size: 9px; color: var(--text-muted); margin-bottom: 15px; }
.preview-content-jarvis { flex: 1; margin: 0; padding: 30px; font-family: 'Share Tech Mono', monospace; font-size: 16px; line-height: 1.8; color: var(--jarvis-cyan); opacity: 0.8; }
.hud-footer-jarvis { padding: 15px 30px; border-top: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); letter-spacing: 0.1em; }
.footer-right { display: flex; align-items: center; gap: 10px; color: var(--jarvis-cyan); }
.bit-dot { width: 6px; height: 6px; background: var(--jarvis-cyan); box-shadow: 0 0 8px var(--jarvis-cyan); animation: pulse 1s infinite; }
.decoding-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 25px; color: var(--jarvis-cyan); }
.decoding-text { font-family: var(--font-mono); font-size: 14px; letter-spacing: 0.4em; }
.progress-bar-tech { width: 300px; height: 2px; background: rgba(0, 245, 212, 0.1); position: relative; overflow: hidden; }
.progress-fill { position: absolute; left: 0; top: 0; height: 100%; width: 50%; background: var(--jarvis-cyan); animation: progress-move 2s infinite; }
@keyframes progress-move { 0% { left: -50%; } 100% { left: 100%; } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
</style>

View File

@@ -0,0 +1,199 @@
/* Knowledge Hud - Jarvis Integrated Station */
:root {
--jarvis-cyan: #00f5d4;
--jarvis-amber: #f9a825;
--jarvis-bg: rgba(2, 10, 20, 0.98);
--jarvis-border: 1px solid rgba(0, 245, 212, 0.4);
--hex-pattern: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMzQuNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTAgMEwwIDUuN3YxMS41TDEwIDIzbDEwLTUuN1Y1LjdMMTAgMHpNMTAgMjJsLTgtNC42VjYuOEwxMCAyLjJsOCA0LjZ2MTAuNkwxMCAyMnoiIGZpbGw9IiMwMGY1ZDQiIGZpbGwtb3BhY2l0eT0iMC4wNSIvPjwvc3ZnPg==');
}
/* 全屏遮罩层 */
.jarvis-hud-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 5, 10, 0.85);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
/* 一体化 HUD 主容器 */
.jarvis-integrated-station {
width: 100%;
max-width: 1100px;
height: 700px;
background: var(--jarvis-bg);
background-image: var(--hex-pattern);
border: var(--jarvis-border);
display: flex;
flex-direction: column;
position: relative;
box-shadow: 0 0 100px rgba(0, 245, 212, 0.15);
}
/* 装饰性科技支架 */
.jarvis-integrated-station::before,
.jarvis-integrated-station::after {
content: '';
position: absolute;
width: 30px;
height: 30px;
border: 3px solid var(--jarvis-cyan);
pointer-events: none;
}
.jarvis-integrated-station::before { top: -1px; left: -1px; border-right: none; border-bottom: none; }
.jarvis-integrated-station::after { bottom: -1px; right: -1px; border-left: none; border-top: none; }
/* 顶部标题栏 */
.station-header {
padding: 20px 30px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.1), transparent);
border-bottom: 1px solid rgba(0, 245, 212, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.station-title-group {
display: flex;
align-items: center;
gap: 15px;
}
.station-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 900;
color: #fff;
letter-spacing: 0.1em;
margin: 0;
}
/* 主体内容区布局 */
.station-body {
flex: 1;
display: flex;
overflow: hidden;
}
/* 左侧栏:目录树 */
.station-sidebar {
width: 280px;
border-right: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
}
.sidebar-label {
padding: 15px 25px;
font-family: var(--font-mono);
font-size: 9px;
color: var(--jarvis-cyan);
opacity: 0.6;
letter-spacing: 0.2em;
}
.folder-nav-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.nav-item-jarvis {
width: 100%;
padding: 12px 15px;
display: flex;
align-items: center;
gap: 12px;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
font-family: var(--font-display);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: 0.2s;
}
.nav-item-jarvis:hover, .nav-item-jarvis.active {
background: rgba(0, 245, 212, 0.1);
color: var(--jarvis-cyan);
padding-left: 20px;
}
/* 右侧内容区 */
.station-main-view {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.1);
}
.view-toolbar {
padding: 15px 25px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
display: flex;
justify-content: space-between;
align-items: center;
}
.view-content-scroll {
flex: 1;
overflow-y: auto;
padding: 25px;
}
/* 文件网格 */
.data-grid-jarvis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 15px;
}
.data-card-jarvis {
padding: 15px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
gap: 15px;
cursor: pointer;
transition: 0.2s;
}
.data-card-jarvis:hover {
border-color: var(--jarvis-cyan);
background: rgba(0, 245, 212, 0.05);
transform: translateY(-2px);
}
/* 底部状态栏 */
.station-footer {
padding: 10px 30px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
display: flex;
justify-content: space-between;
}
/* 按钮通用 */
.jarvis-btn {
background: transparent;
border: 1px solid var(--jarvis-cyan);
color: var(--jarvis-cyan);
padding: 6px 12px;
font-family: var(--font-display);
font-size: 10px;
text-transform: uppercase;
cursor: pointer;
}
.jarvis-btn:hover { background: var(--jarvis-cyan); color: #000; }
.jarvis-btn.amber { border-color: var(--jarvis-amber); color: var(--jarvis-amber); }
.jarvis-btn.amber:hover { background: var(--jarvis-amber); color: #000; }

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { ChevronRight, Database, FileText, FolderOpen, FolderPlus, Loader, Upload, X } from 'lucide-vue-next'
import FolderTree from '@/components/FolderTree.vue'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
const {
folders, documents, currentFolderId, uploadError, uploadSuccess, highlightedDocumentId, uploadInput,
showNewFolderDialog, newFolderName, showRenameDialog, renameFolderName, showDeleteDialog, deletingFolder,
showDocumentDialog, activeDocument, activeDocumentContent, isLoadingDocumentContent, isLoadingDocuments,
isRoot, visibleFolders, breadcrumbs, currentFolder, enterFolder, goToFolder, triggerUpload, handleUpload,
handleDeleteDocument, openNewFolderDialog, createFolder, openRenameDialog, renameFolder, openDeleteDialog,
deleteFolder, openDocument, closeDocumentDialog, getFileTypeColor, formatFileSize, formatDate, getStatusLabel,
} = useKnowledgeView()
</script>
<template>
<div class="knowledge-hud-panel">
<input ref="uploadInput" type="file" class="hidden-upload" accept=".pdf,.md,.txt,.doc,.docx,.csv,.xlsx" @change="handleUpload" />
<div class="hud-toolbar">
<div class="hud-toolbar-left">
<div class="hud-badge"><Database :size="14" /><span>ARCHIVE CORE</span></div>
<div class="hud-breadcrumbs">
<button
v-for="(crumb, index) in breadcrumbs"
:key="`${crumb.id ?? 'root'}-${index}`"
class="hud-crumb"
:class="{ active: crumb.id === currentFolderId || (crumb.id === null && isRoot) }"
@click="goToFolder(crumb.id)"
>
<span>{{ crumb.name }}</span>
<ChevronRight v-if="index < breadcrumbs.length - 1" :size="12" />
</button>
</div>
</div>
<div class="hud-toolbar-actions">
<button class="hud-btn" @click="openNewFolderDialog(currentFolderId)"><FolderPlus :size="14" /><span>NEW FOLDER</span></button>
<button class="hud-btn primary" :disabled="isRoot" @click="triggerUpload"><Upload :size="14" /><span>UPLOAD FILE</span></button>
</div>
</div>
<div v-if="uploadError" class="hud-alert error">{{ uploadError }}</div>
<div v-if="uploadSuccess" class="hud-alert success">{{ uploadSuccess }}</div>
<div class="explorer-shell">
<aside class="folder-sidebar">
<div class="pane-head">
<div class="pane-title">DIRECTORIES</div>
<button class="sidebar-action" @click="openNewFolderDialog(null)">ROOT NODE</button>
</div>
<div v-if="folders.length" class="folder-list tree-host">
<FolderTree
:folders="folders"
:selected-id="currentFolderId"
:on-select="enterFolder"
:on-create="openNewFolderDialog"
:on-rename="openRenameDialog"
:on-delete="openDeleteDialog"
/>
</div>
<div v-else class="pane-empty">
<FolderOpen :size="22" />
<span>NO DIRECTORIES</span>
</div>
</aside>
<section class="content-pane">
<div class="pane-head content-head">
<div>
<div class="pane-title">CONTENTS</div>
<div class="content-subtitle">{{ currentFolder?.name || 'ROOT DIRECTORY' }}</div>
</div>
<div class="content-meta">
<span>{{ visibleFolders.length }} folders</span>
<span>{{ documents.length }} files</span>
</div>
</div>
<div class="content-table">
<div class="content-table-head">
<span>NAME</span>
<span>TYPE</span>
<span>DATE</span>
<span>SIZE</span>
<span>STATUS</span>
<span>ACTIONS</span>
</div>
<div v-if="isLoadingDocuments" class="pane-empty loading-state">
<Loader :size="16" class="spin" />
<span>LOADING DIRECTORY...</span>
</div>
<div v-else-if="visibleFolders.length === 0 && documents.length === 0" class="pane-empty">
<FolderOpen :size="24" />
<span>DIRECTORY EMPTY</span>
</div>
<div v-else class="content-table-body">
<button v-for="folder in visibleFolders" :key="folder.id" class="content-row folder-row" @click="enterFolder(folder)">
<span class="cell name-cell">
<span class="glyph folder"><FolderOpen :size="14" /></span>
<span class="entry-name">{{ folder.name }}</span>
</span>
<span class="cell">FOLDER</span>
<span class="cell">--</span>
<span class="cell">--</span>
<span class="cell"><span class="status-chip ready">READY</span></span>
<span class="cell row-actions">
<button class="inline-btn" @click.stop="openRenameDialog(folder)">RENAME</button>
<button class="inline-btn danger" @click.stop="openDeleteDialog(folder)">DELETE</button>
</span>
</button>
<button
v-for="doc in documents"
:key="doc.id"
class="content-row"
:class="{ highlight: highlightedDocumentId === doc.id }"
@click="openDocument(doc)"
>
<span class="cell name-cell">
<span class="glyph" :style="{ color: getFileTypeColor(doc.file_type) }"><FileText :size="14" /></span>
<span class="entry-name">{{ doc.title }}</span>
</span>
<span class="cell">{{ doc.file_type.toUpperCase() }}</span>
<span class="cell">{{ formatDate(doc.created_at) }}</span>
<span class="cell">{{ formatFileSize(doc.file_size) }}</span>
<span class="cell">
<span class="status-chip" :class="(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded')).toLowerCase()">
{{ getStatusLabel(doc.ingestion_status, doc.is_indexed) }}
</span>
</span>
<span class="cell row-actions">
<button class="inline-btn danger" @click.stop="handleDeleteDocument(doc.id)">DELETE</button>
</span>
</button>
</div>
</div>
</section>
</div>
<div v-if="showNewFolderDialog" class="dialog-overlay" @click.self="showNewFolderDialog = false">
<div class="dialog-card">
<div class="dialog-head"><span>CREATE DIRECTORY</span><button class="dialog-close" @click="showNewFolderDialog = false"><X :size="14" /></button></div>
<input v-model="newFolderName" class="dialog-input" placeholder="DIRECTORY NAME" @keyup.enter="createFolder" />
<div class="dialog-actions"><button class="hud-btn" @click="showNewFolderDialog = false">CANCEL</button><button class="hud-btn primary" @click="createFolder">CREATE</button></div>
</div>
</div>
<div v-if="showRenameDialog" class="dialog-overlay" @click.self="showRenameDialog = false">
<div class="dialog-card">
<div class="dialog-head"><span>RENAME DIRECTORY</span><button class="dialog-close" @click="showRenameDialog = false"><X :size="14" /></button></div>
<input v-model="renameFolderName" class="dialog-input" placeholder="DIRECTORY NAME" @keyup.enter="renameFolder" />
<div class="dialog-actions"><button class="hud-btn" @click="showRenameDialog = false">CANCEL</button><button class="hud-btn primary" @click="renameFolder">SAVE</button></div>
</div>
</div>
<div v-if="showDeleteDialog && deletingFolder" class="dialog-overlay" @click.self="showDeleteDialog = false">
<div class="dialog-card">
<div class="dialog-head"><span>DELETE DIRECTORY</span><button class="dialog-close" @click="showDeleteDialog = false"><X :size="14" /></button></div>
<div class="dialog-copy">DELETE <strong>{{ deletingFolder.name }}</strong> AND ITS CONTENTS?</div>
<div class="dialog-actions"><button class="hud-btn" @click="showDeleteDialog = false">CANCEL</button><button class="hud-btn danger" @click="deleteFolder">DELETE</button></div>
</div>
</div>
<div v-if="showDocumentDialog && activeDocument" class="dialog-overlay hud-preview" @click.self="closeDocumentDialog()">
<div class="dialog-card document-card">
<div class="dialog-head"><span>{{ activeDocument.title }}</span><button class="dialog-close" @click="closeDocumentDialog()"><X :size="14" /></button></div>
<div class="document-meta">
<span>{{ activeDocument.file_type.toUpperCase() }}</span>
<span>{{ formatFileSize(activeDocument.file_size) }}</span>
<span>{{ formatDate(activeDocument.created_at) }}</span>
</div>
<div v-if="isLoadingDocumentContent" class="pane-empty loading-state"><Loader :size="16" class="spin" /><span>LOADING DOCUMENT...</span></div>
<pre v-else class="document-content">{{ activeDocumentContent || 'NO PREVIEW AVAILABLE.' }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
.knowledge-hud-panel{height:100%;display:flex;flex-direction:column;gap:14px;padding:14px 16px 16px;color:var(--text-primary);position:relative}
.knowledge-hud-panel::before,.knowledge-hud-panel::after{content:'';position:absolute;inset:0;pointer-events:none}
.knowledge-hud-panel::before{background:repeating-linear-gradient(180deg,rgba(125,211,252,.01) 0,rgba(125,211,252,.01) 1px,transparent 1px,transparent 10px);opacity:.12}
.knowledge-hud-panel::after{background:radial-gradient(circle at 14% 18%,rgba(34,211,238,.05),transparent 24%),radial-gradient(circle at 82% 14%,rgba(245,158,11,.04),transparent 22%);opacity:.72}
.hidden-upload{display:none}
.hud-toolbar,.explorer-shell,.dialog-card{position:relative;z-index:1;border:1px solid rgba(110,231,255,.18);background:linear-gradient(180deg,rgba(8,15,28,.78),rgba(5,10,18,.64));box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 18px 48px rgba(2,6,14,.26);backdrop-filter:blur(14px) saturate(115%)}
.hud-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 14px;overflow:hidden}
.hud-toolbar-left,.hud-toolbar-actions,.hud-badge,.hud-breadcrumbs,.hud-crumb,.pane-head,.dialog-head,.dialog-actions,.document-meta{display:flex;align-items:center}
.hud-toolbar-left{gap:14px;min-width:0}
.hud-toolbar-actions{gap:10px}
.hud-badge,.pane-title,.content-meta,.sidebar-action,.content-table-head,.status-chip,.inline-btn,.document-meta,.hud-crumb{font-family:'Share Tech Mono','Orbitron',monospace}
.content-subtitle,.dialog-head,.entry-name,.folder-list :deep(.folder-name){font-family:'Orbitron','Share Tech Mono',monospace}
.hud-badge{gap:8px;padding:6px 10px;border:1px solid rgba(34,211,238,.18);background:rgba(12,26,40,.54);color:#d8fbff;font-size:10px;letter-spacing:.16em}
.hud-breadcrumbs{gap:6px;min-width:0;flex-wrap:wrap}
.hud-crumb{gap:6px;border:none;background:transparent;color:var(--text-dim);font-size:10px;cursor:pointer;letter-spacing:.1em}
.hud-crumb.active{color:#d8fbff}
.hud-btn,.sidebar-action,.inline-btn{display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:border-color var(--transition-fast),background var(--transition-fast),transform var(--transition-fast)}
.hud-btn{gap:8px;padding:8px 12px;border:1px solid rgba(148,163,184,.18);background:rgba(9,16,30,.42);color:var(--text-primary);font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.12em}
.hud-btn.primary{border-color:rgba(34,211,238,.18);color:#d8fbff}
.hud-btn.danger,.inline-btn.danger{border-color:rgba(255,71,87,.24);color:#fda4af}
.hud-btn:hover:not(:disabled),.sidebar-action:hover,.inline-btn:hover{transform:translateY(-1px);border-color:rgba(34,211,238,.24);background:rgba(14,24,40,.58)}
.hud-btn:disabled{opacity:.45;cursor:not-allowed}
.hud-alert{position:relative;z-index:1;padding:9px 12px;font-size:12px;font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.08em}
.hud-alert.error{background:rgba(127,29,29,.28);color:#fecaca}
.hud-alert.success{background:rgba(20,83,45,.28);color:#bbf7d0}
.explorer-shell{display:grid;grid-template-columns:280px 1fr;gap:0;flex:1;min-height:0;overflow:hidden}
.folder-sidebar{border-right:1px solid rgba(110,231,255,.1);background:linear-gradient(180deg,rgba(8,14,24,.62),rgba(6,10,16,.4));display:flex;flex-direction:column;min-height:0}
.content-pane{display:flex;flex-direction:column;min-height:0;background:linear-gradient(180deg,rgba(8,16,28,.28),rgba(5,10,18,.14))}
.pane-head{justify-content:space-between;gap:12px;padding:14px 16px 10px}
.pane-title{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:rgba(186,230,253,.78)}
.content-subtitle{margin-top:6px;color:#d8fbff;font-size:14px;letter-spacing:.12em;text-transform:uppercase}
.content-meta{display:inline-flex;gap:10px;color:var(--text-dim);font-size:10px;letter-spacing:.08em}
.sidebar-action{padding:0;border:none;background:transparent;color:#93f7ff;font-size:10px;letter-spacing:.14em}
.folder-list{padding:0 12px 14px;overflow:auto}
.tree-host :deep(.folder-tree){font-size:12px}
.tree-host :deep(.folder-row){padding:8px 10px;gap:8px;border-radius:0}
.tree-host :deep(.folder-row:hover){background:rgba(34,211,238,.06)}
.tree-host :deep(.folder-row.selected){background:rgba(34,211,238,.08);border:1px solid rgba(34,211,238,.28);box-shadow:inset 3px 0 0 #22d3ee}
.tree-host :deep(.folder-icon){color:#fbbf24}
.tree-host :deep(.folder-name){font-family:'Orbitron','Share Tech Mono',monospace;letter-spacing:.06em}
.tree-host :deep(.folder-actions button){font-family:'Share Tech Mono','Orbitron',monospace}
.tree-host :deep(.folder-children){padding-left:18px}
.glyph,.tree-host :deep(.folder-icon){flex-shrink:0}
.glyph{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;background:rgba(10,22,36,.68);border:1px solid rgba(34,211,238,.16);color:#93f7ff}
.entry-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:.06em}
.content-table{flex:1;min-height:0;display:flex;flex-direction:column;padding:0 16px 16px}
.content-table-head,.content-row{display:grid;grid-template-columns:minmax(220px,2fr) 90px 110px 90px 110px 110px;gap:12px;align-items:center}
.content-table-head{padding:10px 14px;border-bottom:1px solid rgba(148,163,184,.12);color:rgba(186,230,253,.78);font-size:10px;letter-spacing:.14em;text-transform:uppercase}
.content-table-body{flex:1;overflow:auto}
.content-row{width:100%;padding:12px 14px;border:none;border-bottom:1px solid rgba(148,163,184,.08);background:rgba(8,14,26,.08);color:var(--text-secondary);text-align:left;cursor:pointer;transition:background var(--transition-fast),box-shadow var(--transition-fast)}
.content-row:hover,.content-row.highlight{background:rgba(34,211,238,.06);box-shadow:inset 3px 0 0 #22d3ee,0 0 0 1px rgba(34,211,238,.08)}
.cell{min-width:0;font-size:12px}
.name-cell{display:flex;align-items:center;gap:10px}
.glyph.folder{color:#fbbf24}
.status-chip{display:inline-flex;padding:4px 8px;border:1px solid rgba(148,163,184,.12);background:rgba(8,14,26,.24);font-size:9px;letter-spacing:.12em}
.status-chip.ready{color:#86efac}.status-chip.failed{color:#fda4af}.status-chip.warning,.status-chip.uploaded,.status-chip.parsing,.status-chip.indexing{color:#fde68a}
.row-actions{display:inline-flex;gap:10px}
.inline-btn{padding:0;border:none;background:transparent;color:#93f7ff;font-size:10px;letter-spacing:.14em}
.pane-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--text-dim);text-align:center;font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.14em}
.loading-state{min-height:180px}
.dialog-overlay{position:absolute;inset:0;z-index:8;display:flex;align-items:center;justify-content:center;background:rgba(2,6,14,.34);backdrop-filter:blur(10px)}
.dialog-overlay.hud-preview{background:radial-gradient(circle at center,rgba(34,211,238,.08),transparent 24%),rgba(2,6,14,.48)}
.dialog-card{width:min(520px,calc(100vw - 48px));padding:16px;overflow:hidden}
.document-card{width:min(900px,calc(100vw - 72px))}
.dialog-head{justify-content:space-between;gap:12px;margin-bottom:14px;letter-spacing:.14em;color:#d8fbff;text-transform:uppercase}
.dialog-close{border:none;background:transparent;color:var(--text-dim);cursor:pointer}
.dialog-input,.document-content{width:100%;border:1px solid rgba(148,163,184,.16);background:rgba(7,13,24,.34);color:var(--text-primary);backdrop-filter:blur(12px)}
.dialog-input{padding:12px 14px;margin-bottom:14px;font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.1em}
.dialog-copy{margin-bottom:16px;color:var(--text-secondary);font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.08em}
.dialog-actions{gap:14px;justify-content:flex-end;padding-top:4px}
.dialog-actions .hud-btn{min-width:124px;padding:10px 16px}
.document-meta{justify-content:flex-start;gap:12px;margin-bottom:12px;color:var(--text-dim);font-size:10px;letter-spacing:.12em}
.document-content{min-height:360px;max-height:min(60vh,680px);overflow:auto;padding:14px;white-space:pre-wrap;line-height:1.6;font-family:'Share Tech Mono','Orbitron',monospace}
.spin{animation:spin 1s linear infinite}
@media (max-width:900px){.explorer-shell{grid-template-columns:1fr}.folder-sidebar{max-height:220px;border-right:none;border-bottom:1px solid rgba(34,211,238,.12)}.content-table-head,.content-row{grid-template-columns:minmax(180px,2fr) 80px 90px 90px}.content-table-head span:nth-child(5),.content-table-head span:nth-child(6),.content-row .cell:nth-child(5),.content-row .cell:nth-child(6){display:none}}
</style>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ChevronRight, Loader, Hash, Activity, Plus } from 'lucide-vue-next'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import './KnowledgeHud.css'
const { folders, isLoadingDocuments, openNewFolderDialog } = useKnowledgeView()
const emit = defineEmits<{
(e: 'select-folder', folder: any): void
}>()
function handleSelect(folder: any) {
emit('select-folder', folder)
}
</script>
<template>
<div class="knowledge-launcher">
<div class="launcher-header-jarvis">
<div class="header-main">
<Hash :size="10" class="tech-icon" />
<span class="launcher-title">DIRECTORY_0x{{ folders.length }}</span>
</div>
<button class="action-btn-jarvis" @click="openNewFolderDialog(null)">
<Plus :size="10" />
<span>Init_Node</span>
</button>
</div>
<div class="folder-scroll">
<button
v-for="(folder, index) in folders"
:key="folder.id"
class="folder-node-jarvis"
@click="handleSelect(folder)"
>
<span class="node-coord">[{{ index.toString().padStart(2, '0') }}]</span>
<span class="node-name">{{ folder.name }}</span>
<Activity :size="10" class="tech-activity" />
</button>
</div>
<div class="launcher-footer-tech">
<div class="status-pulse"></div>
<span>SYSTEM_STREAM_ACTIVE</span>
</div>
</div>
</template>
<style scoped>
.header-main { display: flex; align-items: center; gap: 6px; }
.tech-icon { color: var(--jarvis-cyan); }
.node-coord { font-family: var(--font-mono); font-size: 8px; color: var(--jarvis-cyan); opacity: 0.5; }
.node-name { flex: 1; font-family: var(--font-display); font-size: 12px; letter-spacing: 0.05em; }
.tech-activity { opacity: 0.3; color: var(--jarvis-cyan); }
.launcher-footer-tech { padding: 6px 12px; display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 7px; color: var(--text-dim); border-top: 1px solid rgba(255,255,255,0.05); }
.status-pulse { width: 4px; height: 4px; border-radius: 50%; background: var(--jarvis-cyan); box-shadow: 0 0 5px var(--jarvis-cyan); animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
</style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import {
X,
ChevronRight,
ArrowLeft,
FileCode,
Box,
Activity,
Upload,
FolderPlus,
Compass
} from 'lucide-vue-next'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import './KnowledgeHud.css'
import { watch } from 'vue'
const props = defineProps<{
folder: any
}>()
const emit = defineEmits(['close', 'open-preview', 'trigger-new-folder', 'trigger-upload'])
const {
documents,
visibleFolders,
isLoadingDocuments,
isRoot,
enterFolder,
goBack,
getFileTypeColor,
formatFileSize,
} = useKnowledgeView()
watch(() => props.folder, (newFolder) => {
if (newFolder) {
enterFolder(newFolder)
}
}, { immediate: true })
function handleFileClick(doc: any) {
emit('open-preview', doc)
}
</script>
<template>
<div class="knowledge-slide-panel">
<header class="slide-header-jarvis">
<div class="header-top-tech">
<div class="coord-tag">
<Compass :size="10" />
<span>SEC_0x{{ props.folder?.id.substring(0,6).toUpperCase() }}</span>
</div>
<button class="close-btn-jarvis" @click="emit('close')"><X :size="16" /></button>
</div>
<div class="title-main-jarvis">
<button v-if="!isRoot" class="back-btn-jarvis" @click="goBack"><ArrowLeft :size="14" /></button>
<h3 class="slide-title-jarvis">{{ props.folder?.name.toUpperCase() }}</h3>
</div>
</header>
<div class="action-row-jarvis">
<button class="action-btn-jarvis" @click="emit('trigger-new-folder', props.folder?.id)">
<FolderPlus :size="10" />
<span>New_Sub</span>
</button>
<button class="action-btn-jarvis amber" @click="emit('trigger-upload')">
<Upload :size="10" />
<span>Upload_Data</span>
</button>
</div>
<div class="slide-content slim-scroll">
<!-- Sub-sectors -->
<section v-if="visibleFolders.length" class="hud-section-jarvis">
<div class="matrix-label">
<span class="label-text">CHILD_NODES</span>
<div class="label-line"></div>
</div>
<div class="jarvis-grid">
<div
v-for="(sub, idx) in visibleFolders"
:key="sub.id"
class="hud-item-jarvis"
@click="enterFolder(sub)"
>
<span class="item-idx">{{ idx.toString(16).padStart(2, '0') }}</span>
<Box :size="14" class="item-icon-cyan" />
<span class="item-name-jarvis">{{ sub.name }}</span>
<ChevronRight :size="12" class="item-arrow-cyan" />
</div>
</div>
</section>
<!-- Data Objects -->
<section v-if="documents.length || isLoadingDocuments" class="hud-section-jarvis">
<div class="matrix-label">
<span class="label-text">DATA_STREAMS</span>
<div class="label-line"></div>
</div>
<div v-if="isLoadingDocuments" class="jarvis-loader">
<Activity :size="16" class="spin-tech" />
<span>QUERYING_BUFFER...</span>
</div>
<div v-else class="jarvis-grid">
<div
v-for="(doc, idx) in documents"
:key="doc.id"
class="hud-item-jarvis doc-style"
@click="handleFileClick(doc)"
>
<span class="item-idx">{{ (idx + 10).toString(16).toUpperCase() }}</span>
<FileCode :size="14" :style="{ color: getFileTypeColor(doc.file_type) }" />
<div class="item-info-jarvis">
<span class="item-name-jarvis">{{ doc.title }}</span>
<span class="item-meta-jarvis">{{ formatFileSize(doc.file_size) }} // {{ doc.file_type.toUpperCase() }}</span>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.header-top-tech { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.coord-tag { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 8px; color: var(--jarvis-cyan); opacity: 0.6; }
.title-main-jarvis { display: flex; align-items: center; gap: 12px; }
.close-btn-jarvis, .back-btn-jarvis { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; cursor: pointer; padding: 6px; transition: 0.2s; }
.close-btn-jarvis:hover { background: var(--accent-red); border-color: var(--accent-red); }
.back-btn-jarvis:hover { border-color: var(--jarvis-cyan); color: var(--jarvis-cyan); }
.action-btn-jarvis.amber { border-color: var(--jarvis-amber); color: var(--jarvis-amber); }
.action-btn-jarvis.amber:hover { background: var(--jarvis-amber); color: #000; }
.hud-section-jarvis { margin-bottom: 25px; }
.matrix-label { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.label-text { font-family: var(--font-display); font-size: 9px; color: var(--jarvis-cyan); letter-spacing: 0.2em; }
.label-line { flex: 1; height: 1px; background: linear-gradient(90deg, rgba(0, 245, 212, 0.3), transparent); }
.jarvis-grid { display: flex; flex-direction: column; gap: 6px; }
.item-idx { font-family: var(--font-mono); font-size: 8px; opacity: 0.3; color: var(--jarvis-cyan); width: 15px; }
.item-icon-cyan { color: var(--jarvis-cyan); }
.item-name-jarvis { flex: 1; font-family: var(--font-mono); font-size: 12px; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-arrow-cyan { color: var(--jarvis-cyan); opacity: 0.5; }
.item-info-jarvis { display: flex; flex-direction: column; min-width: 0; }
.item-meta-jarvis { font-size: 8px; font-family: var(--font-mono); color: var(--jarvis-cyan); opacity: 0.4; margin-top: 2px; }
.jarvis-loader { display: flex; align-items: center; gap: 12px; color: var(--jarvis-cyan); font-family: var(--font-mono); font-size: 10px; padding: 20px; }
.spin-tech { animation: spin 2s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,68 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MonitorEcgStrip from './MonitorEcgStrip.vue'
describe('MonitorEcgStrip', () => {
beforeEach(() => {
vi.useFakeTimers()
})
it('renders an ECG svg shell with a waveform path', () => {
const wrapper = mount(MonitorEcgStrip, {
props: {
mode: 'ready',
},
})
expect(wrapper.get('[data-testid="monitor-ecg-strip"]')).toBeTruthy()
expect(wrapper.get('[data-testid="monitor-ecg-svg"]')).toBeTruthy()
const path = wrapper.get('[data-testid="monitor-ecg-line"]')
expect(path.attributes('d')).toBeTruthy()
expect(path.attributes('d')?.length).toBeGreaterThan(50)
})
it('switches visual mode between ready and processing', async () => {
const wrapper = mount(MonitorEcgStrip, {
props: {
mode: 'ready',
},
})
expect(wrapper.classes()).toContain('mode-ready')
expect((wrapper.element as HTMLElement).style.getPropertyValue('--monitor-ecg-duration')).toBe('3s')
await wrapper.setProps({ mode: 'processing' })
await nextTick()
expect(wrapper.classes()).toContain('mode-processing')
expect((wrapper.element as HTMLElement).style.getPropertyValue('--monitor-ecg-duration')).toBe('1.6s')
})
it('animates with irregular heartbeat pulses', async () => {
const wrapper = mount(MonitorEcgStrip, {
props: {
mode: 'processing',
random: () => 0,
beatRangeMs: { min: 100, max: 100 },
},
})
const track = wrapper.get('.monitor-ecg-track')
expect(track.attributes('style') || '').toContain('translateY(0px) scaleY(1)')
vi.advanceTimersByTime(100)
await nextTick()
expect(track.attributes('style') || '').toContain('translateY(-1.4px) scaleY(1.12)')
vi.advanceTimersByTime(120)
await nextTick()
expect(track.attributes('style') || '').toContain('translateY(0.6px) scaleY(1.03)')
vi.advanceTimersByTime(110)
await nextTick()
expect(track.attributes('style') || '').toContain('translateY(0px) scaleY(1)')
})
})

View File

@@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { buildMonitorEcgPath } from './monitorEcg'
type BeatRangeMs = {
min: number
max: number
}
let patternIdCounter = 0
const props = withDefaults(defineProps<{
mode: 'ready' | 'processing'
width?: number
height?: number
ariaLabel?: string
beatEnabled?: boolean
beatRangeMs?: BeatRangeMs
random?: () => number
}>(), {
width: 120,
height: 30,
ariaLabel: 'System monitor ECG',
beatEnabled: true,
beatRangeMs: undefined,
random: undefined,
})
const randomFn = computed(() => props.random ?? Math.random)
const totalWidth = computed(() => props.width * 2)
const waveformPath = computed(() => buildMonitorEcgPath({
segmentWidth: props.width,
height: props.height,
beatsPerSegment: props.mode === 'processing' ? 3 : 2,
segments: 2,
samplesPerBeat: 96,
}))
const styleVars = computed(() => ({
'--monitor-ecg-width': `${props.width}px`,
'--monitor-ecg-height': `${props.height}px`,
'--monitor-ecg-duration': props.mode === 'processing' ? '1.6s' : '3s',
'--monitor-ecg-stroke-opacity': props.mode === 'processing' ? '1' : '0.72',
'--monitor-ecg-glow-opacity': props.mode === 'processing' ? '0.9' : '0.45',
'--monitor-ecg-grid-opacity': props.mode === 'processing' ? '0.26' : '0.16',
}))
const patternId = `monitor-ecg-grid-${patternIdCounter += 1}`
const beatPulse = ref(0)
const beatLift = ref(0)
const beatStyle = computed(() => ({
transform: `translateY(${beatLift.value}px) scaleY(${1 + beatPulse.value})`,
}))
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function resolveBeatRange(mode: 'ready' | 'processing', override?: BeatRangeMs): BeatRangeMs {
if (override) {
return {
min: clamp(override.min, 1, 60000),
max: clamp(override.max, clamp(override.min, 1, 60000), 60000),
}
}
if (mode === 'processing') {
return { min: 420, max: 980 }
}
return { min: 780, max: 1600 }
}
let beatTimer: ReturnType<typeof setTimeout> | null = null
let beatPhaseTimer: ReturnType<typeof setTimeout> | null = null
function clearBeatTimers() {
if (beatTimer) {
clearTimeout(beatTimer)
beatTimer = null
}
if (beatPhaseTimer) {
clearTimeout(beatPhaseTimer)
beatPhaseTimer = null
}
}
function scheduleNextBeat() {
if (!props.beatEnabled) return
const range = resolveBeatRange(props.mode, props.beatRangeMs)
const mix = randomFn.value()
const delay = range.min + (range.max - range.min) * clamp(mix, 0, 1)
beatTimer = setTimeout(() => {
const strengthBase = props.mode === 'processing' ? 0.12 : 0.07
const strengthJitter = props.mode === 'processing' ? 0.06 : 0.04
const strength = strengthBase + strengthJitter * clamp(randomFn.value(), 0, 1)
beatPulse.value = strength
beatLift.value = -(1.4 + 1.6 * clamp(randomFn.value(), 0, 1))
beatPhaseTimer = setTimeout(() => {
beatPulse.value = Math.min(strength * 0.25, 0.05)
beatLift.value = 0.6
beatPhaseTimer = setTimeout(() => {
beatPulse.value = 0
beatLift.value = 0
}, 110)
}, 120)
scheduleNextBeat()
}, delay)
}
watch(() => props.mode, () => {
clearBeatTimers()
scheduleNextBeat()
})
watch(() => props.beatEnabled, (enabled) => {
clearBeatTimers()
if (enabled) scheduleNextBeat()
})
onMounted(() => {
scheduleNextBeat()
})
onUnmounted(() => {
clearBeatTimers()
})
</script>
<template>
<div
class="monitor-ecg-strip"
:class="`mode-${mode}`"
:style="styleVars"
data-testid="monitor-ecg-strip"
>
<svg
class="monitor-ecg-svg"
:viewBox="`0 0 ${totalWidth} ${height}`"
preserveAspectRatio="none"
role="img"
:aria-label="ariaLabel"
data-testid="monitor-ecg-svg"
>
<defs>
<pattern
:id="patternId"
patternUnits="userSpaceOnUse"
width="10"
height="10"
>
<path d="M 10 0 L 0 0 0 10" class="monitor-ecg-grid-line" />
</pattern>
</defs>
<rect class="monitor-ecg-grid" :width="totalWidth" :height="height" :fill="`url(#${patternId})`" />
<g class="monitor-ecg-track" :style="beatStyle">
<path class="monitor-ecg-glow" :d="waveformPath" />
<path class="monitor-ecg-line" :d="waveformPath" data-testid="monitor-ecg-line" />
</g>
</svg>
</div>
</template>
<style scoped>
.monitor-ecg-strip {
width: var(--monitor-ecg-width);
height: var(--monitor-ecg-height);
overflow: hidden;
margin-left: 8px;
border-radius: 4px;
background:
linear-gradient(180deg, rgba(0, 245, 212, 0.06), rgba(0, 245, 212, 0.015)),
rgba(0, 245, 212, 0.03);
border: 1px solid rgba(0, 245, 212, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.monitor-ecg-svg {
width: 100%;
height: 100%;
display: block;
}
.monitor-ecg-grid {
opacity: 1;
}
.monitor-ecg-grid-line {
fill: none;
stroke: rgba(0, 245, 212, var(--monitor-ecg-grid-opacity));
stroke-width: 0.4;
}
.monitor-ecg-track {
transform-origin: center;
transform-box: fill-box;
animation: monitor-ecg-scroll var(--monitor-ecg-duration) linear infinite;
will-change: transform;
}
.monitor-ecg-glow,
.monitor-ecg-line {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.monitor-ecg-glow {
stroke: rgba(0, 245, 212, var(--monitor-ecg-glow-opacity));
stroke-width: 2.8;
filter: blur(1.8px);
}
.monitor-ecg-line {
stroke: rgba(0, 245, 212, var(--monitor-ecg-stroke-opacity));
stroke-width: 1.35;
filter: drop-shadow(0 0 4px rgba(0, 245, 212, 0.55));
}
.mode-processing {
border-color: rgba(0, 245, 212, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 0 12px rgba(0, 245, 212, 0.12);
}
@keyframes monitor-ecg-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.monitor-ecg-track {
animation-duration: calc(var(--monitor-ecg-duration) * 2.2);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
const props = defineProps<{
defineProps<{
visible: boolean
status: 'idle' | 'active' | 'complete' | 'error'
insight: {
@@ -11,8 +11,14 @@ const props = defineProps<{
visitedAgents: string[]
events: Array<{
id: string
label: string
kind: 'info' | 'tool' | 'success' | 'error'
startedAt: string
status: 'active' | 'success' | 'error'
items: Array<{
id: string
time: string
label: string
kind: 'info' | 'tool' | 'success' | 'error'
}>
}>
systemTelemetry: {
cpu: { current: number | null; series: number[]; online: boolean }
@@ -26,81 +32,30 @@ const props = defineProps<{
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 }]">
<aside class="orchestration-panel" :class="{ 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 class="panel-title">RECENT EVENTS</div>
<div class="panel-count">{{ events.length }}</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 v-if="events.length === 0" class="feed-empty">No events yet</div>
<div v-else class="feed-list">
<div v-for="event in events" :key="event.id" class="feed-item" :class="event.status">
<div class="feed-copy">
<div class="feed-bubble-head">
<span class="feed-time">[{{ event.startedAt }}]</span>
<span class="feed-status">{{ event.status }}</span>
</div>
<div class="feed-steps">
<div v-for="item in event.items" :key="item.id" class="feed-step" :class="item.kind">
<span class="feed-step-time">{{ item.time }}</span>
<span class="feed-label">{{ item.label }}</span>
</div>
</div>
</div>
</div>
</div>
@@ -112,6 +67,7 @@ function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
.orchestration-panel {
width: 340px;
min-width: 340px;
height: 100%;
padding: 18px 18px 18px 0;
opacity: 1;
}
@@ -120,18 +76,18 @@ function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
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);
background: linear-gradient(180deg, rgba(8, 14, 28, 0.96), rgba(6, 10, 20, 0.92));
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;
gap: 14px;
}
.panel-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@@ -143,413 +99,130 @@ function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
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;
.panel-count {
min-width: 28px;
padding: 4px 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;
font-size: 10px;
text-align: center;
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);
color: var(--text-dim);
}
.feed-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
}
.feed-item {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(34, 211, 238, 0.14);
background: linear-gradient(180deg, rgba(10, 18, 32, 0.86), rgba(8, 14, 26, 0.72));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
font-size: 12px;
color: var(--text-secondary);
}
.feed-copy {
display: flex;
flex-direction: column;
gap: 10px;
}
.feed-item {
.feed-bubble-head {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feed-time {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.feed-status {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
}
.feed-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.feed-step {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
align-items: start;
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
.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-step:first-child {
padding-top: 0;
border-top: none;
}
.feed-item.tool .feed-marker {
background: var(--accent-amber);
box-shadow: 0 0 8px rgba(249, 168, 37, 0.24);
.feed-step-time {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--text-muted);
white-space: nowrap;
}
.feed-item.success .feed-marker {
background: var(--accent-green);
box-shadow: 0 0 8px rgba(74, 222, 128, 0.24);
.feed-step.tool .feed-label {
color: #fde68a;
}
.feed-item.error .feed-marker {
background: var(--accent-red);
box-shadow: 0 0 8px rgba(255, 71, 87, 0.24);
.feed-step.success .feed-label {
color: #86efac;
}
.feed-step.error .feed-label {
color: #fda4af;
}
.feed-item.success {
border-color: rgba(74, 222, 128, 0.22);
background: linear-gradient(180deg, rgba(10, 34, 22, 0.8), rgba(8, 18, 14, 0.72));
}
.feed-item.error {
border-color: rgba(255, 71, 87, 0.22);
background: linear-gradient(180deg, rgba(42, 16, 22, 0.82), rgba(24, 10, 14, 0.72));
}
.feed-label {
line-height: 1.5;
line-height: 1.6;
}
@media (max-width: 1280px) {
@media (max-width: 960px) {
.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;
width: 100%;
min-width: 0;
padding: 12px 12px 12px 0;
}
}
</style>

View File

@@ -2,8 +2,10 @@
import { computed } from 'vue'
const props = withDefaults(defineProps<{
points: number[]
points?: number[]
data?: number[]
stroke?: string
color?: string
fill?: string
grid?: boolean
}>(), {
@@ -16,8 +18,11 @@ const width = 220
const height = 52
const padding = 4
const sourcePoints = computed(() => props.points ?? props.data ?? [])
const strokeColor = computed(() => props.stroke ?? props.color ?? '#22d3ee')
const normalizedPoints = computed(() => {
const source = props.points.length ? props.points : [0, 0]
const source = sourcePoints.value.length ? sourcePoints.value : [0, 0]
const max = Math.max(...source, 100)
const min = 0
return source.map((value, index) => {
@@ -46,7 +51,7 @@ const areaPath = computed(() => {
<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" />
<path :d="linePath" class="sparkline-line" :stroke="strokeColor" />
</svg>
</template>

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { buildMonitorEcgPath } from './monitorEcg'
describe('monitorEcg', () => {
it('builds a deterministic tiled ECG path', () => {
const first = buildMonitorEcgPath({
segmentWidth: 100,
height: 30,
beatsPerSegment: 2,
segments: 2,
samplesPerBeat: 80,
})
const second = buildMonitorEcgPath({
segmentWidth: 100,
height: 30,
beatsPerSegment: 2,
segments: 2,
samplesPerBeat: 80,
})
expect(first).toBe(second)
expect(first.length).toBeGreaterThan(50)
})
it('starts and ends on the baseline', () => {
const path = buildMonitorEcgPath({
segmentWidth: 100,
height: 30,
beatsPerSegment: 2,
segments: 2,
samplesPerBeat: 80,
})
expect(path).toMatch(/^M\s*0\s*,\s*15(\s|,)/)
expect(path).toContain('200,15')
})
})

View File

@@ -0,0 +1,79 @@
export type MonitorEcgPathOptions = {
segmentWidth: number
height: number
beatsPerSegment?: number
segments?: number
samplesPerBeat?: number
}
type Point = {
x: number
y: number
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function beatAmplitude(progress: number) {
const pWave = 1.2 * Math.exp(-Math.pow((progress - 0.18) / 0.035, 2))
const qWave = -1.4 * Math.exp(-Math.pow((progress - 0.39) / 0.014, 2))
const rWave = 7.6 * Math.exp(-Math.pow((progress - 0.43) / 0.01, 2))
const sWave = -2.8 * Math.exp(-Math.pow((progress - 0.47) / 0.018, 2))
const tWave = 2.3 * Math.exp(-Math.pow((progress - 0.7) / 0.07, 2))
return pWave + qWave + rWave + sWave + tWave
}
function toPath(points: Point[]) {
return points
.map((point, index) => `${index === 0 ? 'M' : 'L'}${Number(point.x.toFixed(3))},${Number(point.y.toFixed(3))}`)
.join(' ')
}
export function buildMonitorEcgPath(options: MonitorEcgPathOptions) {
const segmentWidth = clamp(options.segmentWidth, 1, Number.MAX_SAFE_INTEGER)
const height = clamp(options.height, 1, Number.MAX_SAFE_INTEGER)
const beatsPerSegment = clamp(options.beatsPerSegment ?? 2, 1, 12)
const segments = clamp(options.segments ?? 2, 1, 8)
const samplesPerBeat = clamp(options.samplesPerBeat ?? 96, 12, 240)
const baseline = height / 2
const amplitudeScale = height / 16
const points: Point[] = []
for (let segmentIndex = 0; segmentIndex < segments; segmentIndex += 1) {
for (let beatIndex = 0; beatIndex < beatsPerSegment; beatIndex += 1) {
const beatStart = segmentIndex * segmentWidth + (beatIndex * segmentWidth) / beatsPerSegment
const beatWidth = segmentWidth / beatsPerSegment
for (let sampleIndex = 0; sampleIndex <= samplesPerBeat; sampleIndex += 1) {
const progress = sampleIndex / samplesPerBeat
const x = beatStart + beatWidth * progress
const y = baseline - beatAmplitude(progress) * amplitudeScale
if (points.length > 0) {
const previous = points[points.length - 1]
if (Math.abs(previous.x - x) < 0.0001) {
points[points.length - 1] = { x, y }
continue
}
}
points.push({ x, y })
}
}
}
if (points.length === 0) {
return `M0,${baseline} L${segmentWidth * segments},${baseline}`
}
const last = points[points.length - 1]
points[points.length - 1] = { x: segmentWidth * segments, y: baseline }
if (last.x < segmentWidth * segments) {
points.push({ x: segmentWidth * segments, y: baseline })
}
return toPath(points)
}