diff --git a/app/actions/chat.ts b/app/actions/chat.ts new file mode 100644 index 0000000..b650635 --- /dev/null +++ b/app/actions/chat.ts @@ -0,0 +1,134 @@ +'use server'; + +import ollama, { Tool } from 'ollama'; + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + tool_calls?: any[]; // Ollama tool calls + images?: string[]; +} + +// --- Tool Definitions --- + +const tools: Tool[] = [ + { + type: 'function', + function: { + name: 'get_current_time', + description: 'Get the current time', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + }, + }, + { + type: 'function', + function: { + name: 'calculate', + description: 'Perform a mathematical calculation', + parameters: { + type: 'object', + properties: { + expression: { + type: 'string', + description: 'The mathematical expression to evaluate (e.g., "2 + 2" or "15 * 7")', + }, + }, + required: ['expression'], + }, + }, + }, +]; + +// --- Tool Implementations --- + +const availableTools: Record = { + get_current_time: () => { + return new Date().toLocaleTimeString(); + }, + calculate: ({ expression }: { expression: string }) => { + try { + // Safety: simplistic eval for demo purposes. + // In production, use a math parser library like mathjs. + // eslint-disable-next-line no-eval + return eval(expression).toString(); + } catch { + return 'Error evaluating expression'; + } + }, +}; + +export async function chat(model: string, messages: ChatMessage[]) { + try { + // 1. Initial Call + let response; + try { + response = await ollama.chat({ + model: model, + messages: messages, + tools: tools, + }); + } catch (e: any) { + // Fallback: If model doesn't support tools, retry without them + if (e.message?.includes('does not support tools')) { + console.warn(`Model ${model} does not support tools. Falling back to standard chat.`); + response = await ollama.chat({ + model: model, + messages: messages, + }); + } else { + throw e; + } + } + + // 2. Loop to handle tool calls (Ollama might chain multiple calls) + // We limit recursion to avoid infinite loops + let maxTurns = 5; + + while (response.message.tool_calls && response.message.tool_calls.length > 0 && maxTurns > 0) { + maxTurns--; + + // Append the assistant's message (which contains the tool calls) to history + messages.push(response.message as ChatMessage); + + // Execute each tool call + for (const tool of response.message.tool_calls) { + const functionName = tool.function.name; + const functionToCall = availableTools[functionName]; + + if (functionToCall) { + console.log(`🤖 Tool Call: ${functionName}`, tool.function.arguments); + const functionArgs = tool.function.arguments; + const functionResponse = functionToCall(functionArgs); + + // Append the tool result to history + messages.push({ + role: 'tool', + content: functionResponse, + }); + } + } + + // 3. Send the tool results back to the model to get the final answer + response = await ollama.chat({ + model: model, + messages: messages, + tools: tools, + }); + } + + return { + success: true, + message: response.message, + }; + } catch (error: any) { + console.error('Chat error:', error); + return { + success: false, + error: error.message || 'Failed to generate response', + }; + } +} diff --git a/app/actions/ollama.ts b/app/actions/ollama.ts new file mode 100644 index 0000000..997c569 --- /dev/null +++ b/app/actions/ollama.ts @@ -0,0 +1,51 @@ +'use server'; + +import ollama from 'ollama'; + +export interface OllamaModel { + name: string; + size: number; + digest: string; + details: { + format: string; + family: string; + families: string[]; + parameter_size: string; + quantization_level: string; + }; +} + +export async function getInstalledModels(): Promise { + try { + const response = await ollama.list(); + return response.models as OllamaModel[]; + } catch (error) { + console.error('Error fetching models:', error); + return []; + } +} + +export async function pullModel(modelName: string): Promise<{ success: boolean; message: string }> { + try { + // This awaits the full pull. For large models, this might timeout the server action. + // Ideally we would stream this, but for now we'll try a simple await. + // Next.js Server Actions have a default timeout. + await ollama.pull({ model: modelName }); + return { success: true, message: `Successfully pulled ${modelName}` }; + } catch (error: any) { + console.error('Error pulling model:', error); + return { success: false, message: error.message || 'Failed to pull model' }; + } +} + +export async function deleteModel( + modelName: string +): Promise<{ success: boolean; message: string }> { + try { + await ollama.delete({ model: modelName }); + return { success: true, message: `Successfully deleted ${modelName}` }; + } catch (error: any) { + console.error('Error deleting model:', error); + return { success: false, message: error.message || 'Failed to delete model' }; + } +} diff --git a/components/Chat/ChatLayout.tsx b/components/Chat/ChatLayout.tsx index 3b04ad7..aad87a2 100644 --- a/components/Chat/ChatLayout.tsx +++ b/components/Chat/ChatLayout.tsx @@ -20,6 +20,7 @@ import { Paper, rem, ScrollArea, + Select, Stack, Text, TextInput, @@ -29,6 +30,8 @@ import { useMantineTheme, } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; +import { chat, type ChatMessage } from '@/app/actions/chat'; +import { getInstalledModels, type OllamaModel } from '@/app/actions/ollama'; import { useThemeContext } from '@/components/DynamicThemeProvider'; import { SettingsModal } from '@/components/Settings/SettingsModal'; @@ -66,10 +69,25 @@ export default function ChatLayout() { const [isInputFocused, setIsInputFocused] = useState(false); const [isLoadingChats, setIsLoadingChats] = useState(false); - // Fetch chats on load + // Model State + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + + // Fetch chats and models on load useEffect(() => { fetchChats(); - }, [settingsOpened]); // Refresh when settings close (might have logged in/out) + 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); @@ -98,7 +116,6 @@ export default function ChatLayout() { if (chat.messages) { setMessages(chat.messages); } else { - // In a real app we might fetch full messages here if not included in list setMessages([]); } if (mobileOpened) { @@ -121,9 +138,7 @@ export default function ChatLayout() { }; const handleSendMessage = async () => { - if (!inputValue.trim()) { - return; - } + if (!inputValue.trim() || !selectedModel) return; const userMessage: Message = { id: Date.now().toString(), @@ -135,54 +150,38 @@ export default function ChatLayout() { const newMessages = [...messages, userMessage]; setMessages(newMessages); setInputValue(''); + setIsGenerating(true); try { - // Save to backend - const res = await fetch('/api/chats', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: [userMessage], - chatId: activeChatId, - }), - }); + // Convert to format expected by server action + const chatHistory: ChatMessage[] = newMessages.map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })); - if (res.ok) { - const data = await res.json(); - if (data.chatId && data.chatId !== activeChatId) { - setActiveChatId(data.chatId); - fetchChats(); // Refresh list to show new chat - } + // Call Ollama via Server Action + const result = await chat(selectedModel, chatHistory); - // Simulate AI response - setTimeout(async () => { - const responseMessage: Message = { - id: (Date.now() + 1).toString(), - role: 'assistant', - content: - 'I am a simulated AI response. I do not have a backend yet. I just repeat that I am simulated.', - }; - - const updatedMessages = [...newMessages, responseMessage]; - setMessages(updatedMessages); - - // Save AI response to backend - try { - await fetch('/api/chats', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: [responseMessage], - chatId: data.chatId || activeChatId, - }), - }); - } catch (e) { - console.error(e); - } - }, 1000); + if (result.success && result.message) { + const responseMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: result.message.content, + }; + setMessages([...newMessages, responseMessage]); + } else { + // Error handling + const errorMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: `Error: ${result.error}`, + }; + setMessages([...newMessages, errorMessage]); } } catch (e) { - console.error('Failed to save message', e); + console.error('Failed to send message', e); + } finally { + setIsGenerating(false); } }; @@ -213,7 +212,18 @@ export default function ChatLayout() { - AI Chat + + AI Chat + +