mirror of
https://github.com/open-webui/open-webui.git
synced 2026-06-13 19:20:05 +00:00
1628 lines
44 KiB
Svelte
1628 lines
44 KiB
Svelte
<script lang="ts">
|
|
import Fuse from 'fuse.js';
|
|
import { toast } from 'svelte-sonner';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
|
|
|
|
import { onMount, getContext, onDestroy, tick } from 'svelte';
|
|
const i18n = getContext('i18n');
|
|
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/stores';
|
|
import {
|
|
mobile,
|
|
showSidebar,
|
|
knowledge as _knowledge,
|
|
config,
|
|
user,
|
|
settings
|
|
} from '$lib/stores';
|
|
|
|
import {
|
|
updateFileDataContentById,
|
|
uploadFile,
|
|
deleteFileById,
|
|
getFileById,
|
|
renameFileById
|
|
} from '$lib/apis/files';
|
|
import {
|
|
addFileToKnowledgeById,
|
|
getKnowledgeById,
|
|
getPendingKnowledgeFiles,
|
|
removeFileFromKnowledgeById,
|
|
resetKnowledgeById,
|
|
updateFileFromKnowledgeById,
|
|
updateKnowledgeById,
|
|
updateKnowledgeAccessGrants,
|
|
searchKnowledgeFilesById,
|
|
createKnowledgeDirectory,
|
|
updateKnowledgeDirectory,
|
|
deleteKnowledgeDirectory,
|
|
moveFileInKnowledge,
|
|
syncKnowledgeDiff,
|
|
syncKnowledgeCleanup
|
|
} from '$lib/apis/knowledge';
|
|
import { processWeb, processYoutubeVideo } from '$lib/apis/retrieval';
|
|
|
|
import { blobToFile, isYoutubeUrl, copyToClipboard } from '$lib/utils';
|
|
import { computeFileHash } from '$lib/utils/hash';
|
|
|
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
|
import Files from './KnowledgeBase/Files.svelte';
|
|
import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
|
|
|
|
import AddContentMenu from './KnowledgeBase/AddContentMenu.svelte';
|
|
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
|
|
import NewDirectoryModal from './KnowledgeBase/NewDirectoryModal.svelte';
|
|
import KnowledgeBreadcrumbs from './KnowledgeBase/KnowledgeBreadcrumbs.svelte';
|
|
|
|
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
|
|
import ConfirmDialog from '../../common/ConfirmDialog.svelte';
|
|
import Drawer from '$lib/components/common/Drawer.svelte';
|
|
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
|
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
|
import AccessControlModal from '../common/AccessControlModal.svelte';
|
|
import Search from '$lib/components/icons/Search.svelte';
|
|
import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte';
|
|
import DropdownOptions from '$lib/components/common/DropdownOptions.svelte';
|
|
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
|
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
|
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
|
|
import Pagination from '$lib/components/common/Pagination.svelte';
|
|
import AttachWebpageModal from '$lib/components/chat/MessageInput/AttachWebpageModal.svelte';
|
|
|
|
let largeScreen = true;
|
|
|
|
let pane;
|
|
let showSidepanel = true;
|
|
|
|
let showAddWebpageModal = false;
|
|
let showAddTextContentModal = false;
|
|
let showNewDirectoryModal = false;
|
|
|
|
let showSyncConfirmModal = false;
|
|
let pendingSyncFiles: Array<{ path: string; filename: string; file: File }> | null = null;
|
|
let syncing: string | null = null;
|
|
let showAccessControlModal = false;
|
|
let showResetConfirm = false;
|
|
|
|
let minSize = 0;
|
|
type Knowledge = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
data: {
|
|
file_ids: string[];
|
|
};
|
|
files: any[];
|
|
access_grants?: any[];
|
|
write_access?: boolean;
|
|
};
|
|
|
|
let id = null;
|
|
let knowledge: Knowledge | null = null;
|
|
let knowledgeId = null;
|
|
|
|
let selectedFileId = null;
|
|
let selectedFile = null;
|
|
let selectedFileContent = '';
|
|
|
|
let inputFiles = null;
|
|
|
|
let query = '';
|
|
let includeContent = false;
|
|
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
|
|
|
let viewOption = null;
|
|
let sortKey = null;
|
|
let direction = null;
|
|
|
|
let currentPage = 1;
|
|
let fileItems = null;
|
|
let fileItemsTotal = null;
|
|
|
|
// Directory state
|
|
let currentDirectoryId: string | null = null;
|
|
let directoryItems = [];
|
|
let breadcrumbs = [];
|
|
|
|
let showDeleteDirectoryConfirm = false;
|
|
let pendingDeleteDirectoryId: string | null = null;
|
|
let deleteDirectoryContents = true;
|
|
|
|
let pendingPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const reset = () => {
|
|
currentPage = 1;
|
|
};
|
|
|
|
const init = async () => {
|
|
reset();
|
|
await getItemsPage();
|
|
};
|
|
|
|
// Debounce only query changes
|
|
$: if (query !== undefined) {
|
|
clearTimeout(searchDebounceTimer);
|
|
|
|
searchDebounceTimer = setTimeout(() => {
|
|
getItemsPage();
|
|
}, 300);
|
|
}
|
|
|
|
// Immediate response to filter/pagination changes
|
|
$: if (
|
|
knowledgeId !== null &&
|
|
viewOption !== undefined &&
|
|
sortKey !== undefined &&
|
|
direction !== undefined &&
|
|
currentPage !== undefined &&
|
|
includeContent !== undefined
|
|
) {
|
|
getItemsPage();
|
|
}
|
|
|
|
$: if (
|
|
query !== undefined &&
|
|
viewOption !== undefined &&
|
|
sortKey !== undefined &&
|
|
direction !== undefined
|
|
) {
|
|
reset();
|
|
}
|
|
|
|
const getItemsPage = async () => {
|
|
if (knowledgeId === null) return;
|
|
|
|
fileItems = null;
|
|
fileItemsTotal = null;
|
|
|
|
if (sortKey === null) {
|
|
direction = null;
|
|
}
|
|
|
|
const res = await searchKnowledgeFilesById(
|
|
localStorage.token,
|
|
knowledge.id,
|
|
query,
|
|
viewOption,
|
|
sortKey,
|
|
direction,
|
|
currentPage,
|
|
currentDirectoryId,
|
|
includeContent
|
|
).catch(() => {
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
fileItems = res.items;
|
|
fileItemsTotal = res.total;
|
|
directoryItems = res.directories ?? [];
|
|
breadcrumbs = res.breadcrumbs ?? [];
|
|
|
|
// Merge in-flight files not yet linked to the knowledge base
|
|
try {
|
|
const pendingFiles = await getPendingKnowledgeFiles(localStorage.token, knowledgeId);
|
|
if (pendingFiles && pendingFiles.length > 0) {
|
|
const existingIds = new Set(fileItems.map((f) => f.id));
|
|
const newPending = pendingFiles
|
|
.filter((f) => !existingIds.has(f.id))
|
|
.map((f) => ({
|
|
...f,
|
|
name: f.meta?.name ?? f.filename,
|
|
status: 'uploading'
|
|
}));
|
|
if (newPending.length > 0) {
|
|
fileItems = [...newPending, ...fileItems];
|
|
|
|
// Start polling for completion (if not already polling)
|
|
if (!pendingPollTimer) {
|
|
pendingPollTimer = setInterval(async () => {
|
|
try {
|
|
const still = await getPendingKnowledgeFiles(localStorage.token, knowledgeId);
|
|
if (!still || still.length === 0) {
|
|
clearInterval(pendingPollTimer);
|
|
pendingPollTimer = null;
|
|
init();
|
|
}
|
|
} catch {}
|
|
}, 5000);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to fetch pending files:', e);
|
|
}
|
|
}
|
|
|
|
return res;
|
|
};
|
|
|
|
const fileSelectHandler = async (file) => {
|
|
try {
|
|
selectedFile = file;
|
|
selectedFileContent = selectedFile?.data?.content || '';
|
|
} catch (e) {
|
|
toast.error($i18n.t('Failed to load file content.'));
|
|
}
|
|
};
|
|
|
|
const createFileFromText = (name, content) => {
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const file = blobToFile(blob, `${name}.txt`);
|
|
|
|
console.log(file);
|
|
return file;
|
|
};
|
|
|
|
const uploadWeb = async (urls) => {
|
|
if (!Array.isArray(urls)) {
|
|
urls = [urls];
|
|
}
|
|
|
|
const newFileItems = urls.map((url) => ({
|
|
type: 'file',
|
|
file: '',
|
|
id: null,
|
|
url: url,
|
|
name: url,
|
|
size: null,
|
|
status: 'uploading',
|
|
error: '',
|
|
itemId: uuidv4()
|
|
}));
|
|
|
|
// Display all items at once
|
|
fileItems = [...newFileItems, ...(fileItems ?? [])];
|
|
|
|
for (const fileItem of newFileItems) {
|
|
try {
|
|
console.log(fileItem);
|
|
const res = await processWeb(localStorage.token, '', fileItem.url, false).catch((e) => {
|
|
console.error('Error processing web URL:', e);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
console.log(res);
|
|
const file = createFileFromText(
|
|
// Use URL as filename, sanitized
|
|
fileItem.url
|
|
.replace(/[^a-z0-9]/gi, '_')
|
|
.toLowerCase()
|
|
.slice(0, 50),
|
|
res.content
|
|
);
|
|
|
|
const uploadedFile = await uploadFile(localStorage.token, file, {
|
|
knowledge_id: knowledge.id
|
|
}).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (uploadedFile) {
|
|
console.log(uploadedFile);
|
|
fileItems = fileItems.map((item) => {
|
|
if (item.itemId === fileItem.itemId) {
|
|
item.id = uploadedFile.id;
|
|
}
|
|
return item;
|
|
});
|
|
|
|
if (uploadedFile.error) {
|
|
console.warn('File upload warning:', uploadedFile.error);
|
|
toast.warning(uploadedFile.error);
|
|
fileItems = fileItems.filter((file) => file.id !== uploadedFile.id);
|
|
} else {
|
|
toast.success($i18n.t('File added successfully.'));
|
|
init();
|
|
}
|
|
} else {
|
|
toast.error($i18n.t('Failed to upload file.'));
|
|
}
|
|
} else {
|
|
// remove the item from fileItems
|
|
fileItems = fileItems.filter((item) => item.itemId !== fileItem.itemId);
|
|
toast.error($i18n.t('Failed to process URL: {{url}}', { url: fileItem.url }));
|
|
}
|
|
} catch (e) {
|
|
// remove the item from fileItems
|
|
fileItems = fileItems.filter((item) => item.itemId !== fileItem.itemId);
|
|
toast.error(`${e}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const uploadFileHandler = async (file) => {
|
|
console.log(file);
|
|
|
|
const fileItem = {
|
|
type: 'file',
|
|
file: '',
|
|
id: null,
|
|
url: '',
|
|
name: file.name,
|
|
size: file.size,
|
|
status: 'uploading',
|
|
error: '',
|
|
itemId: uuidv4()
|
|
};
|
|
|
|
if (fileItem.size == 0) {
|
|
toast.error($i18n.t('You cannot upload an empty file.'));
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
($config?.file?.max_size ?? null) !== null &&
|
|
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
|
|
) {
|
|
console.log('File exceeds max size limit:', {
|
|
fileSize: file.size,
|
|
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
|
|
});
|
|
toast.error(
|
|
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
|
|
maxSize: $config?.file?.max_size
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
fileItems = [fileItem, ...(fileItems ?? [])];
|
|
try {
|
|
let metadata = {
|
|
knowledge_id: knowledge.id,
|
|
// If the file is an audio file, provide the language for STT.
|
|
...((file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
|
$settings?.audio?.stt?.language
|
|
? {
|
|
language: $settings?.audio?.stt?.language
|
|
}
|
|
: {})
|
|
};
|
|
|
|
const uploadedFile = await uploadFile(localStorage.token, file, metadata).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (uploadedFile) {
|
|
console.log(uploadedFile);
|
|
fileItems = fileItems.map((item) => {
|
|
if (item.itemId === fileItem.itemId) {
|
|
item.id = uploadedFile.id;
|
|
}
|
|
return item;
|
|
});
|
|
|
|
if (uploadedFile.error) {
|
|
console.warn('File upload warning:', uploadedFile.error);
|
|
toast.warning(uploadedFile.error);
|
|
fileItems = fileItems.filter((file) => file.id !== uploadedFile.id);
|
|
} else {
|
|
toast.success($i18n.t('File added successfully.'));
|
|
init();
|
|
}
|
|
} else {
|
|
toast.error($i18n.t('Failed to upload file.'));
|
|
}
|
|
} catch (e) {
|
|
toast.error(`${e}`);
|
|
}
|
|
};
|
|
|
|
const uploadDirectoryHandler = async () => {
|
|
// Check if File System Access API is supported
|
|
const isFileSystemAccessSupported = 'showDirectoryPicker' in window;
|
|
|
|
try {
|
|
if (isFileSystemAccessSupported) {
|
|
// Modern browsers (Chrome, Edge) implementation
|
|
await handleModernBrowserUpload();
|
|
} else {
|
|
// Firefox fallback
|
|
await handleFirefoxUpload();
|
|
}
|
|
} catch (error) {
|
|
handleUploadError(error);
|
|
}
|
|
};
|
|
|
|
// Helper function to check if a path contains hidden folders
|
|
const hasHiddenFolder = (path) => {
|
|
return path.split('/').some((part) => part.startsWith('.'));
|
|
};
|
|
|
|
// Modern browsers implementation using File System Access API
|
|
const handleModernBrowserUpload = async () => {
|
|
const dirHandle = await window.showDirectoryPicker();
|
|
let totalFiles = 0;
|
|
let uploadedFiles = 0;
|
|
|
|
// Function to update the UI with the progress
|
|
const updateProgress = () => {
|
|
const percentage = (uploadedFiles / totalFiles) * 100;
|
|
toast.info(
|
|
$i18n.t('Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)', {
|
|
uploadedFiles: uploadedFiles,
|
|
totalFiles: totalFiles,
|
|
percentage: percentage.toFixed(2)
|
|
})
|
|
);
|
|
};
|
|
|
|
// Recursive function to count all files excluding hidden ones
|
|
async function countFiles(dirHandle) {
|
|
for await (const entry of dirHandle.values()) {
|
|
// Skip hidden files and directories
|
|
if (entry.name.startsWith('.')) continue;
|
|
|
|
if (entry.kind === 'file') {
|
|
totalFiles++;
|
|
} else if (entry.kind === 'directory') {
|
|
// Only process non-hidden directories
|
|
if (!entry.name.startsWith('.')) {
|
|
await countFiles(entry);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recursive function to process directories excluding hidden files and folders
|
|
async function processDirectory(dirHandle, path = '') {
|
|
for await (const entry of dirHandle.values()) {
|
|
// Skip hidden files and directories
|
|
if (entry.name.startsWith('.')) continue;
|
|
|
|
const entryPath = path ? `${path}/${entry.name}` : entry.name;
|
|
|
|
// Skip if the path contains any hidden folders
|
|
if (hasHiddenFolder(entryPath)) continue;
|
|
|
|
if (entry.kind === 'file') {
|
|
const file = await entry.getFile();
|
|
const fileWithPath = new File([file], entryPath, { type: file.type });
|
|
|
|
await uploadFileHandler(fileWithPath);
|
|
uploadedFiles++;
|
|
updateProgress();
|
|
} else if (entry.kind === 'directory') {
|
|
// Only process non-hidden directories
|
|
if (!entry.name.startsWith('.')) {
|
|
await processDirectory(entry, entryPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await countFiles(dirHandle);
|
|
updateProgress();
|
|
|
|
if (totalFiles > 0) {
|
|
await processDirectory(dirHandle);
|
|
} else {
|
|
console.log('No files to upload.');
|
|
}
|
|
};
|
|
|
|
// Firefox fallback implementation using traditional file input
|
|
const handleFirefoxUpload = async () => {
|
|
return new Promise((resolve, reject) => {
|
|
// Create hidden file input
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.webkitdirectory = true;
|
|
input.directory = true;
|
|
input.multiple = true;
|
|
input.style.display = 'none';
|
|
|
|
// Add input to DOM temporarily
|
|
document.body.appendChild(input);
|
|
|
|
input.onchange = async () => {
|
|
try {
|
|
const files = Array.from(input.files)
|
|
// Filter out files from hidden folders
|
|
.filter((file) => !hasHiddenFolder(file.webkitRelativePath));
|
|
|
|
let totalFiles = files.length;
|
|
let uploadedFiles = 0;
|
|
|
|
// Function to update the UI with the progress
|
|
const updateProgress = () => {
|
|
const percentage = (uploadedFiles / totalFiles) * 100;
|
|
toast.info(
|
|
$i18n.t('Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)', {
|
|
uploadedFiles: uploadedFiles,
|
|
totalFiles: totalFiles,
|
|
percentage: percentage.toFixed(2)
|
|
})
|
|
);
|
|
};
|
|
|
|
updateProgress();
|
|
|
|
// Process all files
|
|
for (const file of files) {
|
|
// Skip hidden files (additional check)
|
|
if (!file.name.startsWith('.')) {
|
|
const relativePath = file.webkitRelativePath || file.name;
|
|
const fileWithPath = new File([file], relativePath, { type: file.type });
|
|
|
|
await uploadFileHandler(fileWithPath);
|
|
uploadedFiles++;
|
|
updateProgress();
|
|
}
|
|
}
|
|
|
|
// Clean up
|
|
document.body.removeChild(input);
|
|
resolve();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
input.onerror = (error) => {
|
|
document.body.removeChild(input);
|
|
reject(error);
|
|
};
|
|
|
|
// Trigger file picker
|
|
input.click();
|
|
});
|
|
};
|
|
|
|
// Error handler
|
|
const handleUploadError = (error) => {
|
|
if (error.name === 'AbortError') {
|
|
toast.info($i18n.t('Directory selection was cancelled'));
|
|
} else {
|
|
toast.error($i18n.t('Error accessing directory'));
|
|
console.error('Directory access error:', error);
|
|
}
|
|
};
|
|
|
|
// Collect files from a directory without uploading — returns {path, filename, file}[]
|
|
const collectDirectoryFiles = async (): Promise<Array<{
|
|
path: string;
|
|
filename: string;
|
|
file: File;
|
|
}> | null> => {
|
|
const isFileSystemAccessSupported = 'showDirectoryPicker' in window;
|
|
|
|
try {
|
|
if (isFileSystemAccessSupported) {
|
|
const dirHandle = await window.showDirectoryPicker();
|
|
const collected: Array<{ path: string; filename: string; file: File }> = [];
|
|
|
|
async function traverse(handle: FileSystemDirectoryHandle, dirPath = '') {
|
|
for await (const entry of handle.values()) {
|
|
if (entry.name.startsWith('.')) continue;
|
|
const entryPath = dirPath ? `${dirPath}/${entry.name}` : entry.name;
|
|
if (hasHiddenFolder(entryPath)) continue;
|
|
|
|
if (entry.kind === 'file') {
|
|
const file = await entry.getFile();
|
|
collected.push({ path: dirPath, filename: entry.name, file });
|
|
} else if (entry.kind === 'directory') {
|
|
await traverse(entry, entryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
await traverse(dirHandle);
|
|
return collected;
|
|
} else {
|
|
// Firefox fallback
|
|
return new Promise((resolve, reject) => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.webkitdirectory = true;
|
|
input.directory = true;
|
|
input.multiple = true;
|
|
input.style.display = 'none';
|
|
document.body.appendChild(input);
|
|
|
|
input.onchange = () => {
|
|
try {
|
|
const files = Array.from(input.files || []).filter(
|
|
(file) => !hasHiddenFolder(file.webkitRelativePath) && !file.name.startsWith('.')
|
|
);
|
|
|
|
const collected = files.map((file) => {
|
|
const parts = file.webkitRelativePath.split('/');
|
|
// Remove root dir name, extract path and filename
|
|
const withoutRoot = parts.slice(1);
|
|
const filename = withoutRoot.pop() || file.name;
|
|
const path = withoutRoot.join('/');
|
|
return { path, filename, file };
|
|
});
|
|
|
|
document.body.removeChild(input);
|
|
resolve(collected);
|
|
} catch (error) {
|
|
document.body.removeChild(input);
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
input.onerror = (error) => {
|
|
document.body.removeChild(input);
|
|
reject(error);
|
|
};
|
|
|
|
input.click();
|
|
});
|
|
}
|
|
} catch (error) {
|
|
handleUploadError(error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Incremental sync: hash locally → diff on server → upload only what changed
|
|
const syncDirectoryHandler = async () => {
|
|
if (!pendingSyncFiles?.length) return;
|
|
|
|
try {
|
|
// ── 2. Compute checksums ──
|
|
syncing = $i18n.t('Computing checksums ({{count}} files)', {
|
|
count: pendingSyncFiles.length
|
|
});
|
|
const manifest = await Promise.all(
|
|
pendingSyncFiles.map(async (entry) => ({
|
|
...entry,
|
|
checksum: await computeFileHash(entry.file),
|
|
size: entry.file.size
|
|
}))
|
|
);
|
|
pendingSyncFiles = null;
|
|
|
|
// ── 3. Diff against knowledge base ──
|
|
syncing = $i18n.t('Comparing with knowledge base...');
|
|
const diff = await syncKnowledgeDiff(
|
|
localStorage.token,
|
|
id,
|
|
manifest.map(({ filename, path, checksum, size }) => ({ filename, path, checksum, size }))
|
|
);
|
|
|
|
if (!diff) {
|
|
toast.error($i18n.t('Failed to compare files.'));
|
|
return;
|
|
}
|
|
|
|
// ── 4. Cleanup — remove deleted + stale modified files first ──
|
|
const staleFileIds = [
|
|
...diff.deleted.map((d: any) => d.file_id),
|
|
...diff.modified.map((m: any) => m.stale_file_id)
|
|
];
|
|
|
|
if (staleFileIds.length > 0 || diff.rmdir.length > 0) {
|
|
syncing = $i18n.t('Removing {{count}} stale files...', { count: staleFileIds.length });
|
|
await syncKnowledgeCleanup(localStorage.token, id, staleFileIds, diff.rmdir);
|
|
}
|
|
|
|
// ── 5. mkdir — create missing directories (parents first) ──
|
|
const directoryIdByPath: Record<string, string> = { ...(diff.directory_map || {}) };
|
|
for (const dirPath of diff.mkdir) {
|
|
const segments = dirPath.split('/');
|
|
const name = segments.at(-1)!;
|
|
const parentPath = segments.slice(0, -1).join('/');
|
|
const parentId = parentPath ? directoryIdByPath[parentPath] : null;
|
|
|
|
const directory = await createKnowledgeDirectory(
|
|
localStorage.token,
|
|
knowledge.id,
|
|
name,
|
|
parentId
|
|
);
|
|
if (directory) {
|
|
directoryIdByPath[dirPath] = directory.id;
|
|
}
|
|
}
|
|
|
|
// ── 6. Upload added + modified files ──
|
|
const filesToUpload = manifest.filter(
|
|
(entry) =>
|
|
diff.added.some((a: any) => a.filename === entry.filename && a.path === entry.path) ||
|
|
diff.modified.some((m: any) => m.filename === entry.filename && m.path === entry.path)
|
|
);
|
|
|
|
let uploadedCount = 0;
|
|
for (const entry of filesToUpload) {
|
|
uploadedCount++;
|
|
const displayPath = entry.path ? `${entry.path}/${entry.filename}` : entry.filename;
|
|
syncing = $i18n.t('Uploading {{current}}/{{total}}: {{file}}', {
|
|
current: uploadedCount,
|
|
total: filesToUpload.length,
|
|
file: displayPath
|
|
});
|
|
|
|
const fileObject = new File([entry.file], entry.filename, { type: entry.file.type });
|
|
await uploadFile(localStorage.token, fileObject, {
|
|
knowledge_id: knowledge.id,
|
|
file_hash: entry.checksum,
|
|
directory_id: entry.path ? directoryIdByPath[entry.path] : null
|
|
}).catch(() => null);
|
|
}
|
|
|
|
// ── 7. Report ──
|
|
toast.success(
|
|
$i18n.t(
|
|
'Sync complete: {{added}} added, {{modified}} modified, {{deleted}} deleted, {{unmodified}} unmodified',
|
|
{
|
|
added: diff.added.length,
|
|
modified: diff.modified.length,
|
|
deleted: diff.deleted.length,
|
|
unmodified: diff.unmodified_count
|
|
}
|
|
)
|
|
);
|
|
init();
|
|
} catch (e) {
|
|
toast.error(`${e}`);
|
|
} finally {
|
|
syncing = null;
|
|
}
|
|
};
|
|
|
|
const addFileHandler = async (fileId) => {
|
|
const res = await addFileToKnowledgeById(
|
|
localStorage.token,
|
|
id,
|
|
fileId,
|
|
currentDirectoryId
|
|
).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('File added successfully.'));
|
|
init();
|
|
} else {
|
|
toast.error($i18n.t('Failed to add file.'));
|
|
fileItems = fileItems.filter((file) => file.id !== fileId);
|
|
}
|
|
};
|
|
|
|
// Directory handlers
|
|
const navigateToDirectory = (directoryId: string | null) => {
|
|
currentDirectoryId = directoryId;
|
|
currentPage = 1;
|
|
selectedFileId = null;
|
|
selectedFile = null;
|
|
getItemsPage();
|
|
};
|
|
|
|
const createDirectoryHandler = async (name: string) => {
|
|
const res = await createKnowledgeDirectory(
|
|
localStorage.token,
|
|
knowledge.id,
|
|
name,
|
|
currentDirectoryId
|
|
).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('Directory created.'));
|
|
getItemsPage();
|
|
}
|
|
};
|
|
|
|
const renameDirectoryHandler = async (dirId: string, name: string) => {
|
|
const res = await updateKnowledgeDirectory(localStorage.token, knowledge.id, dirId, {
|
|
name
|
|
}).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('Directory renamed.'));
|
|
getItemsPage();
|
|
}
|
|
};
|
|
|
|
const confirmDeleteDirectory = (dirId: string) => {
|
|
pendingDeleteDirectoryId = dirId;
|
|
showDeleteDirectoryConfirm = true;
|
|
};
|
|
|
|
const deleteDirectoryHandler = async (moveFiles: boolean) => {
|
|
if (!pendingDeleteDirectoryId) return;
|
|
|
|
const res = await deleteKnowledgeDirectory(
|
|
localStorage.token,
|
|
knowledge.id,
|
|
pendingDeleteDirectoryId,
|
|
moveFiles
|
|
).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('Directory deleted.'));
|
|
getItemsPage();
|
|
}
|
|
pendingDeleteDirectoryId = null;
|
|
};
|
|
|
|
const moveFileToDirectoryHandler = async (fileId: string, directoryId: string | null) => {
|
|
const res = await moveFileInKnowledge(
|
|
localStorage.token,
|
|
knowledge.id,
|
|
fileId,
|
|
directoryId
|
|
).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('File moved.'));
|
|
getItemsPage();
|
|
}
|
|
};
|
|
|
|
const moveDirectoryHandler = async (dirId: string, targetParentId: string | null) => {
|
|
if (dirId === targetParentId) return;
|
|
const res = await updateKnowledgeDirectory(localStorage.token, knowledge.id, dirId, {
|
|
parent_id: targetParentId
|
|
}).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('Directory moved.'));
|
|
getItemsPage();
|
|
}
|
|
};
|
|
|
|
const deleteFileHandler = async (fileId) => {
|
|
try {
|
|
console.log('Starting file deletion process for:', fileId);
|
|
|
|
// Remove from knowledge base only
|
|
const res = await removeFileFromKnowledgeById(localStorage.token, id, fileId);
|
|
console.log('Knowledge base updated:', res);
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('File removed successfully.'));
|
|
await init();
|
|
}
|
|
} catch (e) {
|
|
console.error('Error in deleteFileHandler:', e);
|
|
toast.error(`${e}`);
|
|
}
|
|
};
|
|
|
|
const renameFileHandler = async (fileId: string, name: string) => {
|
|
try {
|
|
const res = await renameFileById(localStorage.token, fileId, name);
|
|
if (res) {
|
|
toast.success($i18n.t('File renamed.'));
|
|
getItemsPage();
|
|
}
|
|
} catch (e) {
|
|
toast.error(`${e}`);
|
|
}
|
|
};
|
|
|
|
let debounceTimeout = null;
|
|
let mediaQuery;
|
|
|
|
let dragged = false;
|
|
let isSaving = false;
|
|
|
|
const updateFileContentHandler = async () => {
|
|
if (isSaving) {
|
|
console.log('Save operation already in progress, skipping...');
|
|
return;
|
|
}
|
|
|
|
isSaving = true;
|
|
|
|
try {
|
|
const res = await updateFileDataContentById(
|
|
localStorage.token,
|
|
selectedFile.id,
|
|
selectedFileContent
|
|
).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('File content updated successfully.'));
|
|
|
|
selectedFileId = null;
|
|
selectedFile = null;
|
|
selectedFileContent = '';
|
|
|
|
await init();
|
|
}
|
|
} finally {
|
|
isSaving = false;
|
|
}
|
|
};
|
|
|
|
const changeDebounceHandler = () => {
|
|
console.log('debounce');
|
|
if (debounceTimeout) {
|
|
clearTimeout(debounceTimeout);
|
|
}
|
|
|
|
debounceTimeout = setTimeout(async () => {
|
|
if (knowledge.name.trim() === '' || knowledge.description.trim() === '') {
|
|
toast.error($i18n.t('Please fill in all fields.'));
|
|
return;
|
|
}
|
|
|
|
const res = await updateKnowledgeById(localStorage.token, id, {
|
|
...knowledge,
|
|
name: knowledge.name,
|
|
description: knowledge.description,
|
|
access_grants: knowledge.access_grants ?? []
|
|
}).catch((e) => {
|
|
toast.error(`${e}`);
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('Knowledge updated successfully'));
|
|
}
|
|
}, 1000);
|
|
};
|
|
|
|
const handleMediaQuery = async (e) => {
|
|
if (e.matches) {
|
|
largeScreen = true;
|
|
} else {
|
|
largeScreen = false;
|
|
}
|
|
};
|
|
|
|
const onDragOver = (e) => {
|
|
e.preventDefault();
|
|
|
|
// Check if a file is being draggedOver.
|
|
if (e.dataTransfer?.types?.includes('Files')) {
|
|
dragged = true;
|
|
} else {
|
|
dragged = false;
|
|
}
|
|
};
|
|
|
|
const onDragLeave = () => {
|
|
dragged = false;
|
|
};
|
|
|
|
const onDrop = async (e) => {
|
|
e.preventDefault();
|
|
dragged = false;
|
|
|
|
if (!knowledge?.write_access) {
|
|
toast.error($i18n.t('You do not have permission to upload files to this knowledge base.'));
|
|
return;
|
|
}
|
|
|
|
const handleUploadingFileFolder = (items) => {
|
|
for (const item of items) {
|
|
if (item.isFile) {
|
|
item.file((file) => {
|
|
uploadFileHandler(file);
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Not sure why you have to call webkitGetAsEntry and isDirectory seperate, but it won't work if you try item.webkitGetAsEntry().isDirectory
|
|
const wkentry = item.webkitGetAsEntry();
|
|
const isDirectory = wkentry.isDirectory;
|
|
if (isDirectory) {
|
|
// Read the directory
|
|
wkentry.createReader().readEntries(
|
|
(entries) => {
|
|
handleUploadingFileFolder(entries);
|
|
},
|
|
(error) => {
|
|
console.error('Error reading directory entries:', error);
|
|
}
|
|
);
|
|
} else {
|
|
uploadFileHandler(item.getAsFile());
|
|
}
|
|
}
|
|
};
|
|
|
|
if (e.dataTransfer?.types?.includes('Files')) {
|
|
if (e.dataTransfer?.files) {
|
|
const inputItems = e.dataTransfer?.items;
|
|
|
|
if (inputItems && inputItems.length > 0) {
|
|
handleUploadingFileFolder(inputItems);
|
|
} else {
|
|
toast.error($i18n.t(`File not found.`));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
onMount(async () => {
|
|
// listen to resize 1024px
|
|
mediaQuery = window.matchMedia('(min-width: 1024px)');
|
|
|
|
mediaQuery.addEventListener('change', handleMediaQuery);
|
|
handleMediaQuery(mediaQuery);
|
|
|
|
// Select the container element you want to observe
|
|
const container = document.getElementById('collection-container');
|
|
|
|
// initialize the minSize based on the container width
|
|
minSize = !largeScreen ? 100 : Math.floor((300 / container.clientWidth) * 100);
|
|
|
|
// Create a new ResizeObserver instance
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
for (let entry of entries) {
|
|
const width = entry.contentRect.width;
|
|
// calculate the percentage of 300
|
|
const percentage = (300 / width) * 100;
|
|
// set the minSize to the percentage, must be an integer
|
|
minSize = !largeScreen ? 100 : Math.floor(percentage);
|
|
|
|
if (showSidepanel) {
|
|
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
|
|
pane.resize(minSize);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Start observing the container's size changes
|
|
resizeObserver.observe(container);
|
|
|
|
if (pane) {
|
|
pane.expand();
|
|
}
|
|
|
|
id = $page.params.id;
|
|
const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
|
|
toast.error(`${e}`);
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
knowledge = res;
|
|
if (!Array.isArray(knowledge?.access_grants)) {
|
|
knowledge.access_grants = [];
|
|
}
|
|
knowledgeId = knowledge?.id;
|
|
} else {
|
|
goto('/workspace/knowledge');
|
|
}
|
|
|
|
const dropZone = document.querySelector('body');
|
|
dropZone?.addEventListener('dragover', onDragOver);
|
|
dropZone?.addEventListener('drop', onDrop);
|
|
dropZone?.addEventListener('dragleave', onDragLeave);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
clearTimeout(searchDebounceTimer);
|
|
if (pendingPollTimer) {
|
|
clearInterval(pendingPollTimer);
|
|
pendingPollTimer = null;
|
|
}
|
|
mediaQuery?.removeEventListener('change', handleMediaQuery);
|
|
const dropZone = document.querySelector('body');
|
|
dropZone?.removeEventListener('dragover', onDragOver);
|
|
dropZone?.removeEventListener('drop', onDrop);
|
|
dropZone?.removeEventListener('dragleave', onDragLeave);
|
|
});
|
|
|
|
const decodeString = (str: string) => {
|
|
try {
|
|
return decodeURIComponent(str);
|
|
} catch (e) {
|
|
return str;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<FilesOverlay show={dragged} />
|
|
<SyncConfirmDialog
|
|
bind:show={showSyncConfirmModal}
|
|
message={$i18n.t(
|
|
'{{count}} files selected. Only new and modified files will be uploaded. Deleted files will be removed. The folder structure will be mirrored. Continue?',
|
|
{ count: pendingSyncFiles?.length ?? 0 }
|
|
)}
|
|
on:confirm={() => {
|
|
syncDirectoryHandler();
|
|
}}
|
|
on:cancel={() => {
|
|
pendingSyncFiles = null;
|
|
}}
|
|
/>
|
|
|
|
<AttachWebpageModal
|
|
bind:show={showAddWebpageModal}
|
|
onSubmit={async (e) => {
|
|
uploadWeb(e.data);
|
|
}}
|
|
/>
|
|
|
|
<AddTextContentModal
|
|
bind:show={showAddTextContentModal}
|
|
on:submit={(e) => {
|
|
const file = createFileFromText(e.detail.name, e.detail.content);
|
|
uploadFileHandler(file);
|
|
}}
|
|
/>
|
|
|
|
<NewDirectoryModal
|
|
bind:show={showNewDirectoryModal}
|
|
on:submit={(e) => {
|
|
createDirectoryHandler(e.detail.name);
|
|
}}
|
|
/>
|
|
|
|
<input
|
|
id="files-input"
|
|
bind:files={inputFiles}
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
on:change={async () => {
|
|
if (inputFiles && inputFiles.length > 0) {
|
|
for (const file of inputFiles) {
|
|
await uploadFileHandler(file);
|
|
}
|
|
|
|
inputFiles = null;
|
|
const fileInputElement = document.getElementById('files-input');
|
|
|
|
if (fileInputElement) {
|
|
fileInputElement.value = '';
|
|
}
|
|
} else {
|
|
toast.error($i18n.t(`File not found.`));
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<div class="flex flex-col w-full h-full min-h-full" id="collection-container">
|
|
{#if id && knowledge}
|
|
<AccessControlModal
|
|
bind:show={showAccessControlModal}
|
|
bind:accessGrants={knowledge.access_grants}
|
|
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
|
|
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
|
shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) ||
|
|
$user?.role === 'admin'}
|
|
onChange={async () => {
|
|
try {
|
|
await updateKnowledgeAccessGrants(localStorage.token, id, knowledge.access_grants ?? []);
|
|
toast.success($i18n.t('Saved'));
|
|
} catch (error) {
|
|
toast.error(`${error}`);
|
|
}
|
|
}}
|
|
accessRoles={['read', 'write']}
|
|
/>
|
|
<div class="w-full px-2">
|
|
<div class=" flex w-full">
|
|
<div class="flex-1">
|
|
<div class="flex items-center justify-between w-full">
|
|
<div class="w-full flex justify-between items-center">
|
|
<input
|
|
type="text"
|
|
class="text-left w-full text-lg bg-transparent outline-hidden flex-1"
|
|
bind:value={knowledge.name}
|
|
aria-label={$i18n.t('Knowledge Name')}
|
|
placeholder={$i18n.t('Knowledge Name')}
|
|
disabled={!knowledge?.write_access}
|
|
on:input={() => {
|
|
changeDebounceHandler();
|
|
}}
|
|
/>
|
|
|
|
<div class="shrink-0 mr-2.5">
|
|
{#if fileItemsTotal}
|
|
<div class="text-xs text-gray-500">
|
|
<!-- {$i18n.t('{{COUNT}} files')} -->
|
|
{$i18n.t('{{COUNT}} files', {
|
|
COUNT: fileItemsTotal
|
|
})}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if knowledge?.write_access}
|
|
<div class="self-center shrink-0">
|
|
<button
|
|
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
|
|
type="button"
|
|
on:click={() => {
|
|
showAccessControlModal = true;
|
|
}}
|
|
>
|
|
<LockClosed strokeWidth="2.5" className="size-3.5" />
|
|
|
|
<div class="text-sm font-medium shrink-0">
|
|
{$i18n.t('Access')}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="text-xs shrink-0 text-gray-500">
|
|
{$i18n.t('Read Only')}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex w-full items-center">
|
|
<input
|
|
type="text"
|
|
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden flex-1"
|
|
bind:value={knowledge.description}
|
|
aria-label={$i18n.t('Knowledge Description')}
|
|
placeholder={$i18n.t('Knowledge Description')}
|
|
disabled={!knowledge?.write_access}
|
|
on:input={() => {
|
|
changeDebounceHandler();
|
|
}}
|
|
/>
|
|
|
|
<div class="hidden md:block">
|
|
<Tooltip content={$i18n.t('Click to copy ID')}>
|
|
<button
|
|
class="text-xs text-gray-500 font-mono shrink-0 px-2 py-1 rounded-lg cursor-pointer hover:underline transition whitespace-nowrap"
|
|
on:click={() => {
|
|
copyToClipboard(id);
|
|
toast.success($i18n.t('ID copied to clipboard'));
|
|
}}
|
|
>
|
|
{id}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="mt-2 mb-2.5 py-2 -mx-0 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30 flex-1"
|
|
>
|
|
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
|
<div class="flex flex-1 items-center">
|
|
<div class=" self-center ml-1 mr-3">
|
|
<Search className="size-3.5" />
|
|
</div>
|
|
<input
|
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
|
bind:value={query}
|
|
aria-label={$i18n.t('Search Collection')}
|
|
placeholder={$i18n.t('Search Collection')}
|
|
on:focus={() => {
|
|
selectedFileId = null;
|
|
}}
|
|
/>
|
|
|
|
<Dropdown align="end">
|
|
<button
|
|
class="p-1.5 mr-1 rounded-xl text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
|
type="button"
|
|
>
|
|
<AdjustmentsHorizontal className="size-3.5" strokeWidth="2" />
|
|
</button>
|
|
|
|
<div slot="content">
|
|
<div
|
|
class="min-w-[180px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
|
>
|
|
<button
|
|
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
|
type="button"
|
|
on:click={() => {
|
|
includeContent = !includeContent;
|
|
}}
|
|
>
|
|
<Checkbox
|
|
state={includeContent ? 'checked' : 'unchecked'}
|
|
on:change={(e) => {
|
|
includeContent = e.detail === 'checked';
|
|
}}
|
|
/>
|
|
{$i18n.t('File content')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Dropdown>
|
|
|
|
{#if knowledge?.write_access}
|
|
<div>
|
|
<AddContentMenu
|
|
onUpload={(data) => {
|
|
if (data.type === 'directory') {
|
|
uploadDirectoryHandler();
|
|
} else if (data.type === 'new_directory') {
|
|
showNewDirectoryModal = true;
|
|
} else if (data.type === 'web') {
|
|
showAddWebpageModal = true;
|
|
} else if (data.type === 'text') {
|
|
showAddTextContentModal = true;
|
|
} else {
|
|
document.getElementById('files-input').click();
|
|
}
|
|
}}
|
|
onSync={async () => {
|
|
pendingSyncFiles = await collectDirectoryFiles();
|
|
if (pendingSyncFiles?.length) {
|
|
showSyncConfirmModal = true;
|
|
}
|
|
}}
|
|
onReset={() => {
|
|
showResetConfirm = true;
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-3 flex justify-between">
|
|
<div
|
|
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
|
|
on:wheel={(e) => {
|
|
if (e.deltaY !== 0) {
|
|
e.preventDefault();
|
|
e.currentTarget.scrollLeft += e.deltaY;
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
|
>
|
|
<DropdownOptions
|
|
align="start"
|
|
className="flex shrink-0 items-center gap-2 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
|
|
bind:value={viewOption}
|
|
items={[
|
|
{ value: null, label: $i18n.t('All') },
|
|
{ value: 'created', label: $i18n.t('Created by you') },
|
|
{ value: 'shared', label: $i18n.t('Shared with you') }
|
|
]}
|
|
onChange={(value) => {
|
|
if (value) {
|
|
localStorage.workspaceViewOption = value;
|
|
} else {
|
|
delete localStorage.workspaceViewOption;
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<DropdownOptions
|
|
align="start"
|
|
bind:value={sortKey}
|
|
placeholder={$i18n.t('Sort')}
|
|
items={[
|
|
{ value: 'name', label: $i18n.t('Name') },
|
|
{ value: 'created_at', label: $i18n.t('Created At') },
|
|
{ value: 'updated_at', label: $i18n.t('Updated At') }
|
|
]}
|
|
/>
|
|
|
|
{#if sortKey}
|
|
<DropdownOptions
|
|
align="start"
|
|
bind:value={direction}
|
|
items={[
|
|
{ value: 'asc', label: $i18n.t('Asc') },
|
|
{ value: null, label: $i18n.t('Desc') }
|
|
]}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if currentDirectoryId !== null}
|
|
<div class="px-5 mt-2">
|
|
<KnowledgeBreadcrumbs
|
|
rootLabel={knowledge.name}
|
|
{breadcrumbs}
|
|
onNavigate={(dirId) => navigateToDirectory(dirId)}
|
|
onMoveFile={(fileId, dirId) => moveFileToDirectoryHandler(fileId, dirId)}
|
|
onMoveDir={(dirId, targetId) => moveDirectoryHandler(dirId, targetId)}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if syncing}
|
|
<div class="mx-2.5 mt-2.5 -mb-0.5">
|
|
<div class="flex items-center gap-2.5 rounded-xl py-2 px-3 bg-gray-50 dark:bg-gray-850">
|
|
<Spinner className="size-3.5 shrink-0" />
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
{syncing}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if fileItems !== null && fileItemsTotal !== null}
|
|
<div class="flex flex-row flex-1 gap-3 px-2.5 mt-2">
|
|
<div class="flex-1 flex">
|
|
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
|
|
<div class="w-full h-full flex flex-col min-h-full">
|
|
{#if fileItems.length > 0 || directoryItems.length > 0}
|
|
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
|
|
<Files
|
|
files={fileItems}
|
|
directories={directoryItems}
|
|
{knowledge}
|
|
{selectedFileId}
|
|
onClick={(fileId) => {
|
|
selectedFileId = fileId;
|
|
|
|
if (fileItems) {
|
|
const file = fileItems.find((file) => file.id === selectedFileId);
|
|
if (file) {
|
|
fileSelectHandler(file);
|
|
} else {
|
|
selectedFile = null;
|
|
}
|
|
}
|
|
}}
|
|
onDelete={(fileId) => {
|
|
selectedFileId = null;
|
|
selectedFile = null;
|
|
|
|
deleteFileHandler(fileId);
|
|
}}
|
|
onRename={(fileId, name) => renameFileHandler(fileId, name)}
|
|
onNavigateDirectory={(dirId) => navigateToDirectory(dirId)}
|
|
onRenameDirectory={(id, name) => renameDirectoryHandler(id, name)}
|
|
onDeleteDirectory={(id) => confirmDeleteDirectory(id)}
|
|
onMoveFileToDirectory={(fileId, dirId) =>
|
|
moveFileToDirectoryHandler(fileId, dirId)}
|
|
onMoveDirectoryToDirectory={(dirId, targetId) =>
|
|
moveDirectoryHandler(dirId, targetId)}
|
|
/>
|
|
</div>
|
|
|
|
{#if fileItemsTotal > 30}
|
|
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
|
|
{/if}
|
|
{:else}
|
|
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
|
<div>
|
|
{$i18n.t('No content found')}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if selectedFileId !== null}
|
|
<Drawer
|
|
className="h-full"
|
|
show={selectedFileId !== null}
|
|
onClose={() => {
|
|
selectedFileId = null;
|
|
selectedFile = null;
|
|
}}
|
|
>
|
|
<div class="flex flex-col justify-start h-full max-h-full">
|
|
<div class=" flex flex-col w-full h-full max-h-full">
|
|
<div class="shrink-0 flex items-center p-2">
|
|
<div class="mr-2">
|
|
<button
|
|
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
|
aria-label={$i18n.t('Close')}
|
|
on:click={() => {
|
|
selectedFileId = null;
|
|
selectedFile = null;
|
|
}}
|
|
>
|
|
<ChevronLeft strokeWidth="2.5" />
|
|
</button>
|
|
</div>
|
|
<div class=" flex-1 text-lg line-clamp-1">
|
|
{selectedFile?.meta?.name}
|
|
</div>
|
|
|
|
{#if knowledge?.write_access}
|
|
<div>
|
|
<button
|
|
class="flex self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
disabled={isSaving}
|
|
on:click={() => {
|
|
updateFileContentHandler();
|
|
}}
|
|
>
|
|
{$i18n.t('Save')}
|
|
{#if isSaving}
|
|
<div class="ml-2 self-center">
|
|
<Spinner />
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#key selectedFile.id}
|
|
<textarea
|
|
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
|
|
bind:value={selectedFileContent}
|
|
disabled={!knowledge?.write_access}
|
|
aria-label={$i18n.t('File content')}
|
|
placeholder={$i18n.t('Add content here')}
|
|
/>
|
|
{/key}
|
|
</div>
|
|
</div>
|
|
</Drawer>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="my-10">
|
|
<Spinner className="size-4" />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<Spinner className="size-5" />
|
|
{/if}
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
bind:show={showDeleteDirectoryConfirm}
|
|
title={$i18n.t('Delete directory?')}
|
|
on:confirm={() => {
|
|
deleteDirectoryHandler(!deleteDirectoryContents);
|
|
}}
|
|
on:cancel={() => {
|
|
pendingDeleteDirectoryId = null;
|
|
}}
|
|
>
|
|
<div class="text-sm text-gray-700 dark:text-gray-300 flex-1 line-clamp-3 mb-2">
|
|
{$i18n.t(`Are you sure you want to delete this directory?`)}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
<input type="checkbox" bind:checked={deleteDirectoryContents} />
|
|
|
|
<div class="text-xs text-gray-500">
|
|
{$i18n.t('Delete all contents inside this directory')}
|
|
</div>
|
|
</div>
|
|
</ConfirmDialog>
|
|
|
|
<ConfirmDialog
|
|
bind:show={showResetConfirm}
|
|
title={$i18n.t('Reset knowledge base?')}
|
|
on:confirm={async () => {
|
|
await resetKnowledgeById(localStorage.token, id);
|
|
toast.success($i18n.t('Knowledge base has been reset'));
|
|
init();
|
|
}}
|
|
>
|
|
<div class="text-sm text-gray-700 dark:text-gray-300 flex-1 line-clamp-3">
|
|
{$i18n.t(
|
|
'This will remove all files and directories from this knowledge base. This action cannot be undone.'
|
|
)}
|
|
</div>
|
|
</ConfirmDialog>
|