All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
290 lines
9.7 KiB
TypeScript
290 lines
9.7 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { api } from '@/lib/api';
|
|
import { destroySocket, getSocket } from '@/lib/socket';
|
|
import type { Conversation, Message } from '@/lib/types';
|
|
import { ConversationList } from '@/components/chat/conversation-list';
|
|
import { MessageBubble } from '@/components/chat/message-bubble';
|
|
import { ChatInput } from '@/components/chat/chat-input';
|
|
import { StreamingMessage } from '@/components/chat/streaming-message';
|
|
|
|
export default function ChatPage(): React.ReactElement {
|
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [streamingText, setStreamingText] = useState('');
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Track the active conversation ID in a ref so socket event handlers always
|
|
// see the current value without needing to be re-registered.
|
|
const activeIdRef = useRef<string | null>(null);
|
|
activeIdRef.current = activeId;
|
|
|
|
// Accumulate streamed text in a ref so agent:end can read the full content
|
|
// without stale-closure issues.
|
|
const streamingTextRef = useRef('');
|
|
|
|
// Load conversations on mount
|
|
useEffect(() => {
|
|
api<Conversation[]>('/api/conversations')
|
|
.then(setConversations)
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// Load messages when active conversation changes
|
|
useEffect(() => {
|
|
if (!activeId) {
|
|
setMessages([]);
|
|
return;
|
|
}
|
|
// Clear streaming state when switching conversations
|
|
setIsStreaming(false);
|
|
setStreamingText('');
|
|
streamingTextRef.current = '';
|
|
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
|
.then(setMessages)
|
|
.catch(() => {});
|
|
}, [activeId]);
|
|
|
|
// Auto-scroll to bottom
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages, streamingText]);
|
|
|
|
// Socket.io setup — connect once for the page lifetime
|
|
useEffect(() => {
|
|
const socket = getSocket();
|
|
|
|
function onAgentStart(data: { conversationId: string }): void {
|
|
// Only update state if the event belongs to the currently viewed conversation
|
|
if (activeIdRef.current !== data.conversationId) return;
|
|
setIsStreaming(true);
|
|
setStreamingText('');
|
|
streamingTextRef.current = '';
|
|
}
|
|
|
|
function onAgentText(data: { conversationId: string; text: string }): void {
|
|
if (activeIdRef.current !== data.conversationId) return;
|
|
streamingTextRef.current += data.text;
|
|
setStreamingText((prev) => prev + data.text);
|
|
}
|
|
|
|
function onAgentEnd(data: { conversationId: string }): void {
|
|
if (activeIdRef.current !== data.conversationId) return;
|
|
const finalText = streamingTextRef.current;
|
|
setIsStreaming(false);
|
|
setStreamingText('');
|
|
streamingTextRef.current = '';
|
|
// Append the completed assistant message to the local message list.
|
|
// The Pi agent session is in-memory so the assistant response is not
|
|
// persisted to the DB — we build the local UI state instead.
|
|
if (finalText) {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: `assistant-${Date.now()}`,
|
|
conversationId: data.conversationId,
|
|
role: 'assistant' as const,
|
|
content: finalText,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
]);
|
|
}
|
|
}
|
|
|
|
function onError(data: { error: string; conversationId?: string }): void {
|
|
setIsStreaming(false);
|
|
setStreamingText('');
|
|
streamingTextRef.current = '';
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: `error-${Date.now()}`,
|
|
conversationId: data.conversationId ?? '',
|
|
role: 'system' as const,
|
|
content: `Error: ${data.error}`,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
]);
|
|
}
|
|
|
|
socket.on('agent:start', onAgentStart);
|
|
socket.on('agent:text', onAgentText);
|
|
socket.on('agent:end', onAgentEnd);
|
|
socket.on('error', onError);
|
|
|
|
// Connect if not already connected
|
|
if (!socket.connected) {
|
|
socket.connect();
|
|
}
|
|
|
|
return () => {
|
|
socket.off('agent:start', onAgentStart);
|
|
socket.off('agent:text', onAgentText);
|
|
socket.off('agent:end', onAgentEnd);
|
|
socket.off('error', onError);
|
|
// Fully tear down the socket when the chat page unmounts so we get a
|
|
// fresh authenticated connection next time the page is visited.
|
|
destroySocket();
|
|
};
|
|
}, []);
|
|
|
|
const handleNewConversation = useCallback(async () => {
|
|
const conv = await api<Conversation>('/api/conversations', {
|
|
method: 'POST',
|
|
body: { title: 'New conversation' },
|
|
});
|
|
setConversations((prev) => [conv, ...prev]);
|
|
setActiveId(conv.id);
|
|
setMessages([]);
|
|
}, []);
|
|
|
|
const handleRename = useCallback(async (id: string, title: string) => {
|
|
const updated = await api<Conversation>(`/api/conversations/${id}`, {
|
|
method: 'PATCH',
|
|
body: { title },
|
|
});
|
|
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
|
}, []);
|
|
|
|
const handleDelete = useCallback(
|
|
async (id: string) => {
|
|
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
|
|
setConversations((prev) => prev.filter((c) => c.id !== id));
|
|
if (activeId === id) {
|
|
setActiveId(null);
|
|
setMessages([]);
|
|
}
|
|
},
|
|
[activeId],
|
|
);
|
|
|
|
const handleArchive = useCallback(
|
|
async (id: string, archived: boolean) => {
|
|
const updated = await api<Conversation>(`/api/conversations/${id}`, {
|
|
method: 'PATCH',
|
|
body: { archived },
|
|
});
|
|
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
|
// If archiving the active conversation, deselect it
|
|
if (archived && activeId === id) {
|
|
setActiveId(null);
|
|
setMessages([]);
|
|
}
|
|
},
|
|
[activeId],
|
|
);
|
|
|
|
const handleSend = useCallback(
|
|
async (content: string) => {
|
|
let convId = activeId;
|
|
|
|
// Auto-create conversation if none selected
|
|
if (!convId) {
|
|
const autoTitle = content.slice(0, 60);
|
|
const conv = await api<Conversation>('/api/conversations', {
|
|
method: 'POST',
|
|
body: { title: autoTitle },
|
|
});
|
|
setConversations((prev) => [conv, ...prev]);
|
|
setActiveId(conv.id);
|
|
convId = conv.id;
|
|
} else {
|
|
// Auto-title: if the active conversation still has the default "New
|
|
// conversation" title and this is the first message, update the title
|
|
// from the message content.
|
|
const activeConv = conversations.find((c) => c.id === convId);
|
|
if (activeConv?.title === 'New conversation' && messages.length === 0) {
|
|
const autoTitle = content.slice(0, 60);
|
|
api<Conversation>(`/api/conversations/${convId}`, {
|
|
method: 'PATCH',
|
|
body: { title: autoTitle },
|
|
})
|
|
.then((updated) => {
|
|
setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c)));
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
|
|
// Optimistic user message in local UI state
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: `user-${Date.now()}`,
|
|
conversationId: convId,
|
|
role: 'user' as const,
|
|
content,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
]);
|
|
|
|
// Persist the user message to the DB so conversation history is
|
|
// available when the page is reloaded or a new session starts.
|
|
api<Message>(`/api/conversations/${convId}/messages`, {
|
|
method: 'POST',
|
|
body: { role: 'user', content },
|
|
}).catch(() => {
|
|
// Non-fatal: the agent can still process the message even if
|
|
// REST persistence fails.
|
|
});
|
|
|
|
// Send to WebSocket — gateway creates/resumes the agent session and
|
|
// streams the response back via agent:start / agent:text / agent:end.
|
|
const socket = getSocket();
|
|
if (!socket.connected) {
|
|
socket.connect();
|
|
}
|
|
socket.emit('message', { conversationId: convId, content });
|
|
},
|
|
[activeId, conversations, messages],
|
|
);
|
|
|
|
return (
|
|
<div className="-m-6 flex h-[calc(100vh-3.5rem)]">
|
|
<ConversationList
|
|
conversations={conversations}
|
|
activeId={activeId}
|
|
onSelect={setActiveId}
|
|
onNew={handleNewConversation}
|
|
onRename={handleRename}
|
|
onDelete={handleDelete}
|
|
onArchive={handleArchive}
|
|
/>
|
|
|
|
<div className="flex flex-1 flex-col">
|
|
{activeId ? (
|
|
<>
|
|
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
|
{messages.map((msg) => (
|
|
<MessageBubble key={msg.id} message={msg} />
|
|
))}
|
|
{isStreaming && <StreamingMessage text={streamingText} />}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
<ChatInput onSend={handleSend} disabled={isStreaming} />
|
|
</>
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<div className="text-center">
|
|
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
|
|
<p className="mt-1 text-sm text-text-muted">
|
|
Select a conversation or start a new one
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={handleNewConversation}
|
|
className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
|
>
|
|
Start new conversation
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|