|
|
|
|
@@ -21,6 +21,7 @@ function ThreadList({
|
|
|
|
|
activeThread,
|
|
|
|
|
onSelect,
|
|
|
|
|
onCreate,
|
|
|
|
|
onRename,
|
|
|
|
|
onDelete,
|
|
|
|
|
onClose,
|
|
|
|
|
}: {
|
|
|
|
|
@@ -28,9 +29,25 @@ function ThreadList({
|
|
|
|
|
activeThread: string | null;
|
|
|
|
|
onSelect: (key: string) => void;
|
|
|
|
|
onCreate: () => void;
|
|
|
|
|
onRename?: (key: string, name: string) => void;
|
|
|
|
|
onDelete?: (key: string) => void;
|
|
|
|
|
onClose?: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
|
|
|
|
const [editName, setEditName] = useState("");
|
|
|
|
|
|
|
|
|
|
const startRename = (key: string, currentName: string) => {
|
|
|
|
|
setEditingKey(key);
|
|
|
|
|
setEditName(currentName);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const commitRename = () => {
|
|
|
|
|
if (editingKey && editName.trim() && onRename) {
|
|
|
|
|
onRename(editingKey, editName.trim());
|
|
|
|
|
}
|
|
|
|
|
setEditingKey(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full sm:w-64 bg-white border-r border-gray-200 flex flex-col h-full">
|
|
|
|
|
<div className="p-3 border-b border-gray-100 flex items-center justify-between">
|
|
|
|
|
@@ -71,15 +88,36 @@ function ThreadList({
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => onSelect(thread.sessionKey)}
|
|
|
|
|
>
|
|
|
|
|
<div className="text-sm font-medium text-gray-800 truncate pr-6">
|
|
|
|
|
{thread.name}
|
|
|
|
|
</div>
|
|
|
|
|
{editingKey === thread.sessionKey ? (
|
|
|
|
|
<input
|
|
|
|
|
autoFocus
|
|
|
|
|
className="text-sm font-medium text-gray-800 w-full bg-white border border-amber-300 rounded px-1 py-0.5 outline-none"
|
|
|
|
|
value={editName}
|
|
|
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
|
|
|
onBlur={commitRename}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter") commitRename();
|
|
|
|
|
if (e.key === "Escape") setEditingKey(null);
|
|
|
|
|
}}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div
|
|
|
|
|
className="text-sm font-medium text-gray-800 truncate pr-6"
|
|
|
|
|
onDoubleClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
startRename(thread.sessionKey, thread.name);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{thread.name}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{thread.lastMessage && (
|
|
|
|
|
<div className="text-xs text-gray-400 truncate mt-0.5">
|
|
|
|
|
{thread.lastMessage}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{onDelete && (
|
|
|
|
|
{onDelete && editingKey !== thread.sessionKey && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
@@ -101,7 +139,14 @@ function ThreadList({
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTimestamp(ts?: number): string {
|
|
|
|
|
if (!ts) return "";
|
|
|
|
|
const d = new Date(ts);
|
|
|
|
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
|
|
|
|
const [showTime, setShowTime] = useState(false);
|
|
|
|
|
const isUser = msg.role === "user";
|
|
|
|
|
const isSystem = msg.role === "system";
|
|
|
|
|
|
|
|
|
|
@@ -116,37 +161,65 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3`}>
|
|
|
|
|
<div
|
|
|
|
|
className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3 group`}
|
|
|
|
|
onClick={() => setShowTime(!showTime)}
|
|
|
|
|
>
|
|
|
|
|
{!isUser && (
|
|
|
|
|
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
|
|
|
|
|
🔨
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div
|
|
|
|
|
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
|
|
|
|
|
isUser
|
|
|
|
|
? "bg-blue-500 text-white rounded-br-md"
|
|
|
|
|
: "bg-gray-100 text-gray-800 rounded-bl-md"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{isUser ? (
|
|
|
|
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-sm leading-relaxed prose prose-sm prose-gray max-w-none [&_pre]:bg-gray-800 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500">
|
|
|
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
|
|
|
{msg.content}
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<div
|
|
|
|
|
className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${
|
|
|
|
|
isUser
|
|
|
|
|
? "bg-blue-500 text-white rounded-br-md"
|
|
|
|
|
: "bg-gray-100 text-gray-800 rounded-bl-md"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{isUser ? (
|
|
|
|
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-sm leading-relaxed prose prose-sm prose-gray max-w-none [&_pre]:bg-gray-800 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500">
|
|
|
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
|
|
|
{msg.content}
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{showTime && msg.timestamp && (
|
|
|
|
|
<span className={`text-[10px] text-gray-400 mt-0.5 ${isUser ? "text-right" : "text-left"}`}>
|
|
|
|
|
{formatTimestamp(msg.timestamp)}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ThinkingIndicator() {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex justify-start mb-3">
|
|
|
|
|
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
|
|
|
|
|
🔨
|
|
|
|
|
</div>
|
|
|
|
|
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
|
|
|
|
|
<div className="flex gap-1.5">
|
|
|
|
|
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
|
|
|
|
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
|
|
|
|
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ChatArea({
|
|
|
|
|
messages,
|
|
|
|
|
loading,
|
|
|
|
|
streaming,
|
|
|
|
|
thinking,
|
|
|
|
|
streamText,
|
|
|
|
|
onSend,
|
|
|
|
|
onAbort,
|
|
|
|
|
@@ -155,6 +228,7 @@ function ChatArea({
|
|
|
|
|
messages: ChatMessage[];
|
|
|
|
|
loading: boolean;
|
|
|
|
|
streaming: boolean;
|
|
|
|
|
thinking: boolean;
|
|
|
|
|
streamText: string;
|
|
|
|
|
onSend: (msg: string) => void;
|
|
|
|
|
onAbort: () => void;
|
|
|
|
|
@@ -166,7 +240,15 @@ function ChatArea({
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
|
|
|
}, [messages, streamText]);
|
|
|
|
|
}, [messages, streamText, thinking]);
|
|
|
|
|
|
|
|
|
|
// Auto-resize textarea
|
|
|
|
|
const autoResize = useCallback(() => {
|
|
|
|
|
const el = inputRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
el.style.height = "42px";
|
|
|
|
|
el.style.height = Math.min(el.scrollHeight, 128) + "px";
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleSend = () => {
|
|
|
|
|
const text = input.trim();
|
|
|
|
|
@@ -204,6 +286,7 @@ function ChatArea({
|
|
|
|
|
{streaming && streamText && (
|
|
|
|
|
<MessageBubble msg={{ role: "assistant", content: streamText }} />
|
|
|
|
|
)}
|
|
|
|
|
{thinking && !streaming && <ThinkingIndicator />}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<div ref={messagesEndRef} />
|
|
|
|
|
@@ -227,7 +310,7 @@ function ChatArea({
|
|
|
|
|
<textarea
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
value={input}
|
|
|
|
|
onChange={(e) => setInput(e.target.value)}
|
|
|
|
|
onChange={(e) => { setInput(e.target.value); autoResize(); }}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
placeholder={connected ? "Type a message..." : "Connecting..."}
|
|
|
|
|
disabled={!connected}
|
|
|
|
|
@@ -271,6 +354,7 @@ export function ChatPage() {
|
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [streaming, setStreaming] = useState(false);
|
|
|
|
|
const [thinking, setThinking] = useState(false);
|
|
|
|
|
const [streamText, setStreamText] = useState("");
|
|
|
|
|
const [showThreads, setShowThreads] = useState(false);
|
|
|
|
|
|
|
|
|
|
@@ -295,6 +379,7 @@ export function ChatPage() {
|
|
|
|
|
if (payload.sessionKey !== activeThread) return;
|
|
|
|
|
|
|
|
|
|
if (payload.state === "delta" && payload.message?.content) {
|
|
|
|
|
setThinking(false);
|
|
|
|
|
setStreaming(true);
|
|
|
|
|
const textParts = payload.message.content
|
|
|
|
|
.filter((c: any) => c.type === "text")
|
|
|
|
|
@@ -304,13 +389,14 @@ export function ChatPage() {
|
|
|
|
|
setStreamText((prev) => prev + textParts);
|
|
|
|
|
}
|
|
|
|
|
} else if (payload.state === "final") {
|
|
|
|
|
setThinking(false);
|
|
|
|
|
if (payload.message?.content) {
|
|
|
|
|
const text = payload.message.content
|
|
|
|
|
.filter((c: any) => c.type === "text")
|
|
|
|
|
.map((c: any) => c.text)
|
|
|
|
|
.join("");
|
|
|
|
|
if (text) {
|
|
|
|
|
setMessages((prev) => [...prev, { role: "assistant", content: text }]);
|
|
|
|
|
setMessages((prev) => [...prev, { role: "assistant", content: text, timestamp: Date.now() }]);
|
|
|
|
|
setThreads((prev) =>
|
|
|
|
|
prev.map((t) =>
|
|
|
|
|
t.sessionKey === activeThread
|
|
|
|
|
@@ -323,6 +409,7 @@ export function ChatPage() {
|
|
|
|
|
setStreaming(false);
|
|
|
|
|
setStreamText("");
|
|
|
|
|
} else if (payload.state === "aborted" || payload.state === "error") {
|
|
|
|
|
setThinking(false);
|
|
|
|
|
setStreaming(false);
|
|
|
|
|
if (streamText) {
|
|
|
|
|
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
|
|
|
|
|
@@ -385,6 +472,10 @@ export function ChatPage() {
|
|
|
|
|
setMessages([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRenameThread = (key: string, name: string) => {
|
|
|
|
|
setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeleteThread = (key: string) => {
|
|
|
|
|
setThreads((prev) => prev.filter((t) => t.sessionKey !== key));
|
|
|
|
|
if (activeThread === key) {
|
|
|
|
|
@@ -397,7 +488,8 @@ export function ChatPage() {
|
|
|
|
|
const handleSend = async (text: string) => {
|
|
|
|
|
if (!activeThread) return;
|
|
|
|
|
|
|
|
|
|
setMessages((prev) => [...prev, { role: "user", content: text }]);
|
|
|
|
|
setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
|
|
|
|
|
setThinking(true);
|
|
|
|
|
setThreads((prev) =>
|
|
|
|
|
prev.map((t) =>
|
|
|
|
|
t.sessionKey === activeThread
|
|
|
|
|
@@ -410,6 +502,7 @@ export function ChatPage() {
|
|
|
|
|
await client.chatSend(activeThread, text);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("Failed to send:", e);
|
|
|
|
|
setThinking(false);
|
|
|
|
|
setMessages((prev) => [
|
|
|
|
|
...prev,
|
|
|
|
|
{ role: "system", content: "Failed to send message. Please try again." },
|
|
|
|
|
@@ -494,6 +587,7 @@ export function ChatPage() {
|
|
|
|
|
handleCreateThread();
|
|
|
|
|
setShowThreads(false);
|
|
|
|
|
}}
|
|
|
|
|
onRename={handleRenameThread}
|
|
|
|
|
onDelete={handleDeleteThread}
|
|
|
|
|
onClose={() => setShowThreads(false)}
|
|
|
|
|
/>
|
|
|
|
|
@@ -504,6 +598,7 @@ export function ChatPage() {
|
|
|
|
|
messages={messages}
|
|
|
|
|
loading={loading}
|
|
|
|
|
streaming={streaming}
|
|
|
|
|
thinking={thinking}
|
|
|
|
|
streamText={streamText}
|
|
|
|
|
onSend={handleSend}
|
|
|
|
|
onAbort={handleAbort}
|
|
|
|
|
|