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:
72
frontend/src/api/remoteMount.ts
Normal file
72
frontend/src/api/remoteMount.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
935
frontend/src/components/chat/KnowledgeRAG.css
Normal file
935
frontend/src/components/chat/KnowledgeRAG.css
Normal 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);
|
||||
}
|
||||
}
|
||||
224
frontend/src/components/chat/KnowledgeRAGPanel.test.ts
Normal file
224
frontend/src/components/chat/KnowledgeRAGPanel.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
771
frontend/src/components/chat/KnowledgeRAGPanel.vue
Normal file
771
frontend/src/components/chat/KnowledgeRAGPanel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user