Files
JARVIS/frontend/src/pages/chat/index.vue

792 lines
33 KiB
Vue
Raw Normal View History

2026-03-21 10:13:35 +08:00
<script setup lang="ts">
import { ref } from 'vue'
import {
ChevronRight,
Send,
Sparkles,
CornerDownLeft,
Paperclip,
Smile,
X,
} from 'lucide-vue-next'
2026-03-21 10:13:35 +08:00
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
import FileMessage from '@/components/chat/FileMessage.vue'
import KnowledgeHudPanel from '@/components/chat/KnowledgeHudPanel.vue'
import KnowledgeSlidePanel from '@/components/chat/KnowledgeSlidePanel.vue'
import KnowledgeHUDPreview from '@/components/chat/KnowledgeHUDPreview.vue'
import OrchestrationPanel from '@/components/chat/OrchestrationPanel.vue'
import TelemetrySparkline from '@/components/chat/TelemetrySparkline.vue'
import NavShortcutRow from '@/components/navigation/NavShortcutRow.vue'
import DailyDigestCard from '@/components/memory/DailyDigestCard.vue'
import ReminderToast from '@/components/memory/ReminderToast.vue'
import { useChatView } from '@/pages/chat/composables/useChatView'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import { useDailyDigest } from '@/pages/chat/composables/useDailyDigest'
import { useClientTime, formatNetworkRate } from '@/pages/chat/composables/useClientTime'
import { useSidebarPlan } from '@/pages/chat/composables/useSidebarPlan'
// --- Chat view (core messaging logic) ---
const {
store,
inputMessage,
isSending,
chatContainer,
inputRef,
isTyping,
fileInputRef,
showEmojiPicker,
chatModels,
selectedModelName,
selectedModel,
isLoadingModels,
orchestrationStatus,
orchestrationInsight,
activeAgent,
visitedAgents,
orchestrationEventFeed,
systemMeta,
systemTelemetry,
sessionTelemetry,
sendMessage,
formatTime,
autoResize,
handleFileSelect,
insertEmoji,
openFilePicker,
} = useChatView()
// --- Knowledge view ---
const {
showNewFolderDialog, newFolderName, createFolder, openNewFolderDialog,
triggerUpload, handleUpload, uploadInput
} = useKnowledgeView()
// --- Client time & weather ---
const { clientTime, weatherIcon, weatherSummary, formatClientDate, formatClientClock } = useClientTime()
// --- Daily digest & reminders ---
const { dailyDigest, digestLoading, activeReminder, reminderVisible, loadDailyDigest, handleSnooze, handleDismiss } = useDailyDigest()
// --- Sidebar plan (calendar, focus, review) ---
const {
calendarCells, todayPlanCounters,
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules
} = useSidebarPlan(clientTime, loadDailyDigest)
// --- Local UI state ---
const sidebarCollapsed = ref(false)
const orchestrationDrawerOpen = ref(false)
const knowledgeHudOpen = ref(false)
const selectedFolder = ref<any>(null)
const previewDoc = ref<any>(null)
function openOrchestrationDrawer() { orchestrationDrawerOpen.value = true }
function closeOrchestrationDrawer() { orchestrationDrawerOpen.value = false }
function openKnowledgeHud() {
selectedFolder.value = null
previewDoc.value = null
knowledgeHudOpen.value = true
}
function closeKnowledgeHud() { knowledgeHudOpen.value = false }
function handleSelectFolder(folder: any) { selectedFolder.value = folder }
function handleOpenPreview(doc: any) { previewDoc.value = doc }
// --- Message rendering utilities (kept inline for clarity) ---
function formatUptime(seconds: number) {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h ${minutes}m`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
function currentIp() {
if (typeof window === 'undefined') return '--'
return window.location.hostname || '--'
}
function currentPort() {
if (typeof window === 'undefined') return '--'
return window.location.port || (window.location.protocol === 'https:' ? '443' : '80')
}
function formatMemoryMb(value: number | null) {
if (value === null) return '--'
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`
return `${Math.round(value)} MB`
}
function formatDiskGb(value: number) {
if (value >= 1024) return `${(value / 1024).toFixed(1)} TB`
return `${value.toFixed(1)} GB`
}
function extractThinkParts(content: string) {
const thinkPattern = /<think>([\s\S]*?)<\/think>/gi
const thinkBlocks = Array.from(content.matchAll(thinkPattern))
.map((match) => match[1]?.trim())
.filter((block): block is string => Boolean(block))
const visibleContent = content.replace(thinkPattern, '').trim()
return { hasThink: thinkBlocks.length > 0, thinkBlocks, visibleContent }
}
function escapeHtml(content: string) {
return content
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function renderInlineMarkdown(content: string) {
return content
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
}
function isMarkdownTable(lines: string[]) {
return lines.length >= 2
&& lines[0].includes('|')
&& lines[1].includes('|')
&& lines[1].split('|').filter(Boolean).every((cell) => /^\s*:?-{3,}:?\s*$/.test(cell))
}
function splitTableRow(line: string) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim())
}
function renderMarkdown(content: string) {
const normalizedContent = content.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n')
const normalized = escapeHtml(normalizedContent)
const blocks = normalized.split(/\n\n+/)
return blocks.map((block) => {
const trimmed = block.trim()
if (!trimmed) return ''
if (/^```[\s\S]*```$/.test(trimmed)) {
const code = trimmed.replace(/^```\w*\n?/, '').replace(/```$/, '').trimEnd()
return `<pre class="md-pre"><code>${code}</code></pre>`
}
const lines = trimmed.split('\n')
if (isMarkdownTable(lines)) {
const [headerLine, , ...bodyLines] = lines
const headers = splitTableRow(headerLine)
const thead = `<thead><tr>${headers.map((cell) => `<th>${renderInlineMarkdown(cell)}</th>`).join('')}</tr></thead>`
const tbody = bodyLines.length
? `<tbody>${bodyLines.map((line) => `<tr>${splitTableRow(line).map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`).join('')}</tr>`).join('')}</tbody>`
: ''
return `<div class="md-table-wrap"><table class="md-table">${thead}${tbody}</table></div>`
}
if (lines.every((line) => /^\s*[-*]\s+/.test(line))) {
const items = lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^\s*[-*]\s+/, '').trim())}</li>`).join('')
return `<ul class="md-list">${items}</ul>`
}
if (lines.every((line) => /^\s*\d+\.\s+/.test(line))) {
const items = lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^\s*\d+\.\s+/, '').trim())}</li>`).join('')
return `<ol class="md-list">${items}</ol>`
}
if (/^#{1,3}\s+/.test(trimmed)) {
const level = Math.min((trimmed.match(/^#+/)?.[0].length || 1) + 1, 6)
const text = trimmed.replace(/^#{1,3}\s+/, '')
return `<h${level} class="md-heading">${renderInlineMarkdown(text)}</h${level}>`
}
return `<p>${renderInlineMarkdown(trimmed).replace(/\n/g, '<br>')}</p>`
}).join('')
}
2026-03-21 10:13:35 +08:00
</script>
<template>
<div class="chat-view">
<!-- Conversation list sidebar -->
<aside class="conv-sidebar jarvis-sidebar" :class="{ collapsed: sidebarCollapsed }">
<button
class="jarvis-sidebar-toggle"
type="button"
:title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
:aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
@click="sidebarCollapsed = !sidebarCollapsed"
>
<ChevronRight :size="14" class="jarvis-sidebar-toggle-icon" :class="{ expanded: !sidebarCollapsed }" />
</button>
<div v-if="sidebarCollapsed" class="jarvis-sidebar-icon-rail">
<button
v-for="module in sidebarCollapsedModules"
:key="module.id"
class="jarvis-sidebar-icon-btn"
type="button"
:title="module.label"
:aria-label="module.label"
@click="sidebarCollapsed = false"
>
<component :is="module.icon" :size="18" />
</button>
</div>
<div v-else class="jarvis-sidebar-scroll">
<div class="jarvis-panel jarvis-date-panel">
<div class="jarvis-date-row">
<div class="jarvis-date-num">{{ clientTime.getDate().toString().padStart(2, '0') }}</div>
<div class="jarvis-date-meta">
<div class="jarvis-month">{{ clientTime.toLocaleString('zh-CN', { month: 'long' }) }} / {{ clientTime.getFullYear() }}</div>
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('zh-CN', { hour12: false }) }}</div>
</div>
</div>
<div class="jarvis-calendar">
<div class="calendar-header">
<span v-for="label in sidebarWeekLabels" :key="label">{{ label }}</span>
</div>
<div class="calendar-grid">
<span
v-for="cell in calendarCells"
:key="cell.key"
class="calendar-day"
:class="{ active: cell.active, busy: cell.busy, muted: cell.value === null }"
>
{{ cell.value ?? '' }}
</span>
</div>
</div>
</div>
2026-03-21 10:13:35 +08:00
<div class="jarvis-panel jarvis-plan-panel">
<div class="jarvis-section-title">&#x4ECA;&#x65E5;&#x8BA1;&#x5212;&#x60C5;&#x51B5;</div>
<div class="jarvis-status-shell">
<div class="jarvis-progress-ring" :style="{ '--completion': `${todayPlanCounters.completion}%` }">
<div class="jarvis-progress-core">
<strong>{{ todayPlanCounters.completion }}%</strong>
<span>&#x5B8C;&#x6210;&#x7387;</span>
</div>
</div>
<div class="jarvis-status-copy">
<div class="jarvis-status-headline">
{{ sidebarStatusHeadline }}
</div>
<ul class="jarvis-status-list">
<li v-for="item in sidebarStatusBreakdown" :key="item.key" class="jarvis-status-item">
<span class="status-dot" :class="item.tone"></span>
<span class="status-label">{{ item.label }}</span>
<strong class="status-value">{{ item.value }}</strong>
</li>
</ul>
</div>
</div>
</div>
<div class="jarvis-panel jarvis-focus-panel">
<div class="jarvis-section-title">&#x4ECA;&#x65E5;&#x8BA1;&#x5212;&#x91CD;&#x70B9;</div>
<ul v-if="sidebarFocusItems.length > 0" class="jarvis-focus-list">
<li v-for="(item, index) in sidebarFocusItems" :key="item.id" class="jarvis-focus-item" :class="`is-${item.tone}`">
<span class="focus-order">{{ String(index + 1).padStart(2, '0') }}</span>
<div class="focus-copy">
<div class="focus-label">{{ item.label }}</div>
<div class="focus-title">{{ item.title }}</div>
<div class="focus-meta">{{ item.meta }}</div>
</div>
</li>
</ul>
<div v-else class="jarvis-empty-state">&#x6682;&#x65E0;&#x4ECA;&#x65E5;&#x91CD;&#x70B9;&#xFF0C;&#x7B49;&#x5F85;&#x65E5;&#x7A0B;&#x4E2D;&#x5FC3;&#x8FD4;&#x56DE;&#x6570;&#x636E;&#x3002;</div>
</div>
<div class="jarvis-panel jarvis-review-panel">
<div class="jarvis-section-title">&#x672C;&#x6708;&#x8BA1;&#x5212;&#x590D;&#x76D8;</div>
<div class="jarvis-review-group">
<div class="jarvis-review-subtitle">&#x6210;&#x679C;</div>
<ul class="jarvis-review-list">
<li v-for="item in sidebarReviewAchievements" :key="item" class="jarvis-review-item">{{ item }}</li>
</ul>
</div>
<div class="jarvis-review-group">
<div class="jarvis-review-subtitle">&#x53CD;&#x601D;</div>
<ul class="jarvis-review-list reflection">
<li v-for="item in sidebarReviewReflections" :key="item" class="jarvis-review-item">{{ item }}</li>
</ul>
</div>
2026-03-21 10:13:35 +08:00
</div>
</div>
2026-03-21 10:13:35 +08:00
</aside>
<!-- Chat area -->
<section class="chat-area">
<div class="chat-shell">
<div class="chat-main">
<!-- Top bar -->
<div class="chat-topbar">
<div class="chat-shortcuts">
<NavShortcutRow
@select-folder="handleSelectFolder"
@open-knowledge-hud="openKnowledgeHud"
/>
</div>
<div class="chat-model-panel">
<label class="chat-model-label" for="chat-model-select">
<Sparkles :size="12" />
<span>CHAT MODEL</span>
</label>
<select
id="chat-model-select"
v-model="selectedModelName"
class="chat-model-select"
:disabled="isSending || isLoadingModels || chatModels.length === 0"
>
<option v-if="chatModels.length === 0" value="">
{{ isLoadingModels ? 'Loading models...' : 'No chat models' }}
</option>
<option v-for="model in chatModels" :key="model.name" :value="model.name">
{{ model.name }}
</option>
</select>
<div v-if="selectedModel" class="chat-model">
<Sparkles :size="12" />
<span>{{ selectedModel.model }}</span>
</div>
2026-03-21 10:13:35 +08:00
</div>
</div>
<div class="chat-intel-strip" v-if="sidebarFeedItems.length > 0">
<div class="chat-intel-label">RSS NEWS</div>
<div class="chat-intel-marquee">
<div class="chat-intel-track">
<span v-for="(item, index) in topbarFeedItems" :key="`${item.id}-${index}`" class="chat-intel-item">
<span class="chat-intel-item-meta">{{ item.meta }}</span>
<span class="chat-intel-item-title">{{ item.title }}</span>
</span>
</div>
</div>
</div>
<!-- Messages -->
<div ref="chatContainer" class="messages-area">
<!-- Welcome screen -->
<div v-if="store.messages.length === 0" class="welcome-screen">
<div class="welcome-icon">
<div class="welcome-ring r1"></div>
<div class="welcome-ring r2"></div>
<div class="welcome-ring r3"></div>
<div class="welcome-core">
<Sparkles :size="28" />
</div>
</div>
<div class="welcome-title">JARVIS</div>
<div class="welcome-sub">Strategic Thinking Partner</div>
<div class="welcome-hint">把目标给我我先帮您收束重点再往下推进</div>
2026-03-21 10:13:35 +08:00
</div>
<!-- Daily Digest Card -->
<DailyDigestCard
v-if="dailyDigest"
:digest="dailyDigest"
:loading="digestLoading"
/>
<!-- Message bubbles -->
<div
v-for="(msg, i) in store.messages"
:key="msg.id"
class="message-row"
:class="msg.role"
:style="{ animationDelay: `${i * 30}ms` }"
>
<div class="msg-avatar">
<span v-if="msg.role === 'user'">{{ '>' }}</span>
<span v-else class="ai-icon">J</span>
</div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-role">{{ msg.role === 'user' ? 'YOU' : 'JARVIS' }}</span>
<span v-if="msg.role === 'assistant' && msg.model" class="msg-model">{{ msg.model }}</span>
<span class="msg-time">{{ formatTime(msg.created_at) }}</span>
</div>
<template v-if="msg.role === 'assistant' && extractThinkParts(msg.content).hasThink">
<details class="think-panel">
<summary class="think-summary">
<span class="think-chip">
<span class="think-chip-dot"></span>
<span>THINK</span>
</span>
</summary>
<div class="think-content">
<div
v-for="(block, index) in extractThinkParts(msg.content).thinkBlocks"
:key="index"
class="think-block"
v-html="renderMarkdown(block)"
></div>
</div>
</details>
<div
v-if="extractThinkParts(msg.content).visibleContent"
class="msg-bubble markdown-body"
v-html="renderMarkdown(extractThinkParts(msg.content).visibleContent)"
></div>
</template>
<div v-else class="msg-bubble markdown-body" v-html="renderMarkdown(msg.content)"></div>
<div v-if="msg.role === 'user' && msg.attachments?.length" class="msg-attachments">
<FileMessage
v-for="att in msg.attachments"
:key="att.id"
:filename="att.name"
:file-type="att.type"
:file-size="att.size"
/>
</div>
</div>
2026-03-21 10:13:35 +08:00
</div>
<div v-if="isTyping" class="message-row assistant thinking-row">
<div class="msg-avatar">
<span class="ai-icon">J</span>
</div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-role">JARVIS</span>
<span class="msg-model">thinking</span>
</div>
<div class="msg-bubble thinking-bubble">
<div class="thinking-hud">
<div class="thinking-core">
<span class="thinking-ring ring-1"></span>
<span class="thinking-ring ring-2"></span>
<span class="thinking-ring ring-3"></span>
<span class="thinking-dot"></span>
</div>
<div class="thinking-copy">
<div class="thinking-title">JARVIS THINKING</div>
<div class="thinking-subtitle">正在分析请求并准备响应</div>
</div>
<div class="typing-inline" aria-hidden="true">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</div>
</div>
</div>
2026-03-21 10:13:35 +08:00
</div>
<!-- Top buttons above input -->
<div class="top-buttons-row">
<button class="top-action-btn" @click="$router.push('/temple')">
<span class="btn-icon"></span>
<span class="btn-text">智慧神殿</span>
</button>
<button class="top-action-btn" @click="$router.push('/knowledge')">
<span class="btn-icon">📚</span>
<span class="btn-text">知识仓库</span>
</button>
<button class="top-action-btn" @click="$router.push('/war-room')">
<span class="btn-icon">🗺</span>
<span class="btn-text">战情室</span>
</button>
</div>
<!-- Input area -->
<div class="input-area">
<div class="input-frame">
<div class="input-corners tl"></div>
<div class="input-corners tr"></div>
<div class="input-corners bl"></div>
<div class="input-corners br"></div>
<textarea
ref="inputRef"
v-model="inputMessage"
placeholder="输入指令,按 Enter 发送..."
:disabled="isSending"
rows="1"
@keydown.enter.exact.prevent="sendMessage"
@input="autoResize"
></textarea>
<input
ref="fileInputRef"
type="file"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
style="display: none"
@change="handleFileSelect"
/>
<button class="attach-btn" @click="openFilePicker" title="上传文件">
<Paperclip :size="15" />
</button>
<div class="emoji-wrapper">
<button
class="emoji-btn"
:class="{ active: showEmojiPicker }"
@click="showEmojiPicker = !showEmojiPicker"
title="表情包"
>
<Smile :size="15" />
</button>
<EmojiPicker
:visible="showEmojiPicker"
@select="insertEmoji"
@close="showEmojiPicker = false"
/>
</div>
<button
class="send-btn"
:class="{ active: inputMessage.trim() }"
:disabled="!inputMessage.trim() || isSending"
@click="sendMessage"
>
<Send :size="15" />
<CornerDownLeft :size="12" class="enter-hint" />
</button>
2026-03-21 10:13:35 +08:00
</div>
<div class="input-hints">
<span class="hint-item">ENTER 发送</span>
<span class="hint-sep">|</span>
<span class="hint-item">SHIFT+ENTER 换行</span>
2026-03-21 10:13:35 +08:00
</div>
</div>
</div>
<aside class="runtime-sidebar">
<div class="sidebar-runtime-panel" :class="`is-${orchestrationStatus}`">
<div class="section-label">// RUNTIME STATUS</div>
<div class="runtime-meta-panel runtime-meta-panel-merged">
<div class="runtime-panel-title">SYSTEM</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">DATE</span>
<span class="runtime-meta-value">{{ formatClientDate(clientTime) }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">TIME</span>
<span class="runtime-meta-value">{{ formatClientClock(clientTime) }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">WEATHER</span>
<span class="runtime-meta-value weather-inline">
<component :is="weatherIcon" :size="14" class="weather-icon" />
<span>{{ weatherSummary }}</span>
</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">OS</span>
<span class="runtime-meta-value">{{ systemMeta.systemName }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">VERSION</span>
<span class="runtime-meta-value">{{ systemMeta.systemVersion }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">IP</span>
<span class="runtime-meta-value">{{ currentIp() }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">PORT</span>
<span class="runtime-meta-value">{{ currentPort() }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">UPTIME</span>
<span class="runtime-meta-value">{{ formatUptime(systemMeta.uptimeSeconds) }}</span>
</div>
</div>
<div class="runtime-grid">
<div class="runtime-card cpu-card">
<div class="runtime-topline">
<span class="runtime-label">CPU</span>
<span class="runtime-value">{{ systemTelemetry.cpu.online && systemTelemetry.cpu.current !== null ? `${systemTelemetry.cpu.current}%` : 'OFFLINE' }}</span>
</div>
<TelemetrySparkline :points="systemTelemetry.cpu.series" stroke="#22d3ee" fill="rgba(34, 211, 238, 0.16)" />
</div>
<div class="runtime-card gpu-card">
<div class="runtime-topline">
<span class="runtime-label">GPU</span>
<span class="runtime-value">{{ systemMeta.gpuUtilPercent !== null ? `${Math.round(systemMeta.gpuUtilPercent)}%` : 'N/A' }}</span>
</div>
<div class="gpu-info-stats">
<div class="gpu-info-row">
<span class="gpu-info-key">DEVICE</span>
<span class="gpu-info-value gpu-info-copy">{{ systemMeta.gpuName || 'GPU unavailable' }}</span>
</div>
<div class="gpu-info-row">
<span class="gpu-info-key">VRAM</span>
<span class="gpu-info-value">{{ formatMemoryMb(systemMeta.gpuMemoryUsedMb) }} / {{ formatMemoryMb(systemMeta.gpuMemoryTotalMb) }}</span>
</div>
</div>
<TelemetrySparkline
:points="systemTelemetry.gpu.series"
stroke="#f472b6"
fill="rgba(244, 114, 182, 0.16)"
/>
</div>
<div class="runtime-card mem-card">
<div class="runtime-topline">
<span class="runtime-label">MEM</span>
<span class="runtime-value">{{ systemTelemetry.memory.online && systemTelemetry.memory.current !== null ? `${systemTelemetry.memory.current}%` : 'OFFLINE' }}</span>
</div>
<TelemetrySparkline :points="systemTelemetry.memory.series" stroke="#a78bfa" fill="rgba(167, 139, 250, 0.14)" />
</div>
<div class="runtime-card disk-card">
<div class="runtime-topline">
<span class="runtime-label">DISK</span>
<span class="runtime-value">{{ systemTelemetry.disk.online && systemTelemetry.disk.current !== null ? `${systemTelemetry.disk.current}%` : 'OFFLINE' }}</span>
</div>
<div class="gpu-info-stats">
<div class="gpu-info-row">
<span class="gpu-info-key">CAPACITY</span>
<span class="gpu-info-value">{{ `${formatDiskGb(systemMeta.diskUsedGb)} / ${formatDiskGb(systemMeta.diskTotalGb)}` }}</span>
</div>
</div>
<TelemetrySparkline :points="systemTelemetry.disk.series" stroke="#4ade80" fill="rgba(74, 222, 128, 0.14)" />
</div>
<div class="runtime-card network-card">
<div class="runtime-topline">
<span class="runtime-label">NETWORK</span>
<span class="runtime-value">{{ systemTelemetry.network.upload.online ? 'LIVE' : 'OFFLINE' }}</span>
</div>
<div class="network-metric-head">
<div class="network-legend">
<span class="network-direction up"></span>
<span class="network-speed up">{{ formatNetworkRate(systemTelemetry.network.upload.current, systemTelemetry.network.upload.online) }}</span>
</div>
<div class="network-legend">
<span class="network-direction down"></span>
<span class="network-speed down">{{ formatNetworkRate(systemTelemetry.network.download.current, systemTelemetry.network.download.online) }}</span>
</div>
</div>
<div class="network-overlay-chart">
<TelemetrySparkline :points="systemTelemetry.network.upload.series" stroke="#2563eb" fill="rgba(37, 99, 235, 0.10)" />
<TelemetrySparkline :points="systemTelemetry.network.download.series" stroke="#38bdf8" fill="rgba(56, 189, 248, 0.06)" />
</div>
</div>
</div>
</div>
<div class="runtime-sidebar-footer">
<button class="event-feed runtime-feed runtime-feed-launch" type="button" @click="openOrchestrationDrawer">
<div class="feed-hero">
<div class="feed-title">Recent Events</div>
<div class="feed-hero-meta">
<div class="runtime-log-count">{{ orchestrationEventFeed.length }}</div>
<ChevronRight :size="16" class="feed-launch-arrow" />
</div>
</div>
</button>
</div>
</aside>
<div class="knowledge-hud-shell" :class="{ open: knowledgeHudOpen }">
<button
v-if="knowledgeHudOpen"
class="knowledge-hud-backdrop"
type="button"
aria-label="Close knowledge HUD"
@click="closeKnowledgeHud"
></button>
<section class="knowledge-hud" :class="{ open: knowledgeHudOpen }">
<div class="knowledge-hud-frame">
<div class="knowledge-hud-chrome">
<div class="knowledge-hud-title">
<Database :size="14" />
<span>KNOWLEDGE ARCHIVE HUD</span>
</div>
<button class="knowledge-hud-close" type="button" aria-label="Close knowledge HUD" @click="closeKnowledgeHud">
<X :size="16" />
</button>
</div>
<div class="knowledge-hud-body">
<KnowledgeHudPanel />
</div>
</div>
</section>
</div>
<div class="agent-drawer-shell" :class="{ open: orchestrationDrawerOpen }">
<button
v-if="orchestrationDrawerOpen"
class="agent-drawer-backdrop"
type="button"
aria-label="Close agent drawer"
@click="closeOrchestrationDrawer"
></button>
<aside class="agent-drawer" :class="{ open: orchestrationDrawerOpen }">
<OrchestrationPanel
:visible="true"
:status="orchestrationStatus"
:insight="orchestrationInsight"
:active-agent="activeAgent"
:visited-agents="visitedAgents"
:events="orchestrationEventFeed"
:system-telemetry="{
cpu: systemTelemetry.cpu,
memory: systemTelemetry.memory,
disk: systemTelemetry.disk,
}"
:session-telemetry="sessionTelemetry"
/>
</aside>
</div>
<!-- Knowledge Side Panel (Phase 02) -->
<Transition name="slide">
<KnowledgeSlidePanel
v-if="selectedFolder"
:folder="selectedFolder"
@close="selectedFolder = null"
@open-preview="handleOpenPreview"
@trigger-new-folder="openNewFolderDialog"
@trigger-upload="triggerUpload"
/>
</Transition>
<!-- Knowledge HUD Preview (Phase 03) -->
<Transition name="fade">
<KnowledgeHUDPreview
v-if="previewDoc"
:doc="previewDoc"
@close="previewDoc = null"
/>
</Transition>
<!-- Global Dialogs for Knowledge -->
<input ref="uploadInput" type="file" class="hidden-upload" accept=".pdf,.md,.txt,.doc,.docx,.csv,.xlsx" @change="handleUpload" />
<!-- Reminder Toast -->
<ReminderToast
:reminder="activeReminder"
:visible="reminderVisible"
@snooze="handleSnooze"
@dismiss="handleDismiss"
/>
<div v-if="showNewFolderDialog" class="knowledge-hud-preview" @click.self="showNewFolderDialog = false">
<div class="hud-dialog-jarvis">
<div class="dialog-header-tech">
<span class="sub-kicker">INIT_NODE_PROCEDURE</span>
<button class="close-jarvis-btn small" @click="showNewFolderDialog = false"><X :size="14" /></button>
</div>
<div class="dialog-body-tech">
<p class="dialog-label">ENTER_VIRTUAL_ADDRESS_NAME</p>
<input v-model="newFolderName" class="jarvis-input" placeholder="SECTOR_NAME..." @keyup.enter="createFolder" autofocus />
</div>
<div class="dialog-actions-tech">
<button class="action-btn-jarvis small" @click="showNewFolderDialog = false">ABORT</button>
<button class="action-btn-jarvis amber small" @click="createFolder">INITIALIZE</button>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped src="./chatPage.css">
</style>