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

933 lines
38 KiB
Vue
Raw Normal View History

2026-03-21 10:13:35 +08:00
<script setup lang="ts">
import { ref, toRef } from 'vue'
import {
Database,
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 KnowledgeRAGPanel from '@/components/chat/KnowledgeRAGPanel.vue'
import OrchestrationPanel from '@/components/chat/OrchestrationPanel.vue'
import KanbanPanel from '@/components/chat/KanbanPanel.vue'
import KanbanDetail from '@/components/chat/KanbanDetail.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 type { TaskQuadrant } from '@/api/task'
import { documentApi } from '@/api/document'
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, formatDateKey } from '@/pages/chat/composables/useSidebarPlan'
import TempleModal from '@/pages/temple/index.vue'
// --- Chat view (core messaging logic) ---
const {
store,
inputMessage,
isSending,
chatContainer,
inputRef,
isTyping,
fileInputRef,
showEmojiPicker,
selectedModelName,
selectedModel,
selectedRuntime,
orchestrationStatus,
orchestrationInsight,
activeAgent,
visitedAgents,
orchestrationEventFeed,
systemMeta,
systemTelemetry,
sessionTelemetry,
sendMessage,
selectConversation,
newConversation,
loadConversations,
formatTime,
autoResize,
handleFileSelect,
insertEmoji,
openFilePicker,
} = useChatView()
// --- Knowledge view ---
const {
showNewFolderDialog, newFolderName, createFolder, openNewFolderDialog,
triggerUpload, handleUpload, uploadInput
} = useKnowledgeView()
// --- Client time & weather ---
const { clientTime, city, weatherIcon, weatherSummary } = useClientTime()
// --- Daily digest & reminders ---
const { dailyDigest, digestLoading, activeReminder, reminderVisible, loadDailyDigest, handleSnooze, handleDismiss } = useDailyDigest()
// --- Sidebar plan (calendar, focus, review) ---
const {
calendarCells, issueStatusCounters,
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
sidebarFocusItems, issueStatusQuadrants, issueCommanderSummary, sidebarReviewAchievements, sidebarReviewReflections,
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules,
selectCalendarDate, loadSidebarPlanSnapshot
} = useSidebarPlan(clientTime, loadDailyDigest, toRef(store, 'conversations'))
// --- Local UI state ---
const sidebarCollapsed = ref(false)
const orchestrationDrawerOpen = ref(false)
const kanbanDrawerOpen = ref(false)
const kanbanDetailOpen = ref(false)
const kanbanDetailState = ref<{ mode: 'create' | 'edit'; taskId?: string | null; quadrant?: TaskQuadrant | null } | null>(null)
const knowledgeHudOpen = ref(false)
const knowledgeRAGOpen = ref(false)
const ragPanelRef = ref<any>(null)
const selectedFolder = ref<any>(null)
const previewDoc = ref<any>(null)
const templeVisible = ref(false)
function openOrchestrationDrawer() { orchestrationDrawerOpen.value = true }
function closeOrchestrationDrawer() { orchestrationDrawerOpen.value = false }
function openKanbanDrawer() { kanbanDrawerOpen.value = true }
function closeKanbanDrawer() { kanbanDrawerOpen.value = false }
function openKanbanCreate(quadrantId: string) {
kanbanDetailState.value = { mode: 'create', quadrant: quadrantId as TaskQuadrant }
kanbanDetailOpen.value = true
}
function openKanbanTask(taskId: string) {
kanbanDetailState.value = { mode: 'edit', taskId }
kanbanDetailOpen.value = true
}
function closeKanbanDetail() {
kanbanDetailOpen.value = false
kanbanDetailState.value = null
refreshTodayStatus()
}
function refreshTodayStatus() {
void loadSidebarPlanSnapshot(clientTime.value)
}
async function handleKanbanSaved() {
await loadSidebarPlanSnapshot(clientTime.value)
}
async function handleKanbanDeleted() {
await loadSidebarPlanSnapshot(clientTime.value)
closeKanbanDetail()
}
function openKnowledgeHud() {
selectedFolder.value = null
previewDoc.value = null
knowledgeHudOpen.value = true
}
function closeKnowledgeHud() { knowledgeHudOpen.value = false }
function openKnowledgeRAG() { knowledgeRAGOpen.value = true }
function closeKnowledgeRAG() {
knowledgeRAGOpen.value = false
}
// RAG chat mode - send message to knowledge base instead of chat
async function sendRAGMessage() {
if (!inputMessage.value.trim() || isSending.value) return
const userQuery = inputMessage.value.trim()
inputMessage.value = ''
isSending.value = true
try {
// Add user message to RAG panel
ragPanelRef.value?.addMessage({
id: Date.now().toString(),
role: 'user',
content: userQuery,
})
// Call RAG API
const response = await documentApi.ragChat({
query: userQuery,
top_k: 5,
})
// Add assistant response to RAG panel
ragPanelRef.value?.addMessage({
id: (Date.now() + 1).toString(),
role: 'assistant',
content: response.data.answer,
sources: response.data.sources || [],
})
} catch (error) {
// Fallback response
ragPanelRef.value?.addMessage({
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '抱歉,搜索知识库时出现问题。请稍后重试。',
sources: [],
})
} finally {
isSending.value = false
}
}
function handleSelectFolder(folder: any) { selectedFolder.value = folder }
function handleOpenPreview(doc: any) { previewDoc.value = doc }
function handleCalendarDateSelect(dateKey: string) {
selectCalendarDate(dateKey)
loadConversations().then(() => {
const conversations = store.conversations
const conversation = conversations.find((conv: { id: string; updated_at: string }) => formatDateKey(new Date(conv.updated_at)) === dateKey)
if (conversation) {
selectConversation(conversation.id)
return
}
newConversation()
}).catch((err) => {
console.error('[Calendar] Error loading conversations:', err)
})
}
// --- 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" :class="{ 'blur-when-modal': kanbanDetailOpen }">
2026-03-21 10:13:35 +08:00
<!-- Conversation list sidebar -->
<aside class="conv-sidebar jarvis-sidebar" :class="{ collapsed: sidebarCollapsed }">
<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="module.id === 'kanban' ? openKanbanDrawer() : (sidebarCollapsed = false)"
>
<component :is="module.icon" :size="18" />
</button>
</div>
<div v-else class="jarvis-sidebar-scroll">
<div class="section-label">// DAILY STATUS</div>
<div class="jarvis-panel jarvis-date-panel">
<div class="jarvis-date-row">
<div class="jarvis-date-meta">
<div class="jarvis-month">{{ clientTime.toLocaleString('en-US', { month: 'long', year: 'numeric' }) }}</div>
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('en-US', { hour12: true }) }}</div>
</div>
<div class="jarvis-location">
<div class="location-info">
<span class="location-name">{{ city }}</span>
<span class="location-weather-text">{{ weatherSummary }}</span>
</div>
<i :class="['wi', weatherIcon, 'location-icon']"></i>
</div>
</div>
<div class="jarvis-calendar">
<div class="calendar-header">
<span v-for="item in sidebarWeekLabels" :key="item.label" :class="{ 'is-weekend': item.isWeekend }">{{ item.label }}</span>
</div>
<div class="calendar-grid">
<span
v-for="cell in calendarCells"
:key="cell.key"
class="calendar-day"
:class="{ active: cell.active, muted: cell.value === null, selected: cell.selected, clickable: cell.active || cell.hasConversation }"
@click="(cell.active || cell.hasConversation) && handleCalendarDateSelect(cell.key)"
>
{{ cell.value ?? '' }}
<span v-if="cell.hasConversation" class="conv-indicator"></span>
</span>
</div>
</div>
</div>
2026-03-21 10:13:35 +08:00
<div class="jarvis-panel jarvis-plan-panel" @click="openKanbanDrawer" style="cursor: pointer;">
<div class="jarvis-section-title">ISSUE STATUS</div>
<div class="jarvis-status-shell">
<div class="jarvis-progress-ring" :style="{ '--completion': `${issueStatusCounters.completion}%` }">
<div class="jarvis-progress-core">
<strong>{{ issueStatusCounters.completion }}%</strong>
<span>COMPLETION</span>
</div>
</div>
<div class="jarvis-status-copy">
<div v-if="sidebarStatusHeadline" class="jarvis-status-headline">
{{ sidebarStatusHeadline }}
</div>
<ul class="jarvis-status-list">
<li v-for="item in sidebarStatusBreakdown" :key="item.key" class="jarvis-status-item" :class="{ 'is-total': item.key === 'total' }">
<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">TODAY'S FOCUS</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" :class="{ 'is-done': item.tone === 'done' }">
<span v-if="item.tone === 'done'" class="focus-check"></span>
<span v-else>{{ String(index + 1).padStart(2, '0') }}</span>
</span>
<div class="focus-copy">
<div class="focus-label">{{ item.label }}</div>
<div class="focus-title" :class="{ 'is-done': item.tone === 'done' }">{{ 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">MONTHLY REVIEW</div>
<div class="jarvis-review-group">
<div class="jarvis-review-subtitle">ACHIEVEMENTS</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">REFLECTIONS</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>MODEL</span>
</label>
<div class="chat-model-display">
<Sparkles :size="12" />
<span>{{ selectedModel?.model || selectedModelName || 'Default' }}</span>
</div>
2026-03-21 10:13:35 +08:00
</div>
<div class="chat-model-panel">
<label class="chat-model-label" for="chat-runtime-select">
<Sparkles :size="12" />
<span>RUNTIME</span>
</label>
<select id="chat-runtime-select" v-model="selectedRuntime" class="chat-runtime-select">
<option value="jarvis">Jarvis</option>
<option value="hermes">Hermes</option>
</select>
</div>
2026-03-21 10:13:35 +08:00
</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="templeVisible = true" title="Temple">
<span class="btn-icon temple-icon"></span>
</button>
<button class="top-action-btn" @click="openKnowledgeRAG()" title="Knowledge">
<span class="btn-icon knowledge-icon"></span>
</button>
<button class="top-action-btn" @click="$router.push('/war-room')" title="War Room">
<span class="btn-icon war-icon"></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="knowledgeRAGOpen ? '输入问题搜索知识库...' : '输入指令,按 Enter 发送...'"
:disabled="isSending"
rows="1"
@keydown.enter.exact.prevent="knowledgeRAGOpen ? sendRAGMessage() : 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="knowledgeRAGOpen ? sendRAGMessage() : sendMessage()"
>
<Send :size="15" />
<CornerDownLeft :size="12" class="enter-hint" />
</button>
2026-03-21 10:13:35 +08:00
</div>
<div class="input-hints">
<template v-if="knowledgeRAGOpen">
<span class="hint-item rag-hint">知识库搜索模式</span>
</template>
<template v-else>
<span class="hint-item">ENTER 发送</span>
<span class="hint-sep">|</span>
<span class="hint-item">SHIFT+ENTER 换行</span>
</template>
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">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>
<!-- Knowledge RAG Panel (Slide-up from bottom) -->
<KnowledgeRAGPanel
v-if="knowledgeRAGOpen"
ref="ragPanelRef"
:is-chat-loading="isSending"
@close="closeKnowledgeRAG"
@send="sendRAGMessage"
/>
<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>
<!-- Kanban Drawer (四象限任务管理) -->
<div class="kanban-drawer-shell" :class="{ open: kanbanDrawerOpen }">
<button
v-if="kanbanDrawerOpen"
class="kanban-drawer-backdrop"
type="button"
aria-label="Close kanban"
@click="closeKanbanDrawer"
></button>
<aside class="kanban-drawer" :class="{ open: kanbanDrawerOpen }">
<KanbanPanel
:visible="kanbanDrawerOpen"
:quadrants="issueStatusQuadrants"
:commander-summary="issueCommanderSummary"
@close="closeKanbanDrawer"
@create-task="openKanbanCreate"
@open-task="openKanbanTask"
/>
</aside>
</div>
<!-- Kanban Detail Modal (Teleported to body to avoid blur) -->
<Teleport to="body">
<KanbanDetail
v-if="kanbanDetailState"
:visible="kanbanDetailOpen"
:mode="kanbanDetailState.mode"
:task-id="kanbanDetailState.taskId"
:default-quadrant="kanbanDetailState.quadrant"
@close="closeKanbanDetail"
@saved="handleKanbanSaved"
@deleted="handleKanbanDeleted"
/>
</Teleport>
<!-- 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"
/>
<!-- Temple Modal (智慧神殿) -->
<TempleModal
:visible="templeVisible"
@close="templeVisible = false"
/>
<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>