diff --git a/client/src/components/admin/AiSettingsCard.tsx b/client/src/components/admin/AiSettingsCard.tsx index d0db262..5afcf18 100644 --- a/client/src/components/admin/AiSettingsCard.tsx +++ b/client/src/components/admin/AiSettingsCard.tsx @@ -10,6 +10,18 @@ import { Loader2, Bot } from "lucide-react"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"; +import { Loader2, Bot, RefreshCw, Download, Server } from "lucide-react"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; + export function AiSettingsCard() { const { t } = useTranslation(); const { toast } = useToast(); @@ -24,6 +36,10 @@ export function AiSettingsCard() { const [model, setModel] = useState("gpt-4o"); const [baseUrl, setBaseUrl] = useState(""); + // Ollama specific state + const [ollamaModels, setOllamaModels] = useState([]); + const [pullModelName, setPullModelName] = useState(""); + useEffect(() => { if (settings) { setProvider(settings.ai_provider || "openai"); @@ -47,6 +63,39 @@ export function AiSettingsCard() { } }); + const fetchOllamaModelsMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest("POST", "/api/admin/ollama/tags", { baseUrl }); + if (!res.ok) throw new Error("Failed to fetch models"); + return res.json(); + }, + onSuccess: (data: any) => { + const models = data.models?.map((m: any) => m.name) || []; + setOllamaModels(models); + toast({ title: `Found ${models.length} models` }); + }, + onError: (err: Error) => { + toast({ title: "Could not connect to Ollama", description: err.message, variant: "destructive" }); + } + }); + + const pullOllamaModelMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest("POST", "/api/admin/ollama/pull", { baseUrl, model: pullModelName }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); + }, + onSuccess: () => { + toast({ title: "Model pulled successfully" }); + setPullModelName(""); + fetchOllamaModelsMutation.mutate(); // Refresh list + }, + onError: (err: Error) => { + toast({ title: "Failed to pull model", description: err.message, variant: "destructive" }); + } + }); + + const handleSave = () => { mutation.mutate({ ai_provider: provider, @@ -75,8 +124,8 @@ export function AiSettingsCard() { if (v === 'openai' && model === 'claude-3-5-sonnet') setModel('gpt-4o'); if (v === 'anthropic' && model === 'gpt-4o') setModel('claude-3-5-sonnet'); if (v === 'ollama') { - setBaseUrl('http://localhost:11434'); - setModel('llama3'); + setBaseUrl(baseUrl || 'http://localhost:11434'); + if (!ollamaModels.length) setModel('llama3'); // Default if unknown } }}> @@ -102,27 +151,87 @@ export function AiSettingsCard() {
- - setModel(e.target.value)} - placeholder="e.g. gpt-4, claude-3-opus, llama3" - /> + +
+ setBaseUrl(e.target.value)} + placeholder={provider === 'ollama' ? 'http://localhost:11434' : 'Optional override'} + /> + {provider === 'ollama' && ( + + )} +
- - setBaseUrl(e.target.value)} - placeholder={provider === 'ollama' ? 'http://localhost:11434' : 'Optional override'} - /> + + {provider === 'ollama' && ollamaModels.length > 0 ? ( +
+ +
+ ) : ( + setModel(e.target.value)} + placeholder="e.g. gpt-4, llama3" + /> + )}
+ + {provider === 'ollama' && ( +
+ +
+ setPullModelName(e.target.value)} + /> + +
+

+ Please check your server logs/Ollama console if the pull takes a long time. + Ensure the container has internet access. +

+
+ )} + diff --git a/server/routes.ts b/server/routes.ts index b4d7da8..45f7ba9 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -210,6 +210,51 @@ export async function registerRoutes(app: Express): Promise { res.json({ success: true }); }); + + app.post("/api/admin/ollama/tags", isAdmin, async (req, res) => { + const { baseUrl } = req.body; + // Default to localhost:11434 if not provided, or stored setting? + // Client should send the value from the input field. + const url = (baseUrl || "http://localhost:11434").replace(/\/$/, "") + "/api/tags"; + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Ollama Error: ${resp.statusText}`); + const data = await resp.json(); + res.json(data); + } catch (e: any) { + console.error("Ollama Tags Error:", e); + res.status(500).json({ error: "Failed to fetch Ollama tags: " + e.message }); + } + }); + + app.post("/api/admin/ollama/pull", isAdmin, async (req, res) => { + const { baseUrl, model } = req.body; + if (!model) return res.status(400).json({ error: "Model name required" }); + + const url = (baseUrl || "http://localhost:11434").replace(/\/$/, "") + "/api/pull"; + console.log(`Pulling Ollama model ${model} from ${url}...`); + + try { + // connecting to ollama + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: model, stream: false }), + }); + + if (!resp.ok) { + const errText = await resp.text(); + throw new Error(`Ollama Pull Error: ${errText}`); + } + + const data = await resp.json(); + res.json(data); + } catch (e: any) { + console.error("Ollama Pull Error:", e); + res.status(500).json({ error: "Failed to pull model: " + e.message }); + } + }); + // --- AI Routes --- app.post("/api/ai/chat", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401);