'use client'; import { useEffect, useRef, useState } from 'react'; import { IconLayoutSidebar, IconMessage, IconPencil, IconPin, IconPlus, IconRobot, IconSend, IconSettings, IconTrash, IconUser, } from '@tabler/icons-react'; import { ActionIcon, AppShell, Avatar, Burger, Container, Group, Menu, NavLink, Paper, ScrollArea, Select, Stack, Text, TextInput, TextInputProps, Title, Tooltip, useMantineTheme, } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { getInstalledModels, type OllamaModel } from '@/app/actions/ollama'; import { useThemeContext } from '@/components/DynamicThemeProvider'; import { SettingsModal } from '@/components/Settings/SettingsModal'; import { deleteLocalChat, deleteScrollPosition, getLocalChat, getLocalChats, getScrollPosition, mergeChats, saveLocalChat, saveScrollPosition, setLocalChats, } from '@/lib/chatStorage'; import { MarkdownMessage } from './MarkdownMessage'; import classes from './ChatLayout.module.css'; interface Message { id: string; role: 'user' | 'assistant'; content: string; } interface Chat { id: string; title: string; updatedAt: string; messages?: Message[]; pinned?: boolean; } export function InputWithButton(props: TextInputProps) { const theme = useMantineTheme(); return ( } {...props} /> ); } export default function ChatLayout() { const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); const [settingsOpened, { open: openSettings, close: closeSettings }] = useDisclosure(false); const { primaryColor, setPrimaryColor } = useThemeContext(); const theme = useMantineTheme(); // State const [chats, setChats] = useState([]); const [activeChatId, setActiveChatId] = useState(null); const [messages, setMessages] = useState([ { id: '1', role: 'assistant', content: 'Hello! I am an AI assistant. How can I help you today?', }, ]); const [inputValue, setInputValue] = useState(''); const [isLoadingChats, setIsLoadingChats] = useState(false); // Model State const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(null); const [_isGenerating, setIsGenerating] = useState(false); const [streamingMessageId, setStreamingMessageId] = useState(null); // Inline editing state const [editingChatId, setEditingChatId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const editInputRef = useRef(null); const justStartedEditing = useRef(false); // Scroll state const scrollViewportRef = useRef(null); const isUserScrolledUp = useRef(false); const isStreaming = useRef(false); // Handle scroll events - save position and track if user scrolled up const handleScroll = () => { const viewport = scrollViewportRef.current; if (!viewport) { return; } // Save scroll position for current chat if (activeChatId) { saveScrollPosition(activeChatId, viewport.scrollTop); } // Track if user scrolled up (only when not streaming) if (!isStreaming.current) { const threshold = 50; isUserScrolledUp.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > threshold; } }; // Scroll to bottom using CSS scroll-behavior for smooth animation const scrollToBottom = () => { const viewport = scrollViewportRef.current; if (viewport && !isUserScrolledUp.current) { viewport.scrollTop = viewport.scrollHeight; } }; // Restore scroll position for a chat const restoreScrollPosition = (chatId: string) => { // Use requestAnimationFrame to wait for DOM to update after messages render requestAnimationFrame(() => { requestAnimationFrame(() => { const viewport = scrollViewportRef.current; if (!viewport) { return; } const savedPosition = getScrollPosition(chatId); if (savedPosition !== null) { // Temporarily disable smooth scrolling for instant restore viewport.style.scrollBehavior = 'auto'; viewport.scrollTop = savedPosition; viewport.style.scrollBehavior = ''; } else { // New chat or no saved position - scroll to bottom viewport.style.scrollBehavior = 'auto'; viewport.scrollTop = viewport.scrollHeight; viewport.style.scrollBehavior = ''; } }); }); }; // Auto-scroll when messages change (during streaming) useEffect(() => { if (streamingMessageId) { scrollToBottom(); } }, [messages, streamingMessageId]); // Track streaming state and scroll to bottom when streaming starts useEffect(() => { if (streamingMessageId) { isStreaming.current = true; isUserScrolledUp.current = false; scrollToBottom(); } else { isStreaming.current = false; } }, [streamingMessageId]); // Fetch chats and models on load useEffect(() => { fetchChats(); fetchModels(); }, [settingsOpened]); const fetchModels = async () => { const list = await getInstalledModels(); setModels(list); // Select first model if none selected and list not empty if (!selectedModel && list.length > 0) { setSelectedModel(list[0].name); } }; const fetchChats = async () => { // Load from localStorage first for instant display const localChats = getLocalChats(); if (localChats.length > 0) { setChats(localChats); } // Then fetch from database and merge setIsLoadingChats(true); try { const res = await fetch('/api/chats'); if (res.ok) { const remoteChats = await res.json(); if (Array.isArray(remoteChats)) { // Merge local and remote, update both state and localStorage const merged = mergeChats(localChats, remoteChats); setChats(merged); setLocalChats(merged); } } } catch (e) { console.error('Failed to fetch chats from server:', e); // Keep using local chats if server fails } finally { setIsLoadingChats(false); } }; const handleSelectChat = (chat: Chat) => { setActiveChatId(chat.id); // Try to load from localStorage first (faster), fall back to passed chat data const localChat = getLocalChat(chat.id); if (localChat?.messages && localChat.messages.length > 0) { setMessages(localChat.messages); } else if (chat.messages) { setMessages(chat.messages); } else { setMessages([]); } if (mobileOpened) { toggleMobile(); } // Restore saved scroll position restoreScrollPosition(chat.id); }; const handleNewChat = () => { setActiveChatId(null); setMessages([ { id: Date.now().toString(), role: 'assistant', content: 'Hello! I am an AI assistant. How can I help you today?', }, ]); if (mobileOpened) { toggleMobile(); } }; const handleSendMessage = async () => { if (!inputValue.trim() || !selectedModel) { return; } const userMessage: Message = { id: Date.now().toString(), role: 'user', content: inputValue, }; // Optimistic update - add user message and empty assistant message for streaming const assistantMessageId = (Date.now() + 1).toString(); const newMessages = [...messages, userMessage]; setMessages([...newMessages, { id: assistantMessageId, role: 'assistant', content: '' }]); setInputValue(''); setIsGenerating(true); setStreamingMessageId(assistantMessageId); try { // Convert to format expected by API const chatHistory = newMessages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, })); // Call streaming API const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: selectedModel, messages: chatHistory, }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to get response'); } // Read the stream const reader = response.body?.getReader(); if (!reader) { throw new Error('No response body'); } const decoder = new TextDecoder(); let fullContent = ''; while (true) { const { done, value } = await reader.read(); if (done) { break; } const chunk = decoder.decode(value, { stream: true }); fullContent += chunk; // Update the assistant message with accumulated content setMessages((prev) => prev.map((m) => (m.id === assistantMessageId ? { ...m, content: fullContent } : m)) ); } // Create final response message for saving const responseMessage: Message = { id: assistantMessageId, role: 'assistant', content: fullContent, }; // Save to both localStorage and database try { // Save user message to database const userSaveRes = await fetch('/api/chats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chatId: activeChatId, messages: [userMessage], }), }); const userSaveData = await userSaveRes.json(); const savedChatId = userSaveData.chatId; const chatTitle = userSaveData.title || userMessage.content.slice(0, 50); // Update activeChatId if this was a new chat if (!activeChatId && savedChatId) { setActiveChatId(savedChatId); } // Save assistant response to database await fetch('/api/chats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chatId: savedChatId, messages: [responseMessage], }), }); // Save to localStorage const finalMessages = [...newMessages, responseMessage]; saveLocalChat({ id: savedChatId, title: chatTitle, updatedAt: new Date().toISOString(), messages: finalMessages, }); // Refresh chat list fetchChats(); } catch (saveError) { console.error('Failed to save messages:', saveError); } } catch (e) { console.error('Failed to send message', e); // Update assistant message with error setMessages((prev) => prev.map((m) => m.id === assistantMessageId ? { ...m, content: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` } : m ) ); } finally { setIsGenerating(false); setStreamingMessageId(null); } }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { handleSendMessage(); } }; // Chat menu handlers - start inline editing mode const handleRenameChat = (chatId: string) => { const chat = chats.find((c) => c.id === chatId); if (!chat) { return; } // Small delay to let the menu close first before entering edit mode setTimeout(() => { justStartedEditing.current = true; setEditingChatId(chatId); setEditingTitle(chat.title); // Focus the input after React renders setTimeout(() => { editInputRef.current?.focus(); editInputRef.current?.select(); // Allow blur to work after a short delay setTimeout(() => { justStartedEditing.current = false; }, 100); }, 0); }, 50); }; // Save the renamed chat title const saveRenamedChat = async () => { // Skip if we just started editing (prevents immediate blur from closing) if (justStartedEditing.current) { return; } if (!editingChatId) { return; } const chat = chats.find((c) => c.id === editingChatId); const newTitle = editingTitle.trim(); // Cancel if empty or unchanged if (!newTitle || newTitle === chat?.title) { setEditingChatId(null); setEditingTitle(''); return; } try { // Update in database await fetch(`/api/chats/${editingChatId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle }), }); // Update local state setChats((prev) => prev.map((c) => (c.id === editingChatId ? { ...c, title: newTitle } : c))); // Update localStorage const localChat = getLocalChat(editingChatId); if (localChat) { saveLocalChat({ ...localChat, title: newTitle }); } } catch (e) { console.error('Failed to rename chat:', e); } finally { setEditingChatId(null); setEditingTitle(''); } }; // Handle keyboard events in rename input const handleRenameKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); saveRenamedChat(); } else if (event.key === 'Escape') { setEditingChatId(null); setEditingTitle(''); } }; const handlePinChat = async (chatId: string) => { const chat = chats.find((c) => c.id === chatId); if (!chat) { return; } const newPinned = !chat.pinned; try { // Update in database await fetch(`/api/chats/${chatId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned: newPinned }), }); // Update local state and re-sort (pinned first) setChats((prev) => { const updated = prev.map((c) => (c.id === chatId ? { ...c, pinned: newPinned } : c)); return updated.sort((a, b) => { if (a.pinned && !b.pinned) { return -1; } if (!a.pinned && b.pinned) { return 1; } return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); }); // Update localStorage const localChat = getLocalChat(chatId); if (localChat) { saveLocalChat({ ...localChat, pinned: newPinned }); } } catch (e) { console.error('Failed to pin chat:', e); } }; const handleRemoveChat = async (chatId: string) => { if (!window.confirm('Are you sure you want to delete this chat?')) { return; } try { // Delete from database await fetch(`/api/chats/${chatId}`, { method: 'DELETE', }); // Remove from local state setChats((prev) => prev.filter((c) => c.id !== chatId)); // Remove from localStorage deleteLocalChat(chatId); deleteScrollPosition(chatId); // If this was the active chat, clear messages if (activeChatId === chatId) { setActiveChatId(null); setMessages([ { id: Date.now().toString(), role: 'assistant', content: 'Hello! I am an AI assistant. How can I help you today?', }, ]); } } catch (e) { console.error('Failed to delete chat:', e); } }; return ( <> ChatGPZ