feat: Enhanced Ollama Integration (Model Fetching/Pulling)
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -10,6 +10,18 @@ import { Loader2, Bot } from "lucide-react";
|
|||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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() {
|
export function AiSettingsCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -24,6 +36,10 @@ export function AiSettingsCard() {
|
|||||||
const [model, setModel] = useState("gpt-4o");
|
const [model, setModel] = useState("gpt-4o");
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
|
|
||||||
|
// Ollama specific state
|
||||||
|
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||||
|
const [pullModelName, setPullModelName] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
setProvider(settings.ai_provider || "openai");
|
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 = () => {
|
const handleSave = () => {
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
ai_provider: provider,
|
ai_provider: provider,
|
||||||
@@ -75,8 +124,8 @@ export function AiSettingsCard() {
|
|||||||
if (v === 'openai' && model === 'claude-3-5-sonnet') setModel('gpt-4o');
|
if (v === 'openai' && model === 'claude-3-5-sonnet') setModel('gpt-4o');
|
||||||
if (v === 'anthropic' && model === 'gpt-4o') setModel('claude-3-5-sonnet');
|
if (v === 'anthropic' && model === 'gpt-4o') setModel('claude-3-5-sonnet');
|
||||||
if (v === 'ollama') {
|
if (v === 'ollama') {
|
||||||
setBaseUrl('http://localhost:11434');
|
setBaseUrl(baseUrl || 'http://localhost:11434');
|
||||||
setModel('llama3');
|
if (!ollamaModels.length) setModel('llama3'); // Default if unknown
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -101,28 +150,88 @@ export function AiSettingsCard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>{t('settings.ai.model')}</Label>
|
|
||||||
<Input
|
|
||||||
value={model}
|
|
||||||
onChange={(e) => setModel(e.target.value)}
|
|
||||||
placeholder="e.g. gpt-4, claude-3-opus, llama3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t('settings.ai.baseUrl')}</Label>
|
<Label>{t('settings.ai.baseUrl')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
placeholder={provider === 'ollama' ? 'http://localhost:11434' : 'Optional override'}
|
placeholder={provider === 'ollama' ? 'http://localhost:11434' : 'Optional override'}
|
||||||
/>
|
/>
|
||||||
|
{provider === 'ollama' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => fetchOllamaModelsMutation.mutate()}
|
||||||
|
disabled={fetchOllamaModelsMutation.isPending}
|
||||||
|
title="Fetch Models from Server"
|
||||||
|
>
|
||||||
|
{fetchOllamaModelsMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>{t('settings.ai.model')}</Label>
|
||||||
|
{provider === 'ollama' && ollamaModels.length > 0 ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={model} onValueChange={setModel}>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ollamaModels.map(m => (
|
||||||
|
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="e.g. gpt-4, llama3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider === 'ollama' && (
|
||||||
|
<div className="pt-4 border-t space-y-3">
|
||||||
|
<Label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Pull Model from Ollama Library
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Model name (e.g. llama3, mistral)"
|
||||||
|
value={pullModelName}
|
||||||
|
onChange={(e) => setPullModelName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => pullOllamaModelMutation.mutate()}
|
||||||
|
disabled={!pullModelName || pullOllamaModelMutation.isPending}
|
||||||
|
>
|
||||||
|
{pullOllamaModelMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Pulling...
|
||||||
|
</>
|
||||||
|
) : "Pull"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Please check your server logs/Ollama console if the pull takes a long time.
|
||||||
|
Ensure the container has internet access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Save
|
Save Settings
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -210,6 +210,51 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
res.json({ success: true });
|
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 ---
|
// --- AI Routes ---
|
||||||
app.post("/api/ai/chat", async (req, res) => {
|
app.post("/api/ai/chat", async (req, res) => {
|
||||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||||
|
|||||||
Reference in New Issue
Block a user