feat(ollama): add availability check before model operations

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.
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-29 16:21:49 -03:00
1 parent f96d4ed696
commit 7b2386876a
12 files changed
+211 -20

No files matched your search

+3 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { listModelsViaSdk, listModelsViaHttp } from "@/lib/ollama"; import { listModelsViaSdk, listModelsViaHttp, ensureOllamaAvailable } from "@/lib/ollama";
import { import {
estimateCpuVram, estimateCpuVram,
inferDevice, inferDevice,
@@ -31,6 +31,8 @@ function normalizeModel(model: any): ModelInfo {
export async function GET() { export async function GET() {
try { try {
let models: any[] = []; let models: any[] = [];
// Tentar garantir disponibilidade rapidamente (não bloquear longamente a UI)
await ensureOllamaAvailable({ timeoutMs: 3000 }).catch(() => {});
try { try {
models = await listModelsViaSdk(); models = await listModelsViaSdk();
} catch { } catch {
+13 -1
View File
@@ -1,5 +1,5 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { pullModel } from "@/lib/ollama"; import { pullModel, ensureOllamaAvailable } from "@/lib/ollama";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,6 +14,18 @@ export async function POST(req: NextRequest) {
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
try { try {
// Garantir que o servidor esteja disponível antes do pull
const ok = await ensureOllamaAvailable({ timeoutMs: 20000 });
if (!ok) {
controller.enqueue(
new TextEncoder().encode(
JSON.stringify({ error: "Ollama indisponível" }) + "\n",
),
);
controller.close();
return;
}
const response = await pullModel(model); const response = await pullModel(model);
for await (const chunk of response) { for await (const chunk of response) {
+9 -13
View File
@@ -435,20 +435,16 @@ export function ChatContainer({
<div className="border-b border-[var(--border)] p-4 flex items-center justify-between gap-3 flex-shrink-0"> <div className="border-b border-[var(--border)] p-4 flex items-center justify-between gap-3 flex-shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
{selectedModelData {selectedModelData ? "Chat with" : "Select a model to start chatting"}
? "Chat with"
: "Select a model to start chatting"}
</h1> </h1>
{selectedModelData && ( <ModelDropdown
<ModelDropdown models={models}
models={models} selectedModel={selectedModel}
selectedModel={selectedModel} onSelectModel={handleSelectModel}
onSelectModel={handleSelectModel} onDeleteModel={handleDeleteModel}
onDeleteModel={handleDeleteModel} onPullModel={handlePullModel}
onPullModel={handlePullModel} disabled={isStreaming}
disabled={isStreaming} />
/>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SettingsModal <SettingsModal
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+47
View File
@@ -1,4 +1,6 @@
import { Ollama } from "ollama"; 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 const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434";
@@ -20,6 +22,51 @@ export async function listModelsViaHttp() {
return data?.models ?? []; 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( export async function chatWithStream(
model: string, model: string,
messages: Array<{ role: string; content: string }>, messages: Array<{ role: string; content: string }>,
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "tsx scripts/init-db.ts", "predev": "powershell -ExecutionPolicy Bypass -File scripts/ensure-ollama.ps1 && tsx scripts/init-db.ts",
"prebuild": "tsx scripts/init-db.ts", "prebuild": "tsx scripts/init-db.ts",
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
+1
View File
@@ -1,4 +1,5 @@
ignoredBuiltDependencies: ignoredBuiltDependencies:
- esbuild
- sharp - sharp
onlyBuiltDependencies: onlyBuiltDependencies:
+123
View File
@@ -0,0 +1,123 @@
# Ensures Ollama is running on localhost using WSL (Ubuntu) when available,
# with a Windows-native fallback. Designed to be idempotent.
param()
$ErrorActionPreference = 'SilentlyContinue'
function Write-Info($msg) { Write-Host "[ensure-ollama] $msg" }
function Write-Warn($msg) { Write-Warning "[ensure-ollama] $msg" }
function Get-EnvOrDefault([string]$name, [string]$default) {
$val = [Environment]::GetEnvironmentVariable($name)
if ([string]::IsNullOrWhiteSpace($val)) { return $default }
return $val
}
function Test-Port([int]$Port) {
try {
$res = Test-NetConnection -ComputerName localhost -Port $Port -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
return [bool]$res.TcpTestSucceeded
} catch { return $false }
}
function Wait-Healthy([int]$Port, [int]$TimeoutSeconds) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-Port -Port $Port) { return $true }
Start-Sleep -Seconds 1
}
return $false
}
function Has-WSL() {
return $null -ne (Get-Command wsl.exe -ErrorAction SilentlyContinue)
}
function WSL-HasDistro([string]$Distro) {
try {
$list = & wsl.exe -l -q 2>$null
return $list -split "`r?`n" | Where-Object { $_ -eq $Distro } | ForEach-Object { $true } | Select-Object -First 1
} catch { return $false }
}
function Invoke-WSL([string]$Distro, [string]$Cmd) {
& wsl.exe -d $Distro -- bash -lc $Cmd
}
function Ensure-Ollama-WSL([string]$Distro) {
Write-Info "Verificando/instalando Ollama na WSL ($Distro)"
$installCmd = 'command -v ollama >/dev/null 2>&1 || (curl -fsSL https://ollama.com/install.sh | sh)'
Invoke-WSL -Distro $Distro -Cmd $installCmd | Out-Null
}
function Start-Ollama-WSL([string]$Distro) {
Write-Info "Iniciando ollama serve em background (WSL:$Distro)"
$startCmd = 'mkdir -p "$HOME/.ollama"; nohup ollama serve > "$HOME/.ollama/ollama.log" 2>&1 & disown || true'
Invoke-WSL -Distro $Distro -Cmd $startCmd | Out-Null
}
function Has-Ollama-OnWindows() {
return $null -ne (Get-Command ollama -ErrorAction SilentlyContinue)
}
function Start-Ollama-Windows() {
try {
$ollama = (Get-Command ollama -ErrorAction Stop).Source
Write-Info "Iniciando ollama serve no Windows (hidden)"
Start-Process -FilePath $ollama -ArgumentList 'serve' -WindowStyle Hidden | Out-Null
return $true
} catch {
Write-Warn "Ollama não encontrado no PATH do Windows."
return $false
}
}
# Main
$port = [int](Get-EnvOrDefault 'OLLAMA_PORT' '11434')
$timeout = [int](Get-EnvOrDefault 'OLLAMA_START_TIMEOUT' '20')
$distro = Get-EnvOrDefault 'OLLAMA_WSL_DISTRO' 'Ubuntu'
if (Test-Port -Port $port) {
Write-Info "Porta $port já está respondendo. Nada a fazer."
exit 0
}
if (Has-WSL) {
if (-not (WSL-HasDistro -Distro $distro)) {
Write-Warn "Distro '$distro' não encontrada. Tentando WSL padrão..."
$distro = 'Ubuntu'
}
Write-Info "Tentando iniciar Ollama via WSL ($distro)"
Ensure-Ollama-WSL -Distro $distro
Start-Ollama-WSL -Distro $distro
if (Wait-Healthy -Port $port -TimeoutSeconds $timeout) {
Write-Info "Ollama disponível em localhost:$port (via WSL)."
exit 0
} else {
Write-Warn "Timeout aguardando Ollama (WSL). Prosseguindo com fallback Windows."
}
}
# Fallback Windows
if (-not (Has-Ollama-OnWindows)) {
Write-Warn "Ollama não encontrado no Windows e WSL não atendeu no tempo."
Write-Warn "Instale o Ollama no Windows ou habilite WSL/Ubuntu."
exit 0
}
if (Start-Ollama-Windows) {
if (Wait-Healthy -Port $port -TimeoutSeconds $timeout) {
Write-Info "Ollama disponível em localhost:$port (Windows)."
exit 0
} else {
Write-Warn "Timeout aguardando Ollama (Windows)."
exit 0
}
}
exit 0
+14 -4
View File
@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -19,7 +23,9 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
} }
}, },
"include": [ "include": [
@@ -28,7 +34,11 @@
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts",
".next\\dev/types/**/*.ts",
".next\\dev/types/**/*.ts"
], ],
"exclude": ["node_modules"] "exclude": [
"node_modules"
]
} }