From 1545020afead1c5ea8bbac6160842f772aada3fb Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 29 Oct 2025 17:22:21 -0300 Subject: [PATCH] feat(api): add pagination to messages endpoint and improve chat API robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pagination support to /api/conversations/[id]/messages with limit and before query params, returning hasMore and cursor for efficient message fetching - Enhance /api/ollama/chat by adding non-blocking Ollama availability check and conditionally disabling debug logging in production环境下Featured --- app/api/conversations/[id]/messages/route.ts | 13 +++- app/api/ollama/chat/route.ts | 72 ++++++++++--------- app/api/ollama/models/route.ts | 17 ++++- app/api/ollama/warmup/route.ts | 29 ++++++++ components/chat/ChatContainer.tsx | 4 ++ database/ollahub.db-shm | Bin 32768 -> 32768 bytes database/ollahub.db-wal | Bin 243112 -> 461472 bytes database/repositories/message.ts | 15 ++++ lib/ollama.ts | 2 +- 9 files changed, 114 insertions(+), 38 deletions(-) create mode 100644 app/api/ollama/warmup/route.ts diff --git a/app/api/conversations/[id]/messages/route.ts b/app/api/conversations/[id]/messages/route.ts index 7c8f79d..33e4521 100644 --- a/app/api/conversations/[id]/messages/route.ts +++ b/app/api/conversations/[id]/messages/route.ts @@ -10,12 +10,21 @@ export async function GET( ) { try { const { id } = await params; + const url = new URL(request.url); + const limitParam = url.searchParams.get("limit"); + const beforeParam = url.searchParams.get("before"); + + const limit = Math.min(Math.max(Number(limitParam) || 0, 1), 200) || 50; + const beforeTs = beforeParam ? Number(beforeParam) : undefined; + const db = getDatabase(); const messageRepo = new MessageRepository(db); - const messages = messageRepo.findByConversation(id); + const messages = messageRepo.findByConversationPaged(id, limit, beforeTs); + const hasMore = messages.length === limit; + const cursor = messages.length > 0 ? messages[0].timestamp : null; - return NextResponse.json(messages); + return NextResponse.json({ messages, hasMore, cursor }); } catch (error) { console.error("Erro ao buscar mensagens:", error); return NextResponse.json( diff --git a/app/api/ollama/chat/route.ts b/app/api/ollama/chat/route.ts index 1297ba3..83b0238 100644 --- a/app/api/ollama/chat/route.ts +++ b/app/api/ollama/chat/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; -import { chatWithStream } from "@/lib/ollama"; +import { chatWithStream, ensureOllamaAvailable } from "@/lib/ollama"; import { convertToOllamaMessages } from "@/lib/chat"; import { detectModelCapabilities } from "@/lib/services/model-capabilities"; import { MCPExecutor } from "@/lib/services/mcp-executor"; @@ -97,24 +97,30 @@ export async function POST(req: NextRequest) { }; try { + // Garantir disponibilidade com timeout curto (não bloquear) + ensureOllamaAvailable({ timeoutMs: 2000 }).catch(() => {}); const ollamaMessages = convertToOllamaMessages(messages); - console.log("🗣️ [CHAT] Messages ->", ollamaMessages); + if (process.env.NODE_ENV !== "production") { + console.log("🗣️ [CHAT] Messages ->", ollamaMessages); + } // Merge system prompt com tools prompt const finalSystemPrompt = toolsPrompt ? `${system || ""}\n\n${toolsPrompt}` : system; - if (toolsPrompt) console.log("🧩 [CHAT] Tools prompt injected"); + if (toolsPrompt && process.env.NODE_ENV !== "production") console.log("🧩 [CHAT] Tools prompt injected"); // Preparar parâmetros para chat const chatParams = capabilities.supportsNativeTools ? { tools } : {}; - if (capabilities.supportsNativeTools) { - console.log( - "🧰 [CHAT] Native tools enabled:", - tools?.map((t: any) => t.function?.name) - ); - } else { - console.log("🧰 [CHAT] Prompt-engineering tools mode"); + if (process.env.NODE_ENV !== "production") { + if (capabilities.supportsNativeTools) { + console.log( + "🧰 [CHAT] Native tools enabled:", + tools?.map((t: any) => t.function?.name) + ); + } else { + console.log("🧰 [CHAT] Prompt-engineering tools mode"); + } } const response = await chatWithStream( @@ -124,7 +130,7 @@ export async function POST(req: NextRequest) { finalSystemPrompt, capabilities.supportsNativeTools ? tools : undefined ); - console.log("🌊 [CHAT] Streaming started for model:", model); + if (process.env.NODE_ENV !== "production") console.log("🌊 [CHAT] Streaming started for model:", model); const normalizeParameters = (params: any) => { try { @@ -149,7 +155,7 @@ export async function POST(req: NextRequest) { }; for await (const chunk of response) { - console.log("📦 [CHAT stream chunk]", JSON.stringify(chunk)); + if (process.env.NODE_ENV !== "production") console.log("📦 [CHAT stream chunk]", JSON.stringify(chunk)); // Verificar se há tool calls (modelos com suporte nativo) if ( (chunk.message?.tool_calls && @@ -157,19 +163,16 @@ export async function POST(req: NextRequest) { (Array.isArray((chunk as any).tool_calls) && (chunk as any).tool_calls.length > 0) ) { - console.log("🔧 Model requested tool calls (native)"); + if (process.env.NODE_ENV !== "production") console.log("🔧 Model requested tool calls (native)"); const nativeCalls = chunk.message?.tool_calls || (chunk as any).tool_calls || []; for (const toolCall of nativeCalls) { const toolKey = toolCall.function.name; const toolInfo = mcpToolsMap.get(toolKey); - console.log( - "🧭 [CHAT] Native tool call:", - toolKey, - "->", - toolInfo - ); + if (process.env.NODE_ENV !== "production") { + console.log("🧭 [CHAT] Native tool call:", toolKey, "->", toolInfo); + } if (toolInfo) { // Executar tool @@ -185,11 +188,13 @@ export async function POST(req: NextRequest) { if (result.success) { toolExecuted = true; - console.log("✅ [CHAT] Tool executed successfully", { - mcpId: toolInfo.mcpId, - toolName: toolInfo.toolName, - executionTime: result.executionTime, - }); + if (process.env.NODE_ENV !== "production") { + console.log("✅ [CHAT] Tool executed successfully", { + mcpId: toolInfo.mcpId, + toolName: toolInfo.toolName, + executionTime: result.executionTime, + }); + } // Enviar indicador de execução de tool safeEnqueue( JSON.stringify({ @@ -206,10 +211,12 @@ export async function POST(req: NextRequest) { toolInfo.toolName, result.result ); - console.log( - "🧩 [CHAT] Injecting tool result into context (length)", - String(resultMessage).length - ); + if (process.env.NODE_ENV !== "production") { + console.log( + "🧩 [CHAT] Injecting tool result into context (length)", + String(resultMessage).length + ); + } // Fazer nova chamada ao modelo com o resultado const updatedMessages = [ @@ -264,9 +271,9 @@ export async function POST(req: NextRequest) { extractToolCallFromResponse(accumulatedResponse); if (extractedToolCall) { - console.log("🔧 Model requested tool call (prompt engineering)"); + if (process.env.NODE_ENV !== "production") console.log("🔧 Model requested tool call (prompt engineering)"); lastExtractedToolCall = extractedToolCall; - console.log("🧭 [CHAT] Extracted tool call:", extractedToolCall); + if (process.env.NODE_ENV !== "production") console.log("🧭 [CHAT] Extracted tool call:", extractedToolCall); // Encontrar MCP e tool let foundToolInfo: { mcpId: string; toolName: string } | null = @@ -339,9 +346,8 @@ export async function POST(req: NextRequest) { } } } else { - console.log( - "🕵️ [CHAT] No tool call extracted from accumulated response" - ); + if (process.env.NODE_ENV !== "production") + console.log("🕵️ [CHAT] No tool call extracted from accumulated response"); } } diff --git a/app/api/ollama/models/route.ts b/app/api/ollama/models/route.ts index 0e71db7..416be55 100644 --- a/app/api/ollama/models/route.ts +++ b/app/api/ollama/models/route.ts @@ -10,6 +10,10 @@ import { export const dynamic = "force-dynamic"; +// Cache leve em memória para reduzir chamadas repetidas em curto intervalo +let __modelsCache: { ts: number; payload: any } | null = null; +const CACHE_TTL_MS = 8000; // 8s + function normalizeModel(model: any): ModelInfo { const name: string = model?.model || model?.name || "unknown"; const sizeBytes: number = model?.size || 0; @@ -30,6 +34,11 @@ function normalizeModel(model: any): ModelInfo { export async function GET() { try { + // Tentar retorno via cache recente + if (__modelsCache && Date.now() - __modelsCache.ts < CACHE_TTL_MS) { + return NextResponse.json(__modelsCache.payload); + } + let models: any[] = []; // Tentar garantir disponibilidade rapidamente (não bloquear longamente a UI) await ensureOllamaAvailable({ timeoutMs: 3000 }).catch(() => {}); @@ -39,8 +48,12 @@ export async function GET() { models = await listModelsViaHttp(); } const normalized = models.map((m) => normalizeModel(m)); - return NextResponse.json({ offline: false, models: normalized }); + const payload = { offline: false, models: normalized }; + __modelsCache = { ts: Date.now(), payload }; + return NextResponse.json(payload); } catch (e) { - return NextResponse.json({ offline: true, models: mockModels }); + const payload = { offline: true, models: mockModels }; + __modelsCache = { ts: Date.now(), payload }; + return NextResponse.json(payload); } } diff --git a/app/api/ollama/warmup/route.ts b/app/api/ollama/warmup/route.ts new file mode 100644 index 0000000..1c5b43c --- /dev/null +++ b/app/api/ollama/warmup/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Ollama } from "ollama"; +import { OLLAMA_HOST } from "@/lib/ollama"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + const url = new URL(req.url); + const model = url.searchParams.get("model"); + if (!model) { + return NextResponse.json({ error: "model required" }, { status: 400 }); + } + + const client = new Ollama({ host: OLLAMA_HOST }); + await client.chat({ + model, + messages: [{ role: "system", content: "warm" }], + stream: false, + options: { keep_alive: "5m" }, + }); + + return NextResponse.json({ warmed: true }); + } catch (e) { + return NextResponse.json({ warmed: false }, { status: 200 }); + } +} + + diff --git a/components/chat/ChatContainer.tsx b/components/chat/ChatContainer.tsx index 1dc5672..0fc0211 100644 --- a/components/chat/ChatContainer.tsx +++ b/components/chat/ChatContainer.tsx @@ -46,6 +46,8 @@ export function ChatContainer({ useEffect(() => { if (ready && prefs.selectedModel && !selectedModel) { setSelectedModel(prefs.selectedModel); + // Warmup do modelo em background + fetch(`/api/ollama/warmup?model=${encodeURIComponent(prefs.selectedModel)}`).catch(() => {}); } }, [ready, prefs.selectedModel]); @@ -64,6 +66,8 @@ export function ChatContainer({ const handleModelChange = (modelName: string) => { setSelectedModel(modelName); update({ selectedModel: modelName }); // Salvar no Redis + // Disparar warmup em background + fetch(`/api/ollama/warmup?model=${encodeURIComponent(modelName)}`).catch(() => {}); }; // Carregar MCPs ativos do localStorage diff --git a/database/ollahub.db-shm b/database/ollahub.db-shm index 183738a1f1ab49451ae6b784342cd2ac1389dc47..14ef20c4063cf05d84e051cabc18da0ea6b531f3 100644 GIT binary patch delta 509 zcmb7-yDvj=6vw~6U)p&?+q#L7lYElz`JV4NM^(A1@-6F4CG=uIZh{&cadLcm_;Pu) zb9TPB`)H54ZlzR8a=GLG&Fiz>B$h?7PcRlG-@(L(|AMhu z(oQDc1EdofZ84%06{tZ2+7LuP;#j0Xn#&R#XheXTS8g`Ji7I%|fgZ##N9F@JD=0@b zn$U?}jA34|&@)$>F^CaNVI`AE-iIZ{ix5UJjn#}L?qY8RiVt1r!#EbQvFi;}P7B)6 djR8zxiOe)N7oiq)@M8#J%wR3MeYII;_y(-_a;5+P delta 289 zcmZo@U}|V!s+V}A%K!t63=9I+K#n*N-w88|J0zu8a_VAx%LkblqZf;Gt%LS0nM|sB znAsq6|04mYI1_{Q#KzB?Lzo)uCnqpHV6@sC$fU_Q`Ja=_<{+jWjEvS`{^mcfR~R=Z zFljMv{^bE>v9mI$G3YQD0u_8`VrFAd2Qq#$v2rkIGUx%BKbcrL8MGMm!OY1Y!r6f4 n{a~8>E(*ff{3iB?2t?>kDF=}8jfqv3!IZ%gtbX&2Y7G_ue&SrG diff --git a/database/ollahub.db-wal b/database/ollahub.db-wal index b175a5d98f2bb462b490ed5e7faac052875034b9..8fd86f81e4893c9d85850a44a1ebb543b8c1ee67 100644 GIT binary patch delta 15637 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