Files
JARVIS/frontend/src/pages/knowledge/index.vue
DESKTOP-72TV0V4\caoxiaozhu 7d80a6e2ec Add brain and chat workspace views
Expand the frontend with brain, graph, and chat workspace updates so the
new backend orchestration and memory features have matching screens.
These changes also wire the new APIs into routing and add focused view
and routing tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 13:48:16 +08:00

1657 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 {
ArrowLeft,
Database,
FileText,
Folder,
FolderPlus,
Loader,
Trash2,
Upload,
X,
} from 'lucide-vue-next'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
const {
documents,
currentFolderId,
uploadError,
uploadSuccess,
highlightedDocumentId,
uploadInput,
showNewFolderDialog,
newFolderName,
newFolderParentId,
showRenameDialog,
renameFolderName,
showDeleteDialog,
deletingFolder,
showDocumentDialog,
activeDocument,
activeDocumentContent,
isLoadingDocumentContent,
activeDocumentChunks,
isLoadingDocumentChunks,
chunkDrafts,
chunkEditing,
chunkSaving,
chunkEditError,
isRoot,
visibleFolders,
breadcrumbs,
explorerTitle,
enterFolder,
goToFolder,
goBack,
triggerUpload,
handleUpload,
handleDeleteDocument,
openNewFolderDialog,
createFolder,
openRenameDialog,
renameFolder,
openDeleteDialog,
deleteFolder,
openDocument,
closeDocumentDialog,
startChunkEdit,
cancelChunkEdit,
saveChunkEdit,
getFileTypeColor,
formatFileSize,
formatDate,
getStatusLabel,
} = useKnowledgeView()
</script>
<template>
<div class="knowledge-view grid-bg scanlines">
<div class="page-header">
<div class="header-left">
<div class="header-icon"><Database :size="20" /></div>
<div class="header-text">
<h1>KNOWLEDGE BASE</h1>
<span class="header-sub">{{ explorerTitle }}</span>
</div>
</div>
<div class="header-actions">
<button class="btn" @click="openNewFolderDialog(currentFolderId)">
<FolderPlus :size="14" />
{{ isRoot ? '新建文件夹' : '新建子文件夹' }}
</button>
<button v-if="!isRoot" class="btn primary" @click="triggerUpload">
<Upload :size="14" /> 上传文件
</button>
</div>
</div>
<input
ref="uploadInput"
type="file"
class="hidden-upload"
accept=".pdf,.md,.txt,.doc,.docx,.csv,.xlsx"
@change="handleUpload"
/>
<div class="explorer-shell holo-card">
<div class="toolbar">
<div class="toolbar-left">
<button class="nav-btn" :disabled="isRoot" @click="goBack">
<ArrowLeft :size="14" />
</button>
<div class="breadcrumbs">
<button
v-for="(crumb, index) in breadcrumbs"
:key="`${crumb.id ?? 'root'}-${index}`"
class="breadcrumb-item"
:class="{ active: crumb.id === currentFolderId || (crumb.id === null && isRoot) }"
@click="goToFolder(crumb.id)"
>
{{ crumb.name }}
</button>
</div>
</div>
<div class="toolbar-right">
<span class="location-tag">{{ isRoot ? 'ROOT' : 'FOLDER' }}</span>
</div>
</div>
<div v-if="uploadError" class="upload-error">
{{ uploadError }}
</div>
<div v-if="uploadSuccess" class="upload-success">
{{ uploadSuccess }}
</div>
<div class="explorer-grid">
<template v-if="visibleFolders.length || documents.length">
<button
v-for="folder in visibleFolders"
:key="folder.id"
class="explorer-tile folder-tile"
@click="enterFolder(folder)"
>
<div class="folder-activate-flash"></div>
<div class="tile-frame"></div>
<div class="folder-tech-corners">
<span class="corner corner-tl"></span>
<span class="corner corner-tr"></span>
<span class="corner corner-bl"></span>
<span class="corner corner-br"></span>
</div>
<div class="folder-scan"></div>
<div class="folder-grid-lines"></div>
<div class="tile-actions" @click.stop>
<button class="icon-btn" title="重命名文件夹" @click="openRenameDialog(folder)">
<FileText :size="12" />
</button>
<button class="icon-btn danger" title="删除文件夹" @click="openDeleteDialog(folder)">
<Trash2 :size="12" />
</button>
</div>
<div class="folder-glyph">
<div class="folder-pulse-ring"></div>
<div class="folder-core">
<Folder :size="42" />
</div>
<span class="folder-beam beam-a"></span>
<span class="folder-beam beam-b"></span>
</div>
<div class="folder-label-bar">
<div class="tile-name folder-title-name">{{ folder.name }}</div>
</div>
<div class="tile-meta folder-meta">{{ folder.children?.length ?? 0 }} 个子文件夹</div>
</button>
<button
v-for="doc in documents"
:key="doc.id"
class="explorer-tile file-tile"
:class="{ 'upload-highlight': highlightedDocumentId === doc.id }"
@click="openDocument(doc)"
>
<div class="tile-frame"></div>
<div class="tile-actions" @click.stop>
<button class="icon-btn danger" title="删除文件" @click="handleDeleteDocument(doc.id)">
<Trash2 :size="12" />
</button>
</div>
<div class="file-badge" :style="{ color: getFileTypeColor(doc.file_type), borderColor: `${getFileTypeColor(doc.file_type)}55` }">
{{ doc.file_type.toUpperCase() }}
</div>
<div class="file-icon" :style="{ color: getFileTypeColor(doc.file_type) }">
<FileText :size="38" />
</div>
<div class="tile-name">{{ doc.title }}</div>
<div class="tile-meta">{{ formatFileSize(doc.file_size) }} · {{ formatDate(doc.created_at) }}</div>
<div class="tile-status-row">
<div class="tile-status" :class="(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded')).toLowerCase()">
{{ getStatusLabel(doc.ingestion_status, doc.is_indexed) }}
</div>
<Loader
v-if="['uploaded', 'parsing', 'indexing'].includes(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded'))"
:size="12"
class="spin tile-inline-loader"
/>
</div>
<div v-if="doc.ingestion_error" class="tile-warning">{{ doc.ingestion_error }}</div>
</button>
</template>
<div v-else class="empty-state">
<div class="empty-core-shell">
<div class="empty-orbit orbit-outer"></div>
<div class="empty-orbit orbit-inner"></div>
<div class="empty-scanline"></div>
<div class="empty-core-glow"></div>
<div class="empty-folder-chamber">
<div class="empty-folder-plate"></div>
<div class="empty-folder-pulse"></div>
<div class="empty-folder-icon-wrap">
<Folder :size="54" />
</div>
<span class="empty-beam beam-left"></span>
<span class="empty-beam beam-right"></span>
</div>
</div>
<div class="empty-copy">
<span class="empty-kicker">{{ isRoot ? 'ARCHIVE CORE ONLINE' : 'FOLDER CHAMBER STANDBY' }}</span>
<span class="empty-title">{{ isRoot ? 'ROOT DIRECTORY EMPTY' : 'FOLDER IS READY FOR DEPLOYMENT' }}</span>
<span class="empty-sub">
{{ isRoot ? '当前知识库尚未初始化目录结构,请先创建第一个文件夹。' : '当前目录为空,可在此新建子文件夹或上传文件。' }}
</span>
</div>
<div class="empty-actions">
<button class="btn primary empty-cta" @click="openNewFolderDialog(currentFolderId)">
<FolderPlus :size="14" />
{{ isRoot ? '初始化知识仓库' : '新建子文件夹' }}
</button>
<button v-if="!isRoot" class="btn" @click="triggerUpload">
<Upload :size="14" /> 上传文件
</button>
</div>
</div>
</div>
</div>
<div v-if="showNewFolderDialog" class="dialog-overlay" @click.self="showNewFolderDialog = false">
<div class="dialog">
<div class="dialog-header">
<h3>{{ isRoot && newFolderParentId === null ? '新建文件夹' : '新建子文件夹' }}</h3>
<button class="close-btn" @click="showNewFolderDialog = false">
<X :size="16" />
</button>
</div>
<input
v-model="newFolderName"
class="dialog-input"
placeholder="文件夹名称"
@keyup.enter="createFolder"
autofocus
/>
<div class="dialog-actions">
<button class="btn" @click="showNewFolderDialog = false">取消</button>
<button class="btn primary" @click="createFolder">创建</button>
</div>
</div>
</div>
<div v-if="showRenameDialog" class="dialog-overlay" @click.self="showRenameDialog = false">
<div class="dialog">
<div class="dialog-header">
<h3>重命名文件夹</h3>
<button class="close-btn" @click="showRenameDialog = false">
<X :size="16" />
</button>
</div>
<input
v-model="renameFolderName"
class="dialog-input"
placeholder="文件夹名称"
@keyup.enter="renameFolder"
autofocus
/>
<div class="dialog-actions">
<button class="btn" @click="showRenameDialog = false">取消</button>
<button class="btn primary" @click="renameFolder">重命名</button>
</div>
</div>
</div>
<div v-if="showDeleteDialog" class="dialog-overlay" @click.self="showDeleteDialog = false">
<div class="dialog">
<div class="dialog-header">
<h3>删除文件夹</h3>
<button class="close-btn" @click="showDeleteDialog = false">
<X :size="16" />
</button>
</div>
<p class="dialog-body">
确定要删除文件夹{{ deletingFolder?.name }}该操作不可恢复
</p>
<div class="dialog-actions">
<button class="btn" @click="showDeleteDialog = false">取消</button>
<button class="btn danger" @click="deleteFolder">删除</button>
</div>
</div>
</div>
<div v-if="showDocumentDialog && activeDocument" class="dialog-overlay" @click.self="closeDocumentDialog()">
<div class="dialog document-dialog">
<div class="dialog-header">
<h3>{{ activeDocument.title }}</h3>
<button class="close-btn" @click="closeDocumentDialog()">
<X :size="16" />
</button>
</div>
<div class="document-info-row">
<span class="file-badge" :style="{ color: getFileTypeColor(activeDocument.file_type), borderColor: `${getFileTypeColor(activeDocument.file_type)}55` }">
{{ activeDocument.file_type.toUpperCase() }}
</span>
<span>{{ formatFileSize(activeDocument.file_size) }}</span>
<span class="doc-chunk-count">{{ activeDocument.chunk_count }} 个知识切片</span>
<span class="doc-status-pill">{{ getStatusLabel(activeDocument.ingestion_status, activeDocument.is_indexed) }}</span>
</div>
<div v-if="activeDocument.ingestion_error" class="upload-error">
{{ activeDocument.ingestion_error }}
</div>
<div class="document-content-grid">
<div class="document-preview">
<div v-if="isLoadingDocumentContent" class="preview-loading">
<Loader :size="16" class="spin" />
<span>加载文档内容中...</span>
</div>
<pre v-else>{{ activeDocumentContent || '暂无可预览内容' }}</pre>
</div>
<div class="chunk-panel">
<div class="chunk-panel-header">
<div>
<div class="chunk-panel-title">知识切片</div>
<div class="chunk-panel-subtitle">当前已加载 {{ activeDocumentChunks.length }} / {{ activeDocument.chunk_count }} 个切片</div>
</div>
<span class="chunk-count-badge">{{ activeDocumentChunks.length }}</span>
</div>
<div v-if="isLoadingDocumentChunks" class="chunk-loading-state">
<div class="chunk-loading-bar"></div>
<span>正在读取切片内容...</span>
</div>
<div v-else-if="activeDocumentChunks.length" class="chunk-list">
<div
v-for="chunk in activeDocumentChunks"
:key="chunk.id"
class="chunk-card"
:class="{ editing: chunkEditing[chunk.id] }"
>
<div class="chunk-card-header">
<div class="chunk-card-meta">
<span class="chunk-index">切片 #{{ chunk.chunk_index + 1 }}</span>
<span class="chunk-size">{{ chunk.content.length }} 字符</span>
<span v-if="chunk.metadata_" class="chunk-meta-raw">{{ chunk.metadata_ }}</span>
</div>
<button
v-if="!chunkEditing[chunk.id]"
class="btn"
@click="startChunkEdit(chunk)"
>
编辑
</button>
</div>
<pre v-if="!chunkEditing[chunk.id]" class="chunk-content">{{ chunk.content }}</pre>
<div v-else class="chunk-edit-form">
<textarea
v-model="chunkDrafts[chunk.id]"
class="chunk-textarea"
rows="7"
></textarea>
<div v-if="chunkEditError[chunk.id]" class="upload-error chunk-error">
{{ chunkEditError[chunk.id] }}
</div>
<div class="chunk-actions">
<button class="btn" :disabled="chunkSaving[chunk.id]" @click="cancelChunkEdit(chunk.id)">
取消
</button>
<button class="btn primary" :disabled="chunkSaving[chunk.id]" @click="saveChunkEdit(chunk.id)">
<Loader v-if="chunkSaving[chunk.id]" :size="12" class="spin" />
{{ chunkSaving[chunk.id] ? '保存中' : '保存' }}
</button>
</div>
</div>
</div>
</div>
<div v-else class="chunk-empty">暂无切片数据</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.knowledge-view {
height: 100%;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
color: var(--accent-cyan);
filter: drop-shadow(0 0 8px var(--accent-cyan));
}
h1 {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-primary);
margin: 0;
}
.header-sub {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.hidden-upload {
display: none;
}
.explorer-shell {
min-height: 620px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(0, 245, 212, 0.06), rgba(0, 245, 212, 0.02));
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.nav-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 245, 212, 0.08);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.breadcrumb-item {
padding: 6px 10px;
border-radius: 999px;
background: transparent;
border: 1px solid transparent;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
.breadcrumb-item:hover,
.breadcrumb-item.active {
color: var(--accent-cyan);
border-color: var(--border-mid);
background: rgba(0, 245, 212, 0.08);
}
.location-tag {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.08);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
}
.upload-error,
.upload-success {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 11px;
}
.upload-error {
color: var(--accent-red);
background: rgba(255, 71, 87, 0.08);
border: 1px solid rgba(255, 71, 87, 0.2);
}
.upload-success {
color: var(--accent-green);
background: rgba(52, 211, 153, 0.08);
border: 1px solid rgba(52, 211, 153, 0.24);
}
.explorer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(196px, 1fr));
gap: 18px;
align-content: start;
}
.explorer-tile {
position: relative;
min-height: 220px;
padding: 18px 16px 16px;
border-radius: 20px;
border: 1px solid var(--border-dim);
background:
radial-gradient(circle at top, rgba(0, 245, 212, 0.08), transparent 45%),
linear-gradient(180deg, rgba(10, 15, 26, 0.92), rgba(13, 21, 37, 0.98));
color: var(--text-primary);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
overflow: hidden;
}
.explorer-tile:hover {
border-color: var(--border-bright);
box-shadow: var(--glow-cyan);
transform: translateY(-2px);
}
.upload-highlight {
border-color: rgba(52, 211, 153, 0.8);
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.35), 0 0 28px rgba(52, 211, 153, 0.25);
}
.tile-frame {
position: absolute;
inset: 10px;
border: 1px solid rgba(0, 245, 212, 0.08);
border-radius: 16px;
pointer-events: none;
}
.tile-actions {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.explorer-tile:hover .tile-actions {
opacity: 1;
}
.icon-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(0, 9, 19, 0.78);
border: 1px solid var(--border-dim);
color: var(--text-secondary);
}
.icon-btn:hover {
color: var(--accent-cyan);
border-color: var(--border-mid);
background: rgba(0, 245, 212, 0.1);
}
.icon-btn.danger:hover {
color: var(--accent-red);
border-color: rgba(255, 71, 87, 0.3);
background: rgba(255, 71, 87, 0.12);
}
.folder-tile {
text-align: center;
}
.folder-activate-flash,
.folder-tech-corners,
.folder-scan,
.folder-grid-lines {
position: absolute;
inset: 0;
pointer-events: none;
}
.folder-activate-flash {
opacity: 0;
background: radial-gradient(circle at center, rgba(124, 230, 255, 0.34), rgba(0, 245, 212, 0.14) 35%, transparent 68%);
}
.folder-tech-corners {
z-index: 1;
}
.folder-tech-corners .corner {
position: absolute;
width: 26px;
height: 26px;
border-color: rgba(0, 245, 212, 0.22);
border-style: solid;
opacity: 0.75;
transition: all var(--transition-mid);
}
.folder-tech-corners .corner-tl {
top: 9px;
left: 9px;
border-width: 2px 0 0 2px;
border-top-left-radius: 10px;
}
.folder-tech-corners .corner-tr {
top: 9px;
right: 9px;
border-width: 2px 2px 0 0;
border-top-right-radius: 10px;
}
.folder-tech-corners .corner-bl {
bottom: 9px;
left: 9px;
border-width: 0 0 2px 2px;
border-bottom-left-radius: 10px;
}
.folder-tech-corners .corner-br {
bottom: 9px;
right: 9px;
border-width: 0 2px 2px 0;
border-bottom-right-radius: 10px;
}
.folder-scan,
.folder-grid-lines {
opacity: 0;
transition: opacity var(--transition-mid);
}
.folder-scan {
background: linear-gradient(
115deg,
transparent 24%,
rgba(0, 245, 212, 0.04) 40%,
rgba(124, 230, 255, 0.18) 50%,
rgba(0, 245, 212, 0.04) 60%,
transparent 76%
);
transform: translateX(-130%);
}
.folder-grid-lines {
background:
linear-gradient(rgba(0, 245, 212, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 245, 212, 0.05) 1px, transparent 1px);
background-size: 18px 18px;
mix-blend-mode: screen;
}
.folder-tile:hover .folder-scan,
.folder-tile:hover .folder-grid-lines {
opacity: 1;
}
.folder-tile:hover .folder-tech-corners .corner {
border-color: rgba(124, 230, 255, 0.7);
box-shadow: 0 0 10px rgba(0, 245, 212, 0.18);
}
.folder-tile:hover .corner-tl,
.folder-tile:hover .corner-br {
transform: translate(1px, 1px);
}
.folder-tile:hover .corner-tr,
.folder-tile:hover .corner-bl {
transform: translate(-1px, -1px);
}
.folder-tile:active .folder-activate-flash {
animation: activate-flash 320ms ease-out;
}
.folder-tile:hover .folder-scan {
animation: hologram-scan 1.4s ease-out forwards;
}
.folder-glyph {
position: relative;
margin-top: 22px;
width: 100px;
height: 94px;
display: flex;
align-items: center;
justify-content: center;
animation: folder-float 4.8s ease-in-out infinite;
}
.folder-pulse-ring {
position: absolute;
width: 108px;
height: 108px;
border-radius: 50%;
border: 1px solid rgba(0, 245, 212, 0.16);
box-shadow: 0 0 18px rgba(0, 245, 212, 0.08);
animation: pulse-ring 3.2s ease-in-out infinite;
}
.folder-core {
width: 92px;
height: 78px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 22px;
color: #79d4ff;
background: linear-gradient(180deg, rgba(0, 245, 212, 0.16), rgba(123, 44, 191, 0.12));
border: 1px solid rgba(0, 245, 212, 0.22);
box-shadow:
inset 0 0 18px rgba(0, 245, 212, 0.08),
0 0 18px rgba(0, 245, 212, 0.12);
animation: core-breathe 3.4s ease-in-out infinite;
}
.folder-core svg {
filter: drop-shadow(0 0 6px rgba(124, 230, 255, 0.35));
}
.folder-beam {
position: absolute;
display: block;
width: 70px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.85), transparent);
animation: beam-flicker 2.6s ease-in-out infinite;
}
.beam-a {
top: 18px;
left: -8px;
}
.beam-b {
right: -8px;
bottom: 20px;
}
.file-tile {
text-align: center;
}
.folder-label-bar {
width: 100%;
display: flex;
align-items: center;
min-height: 34px;
padding: 8px 2px 0;
border-top: 1px solid rgba(0, 245, 212, 0.1);
position: relative;
}
.folder-label-bar::before {
content: '';
position: absolute;
top: -1px;
left: 2px;
width: 42px;
height: 1px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.85), transparent);
}
.folder-title-name {
text-align: left;
}
.folder-meta {
color: #84c7d6;
}
.folder-tile:hover .folder-label-bar {
border-top-color: rgba(124, 230, 255, 0.24);
}
.folder-tile:hover .folder-label-bar::before {
width: 64px;
background: linear-gradient(90deg, rgba(124, 230, 255, 0.95), transparent);
}
.folder-tile:hover .folder-title-name {
color: #f2fbff;
}
.file-badge {
margin-top: 8px;
padding: 3px 8px;
border: 1px solid;
border-radius: 999px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.12em;
}
.file-icon {
margin-top: 22px;
min-height: 88px;
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 0 10px currentColor);
}
.tile-name {
width: 100%;
font-size: 13px;
line-height: 1.45;
color: var(--text-primary);
word-break: break-word;
}
.tile-meta {
width: 100%;
min-height: 30px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.5;
}
.tile-status-row {
margin-top: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.tile-status {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.12em;
}
.tile-inline-loader {
color: var(--accent-cyan);
}
.ready {
color: var(--accent-green);
}
.uploaded,
.parsing,
.indexing {
color: var(--accent-amber);
}
.warning {
color: var(--accent-purple);
}
.failed {
color: var(--accent-red);
}
.tile-warning {
width: 100%;
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.4;
word-break: break-word;
}
.doc-chunk-count {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.08em;
}
.doc-status-pill {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.08);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
}
.empty-state {
grid-column: 1 / -1;
min-height: 440px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 40px 24px;
border: 1px solid rgba(0, 245, 212, 0.12);
border-radius: 24px;
background:
radial-gradient(circle at center, rgba(0, 245, 212, 0.08), transparent 42%),
linear-gradient(180deg, rgba(8, 13, 24, 0.96), rgba(4, 8, 16, 0.98));
position: relative;
overflow: hidden;
}
.empty-state::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.05), transparent),
radial-gradient(circle at top, rgba(123, 44, 191, 0.14), transparent 32%);
pointer-events: none;
}
.empty-core-shell {
position: relative;
width: 250px;
height: 250px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-orbit {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(0, 245, 212, 0.12);
box-shadow: inset 0 0 18px rgba(0, 245, 212, 0.04);
}
.orbit-outer {
width: 250px;
height: 250px;
}
.orbit-inner {
width: 184px;
height: 184px;
border-color: rgba(0, 245, 212, 0.18);
}
.empty-scanline {
position: absolute;
width: 180px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.9), transparent);
box-shadow: 0 0 12px rgba(0, 245, 212, 0.5);
animation: float 3s ease-in-out infinite;
}
.empty-core-glow {
position: absolute;
width: 126px;
height: 126px;
border-radius: 50%;
background: radial-gradient(circle, rgba(0, 245, 212, 0.16), rgba(123, 44, 191, 0.08), transparent 70%);
filter: blur(6px);
}
.empty-folder-chamber {
position: relative;
width: 124px;
height: 124px;
display: flex;
align-items: center;
justify-content: center;
animation: folder-float 5.4s ease-in-out infinite;
}
.empty-folder-plate {
position: absolute;
inset: 14px;
border-radius: 28px;
border: 1px solid rgba(0, 245, 212, 0.22);
background: linear-gradient(180deg, rgba(0, 245, 212, 0.14), rgba(123, 44, 191, 0.14));
box-shadow:
inset 0 0 24px rgba(0, 245, 212, 0.08),
0 0 32px rgba(0, 245, 212, 0.12);
}
.empty-folder-pulse {
position: absolute;
width: 138px;
height: 138px;
border-radius: 50%;
border: 1px solid rgba(0, 245, 212, 0.14);
box-shadow: 0 0 22px rgba(0, 245, 212, 0.08);
animation: pulse-ring 3.6s ease-in-out infinite;
}
.empty-folder-icon-wrap {
position: relative;
z-index: 1;
width: 92px;
height: 92px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #7ce6ff;
background: rgba(3, 10, 18, 0.72);
border: 1px solid rgba(0, 245, 212, 0.16);
box-shadow: 0 0 24px rgba(0, 245, 212, 0.16);
animation: core-breathe 3.8s ease-in-out infinite;
}
.empty-folder-icon-wrap svg {
filter: drop-shadow(0 0 8px rgba(124, 230, 255, 0.45));
}
.empty-beam {
position: absolute;
width: 78px;
height: 1px;
top: 50%;
transform: translateY(-50%);
background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.95), transparent);
animation: beam-flicker 2.4s ease-in-out infinite;
}
.beam-left {
left: -34px;
}
.beam-right {
right: -34px;
}
.empty-copy {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
max-width: 560px;
}
.empty-kicker {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.empty-title {
color: var(--text-primary);
font-family: var(--font-display);
font-size: 22px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.empty-sub {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.8;
}
.empty-actions {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.empty-cta {
min-width: 188px;
justify-content: center;
}
@keyframes folder-float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-5px); }
}
@keyframes core-breathe {
0%, 100% {
transform: scale(1);
opacity: 0.92;
}
50% {
transform: scale(1.04);
opacity: 1;
}
}
@keyframes pulse-ring {
0%, 100% {
transform: scale(0.92);
opacity: 0.35;
}
50% {
transform: scale(1.06);
opacity: 0.78;
}
}
@keyframes beam-flicker {
0%, 100% {
opacity: 0.35;
transform: translateY(-50%) scaleX(0.92);
}
50% {
opacity: 1;
transform: translateY(-50%) scaleX(1.06);
}
}
@keyframes hologram-scan {
0% {
transform: translateX(-130%);
opacity: 0;
}
20% {
opacity: 0.55;
}
100% {
transform: translateX(130%);
opacity: 0;
}
}
@keyframes activate-flash {
0% {
opacity: 0;
transform: scale(0.92);
}
35% {
opacity: 0.95;
transform: scale(1.03);
}
100% {
opacity: 0;
transform: scale(1.08);
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.folder-tile:hover .folder-glyph,
.folder-tile:hover .empty-folder-chamber {
animation-duration: 2.3s;
}
.folder-tile:hover .folder-core,
.folder-tile:hover .folder-pulse-ring,
.folder-tile:hover .empty-folder-icon-wrap,
.folder-tile:hover .empty-folder-pulse {
animation-duration: 2s;
}
.folder-tile:hover .folder-beam,
.folder-tile:hover .empty-beam {
animation-duration: 1.2s;
}
@media (prefers-reduced-motion: reduce) {
.folder-glyph,
.folder-pulse-ring,
.folder-core,
.folder-beam,
.folder-scan,
.folder-tech-corners .corner,
.empty-folder-chamber,
.empty-folder-pulse,
.empty-folder-icon-wrap,
.empty-beam {
animation: none !important;
}
}
@media (max-width: 700px) {
.explorer-grid {
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 14px;
}
.explorer-tile {
min-height: 204px;
padding: 16px 14px 14px;
}
.folder-glyph {
width: 90px;
height: 84px;
margin-top: 18px;
}
.folder-pulse-ring {
width: 96px;
height: 96px;
}
.folder-core {
width: 84px;
height: 72px;
}
.empty-core-shell {
width: 210px;
height: 210px;
}
.orbit-outer {
width: 210px;
height: 210px;
}
.orbit-inner {
width: 156px;
height: 156px;
}
.empty-title {
font-size: 18px;
letter-spacing: 0.12em;
}
}
.spin {
animation: spin 1s linear infinite;
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 24px;
width: 400px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.document-dialog {
width: min(860px, 92vw);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
}
.dialog-header h3 {
font-family: var(--font-display);
font-size: 14px;
letter-spacing: 0.1em;
color: var(--text-primary);
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 4px;
border-radius: 3px;
display: flex;
align-items: center;
transition: all var(--transition-fast);
}
.close-btn:hover {
color: var(--text-primary);
background: var(--bg-panel);
}
.dialog-input {
width: 100%;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 13px;
margin-bottom: 16px;
box-sizing: border-box;
}
.dialog-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
.dialog-body {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 16px 0;
line-height: 1.5;
}
.dialog-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.document-info-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 14px;
}
.document-content-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: 16px;
}
.document-preview,
.chunk-panel {
min-height: 360px;
max-height: 62vh;
overflow: auto;
background: rgba(3, 5, 10, 0.7);
border: 1px solid var(--border-dim);
border-radius: 14px;
padding: 16px;
}
.document-preview pre {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
}
.chunk-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.chunk-panel-title {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.14em;
}
.chunk-panel-subtitle {
margin-top: 4px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
}
.chunk-count-badge {
min-width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(0, 245, 212, 0.2);
background: rgba(0, 245, 212, 0.08);
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 12px;
}
.chunk-loading-state {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
.chunk-loading-bar {
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.15), rgba(124, 230, 255, 0.45), rgba(0, 245, 212, 0.15));
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
.chunk-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.chunk-card {
border: 1px solid var(--border-dim);
border-radius: 12px;
background: rgba(8, 15, 24, 0.82);
padding: 12px;
}
.chunk-card.editing {
border-color: rgba(0, 245, 212, 0.4);
box-shadow: inset 0 0 0 1px rgba(0, 245, 212, 0.08);
}
.chunk-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.chunk-card-meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.chunk-index {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
}
.chunk-size {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
}
.chunk-meta-raw {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.5;
word-break: break-word;
}
.chunk-content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.chunk-edit-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.chunk-textarea {
width: 100%;
min-height: 150px;
resize: vertical;
background: rgba(0, 9, 19, 0.9);
border: 1px solid var(--border-dim);
border-radius: 10px;
color: var(--text-primary);
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
box-sizing: border-box;
}
.chunk-textarea:focus {
border-color: var(--accent-cyan);
outline: none;
}
.chunk-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.chunk-error {
width: 100%;
}
.chunk-empty {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
.preview-loading {
display: flex;
align-items: center;
gap: 8px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-panel);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:hover {
border-color: var(--border-mid);
background: var(--bg-card-hover);
}
.btn.primary {
background: var(--accent-cyan-dim);
border-color: rgba(0, 245, 212, 0.3);
color: var(--accent-cyan);
}
.btn.primary:hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
}
.btn.danger {
background: rgba(255, 71, 87, 0.1);
border-color: rgba(255, 71, 87, 0.3);
color: var(--accent-red);
}
.btn.danger:hover {
background: rgba(255, 71, 87, 0.2);
box-shadow: 0 0 12px rgba(255, 71, 87, 0.2);
}
@media (max-width: 900px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-left,
.toolbar-right {
width: 100%;
}
.toolbar-right {
display: flex;
justify-content: flex-start;
}
.document-content-grid {
grid-template-columns: 1fr;
}
}
</style>