Files
JARVIS/frontend/src/pages/chat/index.vue
WIN-JHFT4D3SIVT\caoxiaozhu 5667190abe feat(agents): implement Code Commander module (Phases 1-5)
- Phase 1: Infrastructure (state, prompts, registry)
- Phase 2: Execution engine (AI adapters, security classifier, executors)
- Phase 3: Agent integration (graph nodes, routing)
- Phase 4: Streaming interaction (PTY terminal, WebSocket)
- Phase 5: Frontend integration (Vue components)
2026-04-05 14:56:45 +08:00

3500 lines
92 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 { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
ChevronRight,
Cloud,
CloudDrizzle,
CloudFog,
CloudLightning,
CloudRain,
CloudSnow,
Database,
Sun,
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 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 { getRecentDigests, getDueReminders, snoozeReminder, dismissReminder } from '@/api/memory'
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
const {
store,
inputMessage,
isSending,
chatContainer,
inputRef,
isTyping,
fileInputRef,
showEmojiPicker,
chatModels,
selectedModelName,
selectedModel,
isLoadingModels,
orchestrationStatus,
orchestrationInsight,
activeAgent,
visitedAgents,
orchestrationEventFeed,
systemMeta,
systemTelemetry,
sessionTelemetry,
sendMessage,
selectConversation,
newConversation,
formatTime,
autoResize,
handleFileSelect,
insertEmoji,
openFilePicker,
} = useChatView()
function formatNetworkRate(bytesPerSecond: number | null, online: boolean) {
if (!online || bytesPerSecond === null) return 'OFFLINE'
if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / (1024 * 1024)).toFixed(2)} MB/s`
if (bytesPerSecond >= 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
return `${bytesPerSecond.toFixed(0)} B/s`
}
const clientTime = ref(new Date())
const weatherSummary = ref('Weather unavailable')
const weatherCode = ref<number | null>(null)
const orchestrationDrawerOpen = ref(false)
const knowledgeHudOpen = ref(false)
const selectedFolder = ref<any>(null)
const previewDoc = ref<any>(null)
let clientTimeTimer: ReturnType<typeof setInterval> | null = null
interface SidebarFocusItem {
id: string
label: string
title: string
meta: string
tone: 'done' | 'doing' | 'pending'
}
interface SidebarNewsItem {
id: string
title: string
meta: string
}
// Daily Digest state
const dailyDigest = ref<any>(null)
const digestLoading = ref(false)
const recentDigests = ref<any[]>([])
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
// Active reminder state
const activeReminder = ref<any>(null)
const reminderVisible = ref(false)
let reminderPollTimer: ReturnType<typeof setInterval> | null = null
const {
showNewFolderDialog, newFolderName, createFolder, openNewFolderDialog,
triggerUpload, handleUpload, uploadInput
} = useKnowledgeView()
// Load daily digest
async function loadDailyDigest() {
digestLoading.value = true
try {
const today = formatDateKey(new Date())
const response = await getRecentDigests(6)
const items = response.data?.items ?? []
recentDigests.value = items
dailyDigest.value = items.find((item: any) => item.date === today) ?? null
} catch (err) {
console.warn('Failed to load daily digest:', err)
recentDigests.value = []
dailyDigest.value = null
} finally {
digestLoading.value = false
}
}
// Poll for due reminders
async function pollDueReminders() {
try {
const response = await getDueReminders()
if (response.data?.items?.length > 0) {
activeReminder.value = response.data.items[0]
reminderVisible.value = true
} else {
reminderVisible.value = false
}
} catch (err) {
console.warn('Failed to poll due reminders:', err)
}
}
async function handleSnooze(id: string, minutes: number) {
try {
await snoozeReminder(id, minutes)
reminderVisible.value = false
// Reschedule next poll
setTimeout(pollDueReminders, minutes * 60 * 1000)
} catch (err) {
console.warn('Failed to snooze reminder:', err)
}
}
async function handleDismiss(id: string) {
try {
await dismissReminder(id)
reminderVisible.value = false
} catch (err) {
console.warn('Failed to dismiss reminder:', err)
}
}
function updateClientTime() {
clientTime.value = new Date()
}
function formatDateKey(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatMonthKey(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
async function loadSidebarPlanSnapshot(date = new Date()) {
const dateKey = formatDateKey(date)
const monthKey = formatMonthKey(date)
try {
const [todayResponse, monthResponse] = await Promise.all([
scheduleCenterApi.date(dateKey),
scheduleCenterApi.month(monthKey),
])
todayPlanDetail.value = todayResponse.data
monthPlanDays.value = monthResponse.data.days
} catch (err) {
console.warn('Failed to load sidebar plan snapshot:', err)
todayPlanDetail.value = null
monthPlanDays.value = []
}
}
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
}
function formatClientDate(date: Date) {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function formatClientClock(date: Date) {
return date.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function weatherCodeLabel(code: number | null | undefined) {
if (code === 0) return 'Clear'
if (code === 1 || code === 2) return 'Partly Cloudy'
if (code === 3) return 'Overcast'
if (code === 45 || code === 48) return 'Fog'
if ([51, 53, 55, 56, 57].includes(code ?? -1)) return 'Drizzle'
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return 'Rain'
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow'
if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm'
return 'Weather'
}
const weatherIcon = computed(() => {
const code = weatherCode.value
if (code === 0) return Sun
if (code === 1 || code === 2 || code === 3) return Cloud
if (code === 45 || code === 48) return CloudFog
if ([51, 53, 55, 56, 57].includes(code ?? -1)) return CloudDrizzle
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return CloudRain
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return CloudSnow
if ([95, 96, 99].includes(code ?? -1)) return CloudLightning
return Cloud
})
const todayDateKey = computed(() => formatDateKey(clientTime.value))
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
const calendarCells = computed(() => {
const year = clientTime.value.getFullYear()
const month = clientTime.value.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean }> = []
for (let index = 0; index < firstDayOffset; index += 1) {
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false })
}
for (let day = 1; day <= daysInMonth; day += 1) {
const monthDate = new Date(year, month, day)
const dateKey = formatDateKey(monthDate)
const summary = monthPlanSummaryMap.value.get(dateKey)
const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0)
cells.push({
key: dateKey,
value: day,
active: day === clientTime.value.getDate(),
busy,
})
}
while (cells.length % 7 !== 0) {
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false })
}
return cells
})
const todayPlanCounters = computed(() => {
const detail = todayPlanDetail.value
if (!detail) {
return {
done: 0,
doing: 0,
pending: 0,
total: 0,
completion: 0,
}
}
const todoDone = detail.todos.filter((item) => item.is_completed).length
const todoPending = detail.todos.filter((item) => !item.is_completed).length
const taskDone = detail.tasks.filter((item) => item.status === 'done').length
const taskDoing = detail.tasks.filter((item) => item.status === 'in_progress').length
const taskPending = detail.tasks.filter((item) => item.status === 'todo').length
const goalDone = detail.goals.filter((item) => item.status === 'done').length
const goalPending = detail.goals.filter((item) => item.status !== 'done').length
const reminderDone = detail.reminders.filter((item) => item.status === 'done' || item.is_dismissed).length
const reminderPending = detail.reminders.filter((item) => item.status !== 'done' && !item.is_dismissed).length
const done = todoDone + taskDone + goalDone + reminderDone
const doing = taskDoing
const pending = todoPending + taskPending + goalPending + reminderPending
const total = done + doing + pending
return {
done,
doing,
pending,
total,
completion: total > 0 ? Math.round((done / total) * 100) : 0,
}
})
const monthReviewStats = computed(() => monthPlanDays.value.reduce(
(acc, item) => {
acc.todoTotal += item.todo_total
acc.todoCompleted += item.todo_completed
acc.taskTotal += item.task_due_total
acc.reminderTotal += item.reminder_total
acc.goalTotal += item.goal_total
acc.highPriorityTotal += item.high_priority_total
if (item.todo_total + item.task_due_total + item.reminder_total + item.goal_total > 0) {
acc.activeDays += 1
}
return acc
},
{
todoTotal: 0,
todoCompleted: 0,
taskTotal: 0,
reminderTotal: 0,
goalTotal: 0,
highPriorityTotal: 0,
activeDays: 0,
},
))
const sidebarWeekLabels = ['\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d', '\u65e5']
const sidebarStatusHeadline = computed(() => (
todayPlanCounters.value.total
? `\u4eca\u65e5\u5171 ${todayPlanCounters.value.total} \u9879\u8ba1\u5212\uff0c\u5df2\u5b8c\u6210 ${todayPlanCounters.value.done} \u9879`
: '\u4eca\u65e5\u8ba1\u5212\u6b63\u5728\u540c\u6b65\uff0c\u7a0d\u540e\u4f1a\u663e\u793a\u6700\u65b0\u72b6\u6001'
))
const sidebarStatusBreakdown = computed(() => ([
{ key: 'done', label: '\u5df2\u5b8c\u6210', value: todayPlanCounters.value.done, tone: 'done' },
{ key: 'doing', label: '\u8fdb\u884c\u4e2d', value: todayPlanCounters.value.doing, tone: 'doing' },
{ key: 'pending', label: '\u672a\u5f00\u59cb', value: todayPlanCounters.value.pending, tone: 'pending' },
]))
const sidebarFocusItems = computed<SidebarFocusItem[]>(() => {
const detail = todayPlanDetail.value
if (!detail) return []
const goalItems = detail.goals
.filter((goal) => goal.status !== 'done')
.map((goal) => ({
id: `goal-${goal.id}`,
label: '\u76ee\u6807',
title: goal.title,
meta: goal.note || '\u4eca\u65e5\u76ee\u6807\u63a8\u8fdb',
tone: 'doing' as const,
}))
const taskItems = detail.tasks
.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
.sort((a, b) => {
const priorityRank = { urgent: 0, high: 1, medium: 2, low: 3 }
return priorityRank[a.priority] - priorityRank[b.priority]
})
.map((task) => ({
id: `task-${task.id}`,
label: task.priority === 'urgent' || task.priority === 'high' ? '\u9ad8\u4f18\u4efb\u52a1' : '\u4efb\u52a1',
title: task.title,
meta: task.status === 'in_progress' ? '\u5904\u7406\u4e2d' : '\u5f85\u542f\u52a8',
tone: task.status === 'in_progress' ? 'doing' as const : 'pending' as const,
}))
const reminderItems = detail.reminders
.filter((reminder) => reminder.status !== 'done' && !reminder.is_dismissed)
.map((reminder) => ({
id: `reminder-${reminder.id}`,
label: '\u63d0\u9192',
title: reminder.title,
meta: reminder.reminder_at.slice(11, 16),
tone: 'pending' as const,
}))
const todoItems = detail.todos
.filter((todo) => !todo.is_completed)
.map((todo) => ({
id: `todo-${todo.id}`,
label: '\u5f85\u529e',
title: todo.title,
meta: todo.source === 'manual' ? '\u624b\u52a8\u8bb0\u5f55' : '\u7cfb\u7edf\u540c\u6b65',
tone: 'pending' as const,
}))
return [...goalItems, ...taskItems, ...reminderItems, ...todoItems].slice(0, 5)
})
const sidebarReviewAchievements = computed(() => {
const stats = monthReviewStats.value
const items = [
stats.todoCompleted > 0 ? `\u7d2f\u8ba1\u5b8c\u6210 ${stats.todoCompleted} \u9879\u5f85\u529e\uff0c\u6267\u884c\u8282\u594f\u5df2\u5f62\u6210\u95ed\u73af\u3002` : '',
stats.activeDays > 0 ? `\u672c\u6708\u5df2\u6709 ${stats.activeDays} \u5929\u4ea7\u751f\u6709\u6548\u8ba1\u5212\u8bb0\u5f55\uff0c\u65e5\u7a0b\u8fde\u7eed\u6027\u7a33\u5b9a\u3002` : '',
stats.highPriorityTotal > 0 ? `\u9ad8\u4f18\u4e8b\u9879\u5171 ${stats.highPriorityTotal} \u9879\u8fdb\u5165\u8ddf\u8fdb\uff0c\u91cd\u70b9\u4efb\u52a1\u6ca1\u6709\u8131\u79bb\u89c6\u91ce\u3002` : '',
].filter(Boolean)
if (items.length > 0) return items.slice(0, 3)
return ['\u672c\u6708\u8ba1\u5212\u6570\u636e\u8fd8\u5728\u79ef\u7d2f\u4e2d\uff0c\u53ef\u4ee5\u4ece\u4eca\u65e5\u91cd\u70b9\u5f00\u59cb\u9010\u6b65\u5efa\u7acb\u590d\u76d8\u6837\u672c\u3002']
})
const sidebarReviewReflections = computed(() => {
const stats = monthReviewStats.value
const pendingTodoCount = Math.max(stats.todoTotal - stats.todoCompleted, 0)
const items = [
pendingTodoCount > 0 ? `\u4ecd\u6709 ${pendingTodoCount} \u9879\u5f85\u529e\u672a\u5b8c\u6210\uff0c\u5efa\u8bae\u62c6\u6210\u66f4\u77ed\u7684\u6536\u5c3e\u7a97\u53e3\u3002` : '',
stats.highPriorityTotal >= 8 ? '\u9ad8\u4f18\u4e8b\u9879\u5bc6\u5ea6\u504f\u9ad8\uff0c\u6700\u597d\u63d0\u524d\u9501\u5b9a 1 \u5230 2 \u4e2a\u7edd\u5bf9\u4f18\u5148\u7ea7\u3002' : '',
stats.reminderTotal >= Math.max(6, stats.activeDays) ? '\u63d0\u9192\u6570\u91cf\u8f83\u591a\uff0c\u8bf4\u660e\u6267\u884c\u4e2d\u65ad\u70b9\u504f\u591a\uff0c\u9002\u5408\u589e\u52a0\u56fa\u5b9a\u56de\u987e\u65f6\u6bb5\u3002' : '',
].filter(Boolean)
if (items.length > 0) return items.slice(0, 3)
return ['\u672c\u6708\u8282\u594f\u76f8\u5bf9\u5e73\u7a33\uff0c\u4e0b\u4e00\u6b65\u53ef\u4ee5\u628a\u91cd\u70b9\u4e8b\u9879\u518d\u6536\u655b\u5230\u66f4\u6e05\u6670\u7684\u4e3b\u7ebf\u3002']
})
const sidebarFeedItems = computed<SidebarNewsItem[]>(() => {
const digestFeed = recentDigests.value.flatMap((digest: any, digestIndex: number) => {
const dateLabel = typeof digest.date === 'string' ? digest.date.slice(5) : '\u8fd1\u671f'
const points = Array.isArray(digest.keyPoints) ? digest.keyPoints : []
return points.slice(0, 2).map((point: any, pointIndex: number) => ({
id: `digest-${digestIndex}-${pointIndex}`,
title: typeof point?.content === 'string' ? point.content : String(point ?? ''),
meta: point?.source || dateLabel,
}))
})
if (digestFeed.length > 0) return digestFeed.slice(0, 4)
return [
{ id: 'fallback-1', title: '\u0041\u0049 \u7814\u53d1\u8282\u594f\u7ee7\u7eed\u5347\u6e29\uff0c\u6a21\u578b\u4e0e\u5de5\u4f5c\u6d41\u4e00\u4f53\u5316\u6210\u4e3a\u4e3b\u6d41\u8bae\u9898\u3002', meta: 'Industry' },
{ id: 'fallback-2', title: '\u672c\u5730\u77e5\u8bc6\u5e93\u4e0e\u8ba1\u5212\u7cfb\u7edf\u7684\u8054\u52a8\u4f53\u9a8c\uff0c\u6b63\u5728\u6210\u4e3a\u6548\u7387\u5de5\u5177\u7684\u65b0\u7ade\u4e89\u70b9\u3002', meta: 'Product' },
{ id: 'fallback-3', title: '\u5efa\u8bae\u63a5\u5165\u771f\u5b9e RSS \u6e90\u540e\u66ff\u6362\u5f53\u524d\u5360\u4f4d\u5361\u7247\uff0c\u4ee5\u83b7\u5f97\u5373\u65f6\u8d44\u8baf\u6d41\u3002', meta: 'System' },
]
})
async function loadWeather(latitude: number, longitude: number) {
try {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,weather_code&timezone=auto`,
)
if (!response.ok) throw new Error('weather request failed')
const data = await response.json()
const current = data.current ?? {}
weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null
const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--'
weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}`
} catch {
weatherCode.value = null
weatherSummary.value = 'Weather unavailable'
}
}
onMounted(() => {
updateClientTime()
clientTimeTimer = setInterval(updateClientTime, 1000)
// Load daily digest
void loadDailyDigest()
void loadSidebarPlanSnapshot(new Date())
// Start polling for due reminders (every 60 seconds)
void pollDueReminders()
reminderPollTimer = setInterval(() => {
void pollDueReminders()
}, 60000)
if (!navigator.geolocation) {
weatherCode.value = null
weatherSummary.value = 'Weather unavailable'
return
}
navigator.geolocation.getCurrentPosition(
(position) => {
void loadWeather(position.coords.latitude, position.coords.longitude)
},
() => {
weatherCode.value = null
weatherSummary.value = 'Weather unavailable'
},
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 },
)
})
onUnmounted(() => {
if (clientTimeTimer) clearInterval(clientTimeTimer)
if (reminderPollTimer) clearInterval(reminderPollTimer)
})
watch(todayDateKey, (next, previous) => {
if (next === previous) return
void loadDailyDigest()
void loadSidebarPlanSnapshot(clientTime.value)
})
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) => line.replace(/^\s*[-*]\s+/, '').trim())
.map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
.join('')
return `<ul class="md-list">${items}</ul>`
}
if (lines.every((line) => /^\s*\d+\.\s+/.test(line))) {
const items = lines
.map((line) => line.replace(/^\s*\d+\.\s+/, '').trim())
.map((line) => `<li>${renderInlineMarkdown(line)}</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">
<!-- Conversation list sidebar -->
<aside class="conv-sidebar jarvis-sidebar">
<div 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 class="jarvis-action-row">
<button class="jarvis-action-chip" type="button" @click="newConversation">&#x65B0;&#x5BF9;&#x8BDD;</button>
<button class="jarvis-action-chip schedule" type="button" @click="selectConversation('schedule-mode')">&#x65E5;&#x7A0B;&#x6A21;&#x5F0F;</button>
<button class="jarvis-action-chip code" type="button" @click="selectConversation('code-mode')">&#x4EE3;&#x7801;&#x6A21;&#x5F0F;</button>
</div>
</div>
<div class="jarvis-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">
<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">
<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>
</div>
<div class="jarvis-panel jarvis-rss-panel">
<div class="jarvis-section-title">RSS &#x65B0;&#x95FB;</div>
<div class="jarvis-rss-list">
<article v-for="item in sidebarFeedItems" :key="item.id" class="jarvis-news-card">
<div class="jarvis-news-meta">{{ item.meta }}</div>
<div class="jarvis-news-title">{{ item.title }}</div>
</article>
</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>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>
</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>
<!-- 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>
</div>
<div class="input-hints">
<span class="hint-item">ENTER 发送</span>
<span class="hint-sep">|</span>
<span class="hint-item">SHIFT+ENTER 换行</span>
</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>
.hidden-upload { display: none; }
.hud-dialog-jarvis { width: 400px; background: var(--jarvis-bg); border: 1px solid var(--jarvis-cyan); padding: 2px; box-shadow: 0 0 50px rgba(0,0,0,0.8); }
.dialog-header-tech { padding: 15px; border-bottom: 1px solid rgba(0, 245, 212, 0.2); display: flex; justify-content: space-between; }
.dialog-body-tech { padding: 25px; }
.dialog-label { font-family: var(--font-mono); font-size: 9px; color: var(--jarvis-cyan); margin-bottom: 10px; opacity: 0.6; }
.jarvis-input { width: 100%; background: rgba(0,0,0,0.3); border: 1px solid rgba(0, 245, 212, 0.2); padding: 12px; color: #fff; font-family: var(--font-mono); outline: none; }
.jarvis-input:focus { border-color: var(--jarvis-cyan); box-shadow: 0 0 10px rgba(0, 245, 212, 0.2); }
.dialog-actions-tech { padding: 15px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid rgba(255,255,255,0.05); }
.action-btn-jarvis.small { padding: 4px 10px; }
.chat-view {
display: flex;
height: 100%;
overflow: hidden;
}
.sidebar-runtime-panel {
flex: 1;
padding: 12px 14px;
border-bottom: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
gap: 10px;
background: linear-gradient(180deg, rgba(7, 12, 24, 0.92), rgba(7, 11, 20, 0.82));
overflow-y: auto;
min-height: 0;
}
.runtime-sidebar-footer {
flex-shrink: 0;
padding: 12px 14px 14px;
border-top: 1px solid rgba(34, 211, 238, 0.08);
background: linear-gradient(180deg, rgba(6, 10, 18, 0.96), rgba(5, 8, 16, 0.98));
}
.runtime-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.runtime-env-panel {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid rgba(56, 189, 248, 0.14);
border-radius: 12px;
background: linear-gradient(180deg, rgba(8, 18, 36, 0.78), rgba(8, 14, 26, 0.62));
}
.runtime-env-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-env-key {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.14em;
color: #7dd3fc;
flex-shrink: 0;
}
.runtime-env-value {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--text-primary);
text-align: right;
word-break: break-word;
}
.weather-inline {
display: inline-flex;
align-items: center;
gap: 6px;
}
.weather-icon {
color: #7dd3fc;
flex-shrink: 0;
}
.runtime-meta-panel {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 12px;
background: rgba(8, 14, 26, 0.68);
}
.runtime-meta-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-meta-key {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.14em;
color: var(--text-dim);
flex-shrink: 0;
}
.runtime-meta-value {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--text-primary);
text-align: right;
word-break: break-all;
}
.runtime-card,
.runtime-activity-card {
border: 1px solid rgba(34, 211, 238, 0.12);
border-radius: 12px;
background: rgba(10, 16, 30, 0.74);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
padding: 10px;
}
.runtime-topline,
.runtime-activity-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.runtime-label,
.runtime-activity-title,
.runtime-stat-key {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: var(--text-dim);
}
.runtime-value,
.runtime-stat-value {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--text-primary);
}
.runtime-badge {
padding: 3px 7px;
border-radius: 999px;
border: 1px solid rgba(245, 158, 11, 0.2);
background: rgba(245, 158, 11, 0.1);
color: var(--accent-amber);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.14em;
}
.cpu-card { border-color: rgba(34, 211, 238, 0.22); }
.gpu-card { border-color: rgba(244, 114, 182, 0.24); }
.mem-card { border-color: rgba(167, 139, 250, 0.22); }
.disk-card { border-color: rgba(74, 222, 128, 0.22); }
.network-card { border-color: rgba(37, 99, 235, 0.26); }
.gpu-info-copy {
font-family: var(--font-display);
font-size: 10px;
line-height: 1.5;
color: var(--text-primary);
text-align: right;
word-break: break-word;
}
.disk-capacity-copy {
margin-bottom: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
}
.gpu-info-stats {
margin-top: 8px;
display: grid;
gap: 4px;
}
.gpu-info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.gpu-info-key {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: var(--text-dim);
}
.gpu-info-value {
font-family: var(--font-display);
font-size: 10px;
color: var(--text-primary);
}
.network-metric-head,
.feed-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.network-metric-head {
margin-bottom: 8px;
}
.network-legend {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.network-direction {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.14em;
color: var(--text-dim);
}
.network-speed,
.runtime-log-count {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--accent-cyan);
}
.network-direction.up,
.network-speed.up {
color: #2563eb;
}
.network-direction.down,
.network-speed.down {
color: #38bdf8;
}
.network-overlay-chart {
position: relative;
}
.network-overlay-chart :deep(svg) {
display: block;
}
.network-overlay-chart :deep(svg + svg) {
margin-top: -34px;
}
.runtime-feed {
margin-top: 0;
}
.runtime-feed-launch {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(34, 211, 238, 0.14);
border-radius: 14px;
background:
radial-gradient(circle at top right, rgba(34, 211, 238, 0.12), transparent 36%),
linear-gradient(180deg, rgba(8, 16, 30, 0.9), rgba(8, 14, 26, 0.72));
text-align: left;
color: var(--text-primary);
cursor: pointer;
transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
appearance: none;
-webkit-appearance: none;
}
.runtime-feed-launch:hover {
transform: translateX(-2px);
border-color: rgba(34, 211, 238, 0.26);
box-shadow: 0 12px 28px rgba(4, 10, 22, 0.34), 0 0 18px rgba(34, 211, 238, 0.08);
}
.feed-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feed-hero-meta {
display: inline-flex;
align-items: center;
gap: 8px;
}
.runtime-feed-launch .feed-title {
color: var(--text-primary);
}
.runtime-feed-launch .runtime-log-count {
color: #f8fafc;
}
.feed-launch-arrow {
color: var(--accent-cyan);
}
.feed-empty {
font-size: 12px;
color: var(--text-muted);
}
.feed-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.feed-item {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
}
.feed-marker {
width: 7px;
height: 7px;
margin-top: 5px;
border-radius: 50%;
background: rgba(34, 211, 238, 0.5);
box-shadow: 0 0 8px rgba(34, 211, 238, 0.2);
flex-shrink: 0;
}
.feed-item.tool .feed-marker {
background: var(--accent-amber);
box-shadow: 0 0 8px rgba(249, 168, 37, 0.24);
}
.feed-item.success .feed-marker {
background: var(--accent-green);
box-shadow: 0 0 8px rgba(74, 222, 128, 0.24);
}
.feed-item.error .feed-marker {
background: var(--accent-red);
box-shadow: 0 0 8px rgba(255, 71, 87, 0.24);
}
.feed-label {
line-height: 1.5;
}
.agent-drawer-shell {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 30;
}
.agent-drawer-shell.open {
pointer-events: auto;
}
.agent-drawer-backdrop {
position: absolute;
inset: 0;
border: none;
background: rgba(2, 6, 14, 0.52);
backdrop-filter: blur(4px);
}
.agent-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
transform: translateX(100%);
transition: transform var(--transition-mid);
}
.agent-drawer.open {
transform: translateX(0);
}
.agent-drawer :deep(.orchestration-panel) {
height: 100%;
padding: 12px 12px 12px 0;
}
.agent-drawer :deep(.panel-frame) {
height: 100%;
}
.agent-drawer-close {
position: absolute;
top: 16px;
right: 72px;
z-index: 2;
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(8, 14, 26, 0.86);
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
.agent-drawer-close:hover {
border-color: rgba(34, 211, 238, 0.26);
color: var(--accent-cyan);
background: rgba(8, 16, 30, 0.96);
}
.knowledge-hud-shell {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 35;
}
.knowledge-hud-shell.open {
pointer-events: auto;
}
.knowledge-hud-backdrop {
position: absolute;
inset: 0;
border: none;
background:
radial-gradient(circle at top center, rgba(34, 211, 238, 0.12), transparent 32%),
rgba(2, 6, 14, 0.68);
backdrop-filter: blur(10px);
}
.knowledge-hud {
position: absolute;
inset: 26px 36px 26px 20px;
transform: scale(0.96);
opacity: 0;
transition: transform var(--transition-mid), opacity var(--transition-mid);
}
.knowledge-hud.open {
transform: scale(1);
opacity: 1;
}
.knowledge-hud-frame {
position: relative;
height: 100%;
border: 1px solid rgba(34, 211, 238, 0.22);
background:
linear-gradient(180deg, rgba(8, 16, 30, 0.96), rgba(5, 10, 20, 0.92));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 0 0 1px rgba(34, 211, 238, 0.08),
0 24px 80px rgba(2, 6, 14, 0.54);
overflow: hidden;
}
.knowledge-hud-frame::before,
.knowledge-hud-frame::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
}
.knowledge-hud-frame::before {
background:
linear-gradient(90deg, rgba(34, 211, 238, 0.08), transparent 14%, transparent 86%, rgba(34, 211, 238, 0.08)),
linear-gradient(180deg, rgba(34, 211, 238, 0.08), transparent 16%, transparent 84%, rgba(245, 158, 11, 0.08));
}
.knowledge-hud-frame::after {
background:
radial-gradient(circle at top right, rgba(34, 211, 238, 0.14), transparent 24%),
radial-gradient(circle at bottom left, rgba(245, 158, 11, 0.08), transparent 28%);
}
.knowledge-hud-chrome {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border-bottom: 1px solid rgba(34, 211, 238, 0.12);
background: linear-gradient(180deg, rgba(10, 22, 38, 0.88), rgba(8, 16, 28, 0.72));
}
.knowledge-hud-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-size: 12px;
letter-spacing: 0.18em;
color: #c8f7ff;
text-shadow: 0 0 14px rgba(34, 211, 238, 0.16);
}
.knowledge-hud-close {
width: 34px;
height: 34px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(8, 14, 26, 0.78);
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
.knowledge-hud-close:hover {
border-color: rgba(34, 211, 238, 0.28);
color: var(--accent-cyan);
background: rgba(8, 18, 32, 0.94);
}
.knowledge-hud-body {
position: relative;
z-index: 1;
height: calc(100% - 63px);
overflow: hidden;
}
.knowledge-hud-body :deep(.knowledge-view) {
height: 100%;
background: transparent;
}
.knowledge-hud-body :deep(.page-header) {
padding-top: 18px;
}
@media (max-width: 960px) {
.knowledge-hud {
inset: 16px 14px 16px 14px;
}
}
/* ── Conversation Sidebar ── */
.conv-sidebar {
width: 260px;
min-width: 260px;
background: var(--bg-panel);
border-right: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
overflow: hidden;
}
.runtime-sidebar {
width: 280px;
min-width: 280px;
background: var(--bg-panel);
border-left: 1px solid var(--border-dim);
overflow: hidden;
display: flex;
flex-direction: column;
}
.conv-sidebar-header {
padding: 12px 14px 10px;
border-bottom: 1px solid var(--border-dim);
}
.section-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.sidebar-runtime-panel .section-label {
margin-bottom: 10px;
}
.conv-sidebar-header .section-label {
margin-bottom: 0;
}
.conv-sidebar-footer {
padding: 12px 14px 14px;
border-top: 1px solid var(--border-dim);
}
.new-chat-btn.muted {
background: rgba(0, 245, 212, 0.08);
}
.new-chat-btn.muted:hover {
background: rgba(0, 245, 212, 0.14);
}
.new-chat-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
font-size: 10px;
letter-spacing: 0.1em;
font-weight: 600;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.new-chat-btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(0,245,212,0.1), transparent);
transform: translateX(-100%);
transition: transform 0.4s;
}
.new-chat-btn:hover::before { transform: translateX(100%); }
.new-chat-btn:hover {
background: rgba(0, 245, 212, 0.18);
box-shadow: var(--glow-cyan);
}
.btn-line {
width: 1px;
height: 12px;
background: var(--accent-cyan);
opacity: 0.6;
}
.conv-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conv-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: 2px;
border: 1px solid transparent;
transition: all var(--transition-fast);
overflow: hidden;
}
.conv-item:hover {
background: rgba(0, 245, 212, 0.04);
border-color: var(--border-dim);
}
.conv-item.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
}
.conv-active-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent-cyan);
opacity: 0;
transition: opacity var(--transition-fast);
box-shadow: 0 0 6px var(--accent-cyan);
}
.conv-item.active .conv-active-line { opacity: 1; }
.conv-item-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.conv-item.active .conv-item-icon { color: var(--accent-cyan); }
.conv-item-body { flex: 1; min-width: 0; }
.conv-title {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.conv-item.active .conv-title { color: var(--accent-cyan); }
.conv-date {
font-size: 9px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.conv-delete {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 3px;
opacity: 0;
transition: all var(--transition-fast);
border-radius: 3px;
}
.conv-item:hover .conv-delete { opacity: 1; }
.conv-delete:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
.conv-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 16px;
gap: 8px;
}
.empty-icon { color: var(--text-dim); }
.empty-text { font-size: 12px; color: var(--text-dim); }
.empty-hint { font-size: 10px; color: var(--text-muted); }
/* ── Chat Area ── */
.chat-area {
flex: 1;
overflow: hidden;
position: relative;
}
.chat-area::before,
.chat-area::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
}
.chat-area::before {
background:
linear-gradient(
180deg,
rgba(34, 211, 238, 0) 0%,
rgba(34, 211, 238, 0.012) 44%,
rgba(34, 211, 238, 0.055) 49%,
rgba(125, 211, 252, 0.09) 50%,
rgba(34, 211, 238, 0.055) 51%,
rgba(34, 211, 238, 0.012) 56%,
rgba(34, 211, 238, 0) 100%
);
transform: translateY(-100%);
opacity: 0.62;
filter: blur(1.5px);
animation: chat-scan-sweep 10.5s ease-in-out infinite;
}
.chat-area::after {
background:
repeating-linear-gradient(
180deg,
rgba(125, 211, 252, 0.012) 0px,
rgba(125, 211, 252, 0.012) 0.5px,
transparent 0.5px,
transparent 6px
);
opacity: 0.18;
animation: chat-scan-drift 14s linear infinite;
}
.chat-shell {
display: flex;
height: 100%;
overflow: hidden;
position: relative;
z-index: 1;
}
.chat-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (max-width: 1200px) {
.runtime-sidebar {
width: 240px;
min-width: 240px;
}
}
@media (max-width: 960px) {
.chat-view {
flex-direction: column;
}
.conv-sidebar,
.runtime-sidebar {
width: 100%;
min-width: 0;
}
.conv-sidebar {
max-height: 220px;
border-right: none;
border-bottom: 1px solid var(--border-dim);
}
.runtime-sidebar {
max-height: 260px;
border-left: none;
border-top: 1px solid var(--border-dim);
}
}
.chat-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5, 8, 16, 0.6);
backdrop-filter: blur(8px);
}
.chat-shortcuts {
display: flex;
align-items: center;
min-width: 0;
}
.chat-model-panel {
display: flex;
align-items: center;
gap: 10px;
}
.chat-model-label {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
color: var(--text-dim);
}
.chat-model-select {
min-width: 220px;
padding: 7px 12px;
border: 1px solid rgba(34, 211, 238, 0.2);
border-radius: 10px;
background: rgba(10, 16, 28, 0.92);
color: var(--text-main);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.chat-model-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.chat-model {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--accent-amber);
padding: 3px 10px;
border: 1px solid rgba(249, 168, 37, 0.2);
border-radius: 20px;
background: var(--accent-amber-dim);
}
/* ── Messages ── */
.messages-area {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Welcome screen */
.welcome-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding-bottom: 80px;
}
.welcome-icon {
position: relative;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-cyan);
margin-bottom: 8px;
}
.welcome-ring {
position: absolute;
border-radius: 50%;
border: 1px solid var(--accent-cyan);
animation: spin linear infinite;
}
.r1 { width: 80px; height: 80px; opacity: 0.3; animation-duration: 8s; border-style: dashed; }
.r2 { width: 60px; height: 60px; opacity: 0.5; animation-duration: 5s; animation-direction: reverse; }
.r3 { width: 40px; height: 40px; opacity: 0.7; animation-duration: 3s; }
.welcome-core {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px var(--accent-cyan));
animation: pulse-glow 2s ease-in-out infinite;
}
.welcome-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 800;
letter-spacing: 0.2em;
color: var(--accent-cyan);
text-shadow: var(--glow-cyan);
}
.welcome-sub {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.3em;
color: var(--text-dim);
text-transform: uppercase;
}
.welcome-hint {
font-size: 13px;
color: var(--text-dim);
margin-top: 20px;
}
/* Message rows */
.message-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
animation: fade-in-up 0.3s ease both;
}
.message-row.user {
flex-direction: row-reverse;
}
.message-row.assistant {
justify-content: flex-start;
}
.msg-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
margin-top: 4px;
}
.user .msg-avatar {
background: rgba(0, 245, 212, 0.1);
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
}
.assistant .msg-avatar {
background: linear-gradient(135deg, var(--accent-cyan-dim), var(--accent-purple-dim));
border: 1px solid var(--border-bright);
color: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan-glow);
}
.msg-content {
display: flex;
flex-direction: column;
min-width: 0;
max-width: min(75%, 820px);
}
.user .msg-content {
align-items: flex-end;
}
.assistant .msg-content {
align-items: flex-start;
}
.msg-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.user .msg-meta {
justify-content: flex-end;
}
.msg-role {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.msg-model {
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-cyan);
letter-spacing: 0.08em;
}
.msg-time {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
}
.msg-bubble {
display: inline-block;
max-width: 100%;
padding: 10px 16px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.65;
word-break: break-word;
white-space: pre-wrap;
}
.user .msg-bubble {
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
color: var(--text-primary);
}
.assistant .msg-bubble {
background: rgba(13, 21, 37, 0.8);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
color: var(--text-secondary);
backdrop-filter: blur(4px);
}
.think-panel {
margin-bottom: 8px;
}
.think-summary {
cursor: pointer;
list-style: none;
user-select: none;
}
.think-summary::-webkit-details-marker {
display: none;
}
.think-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid rgba(167, 139, 250, 0.22);
background: linear-gradient(135deg, rgba(40, 24, 68, 0.72), rgba(18, 24, 48, 0.72));
color: #c4b5fd;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.16em;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03), 0 0 12px rgba(167, 139, 250, 0.08);
}
.think-chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.think-content {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid rgba(167, 139, 250, 0.14);
border-radius: 12px;
background: rgba(18, 20, 34, 0.55);
display: flex;
flex-direction: column;
gap: 10px;
}
.think-block {
font-size: 12px;
line-height: 1.6;
color: var(--text-dim);
}
.markdown-body :deep(p),
.think-block :deep(p) {
margin: 0;
}
.markdown-body :deep(p + p),
.think-block :deep(p + p) {
margin-top: 10px;
}
.markdown-body :deep(.md-heading),
.think-block :deep(.md-heading) {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
}
.markdown-body :deep(.md-list),
.think-block :deep(.md-list) {
margin: 0;
padding-left: 18px;
}
.markdown-body :deep(.md-list li),
.think-block :deep(.md-list li) {
margin: 4px 0;
}
.markdown-body :deep(code),
.think-block :deep(code) {
padding: 1px 5px;
border-radius: 6px;
background: rgba(148, 163, 184, 0.14);
font-family: var(--font-mono);
font-size: 12px;
}
.markdown-body :deep(.md-pre),
.think-block :deep(.md-pre) {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: rgba(5, 10, 20, 0.78);
border: 1px solid rgba(148, 163, 184, 0.12);
overflow-x: auto;
}
.markdown-body :deep(.md-pre code),
.think-block :deep(.md-pre code) {
padding: 0;
background: transparent;
}
.markdown-body :deep(.md-table-wrap),
.think-block :deep(.md-table-wrap) {
width: 100%;
overflow-x: auto;
margin: 2px 0;
border-radius: 10px;
border: 1px solid rgba(34, 211, 238, 0.16);
background: rgba(5, 10, 20, 0.46);
}
.markdown-body :deep(.md-table),
.think-block :deep(.md-table) {
width: 100%;
min-width: 320px;
border-collapse: collapse;
font-size: 12px;
}
.markdown-body :deep(.md-table th),
.markdown-body :deep(.md-table td),
.think-block :deep(.md-table th),
.think-block :deep(.md-table td) {
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.16);
text-align: left;
vertical-align: top;
}
.markdown-body :deep(.md-table th),
.think-block :deep(.md-table th) {
background: rgba(34, 211, 238, 0.1);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
}
.markdown-body :deep(.md-table tbody tr:nth-child(even)),
.think-block :deep(.md-table tbody tr:nth-child(even)) {
background: rgba(148, 163, 184, 0.04);
}
.think-panel[open] .think-chip {
border-color: rgba(167, 139, 250, 0.3);
color: #ddd6fe;
}
.think-panel[open] .think-chip-dot {
animation: pulse-glow 1.4s ease-in-out infinite;
}
/* Typing indicator */
.thinking-row {
padding-top: 2px;
}
.thinking-bubble {
min-width: 280px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(12, 20, 36, 0.92), rgba(16, 18, 34, 0.84));
border-color: rgba(34, 211, 238, 0.18);
}
.thinking-hud {
display: flex;
align-items: center;
gap: 14px;
}
.thinking-core {
position: relative;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.thinking-ring,
.thinking-dot {
position: absolute;
inset: 0;
border-radius: 50%;
}
.thinking-ring {
border: 1px solid rgba(34, 211, 238, 0.45);
}
.ring-1 {
animation: spin 2.6s linear infinite;
}
.ring-2 {
inset: 4px;
border-color: rgba(167, 139, 250, 0.38);
animation: spin 1.8s linear infinite reverse;
}
.ring-3 {
inset: 8px;
border-color: rgba(245, 158, 11, 0.36);
animation: pulse-glow 1.4s ease-in-out infinite;
}
.thinking-dot {
inset: 11px;
background: var(--accent-cyan);
box-shadow: 0 0 10px rgba(34, 211, 238, 0.7);
}
.thinking-copy {
min-width: 0;
flex: 1;
}
.thinking-title {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--accent-cyan);
}
.thinking-subtitle {
margin-top: 3px;
font-size: 12px;
color: var(--text-dim);
}
.typing .dot,
.typing-inline .dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-cyan);
margin: 0 2px;
animation: typing-bounce 1.2s ease-in-out infinite;
}
.typing-inline {
flex-shrink: 0;
white-space: nowrap;
}
.typing .dot:nth-child(2),
.typing-inline .dot:nth-child(2) { animation-delay: 0.2s; }
.typing .dot:nth-child(3),
.typing-inline .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
@keyframes chat-scan-sweep {
0% { transform: translateY(-100%); opacity: 0; }
12% { opacity: 0.38; }
50% { opacity: 0.62; }
88% { opacity: 0.38; }
100% { transform: translateY(100%); opacity: 0; }
}
@keyframes chat-scan-drift {
0% { transform: translateY(0); }
100% { transform: translateY(12px); }
}
/* ── Input Area ── */
.input-area {
padding: 16px 24px 20px;
border-top: 1px solid var(--border-dim);
background: rgba(5, 8, 16, 0.8);
backdrop-filter: blur(12px);
}
.input-frame {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 12px 16px;
display: flex;
align-items: flex-end;
gap: 12px;
transition: all var(--transition-mid);
}
.input-frame:focus-within {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1), var(--glow-cyan);
}
/* Corner accents */
.input-corners {
position: absolute;
width: 8px;
height: 8px;
pointer-events: none;
}
.input-corners::before,
.input-corners::after {
content: '';
position: absolute;
background: var(--accent-cyan);
}
.input-corners::before { width: 100%; height: 1px; }
.input-corners::after { width: 1px; height: 100%; }
.tl { top: -1px; left: -1px; }
.tr { top: -1px; right: -1px; transform: scaleX(-1); }
.bl { bottom: -1px; left: -1px; transform: scaleY(-1); }
.br { bottom: -1px; right: -1px; transform: scale(-1); }
.input-frame textarea {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
resize: none;
max-height: 120px;
padding: 8px 0;
vertical-align: middle;
overflow: hidden;
}
.input-frame textarea::placeholder { color: var(--text-dim); }
.send-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--text-muted);
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
transition: all var(--transition-fast);
}
.send-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
.send-btn.active:hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
transform: scale(1.05);
}
.enter-hint { display: none; }
.input-hints {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-left: 4px;
}
.hint-item {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.hint-sep {
color: var(--text-muted);
font-size: 9px;
}
/* File attachment button */
.attach-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.attach-btn:hover {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Emoji button */
.emoji-wrapper {
position: relative;
}
.emoji-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.emoji-btn:hover,
.emoji-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Message attachments */
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
/* ── JARVIS SIDEBAR STYLES ── */
.jarvis-sidebar {
background: rgba(4, 10, 20, 0.95) !important;
padding: 10px !important;
gap: 12px;
}
.jarvis-panel {
background: rgba(0, 20, 40, 0.5);
border: 1px solid rgba(0, 243, 255, 0.15);
padding: 12px;
margin-bottom: 12px;
position: relative;
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
}
.jarvis-panel::before {
content: '';
position: absolute;
top: 0; left: 0; width: 6px; height: 6px;
border-left: 1px solid #00f3ff; border-top: 1px solid #00f3ff;
}
.jarvis-date-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.jarvis-date-num {
font-family: var(--font-display);
font-size: 32px;
font-weight: 800;
color: #00f3ff;
text-shadow: 0 0 10px rgba(0, 243, 255, 0.5);
}
.jarvis-month {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.6;
letter-spacing: 0.1em;
}
.jarvis-time {
font-family: var(--font-mono);
font-size: 14px;
color: #c8f7ff;
}
.jarvis-calendar {
border-top: 1px solid rgba(0, 243, 255, 0.1);
padding-top: 8px;
}
.calendar-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
font-size: 9px;
text-align: center;
opacity: 0.4;
margin-bottom: 4px;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-family: var(--font-mono);
font-size: 9px;
text-align: center;
}
.calendar-day {
padding: 3px 0;
border-radius: 2px;
color: rgba(0, 243, 255, 0.4);
}
.calendar-day.active {
background: #00f3ff;
color: #000;
font-weight: 800;
box-shadow: 0 0 10px #00f3ff;
}
.jarvis-section-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.2em;
color: rgba(0, 243, 255, 0.6);
margin-bottom: 8px;
padding-left: 4px;
}
.jarvis-conv-list {
padding: 0 !important;
margin-bottom: 10px;
}
.jarvis-session-item {
background: rgba(0, 243, 255, 0.03) !important;
border-left: 2px solid transparent !important;
border-bottom: 1px solid rgba(0, 243, 255, 0.05) !important;
margin-bottom: 4px !important;
border-radius: 0 !important;
transition: all 0.3s;
}
.jarvis-session-item:hover {
background: rgba(0, 243, 255, 0.08) !important;
padding-left: 14px !important;
}
.jarvis-session-item.active {
background: rgba(0, 243, 255, 0.12) !important;
border-left-color: #00f3ff !important;
box-shadow: inset 4px 0 15px rgba(0, 243, 255, 0.05);
}
.jarvis-text-glow {
text-shadow: 0 0 8px rgba(0, 243, 255, 0.4);
}
.jarvis-section-title {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.15em;
color: #00f3ff;
margin-bottom: 10px;
border-bottom: 1px solid rgba(0, 243, 255, 0.1);
padding-bottom: 4px;
}
.jarvis-progress-item {
margin-bottom: 6px;
}
.jarvis-progress-label {
display: flex;
justify-content: space-between;
font-size: 9px;
font-family: var(--font-mono);
opacity: 0.7;
margin-bottom: 4px;
}
.jarvis-progress-bar {
height: 3px;
background: rgba(0, 243, 255, 0.1);
width: 100%;
}
.jarvis-progress-fill {
height: 100%;
background: #00f3ff;
box-shadow: 0 0 10px #00f3ff;
}
.jarvis-plan-list {
list-style: none;
font-size: 10px;
}
.jarvis-plan-item {
display: flex;
gap: 8px;
padding: 4px 0;
opacity: 0.8;
}
.jarvis-plan-item .num {
color: #00f3ff;
font-weight: bold;
}
.jarvis-new-btn {
background: rgba(0, 243, 255, 0.1) !important;
border: 1px solid rgba(0, 243, 255, 0.3) !important;
color: #00f3ff !important;
font-family: var(--font-display);
height: 36px;
box-shadow: 0 0 15px rgba(0, 243, 255, 0.05);
}
.jarvis-new-btn:hover {
background: rgba(0, 243, 255, 0.2) !important;
box-shadow: 0 0 20px rgba(0, 243, 255, 0.15);
}
.conv-sidebar-footer {
border-top: 1px solid rgba(0, 243, 255, 0.1) !important;
}
/* ── COMMANDER CARDS ── */
.jarvis-commander-grid {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.commander-card {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
background: rgba(0, 243, 255, 0.02);
border: 1px solid rgba(0, 243, 255, 0.1);
color: #00f3ff;
text-align: left;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
border-radius: 2px;
}
.commander-card:hover {
background: rgba(0, 243, 255, 0.08);
border-color: rgba(0, 243, 255, 0.4);
box-shadow: 0 0 20px rgba(0, 243, 255, 0.1);
transform: translateY(-2px);
}
/* Internal Glow */
.commander-glow {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, rgba(0, 243, 255, 0.15), transparent 70%);
opacity: 0;
transition: opacity 0.4s;
}
.commander-card:hover .commander-glow { opacity: 1; }
/* Scanning Line */
.commander-scan {
position: absolute;
top: 0; left: -100%; width: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(0, 243, 255, 0.1), transparent);
transition: none;
}
.commander-card:hover .commander-scan {
animation: commander-scan-move 1.5s infinite;
}
@keyframes commander-scan-move {
0% { left: -100%; }
100% { left: 100%; }
}
/* Icon Box */
.commander-icon-box {
width: 38px; height: 38px;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 243, 255, 0.05);
border: 1px solid rgba(0, 243, 255, 0.2);
position: relative;
z-index: 1;
}
.commander-info { position: relative; z-index: 1; flex: 1; }
.commander-title { font-size: 14px; font-weight: 700; letter-spacing: 0.02em; margin-bottom: 2px; }
.commander-status { font-family: var(--font-mono); font-size: 8px; opacity: 0.5; letter-spacing: 0.15em; }
/* Corner Accents */
.commander-corner {
position: absolute; width: 4px; height: 4px;
border: 1px solid #00f3ff;
}
.commander-corner.top-r { top: 0; right: 0; border-left: none; border-bottom: none; }
.commander-corner.bottom-l { bottom: 0; left: 0; border-right: none; border-top: none; }
/* Theme Colors */
.commander-card.schedule { color: #fbbf24; border-color: rgba(245, 158, 11, 0.1); }
.commander-card.schedule:hover { border-color: rgba(245, 158, 11, 0.4); background: rgba(245, 158, 11, 0.08); }
.commander-card.schedule .commander-icon-box { background: rgba(245, 158, 11, 0.05); border-color: rgba(245, 158, 11, 0.2); }
.commander-card.schedule .commander-corner { border-color: #fbbf24; }
.commander-card.schedule .commander-glow { background: radial-gradient(circle at center, rgba(245, 158, 11, 0.15), transparent 70%); }
.commander-card.code { color: #a78bfa; border-color: rgba(167, 139, 250, 0.1); }
.commander-card.code:hover { border-color: rgba(167, 139, 250, 0.4); background: rgba(167, 139, 250, 0.08); }
.commander-card.code .commander-icon-box { background: rgba(167, 139, 250, 0.05); border-color: rgba(167, 139, 250, 0.2); }
.commander-card.code .commander-corner { border-color: #a78bfa; }
.commander-card.code .commander-glow { background: radial-gradient(circle at center, rgba(167, 139, 250, 0.15), transparent 70%); }
/* ── RSS FEED ── */
.jarvis-rss-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.rss-item {
font-family: var(--font-mono);
font-size: 9px;
line-height: 1.4;
color: rgba(200, 247, 255, 0.7);
padding: 4px;
border-left: 1px solid rgba(0, 243, 255, 0.2);
transition: color 0.2s;
}
.rss-item:hover {
color: #00f3ff;
background: rgba(0, 243, 255, 0.05);
}
.limited-height {
max-height: 120px;
overflow-y: auto;
}
/* Sidebar overrides */
.jarvis-sidebar {
background: #f3f6fb !important;
padding: 0 !important;
border-right-color: rgba(15, 23, 42, 0.08);
}
.jarvis-sidebar-scroll {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 12px;
overflow-y: auto;
min-height: 0;
}
.jarvis-sidebar .jarvis-panel {
background: rgba(255, 255, 255, 0.94);
border: 1px solid #e6ebf2;
border-radius: 16px;
padding: 16px 14px;
margin-bottom: 0;
position: relative;
overflow: hidden;
clip-path: none;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
}
.jarvis-sidebar .jarvis-panel::before {
display: none;
}
.jarvis-date-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.jarvis-date-num {
min-width: 62px;
font-family: var(--font-display);
font-size: 42px;
line-height: 1;
font-weight: 800;
color: #0f172a;
text-shadow: none;
}
.jarvis-date-meta {
min-width: 0;
}
.jarvis-month {
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
color: #1f2937;
}
.jarvis-time {
margin-top: 4px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
color: #64748b;
}
.jarvis-calendar {
border-top: 1px solid #edf2f7;
padding-top: 12px;
}
.calendar-header,
.calendar-grid {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.calendar-header {
display: grid;
gap: 6px;
margin-bottom: 6px;
font-family: var(--font-mono);
font-size: 10px;
text-align: center;
color: #94a3b8;
}
.calendar-grid {
display: grid;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
text-align: center;
}
.calendar-day {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
border-radius: 10px;
border: 1px solid transparent;
background: #f8fafc;
color: #64748b;
}
.calendar-day.muted {
background: transparent;
color: transparent;
border-color: transparent;
}
.calendar-day.busy::after {
content: '';
position: absolute;
bottom: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #2563eb;
}
.calendar-day.active {
color: #2563eb;
background: #eaf4ff;
border-color: #bfdcff;
font-weight: 700;
}
.calendar-day.active.busy::after {
background: #2563eb;
}
.jarvis-action-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 14px;
}
.jarvis-action-chip {
padding: 8px 10px;
border: 1px solid #d8e2ef;
border-radius: 12px;
background: #f8fafc;
color: #475569;
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.jarvis-action-chip:hover {
background: #eef6ff;
border-color: #bfdbfe;
color: #2563eb;
}
.jarvis-action-chip.schedule {
color: #0369a1;
}
.jarvis-action-chip.code {
color: #7c3aed;
}
.jarvis-section-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding-bottom: 0;
border-bottom: none;
font-family: var(--font-body);
font-size: 16px;
font-weight: 700;
letter-spacing: 0.01em;
color: #0f172a;
}
.jarvis-section-title::before {
content: '';
width: 4px;
height: 16px;
border-radius: 999px;
background: #60a5fa;
box-shadow: none;
}
.jarvis-status-shell {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 14px;
align-items: center;
}
.jarvis-progress-ring {
--completion: 0%;
position: relative;
width: 88px;
height: 88px;
border-radius: 50%;
background: conic-gradient(#60a5fa var(--completion), #e5edf8 0);
display: flex;
align-items: center;
justify-content: center;
}
.jarvis-progress-ring::before {
content: '';
position: absolute;
inset: 8px;
border-radius: 50%;
background: #ffffff;
border: 1px solid #edf2f7;
}
.jarvis-progress-core {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.jarvis-progress-core strong {
font-family: var(--font-display);
font-size: 20px;
color: #0f172a;
}
.jarvis-progress-core span {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: #94a3b8;
}
.jarvis-status-copy {
min-width: 0;
}
.jarvis-status-headline {
margin-bottom: 10px;
font-size: 13px;
line-height: 1.6;
color: #475569;
}
.jarvis-status-list,
.jarvis-focus-list,
.jarvis-review-list {
list-style: none;
margin: 0;
padding: 0;
}
.jarvis-status-list {
display: grid;
gap: 8px;
}
.jarvis-status-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
font-size: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.done { background: #22c55e; }
.status-dot.doing { background: #f59e0b; }
.status-dot.pending { background: #ef4444; }
.status-label {
color: #64748b;
}
.status-value {
font-family: var(--font-mono);
color: #0f172a;
}
.jarvis-focus-list {
display: grid;
gap: 10px;
}
.jarvis-focus-item {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 10px;
padding: 11px 12px;
border-radius: 14px;
border: 1px solid #e8edf4;
background: #f8fafc;
}
.jarvis-focus-item.is-doing { border-color: #fde68a; }
.jarvis-focus-item.is-pending { border-color: #fecdd3; }
.jarvis-focus-item.is-done { border-color: #bbf7d0; }
.focus-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 10px;
background: #eff6ff;
color: #2563eb;
font-family: var(--font-mono);
font-size: 10px;
}
.focus-copy {
min-width: 0;
}
.focus-label {
margin-bottom: 4px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: #94a3b8;
text-transform: uppercase;
}
.focus-title {
font-size: 13px;
line-height: 1.45;
color: #0f172a;
}
.focus-meta {
margin-top: 4px;
font-size: 11px;
color: #64748b;
}
.jarvis-review-group + .jarvis-review-group {
margin-top: 14px;
}
.jarvis-review-subtitle {
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
color: #334155;
}
.jarvis-review-list {
display: grid;
gap: 8px;
}
.jarvis-review-item {
position: relative;
padding-left: 14px;
font-size: 12px;
line-height: 1.6;
color: #475569;
}
.jarvis-review-item::before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #60a5fa;
}
.jarvis-review-list.reflection .jarvis-review-item::before {
background: #f59e0b;
}
.jarvis-rss-list {
display: grid;
gap: 10px;
}
.jarvis-news-card {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid #e8edf4;
background: #f8fafc;
}
.jarvis-news-meta {
margin-bottom: 6px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: #94a3b8;
text-transform: uppercase;
}
.jarvis-news-title {
font-size: 12px;
line-height: 1.6;
color: #0f172a;
}
.jarvis-empty-state {
padding: 12px;
border-radius: 14px;
border: 1px dashed #d8e2ef;
background: #f8fafc;
font-size: 12px;
line-height: 1.6;
color: #64748b;
}
@media (max-width: 960px) {
.jarvis-sidebar-scroll {
max-height: 220px;
}
}
</style>