Files
JARVIS/frontend/src/components/chat/KnowledgeRAGPanel.vue

772 lines
25 KiB
Vue
Raw Normal View History

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