Add `ensureOllamaAvailable` function to lib/ollama.ts for checking and waiting for Ollama server health with configurable timeout. Integrate the check in GET /api/ollama/models route with 3s timeout to prevent UI blocking, and in POST /api/ollama/pull route with 20s timeout to ensure availability before pulling models. Update ChatContainer to always render ModelDropdown for better UX when no model is selected.
230 lines
6.1 KiB
TypeScript
230 lines
6.1 KiB
TypeScript
import { Ollama } from "ollama";
|
||
import os from "node:os";
|
||
import { spawn } from "node:child_process";
|
||
|
||
export const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434";
|
||
|
||
export async function listModelsViaSdk() {
|
||
try {
|
||
const client = new Ollama({ host: OLLAMA_HOST });
|
||
const res = await client.list();
|
||
return res?.models ?? [];
|
||
} catch (e) {
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
export async function listModelsViaHttp() {
|
||
const url = `${OLLAMA_HOST}/api/tags`;
|
||
const res = await fetch(url, { cache: "no-store" });
|
||
if (!res.ok) throw new Error(`Ollama HTTP error ${res.status}`);
|
||
const data = await res.json();
|
||
return data?.models ?? [];
|
||
}
|
||
|
||
async function isOllamaHealthy(): Promise<boolean> {
|
||
try {
|
||
await listModelsViaHttp();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function waitUntilHealthy(timeoutMs: number, intervalMs = 800): Promise<boolean> {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
if (await isOllamaHealthy()) return true;
|
||
await new Promise((r) => setTimeout(r, intervalMs));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export async function ensureOllamaAvailable(options?: { timeoutMs?: number }): Promise<boolean> {
|
||
const timeoutMs = options?.timeoutMs ?? 20000;
|
||
if (await isOllamaHealthy()) return true;
|
||
|
||
// Windows: tentar iniciar via PowerShell script (WSL-first fallback Windows)
|
||
if (os.platform() === "win32") {
|
||
try {
|
||
const child = spawn(
|
||
"powershell",
|
||
[
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-File",
|
||
"scripts/ensure-ollama.ps1",
|
||
],
|
||
{ stdio: "ignore", detached: true }
|
||
);
|
||
child.unref();
|
||
} catch (e) {
|
||
// Ignorar, tentaremos apenas aguardar
|
||
}
|
||
}
|
||
|
||
// Aguardar saúde
|
||
return await waitUntilHealthy(timeoutMs);
|
||
}
|
||
|
||
export async function chatWithStream(
|
||
model: string,
|
||
messages: Array<{ role: string; content: string }>,
|
||
options?: Record<string, unknown>,
|
||
system?: string,
|
||
tools?: Array<any>
|
||
) {
|
||
const client = new Ollama({ host: OLLAMA_HOST });
|
||
|
||
const chatOptions: any = {
|
||
model,
|
||
messages: system
|
||
? [{ role: "system", content: system }, ...messages]
|
||
: messages,
|
||
stream: true,
|
||
options,
|
||
};
|
||
|
||
// Adicionar tools se fornecidos
|
||
if (tools && tools.length > 0) {
|
||
chatOptions.tools = tools;
|
||
}
|
||
|
||
return client.chat(chatOptions);
|
||
}
|
||
|
||
export async function pullModel(model: string) {
|
||
const client = new Ollama({ host: OLLAMA_HOST });
|
||
return client.pull({ model, stream: true });
|
||
}
|
||
|
||
export async function checkModelExists(modelName: string): Promise<boolean> {
|
||
try {
|
||
const models = await listModelsViaSdk();
|
||
return models.some((model) => model.name === modelName);
|
||
} catch (error) {
|
||
console.error("Erro ao verificar modelos:", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export async function ensureModelExists(
|
||
modelName: string,
|
||
description?: string
|
||
): Promise<void> {
|
||
const exists = await checkModelExists(modelName);
|
||
if (!exists) {
|
||
console.log(`📥 Baixando modelo ${modelName}...`);
|
||
if (description) {
|
||
console.log(`ℹ️ ${description}`);
|
||
}
|
||
|
||
const stream = await pullModel(modelName);
|
||
let downloadedBytes = 0;
|
||
let totalBytes = 0;
|
||
let progress = 0;
|
||
|
||
// Processar stream para mostrar progresso
|
||
for await (const chunk of stream) {
|
||
if (chunk.status === "downloading" && chunk.completed && chunk.total) {
|
||
downloadedBytes = chunk.completed;
|
||
totalBytes = chunk.total;
|
||
progress = Math.round((downloadedBytes / totalBytes) * 100);
|
||
|
||
// Criar barra de progresso visual
|
||
const barLength = 20;
|
||
const filledLength = Math.round((progress / 100) * barLength);
|
||
const bar =
|
||
"█".repeat(filledLength) + "░".repeat(barLength - filledLength);
|
||
|
||
// Mostrar progresso com informações
|
||
const downloadedMB = (downloadedBytes / 1024 / 1024).toFixed(1);
|
||
const totalMB = (totalBytes / 1024 / 1024).toFixed(1);
|
||
|
||
process.stdout.write(
|
||
`\r📥 ${modelName}: [${bar}] ${progress}% (${downloadedMB}MB/${totalMB}MB)`
|
||
);
|
||
} else if (chunk.status === "success") {
|
||
console.log(`\n✅ Modelo ${modelName} baixado com sucesso!`);
|
||
if (description) {
|
||
console.log(
|
||
`🎯 ${description.replace("será usado", "agora funciona")}`
|
||
);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
console.log(`✅ Modelo ${modelName} já está disponível`);
|
||
}
|
||
}
|
||
|
||
export async function deleteModel(modelName: string): Promise<void> {
|
||
const client = new Ollama({ host: OLLAMA_HOST });
|
||
await client.delete({ model: modelName });
|
||
}
|
||
|
||
export async function searchRemoteModels(query?: string): Promise<any[]> {
|
||
try {
|
||
// Ollama não tem API oficial para buscar modelos remotos
|
||
// Vamos usar uma lista conhecida de modelos populares
|
||
const popularModels = [
|
||
"llama3.2:1b",
|
||
"llama3.2:3b",
|
||
"llama3.2:11b",
|
||
"llama3.2:70b",
|
||
"llama3.1:8b",
|
||
"llama3.1:70b",
|
||
"llama3.1:405b",
|
||
"qwen2.5:1.5b",
|
||
"qwen2.5:3b",
|
||
"qwen2.5:7b",
|
||
"qwen2.5:14b",
|
||
"qwen2.5:32b",
|
||
"qwen2.5:72b",
|
||
"qwen2:0.5b",
|
||
"qwen2:1.5b",
|
||
"qwen2:3b",
|
||
"qwen2:7b",
|
||
"qwen2:14b",
|
||
"qwen2:32b",
|
||
"qwen2:72b",
|
||
"mistral:7b",
|
||
"mixtral:8x7b",
|
||
"mixtral:8x22b",
|
||
"phi3:mini",
|
||
"phi3:medium",
|
||
"phi3:large",
|
||
"gemma2:2b",
|
||
"gemma2:9b",
|
||
"gemma2:27b",
|
||
"codellama:7b",
|
||
"codellama:13b",
|
||
"codellama:34b",
|
||
"deepseek-coder:1.3b",
|
||
"deepseek-coder:6.7b",
|
||
"deepseek-coder:33b",
|
||
];
|
||
|
||
// Filtrar por query se fornecida
|
||
const filteredModels = query
|
||
? popularModels.filter((model) =>
|
||
model.toLowerCase().includes(query.toLowerCase())
|
||
)
|
||
: popularModels;
|
||
|
||
// Verificar quais já estão instalados
|
||
const installedModels = await listModelsViaSdk();
|
||
const installedNames = installedModels.map((m) => m.name);
|
||
|
||
return filteredModels.map((modelName) => ({
|
||
name: modelName,
|
||
installed: installedNames.includes(modelName),
|
||
remote: true,
|
||
}));
|
||
} catch (error) {
|
||
console.error("Erro ao buscar modelos remotos:", error);
|
||
return [];
|
||
}
|
||
}
|