files cant't be seen by LLM, but everything else works.
This commit is contained in:
@@ -25,30 +25,44 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
||||
messagesRef.current = conversation?.messages || [];
|
||||
|
||||
const buildSystemPrompt = useCallback(
|
||||
async (basePrompt: string, kbId?: string | null): Promise<string> => {
|
||||
if (!kbId || !settings.apiUrl || !settings.apiKey) return basePrompt;
|
||||
async (basePrompt: string, kbIds?: string[]): Promise<string> => {
|
||||
if (!kbIds || kbIds.length === 0 || !settings.apiUrl || !settings.apiKey) return basePrompt;
|
||||
try {
|
||||
const query = messagesRef.current
|
||||
.filter((m) => m.role === 'user')
|
||||
.slice(-1)[0]?.content || '';
|
||||
if (!query) return basePrompt;
|
||||
|
||||
const { results } = await queryKnowledge(
|
||||
kbId,
|
||||
query,
|
||||
settings.apiUrl,
|
||||
settings.apiKey,
|
||||
settings.embeddingModel,
|
||||
5
|
||||
);
|
||||
const allResults: Array<{ text: string; score: number }> = [];
|
||||
|
||||
if (results.length === 0) return basePrompt;
|
||||
for (const kbId of kbIds) {
|
||||
try {
|
||||
const { results } = await queryKnowledge(
|
||||
kbId,
|
||||
query,
|
||||
settings.apiUrl,
|
||||
settings.apiKey,
|
||||
settings.embeddingModel,
|
||||
5
|
||||
);
|
||||
allResults.push(...results);
|
||||
} catch (e) {
|
||||
console.warn(`Knowledge query failed for ${kbId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
const context = results
|
||||
// Sort by relevance (score descending) and deduplicate
|
||||
const sorted = allResults
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 15);
|
||||
|
||||
if (sorted.length === 0) return basePrompt;
|
||||
|
||||
const context = sorted
|
||||
.map((r, i) => `[${i + 1}] ${r.text}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `${basePrompt}\n\nRelevant context from knowledge base:\n${context}\n\nAnswer using the context above when relevant.`;
|
||||
return `${basePrompt}\n\nRelevant context from knowledge bases:\n${context}\n\nAnswer using the context above when relevant.`;
|
||||
} catch {
|
||||
return basePrompt;
|
||||
}
|
||||
@@ -94,7 +108,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
||||
try {
|
||||
const systemPrompt = await buildSystemPrompt(
|
||||
settings.systemPrompt,
|
||||
conversation?.knowledgeBaseId
|
||||
conversation?.knowledgeBaseIds
|
||||
);
|
||||
|
||||
const stream = streamChatCompletion(
|
||||
@@ -140,7 +154,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[conversationId, settings, addMessage, updateMessage, setLoading, buildSystemPrompt, conversation?.knowledgeBaseId]
|
||||
[conversationId, settings, addMessage, updateMessage, setLoading, buildSystemPrompt, conversation?.knowledgeBaseIds]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Upload, X, FileText, FileImage, File } from 'lucide-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Upload, X, FileText, FileImage, File, FolderOpen } from 'lucide-react';
|
||||
import type { UploadedFile } from '@/types';
|
||||
|
||||
interface FileUploadProps {
|
||||
@@ -8,10 +8,23 @@ interface FileUploadProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SKIP_DIRS = /node_modules|\.git|\.next|\.vscode|\.idea|dist|build|__pycache__|\.cache|\.venv|venv|env|target|\.turbo/i;
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_FILES = 50;
|
||||
|
||||
function shouldSkip(file: File): boolean {
|
||||
// Skip hidden directories by checking webkitRelativePath
|
||||
const parts = file.webkitRelativePath?.split('/') || [];
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('.') || SKIP_DIRS.test(part)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function FileUpload({ files, onFilesChange, disabled }: FileUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -33,32 +46,78 @@ export default function FileUpload({ files, onFilesChange, disabled }: FileUploa
|
||||
await uploadFiles(droppedFiles);
|
||||
}, [disabled]);
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) return;
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
await uploadFiles(selectedFiles);
|
||||
e.target.value = '';
|
||||
}, []);
|
||||
const openFilePicker = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files?.length) {
|
||||
uploadFiles(Array.from(target.files));
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const openFolderPicker = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
// @ts-ignore
|
||||
input.webkitdirectory = true;
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files?.length) {
|
||||
const allFiles = Array.from(target.files);
|
||||
const filtered = allFiles.filter((f) => !shouldSkip(f));
|
||||
if (filtered.length > MAX_FILES) {
|
||||
alert(`Too many files. Maximum ${MAX_FILES} allowed. Got ${filtered.length}`);
|
||||
uploadFiles(filtered.slice(0, MAX_FILES));
|
||||
} else {
|
||||
uploadFiles(filtered);
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const uploadFiles = async (fileList: File[]) => {
|
||||
if (fileList.length === 0) return;
|
||||
setIsUploading(true);
|
||||
setProgress({ current: 0, total: fileList.length });
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
fileList.forEach((f) => formData.append('files', f));
|
||||
const result: UploadedFile[] = [];
|
||||
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i];
|
||||
setProgress({ current: i + 1, total: fileList.length });
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
const data = await response.json();
|
||||
onFilesChange([...files, ...data.files]);
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
console.warn(`Skipping ${file.name}: ${(file.size / 1024 / 1024).toFixed(1)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB limit`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
const data = await response.json();
|
||||
result.push(...data.files);
|
||||
}
|
||||
|
||||
onFilesChange([...files, ...result]);
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
alert('Failed to upload files');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,39 +167,50 @@ export default function FileUpload({ files, onFilesChange, disabled }: FileUploa
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons row */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={openFilePicker}
|
||||
disabled={disabled || isUploading}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Upload size={14} />
|
||||
<span>Select files</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={openFolderPicker}
|
||||
disabled={disabled || isUploading}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
<span>Select folder</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={disabled || isUploading}
|
||||
className={`w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed text-xs transition-all
|
||||
${isDragging
|
||||
? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-text-secondary)] hover:text-[var(--color-text-secondary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-muted)]'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
${disabled ? 'opacity-50' : ''}
|
||||
`}
|
||||
>
|
||||
{isUploading ? (
|
||||
{isUploading && progress ? (
|
||||
<span className="animate-pulse">Uploading {progress.current}/{progress.total}...</span>
|
||||
) : isUploading ? (
|
||||
<span className="animate-pulse">Uploading...</span>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={14} />
|
||||
<span>Drop files or click to upload</span>
|
||||
<span>Or drop files here</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled || isUploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp, FolderOpen, AlertCircle } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp, FolderOpen, AlertCircle, RefreshCw, FolderSearch } from 'lucide-react';
|
||||
import { useChatStore } from '@/stores/chatStore';
|
||||
|
||||
interface FileResult {
|
||||
@@ -9,37 +9,54 @@ interface FileResult {
|
||||
chunkCount?: number;
|
||||
}
|
||||
|
||||
const SKIP_DIRS = /node_modules|\.git|\.next|\.vscode|\.idea|dist|build|__pycache__|\.cache|\.venv|venv|env|target|\.turbo/i;
|
||||
const MAX_FILES = 50;
|
||||
|
||||
function shouldSkip(file: File): boolean {
|
||||
const parts = file.webkitRelativePath?.split('/') || [];
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('.') || SKIP_DIRS.test(part)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function KnowledgePanel() {
|
||||
const { settings, toggleKnowledge, isKnowledgeOpen, setKnowledgeBases, knowledgeBases, removeKnowledgeBase } = useChatStore();
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newChromaDir, setNewChromaDir] = useState('/data/chromadb');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [expandedBase, setExpandedBase] = useState<string | null>(null);
|
||||
const [uploadingBase, setUploadingBase] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [folderLoading, setFolderLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isBackendReady, setIsBackendReady] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isKnowledgeOpen) loadBases();
|
||||
}, [isKnowledgeOpen]);
|
||||
|
||||
const loadBases = async () => {
|
||||
const loadBases = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch('/api/knowledge');
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
if (!res.ok) {
|
||||
if (res.status === 500) {
|
||||
setIsBackendReady(false);
|
||||
throw new Error('Knowledge service not available. Make sure ChromaDB is running.');
|
||||
}
|
||||
throw new Error('Failed to load');
|
||||
}
|
||||
setIsBackendReady(true);
|
||||
const data = await res.json();
|
||||
setKnowledgeBases(data.bases || []);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load knowledge bases:', err);
|
||||
setError('Could not connect to knowledge base service. Is the backend running?');
|
||||
setError(err.message || 'Could not connect to knowledge base service.');
|
||||
setKnowledgeBases([]);
|
||||
}
|
||||
};
|
||||
}, [setKnowledgeBases]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isKnowledgeOpen) loadBases();
|
||||
}, [isKnowledgeOpen, loadBases]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
@@ -48,13 +65,14 @@ export default function KnowledgePanel() {
|
||||
const res = await fetch('/api/knowledge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
||||
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim(), chromaDir: newChromaDir.trim() || '/data/chromadb' }),
|
||||
});
|
||||
const base = await res.json();
|
||||
if (base.error) throw new Error(base.error);
|
||||
setKnowledgeBases([...knowledgeBases, base]);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
setNewChromaDir('/data/chromadb');
|
||||
setIsCreating(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create knowledge base');
|
||||
@@ -63,6 +81,56 @@ export default function KnowledgePanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateFolderPicker = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
// @ts-ignore
|
||||
input.webkitdirectory = true;
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files?.length) {
|
||||
const allFiles = Array.from(target.files).filter(f => !shouldSkip(f));
|
||||
handleCreateFromFiles(allFiles);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleCreateFromFiles = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
setFolderLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const folderName = files[0].webkitRelativePath?.split('/')[0] || 'New Knowledge Base';
|
||||
const res = await fetch('/api/knowledge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: folderName, description: `From folder with ${files.length} files`, chromaDir: '/data/chromadb' }),
|
||||
});
|
||||
const base = await res.json();
|
||||
if (base.error) throw new Error(base.error);
|
||||
|
||||
for (const file of files.slice(0, MAX_FILES)) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('apiUrl', settings.apiUrl);
|
||||
form.append('apiKey', settings.apiKey);
|
||||
form.append('embeddingModel', settings.embeddingModel);
|
||||
try {
|
||||
await fetch(`/api/knowledge/${base.id}/files`, { method: 'POST', body: form });
|
||||
} catch { /* ignore single failures */ }
|
||||
}
|
||||
|
||||
await loadBases();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Folder upload failed');
|
||||
} finally {
|
||||
setFolderLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this knowledge base?')) return;
|
||||
try {
|
||||
@@ -73,93 +141,72 @@ export default function KnowledgePanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadFile = async (baseId: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.[0]) return;
|
||||
const file = e.target.files[0];
|
||||
const openFilePicker = (baseId: string) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files?.length) {
|
||||
handleUploadFiles(baseId, Array.from(target.files));
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const openFolderPicker = (baseId: string) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
// @ts-ignore
|
||||
input.webkitdirectory = true;
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files?.length) {
|
||||
const allFiles = Array.from(target.files).filter(f => !shouldSkip(f));
|
||||
handleUploadFiles(baseId, allFiles);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleUploadFiles = async (baseId: string, files: File[]) => {
|
||||
if (!files.length) return;
|
||||
setUploadingBase(baseId);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('apiUrl', settings.apiUrl);
|
||||
formData.append('apiKey', settings.apiKey);
|
||||
formData.append('embeddingModel', settings.embeddingModel);
|
||||
|
||||
const res = await fetch(`/api/knowledge/${baseId}/files`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
const data = await res.json();
|
||||
|
||||
const updated = knowledgeBases.map((b) =>
|
||||
b.id === baseId
|
||||
? { ...b, files: [...b.files, data.file], documentCount: (b.documentCount || 0) + data.chunks }
|
||||
: b
|
||||
);
|
||||
setKnowledgeBases(updated);
|
||||
} catch (err) {
|
||||
setError('Failed to upload file. Make sure API settings are configured.');
|
||||
} finally {
|
||||
setUploadingBase(null);
|
||||
e.target.value = '';
|
||||
for (const file of files.slice(0, MAX_FILES)) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('apiUrl', settings.apiUrl);
|
||||
form.append('apiKey', settings.apiKey);
|
||||
form.append('embeddingModel', settings.embeddingModel);
|
||||
try {
|
||||
await fetch(`/api/knowledge/${baseId}/files`, { method: 'POST', body: form });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
setUploadingBase(null);
|
||||
await loadBases();
|
||||
};
|
||||
|
||||
const handleUploadFolder = async (baseId: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setFolderLoading(true);
|
||||
const handleScanLocalFolder = async (baseId: string) => {
|
||||
const folderPath = prompt('Enter absolute folder path on server to scan:');
|
||||
if (!folderPath) return;
|
||||
setUploadingBase(baseId);
|
||||
setError(null);
|
||||
|
||||
// For browser folder upload, we can't get the actual folder path.
|
||||
// Instead, upload all files from the file picker using webkitdirectory.
|
||||
const formData = new FormData();
|
||||
Array.from(files).forEach((f) => formData.append('files', f));
|
||||
formData.append('apiUrl', settings.apiUrl);
|
||||
formData.append('apiKey', settings.apiKey);
|
||||
formData.append('embeddingModel', settings.embeddingModel);
|
||||
|
||||
try {
|
||||
// Use a batch upload approach — upload files one by one
|
||||
const uploaded: FileResult[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
const singleForm = new FormData();
|
||||
singleForm.append('file', file);
|
||||
singleForm.append('apiUrl', settings.apiUrl);
|
||||
singleForm.append('apiKey', settings.apiKey);
|
||||
singleForm.append('embeddingModel', settings.embeddingModel);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/knowledge/${baseId}/files`, {
|
||||
method: 'POST',
|
||||
body: singleForm,
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const data = await res.json();
|
||||
uploaded.push(data.file);
|
||||
} catch {
|
||||
errors.push(file.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded.length > 0) {
|
||||
await loadBases();
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(`${errors.length} files failed to upload out of ${files.length}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Folder upload failed');
|
||||
const res = await fetch(`/api/knowledge/${baseId}/scan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folderPath, apiUrl: settings.apiUrl, apiKey: settings.apiKey, embeddingModel: settings.embeddingModel }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Scan failed');
|
||||
await loadBases();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to scan folder');
|
||||
} finally {
|
||||
setFolderLoading(false);
|
||||
e.target.value = '';
|
||||
setUploadingBase(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,8 +217,8 @@ export default function KnowledgePanel() {
|
||||
b.id === baseId
|
||||
? {
|
||||
...b,
|
||||
files: b.files.filter((f) => f.id !== fileId),
|
||||
documentCount: b.files.filter((f) => f.id !== fileId).reduce((s, f) => s + (f.chunkCount || 0), 0),
|
||||
files: b.files.filter((f: any) => f.id !== fileId),
|
||||
documentCount: b.files.filter((f: any) => f.id !== fileId).reduce((s: number, f: any) => s + (f.chunkCount || 0), 0),
|
||||
}
|
||||
: b
|
||||
);
|
||||
@@ -193,17 +240,25 @@ export default function KnowledgePanel() {
|
||||
<Database size={20} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text)]">Knowledge Bases</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleKnowledge}
|
||||
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={loadBases}
|
||||
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleKnowledge}
|
||||
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
<AlertCircle size={16} />
|
||||
@@ -214,16 +269,33 @@ export default function KnowledgePanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new */}
|
||||
{!isCreating ? (
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 border-dashed border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span className="text-sm font-medium">Create Knowledge Base</span>
|
||||
</button>
|
||||
) : (
|
||||
{!isCreating && isBackendReady && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 border-dashed border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span className="text-sm font-medium">Create empty base</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateFolderPicker}
|
||||
disabled={folderLoading}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 border-dashed border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-green-500 hover:text-green-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{folderLoading ? (
|
||||
<span className="text-sm font-medium animate-pulse">Processing...</span>
|
||||
) : (
|
||||
<>
|
||||
<FolderOpen size={18} />
|
||||
<span className="text-sm font-medium">Create from folder</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreating && (
|
||||
<div className="space-y-3 p-4 rounded-xl bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||
<input
|
||||
type="text"
|
||||
@@ -239,6 +311,19 @@ export default function KnowledgePanel() {
|
||||
placeholder="Description (optional)"
|
||||
className="input-field"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[var(--color-text-muted)]">ChromaDB path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newChromaDir}
|
||||
onChange={(e) => setNewChromaDir(e.target.value)}
|
||||
placeholder="/data/chromadb"
|
||||
className="input-field text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--color-text-muted)]">
|
||||
Path inside the ChromaDB container where this base will be stored
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
@@ -248,7 +333,7 @@ export default function KnowledgePanel() {
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsCreating(false); setNewName(''); setNewDesc(''); }}
|
||||
onClick={() => { setIsCreating(false); setNewName(''); setNewDesc(''); setNewChromaDir('/data/chromadb'); }}
|
||||
className="px-4 py-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
@@ -257,14 +342,27 @@ export default function KnowledgePanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{knowledgeBases.length === 0 ? (
|
||||
{!isBackendReady && (
|
||||
<div className="text-center py-12 text-[var(--color-text-muted)]">
|
||||
<AlertCircle size={40} className="mx-auto mb-3 opacity-40" />
|
||||
<p className="text-sm">Knowledge base service unavailable</p>
|
||||
<p className="text-xs mt-1">Make sure ChromaDB is running</p>
|
||||
<button
|
||||
onClick={loadBases}
|
||||
className="mt-4 px-4 py-2 rounded-lg bg-[var(--color-accent)] text-white text-sm hover:bg-[var(--color-accent-hover)] transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBackendReady && knowledgeBases.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--color-text-muted)]">
|
||||
<BookOpen size={40} className="mx-auto mb-3 opacity-40" />
|
||||
<p className="text-sm">No knowledge bases yet</p>
|
||||
<p className="text-xs mt-1">Create one to start adding documents</p>
|
||||
</div>
|
||||
) : (
|
||||
) : isBackendReady && (
|
||||
<div className="space-y-3">
|
||||
{knowledgeBases.map((base) => (
|
||||
<div
|
||||
@@ -287,6 +385,7 @@ export default function KnowledgePanel() {
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{base.documentCount || 0} chunks · {base.files?.length || 0} files
|
||||
{base.chromaDir ? ` · ${base.chromaDir}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -303,11 +402,13 @@ export default function KnowledgePanel() {
|
||||
{base.description && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{base.description}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-[var(--color-text-muted)] font-mono">
|
||||
ChromaDB: {base.chromaDir || '/data/chromadb'}
|
||||
</p>
|
||||
|
||||
{/* Files */}
|
||||
{base.files && base.files.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{base.files.map((file) => (
|
||||
{base.files.map((file: any) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--color-surface)] text-xs"
|
||||
@@ -332,47 +433,31 @@ export default function KnowledgePanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload buttons */}
|
||||
<div className="flex gap-2">
|
||||
<label className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors cursor-pointer ${uploadingBase === base.id ? 'opacity-50' : ''}`}>
|
||||
{uploadingBase === base.id ? (
|
||||
<span className="animate-pulse">Processing...</span>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={14} />
|
||||
<span>Upload file</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".txt,.md,.pdf,.docx,.json,.csv,.js,.ts,.html,.css"
|
||||
onChange={(e) => handleUploadFile(base.id, e)}
|
||||
disabled={uploadingBase === base.id || folderLoading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<label className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-green-500 hover:text-green-500 transition-colors cursor-pointer ${folderLoading ? 'opacity-50' : ''}`}>
|
||||
{folderLoading ? (
|
||||
<span className="animate-pulse">Scanning folder...</span>
|
||||
) : (
|
||||
<>
|
||||
<FolderOpen size={14} />
|
||||
<span>Upload folder</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
ref={folderInputRef}
|
||||
// @ts-ignore — webkitdirectory is non-standard but widely supported
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
multiple
|
||||
onChange={(e) => handleUploadFolder(base.id, e)}
|
||||
disabled={uploadingBase === base.id || folderLoading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => openFilePicker(base.id)}
|
||||
disabled={uploadingBase === base.id}
|
||||
className="flex-1 min-w-[80px] flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Upload size={14} />
|
||||
<span>Upload file</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openFolderPicker(base.id)}
|
||||
disabled={uploadingBase === base.id}
|
||||
className="flex-1 min-w-[80px] flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-green-500 hover:text-green-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
<span>Upload folder</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleScanLocalFolder(base.id)}
|
||||
disabled={uploadingBase === base.id}
|
||||
className="flex-1 min-w-[80px] flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-blue-500 hover:text-blue-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<FolderSearch size={14} />
|
||||
<span>Scan server</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -41,6 +41,17 @@ export default function Sidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleKnowledgeBase = (baseId: string) => {
|
||||
if (!currentConversation) return;
|
||||
const currentIds = currentConversation.knowledgeBaseIds || [];
|
||||
const newIds = currentIds.includes(baseId)
|
||||
? currentIds.filter((id) => id !== baseId)
|
||||
: [...currentIds, baseId];
|
||||
updateConversation(currentConversation.id, {
|
||||
knowledgeBaseIds: newIds.length > 0 ? newIds : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
@@ -118,29 +129,32 @@ export default function Sidebar() {
|
||||
No knowledge bases yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<select
|
||||
value={currentConversation?.knowledgeBaseId || ''}
|
||||
onChange={(e) => {
|
||||
if (currentConversation) {
|
||||
updateConversation(currentConversation.id, {
|
||||
knowledgeBaseId: e.target.value || undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!currentConversation}
|
||||
className="w-full text-xs bg-[var(--color-bg)] border border-[var(--color-border)] rounded-lg px-2 py-1.5 text-[var(--color-text)] outline-none focus:ring-1 focus:ring-[var(--color-accent)] disabled:opacity-50"
|
||||
>
|
||||
<option value="">No knowledge base</option>
|
||||
{knowledgeBases.map((base) => (
|
||||
<option key={base.id} value={base.id}>
|
||||
{base.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{currentConversation?.knowledgeBaseId && (
|
||||
<p className="text-[10px] text-[var(--color-accent)] px-1">
|
||||
Using: {knowledgeBases.find((b) => b.id === currentConversation.knowledgeBaseId)?.name}
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{knowledgeBases.map((base) => (
|
||||
<label
|
||||
key={base.id}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer text-xs transition-colors ${
|
||||
currentConversation?.knowledgeBaseIds?.includes(base.id)
|
||||
? 'text-[var(--color-accent)] bg-[var(--color-accent)]/10'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConversation?.knowledgeBaseIds?.includes(base.id) || false}
|
||||
onChange={() => toggleKnowledgeBase(base.id)}
|
||||
disabled={!currentConversation}
|
||||
className="rounded border-[var(--color-border)] text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
|
||||
/>
|
||||
<span className="truncate flex-1">{base.name}</span>
|
||||
<span className="text-[var(--color-text-muted)] shrink-0">
|
||||
{base.documentCount || 0} chunks
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{currentConversation && (currentConversation.knowledgeBaseIds?.length || 0) > 0 && (
|
||||
<p className="text-[10px] text-[var(--color-accent)] px-1 pt-0.5">
|
||||
{currentConversation.knowledgeBaseIds?.length} base(s) active
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface Conversation {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
model?: string;
|
||||
knowledgeBaseId?: string | null;
|
||||
knowledgeBaseIds?: string[];
|
||||
}
|
||||
|
||||
export interface ChatSettings {
|
||||
@@ -42,6 +42,7 @@ export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
chromaDir: string;
|
||||
files: KnowledgeFile[];
|
||||
documentCount: number;
|
||||
createdAt: number;
|
||||
|
||||
Reference in New Issue
Block a user