diff --git a/app/api/ollama/pull/route.ts b/app/api/ollama/pull/route.ts index 9bf743e..984d9e3 100644 --- a/app/api/ollama/pull/route.ts +++ b/app/api/ollama/pull/route.ts @@ -1,5 +1,7 @@ import { NextRequest } from "next/server"; import { pullModel, ensureOllamaAvailable } from "@/lib/ollama"; +import { getCatalogModels } from "@/lib/catalog"; +import { addSavedRemoteModel } from "@/lib/user-remote-models"; export const dynamic = "force-dynamic"; @@ -26,6 +28,11 @@ export async function POST(req: NextRequest) { return; } + // Buscar metadados do catálogo para melhorar mensagens de erro + const baseName = String(model).split(":")[0]; + const catalog = await getCatalogModels(); + const catalogEntry = catalog.find((m) => m.name === baseName); + const response = await pullModel(model); for await (const chunk of response) { @@ -34,18 +41,59 @@ export async function POST(req: NextRequest) { ); } + // Persistir referência do modelo baixado para reutilização futura + try { + await addSavedRemoteModel({ name: String(model), installed: true }); + } catch {} + controller.enqueue( new TextEncoder().encode( JSON.stringify({ status: "success" }) + "\n", ), ); controller.close(); - } catch (error) { + } catch (error: any) { console.error("Pull streaming error:", error); + const message = typeof error?.message === "string" ? error.message : "Failed to pull model"; + const baseName = String(model).split(":")[0]; + let hint: string | undefined; + + // Recarregar entrada de catálogo para enriquecer erro + let catalogEntry: { url?: string; tags_count?: number } | undefined; + try { + const catalog = await getCatalogModels(); + catalogEntry = catalog.find((m) => m.name === baseName); + } catch {} + + // Heurísticas: manifest não encontrado → provavelmente falta tag válida + if (/manifest/i.test(message) || /does not exist/i.test(message)) { + hint = "Modelo/tag não encontrado no registro. Selecione uma tag válida ou verifique a página do modelo."; + } + + const payload: Record = { + error: "Failed to pull model", + detail: message, + model, + name: baseName, + }; + + // Anexar URL do catálogo quando disponível + if (catalogEntry?.url) { + payload.url = catalogEntry.url; + } + + // Se usuário não especificou tag (sem ":") e sabemos que há múltiplas tags + if (!String(model).includes(":") && typeof catalogEntry?.tags_count === "number") { + payload.tags_count = catalogEntry.tags_count; + if (!hint && catalogEntry.tags_count > 0) { + hint = "Este modelo pode não ter a tag 'latest'. Escolha uma tag específica."; + } + } + + if (hint) payload.hint = hint; + controller.enqueue( - new TextEncoder().encode( - JSON.stringify({ error: "Failed to pull model" }) + "\n", - ), + new TextEncoder().encode(JSON.stringify(payload) + "\n"), ); controller.close(); } diff --git a/app/api/ollama/search/route.ts b/app/api/ollama/search/route.ts index 302e1d3..c1f6574 100644 --- a/app/api/ollama/search/route.ts +++ b/app/api/ollama/search/route.ts @@ -1,17 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; -import { searchRemoteModels } from "@/lib/ollama"; +import { getSavedRemoteModels } from "@/lib/user-remote-models"; export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const query = searchParams.get("q") || undefined; - const remoteModels = await searchRemoteModels(query); + const saved = await getSavedRemoteModels(); + const q = (query || "").trim().toLowerCase(); + const filtered = q + ? saved.filter((m) => m.name.toLowerCase().includes(q)) + : saved; - return NextResponse.json({ - success: true, - models: remoteModels, - }); + const models = filtered.map((m) => ({ + name: m.name, + description: m.description, + url: m.url, + tags_count: m.tags_count, + installed: m.installed === true, + remote: true, + })); + + return NextResponse.json({ success: true, models }); } catch (error) { console.error("Error searching remote models:", error); return NextResponse.json( diff --git a/app/api/ollama/tags/route.ts b/app/api/ollama/tags/route.ts new file mode 100644 index 0000000..ab3de1b --- /dev/null +++ b/app/api/ollama/tags/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCatalogModels } from "@/lib/catalog"; + +// Cache simples em memória para evitar múltiplos scrapes por nome +const TAGS_CACHE = new Map(); +const CACHE_TTL_MS = 60 * 60 * 1000; // 1h + +async function fetchTagsFromPage(url: string): Promise { + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return []; + const html = await res.text(); + + // Heurística best-effort: procurar padrões de tag na página + // Exemplos possíveis: ":7b", ":8b", ":q4_K_S", etc. + // Evitar listas enormes ou falsas-positivas: limitar tamanho e caracteres + const tagRegex = /:\s*([A-Za-z0-9._-]{1,32})/g; + const found = new Set(); + let match: RegExpExecArray | null; + while ((match = tagRegex.exec(html)) !== null) { + const tag = match[1]; + if (tag && !/^(http|https)$/i.test(tag)) { + found.add(tag); + } + } + + // Retornar até 50 tags para não sobrecarregar UI + return Array.from(found).slice(0, 50); + } catch { + return []; + } +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const name = (searchParams.get("name") || "").trim(); + if (!name) { + return NextResponse.json( + { error: "Missing name" }, + { status: 400 }, + ); + } + + // Cache + const cached = TAGS_CACHE.get(name); + const now = Date.now(); + if (cached && cached.expiresAt > now) { + return NextResponse.json({ success: true, name, tags: cached.tags }); + } + + const catalog = await getCatalogModels(); + const entry = catalog.find((m) => m.name === name); + const url = entry?.url; + + // Sem URL conhecida → não conseguimos buscar tags no registro + if (!url) { + TAGS_CACHE.set(name, { tags: [], expiresAt: now + CACHE_TTL_MS }); + return NextResponse.json({ success: true, name, tags: [] }); + } + + const tags = await fetchTagsFromPage(url); + TAGS_CACHE.set(name, { tags, expiresAt: now + CACHE_TTL_MS }); + return NextResponse.json({ success: true, name, url, tags }); + } catch (error) { + console.error("Error fetching model tags:", error); + return NextResponse.json( + { error: "Failed to fetch tags" }, + { status: 500 }, + ); + } +} + + diff --git a/app/api/system/status/route.ts b/app/api/system/status/route.ts new file mode 100644 index 0000000..1251693 --- /dev/null +++ b/app/api/system/status/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server"; +import os from "os"; + +export async function GET() { + try { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const load = os.loadavg(); + const memUsage = process.memoryUsage(); + + // Base com os/fallback + const baseInfo: any = { + os: { + platform: os.platform(), + arch: os.arch(), + release: os.release(), + uptimeSec: os.uptime(), + }, + cpu: { + model: os.cpus()?.[0]?.model || "unknown", + cores: os.cpus()?.length || 0, + speed: os.cpus()?.[0]?.speed || 0, + }, + memory: { + total: totalMem, + free: freeMem, + used: usedMem, + modules: [] as Array<{ size: number; clockSpeed?: number; type?: string }>, + }, + gpus: [] as Array<{ index: number; name: string; vendor?: string; vramMB?: number; bus?: string }>, + metrics: { + loadAvg: { "1m": load[0] ?? 0, "5m": load[1] ?? 0, "15m": load[2] ?? 0 }, + process: { + rss: memUsage.rss, + heapUsed: memUsage.heapUsed, + heapTotal: memUsage.heapTotal, + external: memUsage.external, + }, + }, + }; + + // Tentar usar systeminformation, se disponível + try { + // Import dinâmico sem acionar o bundler (evita Module not found) + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const dynImport: (m: string) => Promise = new Function( + "m", + "return import(m)" + ) as any; + const si = await dynImport("systeminformation"); + + const [cpu, osInfo, memLayout, graphics, currentLoad] = await Promise.all([ + si.cpu(), + si.osInfo(), + si.memLayout(), + si.graphics(), + si.currentLoad(), + ]); + + baseInfo.os = { + platform: osInfo.platform || baseInfo.os.platform, + arch: os.arch(), + release: osInfo.release || baseInfo.os.release, + uptimeSec: baseInfo.os.uptimeSec, + distro: osInfo.distro, + }; + baseInfo.cpu = { + model: cpu.brand || baseInfo.cpu.model, + cores: cpu.cores || baseInfo.cpu.cores, + physicalCores: cpu.physicalCores, + speed: Number(cpu.speed) || baseInfo.cpu.speed, + }; + baseInfo.memory.modules = (memLayout || []).map((m: any) => ({ size: m.size, clockSpeed: m.clockSpeed, type: m.type })); + baseInfo.gpus = (graphics?.controllers || []).map((c: any, idx: number) => ({ index: idx, name: c.model || c.vendor || "GPU", vendor: c.vendor, vramMB: c.vram, bus: c.bus })); + baseInfo.metrics.loadAvg = { + "1m": currentLoad?.avgload ?? baseInfo.metrics.loadAvg["1m"], + "5m": baseInfo.metrics.loadAvg["5m"], + "15m": baseInfo.metrics.loadAvg["15m"], + }; + } catch { + // sem systeminformation, seguimos com fallback + } + + // Fallback adicional para detectar GPUs no Windows via PowerShell + try { + if ((!baseInfo.gpus || baseInfo.gpus.length === 0) && (baseInfo.os.platform === "win32" || os.platform() === "win32")) { + const { execFileSync } = await import("child_process"); + const out = execFileSync("powershell", [ + "-NoProfile", + "-Command", + "Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,PNPDeviceID | ConvertTo-Json -Depth 3" + ], { encoding: "utf8", timeout: 2000 }); + const parsed = JSON.parse(out); + const arr = Array.isArray(parsed) ? parsed : [parsed]; + baseInfo.gpus = arr.map((g: any, idx: number) => ({ index: idx, name: g.Name || "GPU", vendor: undefined, vramMB: g.AdapterRAM ? Math.round(g.AdapterRAM / 1024 / 1024) : undefined, bus: g.PNPDeviceID })); + } + } catch { + // ignore fallback errors + } + + return NextResponse.json({ success: true, info: baseInfo }); + } catch (e) { + return NextResponse.json({ success: false, error: String(e) }, { status: 500 }); + } +} + + diff --git a/components/ClientHome.tsx b/components/ClientHome.tsx index 510c44e..0339a16 100644 --- a/components/ClientHome.tsx +++ b/components/ClientHome.tsx @@ -8,7 +8,6 @@ import { SettingsButton, SettingsModal, } from "@/components/settings/SettingsModal"; -import { MCPManualInstallModal } from "@/components/mcp/MCPManualInstallModal"; import { Store } from "lucide-react"; interface ClientHomeProps { @@ -22,7 +21,7 @@ export function ClientHome({ offline, models }: ClientHomeProps) { const [currentConversationId, setCurrentConversationId] = useState< string | null >(null); - const [showManualInstall, setShowManualInstall] = useState(false); + const handleConversationCreated = () => { // Incrementar trigger para forçar atualização da sidebar @@ -48,13 +47,6 @@ export function ClientHome({ offline, models }: ClientHomeProps) { Ollama offline )} - setOpen(true)} /> @@ -77,10 +69,7 @@ export function ClientHome({ offline, models }: ClientHomeProps) { setOpen(false)} /> - setShowManualInstall(false)} - /> + ); } diff --git a/components/chat/ChatContainer.tsx b/components/chat/ChatContainer.tsx index 0fc0211..3c44149 100644 --- a/components/chat/ChatContainer.tsx +++ b/components/chat/ChatContainer.tsx @@ -4,8 +4,9 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { ModelDropdown } from "@/components/model/ModelDropdown"; import { ModelPullDialog } from "@/components/model/ModelPullDialog"; import { ChatMessage } from "@/components/chat/ChatMessage"; -import { SettingsModal } from "@/components/chat/SettingsModal"; +// Settings are controlled globally in header import { MCPSelector, ActiveMCPBadges } from "@/components/chat/MCPSelector"; +import { MCPManualInstallModal } from "@/components/mcp/MCPManualInstallModal"; import { Send, Square } from "lucide-react"; import type { ModelInfo } from "@/lib/models"; import type { Message } from "@/lib/chat"; @@ -37,6 +38,11 @@ export function ChatContainer({ const [modelToPull, setModelToPull] = useState(""); const [systemPrompt, setSystemPrompt] = useState(""); const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const [showManualInstall, setShowManualInstall] = useState(false); + const [showCommandMenu, setShowCommandMenu] = useState(false); + const [commandQuery, setCommandQuery] = useState(""); + const [highlightedCmd, setHighlightedCmd] = useState(0); // Estado para MCPs ativos const [activeMcps, setActiveMcps] = useState([]); @@ -141,6 +147,16 @@ export function ChatContainer({ scrollToBottom(); }, [messages, streamingContent]); + // Auto-expansão do textarea conforme o usuário digita + useEffect(() => { + if (inputRef.current) { + const el = inputRef.current; + el.style.height = "auto"; + const maxPx = 160; // ~10rem + el.style.height = `${Math.min(el.scrollHeight, maxPx)}px`; + } + }, [inputContent]); + const loadConversation = async (id: string) => { try { const response = await fetch(`/api/conversations/${id}`); @@ -331,15 +347,59 @@ export function ChatContainer({ } }; + // Parse de comando no submit + const tryHandleSlashCommand = (text: string): boolean => { + if (!text.startsWith("/")) return false; + const [cmd, ...rest] = text.trim().split(/\s+/); + if (cmd === "/clear") { + startNewConversation(); + setInputContent(""); + return true; + } + if (cmd === "/model") { + const newModel = rest.join(" "); + if (newModel) { + handleSelectModel(newModel); + setInputContent(""); + } + return true; + } + return false; + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (inputContent.trim() && !isStreaming) { + if (tryHandleSlashCommand(inputContent.trim())) return; handleSendMessage(inputContent.trim()); setInputContent(""); } }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (showCommandMenu) { + const commands = filteredCommands; + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedCmd((h) => (commands.length ? (h + 1) % commands.length : 0)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedCmd((h) => (commands.length ? (h - 1 + commands.length) % commands.length : 0)); + return; + } + if (e.key === "Escape") { + setShowCommandMenu(false); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + const cmd = commands[highlightedCmd] || commands[0]; + if (cmd) executeCommand(cmd); + return; + } + } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(e); @@ -357,6 +417,110 @@ export function ChatContainer({ update({ systemPrompt: prompt }); // Salvar no SQLite }; + // Regenerar última resposta do assistente + const handleRegenerate = async () => { + if (isStreaming || messages.length === 0 || !selectedModel) return; + const lastUserIndex = [...messages].map((m) => m.role).lastIndexOf("user"); + if (lastUserIndex === -1) return; + const baseMessages = messages.slice(0, lastUserIndex + 1); + setMessages(baseMessages); + setIsStreaming(true); + setStreamingContent(""); + try { + const options = buildOllamaOptions( + prefs.device, + prefs.numGpu, + prefs.gpuIndex + ); + abortRef.current = new AbortController(); + const response = await fetch("/api/ollama/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: selectedModel, + messages: baseMessages, + options, + system: systemPrompt || undefined, + activeMcps: activeMcps.length > 0 ? activeMcps : undefined, + }), + signal: abortRef.current.signal, + }); + if (!response.ok) throw new Error("Failed to regenerate"); + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + const decoder = new TextDecoder(); + let buffer = ""; + let assistantContent = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + if (data.clearPrevious) { + assistantContent = ""; + setStreamingContent(""); + } + if (data.token) { + assistantContent += data.token; + setStreamingContent(assistantContent); + } + if (data.done) { + const assistantMessage = createMessage("assistant", assistantContent); + setMessages((prev) => [...prev, assistantMessage]); + setStreamingContent(""); + if (conversationId) { + try { + await fetch(`/api/conversations/${conversationId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role: "assistant", content: assistantContent }), + }); + } catch {} + } + } + if (data.error) throw new Error(data.error); + } catch (e) { + console.error("Error parsing stream:", e); + } + } + } + } catch (error) { + console.error("Regenerate error:", error); + } finally { + setIsStreaming(false); + setStreamingContent(""); + abortRef.current = null; + } + }; + + // Comandos do chat + type ChatCommand = { id: string; label: string; hint?: string }; + const availableCommands: ChatCommand[] = [ + { id: "clear", label: "/clear", hint: "Limpar o histórico atual" }, + { id: "model", label: "/model ", hint: "Trocar de modelo ativo" }, + ]; + const filteredCommands = availableCommands.filter((c) => + c.label.toLowerCase().includes(commandQuery.toLowerCase()) + ); + const executeCommand = (cmd: ChatCommand) => { + if (cmd.id === "clear") { + startNewConversation(); + setInputContent(""); + setShowCommandMenu(false); + return; + } + if (cmd.id === "model") { + setInputContent("/model "); + setTimeout(() => inputRef.current?.focus(), 0); + return; + } + }; + const handleSelectModel = (modelId: string) => { const model = models.find((m) => m.id === modelId); if (model) { @@ -420,6 +584,11 @@ export function ChatContainer({ if (data.status === "success") { // Model pulled successfully, refresh models list window.location.reload(); + } else if (data.error) { + // Mostrar erro amigável quando backend identifica problema com tag/manifest + const hint = data.hint ? `\nHint: ${data.hint}` : ""; + const url = data.url ? `\nPágina do modelo: ${data.url}` : ""; + alert(`Falha ao baixar o modelo: ${data.detail || data.error}${hint}${url}`); } } catch (e) { console.error("Error parsing pull stream:", e); @@ -431,6 +600,12 @@ export function ChatContainer({ } }; + // Abrir diálogo de instalação (seleção/edição de tag) + const openPullDialog = (name: string) => { + setModelToPull(name); + setShowPullDialog(true); + }; + const selectedModelData = models.find((m) => m.id === selectedModel); return ( @@ -446,17 +621,18 @@ export function ChatContainer({ selectedModel={selectedModel} onSelectModel={handleSelectModel} onDeleteModel={handleDeleteModel} - onPullModel={handlePullModel} + onPullModel={openPullDialog} disabled={isStreaming} />
- +