'use client'; import { useEffect, useRef, useState } from 'react'; import { IconLayoutSidebar, IconMessage, IconPlus, IconRobot, IconSend, IconSettings, IconUser, } from '@tabler/icons-react'; import { ActionIcon, AppShell, Avatar, Burger, Container, Group, Paper, ScrollArea, Select, Stack, Text, TextInput, TextInputProps, Title, Tooltip, UnstyledButton, 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 { 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[]; } 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); // Scroll state const scrollViewportRef = useRef(null); const isUserScrolledUp = useRef(false); const isStreaming = useRef(false); // Handle scroll events to track if user scrolled up (only when not streaming) const handleScroll = () => { if (isStreaming.current) { return; // Ignore scroll position checks during streaming } const viewport = scrollViewportRef.current; if (!viewport) { return; } 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; } }; // 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 () => { setIsLoadingChats(true); try { const res = await fetch('/api/chats'); if (res.ok) { const data = await res.json(); if (Array.isArray(data)) { setChats(data); } else { setChats([]); } } else { setChats([]); } } catch (e) { console.error('Failed to fetch chats', e); setChats([]); } finally { setIsLoadingChats(false); } }; const handleSelectChat = (chat: Chat) => { setActiveChatId(chat.id); if (chat.messages) { setMessages(chat.messages); } else { setMessages([]); } if (mobileOpened) { toggleMobile(); } }; 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 both user message and assistant response to database try { // Save user message 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; // Update activeChatId if this was a new chat if (!activeChatId && savedChatId) { setActiveChatId(savedChatId); } // Save assistant response await fetch('/api/chats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chatId: savedChatId, messages: [responseMessage], }), }); // 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(); } }; return ( <> ChatGPZ