You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
137 lines
5.0 KiB
137 lines
5.0 KiB
'use client';
|
|
|
|
import { streamChat } from '@/lib/api';
|
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { Send, Loader2, Bot, User, BookOpen } from 'lucide-react';
|
|
|
|
interface Message {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
sources?: { title: string; path: string; score: number }[];
|
|
}
|
|
|
|
export default function ChatPage() {
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [streaming, setStreaming] = useState(false);
|
|
const cancelRef = useRef<(() => void) | null>(null);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages]);
|
|
|
|
const sendMessage = useCallback(async () => {
|
|
const text = input.trim();
|
|
if (!text || streaming) return;
|
|
setInput('');
|
|
|
|
const userMsg: Message = { role: 'user', content: text };
|
|
setMessages((prev) => [...prev, userMsg]);
|
|
setStreaming(true);
|
|
|
|
const assistantMsg: Message = { role: 'assistant', content: '', sources: [] };
|
|
setMessages((prev) => [...prev, assistantMsg]);
|
|
|
|
cancelRef.current = streamChat(
|
|
text,
|
|
5,
|
|
(token) => {
|
|
setMessages((prev) => {
|
|
const next = [...prev];
|
|
const last = next[next.length - 1];
|
|
next[next.length - 1] = { ...last, content: last.content + token };
|
|
return next;
|
|
});
|
|
},
|
|
(sources) => {
|
|
setMessages((prev) => {
|
|
const next = [...prev];
|
|
next[next.length - 1] = { ...next[next.length - 1], sources };
|
|
return next;
|
|
});
|
|
},
|
|
() => setStreaming(false),
|
|
);
|
|
}, [input, streaming]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto flex flex-col h-[calc(100vh-5rem)]">
|
|
<h1 className="text-2xl font-bold text-slate-900 mb-4">AI Chat</h1>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-4 pr-1 mb-4">
|
|
{messages.length === 0 && (
|
|
<div className="text-center text-slate-400 py-16">
|
|
<Bot size={48} className="mx-auto mb-3 opacity-30" />
|
|
<p>Ask anything about your knowledge base</p>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg, i) => (
|
|
<div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
{msg.role === 'assistant' && (
|
|
<div className="w-8 h-8 rounded-full bg-brain-600 flex items-center justify-center shrink-0 mt-1">
|
|
<Bot size={16} className="text-white" />
|
|
</div>
|
|
)}
|
|
<div className={`max-w-[80%] ${msg.role === 'user' ? 'order-1' : ''}`}>
|
|
<div className={`rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap
|
|
${msg.role === 'user'
|
|
? 'bg-brain-600 text-white rounded-tr-none'
|
|
: 'bg-white border border-slate-200 text-slate-800 rounded-tl-none'
|
|
}`}>
|
|
{msg.content}
|
|
{msg.role === 'assistant' && streaming && i === messages.length - 1 && (
|
|
<span className="inline-block w-1.5 h-4 bg-brain-500 animate-pulse ml-0.5 rounded" />
|
|
)}
|
|
</div>
|
|
{msg.sources && msg.sources.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{msg.sources.map((src, si) => (
|
|
<span key={si} className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
<BookOpen size={10} />
|
|
{src.title}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{msg.role === 'user' && (
|
|
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center shrink-0 mt-1">
|
|
<User size={16} className="text-slate-600" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<textarea
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={streaming}
|
|
placeholder="Ask your second brain... (Enter to send, Shift+Enter for newline)"
|
|
rows={2}
|
|
className="flex-1 px-4 py-2.5 border border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-brain-500 resize-none text-sm disabled:opacity-60"
|
|
/>
|
|
<button
|
|
onClick={sendMessage}
|
|
disabled={streaming || !input.trim()}
|
|
className="px-4 bg-brain-600 text-white rounded-xl hover:bg-brain-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
|
>
|
|
{streaming ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|