Align knowledge storage with real folders and add WebDAV import surface

Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface.

Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing
Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass
Confidence: medium
Scope-risk: moderate
Reversibility: messy
Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified
Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection
Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
This commit is contained in:
2026-04-09 17:26:37 +08:00
parent aa12c92a5a
commit 8c7cf0732b
18 changed files with 2776 additions and 26 deletions

View File

@@ -0,0 +1,72 @@
import api from './index'
export interface RemoteMountCreate {
name: string
base_url: string
username?: string | null
password?: string | null
root_path?: string
}
export interface RemoteMount {
id: string
name: string
mount_type: string
base_url: string
username: string | null
root_path: string
is_active: boolean
last_sync_at: string | null
created_at: string
updated_at: string
}
export interface RemoteNode {
path: string
name: string
is_dir: boolean
size?: number | null
modified_at?: string | null
etag?: string | null
children: RemoteNode[]
}
export interface RemoteMountTreeResponse {
mount_id: string
root_path: string
nodes: RemoteNode[]
}
export interface RemoteSyncRequest {
remote_path: string
local_folder_id: string
mode?: 'file' | 'folder'
}
export interface RemoteSyncResult {
synced: number
skipped: number
failed: number
document_ids: string[]
errors: string[]
}
export const remoteMountApi = {
list() {
return api.get<RemoteMount[]>('/api/remote-mounts')
},
create(data: RemoteMountCreate) {
return api.post<RemoteMount>('/api/remote-mounts', data)
},
getTree(mountId: string, path?: string) {
return api.get<RemoteMountTreeResponse>(`/api/remote-mounts/${mountId}/tree`, {
params: path ? { path } : undefined,
})
},
sync(mountId: string, data: RemoteSyncRequest) {
return api.post<RemoteSyncResult>(`/api/remote-mounts/${mountId}/sync`, data)
},
}

View File

@@ -0,0 +1,935 @@
/* KnowledgeRAG.css - RAG Panel Styles (Jarvis HUD Style) */
.rag-panel-shell {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.rag-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.52);
backdrop-filter: blur(4px);
animation: fadeIn var(--transition-mid);
}
.rag-panel {
position: relative;
width: 95%;
max-width: 1400px;
height: 90vh;
background: var(--bg-panel);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
animation: scaleIn var(--transition-mid);
box-shadow: var(--glow-cyan), 0 30px 80px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
/* Tech corners */
.rag-panel::before,
.rag-panel::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
border: 1px solid var(--accent-cyan);
pointer-events: none;
}
.rag-panel::before {
top: -1px;
left: -1px;
border-right: none;
border-bottom: none;
border-top-left-radius: var(--radius-lg);
}
.rag-panel::after {
top: -1px;
right: -1px;
border-left: none;
border-bottom: none;
border-top-right-radius: var(--radius-lg);
}
/* Header */
.rag-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--border-dim);
background: var(--accent-cyan-dim);
}
.coord-tag {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-cyan);
letter-spacing: 0.15em;
}
.btn-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-close:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Body - Split Layout */
.rag-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* Left Explorer */
.rag-explorer {
width: 42%;
min-width: 380px;
max-width: 560px;
border-right: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
background: var(--bg-deep);
}
.explorer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border-dim);
}
.toolbar-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 11px;
color: var(--accent-cyan);
letter-spacing: 0.15em;
}
.toolbar-actions {
display: flex;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
}
.action-btn:hover {
background: rgba(0, 245, 212, 0.2);
border-color: var(--accent-cyan);
}
.action-btn.danger {
background: var(--accent-amber-dim);
border-color: rgba(249, 168, 37, 0.3);
color: var(--accent-amber);
}
.action-btn.danger:hover {
background: rgba(249, 168, 37, 0.2);
border-color: var(--accent-amber);
}
.hidden-input {
display: none;
}
.explorer-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-dim);
}
.mode-btn {
padding: 8px 10px;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
cursor: pointer;
}
.mode-btn.active {
color: var(--accent-cyan);
border-color: var(--accent-cyan);
background: var(--accent-cyan-dim);
}
/* Folder Tree */
.folder-tree {
flex: 1;
overflow-y: auto;
padding: 8px 0;
min-height: 0;
}
.remote-tree {
padding-top: 0;
}
.remote-mount-strip,
.remote-sync-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
}
.remote-mount-strip {
border-bottom: 1px solid var(--border-dim);
}
.remote-mount-select {
flex: 1;
min-width: 0;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
}
.remote-sync-hint {
justify-content: space-between;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.remote-sync-hint strong {
color: var(--accent-cyan);
font-weight: 500;
}
.folder-node {
position: relative;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
user-select: none;
}
.folder-row:hover {
background: var(--accent-cyan-dim);
}
.remote-row .action-btn.small {
opacity: 0.9;
}
.folder-row.active {
background: rgba(0, 245, 212, 0.12);
border-left: 2px solid var(--accent-cyan);
}
.folder-contents {
margin: 2px 10px 8px 0;
padding: 8px 0 0 12px;
border-left: 1px dashed rgba(0, 245, 212, 0.18);
}
.folder-contents-state {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
}
.folder-file-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.folder-row:hover .folder-icon {
color: var(--accent-cyan);
}
.folder-toggle {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: var(--text-dim);
flex-shrink: 0;
cursor: pointer;
transition: color var(--transition-fast);
}
.folder-toggle:hover {
color: var(--accent-cyan);
}
.folder-toggle-spacer {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.folder-icon {
width: 18px;
height: 18px;
padding: 2px;
border-radius: 4px;
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.08);
border: 1px solid rgba(0, 245, 212, 0.16);
flex-shrink: 0;
}
.folder-row.active .folder-icon {
background: rgba(0, 245, 212, 0.16);
border-color: rgba(0, 245, 212, 0.32);
}
.folder-name {
flex: 1;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.folder-actions {
display: flex;
gap: 4px;
margin-left: auto;
}
.action-btn.small {
width: 22px;
height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn.primary {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.action-btn.primary:hover {
background: rgba(0, 245, 212, 0.2);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Inline Dialogs */
.inline-dialog {
padding: 12px 16px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-panel);
}
.rag-dialog-overlay {
position: absolute;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.56);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.rag-dialog-card {
width: min(440px, 100%);
background: var(--bg-panel);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
box-shadow: var(--glow-cyan), 0 20px 50px rgba(0, 0, 0, 0.45);
padding: 16px;
}
.dialog-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.dialog-label {
font-family: var(--font-display);
font-size: 11px;
color: var(--accent-cyan);
letter-spacing: 0.1em;
}
.dialog-text {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.dialog-input {
padding: 8px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
transition: border-color var(--transition-fast);
}
.dialog-input:focus {
border-color: var(--accent-cyan);
}
.dialog-input::placeholder {
color: var(--text-dim);
}
.dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.count-badge {
padding: 2px 8px;
background: var(--accent-cyan-dim);
border-radius: 10px;
font-size: 9px;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
gap: 8px;
}
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin-bottom: 4px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.file-item:hover {
background: var(--accent-cyan-dim);
border-color: var(--border-dim);
}
.file-item.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
}
.tree-file-row {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 7px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
text-align: left;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.tree-file-row:hover,
.tree-file-row.active {
background: rgba(0, 245, 212, 0.08);
color: var(--text-primary);
}
.tree-file-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
}
.tree-file-meta {
display: inline-flex;
align-items: center;
gap: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
white-space: nowrap;
}
.tree-file-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
color: var(--text-dim);
}
.tree-file-delete:hover {
background: rgba(249, 168, 37, 0.18);
color: var(--accent-amber);
}
.file-idx {
font-family: var(--font-mono);
font-size: 8px;
color: var(--accent-cyan);
opacity: 0.4;
width: 20px;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
display: block;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
display: block;
font-family: var(--font-mono);
font-size: 9px;
color: var(--accent-cyan);
opacity: 0.5;
margin-top: 2px;
}
/* Right Chat */
.rag-chat {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-deep);
min-width: 0;
}
.chat-header {
padding: 12px 20px;
border-bottom: 1px solid var(--border-dim);
}
.chat-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 11px;
color: var(--accent-cyan);
letter-spacing: 0.15em;
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* Input at bottom-right */
.rag-input-area {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--border-dim);
background: var(--bg-deep);
}
.rag-input {
flex: 1;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: all var(--transition-fast);
}
.rag-input:focus {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
}
.rag-input::placeholder {
color: var(--text-dim);
}
.rag-send-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-cyan);
border: none;
border-radius: var(--radius-md);
color: #000;
cursor: pointer;
transition: all var(--transition-fast);
}
.rag-send-btn:hover {
background: #fff;
box-shadow: 0 0 20px var(--accent-cyan-glow);
}
.chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: var(--text-dim);
}
.welcome-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: 50%;
margin-bottom: 16px;
color: var(--accent-cyan);
}
.chat-welcome p {
margin: 4px 0;
font-family: var(--font-mono);
font-size: 13px;
}
.example-hint {
opacity: 0.6;
font-size: 11px !important;
}
.message-wrapper {
display: flex;
margin-bottom: 16px;
}
.message-wrapper.user {
justify-content: flex-end;
}
.message-wrapper.assistant {
justify-content: flex-start;
}
.message-bubble {
max-width: 75%;
padding: 14px 18px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
}
.message-wrapper.user .message-bubble {
background: linear-gradient(135deg, var(--accent-cyan-dim) 0%, rgba(0, 245, 212, 0.05) 100%);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
color: var(--text-primary);
}
.message-wrapper.assistant .message-bubble {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
color: var(--text-secondary);
}
.message-bubble.loading {
display: flex;
align-items: center;
gap: 10px;
color: var(--accent-cyan);
}
.message-content {
white-space: pre-wrap;
}
/* Sources */
.sources-list {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-dim);
}
.sources-label {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.1em;
margin-bottom: 8px;
}
.source-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin-top: 4px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--accent-cyan);
cursor: pointer;
transition: all var(--transition-fast);
}
.source-item:hover {
background: rgba(0, 245, 212, 0.15);
border-color: var(--accent-cyan);
}
.source-item .similarity {
margin-left: auto;
font-size: 9px;
opacity: 0.6;
}
/* Document Preview */
.doc-preview-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.doc-preview-panel {
width: 100%;
max-width: 1000px;
max-height: 80%;
background: var(--bg-panel);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--accent-cyan-dim);
border-bottom: 1px solid var(--border-mid);
}
.preview-title {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-size: 14px;
color: var(--accent-cyan);
}
.preview-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.preview-close:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.preview-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
gap: 12px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
}
.preview-content {
margin: 0;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.8;
color: var(--accent-cyan);
white-space: pre-wrap;
word-break: break-word;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.85);
}
to {
opacity: 1;
transform: scale(1);
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--transition-mid);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.spin {
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 1100px) {
.rag-body {
flex-direction: column;
}
.rag-explorer {
width: 100%;
min-width: 0;
max-width: none;
max-height: 38vh;
border-right: none;
border-bottom: 1px solid var(--border-dim);
}
}

View File

@@ -0,0 +1,224 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const makeRef = <T>(value: T) => ({ value, __v_isRef: true as const })
const folders = makeRef([
{
id: 'root-1',
name: 'Root',
parent_id: null,
children: [
{
id: 'child-1',
name: 'Child',
parent_id: 'root-1',
children: [],
},
],
},
])
const documents = makeRef([
{
id: 'doc-1',
title: 'Spec',
file_type: 'md',
file_size: 128,
created_at: '2026-04-09T10:00:00.000Z',
},
])
return {
folders,
documents,
currentFolderId: makeRef<string | null>(null),
currentFolder: makeRef<any>(null),
isLoadingDocuments: makeRef(false),
uploadInput: makeRef<HTMLInputElement | null>(null),
triggerUpload: vi.fn(),
handleUpload: vi.fn(),
handleDeleteDocument: vi.fn(),
openDocument: vi.fn(),
enterFolder: vi.fn(async (folder: any) => {
mocks.currentFolderId.value = folder.id
mocks.currentFolder.value = folder
}),
activeDocumentContent: makeRef('Preview'),
isLoadingDocumentContent: makeRef(false),
folderCreate: vi.fn(),
folderGetTree: vi.fn(),
folderDelete: vi.fn(),
documentUpload: vi.fn(),
}
})
vi.mock('@/pages/knowledge/composables/useKnowledgeView', () => ({
useKnowledgeView: () => ({
documents: mocks.documents,
folders: mocks.folders,
currentFolderId: mocks.currentFolderId,
currentFolder: mocks.currentFolder,
isLoadingDocuments: mocks.isLoadingDocuments,
getFileTypeColor: () => '#fff',
formatFileSize: (size: number) => `${size} B`,
formatDate: () => '2026/04/09',
triggerUpload: mocks.triggerUpload,
uploadInput: mocks.uploadInput,
handleUpload: mocks.handleUpload,
handleDeleteDocument: mocks.handleDeleteDocument,
openDocument: mocks.openDocument,
activeDocumentContent: mocks.activeDocumentContent,
isLoadingDocumentContent: mocks.isLoadingDocumentContent,
enterFolder: mocks.enterFolder,
}),
}))
vi.mock('@/api/folder', () => ({
folderApi: {
create: mocks.folderCreate,
getTree: mocks.folderGetTree,
delete: mocks.folderDelete,
},
}))
vi.mock('@/api/document', () => ({
documentApi: {
upload: mocks.documentUpload,
},
}))
import KnowledgeRAGPanel from './KnowledgeRAGPanel.vue'
describe('KnowledgeRAGPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.folders.value = [
{
id: 'root-1',
name: 'Root',
parent_id: null,
children: [
{
id: 'child-1',
name: 'Child',
parent_id: 'root-1',
children: [],
},
],
},
]
mocks.documents.value = [{ id: 'doc-1', title: 'Spec', file_type: 'md', file_size: 128, created_at: '2026-04-09T10:00:00.000Z' }]
mocks.currentFolderId.value = null
mocks.currentFolder.value = null
mocks.folderGetTree.mockResolvedValue({ data: mocks.folders.value })
mocks.folderCreate.mockResolvedValue({ data: {} })
mocks.folderDelete.mockResolvedValue({ data: {} })
mocks.documentUpload.mockResolvedValue({ data: {} })
})
it('renders nested folders in a collapsible tree and selects a folder on click', async () => {
const wrapper = mount(KnowledgeRAGPanel, {
props: { isChatLoading: false },
})
expect(wrapper.text()).toContain('Root')
expect(wrapper.text()).not.toContain('Child')
await wrapper.find('.folder-toggle').trigger('click')
expect(wrapper.text()).toContain('Child')
await wrapper.find('.folder-row').trigger('click')
expect(mocks.enterFolder).toHaveBeenCalledWith(expect.objectContaining({ id: 'root-1' }))
expect(mocks.currentFolderId.value).toBe('root-1')
expect(wrapper.text()).toContain('Spec')
await wrapper.find('.folder-row').trigger('click')
expect(wrapper.text()).toContain('Spec')
})
it('uses top NEW to create root folder when nothing is selected', async () => {
const wrapper = mount(KnowledgeRAGPanel, {
props: { isChatLoading: false },
})
await wrapper.find('.toolbar-actions .action-btn').trigger('click')
expect(wrapper.find('.rag-dialog-overlay').exists()).toBe(true)
expect(wrapper.find('.inline-dialog .dialog-input').exists()).toBe(false)
await wrapper.find('.dialog-input').setValue('RootNew')
await wrapper.find('.action-btn.primary').trigger('click')
await flushPromises()
expect(mocks.folderCreate).toHaveBeenCalledWith({
name: 'RootNew',
parent_id: null,
})
})
it('uses top NEW to create child folder under selected folder', async () => {
const wrapper = mount(KnowledgeRAGPanel, {
props: { isChatLoading: false },
})
await wrapper.find('.folder-row').trigger('click')
await wrapper.find('.toolbar-actions .action-btn').trigger('click')
await wrapper.find('.dialog-input').setValue('Nested')
await wrapper.find('.action-btn.primary').trigger('click')
await flushPromises()
expect(mocks.folderCreate).toHaveBeenCalledWith({
name: 'Nested',
parent_id: 'root-1',
})
})
it('shows delete action only for selected folder and hides per-row create button', async () => {
const wrapper = mount(KnowledgeRAGPanel, {
props: { isChatLoading: false },
})
expect(wrapper.find('button[title="Delete folder"]').exists()).toBe(false)
expect(wrapper.find('button[title="New child folder"]').exists()).toBe(false)
await wrapper.find('.folder-row').trigger('click')
expect(wrapper.find('button[title="Delete folder"]').exists()).toBe(true)
expect(wrapper.findAll('button[title="Delete folder"]')).toHaveLength(1)
})
it('does not show chevron toggle for leaf folders', async () => {
const wrapper = mount(KnowledgeRAGPanel, {
props: { isChatLoading: false },
})
await wrapper.find('.folder-toggle').trigger('click')
expect(wrapper.findAll('.folder-toggle')).toHaveLength(1)
expect(wrapper.findAll('.folder-toggle-spacer')).toHaveLength(1)
})
it('uploads to the currently selected child folder', async () => {
const wrapper = mount(KnowledgeRAGPanel, {
props: { isChatLoading: false },
})
await wrapper.find('.folder-toggle').trigger('click')
await wrapper.findAll('.folder-row')[1].trigger('click')
const upload = wrapper.find('input[type="file"]')
Object.defineProperty(upload.element, 'files', {
value: [new File(['hello'], 'note.md', { type: 'text/markdown' })],
configurable: true,
})
await upload.trigger('change')
await flushPromises()
expect(mocks.documentUpload).toHaveBeenCalledWith(expect.any(File), 'child-1')
})
})

View File

@@ -0,0 +1,771 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
X,
Upload,
FileCode,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Search,
Send,
Loader,
FileText,
ExternalLink,
Trash2,
RefreshCw,
FolderPlus,
Cloud,
Database,
} from 'lucide-vue-next'
import { folderApi, type FolderTree } from '@/api/folder'
import { documentApi } from '@/api/document'
import { remoteMountApi, type RemoteMount, type RemoteNode } from '@/api/remoteMount'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import './KnowledgeRAG.css'
defineProps<{
isChatLoading: boolean
}>()
const emit = defineEmits(['close', 'update:chatInput', 'send'])
function onInputChange(event: Event) {
const target = event.target as HTMLInputElement
chatInput.value = target.value
emit('update:chatInput', target.value)
}
const {
documents,
folders,
currentFolderId,
currentFolder,
isLoadingDocuments,
formatFileSize,
formatDate,
uploadInput,
handleDeleteDocument,
openDocument,
activeDocumentContent,
isLoadingDocumentContent,
enterFolder,
} = useKnowledgeView()
const selectedDoc = ref<any>(null)
const previewOpen = ref(false)
const isUploadingFile = ref(false)
const explorerMode = ref<'local' | 'remote'>('local')
const showNewFolderInput = ref(false)
const newFolderName = ref('')
const newFolderParentId = ref<string | null>(null)
const isCreatingFolder = ref(false)
const showDeleteConfirm = ref(false)
const deletingFolder = ref<FolderTree | null>(null)
const isDeletingFolder = ref(false)
const expandedFolderIds = ref<Set<string>>(new Set())
const remoteExpandedIds = ref<Set<string>>(new Set())
const remoteMounts = ref<RemoteMount[]>([])
const selectedMountId = ref<string | null>(null)
const remoteNodes = ref<RemoteNode[]>([])
const selectedRemotePath = ref<string | null>(null)
const isLoadingRemoteMounts = ref(false)
const isLoadingRemoteTree = ref(false)
const isSyncingRemote = ref<string | null>(null)
const showRemoteMountInput = ref(false)
const remoteMountForm = ref({
name: '',
base_url: '',
username: '',
password: '',
root_path: '/',
})
interface RAGMessage {
id: string
role: 'user' | 'assistant'
content: string
sources?: DocumentSource[]
}
interface DocumentSource {
id: string
title: string
file_type: string
folder_id?: string | null
similarity?: number
chunk_content?: string
}
const chatMessages = ref<RAGMessage[]>([])
const chatInput = ref('')
function ensureExpandedPath(targetId: string | null, nodes: FolderTree[] = folders.value): boolean {
if (!targetId) return false
for (const node of nodes) {
if (node.id === targetId) {
return true
}
if (node.children?.length && ensureExpandedPath(targetId, node.children)) {
expandedFolderIds.value.add(node.id)
return true
}
}
return false
}
function getVisibleFolders(nodes: FolderTree[], depth = 0): Array<{ data: FolderTree; depth: number }> {
const result: Array<{ data: FolderTree; depth: number }> = []
for (const folder of nodes) {
result.push({ data: folder, depth })
if (folder.children?.length && expandedFolderIds.value.has(folder.id)) {
result.push(...getVisibleFolders(folder.children, depth + 1))
}
}
return result
}
const visibleFolders = computed(() => getVisibleFolders(folders.value))
const ROW_BASE_PADDING = 12
const DEPTH_INDENT = 16
const CONTENT_BASE_PADDING = 36
const newFolderTargetLabel = computed(() => {
if (!newFolderParentId.value) {
return 'ROOT'
}
return findFolderNameById(newFolderParentId.value) ?? 'TARGET'
})
function hasChildren(folder: FolderTree) {
return (folder.children?.length ?? 0) > 0
}
function isExpanded(folderId: string) {
return expandedFolderIds.value.has(folderId)
}
function isFolderOpen(folder: FolderTree) {
return currentFolderId.value === folder.id || isExpanded(folder.id)
}
function toggleFolder(folder: FolderTree) {
if (!hasChildren(folder)) {
return
}
if (expandedFolderIds.value.has(folder.id)) {
expandedFolderIds.value.delete(folder.id)
} else {
expandedFolderIds.value.add(folder.id)
}
expandedFolderIds.value = new Set(expandedFolderIds.value)
}
function getFolderIcon(folder: FolderTree) {
return isFolderOpen(folder) ? FolderOpen : Folder
}
function findFolderNameById(targetId: string | null, nodes: FolderTree[] = folders.value): string | null {
if (!targetId) return null
for (const node of nodes) {
if (node.id === targetId) {
return node.name
}
if (node.children?.length) {
const nested = findFolderNameById(targetId, node.children)
if (nested) return nested
}
}
return null
}
function getVisibleRemoteNodes(nodes: RemoteNode[], depth = 0): Array<{ data: RemoteNode; depth: number }> {
const result: Array<{ data: RemoteNode; depth: number }> = []
for (const node of nodes) {
result.push({ data: node, depth })
if (node.is_dir && remoteExpandedIds.value.has(node.path)) {
result.push(...getVisibleRemoteNodes(node.children, depth + 1))
}
}
return result
}
const visibleRemoteNodes = computed(() => getVisibleRemoteNodes(remoteNodes.value))
const selectedLocalFolderLabel = computed(() => currentFolder.value?.name ?? 'Select in LOCAL first')
const selectedMount = computed(() => remoteMounts.value.find((mount) => mount.id === selectedMountId.value) ?? null)
async function loadRemoteMounts() {
isLoadingRemoteMounts.value = true
try {
const response = await remoteMountApi.list()
remoteMounts.value = response.data
if (!selectedMountId.value && response.data.length > 0) {
selectedMountId.value = response.data[0].id
await loadRemoteTree(response.data[0].id)
}
} catch (error) {
console.error('Failed to load remote mounts:', error)
} finally {
isLoadingRemoteMounts.value = false
}
}
async function loadRemoteTree(mountId: string) {
isLoadingRemoteTree.value = true
try {
const response = await remoteMountApi.getTree(mountId)
remoteNodes.value = response.data.nodes
} catch (error) {
console.error('Failed to load remote tree:', error)
remoteNodes.value = []
} finally {
isLoadingRemoteTree.value = false
}
}
async function handleSelectMount(mountId: string) {
selectedMountId.value = mountId
selectedRemotePath.value = null
remoteExpandedIds.value = new Set()
await loadRemoteTree(mountId)
}
function isRemoteExpanded(path: string) {
return remoteExpandedIds.value.has(path)
}
function handleRemoteNodeClick(node: RemoteNode) {
selectedRemotePath.value = node.path
if (node.is_dir) {
if (remoteExpandedIds.value.has(node.path)) {
remoteExpandedIds.value.delete(node.path)
} else {
remoteExpandedIds.value.add(node.path)
}
remoteExpandedIds.value = new Set(remoteExpandedIds.value)
}
}
async function createRemoteMount() {
if (!remoteMountForm.value.name.trim() || !remoteMountForm.value.base_url.trim()) {
return
}
try {
const response = await remoteMountApi.create({
name: remoteMountForm.value.name.trim(),
base_url: remoteMountForm.value.base_url.trim(),
username: remoteMountForm.value.username.trim() || null,
password: remoteMountForm.value.password || null,
root_path: remoteMountForm.value.root_path.trim() || '/',
})
showRemoteMountInput.value = false
remoteMountForm.value = { name: '', base_url: '', username: '', password: '', root_path: '/' }
await loadRemoteMounts()
await handleSelectMount(response.data.id)
} catch (error) {
console.error('Failed to create remote mount:', error)
}
}
async function syncRemoteNode(node: RemoteNode) {
if (!selectedMountId.value || !currentFolderId.value || isSyncingRemote.value) {
return
}
isSyncingRemote.value = node.path
try {
await remoteMountApi.sync(selectedMountId.value, {
remote_path: node.path,
local_folder_id: currentFolderId.value,
mode: node.is_dir ? 'folder' : 'file',
})
if (currentFolder.value) {
await enterFolder(currentFolder.value)
}
} catch (error) {
console.error('Failed to sync remote node:', error)
} finally {
isSyncingRemote.value = null
}
}
function openNewFolderDialog(parentId: string | null = null) {
newFolderParentId.value = parentId
newFolderName.value = ''
if (parentId) {
expandedFolderIds.value.add(parentId)
expandedFolderIds.value = new Set(expandedFolderIds.value)
}
showNewFolderInput.value = true
}
async function createFolderApi(name: string) {
const normalizedName = name.trim()
if (!normalizedName) return
isCreatingFolder.value = true
try {
await folderApi.create({
name: normalizedName,
parent_id: newFolderParentId.value,
})
const response = await folderApi.getTree()
folders.value = response.data
ensureExpandedPath(newFolderParentId.value)
expandedFolderIds.value = new Set(expandedFolderIds.value)
showNewFolderInput.value = false
newFolderName.value = ''
} catch (err) {
console.error('Failed to create folder:', err)
} finally {
isCreatingFolder.value = false
}
}
function cancelNewFolder() {
showNewFolderInput.value = false
newFolderName.value = ''
newFolderParentId.value = null
}
function openDeleteDialog(folder: FolderTree) {
deletingFolder.value = folder
showDeleteConfirm.value = true
}
async function deleteFolderApi() {
if (!deletingFolder.value) return
isDeletingFolder.value = true
try {
await folderApi.delete(deletingFolder.value.id)
const response = await folderApi.getTree()
folders.value = response.data
if (currentFolderId.value === deletingFolder.value.id) {
selectedDoc.value = null
}
showDeleteConfirm.value = false
deletingFolder.value = null
} catch (err) {
console.error('Failed to delete folder:', err)
} finally {
isDeletingFolder.value = false
}
}
function cancelDelete() {
showDeleteConfirm.value = false
deletingFolder.value = null
}
async function handleFolderClick(folder: FolderTree) {
if (currentFolderId.value === folder.id) {
if (expandedFolderIds.value.has(folder.id)) {
expandedFolderIds.value.delete(folder.id)
expandedFolderIds.value = new Set(expandedFolderIds.value)
return
}
expandedFolderIds.value.add(folder.id)
expandedFolderIds.value = new Set(expandedFolderIds.value)
return
}
ensureExpandedPath(folder.id)
expandedFolderIds.value.add(folder.id)
expandedFolderIds.value = new Set(expandedFolderIds.value)
await enterFolder(folder)
}
function triggerFolderUpload() {
if (!currentFolderId.value || isUploadingFile.value) {
return
}
uploadInput.value?.click()
}
function handleFileClick(doc: any) {
selectedDoc.value = doc
previewOpen.value = true
openDocument(doc)
}
async function onUploadChange(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
const selectedFolder = currentFolder.value
if (!file || !selectedFolder?.id) {
target.value = ''
return
}
isUploadingFile.value = true
try {
await documentApi.upload(file, selectedFolder.id)
await enterFolder(selectedFolder)
} catch (error) {
console.error('Failed to upload document:', error)
} finally {
isUploadingFile.value = false
target.value = ''
}
}
function formatSimilarity(score?: number) {
if (!score) return ''
return `${Math.round(score * 100)}%`
}
function addMessage(msg: RAGMessage) {
chatMessages.value.push(msg)
}
onMounted(async () => {
await loadRemoteMounts()
})
defineExpose({ addMessage })
</script>
<template>
<div class="rag-panel-shell">
<div class="rag-backdrop" @click="emit('close')"></div>
<div class="rag-panel">
<header class="rag-header">
<div class="header-left">
<div class="coord-tag">
<Search :size="10" />
<span>KNOWLEDGE_RAG</span>
</div>
</div>
<button class="btn-close" aria-label="Close knowledge panel" @click="emit('close')">
<X :size="16" />
</button>
</header>
<div class="rag-body">
<aside class="rag-explorer">
<div class="explorer-toolbar">
<div class="toolbar-title">
<component :is="explorerMode === 'local' ? Database : Cloud" :size="12" />
<span>{{ explorerMode === 'local' ? 'ARCHIVE TREE' : 'REMOTE MOUNTS' }}</span>
</div>
<div class="toolbar-actions">
<template v-if="explorerMode === 'local'">
<button class="action-btn" @click="openNewFolderDialog(currentFolderId)" title="New folder">
<FolderPlus :size="10" />
<span>NEW</span>
</button>
<button class="action-btn" :disabled="!currentFolderId || isUploadingFile" @click="triggerFolderUpload()">
<Upload :size="10" />
<span>{{ isUploadingFile ? 'UPLOADING' : 'UPLOAD' }}</span>
</button>
</template>
<template v-else>
<button class="action-btn" @click="showRemoteMountInput = true" title="New remote mount">
<FolderPlus :size="10" />
<span>MOUNT</span>
</button>
</template>
<input
ref="uploadInput"
type="file"
accept=".pdf,.md,.txt,.doc,.docx,.csv,.xlsx"
class="hidden-input"
@change="onUploadChange"
/>
</div>
</div>
<div class="explorer-mode-switch">
<button class="mode-btn" :class="{ active: explorerMode === 'local' }" @click="explorerMode = 'local'">LOCAL</button>
<button class="mode-btn" :class="{ active: explorerMode === 'remote' }" @click="explorerMode = 'remote'">REMOTE</button>
</div>
<div v-if="showDeleteConfirm" class="inline-dialog">
<div class="dialog-content">
<span class="dialog-label">DELETE FOLDER</span>
<p class="dialog-text">Delete "{{ deletingFolder?.name }}" and its contents?</p>
<div class="dialog-actions">
<button class="action-btn" @click="cancelDelete">CANCEL</button>
<button class="action-btn danger" @click="deleteFolderApi" :disabled="isDeletingFolder">
<Loader v-if="isDeletingFolder" :size="10" class="spin" />
<span>DELETE</span>
</button>
</div>
</div>
</div>
<div v-if="explorerMode === 'local'" class="folder-tree">
<div v-if="visibleFolders.length === 0" class="empty-state">
<span>NO_FOLDERS</span>
</div>
<div
v-for="{ data: folder, depth } in visibleFolders"
:key="`folder-${folder.id}`"
class="folder-node"
>
<div
class="folder-row"
:class="{ active: currentFolderId === folder.id }"
:style="{ paddingLeft: `${ROW_BASE_PADDING + depth * DEPTH_INDENT}px` }"
@click="handleFolderClick(folder)"
>
<button
class="folder-toggle"
type="button"
@click.stop="handleFolderClick(folder)"
>
<component :is="isFolderOpen(folder) ? ChevronDown : ChevronRight" :size="12" />
</button>
<component :is="getFolderIcon(folder)" :size="12" class="folder-icon" />
<span class="folder-name">{{ folder.name }}</span>
<div v-if="currentFolderId === folder.id" class="folder-actions">
<button class="action-btn small danger" @click.stop="openDeleteDialog(folder)" title="Delete folder">
<Trash2 :size="8" />
</button>
</div>
</div>
<div
v-if="currentFolderId === folder.id && !isLoadingDocuments && documents.length > 0"
class="folder-contents"
:style="{ paddingLeft: `${CONTENT_BASE_PADDING + depth * DEPTH_INDENT}px` }"
>
<div class="folder-file-list">
<button
v-for="doc in documents"
:key="doc.id"
class="tree-file-row"
:class="{ active: selectedDoc?.id === doc.id }"
@click="handleFileClick(doc)"
>
<span class="tree-file-name">{{ doc.title }}</span>
<span class="tree-file-meta">
<span>{{ formatDate(doc.created_at) }}</span>
<span>{{ formatFileSize(doc.file_size) }}</span>
<span class="tree-file-delete" @click.stop="handleDeleteDocument(doc.id)">
<Trash2 :size="10" />
</span>
</span>
</button>
</div>
</div>
</div>
</div>
<div v-else class="folder-tree remote-tree">
<div class="remote-mount-strip">
<select
class="remote-mount-select"
:disabled="isLoadingRemoteMounts || remoteMounts.length === 0"
:value="selectedMountId ?? ''"
@change="handleSelectMount(($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>{{ isLoadingRemoteMounts ? 'Loading mounts...' : 'Select mount' }}</option>
<option v-for="mount in remoteMounts" :key="mount.id" :value="mount.id">{{ mount.name }}</option>
</select>
<button class="action-btn small" :disabled="!selectedMountId || isLoadingRemoteTree" @click="selectedMountId && loadRemoteTree(selectedMountId)">
<RefreshCw :size="10" />
</button>
</div>
<div class="remote-sync-hint">
<span>TARGET</span>
<strong>{{ selectedLocalFolderLabel }}</strong>
</div>
<div v-if="isLoadingRemoteTree" class="empty-state">
<Loader :size="14" class="spin" />
<span>LOADING_REMOTE_TREE...</span>
</div>
<div v-else-if="visibleRemoteNodes.length === 0" class="empty-state">
<span>{{ selectedMount ? 'NO_REMOTE_ITEMS' : 'NO_MOUNT_SELECTED' }}</span>
</div>
<div
v-for="{ data: node, depth } in visibleRemoteNodes"
:key="node.path"
class="folder-node"
>
<div
class="folder-row remote-row"
:class="{ active: selectedRemotePath === node.path }"
:style="{ paddingLeft: `${ROW_BASE_PADDING + depth * DEPTH_INDENT}px` }"
@click="handleRemoteNodeClick(node)"
>
<button class="folder-toggle" type="button" @click.stop="handleRemoteNodeClick(node)">
<component :is="node.is_dir && isRemoteExpanded(node.path) ? ChevronDown : ChevronRight" :size="12" />
</button>
<component :is="node.is_dir ? (isRemoteExpanded(node.path) ? FolderOpen : Folder) : FileCode" :size="12" class="folder-icon" />
<span class="folder-name">{{ node.name }}</span>
<button
class="action-btn small"
:disabled="!currentFolderId || isSyncingRemote === node.path"
@click.stop="syncRemoteNode(node)"
>
<Loader v-if="isSyncingRemote === node.path" :size="8" class="spin" />
<RefreshCw v-else :size="8" />
</button>
</div>
</div>
</div>
</aside>
<main class="rag-chat">
<div class="chat-header">
<div class="chat-title">
<RefreshCw :size="12" />
<span>RAG CONVERSATION</span>
</div>
</div>
<div class="chat-messages">
<div v-if="chatMessages.length === 0" class="chat-welcome">
<div class="welcome-icon">
<Search :size="32" />
</div>
<p>Use natural language to search the selected knowledge folder.</p>
<p class="example-hint">Example: "Find the latest project planning notes."</p>
</div>
<div
v-for="msg in chatMessages"
:key="msg.id"
class="message-wrapper"
:class="msg.role"
>
<div class="message-bubble">
<div class="message-content">{{ msg.content }}</div>
<div v-if="msg.sources?.length" class="sources-list">
<div class="sources-label">SOURCES</div>
<div
v-for="source in msg.sources"
:key="source.id"
class="source-item"
@click="handleFileClick(source)"
>
<FileCode :size="10" />
<span>{{ source.title }}</span>
<span v-if="source.similarity" class="similarity">{{ formatSimilarity(source.similarity) }}</span>
<ExternalLink :size="8" />
</div>
</div>
</div>
</div>
<div v-if="isChatLoading" class="message-wrapper assistant">
<div class="message-bubble loading">
<Loader :size="14" class="spin" />
<span>SEARCHING...</span>
</div>
</div>
</div>
<div class="rag-input-area">
<input
type="text"
class="rag-input"
placeholder="Ask the knowledge base..."
:value="chatInput"
@input="onInputChange"
@keydown.enter="emit('send')"
/>
<button class="rag-send-btn" @click="emit('send')">
<Send :size="16" />
</button>
</div>
</main>
</div>
<Transition name="fade">
<div v-if="showNewFolderInput" class="rag-dialog-overlay" @click.self="cancelNewFolder">
<div class="rag-dialog-card">
<div class="dialog-content">
<span class="dialog-label">NEW FOLDER / {{ newFolderTargetLabel }}</span>
<input
v-model="newFolderName"
type="text"
class="dialog-input"
placeholder="Folder name..."
@keydown.enter="createFolderApi(newFolderName)"
@keydown.esc="cancelNewFolder"
autofocus
/>
<div class="dialog-actions">
<button class="action-btn" @click="cancelNewFolder">CANCEL</button>
<button class="action-btn primary" @click="createFolderApi(newFolderName)" :disabled="isCreatingFolder">
<Loader v-if="isCreatingFolder" :size="10" class="spin" />
<span>CREATE</span>
</button>
</div>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="showRemoteMountInput" class="rag-dialog-overlay" @click.self="showRemoteMountInput = false">
<div class="rag-dialog-card">
<div class="dialog-content">
<span class="dialog-label">NEW WEBDAV MOUNT</span>
<input v-model="remoteMountForm.name" type="text" class="dialog-input" placeholder="Mount name" />
<input v-model="remoteMountForm.base_url" type="text" class="dialog-input" placeholder="https://example.com/dav/" />
<input v-model="remoteMountForm.username" type="text" class="dialog-input" placeholder="Username" />
<input v-model="remoteMountForm.password" type="password" class="dialog-input" placeholder="Password" />
<input v-model="remoteMountForm.root_path" type="text" class="dialog-input" placeholder="/remote/path" />
<div class="dialog-actions">
<button class="action-btn" @click="showRemoteMountInput = false">CANCEL</button>
<button class="action-btn primary" @click="createRemoteMount">CREATE</button>
</div>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="previewOpen && selectedDoc" class="doc-preview-overlay" @click.self="previewOpen = false">
<div class="doc-preview-panel">
<header class="preview-header">
<div class="preview-title">
<FileText :size="14" />
<span>{{ selectedDoc.title }}</span>
</div>
<button class="preview-close" @click="previewOpen = false">
<X :size="16" />
</button>
</header>
<div class="preview-body">
<div v-if="isLoadingDocumentContent" class="preview-loading">
<Loader :size="20" class="spin" />
<span>LOADING_CONTENT...</span>
</div>
<pre v-else class="preview-content">{{ activeDocumentContent || 'NO_CONTENT' }}</pre>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<style scoped>
/* Styles defined in KnowledgeRAG.css */
</style>