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
772 lines
25 KiB
Vue
772 lines
25 KiB
Vue
<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>
|