diff --git a/components/setup/setup-page.tsx b/components/setup/setup-page.tsx index 337f845..fa9f3db 100644 --- a/components/setup/setup-page.tsx +++ b/components/setup/setup-page.tsx @@ -1,10 +1,11 @@ "use client" -import { useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import Link from "next/link" import { AlertCircleIcon, CheckCircle2Icon, + ChevronDownIcon, CloudIcon, ExternalLinkIcon, EyeIcon, @@ -14,6 +15,7 @@ import { Loader2Icon, MessageSquareTextIcon, PlayIcon, + RefreshCwIcon, SaveIcon, SearchIcon, ServerIcon, @@ -40,7 +42,7 @@ import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { apiJsonRequest, testProviderCredentials } from "@/lib/api" +import { apiJson, apiJsonRequest, testProviderCredentials } from "@/lib/api" import type { UiProvider } from "@/lib/contracts" import { providerAuthMode, @@ -50,11 +52,19 @@ import { providerUsesStoredCredentials, sortProvidersByConfiguredCredentials, } from "@/lib/provider-credentials" +import { cn } from "@/lib/utils" type IntegrationTab = "all" | "connected" | "api" | "subscription" | "free" | "local" type IntegrationKind = "api" | "browser" | "free" | "local" | "subscription" +type OllamaStatus = { + baseUrl: string + modelCount: number | null + online: boolean + version: string | null +} + const TAB_ITEMS: Array<{ value: IntegrationTab label: string @@ -197,6 +207,39 @@ export function SetupPage() { const [activeTab, setActiveTab] = useState("all") const [query, setQuery] = useState("") + // Ollama local status (issue #180): consultado ao montar e sob demanda. + const [ollamaStatus, setOllamaStatus] = useState(null) + const [ollamaChecking, setOllamaChecking] = useState(false) + const [ollamaModels, setOllamaModels] = useState(null) + const [ollamaGuideOpen, setOllamaGuideOpen] = useState(false) + + const checkOllama = useCallback(async () => { + setOllamaChecking(true) + try { + const status = await apiJson("/ollama/api/status?force=1") + setOllamaStatus(status) + if (status.online) { + try { + const data = await apiJson<{ models: Array<{ id: string }> }>("/ollama/api/models") + setOllamaModels(data.models.map((m) => m.id)) + } catch { + setOllamaModels(null) + } + } else { + setOllamaModels(null) + } + } catch { + setOllamaStatus(null) + setOllamaModels(null) + } finally { + setOllamaChecking(false) + } + }, []) + + useEffect(() => { + void checkOllama() + }, [checkOllama]) + const sortedProviders = useMemo( () => sortProvidersByConfiguredCredentials(providers, credentials), [credentials, providers], @@ -653,6 +696,191 @@ export function SetupPage() { ) } + function renderOllamaCard(provider: UiProvider) { + const Icon = integrationKindIcon("local") + const checking = ollamaChecking || ollamaStatus === null + const online = ollamaStatus?.online === true + const offline = ollamaStatus?.online === false + + return ( + + +
+
+
+ + {providerInitials(provider.label)} + + + + +
+
+

+ {provider.label} + {checking ? ( + + + Verificando… + + ) : online ? ( + + + + + + Online + {ollamaStatus?.version ? ( + + (v{ollamaStatus.version}) + + ) : null} + + ) : offline ? ( + + + Offline + + ) : null} +

+

+ {online + ? `${ollamaStatus?.modelCount ?? 0} modelo${ollamaStatus?.modelCount === 1 ? "" : "s"} instalado${ollamaStatus?.modelCount === 1 ? "" : "s"} · ${ollamaStatus?.baseUrl}` + : `Servidor local em ${ollamaStatus?.baseUrl ?? "http://localhost:11434"} não detectado`} +

+
+
+
+ + + + Local + +
+
+ + {online && ollamaModels && ollamaModels.length > 0 ? ( +
+ {ollamaModels.slice(0, 12).map((modelId) => ( + + {modelId} + + ))} + {ollamaModels.length > 12 ? ( + + +{ollamaModels.length - 12} outros + + ) : null} +
+ ) : null} + + {!online && ollamaModels === null && !checking ? ( +
+ Nenhum servidor Ollama respondendo. Veja o guia de configuração + rápida abaixo para colocar o Ollama no ar. +
+ ) : null} + +
+ + {ollamaGuideOpen ? ( +
    +
  1. + 1 + + Instale o Ollama em{" "} + + ollama.com + + {" "} + (Windows, macOS ou Linux). + +
  2. +
  3. + 2 + + Execute ollama serve{" "} + no terminal — ou abra o app desktop do Ollama. + +
  4. +
  5. + 3 + + Baixe um modelo:{" "} + ollama pull llama3.2 + +
  6. +
  7. + 4 + + Endereço padrão{" "} + + {ollamaStatus?.baseUrl ?? "http://localhost:11434"} + + {ollamaStatus?.baseUrl && ollamaStatus.baseUrl !== "http://localhost:11434" ? ( + " (configurado via OLLAMA_BASE_URL)" + ) : ( + " — ajuste com a variável OLLAMA_BASE_URL se necessário." + )} + +
  8. +
  9. + 5 + + Depois de iniciar, clique em{" "} + Verificar novamente{" "} + acima para confirmar a conexão. + +
  10. +
+ ) : null} +
+
+
+ ) + } + function renderInformationalProviderCard(provider: UiProvider) { const kind = getIntegrationKind(provider) const Icon = integrationKindIcon(kind) @@ -698,7 +926,9 @@ export function SetupPage() { function renderProviderCard(provider: UiProvider) { return providerUsesStoredCredentials(provider) ? renderPaidProviderCard(provider) - : renderInformationalProviderCard(provider) + : provider.id === "ollama" + ? renderOllamaCard(provider) + : renderInformationalProviderCard(provider) } return ( diff --git a/server/providers/ollama.ts b/server/providers/ollama.ts index b5b558d..2901ae8 100644 --- a/server/providers/ollama.ts +++ b/server/providers/ollama.ts @@ -29,6 +29,58 @@ export async function fetchOllamaModels(): Promise { } } +export type OllamaStatus = { + baseUrl: string + modelCount: number | null + online: boolean + version: string | null +} + +const STATUS_CACHE_TTL_MS = 15_000 +let statusCache: { at: number; value: OllamaStatus } | null = null + +/** + * Verifica o servidor Ollama local (GET /api/tags + /api/version) e devolve um + * snapshot com online/baseUrl/modelCount/version. Resultado é cacheado por 15s + * para aguentar polling da UI sem martelar o servidor local. + */ +export async function fetchOllamaStatus(force = false): Promise { + const now = Date.now() + if (!force && statusCache && now - statusCache.at < STATUS_CACHE_TTL_MS) { + return statusCache.value + } + + let value: OllamaStatus + try { + const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = (await res.json()) as { models?: unknown[] } + + let version: string | null = null + try { + const versionRes = await fetch(`${OLLAMA_BASE_URL}/api/version`, { signal: AbortSignal.timeout(3000) }) + if (versionRes.ok) { + const versionJson = (await versionRes.json()) as { version?: string } + version = versionJson.version ?? null + } + } catch { + // /api/version é opcional em builds antigas; segue sem ela. + } + + value = { + baseUrl: OLLAMA_BASE_URL, + modelCount: Array.isArray(json.models) ? json.models.length : null, + online: true, + version, + } + } catch { + value = { baseUrl: OLLAMA_BASE_URL, modelCount: null, online: false, version: null } + } + + statusCache = { at: now, value } + return value +} + function toOpenAiMessages(messages: Array<{ role: string; content: unknown }>) { return messages.map((m) => ({ role: m.role, @@ -72,4 +124,12 @@ const app = createProviderApp({ }, }) +// GET /ollama/api/status — snapshot do servidor local (online, baseUrl, +// modelCount, version) com cache de 15s; `?force=1` ignora o cache. +app.get('/api/status', async (c) => { + const force = c.req.query('force') === '1' + const status = await fetchOllamaStatus(force) + return c.json(status) +}) + export default app.fetch diff --git a/server/tests/ollama.test.ts b/server/tests/ollama.test.ts new file mode 100644 index 0000000..9102c75 --- /dev/null +++ b/server/tests/ollama.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/db", () => ({ + prisma: { + providerCredential: { findMany: vi.fn().mockResolvedValue([]) }, + usageLog: { create: vi.fn().mockReturnValue({ catch: vi.fn() }) }, + }, +})); +vi.mock("../env", () => ({})); +vi.mock("@/lib/auth/server", () => ({ auth: { getSession: vi.fn().mockResolvedValue({ data: null }) } })); + +const ollamaFetch = (await import("../providers/ollama")).default; +const { fetchOllamaStatus } = await import("../providers/ollama"); + +function ollamaFetchMock(models: unknown[], version?: string) { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/version")) { + return new Response(JSON.stringify({ version: version ?? "0.12.6" }), { status: 200 }); + } + return new Response(JSON.stringify({ models }), { status: 200 }); + }); +} + +describe("Ollama provider", () => { + const originalFetch = globalThis.fetch; + const originalRequireAuth = process.env.REQUIRE_AUTH; + + afterEach(() => { + globalThis.fetch = originalFetch; + process.env.REQUIRE_AUTH = originalRequireAuth; + vi.unstubAllEnvs(); + }); + + it("GET /ollama/api/status reporta online com versao e contagem de modelos", async () => { + process.env.REQUIRE_AUTH = "false"; + globalThis.fetch = ollamaFetchMock([{ name: "llama3.2" }, { name: "qwen2.5-coder" }], "0.12.6"); + + const response = await ollamaFetch( + new Request("https://modelhub.test/ollama/api/status?force=1"), + ); + + await expect(response.json()).resolves.toEqual({ + baseUrl: "http://localhost:11434", + modelCount: 2, + online: true, + version: "0.12.6", + }); + }); + + it("GET /ollama/api/status reporta offline quando o servidor nao responde", async () => { + process.env.REQUIRE_AUTH = "false"; + globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + + const response = await ollamaFetch( + new Request("https://modelhub.test/ollama/api/status?force=1"), + ); + + await expect(response.json()).resolves.toEqual({ + baseUrl: "http://localhost:11434", + modelCount: null, + online: false, + version: null, + }); + }); + + it("cacheia o status por 15s para aguentar polling da UI", async () => { + globalThis.fetch = ollamaFetchMock([{ name: "llama3.2" }]); + + const fresh = await fetchOllamaStatus(true); + expect(fresh.online).toBe(true); + expect(fresh.modelCount).toBe(1); + + // Servidor cai logo apos: sem force, o snapshot em cache continua valido. + globalThis.fetch = vi.fn().mockRejectedValue(new Error("down")); + const cached = await fetchOllamaStatus(); + expect(cached.online).toBe(true); + + // force=1 ignora o cache e refaz a verificacao. + const forced = await fetchOllamaStatus(true); + expect(forced.online).toBe(false); + }); +});