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

933 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, toRef } from 'vue'
import {
Database,
ChevronRight,
Send,
Sparkles,
CornerDownLeft,
Paperclip,
Smile,
X,
} from 'lucide-vue-next'
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('')
}
</script>
<template>
<div class="chat-view" :class="{ 'blur-when-modal': kanbanDetailOpen }">
<!-- 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>
<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>
</div>
</div>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>