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>
|