Add files drag and drop, add chromaDB knowledge base support.
This commit is contained in:
@@ -3,6 +3,7 @@ import { useChatStore } from '@/stores/chatStore';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import ChatArea from '@/components/chat/ChatArea';
|
||||
import SettingsModal from '@/components/chat/SettingsModal';
|
||||
import KnowledgePanel from '@/components/knowledge/KnowledgePanel';
|
||||
import { Menu, Settings } from 'lucide-react';
|
||||
|
||||
function App() {
|
||||
@@ -51,7 +52,7 @@ function App() {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors lg:hidden"
|
||||
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
@@ -79,6 +80,9 @@ function App() {
|
||||
|
||||
{/* Settings Modal */}
|
||||
{isSettingsOpen && <SettingsModal />}
|
||||
|
||||
{/* Knowledge Panel */}
|
||||
<KnowledgePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useChatStore } from '@/stores/chatStore';
|
||||
import { streamChatCompletion } from '@/services/api';
|
||||
import type { Message } from '@/types';
|
||||
import { streamChatCompletion, queryKnowledge } from '@/services/api';
|
||||
import type { Message, UploadedFile } from '@/types';
|
||||
import MessageList from './MessageList';
|
||||
import ChatInput from './ChatInput';
|
||||
|
||||
@@ -24,9 +24,41 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
||||
const conversation = getCurrentConversation();
|
||||
messagesRef.current = conversation?.messages || [];
|
||||
|
||||
const buildSystemPrompt = useCallback(
|
||||
async (basePrompt: string, kbId?: string | null): Promise<string> => {
|
||||
if (!kbId || !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
|
||||
);
|
||||
|
||||
if (results.length === 0) return basePrompt;
|
||||
|
||||
const context = results
|
||||
.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.`;
|
||||
} catch {
|
||||
return basePrompt;
|
||||
}
|
||||
},
|
||||
[settings]
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || !settings.apiUrl || !settings.apiKey || !settings.model) {
|
||||
async (content: string, files: UploadedFile[] = []) => {
|
||||
if ((!content.trim() && files.length === 0) || !settings.apiUrl || !settings.apiKey || !settings.model) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,6 +71,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
timestamp: Date.now(),
|
||||
files: files.length > 0 ? files : undefined,
|
||||
};
|
||||
|
||||
addMessage(conversationId, userMessage);
|
||||
@@ -59,6 +92,11 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
||||
let fullContent = '';
|
||||
|
||||
try {
|
||||
const systemPrompt = await buildSystemPrompt(
|
||||
settings.systemPrompt,
|
||||
conversation?.knowledgeBaseId
|
||||
);
|
||||
|
||||
const stream = streamChatCompletion(
|
||||
[...messagesRef.current, userMessage],
|
||||
settings.apiUrl,
|
||||
@@ -66,7 +104,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
||||
settings.model,
|
||||
settings.temperature,
|
||||
settings.maxTokens,
|
||||
settings.systemPrompt,
|
||||
systemPrompt,
|
||||
abortControllerRef.current.signal
|
||||
);
|
||||
|
||||
@@ -101,7 +139,7 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[conversationId, settings, addMessage, updateMessage, setLoading]
|
||||
[conversationId, settings, addMessage, updateMessage, setLoading, buildSystemPrompt, conversation?.knowledgeBaseId]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Send, Square } from 'lucide-react';
|
||||
import { Send, Square, Paperclip } from 'lucide-react';
|
||||
import { useChatStore } from '@/stores/chatStore';
|
||||
import FileUpload from './FileUpload';
|
||||
import type { UploadedFile } from '@/types';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
onSend: (message: string, files: UploadedFile[]) => void;
|
||||
onStop: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatInput({ onSend, onStop, disabled }: ChatInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { isLoading } = useChatStore();
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
onSend(input);
|
||||
if ((!input.trim() && files.length === 0) || isLoading) return;
|
||||
onSend(input, files);
|
||||
setInput('');
|
||||
setFiles([]);
|
||||
setShowUpload(false);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}, [input, isLoading, onSend]);
|
||||
}, [input, isLoading, files, onSend]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -37,14 +43,31 @@ export default function ChatInput({ onSend, onStop, disabled }: ChatInputProps)
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="max-w-4xl mx-auto space-y-2">
|
||||
{disabled && (
|
||||
<div className="mb-2 text-xs text-center text-amber-400 bg-amber-400/10 rounded-lg py-2 px-3">
|
||||
Please configure API settings (URL, Key, and Model) in the settings menu to start chatting
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File upload area */}
|
||||
{showUpload && (
|
||||
<FileUpload
|
||||
files={files}
|
||||
onFilesChange={setFiles}
|
||||
disabled={disabled || isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-2xl p-2 focus-within:ring-2 focus-within:ring-primary-500/50 focus-within:border-primary-500 transition-all">
|
||||
<button
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
className="p-2.5 rounded-xl text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] transition-colors shrink-0"
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
@@ -68,7 +91,7 @@ export default function ChatInput({ onSend, onStop, disabled }: ChatInputProps)
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || disabled}
|
||||
disabled={(!input.trim() && files.length === 0) || disabled}
|
||||
className="p-2.5 rounded-xl bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0"
|
||||
title="Send message"
|
||||
>
|
||||
@@ -77,7 +100,7 @@ export default function ChatInput({ onSend, onStop, disabled }: ChatInputProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-[var(--color-text-muted)] mt-2">
|
||||
<p className="text-center text-xs text-[var(--color-text-muted)] mt-1">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Upload, X, FileText, FileImage, File } from 'lucide-react';
|
||||
import type { UploadedFile } from '@/types';
|
||||
|
||||
interface FileUploadProps {
|
||||
files: UploadedFile[];
|
||||
onFilesChange: (files: UploadedFile[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function FileUpload({ files, onFilesChange, disabled }: FileUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setIsDragging(true);
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (disabled) return;
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
if (droppedFiles.length === 0) return;
|
||||
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 uploadFiles = async (fileList: File[]) => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
fileList.forEach((f) => formData.append('files', f));
|
||||
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
const data = await response.json();
|
||||
onFilesChange([...files, ...data.files]);
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
alert('Failed to upload files');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (id: string) => {
|
||||
onFilesChange(files.filter((f) => f.id !== id));
|
||||
};
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType.startsWith('image/')) return <FileImage size={14} />;
|
||||
if (mimeType === 'application/pdf' || mimeType.includes('document')) return <FileText size={14} />;
|
||||
return <File size={14} />;
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Attached files */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[var(--color-surface)] border border-[var(--color-border)] text-xs"
|
||||
>
|
||||
<span className="text-[var(--color-text-secondary)]">
|
||||
{getFileIcon(file.mimeType)}
|
||||
</span>
|
||||
<span className="text-[var(--color-text)] max-w-[120px] truncate">
|
||||
{file.originalName}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-muted)]">
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeFile(file.id)}
|
||||
className="ml-1 text-[var(--color-text-muted)] hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone */}
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
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)]'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="animate-pulse">Uploading...</span>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={14} />
|
||||
<span>Drop files or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled || isUploading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,26 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { User, Bot, AlertCircle } from 'lucide-react';
|
||||
import type { Message } from '@/types';
|
||||
import ThinkBlock from './ThinkBlock';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
function parseThinkContent(content: string): { think: string | null; body: string } {
|
||||
const thinkRegex = /<think>([\s\S]*?)<\/think>/;
|
||||
const match = content.match(thinkRegex);
|
||||
if (!match) return { think: null, body: content };
|
||||
const think = match[1].trim();
|
||||
const body = content.replace(thinkRegex, '').trim();
|
||||
return { think, body };
|
||||
}
|
||||
|
||||
interface ParsedMessage extends Omit<Message, 'thinkContent'> {
|
||||
thinkContent: string | null;
|
||||
displayContent: string;
|
||||
}
|
||||
|
||||
export default function MessageList({ messages }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -23,6 +38,14 @@ export default function MessageList({ messages }: MessageListProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const parsedMessages: ParsedMessage[] = messages.map((msg) => {
|
||||
if (msg.role === 'assistant') {
|
||||
const { think, body } = parseThinkContent(msg.content);
|
||||
return { ...msg, thinkContent: think, displayContent: body };
|
||||
}
|
||||
return { ...msg, thinkContent: null, displayContent: msg.content };
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -41,7 +64,7 @@ export default function MessageList({ messages }: MessageListProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
parsedMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-4 ${
|
||||
@@ -77,16 +100,36 @@ export default function MessageList({ messages }: MessageListProps) {
|
||||
}`}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
<div className="space-y-2">
|
||||
{message.files && message.files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{message.files.map((f) => (
|
||||
<span
|
||||
key={f.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-white/20 text-xs"
|
||||
>
|
||||
{f.originalName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="markdown-body text-sm">
|
||||
{message.content ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
) : message.isStreaming ? (
|
||||
<span className="animate-pulse">▊</span>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{/* Think block */}
|
||||
{message.thinkContent && (
|
||||
<ThinkBlock content={message.thinkContent} />
|
||||
)}
|
||||
<div className="markdown-body text-sm">
|
||||
{message.displayContent ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.displayContent}
|
||||
</ReactMarkdown>
|
||||
) : message.isStreaming ? (
|
||||
<span className="animate-pulse">▊</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -216,6 +216,46 @@ export default function SettingsModal() {
|
||||
className="input-field resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-[var(--color-border)]" />
|
||||
|
||||
{/* Embedding Model */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Embedding Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localSettings.embeddingModel}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({ ...prev, embeddingModel: e.target.value }))
|
||||
}
|
||||
placeholder="text-embedding-3-small"
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Model used for knowledge base embeddings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ChromaDB Directory */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
ChromaDB Directory
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localSettings.chromaDir}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({ ...prev, chromaDir: e.target.value }))
|
||||
}
|
||||
placeholder="./data/chromadb"
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Path to ChromaDB persistent storage on the server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||||
|
||||
interface ThinkBlockProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function ThinkBlock({ content }: ThinkBlockProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
<Brain size={14} className="text-[var(--color-accent)]" />
|
||||
<span className="text-xs font-medium text-[var(--color-text-secondary)] flex-1">
|
||||
Reasoning
|
||||
</span>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={14} className="text-[var(--color-text-muted)]" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-3 py-2 border-t border-[var(--color-border)]">
|
||||
<pre className="text-xs text-[var(--color-text-secondary)] whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useChatStore } from '@/stores/chatStore';
|
||||
|
||||
export default function KnowledgePanel() {
|
||||
const { settings, toggleKnowledge, isKnowledgeOpen, setKnowledgeBases, knowledgeBases, removeKnowledgeBase } = useChatStore();
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [expandedBase, setExpandedBase] = useState<string | null>(null);
|
||||
const [uploadingBase, setUploadingBase] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isKnowledgeOpen) loadBases();
|
||||
}, [isKnowledgeOpen]);
|
||||
|
||||
const loadBases = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/knowledge');
|
||||
const data = await res.json();
|
||||
setKnowledgeBases(data.bases || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load knowledge bases:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/knowledge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
||||
});
|
||||
const base = await res.json();
|
||||
setKnowledgeBases([...knowledgeBases, base]);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
setIsCreating(false);
|
||||
} catch (err) {
|
||||
alert('Failed to create knowledge base');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this knowledge base?')) return;
|
||||
try {
|
||||
await fetch(`/api/knowledge/${id}`, { method: 'DELETE' });
|
||||
removeKnowledgeBase(id);
|
||||
} catch (err) {
|
||||
alert('Failed to delete knowledge base');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadFile = async (baseId: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.[0]) return;
|
||||
const file = e.target.files[0];
|
||||
setUploadingBase(baseId);
|
||||
|
||||
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();
|
||||
|
||||
// Update local state
|
||||
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) {
|
||||
alert('Failed to upload file. Make sure API settings are configured.');
|
||||
} finally {
|
||||
setUploadingBase(null);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (baseId: string, fileId: string) => {
|
||||
try {
|
||||
await fetch(`/api/knowledge/${baseId}/files/${fileId}`, { method: 'DELETE' });
|
||||
const updated = knowledgeBases.map((b) =>
|
||||
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),
|
||||
}
|
||||
: b
|
||||
);
|
||||
setKnowledgeBases(updated);
|
||||
} catch (err) {
|
||||
alert('Failed to delete file');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isKnowledgeOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={toggleKnowledge} />
|
||||
<div className="relative bg-[var(--color-surface)] border border-[var(--color-border)] rounded-2xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-[var(--color-border)] shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{/* 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>
|
||||
) : (
|
||||
<div className="space-y-3 p-4 rounded-xl bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="input-field"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="input-field"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={loading || !newName.trim()}
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsCreating(false); setNewName(''); setNewDesc(''); }}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{knowledgeBases.map((base) => (
|
||||
<div
|
||||
key={base.id}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
onClick={() => setExpandedBase(expandedBase === base.id ? null : base.id)}
|
||||
className="flex items-center gap-2 text-left flex-1 min-w-0"
|
||||
>
|
||||
{expandedBase === base.id ? (
|
||||
<ChevronUp size={16} className="text-[var(--color-text-muted)] shrink-0" />
|
||||
) : (
|
||||
<ChevronDown size={16} className="text-[var(--color-text-muted)] shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--color-text)] truncate">
|
||||
{base.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{base.documentCount || 0} chunks · {base.files.length} files
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(base.id)}
|
||||
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expandedBase === base.id && (
|
||||
<div className="px-4 pb-4 border-t border-[var(--color-border)] pt-3 space-y-3">
|
||||
{base.description && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{base.description}</p>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{base.files.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{base.files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--color-surface)] text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText size={12} className="text-[var(--color-text-secondary)] shrink-0" />
|
||||
<span className="text-[var(--color-text)] truncate">
|
||||
{file.originalName}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-muted)] shrink-0">
|
||||
({file.chunkCount} chunks)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteFile(base.id, file.id)}
|
||||
className="p-1 rounded text-[var(--color-text-muted)] hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload */}
|
||||
<div className="relative">
|
||||
<label className={`flex items-center justify-center gap-2 w-full 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 document</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt,.md,.pdf,.docx"
|
||||
onChange={(e) => handleUploadFile(base.id, e)}
|
||||
disabled={uploadingBase === base.id}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useChatStore } from '@/stores/chatStore';
|
||||
import { Plus, Trash2, MessageSquare, ChevronLeft } from 'lucide-react';
|
||||
import { Plus, Trash2, MessageSquare, ChevronLeft, Database } from 'lucide-react';
|
||||
|
||||
export default function Sidebar() {
|
||||
const {
|
||||
@@ -9,8 +9,14 @@ export default function Sidebar() {
|
||||
selectConversation,
|
||||
deleteConversation,
|
||||
toggleSidebar,
|
||||
toggleKnowledge,
|
||||
knowledgeBases,
|
||||
getCurrentConversation,
|
||||
updateConversation,
|
||||
} = useChatStore();
|
||||
|
||||
const currentConversation = getCurrentConversation();
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Delete this conversation?')) {
|
||||
@@ -92,6 +98,55 @@ export default function Sidebar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Knowledge Bases */}
|
||||
<div className="border-t border-[var(--color-border)] p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)] uppercase tracking-wider">
|
||||
Knowledge
|
||||
</h3>
|
||||
<button
|
||||
onClick={toggleKnowledge}
|
||||
className="p-1 rounded-md hover:bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
|
||||
title="Manage Knowledge Bases"
|
||||
>
|
||||
<Database size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{knowledgeBases.length === 0 ? (
|
||||
<p className="text-xs text-[var(--color-text-muted)] px-1">
|
||||
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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[var(--color-border)]">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, OpenAIMessage, OpenAIStreamChunk } from '@/types';
|
||||
import type { Message, OpenAIMessage, OpenAIStreamChunk, KnowledgeBase } from '@/types';
|
||||
|
||||
export async function* streamChatCompletion(
|
||||
messages: Message[],
|
||||
@@ -92,3 +92,114 @@ export async function fetchModels(
|
||||
const data = await response.json();
|
||||
return data.models || [];
|
||||
}
|
||||
|
||||
// ===== FILES =====
|
||||
|
||||
export async function uploadFiles(files: FileList): Promise<{ files: Array<{ id: string; originalName: string; size: number; mimeType: string; uploadedAt: number }> }> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function listFiles(): Promise<{ files: Array<{ id: string; originalName: string; size: number; uploadedAt: number }> }> {
|
||||
const response = await fetch('/api/files');
|
||||
if (!response.ok) throw new Error('Failed to list files');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteFile(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/files/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to delete file');
|
||||
}
|
||||
|
||||
// ===== KNOWLEDGE BASES =====
|
||||
|
||||
export async function listKnowledgeBases(): Promise<{ bases: KnowledgeBase[] }> {
|
||||
const response = await fetch('/api/knowledge');
|
||||
if (!response.ok) throw new Error('Failed to list knowledge bases');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createKnowledgeBase(name: string, description?: string): Promise<KnowledgeBase> {
|
||||
const response = await fetch('/api/knowledge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to create knowledge base');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeBase(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/knowledge/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to delete knowledge base');
|
||||
}
|
||||
|
||||
export async function uploadFileToKnowledge(
|
||||
baseId: string,
|
||||
file: File,
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
embeddingModel: string
|
||||
): Promise<{ file: { id: string; originalName: string; size: number; chunkCount: number }; chunks: number }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('apiUrl', apiUrl);
|
||||
formData.append('apiKey', apiKey);
|
||||
formData.append('embeddingModel', embeddingModel);
|
||||
|
||||
const response = await fetch(`/api/knowledge/${baseId}/files`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to upload file to knowledge base');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteFileFromKnowledge(baseId: string, fileId: string): Promise<void> {
|
||||
const response = await fetch(`/api/knowledge/${baseId}/files/${fileId}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to delete file from knowledge base');
|
||||
}
|
||||
|
||||
export async function queryKnowledge(
|
||||
baseId: string,
|
||||
query: string,
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
embeddingModel: string,
|
||||
topK = 5
|
||||
): Promise<{ results: Array<{ text: string; score: number }> }> {
|
||||
const response = await fetch(`/api/knowledge/${baseId}/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, apiUrl, apiKey, embeddingModel, topK }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to query knowledge base');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Conversation, Message, ChatSettings } from '@/types';
|
||||
import type { Conversation, Message, ChatSettings, KnowledgeBase } from '@/types';
|
||||
|
||||
interface ChatState {
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
isSidebarOpen: boolean;
|
||||
isSettingsOpen: boolean;
|
||||
isKnowledgeOpen: boolean;
|
||||
isLoading: boolean;
|
||||
settings: ChatSettings;
|
||||
knowledgeBases: KnowledgeBase[];
|
||||
|
||||
// Actions
|
||||
createConversation: () => string;
|
||||
@@ -17,12 +19,17 @@ interface ChatState {
|
||||
updateConversationTitle: (id: string, title: string) => void;
|
||||
addMessage: (conversationId: string, message: Message) => void;
|
||||
updateMessage: (conversationId: string, messageId: string, updates: Partial<Message>) => void;
|
||||
updateConversation: (conversationId: string, updates: Partial<Conversation>) => void;
|
||||
toggleSidebar: () => void;
|
||||
closeSettings: () => void;
|
||||
toggleSettings: () => void;
|
||||
toggleKnowledge: () => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
updateSettings: (settings: Partial<ChatSettings>) => void;
|
||||
getCurrentConversation: () => Conversation | undefined;
|
||||
setKnowledgeBases: (bases: KnowledgeBase[]) => void;
|
||||
addKnowledgeBase: (base: KnowledgeBase) => void;
|
||||
removeKnowledgeBase: (id: string) => void;
|
||||
}
|
||||
|
||||
const generateId = () => Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
@@ -34,6 +41,8 @@ const defaultSettings: ChatSettings = {
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
chromaDir: './data/chromadb',
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
};
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
@@ -43,8 +52,10 @@ export const useChatStore = create<ChatState>()(
|
||||
currentConversationId: null,
|
||||
isSidebarOpen: true,
|
||||
isSettingsOpen: false,
|
||||
isKnowledgeOpen: false,
|
||||
isLoading: false,
|
||||
settings: defaultSettings,
|
||||
knowledgeBases: [],
|
||||
|
||||
createConversation: () => {
|
||||
const id = generateId();
|
||||
@@ -121,6 +132,14 @@ export const useChatStore = create<ChatState>()(
|
||||
}));
|
||||
},
|
||||
|
||||
updateConversation: (conversationId, updates) => {
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, ...updates, updatedAt: Date.now() } : c
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ isSidebarOpen: !state.isSidebarOpen }));
|
||||
},
|
||||
@@ -133,6 +152,10 @@ export const useChatStore = create<ChatState>()(
|
||||
set((state) => ({ isSettingsOpen: !state.isSettingsOpen }));
|
||||
},
|
||||
|
||||
toggleKnowledge: () => {
|
||||
set((state) => ({ isKnowledgeOpen: !state.isKnowledgeOpen }));
|
||||
},
|
||||
|
||||
setLoading: (loading) => {
|
||||
set({ isLoading: loading });
|
||||
},
|
||||
@@ -147,6 +170,20 @@ export const useChatStore = create<ChatState>()(
|
||||
const { conversations, currentConversationId } = get();
|
||||
return conversations.find((c) => c.id === currentConversationId);
|
||||
},
|
||||
|
||||
setKnowledgeBases: (bases) => {
|
||||
set({ knowledgeBases: bases });
|
||||
},
|
||||
|
||||
addKnowledgeBase: (base) => {
|
||||
set((state) => ({ knowledgeBases: [...state.knowledgeBases, base] }));
|
||||
},
|
||||
|
||||
removeKnowledgeBase: (id) => {
|
||||
set((state) => ({
|
||||
knowledgeBases: state.knowledgeBases.filter((b) => b.id !== id),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'aiui-chat-storage',
|
||||
@@ -155,6 +192,7 @@ export const useChatStore = create<ChatState>()(
|
||||
currentConversationId: state.currentConversationId,
|
||||
settings: state.settings,
|
||||
isSidebarOpen: state.isSidebarOpen,
|
||||
knowledgeBases: state.knowledgeBases,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface Message {
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
error?: string;
|
||||
files?: UploadedFile[];
|
||||
thinkContent?: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
@@ -14,6 +16,7 @@ export interface Conversation {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
model?: string;
|
||||
knowledgeBaseId?: string | null;
|
||||
}
|
||||
|
||||
export interface ChatSettings {
|
||||
@@ -23,6 +26,34 @@ export interface ChatSettings {
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
systemPrompt: string;
|
||||
chromaDir: string;
|
||||
embeddingModel: string;
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
uploadedAt: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
files: KnowledgeFile[];
|
||||
documentCount: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeFile {
|
||||
id: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
chunkCount: number;
|
||||
uploadedAt: number;
|
||||
}
|
||||
|
||||
export interface OpenAIMessage {
|
||||
|
||||
Reference in New Issue
Block a user