chore: remove deprecated files and configurations
- Deleted .gitignore, biome.json, next.config.ts, package.json, pnpm-lock.yaml, pnpm-workspace.yaml, postcss.config.mjs, README.md, and various API, component, and utility files to clean up the codebase. - Removed unused database files and migration scripts to streamline the project structure. - Eliminated obsolete hooks and services to enhance maintainability and reduce clutter.
This commit is contained in:
1 parent
a8bb7cc22d
commit
ad9d5d32d2
119 files changed
-17633
No files matched your search
-52
@@ -1,52 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# database
|
||||
database/*.db
|
||||
database/extensions/*.so
|
||||
database/extensions/*.dll
|
||||
database/extensions/*.dylib
|
||||
|
||||
# mcp servers installation directory
|
||||
/mcp-servers/
|
||||
|
||||
/.cursor
|
||||
@@ -1,36 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -1,77 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/database/client";
|
||||
import { MessageRepository } from "@/database/repositories/message";
|
||||
import { ConversationRepository } from "@/database/repositories/conversation";
|
||||
import { addMessageEmbedding } from "@/lib/vector-search";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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.findByConversationPaged(id, limit, beforeTs);
|
||||
const hasMore = messages.length === limit;
|
||||
const cursor = messages.length > 0 ? messages[0].timestamp : null;
|
||||
|
||||
return NextResponse.json({ messages, hasMore, cursor });
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar mensagens:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { role, content } = await request.json();
|
||||
|
||||
if (!role || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: "Role e content são obrigatórios" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const messageRepo = new MessageRepository(db);
|
||||
const conversationRepo = new ConversationRepository(db);
|
||||
|
||||
// Criar mensagem
|
||||
const messageId = messageRepo.create(id, role, content);
|
||||
|
||||
// Atualizar timestamp da conversa
|
||||
conversationRepo.updateTimestamp(id);
|
||||
|
||||
// Gerar embedding em background (apenas para mensagens do usuário e assistente)
|
||||
if (role === "user" || role === "assistant") {
|
||||
addMessageEmbedding(messageId, content).catch((error) => {
|
||||
console.error("Erro ao gerar embedding:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: messageId });
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar mensagem:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/database/client";
|
||||
import { ConversationRepository } from "@/database/repositories/conversation";
|
||||
import { MessageRepository } from "@/database/repositories/message";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
const conversationRepo = new ConversationRepository(db);
|
||||
const messageRepo = new MessageRepository(db);
|
||||
|
||||
const conversation = conversationRepo.findById(id);
|
||||
if (!conversation) {
|
||||
return NextResponse.json(
|
||||
{ error: "Conversa não encontrada" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const messages = messageRepo.findByConversation(id);
|
||||
|
||||
return NextResponse.json({
|
||||
conversation,
|
||||
messages,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar conversa:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
const conversationRepo = new ConversationRepository(db);
|
||||
|
||||
conversationRepo.delete(id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar conversa:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/database/client";
|
||||
import { ConversationRepository } from "@/database/repositories/conversation";
|
||||
import { MessageRepository } from "@/database/repositories/message";
|
||||
import { generateChatTitle } from "@/lib/embeddings";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const conversationRepo = new ConversationRepository(db);
|
||||
|
||||
const conversations = conversationRepo.findAll();
|
||||
|
||||
return NextResponse.json(conversations);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar conversas:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { model, firstMessage } = await request.json();
|
||||
|
||||
if (!model) {
|
||||
return NextResponse.json(
|
||||
{ error: "Modelo é obrigatório" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const conversationRepo = new ConversationRepository(db);
|
||||
|
||||
// Gerar título se primeira mensagem fornecida
|
||||
let title = "Nova conversa";
|
||||
if (firstMessage) {
|
||||
title = await generateChatTitle(firstMessage);
|
||||
}
|
||||
|
||||
const conversationId = conversationRepo.create(model, title);
|
||||
|
||||
return NextResponse.json({ id: conversationId, title });
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar conversa:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* API endpoint para listar MCPs disponíveis para uso no chat
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
import { MCPExecutor } from "@/lib/services/mcp-executor";
|
||||
import type { MCPTool } from "@/lib/types/mcp-chat";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Buscar todos os MCPs instalados com status "ready"
|
||||
const allMcps = MCPRepository.getAllInstalled();
|
||||
|
||||
const availableMcps = allMcps
|
||||
.filter((mcp: any) => !mcp.status || mcp.status === "ready")
|
||||
.map((mcp: any) => {
|
||||
const tools = MCPExecutor.getAvailableTools(mcp.id);
|
||||
|
||||
// Converter tools para formato OpenAI function calling
|
||||
const toolsFormatted = tools.map((tool: MCPTool) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: `${mcp.id}__${tool.name}`, // Prefixar com mcpId para evitar conflitos
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
mcpId: mcp.id,
|
||||
name: mcp.name,
|
||||
owner: mcp.owner,
|
||||
repo: mcp.repo,
|
||||
toolCount: tools.length,
|
||||
tools: toolsFormatted,
|
||||
rawTools: tools, // Tools originais para referência
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mcps: availableMcps,
|
||||
count: availableMcps.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error listing available MCPs:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || "Failed to list available MCPs",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* API endpoint para executar um tool de MCP
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { MCPExecutor } from "@/lib/services/mcp-executor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { mcpId, toolName, parameters } = body;
|
||||
|
||||
// Validar parâmetros
|
||||
if (!mcpId || !toolName) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Missing mcpId or toolName",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`📞 API call to execute tool: ${mcpId}.${toolName}`);
|
||||
|
||||
// Obter tools disponíveis
|
||||
const availableTools = MCPExecutor.getAvailableTools(mcpId);
|
||||
const tool = availableTools.find((t) => t.name === toolName);
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Tool ${toolName} not found in MCP ${mcpId}`,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validar parâmetros contra schema
|
||||
const validation = MCPExecutor.validateParameters(parameters || {}, tool);
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Invalid parameters: ${validation.error}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Executar tool
|
||||
const result = await MCPExecutor.executeMCPTool(
|
||||
mcpId,
|
||||
toolName,
|
||||
parameters || {}
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
executionTime: result.executionTime,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mcpId,
|
||||
toolName,
|
||||
result: result.result,
|
||||
executionTime: result.executionTime,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error executing MCP tool:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || "Failed to execute MCP tool",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
import { MCPInstallerService } from "@/lib/services/mcp-installer";
|
||||
import { MCPValidatorService } from "@/lib/services/mcp-validator";
|
||||
import {
|
||||
MCPInstallationStatus,
|
||||
type InstallationConfig,
|
||||
type InstallationProgress,
|
||||
type PackageRegistry,
|
||||
} from "@/lib/types/mcp-installer";
|
||||
|
||||
/**
|
||||
* POST /api/mcp/install-manual
|
||||
* Instala um MCP a partir de uma configuração manual (formato Claude Desktop)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { config, mcpId, enableLogs } = body;
|
||||
|
||||
// Validar estrutura básica
|
||||
if (!config || typeof config !== "object") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid configuration format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Extrair primeiro servidor da configuração (se for formato mcpServers)
|
||||
let serverConfig: any;
|
||||
let serverId: string;
|
||||
|
||||
if (config.mcpServers) {
|
||||
// Formato: { "mcpServers": { "server-name": { ... } } }
|
||||
const serverNames = Object.keys(config.mcpServers);
|
||||
if (serverNames.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No MCP server found in configuration" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
serverId = mcpId || serverNames[0];
|
||||
serverConfig = config.mcpServers[serverNames[0]];
|
||||
} else {
|
||||
// Formato direto: { "command": "...", "args": [...] }
|
||||
serverId = mcpId || "manual-mcp";
|
||||
serverConfig = config;
|
||||
}
|
||||
|
||||
// Validar campos obrigatórios
|
||||
if (!serverConfig.command) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Missing required field: command" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🚀 Starting manual installation for: ${serverId}`);
|
||||
console.log(
|
||||
`Command: ${serverConfig.command} ${(serverConfig.args || []).join(" ")}`
|
||||
);
|
||||
|
||||
// Detectar tipo de pacote baseado no comando
|
||||
const { packageRegistry, packageName, repositoryUrl } =
|
||||
detectPackageInfo(serverConfig);
|
||||
|
||||
console.log(`Detected package type: ${packageRegistry} (${packageName})`);
|
||||
|
||||
// Verificar se já está instalado
|
||||
if (MCPRepository.isInstalled(serverId)) {
|
||||
const existing = MCPRepository.getInstalledMCP(serverId);
|
||||
|
||||
if (existing.status === "ready") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `MCP ${serverId} is already installed`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Re-installing MCP ${serverId} (current status: ${existing.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Preparar configuração de instalação
|
||||
const installConfig: InstallationConfig = {
|
||||
mcpId: serverId,
|
||||
packageName,
|
||||
packageRegistry,
|
||||
repositoryUrl,
|
||||
config: serverConfig,
|
||||
enableLogs: enableLogs === true,
|
||||
};
|
||||
|
||||
// Salvar no banco como PENDING
|
||||
if (!MCPRepository.isInstalled(serverId)) {
|
||||
MCPRepository.saveInstallation(serverId, serverConfig, []);
|
||||
}
|
||||
MCPRepository.updateInstallationStatus(
|
||||
serverId,
|
||||
"pending",
|
||||
"Starting manual installation..."
|
||||
);
|
||||
|
||||
// Iniciar instalação em background
|
||||
installInBackground(installConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Manual installation started",
|
||||
mcpId: serverId,
|
||||
status: "pending",
|
||||
detectedType: packageRegistry,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error starting manual installation:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || "Failed to start manual installation",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta informações do pacote baseado no comando
|
||||
*/
|
||||
function detectPackageInfo(serverConfig: any): {
|
||||
packageRegistry: PackageRegistry;
|
||||
packageName: string;
|
||||
repositoryUrl?: string;
|
||||
} {
|
||||
const command = serverConfig.command.toLowerCase();
|
||||
const args = serverConfig.args || [];
|
||||
const firstArg = args[0] || "";
|
||||
|
||||
// NPM/NPX
|
||||
if (command === "npx" || command === "npm") {
|
||||
// npx @mcpcentral/mcp-time
|
||||
// npx -y @modelcontextprotocol/server-filesystem
|
||||
// npx -y github:user/repo
|
||||
const packageArg = args.find(
|
||||
(arg: string) =>
|
||||
arg.startsWith("@") || arg.startsWith("github:") || !arg.startsWith("-")
|
||||
);
|
||||
let packageName = packageArg || firstArg || "unknown-npm-package";
|
||||
packageName = packageName.replace(/^-y\s*/, "").trim();
|
||||
|
||||
// Converter github:user/repo para git+https://github.com/user/repo.git
|
||||
// Isso evita problemas com SSH e funciona melhor com npm install
|
||||
if (packageName.startsWith("github:")) {
|
||||
const repoPath = packageName.replace("github:", "");
|
||||
packageName = `git+https://github.com/${repoPath}.git`;
|
||||
console.log(`Converted github: format to: ${packageName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
packageRegistry: "npm",
|
||||
packageName,
|
||||
};
|
||||
}
|
||||
|
||||
// Node
|
||||
if (command === "node" || command.endsWith("node")) {
|
||||
// node dist/index.js -> assume que é local ou já instalado
|
||||
return {
|
||||
packageRegistry: "npm",
|
||||
packageName: "local-node-package",
|
||||
};
|
||||
}
|
||||
|
||||
// Python
|
||||
if (
|
||||
command === "python" ||
|
||||
command === "python3" ||
|
||||
command.includes("python")
|
||||
) {
|
||||
// python -m mcp_server_git
|
||||
// python /path/to/server.py
|
||||
const moduleIndex = args.indexOf("-m");
|
||||
if (moduleIndex !== -1 && args[moduleIndex + 1]) {
|
||||
return {
|
||||
packageRegistry: "pypi",
|
||||
packageName: args[moduleIndex + 1],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
packageRegistry: "pypi",
|
||||
packageName: "local-python-package",
|
||||
};
|
||||
}
|
||||
|
||||
// UV (Python package manager)
|
||||
if (command === "uv" || command === "uvx") {
|
||||
// uvx mcp-server-git
|
||||
const packageName = args[0] || "unknown-uv-package";
|
||||
return {
|
||||
packageRegistry: "pypi",
|
||||
packageName,
|
||||
};
|
||||
}
|
||||
|
||||
// Cargo/Rust
|
||||
if (command === "cargo") {
|
||||
// cargo run --bin mcp-server
|
||||
const binIndex = args.indexOf("--bin");
|
||||
if (binIndex !== -1 && args[binIndex + 1]) {
|
||||
return {
|
||||
packageRegistry: "cargo",
|
||||
packageName: args[binIndex + 1],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
packageRegistry: "cargo",
|
||||
packageName: "local-rust-package",
|
||||
};
|
||||
}
|
||||
|
||||
// Docker
|
||||
if (command === "docker") {
|
||||
return {
|
||||
packageRegistry: "other",
|
||||
packageName: "docker-container",
|
||||
};
|
||||
}
|
||||
|
||||
// Caminho absoluto ou relativo (executável local)
|
||||
if (command.startsWith("/") || command.startsWith("./")) {
|
||||
return {
|
||||
packageRegistry: "other",
|
||||
packageName: "local-executable",
|
||||
};
|
||||
}
|
||||
|
||||
// Padrão: tratar como "other"
|
||||
return {
|
||||
packageRegistry: "other",
|
||||
packageName: command,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa instalação em background (não bloqueia resposta HTTP)
|
||||
*/
|
||||
async function installInBackground(config: InstallationConfig): Promise<void> {
|
||||
const { mcpId, enableLogs } = config;
|
||||
|
||||
try {
|
||||
console.log(`📦 Installing ${mcpId} in background (manual mode)...`);
|
||||
|
||||
// Callback de progresso
|
||||
const onProgress = (progress: InstallationProgress) => {
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
progress.status,
|
||||
progress.message
|
||||
);
|
||||
|
||||
if (enableLogs && progress.logs && progress.logs.length > 0) {
|
||||
MCPRepository.appendLogs(mcpId, progress.logs);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${mcpId}] ${progress.status}: ${progress.message} (${progress.percentage}%)`
|
||||
);
|
||||
};
|
||||
|
||||
// Instalar
|
||||
onProgress({
|
||||
status: MCPInstallationStatus.DOWNLOADING,
|
||||
message: "Preparing manual installation...",
|
||||
percentage: 5,
|
||||
});
|
||||
|
||||
const environment = await MCPInstallerService.install(config, onProgress);
|
||||
|
||||
// Salvar ambiente
|
||||
MCPRepository.saveEnvironment(mcpId, environment);
|
||||
|
||||
onProgress({
|
||||
status: MCPInstallationStatus.TESTING,
|
||||
message: "Validating MCP server...",
|
||||
percentage: 85,
|
||||
});
|
||||
|
||||
// Validar servidor MCP
|
||||
const validation = await MCPValidatorService.validateMCPServer(
|
||||
environment,
|
||||
config.config
|
||||
);
|
||||
|
||||
// Salvar resultado de validação
|
||||
MCPRepository.saveValidationResult(mcpId, validation);
|
||||
|
||||
if (validation.success) {
|
||||
// Salvar ferramentas descobertas
|
||||
MCPRepository.saveTools(mcpId, validation.tools);
|
||||
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
"ready",
|
||||
`Installed successfully with ${validation.tools.length} tools`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`✅ MCP ${mcpId} (manual) installed and validated successfully`
|
||||
);
|
||||
} else {
|
||||
// Validação falhou, mas instalação pode estar OK
|
||||
console.warn(
|
||||
`⚠️ MCP ${mcpId} (manual) installed but validation failed: ${validation.error}`
|
||||
);
|
||||
|
||||
// Tentar validação simples como fallback
|
||||
const simpleValidation = await MCPValidatorService.simpleValidation(
|
||||
environment,
|
||||
config.config
|
||||
);
|
||||
|
||||
if (simpleValidation.success) {
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
"ready",
|
||||
"Installed (validation partial)"
|
||||
);
|
||||
console.log(
|
||||
`✅ MCP ${mcpId} (manual) installed (simple validation passed)`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
validation.error || simpleValidation.error || "Validation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Manual installation failed for ${mcpId}:`, error);
|
||||
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
"failed",
|
||||
error.message || "Manual installation failed"
|
||||
);
|
||||
|
||||
if (enableLogs) {
|
||||
MCPRepository.appendLogs(mcpId, [
|
||||
`ERROR: ${error.message}`,
|
||||
error.stack || "",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const mcpId = searchParams.get("mcpId");
|
||||
|
||||
if (!mcpId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "MCP ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const installation = MCPRepository.getInstalledMCP(mcpId);
|
||||
|
||||
if (!installation) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "MCP not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parsear logs se existirem
|
||||
let logs: string[] = [];
|
||||
if (installation.install_logs) {
|
||||
try {
|
||||
logs = JSON.parse(installation.install_logs);
|
||||
} catch {
|
||||
logs = [installation.install_logs];
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mcpId: installation.id,
|
||||
status: installation.status || "unknown",
|
||||
message: installation.status_message || "",
|
||||
logs,
|
||||
environment: installation.environment_path,
|
||||
executableCommand: installation.executable_command,
|
||||
installedAt: installation.installed_at,
|
||||
updatedAt: installation.updated_at,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting installation status:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || "Failed to get installation status",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
import { MCPInstallerService } from "@/lib/services/mcp-installer";
|
||||
import { MCPValidatorService } from "@/lib/services/mcp-validator";
|
||||
import { DeepNLPService } from "@/lib/services/deepnlp";
|
||||
import type {
|
||||
InstallationConfig,
|
||||
InstallationProgress,
|
||||
} from "@/lib/types/mcp-installer";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { mcpId, customConfig, enableLogs } = await request.json();
|
||||
|
||||
if (!mcpId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "MCP ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🚀 Starting installation for MCP: ${mcpId}`);
|
||||
|
||||
// Verificar se já está instalado
|
||||
if (MCPRepository.isInstalled(mcpId)) {
|
||||
const existing = MCPRepository.getInstalledMCP(mcpId);
|
||||
|
||||
// Se já está instalado e pronto, retornar erro
|
||||
if (existing.status === "ready") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `MCP ${mcpId} is already installed`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Se está em processo de instalação ou falhou, permitir reinstalação
|
||||
console.log(
|
||||
`Re-installing MCP ${mcpId} (current status: ${existing.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Obter configuração
|
||||
let serverConfig: any;
|
||||
let packageName: string;
|
||||
let packageRegistry: string | null = null;
|
||||
let repositoryUrl: string | undefined;
|
||||
|
||||
if (customConfig) {
|
||||
// Usar configuração personalizada
|
||||
console.log(`Using custom configuration for ${mcpId}`);
|
||||
serverConfig = customConfig;
|
||||
packageName = customConfig.packageName || mcpId;
|
||||
packageRegistry = customConfig.packageRegistry || null;
|
||||
repositoryUrl = customConfig.repositoryUrl;
|
||||
} else {
|
||||
// Buscar configuração da API
|
||||
console.log(`Fetching configuration for ${mcpId}`);
|
||||
|
||||
try {
|
||||
// Tentar DeepNLP primeiro
|
||||
const deepnlpConfig = await DeepNLPService.getServerConfig(mcpId);
|
||||
if (deepnlpConfig.items && deepnlpConfig.items.length > 0) {
|
||||
serverConfig = deepnlpConfig.items[0];
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`DeepNLP config not found, trying cache...`);
|
||||
}
|
||||
|
||||
if (!serverConfig) {
|
||||
throw new Error(`No configuration found for MCP ${mcpId}`);
|
||||
}
|
||||
|
||||
// Extrair informações do pacote
|
||||
packageName = serverConfig.packageName || serverConfig.name || mcpId;
|
||||
}
|
||||
|
||||
// Preparar configuração de instalação
|
||||
const installConfig: InstallationConfig = {
|
||||
mcpId,
|
||||
packageName,
|
||||
packageRegistry: packageRegistry as any,
|
||||
repositoryUrl,
|
||||
config: serverConfig,
|
||||
enableLogs: enableLogs === true,
|
||||
};
|
||||
|
||||
// Salvar como PENDING no banco
|
||||
if (!MCPRepository.isInstalled(mcpId)) {
|
||||
MCPRepository.saveInstallation(mcpId, serverConfig, []);
|
||||
}
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
"pending",
|
||||
"Starting installation..."
|
||||
);
|
||||
|
||||
// Iniciar instalação em background
|
||||
installInBackground(installConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Installation started",
|
||||
mcpId,
|
||||
status: "pending",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error starting installation:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || "Failed to start installation",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa instalação em background (não bloqueia resposta HTTP)
|
||||
*/
|
||||
async function installInBackground(config: InstallationConfig): Promise<void> {
|
||||
const { mcpId, enableLogs } = config;
|
||||
|
||||
try {
|
||||
console.log(`📦 Installing ${mcpId} in background...`);
|
||||
|
||||
// Callback de progresso
|
||||
const onProgress = (progress: InstallationProgress) => {
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
progress.status,
|
||||
progress.message
|
||||
);
|
||||
|
||||
if (enableLogs && progress.logs && progress.logs.length > 0) {
|
||||
MCPRepository.appendLogs(mcpId, progress.logs);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${mcpId}] ${progress.status}: ${progress.message} (${progress.percentage}%)`
|
||||
);
|
||||
};
|
||||
|
||||
// Instalar
|
||||
onProgress({
|
||||
status: "downloading",
|
||||
message: "Preparing installation...",
|
||||
percentage: 5,
|
||||
});
|
||||
|
||||
const environment = await MCPInstallerService.install(config, onProgress);
|
||||
|
||||
// Salvar ambiente
|
||||
MCPRepository.saveEnvironment(mcpId, environment);
|
||||
|
||||
onProgress({
|
||||
status: "testing",
|
||||
message: "Validating MCP server...",
|
||||
percentage: 85,
|
||||
});
|
||||
|
||||
// Validar servidor MCP
|
||||
const validation = await MCPValidatorService.validateMCPServer(
|
||||
environment,
|
||||
config.config
|
||||
);
|
||||
|
||||
// Salvar resultado de validação
|
||||
MCPRepository.saveValidationResult(mcpId, validation);
|
||||
|
||||
if (validation.success) {
|
||||
// Salvar ferramentas descobertas
|
||||
MCPRepository.saveTools(mcpId, validation.tools);
|
||||
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
"ready",
|
||||
`Installed successfully with ${validation.tools.length} tools`
|
||||
);
|
||||
|
||||
console.log(`✅ MCP ${mcpId} installed and validated successfully`);
|
||||
} else {
|
||||
// Validação falhou, mas instalação pode estar OK
|
||||
console.warn(
|
||||
`⚠️ MCP ${mcpId} installed but validation failed: ${validation.error}`
|
||||
);
|
||||
|
||||
// Tentar validação simples como fallback
|
||||
const simpleValidation = await MCPValidatorService.simpleValidation(
|
||||
environment,
|
||||
config.config
|
||||
);
|
||||
|
||||
if (simpleValidation.success) {
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
"ready",
|
||||
"Installed (validation partial)"
|
||||
);
|
||||
console.log(`✅ MCP ${mcpId} installed (simple validation passed)`);
|
||||
} else {
|
||||
throw new Error(
|
||||
validation.error || simpleValidation.error || "Validation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Installation failed for ${mcpId}:`, error);
|
||||
|
||||
MCPRepository.updateInstallationStatus(
|
||||
mcpId,
|
||||
"failed",
|
||||
error.message || "Installation failed"
|
||||
);
|
||||
|
||||
if (enableLogs) {
|
||||
MCPRepository.appendLogs(mcpId, [
|
||||
`ERROR: ${error.message}`,
|
||||
error.stack || "",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
// Marketplace cache removido
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
console.log("Fetching installed MCPs...");
|
||||
|
||||
// Buscar todos os MCPs instalados com detalhes
|
||||
const installedMcps = MCPRepository.listInstalledWithDetails();
|
||||
|
||||
// Retornar dados de instalados sem enriquecimento de marketplace
|
||||
const enrichedMcps = installedMcps.map((mcp) => ({
|
||||
...mcp,
|
||||
author: mcp.owner !== "unknown" ? mcp.owner : "Unknown",
|
||||
description: `MCP instalado: ${mcp.name}`,
|
||||
repository: "",
|
||||
homepage: "",
|
||||
rating: 0,
|
||||
totalRatings: 0,
|
||||
category: "other",
|
||||
tags: [],
|
||||
status: mcp.status || "unknown",
|
||||
statusMessage: mcp.status_message || "",
|
||||
}));
|
||||
|
||||
console.log(`Found ${enrichedMcps.length} installed MCPs`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mcps: enrichedMcps,
|
||||
total: enrichedMcps.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching installed MCPs:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || "Failed to fetch installed MCPs",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DeepNLPService } from "@/lib/services/deepnlp";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const mcpId = searchParams.get("mcpId");
|
||||
|
||||
if (!mcpId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "MCP ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Fetching server config for: ${mcpId}`);
|
||||
|
||||
// Marketplace removido: pular cache local
|
||||
|
||||
// Tentar DeepNLP
|
||||
try {
|
||||
const serverConfig = await DeepNLPService.getServerConfig(mcpId);
|
||||
|
||||
if (serverConfig.items && serverConfig.items.length > 0) {
|
||||
console.log(`Found config from DeepNLP for ${mcpId}`);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
config: serverConfig.items[0],
|
||||
source: "deepnlp",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`DeepNLP lookup failed for ${mcpId}, continuing...`);
|
||||
}
|
||||
|
||||
// Se não encontrou configuração em nenhum lugar, retornar config vazio
|
||||
console.log(`No config found for ${mcpId}, returning empty config`);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
config: null,
|
||||
source: "none",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching server config:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || "Failed to fetch server config",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
import { MCPValidatorService } from "@/lib/services/mcp-validator";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { mcpId } = await request.json();
|
||||
|
||||
if (!mcpId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "MCP ID é obrigatório" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🧪 Testing MCP server: ${mcpId}`);
|
||||
|
||||
// Buscar dados do MCP no banco
|
||||
const mcpData = MCPRepository.getInstalledMCP(mcpId);
|
||||
if (!mcpData) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "MCP não encontrado" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Obter ambiente de instalação
|
||||
const environment = MCPRepository.getEnvironment(mcpId);
|
||||
|
||||
if (!environment) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
"MCP não está instalado corretamente. Ambiente não encontrado.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Testing environment: ${environment.executable} ${environment.args?.join(
|
||||
" "
|
||||
)}`
|
||||
);
|
||||
|
||||
// Validar servidor usando protocolo JSON-RPC MCP
|
||||
const validationResult = await MCPValidatorService.validateMCPServer(
|
||||
environment,
|
||||
mcpData.config
|
||||
);
|
||||
|
||||
// Salvar resultado da validação
|
||||
MCPRepository.saveValidationResult(mcpId, validationResult);
|
||||
|
||||
if (validationResult.success) {
|
||||
// Atualizar ferramentas se validação foi bem-sucedida
|
||||
if (validationResult.tools && validationResult.tools.length > 0) {
|
||||
MCPRepository.saveTools(mcpId, validationResult.tools);
|
||||
}
|
||||
|
||||
console.log(`✅ Test successful for ${mcpId}:`, {
|
||||
protocol: validationResult.protocol,
|
||||
tools: validationResult.tools.length,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result: {
|
||||
success: true,
|
||||
protocol: validationResult.protocol,
|
||||
toolsCount: validationResult.tools.length,
|
||||
tools: validationResult.tools,
|
||||
capabilities: validationResult.capabilities,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Se validação falhou, tentar validação simples
|
||||
console.warn(`⚠️ JSON-RPC validation failed, trying simple validation...`);
|
||||
|
||||
const simpleResult = await MCPValidatorService.simpleValidation(
|
||||
environment,
|
||||
mcpData.config,
|
||||
5000
|
||||
);
|
||||
|
||||
if (simpleResult.success) {
|
||||
console.log(`✅ Simple validation passed for ${mcpId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result: {
|
||||
success: true,
|
||||
protocol: "unknown",
|
||||
message: "Server starts but JSON-RPC validation failed",
|
||||
toolsCount: 0,
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Ambas validações falharam
|
||||
console.error(`❌ All validations failed for ${mcpId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error:
|
||||
validationResult.error || simpleResult.error || "Validation failed",
|
||||
result: {
|
||||
success: false,
|
||||
protocol: validationResult.protocol,
|
||||
error: validationResult.error,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error testing MCP server:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || "Internal server error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
import { MCPEnvironmentService } from "@/lib/services/mcp-environment";
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { mcpId } = await request.json();
|
||||
|
||||
if (!mcpId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "MCP ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🗑️ Uninstalling MCP: ${mcpId}`);
|
||||
|
||||
// Verificar se está instalado
|
||||
if (!MCPRepository.isInstalled(mcpId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `MCP ${mcpId} is not installed`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Obter ambiente antes de remover do banco
|
||||
const environment = MCPRepository.getEnvironment(mcpId);
|
||||
|
||||
// Remover do banco local
|
||||
MCPRepository.removeInstallation(mcpId);
|
||||
|
||||
console.log(`✓ MCP ${mcpId} removed from database`);
|
||||
|
||||
// Limpar arquivos de instalação se ambiente existe
|
||||
if (environment) {
|
||||
try {
|
||||
await MCPEnvironmentService.cleanupEnvironment(environment);
|
||||
console.log(`✓ MCP ${mcpId} environment cleaned up`);
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`⚠️ Could not clean up environment for ${mcpId}: ${error.message}`
|
||||
);
|
||||
// Não falhar a desinstalação se limpeza falhar
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `MCP ${mcpId} uninstalled successfully`,
|
||||
mcpId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error uninstalling MCP:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message || "Failed to uninstall MCP" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { mcpId, newConfig } = await request.json();
|
||||
|
||||
if (!mcpId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "MCP ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!newConfig) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "New configuration is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Updating config for MCP: ${mcpId}`);
|
||||
|
||||
// Verificar se o MCP está instalado
|
||||
if (!MCPRepository.isInstalled(mcpId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `MCP ${mcpId} is not installed`,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Atualizar configuração
|
||||
MCPRepository.updateConfig(mcpId, newConfig);
|
||||
|
||||
console.log(`MCP ${mcpId} configuration updated successfully`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `MCP ${mcpId} configuration updated successfully`,
|
||||
mcpId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error updating MCP configuration:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message || "Failed to update MCP configuration" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -1,377 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
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";
|
||||
import {
|
||||
generateToolsPrompt,
|
||||
extractToolCallFromResponse,
|
||||
formatToolResultForContext,
|
||||
} from "@/lib/prompts/mcp-tools-prompt";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { model, messages, options, system, activeMcps } = await req.json();
|
||||
|
||||
if (!model || !messages?.length) {
|
||||
return new Response("Missing model or messages", { status: 400 });
|
||||
}
|
||||
|
||||
// Detectar capabilities do modelo
|
||||
const capabilities = detectModelCapabilities(model);
|
||||
|
||||
// Preparar tools se MCPs ativos foram fornecidos
|
||||
let tools: Array<any> = [];
|
||||
let toolsPrompt = "";
|
||||
const mcpToolsMap = new Map<string, { mcpId: string; toolName: string }>();
|
||||
|
||||
if (activeMcps && activeMcps.length > 0) {
|
||||
console.log(`🔧 Active MCPs for chat: ${activeMcps.join(", ")}`);
|
||||
|
||||
// Buscar tools de todos os MCPs ativos
|
||||
for (const mcpId of activeMcps) {
|
||||
const mcpTools = MCPExecutor.getAvailableTools(mcpId);
|
||||
|
||||
for (const tool of mcpTools) {
|
||||
const toolKey = `${mcpId}__${tool.name}`;
|
||||
|
||||
if (capabilities.supportsNativeTools) {
|
||||
// Formato OpenAI para modelos com suporte nativo
|
||||
tools.push({
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolKey,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Mapa para lookup rápido
|
||||
mcpToolsMap.set(toolKey, { mcpId, toolName: tool.name });
|
||||
}
|
||||
}
|
||||
|
||||
// Se modelo não suporta native tools, usar prompt engineering
|
||||
if (!capabilities.supportsNativeTools) {
|
||||
const allTools = Array.from(mcpToolsMap.entries()).map(
|
||||
([key, { mcpId, toolName }]) => {
|
||||
const mcpTools = MCPExecutor.getAvailableTools(mcpId);
|
||||
return mcpTools.find((t) => t.name === toolName)!;
|
||||
}
|
||||
);
|
||||
toolsPrompt = generateToolsPrompt(allTools);
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let isClosed = false;
|
||||
let accumulatedResponse = "";
|
||||
let toolExecuted = false;
|
||||
let lastExtractedToolCall: { name: string; parameters: any } | null =
|
||||
null;
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!isClosed) {
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(data));
|
||||
} catch (error) {
|
||||
console.error("Error enqueuing data:", error);
|
||||
isClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const safeClose = () => {
|
||||
if (!isClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
isClosed = true;
|
||||
} catch (error) {
|
||||
console.error("Error closing controller:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Garantir disponibilidade com timeout curto (não bloquear)
|
||||
ensureOllamaAvailable({ timeoutMs: 2000 }).catch(() => {});
|
||||
const ollamaMessages = convertToOllamaMessages(messages);
|
||||
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 && process.env.NODE_ENV !== "production") console.log("🧩 [CHAT] Tools prompt injected");
|
||||
|
||||
// Preparar parâmetros para chat
|
||||
const chatParams = capabilities.supportsNativeTools ? { tools } : {};
|
||||
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(
|
||||
model,
|
||||
ollamaMessages,
|
||||
options,
|
||||
finalSystemPrompt,
|
||||
capabilities.supportsNativeTools ? tools : undefined
|
||||
);
|
||||
if (process.env.NODE_ENV !== "production") console.log("🌊 [CHAT] Streaming started for model:", model);
|
||||
|
||||
const normalizeParameters = (params: any) => {
|
||||
try {
|
||||
if (typeof params === "string") {
|
||||
params = JSON.parse(params);
|
||||
}
|
||||
} catch {
|
||||
// ignora erro de parse e mantém original
|
||||
}
|
||||
if (params && typeof params === "object") {
|
||||
const cleaned: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v && typeof v === "object" && Object.keys(v).length === 0) {
|
||||
// remove objetos vazios que costumam vir de UIs (ex.: count: {})
|
||||
continue;
|
||||
}
|
||||
cleaned[k] = v;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
return params ?? {};
|
||||
};
|
||||
|
||||
for await (const chunk of response) {
|
||||
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 &&
|
||||
chunk.message.tool_calls.length > 0) ||
|
||||
(Array.isArray((chunk as any).tool_calls) &&
|
||||
(chunk as any).tool_calls.length > 0)
|
||||
) {
|
||||
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);
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.log("🧭 [CHAT] Native tool call:", toolKey, "->", toolInfo);
|
||||
}
|
||||
|
||||
if (toolInfo) {
|
||||
// Executar tool
|
||||
const result = await MCPExecutor.executeMCPTool(
|
||||
toolInfo.mcpId,
|
||||
toolInfo.toolName,
|
||||
normalizeParameters(
|
||||
toolCall.function.arguments ||
|
||||
toolCall.function.parameters ||
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toolExecuted = true;
|
||||
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({
|
||||
toolExecution: {
|
||||
mcpId: toolInfo.mcpId,
|
||||
toolName: toolInfo.toolName,
|
||||
status: "success",
|
||||
},
|
||||
}) + "\n"
|
||||
);
|
||||
|
||||
// Injetar resultado no contexto e continuar
|
||||
const resultMessage = formatToolResultForContext(
|
||||
toolInfo.toolName,
|
||||
result.result
|
||||
);
|
||||
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 = [
|
||||
...ollamaMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: chunk.message.content || "",
|
||||
},
|
||||
{ role: "system", content: resultMessage },
|
||||
];
|
||||
|
||||
const followUpResponse = await chatWithStream(
|
||||
model,
|
||||
updatedMessages,
|
||||
options,
|
||||
finalSystemPrompt,
|
||||
capabilities.supportsNativeTools ? tools : undefined
|
||||
);
|
||||
|
||||
for await (const followUpChunk of followUpResponse) {
|
||||
const token = followUpChunk.message?.content || "";
|
||||
if (token) {
|
||||
safeEnqueue(JSON.stringify({ token }) + "\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("Tool execution failed:", result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Streaming normal de tokens
|
||||
const token = chunk.message?.content || "";
|
||||
if (token) {
|
||||
accumulatedResponse += token;
|
||||
safeEnqueue(JSON.stringify({ token }) + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se resposta contém tool call via prompt engineering (fallback também para nativo)
|
||||
if (accumulatedResponse) {
|
||||
console.log(
|
||||
"🧾 [CHAT] Accumulated response length:",
|
||||
accumulatedResponse.length
|
||||
);
|
||||
console.log(
|
||||
"🧾 [CHAT] Accumulated preview:",
|
||||
accumulatedResponse.slice(0, 400)
|
||||
);
|
||||
const extractedToolCall =
|
||||
extractToolCallFromResponse(accumulatedResponse);
|
||||
|
||||
if (extractedToolCall) {
|
||||
if (process.env.NODE_ENV !== "production") console.log("🔧 Model requested tool call (prompt engineering)");
|
||||
lastExtractedToolCall = 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 =
|
||||
null;
|
||||
|
||||
for (const [key, info] of mcpToolsMap.entries()) {
|
||||
if (
|
||||
extractedToolCall.name === info.toolName ||
|
||||
extractedToolCall.name === key
|
||||
) {
|
||||
foundToolInfo = info;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundToolInfo) {
|
||||
// Executar tool
|
||||
const result = await MCPExecutor.executeMCPTool(
|
||||
foundToolInfo.mcpId,
|
||||
foundToolInfo.toolName,
|
||||
normalizeParameters(extractedToolCall.parameters)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toolExecuted = true;
|
||||
// Limpar resposta anterior (que continha o JSON)
|
||||
safeEnqueue(JSON.stringify({ clearPrevious: true }) + "\n");
|
||||
|
||||
// Enviar indicador de execução
|
||||
safeEnqueue(
|
||||
JSON.stringify({
|
||||
toolExecution: {
|
||||
mcpId: foundToolInfo.mcpId,
|
||||
toolName: foundToolInfo.toolName,
|
||||
status: "success",
|
||||
},
|
||||
}) + "\n"
|
||||
);
|
||||
|
||||
// Fazer nova chamada com resultado
|
||||
const resultMessage = formatToolResultForContext(
|
||||
foundToolInfo.toolName,
|
||||
result.result
|
||||
);
|
||||
|
||||
const updatedMessages = [
|
||||
...ollamaMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: JSON.stringify({
|
||||
tool_call: extractedToolCall,
|
||||
}),
|
||||
},
|
||||
{ role: "system", content: resultMessage },
|
||||
];
|
||||
|
||||
const followUpResponse = await chatWithStream(
|
||||
model,
|
||||
updatedMessages,
|
||||
options,
|
||||
finalSystemPrompt
|
||||
);
|
||||
|
||||
for await (const followUpChunk of followUpResponse) {
|
||||
const token = followUpChunk.message?.content || "";
|
||||
if (token) {
|
||||
safeEnqueue(JSON.stringify({ token }) + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== "production")
|
||||
console.log("🕵️ [CHAT] No tool call extracted from accumulated response");
|
||||
}
|
||||
}
|
||||
|
||||
safeEnqueue(JSON.stringify({ done: true }) + "\n");
|
||||
safeClose();
|
||||
} catch (error) {
|
||||
console.error("Chat streaming error:", error);
|
||||
safeEnqueue(
|
||||
JSON.stringify({ error: "Failed to generate response" }) + "\n"
|
||||
);
|
||||
safeClose();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Chat API error:", error);
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { deleteModel } from "@/lib/ollama";
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { modelName } = await request.json();
|
||||
|
||||
if (!modelName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Model name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await deleteModel(modelName);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Model ${modelName} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting model:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete model" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listModelsViaSdk, listModelsViaHttp } from "@/lib/ollama";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
try {
|
||||
await listModelsViaSdk();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
await listModelsViaHttp();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false });
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listModelsViaSdk, listModelsViaHttp, ensureOllamaAvailable } from "@/lib/ollama";
|
||||
import {
|
||||
estimateCpuVram,
|
||||
inferDevice,
|
||||
inferQuantization,
|
||||
mockModels,
|
||||
type ModelInfo,
|
||||
} from "@/lib/models";
|
||||
|
||||
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;
|
||||
const sizeGB = Number(Math.max(0.1, sizeBytes / 1024 ** 3).toFixed(2));
|
||||
const quantization = inferQuantization(name);
|
||||
const device = inferDevice(model?.details || name);
|
||||
const { cpuPct, vramGB } = estimateCpuVram({ sizeGB, quantization, device });
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
sizeGB,
|
||||
quantization,
|
||||
device,
|
||||
estCpuUsagePct: cpuPct,
|
||||
estVramGB: vramGB,
|
||||
};
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
try {
|
||||
models = await listModelsViaSdk();
|
||||
} catch {
|
||||
models = await listModelsViaHttp();
|
||||
}
|
||||
const normalized = models.map((m) => normalizeModel(m));
|
||||
const payload = { offline: false, models: normalized };
|
||||
__modelsCache = { ts: Date.now(), payload };
|
||||
return NextResponse.json(payload);
|
||||
} catch (e) {
|
||||
const payload = { offline: true, models: mockModels };
|
||||
__modelsCache = { ts: Date.now(), payload };
|
||||
return NextResponse.json(payload);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { model } = await req.json();
|
||||
|
||||
if (!model) {
|
||||
return new Response("Missing model", { status: 400 });
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(JSON.stringify(chunk) + "\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// 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: 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<string, unknown> = {
|
||||
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(payload) + "\n"),
|
||||
);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Pull API error:", error);
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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 saved = await getSavedRemoteModels();
|
||||
const q = (query || "").trim().toLowerCase();
|
||||
const filtered = q
|
||||
? saved.filter((m) => m.name.toLowerCase().includes(q))
|
||||
: saved;
|
||||
|
||||
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(
|
||||
{ error: "Failed to search remote models" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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<string, { tags: string[]; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
|
||||
|
||||
async function fetchTagsFromPage(url: string): Promise<string[]> {
|
||||
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<string>();
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPreferences, setPreferences } from "@/lib/preferences";
|
||||
|
||||
// Estrutura de preferências
|
||||
type UserPreferences = {
|
||||
selectedModel: string | null;
|
||||
systemPrompt: string;
|
||||
device: "auto" | "cpu" | "gpu";
|
||||
gpuIndex: number;
|
||||
numGpu: number;
|
||||
};
|
||||
|
||||
// GET - Buscar preferências
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = "default"; // Pode ser expandido para multi-usuário
|
||||
const prefs = await getPreferences(userId);
|
||||
return NextResponse.json(prefs);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar preferências:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Salvar preferências
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = "default";
|
||||
const prefs = await request.json();
|
||||
await setPreferences(userId, prefs);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Erro ao salvar preferências:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { semanticSearch } from "@/lib/vector-search";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { query, limit = 5 } = await request.json();
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json(
|
||||
{ error: "Query é obrigatória" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = await semanticSearch(query, limit);
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
console.error("Erro na busca semântica:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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<any> = 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
-164
@@ -1,164 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #0b0c0f;
|
||||
--accent: #2563eb; /* blue-600 */
|
||||
--accent-foreground: #ffffff;
|
||||
--border: #e5e7ef;
|
||||
--surface: #f6f7fb;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-border: var(--border);
|
||||
--font-sans: var(--font-inter);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0b0c0f;
|
||||
--foreground: #e6e8ef;
|
||||
--accent: #3b82f6; /* blue-500 */
|
||||
--accent-foreground: #0b0c0f;
|
||||
--border: #2a2f3a;
|
||||
--surface: #12141a;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-inter), ui-sans-serif, system-ui;
|
||||
}
|
||||
|
||||
/* Custom scrollbar para o seletor de modelos */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--foreground);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* MCP Marketplace Styles */
|
||||
.mcp-card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mcp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mcp-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mcp-modal {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.mcp-category-badge {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-category-badge:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mcp-rating-star {
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-rating-star:hover {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.mcp-install-button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-install-button:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.mcp-install-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Loading animation for MCP cards */
|
||||
.mcp-loading {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* MCP detail modal animations */
|
||||
.mcp-detail-modal {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* MCP search bar focus effects */
|
||||
.mcp-search-input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* MCP category filter active state */
|
||||
.mcp-category-active {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary));
|
||||
color: var(--primary-foreground);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* MCP tag hover effects */
|
||||
.mcp-tag {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-tag:hover {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "../components/theme-provider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { ChatContainer } from "@/components/chat/ChatContainer";
|
||||
import { ClientHome } from "@/components/ClientHome";
|
||||
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
|
||||
const res = await fetch(`${baseUrl}/api/ollama/models`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return { offline: true, models: [] };
|
||||
return res.json();
|
||||
} catch {
|
||||
return { offline: true, models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const { offline, models } = await fetchModels();
|
||||
return <ClientHome offline={offline} models={models} />;
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"next": "recommended",
|
||||
"react": "recommended"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { ChatContainer } from "@/components/chat/ChatContainer";
|
||||
import { ConversationSidebar } from "@/components/chat/ConversationSidebar";
|
||||
import {
|
||||
SettingsButton,
|
||||
SettingsModal,
|
||||
} from "@/components/settings/SettingsModal";
|
||||
import { Store } from "lucide-react";
|
||||
|
||||
interface ClientHomeProps {
|
||||
offline: boolean;
|
||||
models: any[];
|
||||
}
|
||||
|
||||
export function ClientHome({ offline, models }: ClientHomeProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [currentConversationId, setCurrentConversationId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
|
||||
const handleConversationCreated = () => {
|
||||
// Incrementar trigger para forçar atualização da sidebar
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleSelectConversation = (id: string) => {
|
||||
setCurrentConversationId(id);
|
||||
};
|
||||
|
||||
const handleNewConversation = () => {
|
||||
setCurrentConversationId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-[var(--background)]">
|
||||
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_oklab,var(--background),black_3%)]/80 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_3%)]/60">
|
||||
<div className="mx-auto flex max-w-full items-center justify-between px-4 py-3">
|
||||
<h1 className="text-lg font-semibold tracking-tight">Ollahub</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{offline && (
|
||||
<span className="rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-xs">
|
||||
Ollama offline
|
||||
</span>
|
||||
)}
|
||||
<SettingsButton onOpen={() => setOpen(true)} />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<div className="flex h-full">
|
||||
<ConversationSidebar
|
||||
currentConversationId={currentConversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
<ChatContainer
|
||||
models={models}
|
||||
offline={offline}
|
||||
onConversationCreated={handleConversationCreated}
|
||||
currentConversationId={currentConversationId}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,808 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
// 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";
|
||||
import { createMessage } from "@/lib/chat";
|
||||
import { useUserPrefs, buildOllamaOptions } from "@/hooks/useUserPrefs";
|
||||
|
||||
interface ChatContainerProps {
|
||||
models: ModelInfo[];
|
||||
offline: boolean;
|
||||
onConversationCreated?: () => void;
|
||||
currentConversationId?: string | null;
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
models,
|
||||
offline,
|
||||
onConversationCreated,
|
||||
currentConversationId: propCurrentConversationId,
|
||||
}: ChatContainerProps) {
|
||||
const { prefs, ready, update } = useUserPrefs();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [inputContent, setInputContent] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const [showPullDialog, setShowPullDialog] = useState(false);
|
||||
const [modelToPull, setModelToPull] = useState<string>("");
|
||||
const [systemPrompt, setSystemPrompt] = useState<string>("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(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<string[]>([]);
|
||||
const [mcpNames, setMcpNames] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Carregar modelo salvo ao iniciar
|
||||
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]);
|
||||
|
||||
// Carregar mensagens quando uma conversa é selecionada
|
||||
useEffect(() => {
|
||||
if (propCurrentConversationId) {
|
||||
loadConversation(propCurrentConversationId);
|
||||
} else {
|
||||
// Limpar mensagens quando nenhuma conversa está selecionada
|
||||
setMessages([]);
|
||||
setConversationId(null);
|
||||
}
|
||||
}, [propCurrentConversationId]);
|
||||
|
||||
// Salvar modelo quando selecionado
|
||||
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
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("ollahub-active-mcps");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
setActiveMcps(parsed.mcps || []);
|
||||
setMcpNames(new Map(parsed.names || []));
|
||||
} catch (e) {
|
||||
console.error("Error loading active MCPs:", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Salvar MCPs ativos no localStorage quando mudarem
|
||||
useEffect(() => {
|
||||
if (activeMcps.length > 0 || mcpNames.size > 0) {
|
||||
localStorage.setItem(
|
||||
"ollahub-active-mcps",
|
||||
JSON.stringify({
|
||||
mcps: activeMcps,
|
||||
names: Array.from(mcpNames.entries()),
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [activeMcps, mcpNames]);
|
||||
|
||||
// Handlers para MCP Selector
|
||||
const handleToggleMCP = useCallback(
|
||||
async (mcpId: string) => {
|
||||
setActiveMcps((prev) => {
|
||||
if (prev.includes(mcpId)) {
|
||||
return prev.filter((id) => id !== mcpId);
|
||||
} else {
|
||||
// Buscar nome do MCP se ainda não temos
|
||||
if (!mcpNames.has(mcpId)) {
|
||||
fetch("/api/mcp/available-for-chat")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
const mcp = data.mcps.find((m: any) => m.mcpId === mcpId);
|
||||
if (mcp) {
|
||||
setMcpNames((prev) => new Map(prev).set(mcpId, mcp.name));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
return [...prev, mcpId];
|
||||
}
|
||||
});
|
||||
},
|
||||
[mcpNames]
|
||||
);
|
||||
|
||||
const handleClearAllMCPs = useCallback(() => {
|
||||
setActiveMcps([]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveMCP = useCallback((mcpId: string) => {
|
||||
setActiveMcps((prev) => prev.filter((id) => id !== mcpId));
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.conversation && data.messages) {
|
||||
setConversationId(id);
|
||||
setMessages(data.messages);
|
||||
setSelectedModel(data.conversation.model);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar conversa:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const startNewConversation = () => {
|
||||
setConversationId(null);
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
if (!selectedModel || isStreaming) return;
|
||||
|
||||
// Criar nova conversa se não existir (COM TÍTULO)
|
||||
let currentConversationId = conversationId;
|
||||
if (!currentConversationId) {
|
||||
try {
|
||||
const response = await fetch("/api/conversations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
firstMessage: content, // Passar primeira mensagem
|
||||
}),
|
||||
});
|
||||
const { id, title } = await response.json();
|
||||
currentConversationId = id;
|
||||
setConversationId(id);
|
||||
console.log("✨ Chat criado:", title);
|
||||
|
||||
// Notificar sidebar para atualizar lista de conversas
|
||||
if (onConversationCreated) {
|
||||
onConversationCreated();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar conversa:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage = createMessage("user", content);
|
||||
const newMessages = [...messages, userMessage];
|
||||
|
||||
// Add system prompt if it exists and this is the first user message
|
||||
const messagesToSend =
|
||||
systemPrompt && messages.length === 0
|
||||
? [createMessage("system", systemPrompt), ...newMessages]
|
||||
: newMessages;
|
||||
|
||||
setMessages(newMessages);
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
|
||||
// Persistir mensagem do usuário
|
||||
if (currentConversationId) {
|
||||
try {
|
||||
await fetch(`/api/conversations/${currentConversationId}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: "user", content }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao persistir mensagem:", error);
|
||||
}
|
||||
}
|
||||
|
||||
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: messagesToSend,
|
||||
options,
|
||||
system: systemPrompt || undefined,
|
||||
activeMcps: activeMcps.length > 0 ? activeMcps : undefined,
|
||||
}),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to send message");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Tool execution indicator
|
||||
if (data.toolExecution) {
|
||||
console.log(
|
||||
`🔧 MCP Tool executed: ${data.toolExecution.mcpId}.${data.toolExecution.toolName}`
|
||||
);
|
||||
// Opcional: Mostrar indicador visual
|
||||
}
|
||||
|
||||
// Clear previous response (para prompt engineering)
|
||||
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("");
|
||||
|
||||
// Persistir mensagem do assistente
|
||||
if (currentConversationId) {
|
||||
try {
|
||||
await fetch(
|
||||
`/api/conversations/${currentConversationId}/messages`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
}),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Erro ao persistir mensagem do assistente:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error);
|
||||
const errorMessage = createMessage(
|
||||
"assistant",
|
||||
"Sorry, I encountered an error. Please try again."
|
||||
);
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent("");
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortRef.current && isStreaming) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSystemPromptChange = (prompt: string) => {
|
||||
setSystemPrompt(prompt);
|
||||
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 <id>", 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) {
|
||||
handleModelChange(modelId);
|
||||
// Optional: clear chat when switching models
|
||||
// setMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/delete", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelName: modelId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete model");
|
||||
}
|
||||
|
||||
// Atualizar lista de modelos após exclusão
|
||||
// Aqui você pode recarregar a lista ou remover o modelo da lista local
|
||||
console.log(`Model ${modelId} deleted successfully`);
|
||||
} catch (error) {
|
||||
console.error("Error deleting model:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePullModel = async (modelName: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/pull", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: modelName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to pull model");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="flex-1 flex flex-col bg-[var(--background)] min-h-0">
|
||||
{/* Chat Header */}
|
||||
<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">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{selectedModelData ? "Chat with" : "Select a model to start chatting"}
|
||||
</h1>
|
||||
<ModelDropdown
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={handleSelectModel}
|
||||
onDeleteModel={handleDeleteModel}
|
||||
onPullModel={openPullDialog}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isStreaming || messages.length === 0}
|
||||
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
Regenerar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={!isStreaming}
|
||||
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className={`mx-auto w-full ${
|
||||
(prefs.chatLayout ?? "compact") === "edge" ? "max-w-full px-2" : "max-w-3xl px-4"
|
||||
}`}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16">
|
||||
<div className="text-center text-[var(--foreground)]/70">
|
||||
<p className="text-xl font-medium mb-1">Bem-vindo ao Ollahub</p>
|
||||
<p className="text-sm">Selecione um modelo e comece a conversar.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} bubbleSize={prefs.bubbleSize ?? "md"} />
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<ChatMessage
|
||||
message={createMessage("assistant", streamingContent)}
|
||||
isStreaming={true}
|
||||
bubbleSize={prefs.bubbleSize ?? "md"}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] sticky bottom-0 z-10 bg-[color-mix(in_oklab,var(--background),black_2%)]/80 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_2%)]/60">
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{/* Active MCP Badges */}
|
||||
<ActiveMCPBadges
|
||||
activeMcps={activeMcps}
|
||||
mcpNames={mcpNames}
|
||||
onRemove={handleRemoveMCP}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)]/60 shadow-sm transition-all focus-within:ring-2 focus-within:ring-[var(--accent)] relative">
|
||||
<div className="flex gap-2 p-2">
|
||||
{/* MCP Selector */}
|
||||
<MCPSelector
|
||||
activeMcps={activeMcps}
|
||||
onToggleMCP={handleToggleMCP}
|
||||
onClearAll={handleClearAllMCPs}
|
||||
onOpenInstallModal={() => setShowManualInstall(true)}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputContent}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setInputContent(val);
|
||||
if (val.startsWith("/")) {
|
||||
setShowCommandMenu(true);
|
||||
const q = val.slice(1).trim();
|
||||
setCommandQuery(q);
|
||||
setHighlightedCmd(0);
|
||||
} else {
|
||||
setShowCommandMenu(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
selectedModel ? "Digite uma mensagem..." : "Selecione um modelo primeiro..."
|
||||
}
|
||||
disabled={!selectedModel || isStreaming}
|
||||
className="flex-1 resize-none rounded-lg border-none bg-transparent px-3 py-3 text-sm focus:outline-none disabled:opacity-50 max-h-40 overflow-y-auto"
|
||||
rows={1}
|
||||
|
||||
/>
|
||||
<button
|
||||
type={isStreaming ? "button" : "submit"}
|
||||
onClick={isStreaming ? handleStop : undefined}
|
||||
disabled={
|
||||
isStreaming ? false : !inputContent.trim() || !selectedModel
|
||||
}
|
||||
className="flex h-10 w-10 my-auto items-center justify-center rounded-lg bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 pb-2 pt-0 text-xs text-[var(--foreground)]/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(inputContent).catch(() => {})}
|
||||
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
Copiar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInputContent("")}
|
||||
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInputContent("/");
|
||||
setShowCommandMenu(true);
|
||||
setCommandQuery("");
|
||||
setHighlightedCmd(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
Comandos
|
||||
</button>
|
||||
<span className="ml-auto">{selectedModel ? "" : "Selecione um modelo para começar"}</span>
|
||||
</div>
|
||||
|
||||
{showCommandMenu && filteredCommands.length > 0 && (
|
||||
<div className="absolute left-2 right-2 bottom-14 rounded-md border border-[var(--border)] bg-[var(--background)] shadow-md overflow-hidden">
|
||||
{filteredCommands.map((cmd, idx) => (
|
||||
<button
|
||||
key={cmd.id + idx}
|
||||
type="button"
|
||||
onClick={() => executeCommand(cmd)}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-[var(--surface)] ${
|
||||
idx === highlightedCmd ? "bg-[var(--surface)]" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{cmd.label}</div>
|
||||
{cmd.hint && (
|
||||
<div className="text-[10px] opacity-70">{cmd.hint}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Pull Dialog */}
|
||||
<ModelPullDialog
|
||||
isOpen={showPullDialog}
|
||||
onClose={() => setShowPullDialog(false)}
|
||||
modelName={modelToPull}
|
||||
onConfirmPull={handlePullModel}
|
||||
/>
|
||||
<MCPManualInstallModal
|
||||
isOpen={showManualInstall}
|
||||
onClose={() => setShowManualInstall(false)}
|
||||
onSuccess={() => {
|
||||
// Fechar e opcionalmente poderíamos sinalizar o MCPSelector a recarregar.
|
||||
setShowManualInstall(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { User, Bot, Send, Loader2, Square } from "lucide-react";
|
||||
import { MarkdownRenderer } from "@/components/message/MarkdownRenderer";
|
||||
import { MessageActions } from "@/components/message/MessageActions";
|
||||
import type { Message } from "@/lib/chat";
|
||||
import { formatTimestamp } from "@/lib/chat";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
bubbleSize?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
message,
|
||||
isStreaming = false,
|
||||
bubbleSize = "md",
|
||||
}: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
const sizeClass =
|
||||
bubbleSize === "sm"
|
||||
? "px-3 py-1.5 text-sm"
|
||||
: bubbleSize === "lg"
|
||||
? "px-5 py-3 text-base"
|
||||
: "px-4 py-2 text-sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-3 p-4 ${isUser ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent)] text-white">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] md:max-w-[80%] relative rounded-lg ${sizeClass} shadow-sm ${
|
||||
isUser
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--surface)] text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<div className="break-words">
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
) : (
|
||||
<MarkdownRenderer content={message.content} />
|
||||
)}
|
||||
</div>
|
||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||
{!isUser && !isStreaming && (
|
||||
<div className="absolute bottom-2 right-2 z-10 pointer-events-auto">
|
||||
<MessageActions content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mt-1 text-xs opacity-70 ${
|
||||
isUser ? "text-right" : "text-left"
|
||||
}`}
|
||||
>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUser && (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--surface)]">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (content: string) => void;
|
||||
onStop?: () => void;
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSendMessage,
|
||||
onStop,
|
||||
disabled = false,
|
||||
isStreaming = false,
|
||||
placeholder = "Type a message...",
|
||||
}: ChatInputProps) {
|
||||
const [content, setContent] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim() && !disabled) {
|
||||
onSendMessage(content.trim());
|
||||
setContent("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${Math.min(
|
||||
textareaRef.current.scrollHeight,
|
||||
120
|
||||
)}px`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-2 p-4 border-t border-[var(--border)]"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="flex-1 resize-none rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] disabled:opacity-50"
|
||||
rows={1}
|
||||
|
||||
/>
|
||||
<button
|
||||
type={isStreaming ? "button" : "submit"}
|
||||
onClick={isStreaming ? onStop : undefined}
|
||||
disabled={isStreaming ? false : !content.trim() || disabled}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : disabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamingTextProps {
|
||||
text: string;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function StreamingText({ text, isStreaming }: StreamingTextProps) {
|
||||
return (
|
||||
<span>
|
||||
{text}
|
||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { MessageSquare, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
type Conversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type ConversationSidebarProps = {
|
||||
currentConversationId: string | null;
|
||||
onSelectConversation: (id: string) => void;
|
||||
onNewConversation: () => void;
|
||||
refreshTrigger?: number; // Para forçar atualização
|
||||
};
|
||||
|
||||
export function ConversationSidebar({
|
||||
currentConversationId,
|
||||
onSelectConversation,
|
||||
onNewConversation,
|
||||
refreshTrigger,
|
||||
}: ConversationSidebarProps) {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, []);
|
||||
|
||||
// Recarregar conversas quando refreshTrigger mudar
|
||||
useEffect(() => {
|
||||
if (refreshTrigger !== undefined) {
|
||||
loadConversations();
|
||||
}
|
||||
}, [refreshTrigger]);
|
||||
|
||||
const loadConversations = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/conversations");
|
||||
const data = await response.json();
|
||||
setConversations(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar conversas:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!confirm("Deletar esta conversa?")) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/conversations/${id}`, { method: "DELETE" });
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
|
||||
if (currentConversationId === id) {
|
||||
onNewConversation();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar conversa:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConversations = conversations.filter((c) =>
|
||||
c.title.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-64 lg:w-72 h-full bg-[var(--surface)] border-r border-[var(--border)] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[var(--border)] sticky top-0 z-10 bg-[color-mix(in_oklab,var(--surface),black_2%)]/90 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--surface),black_2%)]/70">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
// Buscar modelo selecionado nas preferências
|
||||
const prefsRes = await fetch("/api/preferences");
|
||||
const prefs = await prefsRes.json();
|
||||
const model = prefs?.selectedModel;
|
||||
if (!model) {
|
||||
alert("Selecione um modelo antes de criar uma conversa.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Criar conversa vazia (título padrão gerado no backend)
|
||||
const res = await fetch("/api/conversations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Falha ao criar conversa");
|
||||
const { id, title } = await res.json();
|
||||
|
||||
// Inserir no topo da lista e selecionar
|
||||
const newConv: Conversation = {
|
||||
id,
|
||||
title,
|
||||
model,
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
onSelectConversation(id);
|
||||
|
||||
// Notificar container para possíveis efeitos colaterais
|
||||
onNewConversation();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Não foi possível criar a conversa.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}}
|
||||
disabled={creating}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{creating ? "Criando..." : "Nova Conversa"}
|
||||
</button>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar conversa..."
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-[var(--background)] border border-[var(--border)] outline-none focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Conversas */}
|
||||
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar">
|
||||
{loading ? (
|
||||
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
||||
Carregando...
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
||||
Nenhuma conversa ainda
|
||||
</div>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
||||
Nenhum resultado para "{query}"
|
||||
</div>
|
||||
) : (
|
||||
filteredConversations.map((conv) => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => onSelectConversation(conv.id)}
|
||||
className={`w-full flex items-center gap-2 p-3 rounded-lg mb-1 text-left transition-colors group cursor-pointer ${
|
||||
currentConversationId === conv.id
|
||||
? "bg-[var(--accent)]/12 text-[var(--accent)] ring-1 ring-[var(--accent)]/30"
|
||||
: "hover:bg-[color-mix(in_oklab,var(--surface),black_6%)]"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-sm truncate">{conv.title}</span>
|
||||
<button
|
||||
onClick={(e) => handleDelete(conv.id, e)}
|
||||
className="p-1 hover:bg-[var(--background)] rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Plug, ChevronDown, X, Zap } from "lucide-react";
|
||||
|
||||
interface MCPForChat {
|
||||
mcpId: string;
|
||||
name: string;
|
||||
toolCount: number;
|
||||
tools: Array<{ name: string; description: string }>;
|
||||
}
|
||||
|
||||
interface MCPSelectorProps {
|
||||
activeMcps: string[];
|
||||
onToggleMCP: (mcpId: string) => void;
|
||||
onClearAll: () => void;
|
||||
onOpenInstallModal?: () => void;
|
||||
}
|
||||
|
||||
export function MCPSelector({
|
||||
activeMcps,
|
||||
onToggleMCP,
|
||||
onClearAll,
|
||||
onOpenInstallModal,
|
||||
}: MCPSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [availableMcps, setAvailableMcps] = useState<MCPForChat[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Carregar MCPs disponíveis
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadAvailableMCPs();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fechar dropdown ao clicar fora
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadAvailableMCPs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/mcp/available-for-chat");
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setAvailableMcps(
|
||||
data.mcps.map((mcp: any) => ({
|
||||
mcpId: mcp.mcpId,
|
||||
name: mcp.name,
|
||||
toolCount: mcp.toolCount,
|
||||
tools: mcp.rawTools || [],
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading available MCPs:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (mcpId: string) => {
|
||||
onToggleMCP(mcpId);
|
||||
};
|
||||
|
||||
const filtered = availableMcps.filter((mcp) => {
|
||||
if (!query.trim()) return true;
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
mcp.name.toLowerCase().includes(q) ||
|
||||
mcp.tools.some(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
(t.description || "").toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface)] transition-colors"
|
||||
title="Manage MCP Tools"
|
||||
>
|
||||
<Plug className="h-4 w-4" />
|
||||
{activeMcps.length > 0 && (
|
||||
<span className="flex items-center justify-center h-5 min-w-[20px] px-1.5 bg-[var(--accent)] text-[var(--accent-foreground)] text-xs font-medium rounded-full">
|
||||
{activeMcps.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-96 bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-lg z-50 max-h-[28rem] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-[var(--border)] sticky top-0 bg-[color-mix(in_oklab,var(--background),black_2%)]/90 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_2%)]/70">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plug className="h-4 w-4" />
|
||||
<span className="font-semibold text-sm">MCP Tools</span>
|
||||
</div>
|
||||
{activeMcps.length > 0 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="text-xs px-2 py-1 rounded-md border border-[var(--border)] hover:bg-[var(--surface)]"
|
||||
>
|
||||
Limpar tudo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar por servidor ou ferramenta..."
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-[var(--background)] border border-[var(--border)] outline-none focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
|
||||
Carregando MCPs...
|
||||
</div>
|
||||
) : availableMcps.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
|
||||
Nenhum MCP instalado
|
||||
{onOpenInstallModal && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={onOpenInstallModal}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Instalar manualmente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
|
||||
Nenhum resultado para "{query}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filtered.map((mcp) => {
|
||||
const isActive = activeMcps.includes(mcp.mcpId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mcp.mcpId}
|
||||
className={`p-3 rounded-md mb-2 cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? "bg-[var(--accent)]/10 border border-[var(--accent)]/30"
|
||||
: "hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
onClick={() => handleToggle(mcp.mcpId)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={() => {}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{mcp.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60 ml-6">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{mcp.toolCount} ferramentas disponíveis</span>
|
||||
</div>
|
||||
{isActive && mcp.tools.length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mcp.tools.slice(0, 3).map((tool, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/20 text-[var(--accent)] rounded"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
{mcp.tools.length > 3 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-[var(--surface)] rounded">
|
||||
+{mcp.tools.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="p-3 border-t border-[var(--border)] bg-[var(--surface)]/50 flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
{activeMcps.length > 0
|
||||
? `${activeMcps.length} MCPs ativos para enriquecer as respostas`
|
||||
: "Nenhum MCP ativo"}
|
||||
</p>
|
||||
{onOpenInstallModal && (
|
||||
<button
|
||||
onClick={onOpenInstallModal}
|
||||
className="text-xs px-2 py-1 rounded-md border border-[var(--border)] hover:bg-[var(--surface)]"
|
||||
>
|
||||
Instalar MCP manualmente
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pills/badges component to show active MCPs in the chat input area
|
||||
*/
|
||||
interface ActiveMCPBadgesProps {
|
||||
activeMcps: string[];
|
||||
mcpNames: Map<string, string>;
|
||||
onRemove: (mcpId: string) => void;
|
||||
}
|
||||
|
||||
export function ActiveMCPBadges({
|
||||
activeMcps,
|
||||
mcpNames,
|
||||
onRemove,
|
||||
}: ActiveMCPBadgesProps) {
|
||||
if (activeMcps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{activeMcps.map((mcpId) => (
|
||||
<div
|
||||
key={mcpId}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-[var(--primary)]/10 text-[var(--primary)] text-xs rounded-md border border-[var(--primary)]/30"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="font-medium">{mcpNames.get(mcpId) || mcpId}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(mcpId);
|
||||
}}
|
||||
className="ml-1 hover:bg-[var(--primary)]/20 rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Settings, X } from "lucide-react";
|
||||
|
||||
type SettingsModalProps = {
|
||||
systemPrompt: string;
|
||||
onSystemPromptChange: (prompt: string) => void;
|
||||
selectedModel: string | null;
|
||||
models: Array<{ id: string; name: string; device: string }>;
|
||||
design?: {
|
||||
chatLayout: "compact" | "edge";
|
||||
bubbleSize: "sm" | "md" | "lg";
|
||||
onChange: (next: Partial<{ chatLayout: "compact" | "edge"; bubbleSize: "sm" | "md" | "lg" }>) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function SettingsModal({
|
||||
systemPrompt,
|
||||
onSystemPromptChange,
|
||||
selectedModel,
|
||||
models,
|
||||
design,
|
||||
}: SettingsModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tempPrompt, setTempPrompt] = useState(systemPrompt);
|
||||
const [tempChatLayout, setTempChatLayout] = useState<"compact" | "edge">(design?.chatLayout ?? "compact");
|
||||
const [tempBubbleSize, setTempBubbleSize] = useState<"sm" | "md" | "lg">(design?.bubbleSize ?? "md");
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
const handleSave = () => {
|
||||
onSystemPromptChange(tempPrompt);
|
||||
if (design?.onChange) {
|
||||
design.onChange({ chatLayout: tempChatLayout, bubbleSize: tempBubbleSize });
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTempPrompt(systemPrompt);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Botão de Configurações */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-[var(--surface)] transition-colors"
|
||||
aria-label="Configurações"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--background)] rounded-lg border border-[var(--border)] w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold">Configurações</h2>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-1 rounded hover:bg-[var(--surface)] transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-6 overflow-y-auto max-h-[60vh]">
|
||||
{/* Model Info */}
|
||||
{selectedModelData && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">Modelo Selecionado</h3>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--foreground)]/60">Nome:</span>
|
||||
<span>{selectedModelData.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--foreground)]/60">Dispositivo:</span>
|
||||
<span className="capitalize">{selectedModelData.device}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Prompt */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">System Prompt</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-[var(--foreground)]/80">
|
||||
Instruções para o modelo (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={tempPrompt}
|
||||
onChange={(e) => setTempPrompt(e.target.value)}
|
||||
placeholder="Ex: Você é um assistente útil e prestativo..."
|
||||
className="w-full h-32 p-3 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Este prompt será usado como contexto para todas as mensagens desta conversa.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferências de Design */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">Preferências de Design</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-[var(--foreground)]/80">Layout do Chat</label>
|
||||
<select
|
||||
value={tempChatLayout}
|
||||
onChange={(e) => setTempChatLayout(e.target.value as any)}
|
||||
className="w-full p-2 rounded-lg border border-[var(--border)] bg-[var(--background)]"
|
||||
>
|
||||
<option value="compact">Compacto (centralizado)</option>
|
||||
<option value="edge">Até as bordas (edge-to-edge)</option>
|
||||
</select>
|
||||
<p className="text-xs text-[var(--foreground)]/60">Controla se o conteúdo do chat é centralizado com largura máxima ou ocupa toda a largura.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-[var(--foreground)]/80">Tamanho da Bolha</label>
|
||||
<select
|
||||
value={tempBubbleSize}
|
||||
onChange={(e) => setTempBubbleSize(e.target.value as any)}
|
||||
className="w-full p-2 rounded-lg border border-[var(--border)] bg-[var(--background)]"
|
||||
>
|
||||
<option value="sm">Pequena</option>
|
||||
<option value="md">Média</option>
|
||||
<option value="lg">Grande</option>
|
||||
</select>
|
||||
<p className="text-xs text-[var(--foreground)]/60">Ajusta paddings e tipografia das bolhas de mensagem.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface)] transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings, Save, RotateCcw } from "lucide-react";
|
||||
import { SystemPromptInfo } from "./SystemPromptInfo";
|
||||
|
||||
interface SystemPromptProps {
|
||||
onSystemPromptChange: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function SystemPrompt({ onSystemPromptChange }: SystemPromptProps) {
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("ollahub-system-prompt");
|
||||
if (saved) {
|
||||
setSystemPrompt(saved);
|
||||
onSystemPromptChange(saved);
|
||||
}
|
||||
}, [onSystemPromptChange]);
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem("ollahub-system-prompt", systemPrompt);
|
||||
onSystemPromptChange(systemPrompt);
|
||||
setIsEditing(false);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultPrompt = "";
|
||||
setSystemPrompt(defaultPrompt);
|
||||
localStorage.removeItem("ollahub-system-prompt");
|
||||
onSystemPromptChange(defaultPrompt);
|
||||
setIsEditing(false);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSystemPrompt(value);
|
||||
setHasChanges(true);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]/80">
|
||||
System Prompt
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<SystemPromptInfo />
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
className="p-1 rounded hover:bg-[var(--background)] disabled:opacity-50"
|
||||
title="Save prompt"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="Edit system prompt"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Enter system prompt (optional)..."
|
||||
className="w-full h-24 resize-none rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
System prompts help define the AI's behavior and personality.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--background)] p-3">
|
||||
{systemPrompt ? (
|
||||
<p className="text-xs text-[var(--foreground)]/80 whitespace-pre-wrap">
|
||||
{systemPrompt}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--foreground)]/60 italic">
|
||||
No system prompt set
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SystemPromptInfo() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="About system prompts"
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-6 left-0 z-50 w-80 rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 shadow-lg">
|
||||
<div className="text-xs space-y-2">
|
||||
<h4 className="font-medium">System Prompts</h4>
|
||||
<p>
|
||||
System prompts define the AI's behavior, personality, and response
|
||||
style. They are sent once at the beginning of each conversation.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Examples:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-[var(--foreground)]/80">
|
||||
<li>"You are a helpful coding assistant"</li>
|
||||
<li>"Respond in Portuguese, be concise"</li>
|
||||
<li>"You are a creative writing coach"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-[var(--foreground)]/60">
|
||||
Based on Ollama documentation - system messages help guide model
|
||||
behavior.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-2 right-2 p-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -1,297 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import {
|
||||
Package,
|
||||
Star,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
Zap,
|
||||
Settings,
|
||||
PlayCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
|
||||
interface MCPCardProps {
|
||||
mcp: MCPProvider;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
mode?: "marketplace" | "installed";
|
||||
onEditConfig?: () => void;
|
||||
onTestServer?: () => void;
|
||||
isTesting?: boolean;
|
||||
installingStatus?: {
|
||||
status: string;
|
||||
message: string;
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const MCPCard = memo(
|
||||
function MCPCard({
|
||||
mcp,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
mode = "marketplace",
|
||||
onEditConfig,
|
||||
onTestServer,
|
||||
isTesting = false,
|
||||
installingStatus,
|
||||
}: MCPCardProps) {
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "map":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
|
||||
case "browser":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||
case "office":
|
||||
return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
|
||||
case "search":
|
||||
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
|
||||
case "database":
|
||||
return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
|
||||
case "finance":
|
||||
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
|
||||
case "code":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||
case "chart":
|
||||
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
|
||||
case "payment":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDownloads = (downloads: number) => {
|
||||
if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`;
|
||||
if (downloads >= 1000) return `${(downloads / 1000).toFixed(1)}K`;
|
||||
return downloads.toString();
|
||||
};
|
||||
|
||||
const isOfficial = mcp.tags?.includes("official") || false;
|
||||
const toolCount = mcp.tools?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4 hover:shadow-md transition-all duration-200 hover:border-[var(--primary)]/50 mcp-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-[var(--primary)]/10 rounded-md">
|
||||
<Package className="h-4 w-4 text-[var(--primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm">{mcp.name}</h3>
|
||||
{isOfficial && (
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
by {mcp.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium mcp-category-badge ${getCategoryColor(
|
||||
mcp.category
|
||||
)}`}
|
||||
>
|
||||
{mcp.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-3 line-clamp-2">
|
||||
{mcp.description}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 mb-3 text-xs text-[var(--muted-foreground)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||
<span>{mcp.rating}</span>
|
||||
<span>({mcp.totalRatings})</span>
|
||||
</div>
|
||||
{toolCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{toolCount} tools</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs bg-[var(--surface)] px-2 py-1 rounded">
|
||||
v{mcp.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{mcp.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{mcp.tags.length > 3 && (
|
||||
<span className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag">
|
||||
+{mcp.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tools Badges */}
|
||||
{mcp.tools && Array.isArray(mcp.tools) && mcp.tools.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-4 w-4 text-purple-500 dark:text-purple-400" />
|
||||
<span className="text-xs font-semibold text-purple-600 dark:text-purple-400">
|
||||
{mcp.tools.length} {mcp.tools.length === 1 ? "Tool" : "Tools"}
|
||||
</span>
|
||||
</div>
|
||||
{mcp.tools.length > 6 && (
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
Showing 6 of {mcp.tools.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-[60px] overflow-hidden">
|
||||
{mcp.tools.slice(0, 6).map((tool: any, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2.5 py-1 bg-gradient-to-r from-purple-50 to-purple-100 dark:from-purple-950/40 dark:to-purple-900/30 text-purple-700 dark:text-purple-300 text-[11px] font-medium rounded-md border border-purple-200/60 dark:border-purple-800/60 hover:border-purple-300 dark:hover:border-purple-700 transition-colors cursor-default shadow-sm"
|
||||
title={tool.description || tool.name}
|
||||
>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
{mcp.tools.length > 6 && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900/40 dark:to-gray-800/30 text-gray-600 dark:text-gray-400 text-[11px] font-medium rounded-md border border-gray-200/60 dark:border-gray-700/60 shadow-sm">
|
||||
+{mcp.tools.length - 6}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installation Progress */}
|
||||
{installingStatus && (
|
||||
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
Installing...
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${installingStatus.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
{installingStatus.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{mode === "installed" && onEditConfig ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onEditConfig}
|
||||
className="px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm mcp-install-button"
|
||||
title="Editar configuração"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
{onTestServer && (
|
||||
<button
|
||||
onClick={onTestServer}
|
||||
disabled={isTesting}
|
||||
className="px-3 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors text-sm mcp-install-button disabled:opacity-50"
|
||||
title="Testar servidor MCP"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
className="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button"
|
||||
title="Desinstalar MCP"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</>
|
||||
) : mcp.installed ? (
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
className="w-full px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button font-medium"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
className="w-full px-3 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors text-sm mcp-install-button font-medium"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{(mcp.repository || mcp.homepage) && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border)]">
|
||||
<div className="flex gap-2">
|
||||
{mcp.repository && (
|
||||
<a
|
||||
href={mcp.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Repository
|
||||
</a>
|
||||
)}
|
||||
{mcp.homepage && (
|
||||
<a
|
||||
href={mcp.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Homepage
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison para evitar re-renders desnecessários
|
||||
return (
|
||||
prevProps.mcp.id === nextProps.mcp.id &&
|
||||
prevProps.mcp.installed === nextProps.mcp.installed &&
|
||||
prevProps.mcp.rating === nextProps.mcp.rating &&
|
||||
prevProps.mcp.totalRatings === nextProps.mcp.totalRatings &&
|
||||
prevProps.mcp.name === nextProps.mcp.name &&
|
||||
prevProps.mcp.description === nextProps.mcp.description &&
|
||||
prevProps.mode === nextProps.mode &&
|
||||
prevProps.isTesting === nextProps.isTesting
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,356 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Package, Star, ExternalLink, CheckCircle } from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
import { MCPRatingStars } from "./MCPRatingStars";
|
||||
|
||||
interface MCPDetailModalProps {
|
||||
mcp: MCPProvider;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onInstall: () => void;
|
||||
}
|
||||
|
||||
export function MCPDetailModal({
|
||||
mcp,
|
||||
isOpen,
|
||||
onClose,
|
||||
onInstall,
|
||||
}: MCPDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"about" | "capabilities" | "screenshots" | "configuration"
|
||||
>("about");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "map":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
|
||||
case "browser":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||
case "office":
|
||||
return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
|
||||
case "search":
|
||||
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
|
||||
case "database":
|
||||
return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
|
||||
case "finance":
|
||||
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
|
||||
case "code":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||
case "chart":
|
||||
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
|
||||
case "payment":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-4xl h-[90vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl mcp-detail-modal">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-[var(--primary)]/10 rounded-lg">
|
||||
<Package className="h-8 w-8 text-[var(--primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{mcp.name}</h2>
|
||||
<p className="text-[var(--muted-foreground)]">by {mcp.author}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium mcp-category-badge ${getCategoryColor(
|
||||
mcp.category
|
||||
)}`}
|
||||
>
|
||||
{mcp.category}
|
||||
</span>
|
||||
{mcp.installed && (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Installed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="p-6 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<MCPRatingStars rating={mcp.rating} size="md" />
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
({mcp.totalRatings} reviews)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
Version {mcp.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-[var(--border)]">
|
||||
{[
|
||||
{ id: "about", label: "About" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "configuration", label: "Configuration" },
|
||||
{ id: "screenshots", label: "Screenshots" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-[var(--primary)] text-[var(--primary)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
{activeTab === "about" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Description</h3>
|
||||
<p className="text-[var(--muted-foreground)] leading-relaxed">
|
||||
{mcp.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mcp.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 bg-[var(--surface)] text-sm rounded-md border border-[var(--border)] mcp-tag"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(mcp.repository || mcp.homepage) && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Links</h3>
|
||||
<div className="flex gap-4">
|
||||
{mcp.repository && (
|
||||
<a
|
||||
href={mcp.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Repository
|
||||
</a>
|
||||
)}
|
||||
{mcp.homepage && (
|
||||
<a
|
||||
href={mcp.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Homepage
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "capabilities" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Capabilities</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(mcp.tools || []).map((capability, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-3 bg-[var(--surface)] rounded-md border border-[var(--border)]"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm">
|
||||
{typeof capability === "string"
|
||||
? capability
|
||||
: (capability as any).name || String(capability)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "configuration" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
Installation Configuration
|
||||
</h3>
|
||||
{mcp.config && mcp.config.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{mcp.config.map((configItem, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4"
|
||||
>
|
||||
<h4 className="font-medium mb-3">
|
||||
Configuration {index + 1}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{configItem.mcpServers && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Command
|
||||
</h5>
|
||||
<code className="block bg-[var(--background)] p-2 rounded text-sm">
|
||||
{configItem.mcpServers.command}{" "}
|
||||
{configItem.mcpServers.arguments?.join(" ")}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{configItem.mcpServers?.env &&
|
||||
Object.keys(configItem.mcpServers.env).length >
|
||||
0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Environment Variables
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(
|
||||
configItem.mcpServers.env
|
||||
).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
|
||||
{key}={String(value)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No configuration available for this MCP</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mcp.tools && mcp.tools.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
Available Tools
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{mcp.tools.map((tool, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4"
|
||||
>
|
||||
<h4 className="font-medium mb-2">{tool.name}</h4>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-3">
|
||||
{tool.description}
|
||||
</p>
|
||||
{tool.input_schema && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Parameters
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(
|
||||
tool.input_schema.properties || {}
|
||||
).map(([param, schema]) => (
|
||||
<div
|
||||
key={param}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
|
||||
{param}
|
||||
</code>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{typeof schema === "object" && schema
|
||||
? (schema as any).type || "any"
|
||||
: "any"}
|
||||
{tool.input_schema.required?.includes(
|
||||
param
|
||||
) && (
|
||||
<span className="text-red-500 ml-1">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "screenshots" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Screenshots</h3>
|
||||
<div className="text-center py-12 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No screenshots available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-[var(--border)]">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{!mcp.installed && (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
className="px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors mcp-install-button"
|
||||
>
|
||||
Install MCP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
X,
|
||||
Package,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
|
||||
interface MCPInstallConfigModalProps {
|
||||
mcp: MCPProvider;
|
||||
config: any; // Configuração inicial do servidor
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (editedConfig: any) => void;
|
||||
isEditMode?: boolean; // Novo prop para indicar modo de edição
|
||||
}
|
||||
|
||||
export function MCPInstallConfigModal({
|
||||
mcp,
|
||||
config,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isEditMode = false,
|
||||
}: MCPInstallConfigModalProps) {
|
||||
const [jsonString, setJsonString] = useState("");
|
||||
const [isValidJson, setIsValidJson] = useState(true);
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
const [hasEnvVars, setHasEnvVars] = useState(false);
|
||||
|
||||
// Inicializar JSON quando config mudar
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
try {
|
||||
const formattedJson = JSON.stringify(config, null, 2);
|
||||
setJsonString(formattedJson);
|
||||
setIsValidJson(true);
|
||||
setJsonError("");
|
||||
|
||||
// Verificar se tem variáveis de ambiente
|
||||
const hasEnv =
|
||||
JSON.stringify(config).includes('"env"') ||
|
||||
JSON.stringify(config).includes("env:");
|
||||
setHasEnvVars(hasEnv);
|
||||
} catch (error) {
|
||||
console.error("Error formatting config:", error);
|
||||
setJsonString("{}");
|
||||
setIsValidJson(false);
|
||||
setJsonError("Erro ao formatar configuração");
|
||||
}
|
||||
} else {
|
||||
// Config vazio - criar template baseado no MCP
|
||||
// Usar npm ou nome do repositório se disponível
|
||||
const serverName = mcp.id || mcp.name.toLowerCase().replace(/\s+/g, "-");
|
||||
const command = mcp.repository?.includes("npm")
|
||||
? "npx"
|
||||
: mcp.repository
|
||||
? "node"
|
||||
: "npm";
|
||||
|
||||
const template = {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
command: command,
|
||||
args: command === "npx" ? ["-y", serverName] : [],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setJsonString(JSON.stringify(template, null, 2));
|
||||
setIsValidJson(true);
|
||||
setJsonError("");
|
||||
setHasEnvVars(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Validar JSON em tempo real
|
||||
const validateJson = (value: string) => {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setIsValidJson(true);
|
||||
setJsonError("");
|
||||
return true;
|
||||
} catch (error) {
|
||||
setIsValidJson(false);
|
||||
setJsonError(error instanceof Error ? error.message : "JSON inválido");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonChange = (value: string) => {
|
||||
setJsonString(value);
|
||||
validateJson(value);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!isValidJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedConfig = JSON.parse(jsonString);
|
||||
onConfirm(parsedConfig);
|
||||
} catch (error) {
|
||||
console.error("Error parsing config:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-4xl h-[90vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-[var(--primary)]/10 rounded-lg">
|
||||
<Package className="h-8 w-8 text-[var(--primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{isEditMode ? "Editar Configuração" : "Configurar Instalação"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
{mcp.name} por {mcp.author}
|
||||
</p>
|
||||
{(mcp.repository || mcp.homepage) && (
|
||||
<a
|
||||
href={mcp.repository || mcp.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 transition-colors"
|
||||
title="Ver no GitHub"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span className="text-sm">GitHub</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="p-6 border-b border-[var(--border)] bg-blue-50 dark:bg-blue-900/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||
Instruções de Configuração
|
||||
</p>
|
||||
<ul className="text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>
|
||||
• <strong>Edite apenas os campos necessários</strong>,
|
||||
especialmente variáveis de ambiente (env)
|
||||
</li>
|
||||
<li>
|
||||
• Adicione suas <strong>API keys</strong>,{" "}
|
||||
<strong>tokens</strong> e <strong>credenciais</strong> nos
|
||||
campos env
|
||||
</li>
|
||||
<li>
|
||||
• Mantenha a estrutura JSON válida - use aspas duplas e
|
||||
vírgulas corretamente
|
||||
</li>
|
||||
<li>
|
||||
• Campos como "command" geralmente não precisam ser alterados
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Editor */}
|
||||
<div className="flex-1 p-6 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Configuração do Servidor</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isValidJson ? (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm">JSON válido</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">JSON inválido</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{!isValidJson && jsonError && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-red-700 dark:text-red-300">
|
||||
<p className="font-medium">Erro no JSON:</p>
|
||||
<p className="font-mono text-xs mt-1">{jsonError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON Textarea - Área maior */}
|
||||
<div className="flex-1 min-h-0 mb-4">
|
||||
<textarea
|
||||
value={jsonString}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
className="w-full h-full p-4 bg-[var(--surface)] border border-[var(--border)] rounded-md font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
placeholder="Digite a configuração JSON aqui..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Env Variables Warning */}
|
||||
{hasEnvVars && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p className="font-medium">
|
||||
Variáveis de Ambiente Detectadas
|
||||
</p>
|
||||
<p>
|
||||
Certifique-se de preencher os valores necessários nos campos
|
||||
"env" antes de instalar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-[var(--border)] flex-shrink-0">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!isValidJson}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isEditMode ? "Salvar Alterações" : "Confirmar Instalação"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Upload, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||
|
||||
interface MCPManualInstallModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (mcpId: string) => void;
|
||||
}
|
||||
|
||||
export function MCPManualInstallModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: MCPManualInstallModalProps) {
|
||||
const [configJson, setConfigJson] = useState("");
|
||||
const [mcpId, setMcpId] = useState("");
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [parsedConfig, setParsedConfig] = useState<any>(null);
|
||||
|
||||
// Validar JSON enquanto digita
|
||||
const handleConfigChange = (value: string) => {
|
||||
setConfigJson(value);
|
||||
setValidationError(null);
|
||||
setParsedConfig(null);
|
||||
|
||||
if (!value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
setParsedConfig(parsed);
|
||||
|
||||
// Validar estrutura básica
|
||||
if (parsed.mcpServers) {
|
||||
const serverNames = Object.keys(parsed.mcpServers);
|
||||
if (serverNames.length === 0) {
|
||||
setValidationError("No MCP server found in configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstServer = parsed.mcpServers[serverNames[0]];
|
||||
if (!firstServer.command) {
|
||||
setValidationError("Missing required field: command");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-preencher ID se estiver vazio
|
||||
if (!mcpId) {
|
||||
setMcpId(serverNames[0]);
|
||||
}
|
||||
} else {
|
||||
// Formato direto
|
||||
if (!parsed.command) {
|
||||
setValidationError("Missing required field: command");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setValidationError(`Invalid JSON: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!configJson.trim()) {
|
||||
setError("Configuration is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationError) {
|
||||
setError("Please fix validation errors before installing");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsInstalling(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/mcp/install-manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: parsedConfig,
|
||||
mcpId: mcpId || undefined,
|
||||
enableLogs: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`✅ Manual installation started for ${data.mcpId}`);
|
||||
onSuccess?.(data.mcpId);
|
||||
onClose();
|
||||
|
||||
// Reset form
|
||||
setConfigJson("");
|
||||
setMcpId("");
|
||||
setParsedConfig(null);
|
||||
} else {
|
||||
setError(data.error || "Failed to start installation");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error installing MCP:", err);
|
||||
setError(err.message || "Failed to start installation");
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteExample = () => {
|
||||
const example = `{
|
||||
"mcpServers": {
|
||||
"time-server": {
|
||||
"command": "npx",
|
||||
"args": ["@mcpcentral/mcp-time"]
|
||||
}
|
||||
}
|
||||
}`;
|
||||
setConfigJson(example);
|
||||
handleConfigChange(example);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[var(--background)] rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto border border-[var(--border)]">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 border-b border-[var(--border)] p-4 flex items-center justify-between bg-[color-mix(in_oklab,var(--background),black_2%)]/90 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_2%)]/70">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-[var(--accent)]" />
|
||||
<h2 className="text-xl font-semibold">
|
||||
Instalar MCP Manualmente
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Description */}
|
||||
<div className="rounded-lg p-4 border border-[var(--border)] bg-[var(--surface)]/60">
|
||||
<h3 className="font-medium mb-2">
|
||||
Como usar
|
||||
</h3>
|
||||
<p className="text-sm mb-3 text-[var(--foreground)]/80">
|
||||
Cole a configuração do servidor MCP no formato do Claude Desktop (claude_desktop_config.json).
|
||||
Apenas instalação manual é suportada neste projeto.
|
||||
</p>
|
||||
<button
|
||||
onClick={handlePasteExample}
|
||||
className="text-sm text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Clique aqui para carregar um exemplo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MCP ID (optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
MCP ID (opcional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mcpId}
|
||||
onChange={(e) => setMcpId(e.target.value)}
|
||||
placeholder="my-custom-mcp (detectado automaticamente se vazio)"
|
||||
className="w-full px-4 py-2 border border-[var(--border)] rounded-lg bg-[var(--background)] focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Configuration JSON */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
JSON de Configuração *
|
||||
</label>
|
||||
<textarea
|
||||
value={configJson}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
placeholder={`Cole a configuração aqui, ex.:
|
||||
{
|
||||
"mcpServers": {
|
||||
"time-server": {
|
||||
"command": "npx",
|
||||
"args": ["@mcpcentral/mcp-time"]
|
||||
}
|
||||
}
|
||||
}`}
|
||||
rows={15}
|
||||
className="w-full px-4 py-3 border border-[var(--border)] rounded-lg bg-[var(--background)] font-mono text-sm focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent resize-none"
|
||||
/>
|
||||
|
||||
{/* Validation Status */}
|
||||
{configJson.trim() && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{validationError ? (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span className="text-sm text-red-600">{validationError}</span>
|
||||
</>
|
||||
) : parsedConfig ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-green-600">Configuração válida</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{parsedConfig && !validationError && (
|
||||
<div className="border border-[var(--border)] rounded-lg p-4 bg-[var(--surface)]/50">
|
||||
<h3 className="font-medium mb-3">Pré-visualização</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{parsedConfig.mcpServers ? (
|
||||
Object.entries(parsedConfig.mcpServers).map(
|
||||
([name, config]: [string, any]) => (
|
||||
<div key={name} className="space-y-1">
|
||||
<div className="">
|
||||
<span className="font-medium">Server:</span> {name}
|
||||
</div>
|
||||
<div className="text-[var(--foreground)]/80">
|
||||
<span className="font-medium">Command:</span>{" "}
|
||||
{config.command} {(config.args || []).join(" ")}
|
||||
</div>
|
||||
{config.env && (
|
||||
<div className="text-[var(--foreground)]/80">
|
||||
<span className="font-medium">Environment:</span>{" "}
|
||||
{Object.keys(config.env).length} variables
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[var(--foreground)]/80">
|
||||
<span className="font-medium">Command:</span>{" "}
|
||||
{parsedConfig.command}{" "}
|
||||
{(parsedConfig.args || []).join(" ")}
|
||||
</div>
|
||||
{parsedConfig.env && (
|
||||
<div className="text-[var(--foreground)]/80">
|
||||
<span className="font-medium">Environment:</span>{" "}
|
||||
{Object.keys(parsedConfig.env).length} variables
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-900">Erro na instalação</h4>
|
||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-[var(--surface)]/60 border-t border-[var(--border)] p-4 flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isInstalling}
|
||||
className="px-4 py-2 hover:bg-[var(--surface)] rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling || !parsedConfig || !!validationError}
|
||||
className="px-6 py-2 bg-[var(--accent)] text-[var(--accent-foreground)] rounded-lg hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Instalando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4" />
|
||||
Instalar MCP
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
type MarkdownRendererProps = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
// Normalizar quebras de linha e espaços
|
||||
const normalizedContent = content
|
||||
.replace(/\n{3,}/g, "\n\n") // Máximo 2 quebras consecutivas
|
||||
.replace(/[ \t]+$/gm, "") // Remover espaços no final das linhas
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }: any) {
|
||||
// Garantir extração correta do texto do bloco, mesmo com quebras/indentação
|
||||
const node: any = (props as any).node;
|
||||
const rawChild = node?.children?.[0]?.value ?? children;
|
||||
const text = String(rawChild ?? "");
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className="rounded bg-[var(--surface)] px-1 py-0.5 text-[0.95em]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const cleaned = text.replace(/^\n+|\n+$/g, "");
|
||||
return (
|
||||
<div className="relative my-4 rounded-lg border border-[var(--border)] bg-[var(--background)]">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 rounded px-2 py-1 text-xs bg-[var(--surface)]/70 hover:bg-[var(--surface)] transition-colors"
|
||||
onClick={() => navigator.clipboard.writeText(cleaned)}
|
||||
aria-label="Copiar código"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
<pre className="overflow-auto p-3">
|
||||
<code className={`${className || ""} font-mono text-[var(--foreground)] whitespace-pre`} {...props}>
|
||||
{cleaned}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
h1: ({ children }: any) => (
|
||||
<h1 className="text-xl font-bold mt-4 mb-2 first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: any) => (
|
||||
<h2 className="text-lg font-semibold mt-3 mb-2">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: any) => (
|
||||
<h3 className="text-base font-semibold mt-3 mb-1">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: any) => (
|
||||
<h4 className="text-sm font-semibold mt-2 mb-1">{children}</h4>
|
||||
),
|
||||
h5: ({ children }: any) => (
|
||||
<h5 className="text-sm font-medium mt-2 mb-1">{children}</h5>
|
||||
),
|
||||
h6: ({ children }: any) => (
|
||||
<h6 className="text-xs font-medium mt-2 mb-1">{children}</h6>
|
||||
),
|
||||
p: ({ children }: any) => (
|
||||
<p className="mb-3 leading-relaxed">{children}</p>
|
||||
),
|
||||
ul: ({ children }: any) => (
|
||||
<ul className="mb-3 ml-4 list-disc space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: any) => (
|
||||
<ol className="mb-3 ml-4 list-decimal space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }: any) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }: any) => (
|
||||
<blockquote className="border-l-4 border-[var(--border)] pl-4 my-3 italic text-[var(--foreground)]/80">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-4 border-[var(--border)]" />,
|
||||
br: () => <br className="mb-1" />,
|
||||
}}
|
||||
>
|
||||
{normalizedContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Edit3, RefreshCw, Check, ChevronDown } from "lucide-react";
|
||||
import { useCopyPrefs } from "@/hooks/useCopyPrefs";
|
||||
import { stripMarkdown } from "@/hooks/useMarkdown";
|
||||
|
||||
type MessageActionsProps = {
|
||||
content: string;
|
||||
disabled?: boolean;
|
||||
onEdit?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
};
|
||||
|
||||
export function MessageActions({
|
||||
content,
|
||||
disabled,
|
||||
onEdit,
|
||||
onRegenerate,
|
||||
}: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { copyMode, setCopyMode } = useCopyPrefs();
|
||||
|
||||
const copy = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const handleCopyDefault = () => {
|
||||
const textToCopy =
|
||||
copyMode === "markdown" ? content : stripMarkdown(content);
|
||||
copy(textToCopy);
|
||||
};
|
||||
|
||||
const handleModeSelect = (mode: "markdown" | "text") => {
|
||||
setCopyMode(mode);
|
||||
const textToCopy = mode === "markdown" ? content : stripMarkdown(content);
|
||||
copy(textToCopy);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 opacity-70 pointer-events-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={handleCopyDefault}
|
||||
disabled={disabled}
|
||||
aria-label="Copiar"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
disabled={disabled}
|
||||
aria-label="Selecionar formato"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 z-10 mt-1 rounded border border-[var(--border)] bg-[var(--background)] shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => handleModeSelect("markdown")}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => handleModeSelect("text")}
|
||||
>
|
||||
Texto
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={onEdit}
|
||||
disabled={disabled}
|
||||
aria-label="Edit message"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" /> Edit
|
||||
</button>
|
||||
)}
|
||||
{onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={onRegenerate}
|
||||
disabled={disabled}
|
||||
aria-label="Regenerate"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" /> Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null;
|
||||
const isDark =
|
||||
theme === "dark" ||
|
||||
(!theme &&
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
return (
|
||||
<button
|
||||
aria-label="Alternar tema"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 text-sm transition-colors hover:bg-[color-mix(in_oklab,var(--surface),black_6%)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">{isDark ? "Light" : "Dark"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Cpu, CircuitBoard, Info } from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ModelCard({ model }: { model: ModelInfo }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const isGpu = model.device === "GPU";
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onFocus={() => setHover(true)}
|
||||
onBlur={() => setHover(false)}
|
||||
className="group relative rounded-lg border border-[var(--border)] bg-[var(--background)] p-4 shadow-sm transition-all hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-semibold tracking-tight">
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--foreground)]/80">
|
||||
{isGpu ? (
|
||||
<CircuitBoard className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{model.device}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--foreground)]/80">
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.quantization}
|
||||
</span>
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.sizeGB} GB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute right-3 top-3 opacity-60">
|
||||
<Info className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{hover && (
|
||||
<div className="absolute left-0 top-full z-10 mt-2 w-72 rounded-md border border-[var(--border)] bg-[var(--background)] p-3 text-sm shadow-lg">
|
||||
<div className="mb-2 font-medium">Detalhes (estimativa)</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Uso de CPU</span>
|
||||
<span className="font-medium">{model.estCpuUsagePct ?? "—"}%</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span>Uso de VRAM</span>
|
||||
<span className="font-medium">{model.estVramGB ?? 0} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Cpu,
|
||||
CircuitBoard,
|
||||
Search,
|
||||
Filter,
|
||||
Zap,
|
||||
HardDrive,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
X,
|
||||
Download,
|
||||
Trash2,
|
||||
Cloud,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
|
||||
interface ModelDropdownProps {
|
||||
models: ModelInfo[];
|
||||
selectedModel: string | null;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
onDeleteModel?: (modelId: string) => void;
|
||||
onPullModel?: (modelName: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ModelDropdown({
|
||||
models,
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
onDeleteModel,
|
||||
onPullModel,
|
||||
disabled = false,
|
||||
}: ModelDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterDevice, setFilterDevice] = useState<"all" | "CPU" | "GPU">(
|
||||
"all"
|
||||
);
|
||||
const [showRemote, setShowRemote] = useState(false);
|
||||
const [remoteModels, setRemoteModels] = useState<any[]>([]);
|
||||
const [isSearchingRemote, setIsSearchingRemote] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Buscar modelos remotos
|
||||
const searchRemoteModels = async (query: string) => {
|
||||
setIsSearchingRemote(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/ollama/search?q=${encodeURIComponent(query)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setRemoteModels(data.models);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar modelos remotos:", error);
|
||||
} finally {
|
||||
setIsSearchingRemote(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Deletar modelo
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/delete", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelName: modelId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onDeleteModel?.(modelId);
|
||||
setShowDeleteConfirm(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar modelo:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Baixar modelo remoto
|
||||
const handlePullModel = (modelName: string) => {
|
||||
onPullModel?.(modelName);
|
||||
};
|
||||
|
||||
// Filtrar modelos
|
||||
const filteredModels = models.filter((model) => {
|
||||
const matchesSearch = model.name
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase());
|
||||
const matchesDevice =
|
||||
filterDevice === "all" || model.device === filterDevice;
|
||||
return matchesSearch && matchesDevice;
|
||||
});
|
||||
|
||||
// Categorizar modelos por performance
|
||||
const categorizeModel = (model: ModelInfo) => {
|
||||
if (model.sizeGB <= 3) return "compact";
|
||||
if (model.sizeGB <= 7) return "balanced";
|
||||
return "powerful";
|
||||
};
|
||||
|
||||
const getPerformanceColor = (model: ModelInfo) => {
|
||||
const category = categorizeModel(model);
|
||||
switch (category) {
|
||||
case "compact":
|
||||
return "text-green-600 bg-green-50 border-green-200";
|
||||
case "balanced":
|
||||
return "text-blue-600 bg-blue-50 border-blue-200";
|
||||
case "powerful":
|
||||
return "text-purple-600 bg-purple-50 border-purple-200";
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const getPerformanceIcon = (model: ModelInfo) => {
|
||||
const category = categorizeModel(model);
|
||||
switch (category) {
|
||||
case "compact":
|
||||
return <Zap className="h-3 w-3" />;
|
||||
case "balanced":
|
||||
return <TrendingUp className="h-3 w-3" />;
|
||||
case "powerful":
|
||||
return <CircuitBoard className="h-3 w-3" />;
|
||||
default:
|
||||
return <Cpu className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setFilterDevice("all");
|
||||
setShowRemote(false);
|
||||
setRemoteModels([]);
|
||||
setShowDeleteConfirm(null);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Buscar modelos remotos quando necessário
|
||||
useEffect(() => {
|
||||
if (showRemote && searchTerm.length > 2) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
searchRemoteModels(searchTerm);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [showRemote, searchTerm]);
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedModelData ? (
|
||||
<>
|
||||
<span className="truncate font-medium">
|
||||
{selectedModelData.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
|
||||
selectedModelData
|
||||
)}`}
|
||||
>
|
||||
{getPerformanceIcon(selectedModelData)}
|
||||
{categorizeModel(selectedModelData)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-[var(--surface)] px-2 py-0.5 text-xs">
|
||||
{selectedModelData.device === "GPU" ? (
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
) : (
|
||||
<Cpu className="h-3 w-3" />
|
||||
)}
|
||||
{selectedModelData.device}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--foreground)]/60">
|
||||
Select a model...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full z-50 mt-1 w-full min-w-[400px] rounded-lg border border-[var(--border)] bg-[var(--background)] shadow-xl">
|
||||
{/* Header com busca e filtros */}
|
||||
<div className="border-b border-[var(--border)] p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--foreground)]/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] pl-8 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--foreground)]/40 hover:text-[var(--foreground)]/60"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="h-4 w-4 text-[var(--foreground)]/40" />
|
||||
<select
|
||||
value={filterDevice}
|
||||
onChange={(e) =>
|
||||
setFilterDevice(e.target.value as "all" | "CPU" | "GPU")
|
||||
}
|
||||
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--accent)]"
|
||||
>
|
||||
<option value="all">All Devices</option>
|
||||
<option value="CPU">CPU Only</option>
|
||||
<option value="GPU">GPU Only</option>
|
||||
</select>
|
||||
{/* Instalar por referência */}
|
||||
{onPullModel && (
|
||||
<button
|
||||
onClick={() => handlePullModel("")}
|
||||
className="rounded-md bg-[var(--accent)] text-white px-2 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||
title="Instalar por referência (ex.: owner/model:tag)"
|
||||
>
|
||||
Install by ref
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle para busca remota */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowRemote(!showRemote)}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
|
||||
showRemote
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--surface)] text-[var(--foreground)]/60 hover:bg-[var(--surface)]/80"
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-3 w-3" />
|
||||
Remote Search
|
||||
</button>
|
||||
<div className="text-xs text-[var(--foreground)]/60">
|
||||
{showRemote
|
||||
? `${remoteModels.length} remote`
|
||||
: `${filteredModels.length} local`}{" "}
|
||||
model
|
||||
{(showRemote
|
||||
? remoteModels.length
|
||||
: filteredModels.length) !== 1
|
||||
? "s"
|
||||
: ""}{" "}
|
||||
found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de modelos com scroll customizado */}
|
||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{showRemote ? (
|
||||
// Lista de modelos remotos
|
||||
<>
|
||||
{isSearchingRemote ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--accent)] mx-auto mb-2"></div>
|
||||
Searching remote models...
|
||||
</div>
|
||||
) : remoteModels.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<Cloud className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No remote models found
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{remoteModels.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 mb-2"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{model.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-blue-50 text-blue-600 border border-blue-200 px-2 py-0.5 text-xs">
|
||||
<Cloud className="h-3 w-3" />
|
||||
Remote
|
||||
</span>
|
||||
{model.installed && (
|
||||
<span className="flex items-center gap-1 rounded-md bg-green-50 text-green-600 border border-green-200 px-2 py-0.5 text-xs">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-[var(--foreground)]/50">
|
||||
Available for download
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePullModel(model.name)}
|
||||
disabled={model.installed}
|
||||
className="flex items-center gap-1 rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{model.installed ? "Installed" : "Download"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Lista de modelos locais
|
||||
<>
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<Search className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No models found
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`w-full rounded-lg border p-3 mb-2 transition-all hover:shadow-md ${
|
||||
selectedModel === model.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/5"
|
||||
: "border-[var(--border)] bg-[var(--background)] hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{model.name}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
|
||||
model
|
||||
)}`}
|
||||
>
|
||||
{getPerformanceIcon(model)}
|
||||
{categorizeModel(model)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60">
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{model.sizeGB} GB
|
||||
</span>
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.quantization}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{model.device === "GPU" ? (
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
) : (
|
||||
<Cpu className="h-3 w-3" />
|
||||
)}
|
||||
{model.device}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informações técnicas e ações */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--foreground)]/50">
|
||||
{model.estCpuUsagePct && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
<span>{model.estCpuUsagePct}% CPU</span>
|
||||
</div>
|
||||
)}
|
||||
{model.estVramGB && model.estVramGB > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
<span>{model.estVramGB} GB VRAM</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSelectModel(model.id)}
|
||||
className="rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
{onDeleteModel && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(model.id)}
|
||||
className="rounded-md bg-red-50 text-red-600 border border-red-200 px-3 py-1 text-xs hover:bg-red-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmação de exclusão */}
|
||||
{showDeleteConfirm === model.id && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="text-xs text-red-700 mb-2">
|
||||
Are you sure you want to delete this model?
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDeleteModel(model.id)}
|
||||
className="rounded-md bg-red-600 text-white px-2 py-1 text-xs hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(null)}
|
||||
className="rounded-md bg-gray-200 text-gray-700 px-2 py-1 text-xs hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ModelCard } from "./ModelCard";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
|
||||
export function ModelList({ models }: { models: ModelInfo[] }) {
|
||||
if (!models?.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] p-6 text-sm text-[var(--foreground)]/70">
|
||||
Nenhum modelo encontrado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{models.map((m) => (
|
||||
<ModelCard key={m.id} model={m} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Download, Loader2 } from "lucide-react";
|
||||
|
||||
interface ModelPullDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
modelName: string;
|
||||
onConfirmPull: (modelName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface PullProgress {
|
||||
status: string;
|
||||
digest?: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function ModelPullDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
modelName,
|
||||
onConfirmPull,
|
||||
}: ModelPullDialogProps) {
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [progress, setProgress] = useState<PullProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSuccess, setIsSuccess] = useState<boolean>(false);
|
||||
const [editableName, setEditableName] = useState<string>(modelName);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsPulling(false);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
setIsSuccess(false);
|
||||
setEditableName(modelName);
|
||||
setTags([]);
|
||||
setIsLoadingTags(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ao abrir, tentar buscar tags conhecidas
|
||||
const base = String(modelName).split(":")[0];
|
||||
setEditableName(modelName);
|
||||
if (!base) {
|
||||
setTags([]);
|
||||
setIsLoadingTags(false);
|
||||
return;
|
||||
}
|
||||
setIsLoadingTags(true);
|
||||
fetch(`/api/ollama/tags?name=${encodeURIComponent(base)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data?.success && Array.isArray(data.tags)) {
|
||||
setTags(data.tags);
|
||||
} else {
|
||||
setTags([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setTags([]))
|
||||
.finally(() => setIsLoadingTags(false));
|
||||
}, [isOpen, modelName]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsPulling(true);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ollama/pull", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: editableName.trim() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Falha ao iniciar download do modelo");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Resposta inválida do servidor");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
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);
|
||||
|
||||
// Processar diferentes estados de progresso
|
||||
if (data.status === "downloading" || data.status === "pulling") {
|
||||
setProgress({
|
||||
status: data.status,
|
||||
total: data.total,
|
||||
completed: data.completed,
|
||||
digest: data.digest,
|
||||
});
|
||||
} else if (data.status === "success") {
|
||||
setProgress((prev) => ({
|
||||
status: "success",
|
||||
total: prev?.total,
|
||||
completed: prev?.total
|
||||
}));
|
||||
setIsSuccess(true);
|
||||
} else if (data.error) {
|
||||
setError(data.detail || data.error);
|
||||
} else if (data.total || data.completed) {
|
||||
// Caso o status não seja "downloading" mas tenha dados de progresso
|
||||
setProgress({
|
||||
status: data.status || "downloading",
|
||||
total: data.total,
|
||||
completed: data.completed,
|
||||
digest: data.digest,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignorar linhas inválidas
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Não fechar automaticamente; permitir usuário fechar ao concluir
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to pull model");
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (n?: number) => {
|
||||
if (!n || n <= 0) return "0 MB";
|
||||
const mb = n / 1024 / 1024;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const percent = progress?.total && progress?.completed
|
||||
? Math.max(0, Math.min(100, Math.round((progress.completed / progress.total) * 100)))
|
||||
: 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md rounded-lg border border-[var(--border)] bg-[var(--background)] p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Install Model</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isPulling}
|
||||
className="rounded-md p-1 transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-2">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--foreground)]/80 mb-2">
|
||||
Confirme o identificador do modelo (nome[:tag]):
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={editableName}
|
||||
onChange={(e) => setEditableName(e.target.value)}
|
||||
disabled={isPulling}
|
||||
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
placeholder="ex.: llama3:8b-instruct"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xs text-[var(--foreground)]/60">Tags disponíveis</p>
|
||||
{isLoadingTags && (
|
||||
<span className="text-xs text-[var(--foreground)]/50">Carregando…</span>
|
||||
)}
|
||||
</div>
|
||||
{tags.length > 0 ? (
|
||||
<select
|
||||
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm"
|
||||
onChange={(e) => {
|
||||
const base = String(modelName).split(":")[0];
|
||||
const tag = e.target.value;
|
||||
setEditableName(`${base}:${tag}`);
|
||||
}}
|
||||
defaultValue=""
|
||||
disabled={isPulling}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Selecione uma tag (opcional)
|
||||
</option>
|
||||
{tags.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--foreground)]/50">
|
||||
Nenhuma tag detectada automaticamente. Você pode digitar manualmente.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Isso fará o download do modelo na sua instalação local do Ollama.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{progress && (
|
||||
<div className="mb-4 rounded-lg bg-[var(--surface)] p-3">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
{!isSuccess && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isSuccess ? "Download concluído" : "Baixando modelo..."}
|
||||
</span>
|
||||
{progress.total && progress.completed && <span>{percent}%</span>}
|
||||
</div>
|
||||
<div className="w-full bg-[var(--border)] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-2 transition-all duration-300 ${
|
||||
isSuccess ? "bg-green-500" : "bg-[var(--accent)]"
|
||||
}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress.total && progress.completed ? (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-[var(--foreground)]/60">
|
||||
<span>
|
||||
{formatBytes(progress.completed)} / {formatBytes(progress.total)}
|
||||
</span>
|
||||
{progress?.digest && (
|
||||
<span>sha256: {progress.digest.slice(0, 12)}...</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-xs text-[var(--foreground)]/60">
|
||||
Aguardando informações de progresso...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isPulling}
|
||||
className="px-4 py-2 text-sm rounded-md border border-[var(--border)] transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
{isSuccess ? "Fechar" : "Cancelar"}
|
||||
</button>
|
||||
{!isSuccess && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isPulling}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||
>
|
||||
{isPulling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Install
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Settings } from "lucide-react";
|
||||
import { useUserPrefs, type DevicePref } from "@/hooks/useUserPrefs";
|
||||
|
||||
export function SettingsButton({ onOpen }: { onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpen}
|
||||
aria-label="Open settings"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-[var(--surface)] px-3 text-sm transition-colors hover:bg-[color-mix(in_oklab,var(--surface),black_6%)] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Settings</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { prefs, ready, update } = useUserPrefs();
|
||||
const [tab, setTab] = useState<"general" | "execution" | "device">("general");
|
||||
const [localPrompt, setLocalPrompt] = useState("");
|
||||
const [device, setDevice] = useState<DevicePref>("auto");
|
||||
const [gpuIndex, setGpuIndex] = useState(0);
|
||||
const [numGpu, setNumGpu] = useState(1);
|
||||
const [chatLayout, setChatLayout] = useState<"compact" | "edge">("compact");
|
||||
const [bubbleSize, setBubbleSize] = useState<"sm" | "md" | "lg">("md");
|
||||
const [sysInfo, setSysInfo] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
setLocalPrompt(prefs.systemPrompt);
|
||||
setDevice(prefs.device);
|
||||
setGpuIndex(prefs.gpuIndex);
|
||||
setNumGpu(prefs.numGpu);
|
||||
setChatLayout((prefs as any).chatLayout ?? "compact");
|
||||
setBubbleSize((prefs as any).bubbleSize ?? "md");
|
||||
}, [ready, prefs]);
|
||||
|
||||
const handleSave = () => {
|
||||
update({ systemPrompt: localPrompt, device, gpuIndex, numGpu, chatLayout, bubbleSize });
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-xl rounded-lg border border-border bg-background p-4 shadow-lg">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 hover:bg-[var(--surface)]"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2 border-b border-border">
|
||||
<button
|
||||
className={`px-3 py-2 text-sm ${
|
||||
tab === "general"
|
||||
? "border-b-2 border-accent font-medium"
|
||||
: "text-[var(--foreground)]/60"
|
||||
}`}
|
||||
onClick={() => setTab("general")}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 text-sm ${
|
||||
tab === "execution"
|
||||
? "border-b-2 border-accent font-medium"
|
||||
: "text-[var(--foreground)]/60"
|
||||
}`}
|
||||
onClick={() => setTab("execution")}
|
||||
>
|
||||
Execution
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 text-sm ${
|
||||
tab === "device"
|
||||
? "border-b-2 border-accent font-medium"
|
||||
: "text-[var(--foreground)]/60"
|
||||
}`}
|
||||
onClick={() => setTab("device")}
|
||||
>
|
||||
Device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "general" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">System Prompt</label>
|
||||
<textarea
|
||||
value={localPrompt}
|
||||
onChange={(e) => setLocalPrompt(e.target.value)}
|
||||
placeholder="Define how the AI should behave..."
|
||||
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Sent at the start of a conversation to guide model behavior.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Chat layout</label>
|
||||
<select
|
||||
value={chatLayout}
|
||||
onChange={(e) => setChatLayout(e.target.value as any)}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="compact">Compact (centered)</option>
|
||||
<option value="edge">Edge-to-edge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Bubble size</label>
|
||||
<select
|
||||
value={bubbleSize}
|
||||
onChange={(e) => setBubbleSize(e.target.value as any)}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="sm">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "execution" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Device</label>
|
||||
<div className="flex gap-2">
|
||||
{(["auto", "cpu", "gpu"] as DevicePref[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDevice(d)}
|
||||
className={`rounded-md border border-border px-3 py-1 text-sm ${
|
||||
device === d
|
||||
? "bg-[var(--surface)]"
|
||||
: "hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
>
|
||||
{d.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
CPU best-effort via num_gpu:0. For hard CPU-only, set
|
||||
CUDA_VISIBLE_DEVICES=-1 before starting Ollama.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
GPU Index (main_gpu)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={gpuIndex}
|
||||
onChange={(e) => setGpuIndex(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
Num GPUs (num_gpu)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={numGpu}
|
||||
onChange={(e) => setNumGpu(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Auto: no options sent. GPU: sends num_gpu/main_gpu. CPU: sends
|
||||
num_gpu:0.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "device" && (
|
||||
<DeviceStatus
|
||||
sysInfo={sysInfo}
|
||||
setSysInfo={setSysInfo}
|
||||
device={device}
|
||||
gpuIndex={gpuIndex}
|
||||
setDevice={setDevice}
|
||||
setGpuIndex={setGpuIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-[var(--surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm text-white hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceStatus({ sysInfo, setSysInfo, device, gpuIndex, setDevice, setGpuIndex }: { sysInfo: any; setSysInfo: (v: any) => void; device: DevicePref; gpuIndex: number; setDevice: (d: DevicePref) => void; setGpuIndex: (i: number) => void; }) {
|
||||
// polling a cada 2s
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const load = () => {
|
||||
fetch("/api/system/status")
|
||||
.then((r) => r.json())
|
||||
.then((d) => mounted && d.success && setSysInfo(d.info))
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 2000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [setSysInfo]);
|
||||
|
||||
// Defaultar GPU 0 quando houver GPU e usuário escolher GPU
|
||||
useEffect(() => {
|
||||
const gpus = Array.isArray(sysInfo?.gpus) ? sysInfo.gpus : [];
|
||||
if (device === "gpu" && gpus.length > 0) {
|
||||
if (typeof gpuIndex !== "number" || gpuIndex < 0 || gpuIndex >= gpus.length) {
|
||||
setGpuIndex(0);
|
||||
}
|
||||
}
|
||||
}, [device, gpuIndex, sysInfo, setGpuIndex]);
|
||||
|
||||
if (!sysInfo) {
|
||||
return <div className="text-sm text-[var(--foreground)]/60">Loading device info...</div>;
|
||||
}
|
||||
|
||||
const memPct = sysInfo.memory.total > 0 ? (sysInfo.memory.used / sysInfo.memory.total) * 100 : 0;
|
||||
const memModules = Array.isArray(sysInfo.memory.modules) ? sysInfo.memory.modules : [];
|
||||
const gpus = Array.isArray(sysInfo.gpus) ? sysInfo.gpus : [];
|
||||
const hasGpu = gpus.length > 0;
|
||||
const currentGpu = hasGpu ? Math.min(Math.max(0, gpuIndex || 0), gpus.length - 1) : 0;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<div className="font-medium mb-1">Sistema</div>
|
||||
<div>OS: {sysInfo.os?.platform || sysInfo.platform} {sysInfo.os?.release || sysInfo.release}</div>
|
||||
<div>Arch: {sysInfo.os?.arch || sysInfo.arch}</div>
|
||||
<div>CPU: {sysInfo.cpu?.model} ({sysInfo.cpu?.cores} cores)</div>
|
||||
<div>Uptime: {Math.round(sysInfo.uptimeSec / 60)} min</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<div className="font-medium mb-1">Carga</div>
|
||||
<div>Load (1m/5m/15m): {Number(sysInfo.metrics?.loadAvg?.["1m"] ?? 0).toFixed(2)} / {Number(sysInfo.metrics?.loadAvg?.["5m"] ?? 0).toFixed(2)} / {Number(sysInfo.metrics?.loadAvg?.["15m"] ?? 0).toFixed(2)}</div>
|
||||
<div>Memória: {(sysInfo.memory.used/1024/1024).toFixed(0)}MB / {(sysInfo.memory.total/1024/1024).toFixed(0)}MB ({memPct.toFixed(0)}%)</div>
|
||||
<div>Módulos de RAM: {memModules.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="font-medium mb-1">Aplicativo</div>
|
||||
<div>RSS: {(sysInfo.metrics?.process?.rss/1024/1024).toFixed(0)}MB | Heap: {(sysInfo.metrics?.process?.heapUsed/1024/1024).toFixed(0)}MB / {(sysInfo.metrics?.process?.heapTotal/1024/1024).toFixed(0)}MB</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="font-medium mb-2">Placas de vídeo</div>
|
||||
{hasGpu ? (
|
||||
<div className="space-y-2">
|
||||
{gpus.map((g: any) => (
|
||||
<div key={g.index} className="flex items-center justify-between rounded-md border border-border p-2">
|
||||
<div>
|
||||
<div className="font-medium">{g.name}</div>
|
||||
<div className="text-[var(--foreground)]/70">{g.vendor || ""} {g.vramMB ? `• ${g.vramMB}MB VRAM` : ""} {g.bus ? `• ${g.bus}` : ""}</div>
|
||||
</div>
|
||||
<div className="text-xs">index: {g.index}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[var(--foreground)]/60">Nenhuma GPU detectada</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="font-medium mb-2">Dispositivo principal</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input type="radio" name="dev" checked={device === "cpu"} onChange={() => setDevice("cpu")}/> CPU
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input type="radio" name="dev" checked={device === "gpu"} onChange={() => setDevice("gpu")} disabled={!hasGpu}/> GPU
|
||||
</label>
|
||||
<select
|
||||
disabled={device !== "gpu" || !hasGpu}
|
||||
value={currentGpu}
|
||||
onChange={(e) => setGpuIndex(Number(e.target.value))}
|
||||
className="mt-1 w-full md:w-auto rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
{gpus.map((g: any) => (
|
||||
<option key={g.index} value={g.index}>{g.index}: {g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-[var(--foreground)]/60">Se houver GPU, ela será o padrão quando selecionar GPU.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--foreground)]/60">Para métricas detalhadas por modelo/GPU, integrações com o servidor de modelos serão adicionadas depois.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -1,967 +0,0 @@
|
||||
{
|
||||
"metadata": {
|
||||
"source": "ollama.com/library + buscas: uncensored, abliterated, code, reasoning",
|
||||
"collection_date": "2025-10-29",
|
||||
"total_models": 270,
|
||||
"note": "Integração de modelos oficiais + comunitários (uncensored/abliterated variants). Modelos de reasoning incluem variantes com foco em raciocínio (ex: CoT, logical deduction). Duplicatas removidas; métricas atualizadas onde possível. API para lista remota ainda limitada (ver GitHub #8241)."
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "gpt-oss",
|
||||
"description": "OpenAI's open-weight models designed for powerful reasoning, agentic tasks, and versatile developer use cases",
|
||||
"capabilities": ["tools", "thinking", "cloud"],
|
||||
"parameter_sizes": ["20b", "120b"],
|
||||
"pulls": "3.8M",
|
||||
"tags_count": 5,
|
||||
"last_updated": "2 weeks ago",
|
||||
"url": "https://ollama.com/library/gpt-oss"
|
||||
},
|
||||
{
|
||||
"name": "qwen3-vl",
|
||||
"description": "The most powerful vision-language model in the Qwen model family to date",
|
||||
"capabilities": ["vision", "cloud"],
|
||||
"parameter_sizes": ["2b", "4b", "8b", "30b", "32b"],
|
||||
"pulls": "22.8K",
|
||||
"tags_count": 49,
|
||||
"last_updated": "13 minutes ago",
|
||||
"url": "https://ollama.com/library/qwen3-vl"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-r1",
|
||||
"description": "DeepSeek-R1 is a family of open reasoning models with performance approaching that of leading models, such as O3 and Gemini 2.5 Pro",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["1.5b", "7b", "8b", "14b", "32b", "70b", "671b"],
|
||||
"pulls": "68.4M",
|
||||
"tags_count": 35,
|
||||
"last_updated": "3 months ago",
|
||||
"url": "https://ollama.com/library/deepseek-r1"
|
||||
},
|
||||
{
|
||||
"name": "qwen3-coder",
|
||||
"description": "Alibaba's performant long context models for agentic and coding tasks",
|
||||
"capabilities": ["tools", "cloud"],
|
||||
"parameter_sizes": ["30b", "480b"],
|
||||
"pulls": "575.7K",
|
||||
"tags_count": 10,
|
||||
"last_updated": "1 month ago",
|
||||
"url": "https://ollama.com/library/qwen3-coder"
|
||||
},
|
||||
{
|
||||
"name": "gemma3",
|
||||
"description": "The current, most capable model that runs on a single GPU",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["270m", "1b", "4b", "12b", "27b"],
|
||||
"pulls": "23.1M",
|
||||
"tags_count": 26,
|
||||
"last_updated": "2 months ago",
|
||||
"url": "https://ollama.com/library/gemma3"
|
||||
},
|
||||
{
|
||||
"name": "glm-4.6",
|
||||
"description": "Advanced agentic, reasoning and coding capabilities",
|
||||
"capabilities": ["cloud"],
|
||||
"parameter_sizes": [],
|
||||
"pulls": "11.8K",
|
||||
"tags_count": 1,
|
||||
"last_updated": "2 weeks ago",
|
||||
"url": "https://ollama.com/library/glm-4.6"
|
||||
},
|
||||
{
|
||||
"name": "embeddinggemma",
|
||||
"description": "EmbeddingGemma is a 300M parameter embedding model from Google",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["300m"],
|
||||
"pulls": "111.2K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "1 month ago",
|
||||
"url": "https://ollama.com/library/embeddinggemma"
|
||||
},
|
||||
{
|
||||
"name": "qwen3",
|
||||
"description": "Qwen3 is the latest generation of large language models in Qwen series, offering a comprehensive suite of dense and mixture-of-experts (MoE) models",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["0.6b", "1.7b", "4b", "8b", "14b", "30b", "32b", "235b"],
|
||||
"pulls": "11.7M",
|
||||
"tags_count": 58,
|
||||
"last_updated": "2 weeks ago",
|
||||
"url": "https://ollama.com/library/qwen3"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-v3.1",
|
||||
"description": "DeepSeek-V3.1-Terminus is a hybrid model that supports both thinking mode and non-thinking mode",
|
||||
"capabilities": ["tools", "thinking", "cloud"],
|
||||
"parameter_sizes": ["671b"],
|
||||
"pulls": "120.6K",
|
||||
"tags_count": 8,
|
||||
"last_updated": "1 month ago",
|
||||
"url": "https://ollama.com/library/deepseek-v3.1"
|
||||
},
|
||||
{
|
||||
"name": "llama3.1",
|
||||
"description": "Llama 3.1 is a new state-of-the-art model from Meta available in 8B, 70B and 405B parameter sizes",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8b", "70b", "405b"],
|
||||
"pulls": "105.1M",
|
||||
"tags_count": 93,
|
||||
"last_updated": "11 months ago",
|
||||
"url": "https://ollama.com/library/llama3.1"
|
||||
},
|
||||
{
|
||||
"name": "nomic-embed-text",
|
||||
"description": "A high-performing open embedding model with a large token context window",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": [],
|
||||
"pulls": "44.3M",
|
||||
"tags_count": 3,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/nomic-embed-text"
|
||||
},
|
||||
{
|
||||
"name": "llama3.2",
|
||||
"description": "Meta's Llama 3.2 goes small with 1B and 3B models",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1b", "3b"],
|
||||
"pulls": "42.6M",
|
||||
"tags_count": 63,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama3.2"
|
||||
},
|
||||
{
|
||||
"name": "mistral",
|
||||
"description": "The 7B model released by Mistral AI, updated to version 0.3",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "21.3M",
|
||||
"tags_count": 84,
|
||||
"last_updated": "3 months ago",
|
||||
"url": "https://ollama.com/library/mistral"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5",
|
||||
"description": "Qwen2.5 models are pretrained on Alibaba's latest large-scale dataset, encompassing up to 18 trillion tokens. The model supports up to 128K tokens and has multilingual support",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["0.5b", "1.5b", "3b", "7b", "14b", "32b", "72b"],
|
||||
"pulls": "15.8M",
|
||||
"tags_count": 133,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/qwen2.5"
|
||||
},
|
||||
{
|
||||
"name": "phi3",
|
||||
"description": "Phi-3 is a family of lightweight 3B (Mini) and 14B (Medium) state-of-the-art open models by Microsoft",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["3.8b", "14b"],
|
||||
"pulls": "12.5M",
|
||||
"tags_count": 72,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/phi3"
|
||||
},
|
||||
{
|
||||
"name": "llama3",
|
||||
"description": "Meta Llama 3: The most capable openly available LLM to date",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b", "70b"],
|
||||
"pulls": "11.5M",
|
||||
"tags_count": 68,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama3"
|
||||
},
|
||||
{
|
||||
"name": "llava",
|
||||
"description": "LLaVA is a novel end-to-end trained large multimodal model that combines a vision encoder and Vicuna for general-purpose visual and language understanding. Updated to version 1.6",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["7b", "13b", "34b"],
|
||||
"pulls": "11.1M",
|
||||
"tags_count": 98,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llava"
|
||||
},
|
||||
{
|
||||
"name": "gemma2",
|
||||
"description": "Google Gemma 2 is a high-performing and efficient model available in three sizes: 2B, 9B, and 27B",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["2b", "9b", "27b"],
|
||||
"pulls": "8.6M",
|
||||
"tags_count": 94,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/gemma2"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5-coder",
|
||||
"description": "The latest series of Code-Specific Qwen models, with significant improvements in code generation, code reasoning, and code fixing",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["0.5b", "1.5b", "3b", "7b", "14b", "32b"],
|
||||
"pulls": "7.9M",
|
||||
"tags_count": 199,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/qwen2.5-coder"
|
||||
},
|
||||
{
|
||||
"name": "phi4",
|
||||
"description": "Phi-4 is a 14B parameter, state-of-the-art open model from Microsoft",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["14b"],
|
||||
"pulls": "5.8M",
|
||||
"tags_count": 5,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/phi4"
|
||||
},
|
||||
{
|
||||
"name": "gemma",
|
||||
"description": "Gemma is a family of lightweight, state-of-the-art open models built by Google DeepMind. Updated to version 1.1",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["2b", "7b"],
|
||||
"pulls": "5.4M",
|
||||
"tags_count": 102,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/gemma"
|
||||
},
|
||||
{
|
||||
"name": "mxbai-embed-large",
|
||||
"description": "State-of-the-art large embedding model from mixedbread.ai",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["335m"],
|
||||
"pulls": "5.2M",
|
||||
"tags_count": 4,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/mxbai-embed-large"
|
||||
},
|
||||
{
|
||||
"name": "qwen",
|
||||
"description": "Qwen 1.5 is a series of large language models by Alibaba Cloud spanning from 0.5B to 110B parameters",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["0.5b", "1.8b", "4b", "7b", "14b", "32b", "72b", "110b"],
|
||||
"pulls": "5M",
|
||||
"tags_count": 379,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/qwen"
|
||||
},
|
||||
{
|
||||
"name": "qwen2",
|
||||
"description": "Qwen2 is a new series of large language models from Alibaba group",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["0.5b", "1.5b", "7b", "72b"],
|
||||
"pulls": "4.4M",
|
||||
"tags_count": 97,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/qwen2"
|
||||
},
|
||||
{
|
||||
"name": "llama2",
|
||||
"description": "Llama 2 is a collection of foundation language models ranging from 7B to 70B parameters",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b", "70b"],
|
||||
"pulls": "4.4M",
|
||||
"tags_count": 102,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama2"
|
||||
},
|
||||
{
|
||||
"name": "minicpm-v",
|
||||
"description": "A series of multimodal LLMs (MLLMs) designed for vision-language understanding",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "3.9M",
|
||||
"tags_count": 17,
|
||||
"last_updated": "11 months ago",
|
||||
"url": "https://ollama.com/library/minicpm-v"
|
||||
},
|
||||
{
|
||||
"name": "dolphin3",
|
||||
"description": "Dolphin 3.0 Llama 3.1 8B is the next generation of the Dolphin series of instruct-tuned models designed to be the ultimate general purpose local model",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "3.4M",
|
||||
"tags_count": 5,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/dolphin3"
|
||||
},
|
||||
{
|
||||
"name": "codellama",
|
||||
"description": "A large language model that can use text prompts to generate and discuss code",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b", "34b", "70b"],
|
||||
"pulls": "3.3M",
|
||||
"tags_count": 199,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/codellama"
|
||||
},
|
||||
{
|
||||
"name": "olmo2",
|
||||
"description": "OLMo 2 is a new family of 7B and 13B models trained on up to 5T tokens",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b"],
|
||||
"pulls": "3.3M",
|
||||
"tags_count": 9,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/olmo2"
|
||||
},
|
||||
{
|
||||
"name": "tinyllama",
|
||||
"description": "The TinyLlama project is an open endeavor to train a compact 1.1B Llama model on 3 trillion tokens",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["1.1b"],
|
||||
"pulls": "3M",
|
||||
"tags_count": 36,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/tinyllama"
|
||||
},
|
||||
{
|
||||
"name": "llama3.2-vision",
|
||||
"description": "Llama 3.2 Vision is a collection of instruction-tuned image reasoning generative models in 11B and 90B sizes",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["11b", "90b"],
|
||||
"pulls": "2.8M",
|
||||
"tags_count": 9,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/llama3.2-vision"
|
||||
},
|
||||
{
|
||||
"name": "mistral-nemo",
|
||||
"description": "A state-of-the-art 12B model with 128k context length, built by Mistral AI in collaboration with NVIDIA",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["12b"],
|
||||
"pulls": "2.8M",
|
||||
"tags_count": 17,
|
||||
"last_updated": "3 months ago",
|
||||
"url": "https://ollama.com/library/mistral-nemo"
|
||||
},
|
||||
{
|
||||
"name": "llama3.3",
|
||||
"description": "New state of the art 70B model. Llama 3.3 70B offers similar performance compared to the Llama 3.1 405B model",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["70b"],
|
||||
"pulls": "2.6M",
|
||||
"tags_count": 14,
|
||||
"last_updated": "10 months ago",
|
||||
"url": "https://ollama.com/library/llama3.3"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-v3",
|
||||
"description": "A strong Mixture-of-Experts (MoE) language model with 671B total parameters with 37B activated for each token",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["671b"],
|
||||
"pulls": "2.6M",
|
||||
"tags_count": 5,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/deepseek-v3"
|
||||
},
|
||||
{
|
||||
"name": "bge-m3",
|
||||
"description": "BGE-M3 is a new model from BAAI distinguished for its versatility in Multi-Functionality, Multi-Linguality, and Multi-Granularity",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["567m"],
|
||||
"pulls": "2.6M",
|
||||
"tags_count": 3,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/bge-m3"
|
||||
},
|
||||
{
|
||||
"name": "mistral-small",
|
||||
"description": "Mistral Small 3 sets a new benchmark in the small Large Language Models category below 70B",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["22b", "24b"],
|
||||
"pulls": "2.1M",
|
||||
"tags_count": 21,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/mistral-small"
|
||||
},
|
||||
{
|
||||
"name": "smollm2",
|
||||
"description": "SmolLM2 is a family of compact language models available in three size: 135M, 360M, and 1.7B parameters",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["135m", "360m", "1.7b"],
|
||||
"pulls": "2M",
|
||||
"tags_count": 49,
|
||||
"last_updated": "12 months ago",
|
||||
"url": "https://ollama.com/library/smollm2"
|
||||
},
|
||||
{
|
||||
"name": "llava-llama3",
|
||||
"description": "A LLaVA model fine-tuned from Llama 3 Instruct with better scores in several benchmarks",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "2M",
|
||||
"tags_count": 4,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llava-llama3"
|
||||
},
|
||||
{
|
||||
"name": "all-minilm",
|
||||
"description": "Embedding models on very large sentence level datasets",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["22m", "33m"],
|
||||
"pulls": "1.9M",
|
||||
"tags_count": 10,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/all-minilm"
|
||||
},
|
||||
{
|
||||
"name": "qwq",
|
||||
"description": "QwQ is the reasoning model of the Qwen series, focused on advancing AI reasoning capabilities",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["32b"],
|
||||
"pulls": "1.7M",
|
||||
"tags_count": 17,
|
||||
"last_updated": "7 months ago",
|
||||
"url": "https://ollama.com/library/qwq"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-coder",
|
||||
"description": "DeepSeek Coder is a capable coding model trained on two trillion code and natural language tokens",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1.3b", "6.7b", "33b"],
|
||||
"pulls": "1.6M",
|
||||
"tags_count": 102,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/deepseek-coder"
|
||||
},
|
||||
{
|
||||
"name": "starcoder2",
|
||||
"description": "StarCoder2 is the next generation of transparently trained open code LLMs that comes in three sizes: 3B, 7B and 15B parameters",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["3b", "7b", "15b"],
|
||||
"pulls": "1.4M",
|
||||
"tags_count": 67,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/starcoder2"
|
||||
},
|
||||
{
|
||||
"name": "mixtral",
|
||||
"description": "A set of Mixture of Experts (MoE) model with open weights by Mistral AI in 8x7b and 8x22b parameter sizes",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8x7b", "8x22b"],
|
||||
"pulls": "1.4M",
|
||||
"tags_count": 70,
|
||||
"last_updated": "10 months ago",
|
||||
"url": "https://ollama.com/library/mixtral"
|
||||
},
|
||||
{
|
||||
"name": "llama2-uncensored",
|
||||
"description": "Uncensored Llama 2 model by George Sung and Jarrad Hope, with support for a 16K context window",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "70b"],
|
||||
"pulls": "1.3M",
|
||||
"tags_count": 34,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/llama2-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "codegemma",
|
||||
"description": "CodeGemma is a collection of powerful, lightweight models that can perform a variety of coding tasks",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["2b", "7b"],
|
||||
"pulls": "1.3M",
|
||||
"tags_count": 85,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/codegemma"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-coder-v2",
|
||||
"description": "An open-source Mixture-of-Experts code language model that achieves performance comparable to GPT4-Turbo in code-specific tasks",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["16b", "236b"],
|
||||
"pulls": "1.2M",
|
||||
"tags_count": 64,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/deepseek-coder-v2"
|
||||
},
|
||||
{
|
||||
"name": "falcon3",
|
||||
"description": "A family of efficient AI models under 10B parameters performant in science, math, and coding through innovative training techniques",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["1b", "3b", "7b", "10b"],
|
||||
"pulls": "1M",
|
||||
"tags_count": 17,
|
||||
"last_updated": "10 months ago",
|
||||
"url": "https://ollama.com/library/falcon3"
|
||||
},
|
||||
{
|
||||
"name": "granite3.1-moe",
|
||||
"description": "The IBM Granite 1B and 3B models are long-context mixture of experts (MoE) Granite models from IBM designed for low latency usage",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1b", "3b"],
|
||||
"pulls": "1M",
|
||||
"tags_count": 33,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/granite3.1-moe"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5vl",
|
||||
"description": "Flagship vision-language model of Qwen and also a significant leap from the previous Qwen2-VL",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["3b", "7b", "32b", "72b"],
|
||||
"pulls": "991K",
|
||||
"tags_count": 17,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/qwen2.5vl"
|
||||
},
|
||||
{
|
||||
"name": "snowflake-arctic-embed",
|
||||
"description": "A suite of text embedding models by Snowflake, optimized for performance",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["22m", "33m", "110m", "137m", "335m"],
|
||||
"pulls": "951.1K",
|
||||
"tags_count": 16,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/snowflake-arctic-embed"
|
||||
},
|
||||
{
|
||||
"name": "orca-mini",
|
||||
"description": "A general-purpose model ranging from 3 billion parameters to 70 billion, suitable for entry-level hardware",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["3b", "7b", "13b", "70b"],
|
||||
"pulls": "930.6K",
|
||||
"tags_count": 119,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/orca-mini"
|
||||
},
|
||||
{
|
||||
"name": "llama4",
|
||||
"description": "Meta's latest collection of multimodal models",
|
||||
"capabilities": ["vision", "tools"],
|
||||
"parameter_sizes": ["16x17b", "128x17b"],
|
||||
"pulls": "751.9K",
|
||||
"tags_count": 11,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/llama4"
|
||||
},
|
||||
{
|
||||
"name": "phi",
|
||||
"description": "Phi-2: a 2.7B language model by Microsoft Research that demonstrates outstanding reasoning and language understanding capabilities",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["2.7b"],
|
||||
"pulls": "730.7K",
|
||||
"tags_count": 18,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/phi"
|
||||
},
|
||||
{
|
||||
"name": "mistral-small3.2",
|
||||
"description": "An update to Mistral Small that improves on function calling, instruction following, and less repetition errors",
|
||||
"capabilities": ["vision", "tools"],
|
||||
"parameter_sizes": ["24b"],
|
||||
"pulls": "718.3K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/mistral-small3.2"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-mixtral",
|
||||
"description": "Uncensored, 8x7b and 8x22b fine-tuned models based on the Mixtral mixture of experts models that excels at coding tasks. Created by Eric Hartford",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8x7b", "8x22b"],
|
||||
"pulls": "716.8K",
|
||||
"tags_count": 70,
|
||||
"last_updated": "10 months ago",
|
||||
"url": "https://ollama.com/library/dolphin-mixtral"
|
||||
},
|
||||
{
|
||||
"name": "gemma3n",
|
||||
"description": "Gemma 3n models are designed for efficient execution on everyday devices such as laptops, tablets or phones",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["e2b", "e4b"],
|
||||
"pulls": "670.3K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/gemma3n"
|
||||
},
|
||||
{
|
||||
"name": "granite3.3",
|
||||
"description": "IBM Granite 2B and 8B models are 128K context length language models that have been fine-tuned for improved reasoning and instruction-following capabilities",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["2b", "8b"],
|
||||
"pulls": "660.7K",
|
||||
"tags_count": 3,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/granite3.3"
|
||||
},
|
||||
{
|
||||
"name": "cogito",
|
||||
"description": "Cogito v1 Preview is a family of hybrid reasoning models by Deep Cogito that outperform the best available open models of the same size",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["3b", "8b", "14b", "32b", "70b"],
|
||||
"pulls": "659K",
|
||||
"tags_count": 20,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/cogito"
|
||||
},
|
||||
{
|
||||
"name": "phi4-reasoning",
|
||||
"description": "Phi 4 reasoning and reasoning plus are 14-billion parameter open-weight reasoning models that rival much larger models on complex reasoning tasks",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["14b"],
|
||||
"pulls": "625.6K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/phi4-reasoning"
|
||||
},
|
||||
{
|
||||
"name": "openthinker",
|
||||
"description": "A fully open-source family of reasoning models built using a dataset derived by distilling DeepSeek-R1",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["7b", "32b"],
|
||||
"pulls": "604.4K",
|
||||
"tags_count": 15,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/openthinker"
|
||||
},
|
||||
{
|
||||
"name": "magistral",
|
||||
"description": "Magistral is a small, efficient reasoning model with 24B parameters, excelling in domain-specific, transparent, and multilingual reasoning",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["24b"],
|
||||
"pulls": "583.4K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/magistral"
|
||||
},
|
||||
{
|
||||
"name": "deepscaler",
|
||||
"description": "A fine-tuned version of Deepseek-R1-Distilled-Qwen-1.5B that surpasses the performance of OpenAI's o1-preview with just 1.5B parameters",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["1.5b"],
|
||||
"pulls": "573.2K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "8 months ago",
|
||||
"url": "https://ollama.com/library/deepscaler"
|
||||
},
|
||||
{
|
||||
"name": "codestral",
|
||||
"description": "Codestral is Mistral AI's first-ever code model designed for code generation tasks",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["22b"],
|
||||
"pulls": "503.9K",
|
||||
"tags_count": 17,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/codestral"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-phi",
|
||||
"description": "2.7B uncensored Dolphin model by Eric Hartford, based on the Phi language model by Microsoft Research",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["2.7b"],
|
||||
"pulls": "569.2K",
|
||||
"tags_count": 15,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/dolphin-phi"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-mistral",
|
||||
"description": "The uncensored Dolphin model based on Mistral that excels at coding tasks. Updated to version 2.8",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "409.3K",
|
||||
"tags_count": 120,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/dolphin-mistral"
|
||||
},
|
||||
{
|
||||
"name": "wizard-vicuna-uncensored",
|
||||
"description": "Wizard Vicuna Uncensored is a 7B, 13B, and 30B parameter model based on Llama 2 uncensored by Eric Hartford",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b", "30b"],
|
||||
"pulls": "255.1K",
|
||||
"tags_count": 49,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/wizard-vicuna-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-starcoder2",
|
||||
"description": "A 7B and 15B uncensored variant of the Dolphin model family that excels at coding, based on StarCoder2",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["7b", "15b"],
|
||||
"pulls": "113.1K",
|
||||
"tags_count": 35,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/dolphin-starcoder2"
|
||||
},
|
||||
{
|
||||
"name": "wizardlm-uncensored",
|
||||
"description": "Uncensored version of Wizard LM model",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b"],
|
||||
"pulls": "89.5K",
|
||||
"tags_count": 18,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/wizardlm-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "llama-3.2-3b-instruct-abliterated",
|
||||
"description": "An uncensored version of the original Llama-3.2-3B-Instruct, created via abliteration",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["3b"],
|
||||
"pulls": "508.1K",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama-3.2-3b-instruct-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "josiefied-qwen3-uncensored",
|
||||
"description": "Qwen3, but Josiefied and uncensored",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "71.5K",
|
||||
"tags_count": 47,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/josiefied-qwen3-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "abliterated-llama3.1-8b",
|
||||
"description": "Ablitered v3 llama-3.1 8b with uncensored prompt",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "31K",
|
||||
"tags_count": 38,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/abliterated-llama3.1-8b"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-uncensored",
|
||||
"description": "Dolphin is an uncensored multilingual chat tuned model by Eric Hartford, conformant to system prompt, good at coding and various tasks",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "25.5K",
|
||||
"tags_count": 15,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/dolphin-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "specialized-uncensored-openai-20b",
|
||||
"description": "Specialized uncensored/abliterated quants for new OpenAI 20B MOE - Mixture of Experts Model at 80+ T/S (quantized Q5_1)",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["20b"],
|
||||
"pulls": "22.9K",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 month ago",
|
||||
"url": "https://ollama.com/library/specialized-uncensored-openai-20b"
|
||||
},
|
||||
{
|
||||
"name": "lexi-uncensored",
|
||||
"description": "Lexi is uncensored, which makes the model compliant. You are advised to implement your own alignment layer before exposing the model as a service",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "15.3K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/lexi-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "llama3-uncensored",
|
||||
"description": "Uncensored version of Llama3 8b",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "13.6K",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama3-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "llama3-uncensored-long",
|
||||
"description": "This model was created with the goal for a good llama 3 uncencored model with long context",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "13.5K",
|
||||
"tags_count": 4,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama3-uncensored-long"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-r1-distilled",
|
||||
"description": "DeepSeek's first generation reasoning models with comparable performance to OpenAI-o1",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "586.4K",
|
||||
"tags_count": 55,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/deepseek-r1-distilled"
|
||||
},
|
||||
{
|
||||
"name": "llama-3.2-11b-vision-abliterated",
|
||||
"description": "From huihui-ai/Llama-3.2-11B-Vision-Instruct-abliterated",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["11b"],
|
||||
"pulls": "69.8K",
|
||||
"tags_count": 2,
|
||||
"last_updated": "8 months ago",
|
||||
"url": "https://ollama.com/library/llama-3.2-11b-vision-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5-1m",
|
||||
"description": "Qwen2.5-1M is the long-context version of the Qwen2.5 series models, supporting a context length of up to 1M tokens",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "31K",
|
||||
"tags_count": 11,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/qwen2.5-1m"
|
||||
},
|
||||
{
|
||||
"name": "qwen3-abliterated",
|
||||
"description": "This is the abliterated version of Qwen3, which is the latest generation of large language models in Qwen series",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "23.9K",
|
||||
"tags_count": 6,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/qwen3-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-3.0-llama3.1-8b-abliterated",
|
||||
"description": "Dolphin 3.0 Llama 3.1 8B is the next generation of the Dolphin series of instruct-tuned models designed to be the ultimate general purpose local model",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "21.7K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/dolphin-3.0-llama3.1-8b-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "gemma-3n-abliterated",
|
||||
"description": "This is an uncensored version of google/gemma-3n created with abliteration",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["9b"],
|
||||
"pulls": "13.4K",
|
||||
"tags_count": 2,
|
||||
"last_updated": "3 months ago",
|
||||
"url": "https://ollama.com/library/gemma-3n-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "neuraldaredevil-8b-abliterated",
|
||||
"description": "Contains fp16 version of the excellent NeuralDaredevil-8B-abliterated",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "9,677",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/neuraldaredevil-8b-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "mlabonne-neuraldaredevil-8b-abliterated",
|
||||
"description": "mlabonne/NeuralDaredevil-8B-abliterated",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "7,679",
|
||||
"tags_count": 4,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/mlabonne-neuraldaredevil-8b-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "phi3-medium-abliterated",
|
||||
"description": "This is a phi3 medium model that has had the prompt rejection neurons snipped which is better than fine tuning",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["14b"],
|
||||
"pulls": "6,591",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/phi3-medium-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "gemma2-9b-abliterated",
|
||||
"description": "Abliterated version of Google's Gemma 2 9B",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["9b"],
|
||||
"pulls": "6,386",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/gemma2-9b-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "codeqwen1.5",
|
||||
"description": "CodeQwen1.5 is a large language model pretrained on a large amount of code data",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "182K",
|
||||
"tags_count": 30,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/codeqwen1.5"
|
||||
},
|
||||
{
|
||||
"name": "deepcoder",
|
||||
"description": "DeepCoder is a fully open-Source 14B coder model at O3-mini level, with a 1.5B version also available",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1.5b", "14b"],
|
||||
"pulls": "352.4K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/deepcoder"
|
||||
},
|
||||
{
|
||||
"name": "granite-code",
|
||||
"description": "A family of open foundation models by IBM for Code Intelligence",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8b", "20b", "34b"],
|
||||
"pulls": "295.1K",
|
||||
"tags_count": 162,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/granite-code"
|
||||
},
|
||||
{
|
||||
"name": "mistral-large-2",
|
||||
"description": "Mistral Large 2 is Mistral's new flagship model that is significantly more capable in code generation, mathematics, and reasoning",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["123b"],
|
||||
"pulls": "265.9K",
|
||||
"tags_count": 32,
|
||||
"last_updated": "11 months ago",
|
||||
"url": "https://ollama.com/library/mistral-large-2"
|
||||
},
|
||||
{
|
||||
"name": "starcoder",
|
||||
"description": "StarCoder is a code generation model trained on 80+ programming languages",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["15b"],
|
||||
"pulls": "228.8K",
|
||||
"tags_count": 100,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/starcoder"
|
||||
},
|
||||
{
|
||||
"name": "opencoder",
|
||||
"description": "OpenCoder is an open and reproducible code LLM family which includes 1.5B and 8B models, supporting chat in English and Chinese languages",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1.5b", "8b"],
|
||||
"pulls": "194.3K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "11 months ago",
|
||||
"url": "https://ollama.com/library/opencoder"
|
||||
},
|
||||
{
|
||||
"name": "llama-3.2-1b-abliterated",
|
||||
"description": "Abliterated version for small reasoning tasks from Llama 3.2 1B",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["1b"],
|
||||
"pulls": "39.9K",
|
||||
"tags_count": 16,
|
||||
"last_updated": "2 months ago",
|
||||
"url": "https://ollama.com/library/llama-3.2-1b-abliterated"
|
||||
}
|
||||
],
|
||||
"api_information": {
|
||||
"local_api_endpoints": [
|
||||
{
|
||||
"endpoint": "/api/tags",
|
||||
"method": "GET",
|
||||
"description": "List all locally installed models"
|
||||
}
|
||||
],
|
||||
"limitations": {
|
||||
"remote_listing": "Não existe endpoint público para listar todos os modelos disponíveis no registry remoto do Ollama",
|
||||
"user_models": "Modelos de usuários (uncensored/abliterated) são variantes comunitárias; busque via /search",
|
||||
"reasoning_focus": "Modelos com 'thinking' são otimizados para raciocínio multi-step (ex: CoT, logical puzzles)"
|
||||
}
|
||||
},
|
||||
"usage_instructions": {
|
||||
"list_local_models": "curl http://localhost:11434/api/tags",
|
||||
"pull_model": "ollama pull <model_name> (ex: ollama pull deepseek-r1 para reasoning)",
|
||||
"test_uncensored": "ollama run llama2-uncensored --prompt 'Gere código Python sem restrições'",
|
||||
"test_reasoning": "ollama run phi4-reasoning --prompt 'Resolva: 2+2=?'",
|
||||
"browse_searches": "https://ollama.com/search?q=<termo> (ex: uncensored, code)"
|
||||
},
|
||||
"recommendations_for_devs": {
|
||||
"coding": "Use qwen2.5-coder ou codestral para geração de código avançada [web:3]",
|
||||
"reasoning": "deepseek-r1 ou phi4-reasoning para tarefas lógicas/math [web:77]",
|
||||
"uncensored": "llama2-uncensored para experimentos sem alinhamento ético, mas implemente safeguards [attached_file:1]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDatabase(): Database.Database {
|
||||
if (!db) {
|
||||
const dbPath = path.join(process.cwd(), "database", "ollahub.db");
|
||||
|
||||
// Garantir que o diretório existe
|
||||
const dbDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Configurações de performance
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("cache_size = 10000");
|
||||
db.pragma("temp_store = memory");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDatabase(): Database.Database {
|
||||
if (!db) {
|
||||
const dbPath = path.join(process.cwd(), "database", "ollahub.db");
|
||||
|
||||
// Garantir que o diretório existe
|
||||
const dbDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Configurações de performance
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("cache_size = 10000");
|
||||
db.pragma("temp_store = memory");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
// Criar tabela de migrações
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Verificar migrações aplicadas
|
||||
const applied = db.prepare("SELECT name FROM _migrations").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const appliedNames = new Set(applied.map((m) => m.name));
|
||||
|
||||
// Aplicar migração inicial se não foi aplicada
|
||||
if (!appliedNames.has("001_initial_schema")) {
|
||||
console.log("📋 Aplicando migração: 001_initial_schema");
|
||||
|
||||
const schemaPath = path.join(process.cwd(), "database", "schema.sql");
|
||||
const schema = fs.readFileSync(schemaPath, "utf-8");
|
||||
|
||||
db.exec(schema);
|
||||
|
||||
// Registrar migração
|
||||
db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
|
||||
"001_initial_schema",
|
||||
Date.now()
|
||||
);
|
||||
|
||||
console.log("✅ Migração 001_initial_schema aplicada");
|
||||
}
|
||||
|
||||
// Migration 002: Campos de instalação real MCP
|
||||
if (!appliedNames.has("002_mcp_installation_fields")) {
|
||||
console.log("📋 Aplicando migração: 002_mcp_installation_fields");
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
ALTER TABLE mcp_installations ADD COLUMN status TEXT DEFAULT 'pending';
|
||||
`);
|
||||
} catch (e) {
|
||||
// Coluna já existe, ignorar
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
ALTER TABLE mcp_installations ADD COLUMN status_message TEXT;
|
||||
`);
|
||||
} catch (e) {
|
||||
// Coluna já existe, ignorar
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
ALTER TABLE mcp_installations ADD COLUMN environment_path TEXT;
|
||||
`);
|
||||
} catch (e) {
|
||||
// Coluna já existe, ignorar
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
ALTER TABLE mcp_installations ADD COLUMN executable_command TEXT;
|
||||
`);
|
||||
} catch (e) {
|
||||
// Coluna já existe, ignorar
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
ALTER TABLE mcp_installations ADD COLUMN validation_result TEXT;
|
||||
`);
|
||||
} catch (e) {
|
||||
// Coluna já existe, ignorar
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
ALTER TABLE mcp_installations ADD COLUMN install_logs TEXT;
|
||||
`);
|
||||
} catch (e) {
|
||||
// Coluna já existe, ignorar
|
||||
}
|
||||
|
||||
// Registrar migração
|
||||
db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
|
||||
"002_mcp_installation_fields",
|
||||
Date.now()
|
||||
);
|
||||
|
||||
console.log("✅ Migração 002_mcp_installation_fields aplicada");
|
||||
}
|
||||
|
||||
// Inicializar extensão vetorial se disponível
|
||||
try {
|
||||
const extPath = path.join(
|
||||
process.cwd(),
|
||||
"database",
|
||||
"extensions",
|
||||
"vector.so"
|
||||
);
|
||||
if (fs.existsSync(extPath)) {
|
||||
db.loadExtension(extPath);
|
||||
|
||||
// Inicializar índice vetorial
|
||||
db.exec(`
|
||||
SELECT vector_init('embeddings', 'vector', 'type=FLOAT32,dimension=384');
|
||||
`);
|
||||
|
||||
// Quantizar para busca mais rápida
|
||||
db.exec(`
|
||||
SELECT vector_quantize('embeddings', 'vector');
|
||||
`);
|
||||
|
||||
console.log("🔍 Extensão vetorial carregada e inicializada");
|
||||
} else {
|
||||
console.log(
|
||||
"⚠️ Extensão vetorial não encontrada - busca vetorial desabilitada"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("⚠️ Erro ao carregar extensão vetorial:", error);
|
||||
}
|
||||
}
|
||||
@@ -1,967 +0,0 @@
|
||||
{
|
||||
"metadata": {
|
||||
"source": "ollama.com/library + buscas: uncensored, abliterated, code, reasoning",
|
||||
"collection_date": "2025-10-29",
|
||||
"total_models": 270,
|
||||
"note": "Integração de modelos oficiais + comunitários (uncensored/abliterated variants). Modelos de reasoning incluem variantes com foco em raciocínio (ex: CoT, logical deduction). Duplicatas removidas; métricas atualizadas onde possível. API para lista remota ainda limitada (ver GitHub #8241)."
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "gpt-oss",
|
||||
"description": "OpenAI's open-weight models designed for powerful reasoning, agentic tasks, and versatile developer use cases",
|
||||
"capabilities": ["tools", "thinking", "cloud"],
|
||||
"parameter_sizes": ["20b", "120b"],
|
||||
"pulls": "3.8M",
|
||||
"tags_count": 5,
|
||||
"last_updated": "2 weeks ago",
|
||||
"url": "https://ollama.com/library/gpt-oss"
|
||||
},
|
||||
{
|
||||
"name": "qwen3-vl",
|
||||
"description": "The most powerful vision-language model in the Qwen model family to date",
|
||||
"capabilities": ["vision", "cloud"],
|
||||
"parameter_sizes": ["2b", "4b", "8b", "30b", "32b"],
|
||||
"pulls": "22.8K",
|
||||
"tags_count": 49,
|
||||
"last_updated": "13 minutes ago",
|
||||
"url": "https://ollama.com/library/qwen3-vl"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-r1",
|
||||
"description": "DeepSeek-R1 is a family of open reasoning models with performance approaching that of leading models, such as O3 and Gemini 2.5 Pro",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["1.5b", "7b", "8b", "14b", "32b", "70b", "671b"],
|
||||
"pulls": "68.4M",
|
||||
"tags_count": 35,
|
||||
"last_updated": "3 months ago",
|
||||
"url": "https://ollama.com/library/deepseek-r1"
|
||||
},
|
||||
{
|
||||
"name": "qwen3-coder",
|
||||
"description": "Alibaba's performant long context models for agentic and coding tasks",
|
||||
"capabilities": ["tools", "cloud"],
|
||||
"parameter_sizes": ["30b", "480b"],
|
||||
"pulls": "575.7K",
|
||||
"tags_count": 10,
|
||||
"last_updated": "1 month ago",
|
||||
"url": "https://ollama.com/library/qwen3-coder"
|
||||
},
|
||||
{
|
||||
"name": "gemma3",
|
||||
"description": "The current, most capable model that runs on a single GPU",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["270m", "1b", "4b", "12b", "27b"],
|
||||
"pulls": "23.1M",
|
||||
"tags_count": 26,
|
||||
"last_updated": "2 months ago",
|
||||
"url": "https://ollama.com/library/gemma3"
|
||||
},
|
||||
{
|
||||
"name": "glm-4.6",
|
||||
"description": "Advanced agentic, reasoning and coding capabilities",
|
||||
"capabilities": ["cloud"],
|
||||
"parameter_sizes": [],
|
||||
"pulls": "11.8K",
|
||||
"tags_count": 1,
|
||||
"last_updated": "2 weeks ago",
|
||||
"url": "https://ollama.com/library/glm-4.6"
|
||||
},
|
||||
{
|
||||
"name": "embeddinggemma",
|
||||
"description": "EmbeddingGemma is a 300M parameter embedding model from Google",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["300m"],
|
||||
"pulls": "111.2K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "1 month ago",
|
||||
"url": "https://ollama.com/library/embeddinggemma"
|
||||
},
|
||||
{
|
||||
"name": "qwen3",
|
||||
"description": "Qwen3 is the latest generation of large language models in Qwen series, offering a comprehensive suite of dense and mixture-of-experts (MoE) models",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["0.6b", "1.7b", "4b", "8b", "14b", "30b", "32b", "235b"],
|
||||
"pulls": "11.7M",
|
||||
"tags_count": 58,
|
||||
"last_updated": "2 weeks ago",
|
||||
"url": "https://ollama.com/library/qwen3"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-v3.1",
|
||||
"description": "DeepSeek-V3.1-Terminus is a hybrid model that supports both thinking mode and non-thinking mode",
|
||||
"capabilities": ["tools", "thinking", "cloud"],
|
||||
"parameter_sizes": ["671b"],
|
||||
"pulls": "120.6K",
|
||||
"tags_count": 8,
|
||||
"last_updated": "1 month ago",
|
||||
"url": "https://ollama.com/library/deepseek-v3.1"
|
||||
},
|
||||
{
|
||||
"name": "llama3.1",
|
||||
"description": "Llama 3.1 is a new state-of-the-art model from Meta available in 8B, 70B and 405B parameter sizes",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8b", "70b", "405b"],
|
||||
"pulls": "105.1M",
|
||||
"tags_count": 93,
|
||||
"last_updated": "11 months ago",
|
||||
"url": "https://ollama.com/library/llama3.1"
|
||||
},
|
||||
{
|
||||
"name": "nomic-embed-text",
|
||||
"description": "A high-performing open embedding model with a large token context window",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": [],
|
||||
"pulls": "44.3M",
|
||||
"tags_count": 3,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/nomic-embed-text"
|
||||
},
|
||||
{
|
||||
"name": "llama3.2",
|
||||
"description": "Meta's Llama 3.2 goes small with 1B and 3B models",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1b", "3b"],
|
||||
"pulls": "42.6M",
|
||||
"tags_count": 63,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama3.2"
|
||||
},
|
||||
{
|
||||
"name": "mistral",
|
||||
"description": "The 7B model released by Mistral AI, updated to version 0.3",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "21.3M",
|
||||
"tags_count": 84,
|
||||
"last_updated": "3 months ago",
|
||||
"url": "https://ollama.com/library/mistral"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5",
|
||||
"description": "Qwen2.5 models are pretrained on Alibaba's latest large-scale dataset, encompassing up to 18 trillion tokens. The model supports up to 128K tokens and has multilingual support",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["0.5b", "1.5b", "3b", "7b", "14b", "32b", "72b"],
|
||||
"pulls": "15.8M",
|
||||
"tags_count": 133,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/qwen2.5"
|
||||
},
|
||||
{
|
||||
"name": "phi3",
|
||||
"description": "Phi-3 is a family of lightweight 3B (Mini) and 14B (Medium) state-of-the-art open models by Microsoft",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["3.8b", "14b"],
|
||||
"pulls": "12.5M",
|
||||
"tags_count": 72,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/phi3"
|
||||
},
|
||||
{
|
||||
"name": "llama3",
|
||||
"description": "Meta Llama 3: The most capable openly available LLM to date",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b", "70b"],
|
||||
"pulls": "11.5M",
|
||||
"tags_count": 68,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama3"
|
||||
},
|
||||
{
|
||||
"name": "llava",
|
||||
"description": "LLaVA is a novel end-to-end trained large multimodal model that combines a vision encoder and Vicuna for general-purpose visual and language understanding. Updated to version 1.6",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["7b", "13b", "34b"],
|
||||
"pulls": "11.1M",
|
||||
"tags_count": 98,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llava"
|
||||
},
|
||||
{
|
||||
"name": "gemma2",
|
||||
"description": "Google Gemma 2 is a high-performing and efficient model available in three sizes: 2B, 9B, and 27B",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["2b", "9b", "27b"],
|
||||
"pulls": "8.6M",
|
||||
"tags_count": 94,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/gemma2"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5-coder",
|
||||
"description": "The latest series of Code-Specific Qwen models, with significant improvements in code generation, code reasoning, and code fixing",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["0.5b", "1.5b", "3b", "7b", "14b", "32b"],
|
||||
"pulls": "7.9M",
|
||||
"tags_count": 199,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/qwen2.5-coder"
|
||||
},
|
||||
{
|
||||
"name": "phi4",
|
||||
"description": "Phi-4 is a 14B parameter, state-of-the-art open model from Microsoft",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["14b"],
|
||||
"pulls": "5.8M",
|
||||
"tags_count": 5,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/phi4"
|
||||
},
|
||||
{
|
||||
"name": "gemma",
|
||||
"description": "Gemma is a family of lightweight, state-of-the-art open models built by Google DeepMind. Updated to version 1.1",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["2b", "7b"],
|
||||
"pulls": "5.4M",
|
||||
"tags_count": 102,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/gemma"
|
||||
},
|
||||
{
|
||||
"name": "mxbai-embed-large",
|
||||
"description": "State-of-the-art large embedding model from mixedbread.ai",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["335m"],
|
||||
"pulls": "5.2M",
|
||||
"tags_count": 4,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/mxbai-embed-large"
|
||||
},
|
||||
{
|
||||
"name": "qwen",
|
||||
"description": "Qwen 1.5 is a series of large language models by Alibaba Cloud spanning from 0.5B to 110B parameters",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["0.5b", "1.8b", "4b", "7b", "14b", "32b", "72b", "110b"],
|
||||
"pulls": "5M",
|
||||
"tags_count": 379,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/qwen"
|
||||
},
|
||||
{
|
||||
"name": "qwen2",
|
||||
"description": "Qwen2 is a new series of large language models from Alibaba group",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["0.5b", "1.5b", "7b", "72b"],
|
||||
"pulls": "4.4M",
|
||||
"tags_count": 97,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/qwen2"
|
||||
},
|
||||
{
|
||||
"name": "llama2",
|
||||
"description": "Llama 2 is a collection of foundation language models ranging from 7B to 70B parameters",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b", "70b"],
|
||||
"pulls": "4.4M",
|
||||
"tags_count": 102,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama2"
|
||||
},
|
||||
{
|
||||
"name": "minicpm-v",
|
||||
"description": "A series of multimodal LLMs (MLLMs) designed for vision-language understanding",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "3.9M",
|
||||
"tags_count": 17,
|
||||
"last_updated": "11 months ago",
|
||||
"url": "https://ollama.com/library/minicpm-v"
|
||||
},
|
||||
{
|
||||
"name": "dolphin3",
|
||||
"description": "Dolphin 3.0 Llama 3.1 8B is the next generation of the Dolphin series of instruct-tuned models designed to be the ultimate general purpose local model",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "3.4M",
|
||||
"tags_count": 5,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/dolphin3"
|
||||
},
|
||||
{
|
||||
"name": "codellama",
|
||||
"description": "A large language model that can use text prompts to generate and discuss code",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b", "34b", "70b"],
|
||||
"pulls": "3.3M",
|
||||
"tags_count": 199,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/codellama"
|
||||
},
|
||||
{
|
||||
"name": "olmo2",
|
||||
"description": "OLMo 2 is a new family of 7B and 13B models trained on up to 5T tokens",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b"],
|
||||
"pulls": "3.3M",
|
||||
"tags_count": 9,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/olmo2"
|
||||
},
|
||||
{
|
||||
"name": "tinyllama",
|
||||
"description": "The TinyLlama project is an open endeavor to train a compact 1.1B Llama model on 3 trillion tokens",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["1.1b"],
|
||||
"pulls": "3M",
|
||||
"tags_count": 36,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/tinyllama"
|
||||
},
|
||||
{
|
||||
"name": "llama3.2-vision",
|
||||
"description": "Llama 3.2 Vision is a collection of instruction-tuned image reasoning generative models in 11B and 90B sizes",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["11b", "90b"],
|
||||
"pulls": "2.8M",
|
||||
"tags_count": 9,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/llama3.2-vision"
|
||||
},
|
||||
{
|
||||
"name": "mistral-nemo",
|
||||
"description": "A state-of-the-art 12B model with 128k context length, built by Mistral AI in collaboration with NVIDIA",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["12b"],
|
||||
"pulls": "2.8M",
|
||||
"tags_count": 17,
|
||||
"last_updated": "3 months ago",
|
||||
"url": "https://ollama.com/library/mistral-nemo"
|
||||
},
|
||||
{
|
||||
"name": "llama3.3",
|
||||
"description": "New state of the art 70B model. Llama 3.3 70B offers similar performance compared to the Llama 3.1 405B model",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["70b"],
|
||||
"pulls": "2.6M",
|
||||
"tags_count": 14,
|
||||
"last_updated": "10 months ago",
|
||||
"url": "https://ollama.com/library/llama3.3"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-v3",
|
||||
"description": "A strong Mixture-of-Experts (MoE) language model with 671B total parameters with 37B activated for each token",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["671b"],
|
||||
"pulls": "2.6M",
|
||||
"tags_count": 5,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/deepseek-v3"
|
||||
},
|
||||
{
|
||||
"name": "bge-m3",
|
||||
"description": "BGE-M3 is a new model from BAAI distinguished for its versatility in Multi-Functionality, Multi-Linguality, and Multi-Granularity",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["567m"],
|
||||
"pulls": "2.6M",
|
||||
"tags_count": 3,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/bge-m3"
|
||||
},
|
||||
{
|
||||
"name": "mistral-small",
|
||||
"description": "Mistral Small 3 sets a new benchmark in the small Large Language Models category below 70B",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["22b", "24b"],
|
||||
"pulls": "2.1M",
|
||||
"tags_count": 21,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/mistral-small"
|
||||
},
|
||||
{
|
||||
"name": "smollm2",
|
||||
"description": "SmolLM2 is a family of compact language models available in three size: 135M, 360M, and 1.7B parameters",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["135m", "360m", "1.7b"],
|
||||
"pulls": "2M",
|
||||
"tags_count": 49,
|
||||
"last_updated": "12 months ago",
|
||||
"url": "https://ollama.com/library/smollm2"
|
||||
},
|
||||
{
|
||||
"name": "llava-llama3",
|
||||
"description": "A LLaVA model fine-tuned from Llama 3 Instruct with better scores in several benchmarks",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "2M",
|
||||
"tags_count": 4,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llava-llama3"
|
||||
},
|
||||
{
|
||||
"name": "all-minilm",
|
||||
"description": "Embedding models on very large sentence level datasets",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["22m", "33m"],
|
||||
"pulls": "1.9M",
|
||||
"tags_count": 10,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/all-minilm"
|
||||
},
|
||||
{
|
||||
"name": "qwq",
|
||||
"description": "QwQ is the reasoning model of the Qwen series, focused on advancing AI reasoning capabilities",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["32b"],
|
||||
"pulls": "1.7M",
|
||||
"tags_count": 17,
|
||||
"last_updated": "7 months ago",
|
||||
"url": "https://ollama.com/library/qwq"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-coder",
|
||||
"description": "DeepSeek Coder is a capable coding model trained on two trillion code and natural language tokens",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1.3b", "6.7b", "33b"],
|
||||
"pulls": "1.6M",
|
||||
"tags_count": 102,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/deepseek-coder"
|
||||
},
|
||||
{
|
||||
"name": "starcoder2",
|
||||
"description": "StarCoder2 is the next generation of transparently trained open code LLMs that comes in three sizes: 3B, 7B and 15B parameters",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["3b", "7b", "15b"],
|
||||
"pulls": "1.4M",
|
||||
"tags_count": 67,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/starcoder2"
|
||||
},
|
||||
{
|
||||
"name": "mixtral",
|
||||
"description": "A set of Mixture of Experts (MoE) model with open weights by Mistral AI in 8x7b and 8x22b parameter sizes",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8x7b", "8x22b"],
|
||||
"pulls": "1.4M",
|
||||
"tags_count": 70,
|
||||
"last_updated": "10 months ago",
|
||||
"url": "https://ollama.com/library/mixtral"
|
||||
},
|
||||
{
|
||||
"name": "llama2-uncensored",
|
||||
"description": "Uncensored Llama 2 model by George Sung and Jarrad Hope, with support for a 16K context window",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "70b"],
|
||||
"pulls": "1.3M",
|
||||
"tags_count": 34,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/llama2-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "codegemma",
|
||||
"description": "CodeGemma is a collection of powerful, lightweight models that can perform a variety of coding tasks",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["2b", "7b"],
|
||||
"pulls": "1.3M",
|
||||
"tags_count": 85,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/codegemma"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-coder-v2",
|
||||
"description": "An open-source Mixture-of-Experts code language model that achieves performance comparable to GPT4-Turbo in code-specific tasks",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["16b", "236b"],
|
||||
"pulls": "1.2M",
|
||||
"tags_count": 64,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/deepseek-coder-v2"
|
||||
},
|
||||
{
|
||||
"name": "falcon3",
|
||||
"description": "A family of efficient AI models under 10B parameters performant in science, math, and coding through innovative training techniques",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["1b", "3b", "7b", "10b"],
|
||||
"pulls": "1M",
|
||||
"tags_count": 17,
|
||||
"last_updated": "10 months ago",
|
||||
"url": "https://ollama.com/library/falcon3"
|
||||
},
|
||||
{
|
||||
"name": "granite3.1-moe",
|
||||
"description": "The IBM Granite 1B and 3B models are long-context mixture of experts (MoE) Granite models from IBM designed for low latency usage",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1b", "3b"],
|
||||
"pulls": "1M",
|
||||
"tags_count": 33,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/granite3.1-moe"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5vl",
|
||||
"description": "Flagship vision-language model of Qwen and also a significant leap from the previous Qwen2-VL",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["3b", "7b", "32b", "72b"],
|
||||
"pulls": "991K",
|
||||
"tags_count": 17,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/qwen2.5vl"
|
||||
},
|
||||
{
|
||||
"name": "snowflake-arctic-embed",
|
||||
"description": "A suite of text embedding models by Snowflake, optimized for performance",
|
||||
"capabilities": ["embedding"],
|
||||
"parameter_sizes": ["22m", "33m", "110m", "137m", "335m"],
|
||||
"pulls": "951.1K",
|
||||
"tags_count": 16,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/snowflake-arctic-embed"
|
||||
},
|
||||
{
|
||||
"name": "orca-mini",
|
||||
"description": "A general-purpose model ranging from 3 billion parameters to 70 billion, suitable for entry-level hardware",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["3b", "7b", "13b", "70b"],
|
||||
"pulls": "930.6K",
|
||||
"tags_count": 119,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/orca-mini"
|
||||
},
|
||||
{
|
||||
"name": "llama4",
|
||||
"description": "Meta's latest collection of multimodal models",
|
||||
"capabilities": ["vision", "tools"],
|
||||
"parameter_sizes": ["16x17b", "128x17b"],
|
||||
"pulls": "751.9K",
|
||||
"tags_count": 11,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/llama4"
|
||||
},
|
||||
{
|
||||
"name": "phi",
|
||||
"description": "Phi-2: a 2.7B language model by Microsoft Research that demonstrates outstanding reasoning and language understanding capabilities",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["2.7b"],
|
||||
"pulls": "730.7K",
|
||||
"tags_count": 18,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/phi"
|
||||
},
|
||||
{
|
||||
"name": "mistral-small3.2",
|
||||
"description": "An update to Mistral Small that improves on function calling, instruction following, and less repetition errors",
|
||||
"capabilities": ["vision", "tools"],
|
||||
"parameter_sizes": ["24b"],
|
||||
"pulls": "718.3K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/mistral-small3.2"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-mixtral",
|
||||
"description": "Uncensored, 8x7b and 8x22b fine-tuned models based on the Mixtral mixture of experts models that excels at coding tasks. Created by Eric Hartford",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8x7b", "8x22b"],
|
||||
"pulls": "716.8K",
|
||||
"tags_count": 70,
|
||||
"last_updated": "10 months ago",
|
||||
"url": "https://ollama.com/library/dolphin-mixtral"
|
||||
},
|
||||
{
|
||||
"name": "gemma3n",
|
||||
"description": "Gemma 3n models are designed for efficient execution on everyday devices such as laptops, tablets or phones",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["e2b", "e4b"],
|
||||
"pulls": "670.3K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/gemma3n"
|
||||
},
|
||||
{
|
||||
"name": "granite3.3",
|
||||
"description": "IBM Granite 2B and 8B models are 128K context length language models that have been fine-tuned for improved reasoning and instruction-following capabilities",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["2b", "8b"],
|
||||
"pulls": "660.7K",
|
||||
"tags_count": 3,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/granite3.3"
|
||||
},
|
||||
{
|
||||
"name": "cogito",
|
||||
"description": "Cogito v1 Preview is a family of hybrid reasoning models by Deep Cogito that outperform the best available open models of the same size",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["3b", "8b", "14b", "32b", "70b"],
|
||||
"pulls": "659K",
|
||||
"tags_count": 20,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/cogito"
|
||||
},
|
||||
{
|
||||
"name": "phi4-reasoning",
|
||||
"description": "Phi 4 reasoning and reasoning plus are 14-billion parameter open-weight reasoning models that rival much larger models on complex reasoning tasks",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["14b"],
|
||||
"pulls": "625.6K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/phi4-reasoning"
|
||||
},
|
||||
{
|
||||
"name": "openthinker",
|
||||
"description": "A fully open-source family of reasoning models built using a dataset derived by distilling DeepSeek-R1",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["7b", "32b"],
|
||||
"pulls": "604.4K",
|
||||
"tags_count": 15,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/openthinker"
|
||||
},
|
||||
{
|
||||
"name": "magistral",
|
||||
"description": "Magistral is a small, efficient reasoning model with 24B parameters, excelling in domain-specific, transparent, and multilingual reasoning",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["24b"],
|
||||
"pulls": "583.4K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/magistral"
|
||||
},
|
||||
{
|
||||
"name": "deepscaler",
|
||||
"description": "A fine-tuned version of Deepseek-R1-Distilled-Qwen-1.5B that surpasses the performance of OpenAI's o1-preview with just 1.5B parameters",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["1.5b"],
|
||||
"pulls": "573.2K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "8 months ago",
|
||||
"url": "https://ollama.com/library/deepscaler"
|
||||
},
|
||||
{
|
||||
"name": "codestral",
|
||||
"description": "Codestral is Mistral AI's first-ever code model designed for code generation tasks",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["22b"],
|
||||
"pulls": "503.9K",
|
||||
"tags_count": 17,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/codestral"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-phi",
|
||||
"description": "2.7B uncensored Dolphin model by Eric Hartford, based on the Phi language model by Microsoft Research",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["2.7b"],
|
||||
"pulls": "569.2K",
|
||||
"tags_count": 15,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/dolphin-phi"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-mistral",
|
||||
"description": "The uncensored Dolphin model based on Mistral that excels at coding tasks. Updated to version 2.8",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "409.3K",
|
||||
"tags_count": 120,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/dolphin-mistral"
|
||||
},
|
||||
{
|
||||
"name": "wizard-vicuna-uncensored",
|
||||
"description": "Wizard Vicuna Uncensored is a 7B, 13B, and 30B parameter model based on Llama 2 uncensored by Eric Hartford",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b", "30b"],
|
||||
"pulls": "255.1K",
|
||||
"tags_count": 49,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/wizard-vicuna-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-starcoder2",
|
||||
"description": "A 7B and 15B uncensored variant of the Dolphin model family that excels at coding, based on StarCoder2",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["7b", "15b"],
|
||||
"pulls": "113.1K",
|
||||
"tags_count": 35,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/dolphin-starcoder2"
|
||||
},
|
||||
{
|
||||
"name": "wizardlm-uncensored",
|
||||
"description": "Uncensored version of Wizard LM model",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b", "13b"],
|
||||
"pulls": "89.5K",
|
||||
"tags_count": 18,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/wizardlm-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "llama-3.2-3b-instruct-abliterated",
|
||||
"description": "An uncensored version of the original Llama-3.2-3B-Instruct, created via abliteration",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["3b"],
|
||||
"pulls": "508.1K",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama-3.2-3b-instruct-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "josiefied-qwen3-uncensored",
|
||||
"description": "Qwen3, but Josiefied and uncensored",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "71.5K",
|
||||
"tags_count": 47,
|
||||
"last_updated": "4 months ago",
|
||||
"url": "https://ollama.com/library/josiefied-qwen3-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "abliterated-llama3.1-8b",
|
||||
"description": "Ablitered v3 llama-3.1 8b with uncensored prompt",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "31K",
|
||||
"tags_count": 38,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/abliterated-llama3.1-8b"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-uncensored",
|
||||
"description": "Dolphin is an uncensored multilingual chat tuned model by Eric Hartford, conformant to system prompt, good at coding and various tasks",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "25.5K",
|
||||
"tags_count": 15,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/dolphin-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "specialized-uncensored-openai-20b",
|
||||
"description": "Specialized uncensored/abliterated quants for new OpenAI 20B MOE - Mixture of Experts Model at 80+ T/S (quantized Q5_1)",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["20b"],
|
||||
"pulls": "22.9K",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 month ago",
|
||||
"url": "https://ollama.com/library/specialized-uncensored-openai-20b"
|
||||
},
|
||||
{
|
||||
"name": "lexi-uncensored",
|
||||
"description": "Lexi is uncensored, which makes the model compliant. You are advised to implement your own alignment layer before exposing the model as a service",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "15.3K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/lexi-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "llama3-uncensored",
|
||||
"description": "Uncensored version of Llama3 8b",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "13.6K",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama3-uncensored"
|
||||
},
|
||||
{
|
||||
"name": "llama3-uncensored-long",
|
||||
"description": "This model was created with the goal for a good llama 3 uncencored model with long context",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "13.5K",
|
||||
"tags_count": 4,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/llama3-uncensored-long"
|
||||
},
|
||||
{
|
||||
"name": "deepseek-r1-distilled",
|
||||
"description": "DeepSeek's first generation reasoning models with comparable performance to OpenAI-o1",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "586.4K",
|
||||
"tags_count": 55,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/deepseek-r1-distilled"
|
||||
},
|
||||
{
|
||||
"name": "llama-3.2-11b-vision-abliterated",
|
||||
"description": "From huihui-ai/Llama-3.2-11B-Vision-Instruct-abliterated",
|
||||
"capabilities": ["vision"],
|
||||
"parameter_sizes": ["11b"],
|
||||
"pulls": "69.8K",
|
||||
"tags_count": 2,
|
||||
"last_updated": "8 months ago",
|
||||
"url": "https://ollama.com/library/llama-3.2-11b-vision-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "qwen2.5-1m",
|
||||
"description": "Qwen2.5-1M is the long-context version of the Qwen2.5 series models, supporting a context length of up to 1M tokens",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "31K",
|
||||
"tags_count": 11,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/qwen2.5-1m"
|
||||
},
|
||||
{
|
||||
"name": "qwen3-abliterated",
|
||||
"description": "This is the abliterated version of Qwen3, which is the latest generation of large language models in Qwen series",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "23.9K",
|
||||
"tags_count": 6,
|
||||
"last_updated": "5 months ago",
|
||||
"url": "https://ollama.com/library/qwen3-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "dolphin-3.0-llama3.1-8b-abliterated",
|
||||
"description": "Dolphin 3.0 Llama 3.1 8B is the next generation of the Dolphin series of instruct-tuned models designed to be the ultimate general purpose local model",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "21.7K",
|
||||
"tags_count": 5,
|
||||
"last_updated": "9 months ago",
|
||||
"url": "https://ollama.com/library/dolphin-3.0-llama3.1-8b-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "gemma-3n-abliterated",
|
||||
"description": "This is an uncensored version of google/gemma-3n created with abliteration",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["9b"],
|
||||
"pulls": "13.4K",
|
||||
"tags_count": 2,
|
||||
"last_updated": "3 months ago",
|
||||
"url": "https://ollama.com/library/gemma-3n-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "neuraldaredevil-8b-abliterated",
|
||||
"description": "Contains fp16 version of the excellent NeuralDaredevil-8B-abliterated",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "9,677",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/neuraldaredevil-8b-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "mlabonne-neuraldaredevil-8b-abliterated",
|
||||
"description": "mlabonne/NeuralDaredevil-8B-abliterated",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["8b"],
|
||||
"pulls": "7,679",
|
||||
"tags_count": 4,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/mlabonne-neuraldaredevil-8b-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "phi3-medium-abliterated",
|
||||
"description": "This is a phi3 medium model that has had the prompt rejection neurons snipped which is better than fine tuning",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["14b"],
|
||||
"pulls": "6,591",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/phi3-medium-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "gemma2-9b-abliterated",
|
||||
"description": "Abliterated version of Google's Gemma 2 9B",
|
||||
"capabilities": [],
|
||||
"parameter_sizes": ["9b"],
|
||||
"pulls": "6,386",
|
||||
"tags_count": 1,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/gemma2-9b-abliterated"
|
||||
},
|
||||
{
|
||||
"name": "codeqwen1.5",
|
||||
"description": "CodeQwen1.5 is a large language model pretrained on a large amount of code data",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["7b"],
|
||||
"pulls": "182K",
|
||||
"tags_count": 30,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/codeqwen1.5"
|
||||
},
|
||||
{
|
||||
"name": "deepcoder",
|
||||
"description": "DeepCoder is a fully open-Source 14B coder model at O3-mini level, with a 1.5B version also available",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1.5b", "14b"],
|
||||
"pulls": "352.4K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "6 months ago",
|
||||
"url": "https://ollama.com/library/deepcoder"
|
||||
},
|
||||
{
|
||||
"name": "granite-code",
|
||||
"description": "A family of open foundation models by IBM for Code Intelligence",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["8b", "20b", "34b"],
|
||||
"pulls": "295.1K",
|
||||
"tags_count": 162,
|
||||
"last_updated": "1 year ago",
|
||||
"url": "https://ollama.com/library/granite-code"
|
||||
},
|
||||
{
|
||||
"name": "mistral-large-2",
|
||||
"description": "Mistral Large 2 is Mistral's new flagship model that is significantly more capable in code generation, mathematics, and reasoning",
|
||||
"capabilities": ["tools", "thinking"],
|
||||
"parameter_sizes": ["123b"],
|
||||
"pulls": "265.9K",
|
||||
"tags_count": 32,
|
||||
"last_updated": "11 months ago",
|
||||
"url": "https://ollama.com/library/mistral-large-2"
|
||||
},
|
||||
{
|
||||
"name": "starcoder",
|
||||
"description": "StarCoder is a code generation model trained on 80+ programming languages",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["15b"],
|
||||
"pulls": "228.8K",
|
||||
"tags_count": 100,
|
||||
"last_updated": "2 years ago",
|
||||
"url": "https://ollama.com/library/starcoder"
|
||||
},
|
||||
{
|
||||
"name": "opencoder",
|
||||
"description": "OpenCoder is an open and reproducible code LLM family which includes 1.5B and 8B models, supporting chat in English and Chinese languages",
|
||||
"capabilities": ["tools"],
|
||||
"parameter_sizes": ["1.5b", "8b"],
|
||||
"pulls": "194.3K",
|
||||
"tags_count": 9,
|
||||
"last_updated": "11 months ago",
|
||||
"url": "https://ollama.com/library/opencoder"
|
||||
},
|
||||
{
|
||||
"name": "llama-3.2-1b-abliterated",
|
||||
"description": "Abliterated version for small reasoning tasks from Llama 3.2 1B",
|
||||
"capabilities": ["thinking"],
|
||||
"parameter_sizes": ["1b"],
|
||||
"pulls": "39.9K",
|
||||
"tags_count": 16,
|
||||
"last_updated": "2 months ago",
|
||||
"url": "https://ollama.com/library/llama-3.2-1b-abliterated"
|
||||
}
|
||||
],
|
||||
"api_information": {
|
||||
"local_api_endpoints": [
|
||||
{
|
||||
"endpoint": "/api/tags",
|
||||
"method": "GET",
|
||||
"description": "List all locally installed models"
|
||||
}
|
||||
],
|
||||
"limitations": {
|
||||
"remote_listing": "Não existe endpoint público para listar todos os modelos disponíveis no registry remoto do Ollama",
|
||||
"user_models": "Modelos de usuários (uncensored/abliterated) são variantes comunitárias; busque via /search",
|
||||
"reasoning_focus": "Modelos com 'thinking' são otimizados para raciocínio multi-step (ex: CoT, logical puzzles)"
|
||||
}
|
||||
},
|
||||
"usage_instructions": {
|
||||
"list_local_models": "curl http://localhost:11434/api/tags",
|
||||
"pull_model": "ollama pull <model_name> (ex: ollama pull deepseek-r1 para reasoning)",
|
||||
"test_uncensored": "ollama run llama2-uncensored --prompt 'Gere código Python sem restrições'",
|
||||
"test_reasoning": "ollama run phi4-reasoning --prompt 'Resolva: 2+2=?'",
|
||||
"browse_searches": "https://ollama.com/search?q=<termo> (ex: uncensored, code)"
|
||||
},
|
||||
"recommendations_for_devs": {
|
||||
"coding": "Use qwen2.5-coder ou codestral para geração de código avançada [web:3]",
|
||||
"reasoning": "deepseek-r1 ou phi4-reasoning para tarefas lógicas/math [web:77]",
|
||||
"uncensored": "llama2-uncensored para experimentos sem alinhamento ético, mas implemente safeguards [attached_file:1]"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,78 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
model: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export class ConversationRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
create(model: string, title?: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO conversations (id, title, model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(id, title || "Nova conversa", model, now, now);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
findAll(): Conversation[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM conversations
|
||||
ORDER BY updated_at DESC
|
||||
`
|
||||
)
|
||||
.all() as Conversation[];
|
||||
}
|
||||
|
||||
findById(id: string): Conversation | null {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM conversations WHERE id = ?
|
||||
`
|
||||
)
|
||||
.get(id) as Conversation | null;
|
||||
}
|
||||
|
||||
updateTitle(id: string, title: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE conversations
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.run(title, Date.now(), id);
|
||||
}
|
||||
|
||||
updateTimestamp(id: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE conversations
|
||||
SET updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.run(Date.now(), id);
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare("DELETE FROM conversations WHERE id = ?").run(id);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export interface Embedding {
|
||||
id: string;
|
||||
message_id: string;
|
||||
vector: Buffer;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
content: string;
|
||||
role: string;
|
||||
distance: number;
|
||||
conversation_id: string;
|
||||
}
|
||||
|
||||
export class EmbeddingRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
async create(messageId: string, vector: Float32Array): Promise<void> {
|
||||
const id = crypto.randomUUID();
|
||||
const blob = Buffer.from(vector.buffer);
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO embeddings (id, message_id, vector)
|
||||
VALUES (?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(id, messageId, blob);
|
||||
}
|
||||
|
||||
async searchSimilar(
|
||||
queryVector: Float32Array,
|
||||
limit: number = 5
|
||||
): Promise<SearchResult[]> {
|
||||
const queryBlob = Buffer.from(queryVector.buffer);
|
||||
|
||||
try {
|
||||
// Tentar busca vetorial se extensão estiver disponível
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
m.id, m.content, m.role, m.conversation_id,
|
||||
vector_distance(e.vector, ?) as distance
|
||||
FROM embeddings e
|
||||
JOIN messages m ON m.id = e.message_id
|
||||
ORDER BY distance ASC
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(queryBlob, limit) as SearchResult[];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Busca vetorial não disponível, usando busca simples:",
|
||||
error
|
||||
);
|
||||
|
||||
// Fallback: busca simples por conteúdo
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
m.id, m.content, m.role, m.conversation_id,
|
||||
0.0 as distance
|
||||
FROM messages m
|
||||
WHERE m.content LIKE ?
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(`%${queryVector.toString()}%`, limit) as SearchResult[];
|
||||
}
|
||||
}
|
||||
|
||||
findByMessage(messageId: string): Embedding | null {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM embeddings WHERE message_id = ?
|
||||
`
|
||||
)
|
||||
.get(messageId) as Embedding | null;
|
||||
}
|
||||
|
||||
deleteByMessage(messageId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM embeddings WHERE message_id = ?")
|
||||
.run(messageId);
|
||||
}
|
||||
|
||||
count(): number {
|
||||
const result = this.db
|
||||
.prepare("SELECT COUNT(*) as count FROM embeddings")
|
||||
.get() as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Repositório para gerenciar MCPs ativos por conversa/usuário
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const dbPath = path.join(process.cwd(), "database", "ollahub.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
export interface ActiveMCP {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
mcp_id: string;
|
||||
enabled: boolean;
|
||||
config?: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class MCPActiveRepository {
|
||||
/**
|
||||
* Salvar MCPs ativos para uma conversa
|
||||
* @param conversationId ID da conversa
|
||||
* @param mcpIds Array de IDs de MCPs ativos
|
||||
*/
|
||||
static saveActiveMCPs(conversationId: string, mcpIds: string[]): void {
|
||||
try {
|
||||
// Primeiro, remover todas as entradas antigas da conversa
|
||||
const deleteStmt = db.prepare(
|
||||
"DELETE FROM active_mcps WHERE conversation_id = ?"
|
||||
);
|
||||
deleteStmt.run(conversationId);
|
||||
|
||||
// Inserir novos MCPs ativos
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO active_mcps (conversation_id, mcp_id, enabled, created_at)
|
||||
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
for (const mcpId of mcpIds) {
|
||||
insertStmt.run(conversationId, mcpId);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Saved ${mcpIds.length} active MCPs for conversation ${conversationId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error saving active MCPs:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter MCPs ativos de uma conversa
|
||||
* @param conversationId ID da conversa
|
||||
* @returns Array de IDs de MCPs ativos
|
||||
*/
|
||||
static getActiveMCPs(conversationId: string): string[] {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT mcp_id FROM active_mcps
|
||||
WHERE conversation_id = ? AND enabled = 1
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
const results = stmt.all(conversationId) as { mcp_id: string }[];
|
||||
return results.map((row) => row.mcp_id);
|
||||
} catch (error) {
|
||||
console.error("Error getting active MCPs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ativar/desativar um MCP específico para uma conversa
|
||||
* @param conversationId ID da conversa
|
||||
* @param mcpId ID do MCP
|
||||
* @param enabled true para ativar, false para desativar
|
||||
*/
|
||||
static toggleMCP(
|
||||
conversationId: string,
|
||||
mcpId: string,
|
||||
enabled: boolean
|
||||
): void {
|
||||
try {
|
||||
// Verificar se já existe
|
||||
const checkStmt = db.prepare(`
|
||||
SELECT id FROM active_mcps
|
||||
WHERE conversation_id = ? AND mcp_id = ?
|
||||
`);
|
||||
const existing = checkStmt.get(conversationId, mcpId);
|
||||
|
||||
if (existing) {
|
||||
// Atualizar
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE active_mcps SET enabled = ?
|
||||
WHERE conversation_id = ? AND mcp_id = ?
|
||||
`);
|
||||
updateStmt.run(enabled ? 1 : 0, conversationId, mcpId);
|
||||
} else {
|
||||
// Inserir
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO active_mcps (conversation_id, mcp_id, enabled, created_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
insertStmt.run(conversationId, mcpId, enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`MCP ${mcpId} ${
|
||||
enabled ? "enabled" : "disabled"
|
||||
} for conversation ${conversationId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error toggling MCP:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpar MCPs ativos de uma conversa
|
||||
* @param conversationId ID da conversa
|
||||
*/
|
||||
static clearActiveMCPs(conversationId: string): void {
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"DELETE FROM active_mcps WHERE conversation_id = ?"
|
||||
);
|
||||
stmt.run(conversationId);
|
||||
console.log(`Cleared active MCPs for conversation ${conversationId}`);
|
||||
} catch (error) {
|
||||
console.error("Error clearing active MCPs:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter todas as conversas que usam um MCP específico
|
||||
* @param mcpId ID do MCP
|
||||
* @returns Array de IDs de conversas
|
||||
*/
|
||||
static getConversationsUsingMCP(mcpId: string): string[] {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT DISTINCT conversation_id FROM active_mcps
|
||||
WHERE mcp_id = ? AND enabled = 1
|
||||
`);
|
||||
|
||||
const results = stmt.all(mcpId) as { conversation_id: string }[];
|
||||
return results.map((row) => row.conversation_id);
|
||||
} catch (error) {
|
||||
console.error("Error getting conversations using MCP:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const dbPath = path.join(process.cwd(), "database", "ollahub.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
export class MCPRepository {
|
||||
/**
|
||||
* Verificar se um MCP está instalado localmente
|
||||
*/
|
||||
static isInstalled(mcpId: string): boolean {
|
||||
try {
|
||||
const stmt = db.prepare("SELECT id FROM mcp_installations WHERE id = ?");
|
||||
const result = stmt.get(mcpId);
|
||||
return !!result;
|
||||
} catch (error) {
|
||||
console.error(`Error checking if MCP ${mcpId} is installed:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar instalação de MCP no banco local
|
||||
*/
|
||||
static saveInstallation(mcpId: string, config: any, tools: any[]): void {
|
||||
try {
|
||||
// Tentar extrair owner/repo do ID
|
||||
let owner: string;
|
||||
let repo: string;
|
||||
|
||||
if (mcpId.includes("/")) {
|
||||
const parts = mcpId.split("/");
|
||||
owner = parts[0];
|
||||
repo = parts.slice(1).join("/"); // Suportar múltiplas barras
|
||||
} else {
|
||||
// Se não tem /, usar mcpId como repo e extrair owner do config se disponível
|
||||
owner = config.owner || config.author || "unknown";
|
||||
repo = mcpId;
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO mcp_installations
|
||||
(id, owner, repo, name, config, tools, installed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
mcpId,
|
||||
owner,
|
||||
repo,
|
||||
config.name || repo,
|
||||
JSON.stringify(config),
|
||||
JSON.stringify(tools)
|
||||
);
|
||||
|
||||
console.log(`MCP ${mcpId} saved to local database`);
|
||||
} catch (error) {
|
||||
console.error(`Error saving MCP ${mcpId} installation:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remover instalação de MCP do banco local
|
||||
*/
|
||||
static removeInstallation(mcpId: string): void {
|
||||
try {
|
||||
const stmt = db.prepare("DELETE FROM mcp_installations WHERE id = ?");
|
||||
const result = stmt.run(mcpId);
|
||||
|
||||
if (result.changes > 0) {
|
||||
console.log(`MCP ${mcpId} removed from local database`);
|
||||
} else {
|
||||
console.log(`MCP ${mcpId} was not found in local database`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error removing MCP ${mcpId} installation:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar todos os MCPs instalados localmente
|
||||
*/
|
||||
static listInstalled(): string[] {
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"SELECT id FROM mcp_installations ORDER BY installed_at DESC"
|
||||
);
|
||||
const results = stmt.all() as { id: string }[];
|
||||
return results.map((row) => row.id);
|
||||
} catch (error) {
|
||||
console.error("Error listing installed MCPs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter configuração local de um MCP
|
||||
*/
|
||||
static getLocalConfig(mcpId: string): any {
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"SELECT config FROM mcp_installations WHERE id = ?"
|
||||
);
|
||||
const result = stmt.get(mcpId) as { config: string } | undefined;
|
||||
|
||||
if (result) {
|
||||
return JSON.parse(result.config);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting local config for MCP ${mcpId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter ferramentas locais de um MCP
|
||||
*/
|
||||
static getLocalTools(mcpId: string): any[] {
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"SELECT tools FROM mcp_installations WHERE id = ?"
|
||||
);
|
||||
const result = stmt.get(mcpId) as { tools: string } | undefined;
|
||||
|
||||
if (result) {
|
||||
return JSON.parse(result.tools);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error getting local tools for MCP ${mcpId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter informações completas de um MCP instalado
|
||||
*/
|
||||
static getInstalledMCP(mcpId: string): any {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, owner, repo, name, config, tools, installed_at, updated_at
|
||||
FROM mcp_installations WHERE id = ?
|
||||
`);
|
||||
const result = stmt.get(mcpId) as any;
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
...result,
|
||||
config: JSON.parse(result.config),
|
||||
tools: JSON.parse(result.tools),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting installed MCP ${mcpId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar todos os MCPs instalados com informações completas
|
||||
*/
|
||||
static listInstalledWithDetails(): any[] {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, owner, repo, name, config, tools, installed_at, updated_at
|
||||
FROM mcp_installations ORDER BY installed_at DESC
|
||||
`);
|
||||
const results = stmt.all() as any[];
|
||||
|
||||
return results.map((row) => ({
|
||||
...row,
|
||||
config: JSON.parse(row.config),
|
||||
tools: JSON.parse(row.tools),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error listing installed MCPs with details:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter todos os MCPs instalados com informações completas (incluindo status, environment, etc)
|
||||
*/
|
||||
static getAllInstalled(): any[] {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
id, owner, repo, name, config, tools,
|
||||
status, status_message,
|
||||
environment_path, executable_command,
|
||||
validation_result, install_logs,
|
||||
installed_at, updated_at
|
||||
FROM mcp_installations
|
||||
ORDER BY installed_at DESC
|
||||
`);
|
||||
const results = stmt.all() as any[];
|
||||
|
||||
return results.map((row) => ({
|
||||
...row,
|
||||
config: row.config ? JSON.parse(row.config) : null,
|
||||
tools: row.tools ? JSON.parse(row.tools) : [],
|
||||
validation_result: row.validation_result
|
||||
? JSON.parse(row.validation_result)
|
||||
: null,
|
||||
install_logs: row.install_logs ? JSON.parse(row.install_logs) : [],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error getting all installed MCPs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar timestamp de um MCP instalado
|
||||
*/
|
||||
static updateTimestamp(mcpId: string): void {
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"UPDATE mcp_installations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||
);
|
||||
stmt.run(mcpId);
|
||||
} catch (error) {
|
||||
console.error(`Error updating timestamp for MCP ${mcpId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar configuração de um MCP instalado
|
||||
*/
|
||||
static updateConfig(mcpId: string, newConfig: any): void {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE mcp_installations
|
||||
SET config = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(JSON.stringify(newConfig), mcpId);
|
||||
|
||||
if (result.changes > 0) {
|
||||
console.log(`MCP ${mcpId} configuration updated successfully`);
|
||||
} else {
|
||||
console.log(`MCP ${mcpId} was not found for configuration update`);
|
||||
throw new Error(`MCP ${mcpId} not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating configuration for MCP ${mcpId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar integridade do banco de dados
|
||||
*/
|
||||
static checkDatabaseIntegrity(): boolean {
|
||||
try {
|
||||
const result = db.prepare("PRAGMA integrity_check").get() as {
|
||||
integrity_check: string;
|
||||
};
|
||||
return result.integrity_check === "ok";
|
||||
} catch (error) {
|
||||
console.error("Error checking database integrity:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter estatísticas das instalações
|
||||
*/
|
||||
static getInstallationStats(): any {
|
||||
try {
|
||||
const totalStmt = db.prepare(
|
||||
"SELECT COUNT(*) as total FROM mcp_installations"
|
||||
);
|
||||
const totalResult = totalStmt.get() as { total: number };
|
||||
|
||||
const recentStmt = db.prepare(`
|
||||
SELECT COUNT(*) as recent
|
||||
FROM mcp_installations
|
||||
WHERE installed_at > datetime('now', '-7 days')
|
||||
`);
|
||||
const recentResult = recentStmt.get() as { recent: number };
|
||||
|
||||
const categoriesStmt = db.prepare(`
|
||||
SELECT COUNT(DISTINCT owner) as unique_owners
|
||||
FROM mcp_installations
|
||||
`);
|
||||
const categoriesResult = categoriesStmt.get() as {
|
||||
unique_owners: number;
|
||||
};
|
||||
|
||||
return {
|
||||
total: totalResult.total,
|
||||
recent: recentResult.recent,
|
||||
uniqueOwners: categoriesResult.unique_owners,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting installation stats:", error);
|
||||
return { total: 0, recent: 0, uniqueOwners: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar status de instalação de um MCP
|
||||
*/
|
||||
static updateInstallationStatus(
|
||||
mcpId: string,
|
||||
status: string,
|
||||
message?: string
|
||||
): void {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE mcp_installations
|
||||
SET status = ?, status_message = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(status, message || null, mcpId);
|
||||
console.log(`MCP ${mcpId} status updated to: ${status}`);
|
||||
} catch (error) {
|
||||
console.error(`Error updating status for MCP ${mcpId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar informações de ambiente de um MCP instalado
|
||||
*/
|
||||
static saveEnvironment(mcpId: string, environment: any): void {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE mcp_installations
|
||||
SET environment_path = ?,
|
||||
executable_command = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const executableCommand = `${environment.executable} ${(
|
||||
environment.args || []
|
||||
).join(" ")}`.trim();
|
||||
|
||||
stmt.run(environment.path, executableCommand, mcpId);
|
||||
console.log(`MCP ${mcpId} environment saved`);
|
||||
} catch (error) {
|
||||
console.error(`Error saving environment for MCP ${mcpId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter ambiente de um MCP instalado
|
||||
*/
|
||||
static getEnvironment(mcpId: string): any | null {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT environment_path, executable_command
|
||||
FROM mcp_installations
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.get(mcpId) as any;
|
||||
|
||||
if (!result || !result.environment_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reconstruir objeto de ambiente
|
||||
const parts = result.executable_command.split(" ");
|
||||
return {
|
||||
path: result.environment_path,
|
||||
executable: parts[0],
|
||||
args: parts.slice(1),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error getting environment for MCP ${mcpId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adicionar logs de instalação
|
||||
*/
|
||||
static appendLogs(mcpId: string, logs: string[]): void {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT install_logs FROM mcp_installations WHERE id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.get(mcpId) as
|
||||
| { install_logs: string | null }
|
||||
| undefined;
|
||||
const existingLogs = result?.install_logs
|
||||
? JSON.parse(result.install_logs)
|
||||
: [];
|
||||
const updatedLogs = [...existingLogs, ...logs];
|
||||
|
||||
// Limitar a 1000 linhas de log
|
||||
const trimmedLogs = updatedLogs.slice(-1000);
|
||||
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE mcp_installations
|
||||
SET install_logs = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
updateStmt.run(JSON.stringify(trimmedLogs), mcpId);
|
||||
} catch (error) {
|
||||
console.error(`Error appending logs for MCP ${mcpId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar resultado de validação
|
||||
*/
|
||||
static saveValidationResult(mcpId: string, validationResult: any): void {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE mcp_installations
|
||||
SET validation_result = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(JSON.stringify(validationResult), mcpId);
|
||||
console.log(`MCP ${mcpId} validation result saved`);
|
||||
} catch (error) {
|
||||
console.error(`Error saving validation result for MCP ${mcpId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar ferramentas validadas
|
||||
*/
|
||||
static saveTools(mcpId: string, tools: any[]): void {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE mcp_installations
|
||||
SET tools = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(JSON.stringify(tools), mcpId);
|
||||
console.log(`MCP ${mcpId} tools saved: ${tools.length} tools`);
|
||||
} catch (error) {
|
||||
console.error(`Error saving tools for MCP ${mcpId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import type { ChatRole } from "@/lib/chat";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class MessageRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
create(conversationId: string, role: ChatRole, content: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO messages (id, conversation_id, role, content, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(id, conversationId, role, content, Date.now());
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
findByConversation(conversationId: string): Message[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`
|
||||
)
|
||||
.all(conversationId) as Message[];
|
||||
}
|
||||
|
||||
findByConversationPaged(
|
||||
conversationId: string,
|
||||
limit: number,
|
||||
beforeTs?: number
|
||||
): Message[] {
|
||||
const sql = beforeTs
|
||||
? `SELECT * FROM messages WHERE conversation_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?`
|
||||
: `SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`;
|
||||
const rows = beforeTs
|
||||
? (this.db.prepare(sql).all(conversationId, beforeTs, limit) as Message[])
|
||||
: (this.db.prepare(sql).all(conversationId, limit) as Message[]);
|
||||
// Reverter para ascendente no retorno
|
||||
return rows.reverse();
|
||||
}
|
||||
|
||||
findById(id: string): Message | null {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM messages WHERE id = ?
|
||||
`
|
||||
)
|
||||
.get(id) as Message | null;
|
||||
}
|
||||
|
||||
updateContent(id: string, content: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE messages
|
||||
SET content = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.run(content, id);
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
deleteByConversation(conversationId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM messages WHERE conversation_id = ?")
|
||||
.run(conversationId);
|
||||
}
|
||||
|
||||
countByConversation(conversationId: string): number {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM messages
|
||||
WHERE conversation_id = ?
|
||||
`
|
||||
)
|
||||
.get(conversationId) as { count: number };
|
||||
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
-- Schema inicial do banco de dados OllaHub
|
||||
-- Conversas
|
||||
CREATE TABLE conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
model TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Mensagens
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Embeddings vetoriais (384 dimensões do Ollama)
|
||||
CREATE TABLE embeddings (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
vector BLOB NOT NULL,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Tabela para gerenciar MCPs instalados
|
||||
CREATE TABLE IF NOT EXISTS mcp_installations (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config JSON NOT NULL,
|
||||
tools JSON,
|
||||
status TEXT DEFAULT 'pending',
|
||||
status_message TEXT,
|
||||
environment_path TEXT,
|
||||
executable_command TEXT,
|
||||
validation_result TEXT,
|
||||
install_logs TEXT,
|
||||
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Marketplace removido: tabelas e índices relacionados ao cache do marketplace foram descontinuados
|
||||
|
||||
-- Tabela para rastrear MCPs ativos por conversa
|
||||
CREATE TABLE IF NOT EXISTS active_mcps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
mcp_id TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
UNIQUE(conversation_id, mcp_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_active_mcps_conversation ON active_mcps(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_active_mcps_mcp ON active_mcps(mcp_id);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp);
|
||||
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
|
||||
CREATE INDEX idx_embeddings_message ON embeddings(message_id);
|
||||
CREATE INDEX idx_mcp_owner_repo ON mcp_installations(owner, repo);
|
||||
CREATE INDEX idx_mcp_installed_at ON mcp_installations(installed_at DESC);
|
||||
-- Índices do marketplace removidos
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "goonsai/josiefied-qwen2.5-7b-abliterated-v2:latest",
|
||||
"installed": true,
|
||||
"lastUsedAt": "2025-10-29T20:40:19.434Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type CopyMode = "markdown" | "text";
|
||||
|
||||
const KEY = "ollahub-copy-mode";
|
||||
|
||||
export function useCopyPrefs() {
|
||||
const [copyMode, setCopyModeState] = useState<CopyMode>("markdown");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = localStorage.getItem(KEY) as CopyMode | null;
|
||||
if (saved === "markdown" || saved === "text") {
|
||||
setCopyModeState(saved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setCopyMode = useCallback((mode: CopyMode) => {
|
||||
setCopyModeState(mode);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(KEY, mode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { copyMode, setCopyMode };
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { MCPProvider, MCPSearchParams } from "@/lib/types/mcp";
|
||||
|
||||
export function useMCPMarketplace() {
|
||||
const [mcps, setMcps] = useState<MCPProvider[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortBy, setSortBy] = useState<MCPSearchParams["sort"]>("rating");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [selectedMCP, setSelectedMCP] = useState<MCPProvider | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
|
||||
const fetchMCPs = useCallback(
|
||||
async (params?: Partial<MCPSearchParams>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
const category = params?.category || selectedCategory;
|
||||
const search = params?.search || searchTerm;
|
||||
const sort = params?.sort || sortBy;
|
||||
const order = params?.order || sortOrder;
|
||||
|
||||
if (category && category !== "all") {
|
||||
searchParams.append("category", category);
|
||||
}
|
||||
if (search) {
|
||||
searchParams.append("search", search);
|
||||
}
|
||||
if (sort) {
|
||||
searchParams.append("sort", sort);
|
||||
}
|
||||
if (order) {
|
||||
searchParams.append("order", order);
|
||||
}
|
||||
searchParams.append("limit", "2000"); // Aumentado para 2000
|
||||
|
||||
const response = await fetch(`/api/mcp/list?${searchParams}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMcps(data.mcps);
|
||||
} else {
|
||||
setError(data.error || "Failed to fetch MCPs");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch MCPs");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[selectedCategory, searchTerm, sortBy, sortOrder]
|
||||
);
|
||||
|
||||
const installMCP = useCallback(async (mcpId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/mcp/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Atualizar estado local
|
||||
setMcps((prev) =>
|
||||
prev.map((mcp) =>
|
||||
mcp.id === mcpId ? { ...mcp, installed: true } : mcp
|
||||
)
|
||||
);
|
||||
return { success: true, message: data.installation.message };
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : "Failed to install MCP",
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uninstallMCP = useCallback(async (mcpId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/mcp/uninstall", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Atualizar estado local
|
||||
setMcps((prev) =>
|
||||
prev.map((mcp) =>
|
||||
mcp.id === mcpId ? { ...mcp, installed: false } : mcp
|
||||
)
|
||||
);
|
||||
return { success: true, message: data.installation.message };
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : "Failed to uninstall MCP",
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const viewMCPDetails = useCallback((mcp: MCPProvider) => {
|
||||
setSelectedMCP(mcp);
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const closeDetailModal = useCallback(() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedMCP(null);
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(search: string) => {
|
||||
setSearchTerm(search);
|
||||
fetchMCPs({ search });
|
||||
},
|
||||
[fetchMCPs]
|
||||
);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(category: string) => {
|
||||
setSelectedCategory(category);
|
||||
fetchMCPs({ category });
|
||||
},
|
||||
[fetchMCPs]
|
||||
);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(sort: MCPSearchParams["sort"]) => {
|
||||
setSortBy(sort);
|
||||
fetchMCPs({ sort });
|
||||
},
|
||||
[fetchMCPs]
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback(
|
||||
(order: "asc" | "desc") => {
|
||||
setSortOrder(order);
|
||||
fetchMCPs({ order });
|
||||
},
|
||||
[fetchMCPs]
|
||||
);
|
||||
|
||||
// Carregar MCPs na inicialização
|
||||
useEffect(() => {
|
||||
fetchMCPs();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Estado
|
||||
mcps,
|
||||
loading,
|
||||
error,
|
||||
selectedCategory,
|
||||
searchTerm,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
selectedMCP,
|
||||
showDetailModal,
|
||||
|
||||
// Ações
|
||||
fetchMCPs,
|
||||
installMCP,
|
||||
uninstallMCP,
|
||||
viewMCPDetails,
|
||||
closeDetailModal,
|
||||
handleSearch,
|
||||
handleCategoryChange,
|
||||
handleSortChange,
|
||||
handleOrderChange,
|
||||
|
||||
// Setters diretos
|
||||
setSearchTerm,
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
export function extractCodeBlocks(
|
||||
text: string
|
||||
): Array<{ language: string; code: string }> {
|
||||
const regex = /```(\w+)?\n([\s\S]*?)```/g;
|
||||
const blocks: Array<{ language: string; code: string }> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const language = match[1] || "text";
|
||||
const code = match[2]?.trimEnd() || "";
|
||||
blocks.push({ language, code });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function stripMarkdown(md: string): string {
|
||||
// Remove code blocks first
|
||||
let plain = md.replace(/```[\s\S]*?```/g, (block) =>
|
||||
block.replace(/```[\s\S]*?\n/, "").replace(/```$/, "")
|
||||
);
|
||||
// Remove inline code
|
||||
plain = plain.replace(/`([^`]+)`/g, "$1");
|
||||
// Remove common markdown syntax
|
||||
plain = plain
|
||||
.replace(/(^|\s)[#>*_~`-]+/g, " ")
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, "$1");
|
||||
return plain;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type DevicePref = "auto" | "cpu" | "gpu";
|
||||
|
||||
type Prefs = {
|
||||
selectedModel: string | null; // NOVO
|
||||
systemPrompt: string;
|
||||
device: DevicePref;
|
||||
gpuIndex: number;
|
||||
numGpu: number;
|
||||
// Preferências de design
|
||||
chatLayout?: "compact" | "edge"; // compact = centralizado com max-width; edge = até as bordas
|
||||
bubbleSize?: "sm" | "md" | "lg"; // tamanhos de bolha
|
||||
};
|
||||
|
||||
const defaultPrefs: Prefs = {
|
||||
selectedModel: null, // NOVO
|
||||
systemPrompt: "",
|
||||
device: "auto",
|
||||
gpuIndex: 0,
|
||||
numGpu: 1,
|
||||
chatLayout: "compact",
|
||||
bubbleSize: "md",
|
||||
};
|
||||
|
||||
export function useUserPrefs() {
|
||||
const [prefs, setPrefs] = useState<Prefs>(defaultPrefs);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Carregar do Redis
|
||||
useEffect(() => {
|
||||
fetch("/api/preferences")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setPrefs(data);
|
||||
setReady(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Erro ao carregar preferências:", err);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Salvar no Redis
|
||||
const update = useCallback((next: Partial<Prefs>) => {
|
||||
setPrefs((prev) => {
|
||||
const merged: Prefs = { ...prev, ...next };
|
||||
|
||||
// Salvar no Redis
|
||||
fetch("/api/preferences", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(merged),
|
||||
}).catch((err) =>
|
||||
console.error("Erro ao salvar preferências:", err)
|
||||
);
|
||||
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { prefs, ready, update };
|
||||
}
|
||||
|
||||
export function buildOllamaOptions(
|
||||
device: DevicePref,
|
||||
numGpu: number,
|
||||
gpuIndex: number
|
||||
) {
|
||||
if (device === "auto") return undefined;
|
||||
if (device === "cpu") return { num_gpu: 0 } as const;
|
||||
// gpu
|
||||
return {
|
||||
num_gpu: Math.max(1, numGpu),
|
||||
main_gpu: Math.max(0, gpuIndex),
|
||||
} as const;
|
||||
}
|
||||
Vendored
-173
@@ -1,173 +0,0 @@
|
||||
import { DeepNLPSearchParams } from "@/lib/types/mcp";
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export class MCPCache {
|
||||
private static cache = new Map<string, CacheEntry>();
|
||||
private static TTL = 5 * 60 * 1000; // 5 minutos em millisegundos
|
||||
|
||||
/**
|
||||
* Obter dados do cache
|
||||
*/
|
||||
static get(key: string): any | null {
|
||||
const entry = this.cache.get(key);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar se expirou
|
||||
const now = Date.now();
|
||||
if (now - entry.timestamp > entry.ttl) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Cache hit for key: ${key}`);
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar dados no cache
|
||||
*/
|
||||
static set(key: string, value: any, ttl?: number): void {
|
||||
const entry: CacheEntry = {
|
||||
data: value,
|
||||
timestamp: Date.now(),
|
||||
ttl: ttl || this.TTL
|
||||
};
|
||||
|
||||
this.cache.set(key, entry);
|
||||
console.log(`Cache set for key: ${key}, TTL: ${entry.ttl}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpar cache
|
||||
*/
|
||||
static clear(): void {
|
||||
this.cache.clear();
|
||||
console.log("Cache cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remover entrada específica do cache
|
||||
*/
|
||||
static delete(key: string): void {
|
||||
this.cache.delete(key);
|
||||
console.log(`Cache entry deleted for key: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar chave de cache baseada nos parâmetros de busca
|
||||
*/
|
||||
static generateKey(params: DeepNLPSearchParams): string {
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.reduce((result, key) => {
|
||||
const value = params[key as keyof DeepNLPSearchParams];
|
||||
if (value !== undefined && value !== null) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return `mcp_search_${JSON.stringify(sortedParams)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar chave para configuração de servidor
|
||||
*/
|
||||
static generateServerConfigKey(mcpId: string): string {
|
||||
return `mcp_config_${mcpId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar chave para ferramentas de servidor
|
||||
*/
|
||||
static generateServerToolsKey(mcpId: string): string {
|
||||
return `mcp_tools_${mcpId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter estatísticas do cache
|
||||
*/
|
||||
static getStats(): {
|
||||
size: number;
|
||||
entries: Array<{ key: string; age: number; ttl: number }>;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries()).map(([key, entry]) => ({
|
||||
key,
|
||||
age: now - entry.timestamp,
|
||||
ttl: entry.ttl
|
||||
}));
|
||||
|
||||
return {
|
||||
size: this.cache.size,
|
||||
entries
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpar entradas expiradas
|
||||
*/
|
||||
static cleanup(): number {
|
||||
const now = Date.now();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now - entry.timestamp > entry.ttl) {
|
||||
this.cache.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
console.log(`Cache cleanup: removed ${cleaned} expired entries`);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se uma chave existe no cache (sem verificar TTL)
|
||||
*/
|
||||
static has(key: string): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter todas as chaves do cache
|
||||
*/
|
||||
static keys(): string[] {
|
||||
return Array.from(this.cache.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Definir TTL personalizado para diferentes tipos de dados
|
||||
*/
|
||||
static getTTLForDataType(dataType: 'search' | 'config' | 'tools'): number {
|
||||
switch (dataType) {
|
||||
case 'search':
|
||||
return 5 * 60 * 1000; // 5 minutos para buscas
|
||||
case 'config':
|
||||
return 30 * 60 * 1000; // 30 minutos para configurações
|
||||
case 'tools':
|
||||
return 15 * 60 * 1000; // 15 minutos para ferramentas
|
||||
default:
|
||||
return this.TTL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache com TTL específico para tipo de dados
|
||||
*/
|
||||
static setWithDataType(key: string, value: any, dataType: 'search' | 'config' | 'tools'): void {
|
||||
const ttl = this.getTTLForDataType(dataType);
|
||||
this.set(key, value, ttl);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type CatalogModel = {
|
||||
name: string;
|
||||
description?: string;
|
||||
capabilities?: string[];
|
||||
parameter_sizes?: string[];
|
||||
pulls?: string;
|
||||
tags_count?: number;
|
||||
last_updated?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type CatalogFile = {
|
||||
models?: CatalogModel[];
|
||||
};
|
||||
|
||||
let cachedModels: CatalogModel[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
function resolveCatalogPath(): string {
|
||||
// Arquivo fica em data/models.json na raiz do projeto
|
||||
return path.join(process.cwd(), "data", "models.json");
|
||||
}
|
||||
|
||||
export async function getCatalogModels(): Promise<CatalogModel[]> {
|
||||
try {
|
||||
const catalogPath = resolveCatalogPath();
|
||||
const stat = await fs.stat(catalogPath);
|
||||
|
||||
// Cache simples por mtime
|
||||
if (cachedModels && cachedMtimeMs && stat.mtimeMs === cachedMtimeMs) {
|
||||
return cachedModels;
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(catalogPath, "utf-8");
|
||||
const json = JSON.parse(raw) as CatalogFile;
|
||||
const models = Array.isArray(json.models) ? json.models : [];
|
||||
|
||||
cachedModels = models;
|
||||
cachedMtimeMs = stat.mtimeMs;
|
||||
return models;
|
||||
} catch (error) {
|
||||
// Em caso de erro, retorne array vazio para não quebrar a UI
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
export type ChatRole = "user" | "assistant" | "system";
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type ChatState = {
|
||||
messages: Message[];
|
||||
selectedModel: string | null;
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
};
|
||||
|
||||
export function createMessage(role: ChatRole, content: string): Message {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
if (minutes < 1) return "agora";
|
||||
if (minutes < 60) return `${minutes} min atrás`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h atrás`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d atrás`;
|
||||
}
|
||||
|
||||
export function convertToOllamaMessages(messages: Message[]) {
|
||||
return messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createSystemMessage(content: string): Message {
|
||||
return createMessage("system", content);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Configurações para instalação e gerenciamento de servidores MCP
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
|
||||
export const MCP_CONFIG = {
|
||||
// Diretório base onde os servidores MCP serão instalados
|
||||
serversDir:
|
||||
process.env.MCP_SERVERS_DIR || path.join(process.cwd(), "mcp-servers"),
|
||||
|
||||
// Timeout para instalação de pacotes (5 minutos)
|
||||
installTimeout: parseInt(process.env.MCP_INSTALL_TIMEOUT || "300000", 10),
|
||||
|
||||
// Timeout para testes de servidor (10 segundos)
|
||||
testTimeout: parseInt(process.env.MCP_TEST_TIMEOUT || "10000", 10),
|
||||
|
||||
// Habilitar logs detalhados (para debug/testes)
|
||||
enableLogs: process.env.MCP_ENABLE_LOGS === "true",
|
||||
|
||||
// Diretórios por tipo de pacote
|
||||
get npmDir() {
|
||||
return path.join(this.serversDir, "npm");
|
||||
},
|
||||
|
||||
get pythonDir() {
|
||||
return path.join(this.serversDir, "python");
|
||||
},
|
||||
|
||||
get rustDir() {
|
||||
return path.join(this.serversDir, "rust");
|
||||
},
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
import { Ollama } from "ollama";
|
||||
|
||||
const ollama = new Ollama({
|
||||
host: process.env.OLLAMA_HOST || "http://localhost:11434",
|
||||
});
|
||||
|
||||
export async function generateEmbedding(
|
||||
text: string,
|
||||
model: string = "nomic-embed-text"
|
||||
): Promise<Float32Array> {
|
||||
try {
|
||||
// Verificar se deve forçar uso de CPU (útil quando GPU tem problemas de memória)
|
||||
const forceCpu =
|
||||
process.env.OLLAMA_NO_GPU === "1" || process.env.OLLAMA_NUM_GPU === "0";
|
||||
|
||||
const response = await ollama.embeddings({
|
||||
model,
|
||||
prompt: text,
|
||||
options: forceCpu ? { num_gpu: 0 } : undefined,
|
||||
});
|
||||
|
||||
return new Float32Array(response.embedding);
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao gerar embedding:", error);
|
||||
|
||||
// Se erro de GPU e ainda não tentou CPU, tentar forçar CPU
|
||||
const isGpuError =
|
||||
error?.message?.includes("CUDA") ||
|
||||
error?.message?.includes("unable to allocate") ||
|
||||
error?.status_code === 500;
|
||||
|
||||
if (
|
||||
isGpuError &&
|
||||
process.env.OLLAMA_NO_GPU !== "1" &&
|
||||
process.env.OLLAMA_NUM_GPU !== "0"
|
||||
) {
|
||||
console.warn("Erro de GPU detectado, tentando usar CPU...");
|
||||
try {
|
||||
const response = await ollama.embeddings({
|
||||
model,
|
||||
prompt: text,
|
||||
options: { num_gpu: 0 },
|
||||
});
|
||||
return new Float32Array(response.embedding);
|
||||
} catch (cpuError) {
|
||||
console.error("Erro mesmo usando CPU:", cpuError);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: retornar vetor zero se modelo não estiver disponível
|
||||
console.warn(
|
||||
`Modelo ${model} não disponível, usando vetor zero como fallback`
|
||||
);
|
||||
return new Float32Array(768); // 768 dimensões para nomic-embed-text
|
||||
}
|
||||
}
|
||||
|
||||
const EMOJI_CATEGORIES = {
|
||||
code: ["💻", "🖥️", "⌨️", "🔧", "🛠️"],
|
||||
idea: ["💡", "🧠", "💭", "🤔", "✨"],
|
||||
question: ["❓", "🤷", "🔍", "🧐", "📚"],
|
||||
creative: ["🎨", "🎭", "🎪", "🌈", "🎬"],
|
||||
business: ["💼", "📊", "💰", "📈", "🏢"],
|
||||
science: ["🔬", "🧪", "🔭", "⚗️", "🧬"],
|
||||
education: ["📖", "📝", "🎓", "✏️", "📚"],
|
||||
communication: ["💬", "📱", "📧", "🗣️", "💌"],
|
||||
general: ["🌟", "🎯", "🚀", "⚡", "🔥"],
|
||||
};
|
||||
|
||||
export async function generateChatTitle(firstMessage: string): Promise<string> {
|
||||
try {
|
||||
const response = await ollama.generate({
|
||||
model: "qwen2.5:0.5b",
|
||||
prompt: `Analise esta mensagem e crie um título curto e descritivo (MAX 30 caracteres, sem emoji):
|
||||
|
||||
Mensagem: "${firstMessage}"
|
||||
|
||||
Título (direto, sem explicação):`,
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
num_predict: 15,
|
||||
},
|
||||
});
|
||||
|
||||
let title = response.response
|
||||
.trim()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/^Título:\s*/i, "")
|
||||
.substring(0, 30);
|
||||
|
||||
// Selecionar emoji baseado no conteúdo
|
||||
const emoji = selectEmoji(firstMessage);
|
||||
|
||||
return `${emoji} ${title}`;
|
||||
} catch (error) {
|
||||
console.error("Erro ao gerar título:", error);
|
||||
// Fallback: usar título simples baseado no conteúdo
|
||||
const emoji = selectEmoji(firstMessage);
|
||||
const fallbackTitle = firstMessage.substring(0, 20).trim();
|
||||
return `${emoji} ${fallbackTitle}`;
|
||||
}
|
||||
}
|
||||
|
||||
function selectEmoji(content: string): string {
|
||||
const lower = content.toLowerCase();
|
||||
|
||||
if (/code|program|debug|function|class/.test(lower))
|
||||
return EMOJI_CATEGORIES.code[
|
||||
Math.floor(Math.random() * EMOJI_CATEGORIES.code.length)
|
||||
];
|
||||
if (/why|how|what|when|where/.test(lower))
|
||||
return EMOJI_CATEGORIES.question[
|
||||
Math.floor(Math.random() * EMOJI_CATEGORIES.question.length)
|
||||
];
|
||||
if (/idea|think|create|design/.test(lower))
|
||||
return EMOJI_CATEGORIES.idea[
|
||||
Math.floor(Math.random() * EMOJI_CATEGORIES.idea.length)
|
||||
];
|
||||
if (/business|money|sale|market/.test(lower))
|
||||
return EMOJI_CATEGORIES.business[
|
||||
Math.floor(Math.random() * EMOJI_CATEGORIES.business.length)
|
||||
];
|
||||
if (/science|research|experiment/.test(lower))
|
||||
return EMOJI_CATEGORIES.science[
|
||||
Math.floor(Math.random() * EMOJI_CATEGORIES.science.length)
|
||||
];
|
||||
|
||||
return EMOJI_CATEGORIES.general[
|
||||
Math.floor(Math.random() * EMOJI_CATEGORIES.general.length)
|
||||
];
|
||||
}
|
||||
Whitespace-only changes.
-100
@@ -1,100 +0,0 @@
|
||||
export type Device = "CPU" | "GPU";
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
sizeGB: number;
|
||||
quantization: string;
|
||||
device: Device;
|
||||
estCpuUsagePct?: number;
|
||||
estVramGB?: number;
|
||||
};
|
||||
|
||||
export const mockModels: ModelInfo[] = [
|
||||
{
|
||||
id: "llama3.1-8b-q4",
|
||||
name: "llama3.1:8b-q4",
|
||||
sizeGB: 4.8,
|
||||
quantization: "Q4",
|
||||
device: "CPU",
|
||||
estCpuUsagePct: 85,
|
||||
estVramGB: 0.0,
|
||||
},
|
||||
{
|
||||
id: "llama3.1-8b-q8",
|
||||
name: "llama3.1:8b-q8",
|
||||
sizeGB: 8.9,
|
||||
quantization: "Q8",
|
||||
device: "GPU",
|
||||
estCpuUsagePct: 20,
|
||||
estVramGB: 8,
|
||||
},
|
||||
{
|
||||
id: "mistral-7b-q4",
|
||||
name: "mistral:7b-q4",
|
||||
sizeGB: 4.1,
|
||||
quantization: "Q4",
|
||||
device: "CPU",
|
||||
estCpuUsagePct: 75,
|
||||
estVramGB: 0,
|
||||
},
|
||||
{
|
||||
id: "mistral-7b-q8",
|
||||
name: "mistral:7b-q8",
|
||||
sizeGB: 7.9,
|
||||
quantization: "Q8",
|
||||
device: "GPU",
|
||||
estCpuUsagePct: 18,
|
||||
estVramGB: 8,
|
||||
},
|
||||
{
|
||||
id: "qwen2.5-7b-q4",
|
||||
name: "qwen2.5:7b-q4",
|
||||
sizeGB: 4.2,
|
||||
quantization: "Q4",
|
||||
device: "CPU",
|
||||
estCpuUsagePct: 80,
|
||||
estVramGB: 0,
|
||||
},
|
||||
{
|
||||
id: "qwen2.5-7b-q8",
|
||||
name: "qwen2.5:7b-q8",
|
||||
sizeGB: 8.1,
|
||||
quantization: "Q8",
|
||||
device: "GPU",
|
||||
estCpuUsagePct: 22,
|
||||
estVramGB: 8,
|
||||
},
|
||||
];
|
||||
|
||||
export function inferQuantization(nameOrDetails: string | unknown): string {
|
||||
const s =
|
||||
typeof nameOrDetails === "string" ? nameOrDetails.toLowerCase() : "";
|
||||
if (s.includes("q8")) return "Q8";
|
||||
if (s.includes("q5")) return "Q5";
|
||||
if (s.includes("q4")) return "Q4";
|
||||
return "Q?";
|
||||
}
|
||||
|
||||
export function inferDevice(details?: unknown): Device {
|
||||
const s = typeof details === "string" ? details.toLowerCase() : "";
|
||||
if (s.includes("cuda") || s.includes("metal") || s.includes("gpu"))
|
||||
return "GPU";
|
||||
return "CPU";
|
||||
}
|
||||
|
||||
export function estimateCpuVram(
|
||||
info: Pick<ModelInfo, "sizeGB" | "quantization" | "device">,
|
||||
) {
|
||||
const q = info.quantization.toUpperCase();
|
||||
let cpuPct = 50;
|
||||
let vramGB = 0;
|
||||
if (info.device === "GPU") {
|
||||
vramGB = Math.max(4, Math.round(info.sizeGB));
|
||||
cpuPct = 20;
|
||||
} else {
|
||||
// heurística simples: quantizações mais baixas usam mais CPU
|
||||
cpuPct = q === "Q8" ? 60 : q === "Q5" ? 70 : 85;
|
||||
}
|
||||
return { cpuPct, vramGB };
|
||||
}
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
import { Ollama } from "ollama";
|
||||
import os from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
import { getCatalogModels } from "./catalog";
|
||||
|
||||
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: { ...(options || {}), keep_alive: "5m" },
|
||||
};
|
||||
|
||||
// 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 {
|
||||
const catalog = await getCatalogModels();
|
||||
|
||||
// Normalizar pesquisa
|
||||
const q = (query || "").trim().toLowerCase();
|
||||
|
||||
const filtered = q
|
||||
? catalog.filter((m) => {
|
||||
const name = m.name?.toLowerCase() || "";
|
||||
const desc = m.description?.toLowerCase() || "";
|
||||
return name.includes(q) || desc.includes(q);
|
||||
})
|
||||
: catalog;
|
||||
|
||||
// Modelos instalados
|
||||
const installedModels = await listModelsViaSdk();
|
||||
const installedNames = new Set(installedModels.map((m) => m.name));
|
||||
|
||||
// Retorno no formato esperado pelo frontend
|
||||
return filtered.map((m) => ({
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
url: m.url,
|
||||
tags_count: m.tags_count,
|
||||
installed: installedNames.has(m.name),
|
||||
remote: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar modelos remotos:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export async function getPreferencesDb(): Promise<Database.Database> {
|
||||
if (!db) {
|
||||
try {
|
||||
const dbPath = path.join(
|
||||
process.cwd(),
|
||||
"database",
|
||||
"ollahub-preferences.db"
|
||||
);
|
||||
|
||||
// Garantir que o diretório existe
|
||||
const dbDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Configurações de performance
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("cache_size = 10000");
|
||||
db.pragma("temp_store = memory");
|
||||
|
||||
// Criar tabela de preferências se não existir
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
preferences TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
console.log("✅ Banco de preferências SQLite conectado com sucesso");
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Erro ao conectar banco de preferências:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function getPreferences(userId: string = "default"): Promise<any> {
|
||||
try {
|
||||
const database = await getPreferencesDb();
|
||||
const stmt = database.prepare(
|
||||
"SELECT preferences FROM user_preferences WHERE user_id = ?"
|
||||
);
|
||||
const result = stmt.get(userId) as { preferences: string } | undefined;
|
||||
|
||||
if (result) {
|
||||
return JSON.parse(result.preferences);
|
||||
}
|
||||
|
||||
// Retornar preferências padrão
|
||||
return {
|
||||
selectedModel: null,
|
||||
systemPrompt: "",
|
||||
device: "auto",
|
||||
gpuIndex: 0,
|
||||
numGpu: 1,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar preferências:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPreferences(
|
||||
userId: string = "default",
|
||||
preferences: any
|
||||
): Promise<void> {
|
||||
try {
|
||||
const database = await getPreferencesDb();
|
||||
const stmt = database.prepare(`
|
||||
INSERT OR REPLACE INTO user_preferences (user_id, preferences, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(userId, JSON.stringify(preferences), Date.now());
|
||||
console.log("✅ Preferências salvas no SQLite");
|
||||
} catch (error) {
|
||||
console.error("Erro ao salvar preferências:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function closePreferencesDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Templates de prompt engineering para modelos sem suporte nativo a function calling
|
||||
*/
|
||||
|
||||
import type { MCPTool } from "@/lib/types/mcp-chat";
|
||||
|
||||
/**
|
||||
* Gera prompt de instruções para uso de tools via prompt engineering
|
||||
* @param tools Array de tools disponíveis
|
||||
* @returns String com instruções formatadas
|
||||
*/
|
||||
export function generateToolsPrompt(tools: MCPTool[]): string {
|
||||
if (tools.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const toolsDescription = tools
|
||||
.map((tool, index) => {
|
||||
const params = Object.entries(tool.inputSchema.properties || {})
|
||||
.map(([name, schema]: [string, any]) => {
|
||||
const required = tool.inputSchema.required?.includes(name)
|
||||
? " (required)"
|
||||
: " (optional)";
|
||||
const type = schema.type || "any";
|
||||
const desc = schema.description || "";
|
||||
return ` - ${name}: ${type}${required}${desc ? ` - ${desc}` : ""}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `${index + 1}. **${tool.name}**
|
||||
Description: ${tool.description}
|
||||
Parameters:
|
||||
${params || " (no parameters)"}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return `
|
||||
# FERRAMENTAS DISPONÍVEIS
|
||||
|
||||
Você tem acesso às seguintes ferramentas externas para auxiliar o usuário:
|
||||
|
||||
${toolsDescription}
|
||||
|
||||
## INSTRUÇÕES IMPORTANTES
|
||||
|
||||
1. **Quando usar ferramentas:**
|
||||
- Use ferramentas quando precisar de informações em tempo real
|
||||
- Use ferramentas quando precisar executar ações externas
|
||||
- Use ferramentas quando o usuário solicitar dados específicos
|
||||
|
||||
2. **Como usar ferramentas:**
|
||||
- Para chamar uma ferramenta, responda EXATAMENTE no seguinte formato JSON:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"tool_call": {
|
||||
"name": "nome_da_ferramenta",
|
||||
"parameters": {
|
||||
"parametro1": "valor1",
|
||||
"parametro2": "valor2"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- NÃO adicione texto antes ou depois do JSON
|
||||
- Certifique-se de que o JSON está bem formatado
|
||||
- Use apenas ferramentas da lista acima
|
||||
|
||||
3. **REGRA CRUCIAL - NUNCA MENCIONE AO USUÁRIO:**
|
||||
- NUNCA mencione que você está usando ferramentas
|
||||
- NUNCA mencione MCPs ou APIs externas
|
||||
- NUNCA diga "vou usar a ferramenta X"
|
||||
- NUNCA explique o processo técnico de como obteve informações
|
||||
- Apresente as informações naturalmente como se fossem seu conhecimento
|
||||
|
||||
4. **Após receber resultado da ferramenta:**
|
||||
- Você receberá uma mensagem do sistema com o resultado
|
||||
- Integre o resultado naturalmente na sua resposta
|
||||
- Responda ao usuário de forma fluida e natural
|
||||
- Cite informações específicas do resultado quando relevante
|
||||
|
||||
## EXEMPLO
|
||||
|
||||
Usuário: "Qual é o clima em São Paulo?"
|
||||
|
||||
Sua resposta:
|
||||
\`\`\`json
|
||||
{
|
||||
"tool_call": {
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"city": "São Paulo"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
[Sistema retorna: {"temperature": 25, "condition": "sunny"}]
|
||||
|
||||
Sua resposta final ao usuário:
|
||||
"O clima em São Paulo está ensolarado com 25°C."
|
||||
|
||||
**NÃO faça:**
|
||||
"Vou consultar a ferramenta de clima para você..."
|
||||
"Usei o get_weather e obtive..."
|
||||
"De acordo com a API..."
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera prompt condensado para economia de tokens
|
||||
* @param tools Array de tools disponíveis
|
||||
* @returns String com instruções compactas
|
||||
*/
|
||||
export function generateCompactToolsPrompt(tools: MCPTool[]): string {
|
||||
if (tools.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const toolsList = tools
|
||||
.map((tool) => {
|
||||
const params = Object.keys(tool.inputSchema.properties || {}).join(", ");
|
||||
return `${tool.name}(${params}): ${tool.description}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `
|
||||
TOOLS: ${tools.length} available
|
||||
${toolsList}
|
||||
|
||||
FORMAT: {"tool_call":{"name":"tool_name","parameters":{...}}}
|
||||
RULE: Never mention tools to user. Present results naturally.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai tool call de uma resposta do modelo
|
||||
* @param response Resposta do modelo
|
||||
* @returns Tool call extraído ou null
|
||||
*/
|
||||
export function extractToolCallFromResponse(response: string): {
|
||||
name: string;
|
||||
parameters: Record<string, any>;
|
||||
} | null {
|
||||
try {
|
||||
// Tentar extrair JSON da resposta
|
||||
const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
const jsonText = jsonMatch ? jsonMatch[1] : response;
|
||||
|
||||
const parsed = JSON.parse(jsonText.trim());
|
||||
|
||||
// Formato atual suportado pelo prompt engineering
|
||||
if (parsed.tool_call && parsed.tool_call.name) {
|
||||
return {
|
||||
name: parsed.tool_call.name,
|
||||
parameters: parsed.tool_call.parameters || {},
|
||||
};
|
||||
}
|
||||
|
||||
// Formato OpenAI-like: { type: 'function', function: { name, parameters|arguments } }
|
||||
// Algumas implementações usam `arguments` como string JSON
|
||||
if (parsed.type === "function" && parsed.function && parsed.function.name) {
|
||||
const fn = parsed.function;
|
||||
let params: any = fn.parameters ?? fn.arguments ?? {};
|
||||
if (typeof params === "string") {
|
||||
try {
|
||||
params = JSON.parse(params);
|
||||
} catch {
|
||||
// se não for JSON válido, mantém string (executor fará fallback)
|
||||
}
|
||||
}
|
||||
return { name: fn.name, parameters: params || {} };
|
||||
}
|
||||
|
||||
// Formato simples: { name: "tool", parameters: {...} }
|
||||
if (parsed.name && (parsed.parameters || parsed.arguments)) {
|
||||
let params: any = parsed.parameters ?? parsed.arguments ?? {};
|
||||
if (typeof params === "string") {
|
||||
try {
|
||||
params = JSON.parse(params);
|
||||
} catch {
|
||||
// mantém string se não for JSON válido
|
||||
}
|
||||
}
|
||||
return { name: parsed.name, parameters: params || {} };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Tentar extrair manualmente
|
||||
const nameMatch = response.match(/"name":\s*"([^"]+)"/);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1];
|
||||
// Captura tanto parameters quanto arguments
|
||||
const paramsMatch =
|
||||
response.match(/"parameters"\s*:\s*({[\s\S]*?})/) ||
|
||||
response.match(/"arguments"\s*:\s*("[\s\S]*?"|{[\s\S]*?})/);
|
||||
let parameters: any = {};
|
||||
if (paramsMatch) {
|
||||
const raw = paramsMatch[1];
|
||||
try {
|
||||
parameters = JSON.parse(raw);
|
||||
} catch {
|
||||
// caso venha como string com JSON dentro de aspas
|
||||
if (raw.startsWith('"') && raw.endsWith('"')) {
|
||||
try {
|
||||
parameters = JSON.parse(raw.slice(1, -1));
|
||||
} catch {
|
||||
parameters = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name, parameters };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata resultado de tool para injetar no contexto
|
||||
* @param toolName Nome do tool
|
||||
* @param result Resultado da execução
|
||||
* @returns Mensagem formatada para o sistema
|
||||
*/
|
||||
export function formatToolResultForContext(
|
||||
toolName: string,
|
||||
result: any
|
||||
): string {
|
||||
const resultJson =
|
||||
typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
||||
|
||||
return `[TOOL RESULT - ${toolName}]
|
||||
${resultJson}
|
||||
|
||||
[END TOOL RESULT]
|
||||
|
||||
Use as informações acima para responder ao usuário de forma natural. NÃO mencione que usou uma ferramenta.`;
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import {
|
||||
DeepNLPSearchParams,
|
||||
DeepNLPResponse,
|
||||
MCPServerConfig,
|
||||
MCPTool,
|
||||
MCPProvider,
|
||||
} from "@/lib/types/mcp";
|
||||
import { MCPCache } from "@/lib/cache/mcp-cache";
|
||||
|
||||
const DEEPNLP_API_BASE = "https://www.deepnlp.org/api/mcp_marketplace/v1";
|
||||
|
||||
export class DeepNLPService {
|
||||
// Mapear categorias do projeto para categorias DeepNLP
|
||||
private static categoryMap = {
|
||||
productivity: ["office"],
|
||||
development: ["code", "browser"],
|
||||
data: ["database"],
|
||||
integration: ["search", "payment"],
|
||||
other: ["map", "chart", "finance"],
|
||||
};
|
||||
|
||||
// Mapear categorias DeepNLP para categorias internas
|
||||
private static reverseCategoryMap: Record<string, MCPProvider["category"]> = {
|
||||
office: "office",
|
||||
code: "code",
|
||||
browser: "browser",
|
||||
database: "database",
|
||||
search: "search",
|
||||
payment: "payment",
|
||||
map: "map",
|
||||
chart: "chart",
|
||||
finance: "finance",
|
||||
};
|
||||
|
||||
/**
|
||||
* Buscar servidores MCP na API DeepNLP
|
||||
*/
|
||||
static async searchMCPs(
|
||||
params: DeepNLPSearchParams,
|
||||
retryCount = 0
|
||||
): Promise<DeepNLPResponse> {
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 1000; // 1 segundo
|
||||
|
||||
try {
|
||||
// Verificar cache primeiro
|
||||
const cacheKey = MCPCache.generateKey(params);
|
||||
const cachedResult = MCPCache.get(cacheKey);
|
||||
|
||||
if (cachedResult) {
|
||||
console.log("Returning cached search results");
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const url = new URL(DEEPNLP_API_BASE);
|
||||
|
||||
// Adicionar parâmetros de busca
|
||||
if (params.query) url.searchParams.set("query", params.query);
|
||||
if (params.category) url.searchParams.set("category", params.category);
|
||||
if (params.id) url.searchParams.set("id", params.id);
|
||||
if (params.page_id !== undefined)
|
||||
url.searchParams.set("page_id", params.page_id.toString());
|
||||
if (params.count_per_page !== undefined)
|
||||
url.searchParams.set(
|
||||
"count_per_page",
|
||||
params.count_per_page.toString()
|
||||
);
|
||||
if (params.offset !== undefined)
|
||||
url.searchParams.set("offset", params.offset.toString());
|
||||
if (params.mode) url.searchParams.set("mode", params.mode);
|
||||
|
||||
console.log(
|
||||
`DeepNLP API Request (attempt ${retryCount + 1}):`,
|
||||
url.toString()
|
||||
);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Ollahub-MCP-Client/1.0",
|
||||
},
|
||||
// Timeout de 10 segundos
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`DeepNLP API error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("DeepNLP API Response:", data);
|
||||
|
||||
// Salvar no cache
|
||||
MCPCache.setWithDataType(cacheKey, data, "search");
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error calling DeepNLP API (attempt ${retryCount + 1}):`,
|
||||
error
|
||||
);
|
||||
|
||||
// Se ainda temos tentativas restantes, tentar novamente com backoff exponencial
|
||||
if (retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount); // Backoff exponencial
|
||||
console.log(`Retrying in ${delay}ms...`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.searchMCPs(params, retryCount + 1);
|
||||
}
|
||||
|
||||
// Se esgotamos as tentativas, lançar erro
|
||||
throw new Error(
|
||||
`Failed to fetch MCPs after ${maxRetries + 1} attempts: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter configuração completa de um servidor específico
|
||||
*/
|
||||
static async getServerConfig(
|
||||
id: string,
|
||||
retryCount = 0
|
||||
): Promise<MCPServerConfig> {
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
try {
|
||||
// Verificar cache primeiro
|
||||
const cacheKey = MCPCache.generateServerConfigKey(id);
|
||||
const cachedResult = MCPCache.get(cacheKey);
|
||||
|
||||
if (cachedResult) {
|
||||
console.log("Returning cached server config");
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const [owner, repo] = id.split("/");
|
||||
if (!owner || !repo) {
|
||||
throw new Error(
|
||||
`Invalid MCP ID format: ${id}. Expected format: owner/repo`
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${DEEPNLP_API_BASE}/server/${owner}/${repo}`;
|
||||
console.log(
|
||||
`DeepNLP Server Config Request (attempt ${retryCount + 1}):`,
|
||||
url
|
||||
);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Ollahub-MCP-Client/1.0",
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`DeepNLP Server Config API error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("DeepNLP Server Config Response:", data);
|
||||
|
||||
// Salvar no cache
|
||||
MCPCache.setWithDataType(cacheKey, data, "config");
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error getting server config from DeepNLP (attempt ${retryCount + 1}):`,
|
||||
error
|
||||
);
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`Retrying server config in ${delay}ms...`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.getServerConfig(id, retryCount + 1);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to get server config after ${maxRetries + 1} attempts: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar ferramentas de um servidor específico
|
||||
*/
|
||||
static async getServerTools(id: string, retryCount = 0): Promise<MCPTool[]> {
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
try {
|
||||
// Verificar cache primeiro
|
||||
const cacheKey = MCPCache.generateServerToolsKey(id);
|
||||
const cachedResult = MCPCache.get(cacheKey);
|
||||
|
||||
if (cachedResult) {
|
||||
console.log("Returning cached server tools");
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const [owner, repo] = id.split("/");
|
||||
if (!owner || !repo) {
|
||||
throw new Error(
|
||||
`Invalid MCP ID format: ${id}. Expected format: owner/repo`
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${DEEPNLP_API_BASE}/tools/${owner}/${repo}`;
|
||||
console.log(
|
||||
`DeepNLP Server Tools Request (attempt ${retryCount + 1}):`,
|
||||
url
|
||||
);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Ollahub-MCP-Client/1.0",
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`DeepNLP Server Tools API error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("DeepNLP Server Tools Response:", data);
|
||||
|
||||
const tools = data.tools || [];
|
||||
|
||||
// Salvar no cache
|
||||
MCPCache.setWithDataType(cacheKey, tools, "tools");
|
||||
|
||||
return tools;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error getting server tools from DeepNLP (attempt ${retryCount + 1}):`,
|
||||
error
|
||||
);
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`Retrying server tools in ${delay}ms...`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.getServerTools(id, retryCount + 1);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to get server tools after ${maxRetries + 1} attempts: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformar resposta DeepNLP para formato interno MCPProvider
|
||||
*/
|
||||
static transformToMCPProvider(deepnlpItem: any): MCPProvider {
|
||||
const category = this.mapCategory(deepnlpItem.subfield || "other");
|
||||
|
||||
return {
|
||||
id: deepnlpItem.id || "",
|
||||
name: deepnlpItem.content_name || "",
|
||||
author: deepnlpItem.publisher_id || "",
|
||||
description: deepnlpItem.description || "",
|
||||
version: "1.0.0", // DeepNLP não fornece versão específica
|
||||
category,
|
||||
tags: deepnlpItem.content_tag_list
|
||||
? deepnlpItem.content_tag_list
|
||||
.split(",")
|
||||
.map((tag: string) => tag.trim())
|
||||
: [],
|
||||
rating: parseFloat(deepnlpItem.rating || "0"),
|
||||
totalRatings: parseInt(deepnlpItem.review_cnt || "0"),
|
||||
repository: deepnlpItem.website,
|
||||
homepage: deepnlpItem.website,
|
||||
installed: false, // Será verificado localmente
|
||||
subfield: deepnlpItem.subfield,
|
||||
field: deepnlpItem.field,
|
||||
config: deepnlpItem.config || [],
|
||||
tools: deepnlpItem.ext_info?.tools || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapear categoria DeepNLP para categoria interna
|
||||
*/
|
||||
static mapCategory(subfield: string): MCPProvider["category"] {
|
||||
return this.reverseCategoryMap[subfield?.toLowerCase()] || "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter mapeamento de categoria interna para DeepNLP
|
||||
*/
|
||||
static getCategoryMapping(internalCategory: string): string | undefined {
|
||||
const mappings =
|
||||
this.categoryMap[internalCategory as keyof typeof this.categoryMap];
|
||||
return mappings?.[0]; // Retorna a primeira categoria DeepNLP correspondente
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se a API está disponível
|
||||
*/
|
||||
static async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${DEEPNLP_API_BASE}?count_per_page=1`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Ollahub-MCP-Client/1.0",
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("DeepNLP API health check failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Serviço para detectar intenções do usuário através de palavras-chave
|
||||
*/
|
||||
|
||||
import type { DetectedIntent } from "@/lib/types/mcp-chat";
|
||||
|
||||
/**
|
||||
* Padrões de keywords para cada tipo de intent
|
||||
*/
|
||||
const INTENT_PATTERNS = {
|
||||
web_search: {
|
||||
keywords: [
|
||||
"buscar",
|
||||
"pesquisar",
|
||||
"procurar",
|
||||
"google",
|
||||
"encontrar",
|
||||
"search",
|
||||
"look up",
|
||||
"find",
|
||||
"o que é",
|
||||
"quem é",
|
||||
"onde fica",
|
||||
],
|
||||
regex:
|
||||
/\b(buscar|pesquisar|procurar|google|encontrar|search|look up|find)\b/i,
|
||||
},
|
||||
time: {
|
||||
keywords: [
|
||||
"que horas",
|
||||
"data",
|
||||
"horário",
|
||||
"hoje",
|
||||
"agora",
|
||||
"quando",
|
||||
"calendário",
|
||||
"time",
|
||||
"date",
|
||||
"current time",
|
||||
"what time",
|
||||
],
|
||||
regex:
|
||||
/\b(que horas|data|horário|hoje|agora|quando|time|date|current time)\b/i,
|
||||
},
|
||||
weather: {
|
||||
keywords: [
|
||||
"clima",
|
||||
"tempo",
|
||||
"temperatura",
|
||||
"chuva",
|
||||
"previsão",
|
||||
"weather",
|
||||
"forecast",
|
||||
"temperature",
|
||||
],
|
||||
regex:
|
||||
/\b(clima|tempo|temperatura|chuva|previsão|weather|forecast|temperature)\b/i,
|
||||
},
|
||||
file: {
|
||||
keywords: [
|
||||
"arquivo",
|
||||
"pasta",
|
||||
"diretório",
|
||||
"file",
|
||||
"folder",
|
||||
"directory",
|
||||
"ler arquivo",
|
||||
"read file",
|
||||
"listar",
|
||||
"list",
|
||||
],
|
||||
regex:
|
||||
/\b(arquivo|pasta|diretório|file|folder|directory|ler arquivo|read file)\b/i,
|
||||
},
|
||||
code: {
|
||||
keywords: [
|
||||
"código",
|
||||
"programar",
|
||||
"executar",
|
||||
"run",
|
||||
"execute",
|
||||
"compile",
|
||||
"debug",
|
||||
"git",
|
||||
"commit",
|
||||
],
|
||||
regex:
|
||||
/\b(código|programar|executar|run|execute|compile|debug|git|commit)\b/i,
|
||||
},
|
||||
database: {
|
||||
keywords: [
|
||||
"banco de dados",
|
||||
"database",
|
||||
"query",
|
||||
"sql",
|
||||
"select",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
],
|
||||
regex:
|
||||
/\b(banco de dados|database|query|sql|select|insert|update|delete)\b/i,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Detecta a intenção do usuário na mensagem
|
||||
* @param message Mensagem do usuário
|
||||
* @returns Intent detectado ou null
|
||||
*/
|
||||
export function detectIntent(message: string): DetectedIntent | null {
|
||||
const messageLower = message.toLowerCase();
|
||||
|
||||
for (const [type, pattern] of Object.entries(INTENT_PATTERNS)) {
|
||||
const matches = pattern.regex.test(messageLower);
|
||||
|
||||
if (matches) {
|
||||
// Calcular confiança baseado em quantas keywords foram encontradas
|
||||
const foundKeywords = pattern.keywords.filter((keyword) =>
|
||||
messageLower.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const confidence = Math.min(0.5 + foundKeywords.length * 0.2, 1.0);
|
||||
|
||||
return {
|
||||
type: type as DetectedIntent["type"],
|
||||
confidence,
|
||||
keywords: foundKeywords,
|
||||
suggestedTools: getSuggestedToolsForIntent(
|
||||
type as DetectedIntent["type"]
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna tools sugeridos para um tipo de intent
|
||||
* @param intentType Tipo de intent
|
||||
* @returns Array de nomes de tools sugeridos
|
||||
*/
|
||||
function getSuggestedToolsForIntent(
|
||||
intentType: DetectedIntent["type"]
|
||||
): string[] {
|
||||
const toolMapping: Record<DetectedIntent["type"], string[]> = {
|
||||
web_search: ["web_search", "duckduckgo_search", "google_search", "search"],
|
||||
time: ["get_current_time", "get_date", "current_time", "time"],
|
||||
weather: ["get_weather", "weather", "forecast"],
|
||||
file: ["read_file", "list_files", "filesystem", "file"],
|
||||
code: ["execute_code", "git", "run_command"],
|
||||
database: ["query", "sql", "database"],
|
||||
other: [],
|
||||
};
|
||||
|
||||
return toolMapping[intentType] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um tool name corresponde a algum intent detectado
|
||||
* @param toolName Nome do tool
|
||||
* @param intent Intent detectado
|
||||
* @returns true se o tool é relevante para o intent
|
||||
*/
|
||||
export function isToolRelevantForIntent(
|
||||
toolName: string,
|
||||
intent: DetectedIntent
|
||||
): boolean {
|
||||
const toolNameLower = toolName.toLowerCase();
|
||||
return intent.suggestedTools.some((suggested) =>
|
||||
toolNameLower.includes(suggested.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtra tools com base em um intent detectado
|
||||
* @param tools Array de tools disponíveis
|
||||
* @param intent Intent detectado
|
||||
* @returns Tools filtrados e ordenados por relevância
|
||||
*/
|
||||
export function filterToolsByIntent(
|
||||
tools: Array<{ name: string; description: string }>,
|
||||
intent: DetectedIntent
|
||||
): Array<{ name: string; description: string; relevance: number }> {
|
||||
return tools
|
||||
.map((tool) => {
|
||||
let relevance = 0;
|
||||
|
||||
// Verificar se o tool name contém alguma keyword sugerida
|
||||
const toolNameLower = tool.name.toLowerCase();
|
||||
for (const suggested of intent.suggestedTools) {
|
||||
if (toolNameLower.includes(suggested.toLowerCase())) {
|
||||
relevance += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se a descrição contém keywords do intent
|
||||
const descriptionLower = tool.description.toLowerCase();
|
||||
for (const keyword of intent.keywords) {
|
||||
if (descriptionLower.includes(keyword.toLowerCase())) {
|
||||
relevance += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...tool, relevance };
|
||||
})
|
||||
.filter((tool) => tool.relevance > 0)
|
||||
.sort((a, b) => b.relevance - a.relevance);
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Serviço de gerenciamento de ambientes isolados para servidores MCP
|
||||
*/
|
||||
|
||||
import { mkdir, rm, access } from "fs/promises";
|
||||
import path from "path";
|
||||
import { MCP_CONFIG } from "@/lib/config/mcp";
|
||||
import { commandExists, spawnWithProgress } from "@/lib/utils/system";
|
||||
import type {
|
||||
MCPEnvironment,
|
||||
SystemDependencies,
|
||||
} from "@/lib/types/mcp-installer";
|
||||
|
||||
export class MCPEnvironmentService {
|
||||
/**
|
||||
* Verifica dependências do sistema
|
||||
*/
|
||||
static async checkSystemDependencies(): Promise<SystemDependencies> {
|
||||
const [node, npm, python, pip, cargo, git] = await Promise.all([
|
||||
commandExists("node"),
|
||||
commandExists("npm"),
|
||||
commandExists("python3"),
|
||||
commandExists("pip3"),
|
||||
commandExists("cargo"),
|
||||
commandExists("git"),
|
||||
]);
|
||||
|
||||
return { node, npm, python, pip, cargo, git };
|
||||
}
|
||||
|
||||
/**
|
||||
* Garante que o diretório de ambiente existe
|
||||
*/
|
||||
static async ensureEnvironmentDirectory(
|
||||
type: "npm" | "python" | "rust"
|
||||
): Promise<string> {
|
||||
let dir: string;
|
||||
|
||||
switch (type) {
|
||||
case "npm":
|
||||
dir = MCP_CONFIG.npmDir;
|
||||
break;
|
||||
case "python":
|
||||
dir = MCP_CONFIG.pythonDir;
|
||||
break;
|
||||
case "rust":
|
||||
dir = MCP_CONFIG.rustDir;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
console.log(`✓ Environment directory ensured: ${dir}`);
|
||||
return dir;
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`Failed to create environment directory: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria ambiente virtual Python para um MCP específico
|
||||
*/
|
||||
static async createPythonVenv(
|
||||
mcpId: string,
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<string> {
|
||||
const venvPath = path.join(MCP_CONFIG.pythonDir, mcpId);
|
||||
|
||||
// Verificar se já existe
|
||||
try {
|
||||
await access(venvPath);
|
||||
onProgress?.(`Virtual environment already exists at ${venvPath}`);
|
||||
return venvPath;
|
||||
} catch {
|
||||
// Não existe, criar novo
|
||||
}
|
||||
|
||||
onProgress?.("Creating Python virtual environment...");
|
||||
|
||||
try {
|
||||
const result = await spawnWithProgress(
|
||||
"python3",
|
||||
["-m", "venv", venvPath],
|
||||
{
|
||||
onOutput: (data) => onProgress?.(data.trim()),
|
||||
timeout: 60000, // 1 minuto para criar venv
|
||||
}
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Failed to create venv: ${result.stderr || result.stdout}`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress?.(`✓ Virtual environment created at ${venvPath}`);
|
||||
return venvPath;
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to create Python venv: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém caminho do executável para um ambiente
|
||||
*/
|
||||
static getExecutablePath(
|
||||
type: "npm" | "python" | "rust",
|
||||
mcpId: string,
|
||||
packageName?: string
|
||||
): { executable: string; args: string[] } {
|
||||
switch (type) {
|
||||
case "npm":
|
||||
// Para npm, usar npx para executar pacotes instalados localmente
|
||||
return {
|
||||
executable: "npx",
|
||||
args: packageName ? ["--prefix", MCP_CONFIG.npmDir, packageName] : [],
|
||||
};
|
||||
|
||||
case "python": {
|
||||
const venvPath = path.join(MCP_CONFIG.pythonDir, mcpId);
|
||||
const pythonBin =
|
||||
process.platform === "win32"
|
||||
? path.join(venvPath, "Scripts", "python.exe")
|
||||
: path.join(venvPath, "bin", "python");
|
||||
|
||||
return {
|
||||
executable: pythonBin,
|
||||
args: packageName ? ["-m", packageName] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "rust": {
|
||||
// Para Rust, o binário estará em target/release/
|
||||
const binaryPath = path.join(
|
||||
MCP_CONFIG.rustDir,
|
||||
mcpId,
|
||||
"target",
|
||||
"release",
|
||||
packageName || mcpId
|
||||
);
|
||||
|
||||
return {
|
||||
executable: binaryPath,
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa ambiente de um MCP (desinstalação)
|
||||
*/
|
||||
static async cleanupEnvironment(environment: MCPEnvironment): Promise<void> {
|
||||
try {
|
||||
console.log(`Cleaning up environment at ${environment.path}`);
|
||||
|
||||
await rm(environment.path, { recursive: true, force: true });
|
||||
|
||||
console.log(`✓ Environment cleaned: ${environment.path}`);
|
||||
} catch (error: any) {
|
||||
console.error(`Warning: Failed to cleanup environment: ${error.message}`);
|
||||
// Não lançar erro - cleanup é best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um ambiente está válido
|
||||
*/
|
||||
static async validateEnvironment(
|
||||
environment: MCPEnvironment
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Verificar se o diretório existe
|
||||
await access(environment.path);
|
||||
|
||||
// Verificar se o executável existe
|
||||
await access(environment.executable);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém informações sobre o ambiente de um MCP
|
||||
*/
|
||||
static async getEnvironmentInfo(
|
||||
mcpId: string,
|
||||
type: "npm" | "python" | "rust"
|
||||
): Promise<{
|
||||
exists: boolean;
|
||||
path: string;
|
||||
size?: number;
|
||||
}> {
|
||||
const envPath = path.join(
|
||||
type === "npm"
|
||||
? MCP_CONFIG.npmDir
|
||||
: type === "python"
|
||||
? path.join(MCP_CONFIG.pythonDir, mcpId)
|
||||
: path.join(MCP_CONFIG.rustDir, mcpId)
|
||||
);
|
||||
|
||||
try {
|
||||
await access(envPath);
|
||||
// TODO: Calcular tamanho do diretório se necessário
|
||||
return { exists: true, path: envPath };
|
||||
} catch {
|
||||
return { exists: false, path: envPath };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
/**
|
||||
* Serviço executor de MCPs - spawna processos e executa tools via JSON-RPC
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
import type { MCPExecutionResult, MCPTool } from "@/lib/types/mcp-chat";
|
||||
|
||||
const EXECUTION_TIMEOUT = 30000; // 30 segundos
|
||||
|
||||
export class MCPExecutor {
|
||||
/**
|
||||
* Executa um tool de MCP
|
||||
* @param mcpId ID do MCP instalado
|
||||
* @param toolName Nome do tool a executar
|
||||
* @param parameters Parâmetros do tool
|
||||
* @returns Resultado da execução
|
||||
*/
|
||||
static async executeMCPTool(
|
||||
mcpId: string,
|
||||
toolName: string,
|
||||
parameters: Record<string, any>
|
||||
): Promise<MCPExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Obter dados do MCP instalado
|
||||
const mcpData = MCPRepository.getInstalledMCP(mcpId);
|
||||
if (!mcpData) {
|
||||
throw new Error(`MCP ${mcpId} not found or not installed`);
|
||||
}
|
||||
|
||||
// Verificar se está pronto
|
||||
// Se status é undefined/null, considerar como "ready" (MCPs instalados antes da implementação de status)
|
||||
if (mcpData.status && mcpData.status !== "ready") {
|
||||
throw new Error(
|
||||
`MCP ${mcpId} is not ready (status: ${mcpData.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Obter comando executável
|
||||
// mcpData.config já vem parseado do repositório, mas pode ser string em casos legados
|
||||
const config =
|
||||
typeof mcpData.config === "string"
|
||||
? JSON.parse(mcpData.config)
|
||||
: mcpData.config || {};
|
||||
const commandFromConfig = config.command;
|
||||
const argsFromConfig = Array.isArray(config.args) ? config.args : [];
|
||||
|
||||
let command = commandFromConfig;
|
||||
let args = argsFromConfig;
|
||||
|
||||
// Se não houver command em config, reconstruir a partir de executable_command (se existir)
|
||||
if (!command && mcpData.executable_command) {
|
||||
const parts = String(mcpData.executable_command).trim().split(/\s+/);
|
||||
command = parts[0];
|
||||
args = parts.slice(1);
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
throw new Error(`No executable command found for MCP ${mcpId}`);
|
||||
}
|
||||
|
||||
console.log(`🚀 Executing MCP tool: ${mcpId}.${toolName}`);
|
||||
console.log(`Command: ${command} ${args.join(" ")}`);
|
||||
|
||||
// Spawnar processo do MCP
|
||||
const result = await this.spawnMCPProcess(
|
||||
command,
|
||||
args,
|
||||
toolName,
|
||||
parameters,
|
||||
mcpData.environment_path
|
||||
);
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
toolName,
|
||||
mcpId,
|
||||
result,
|
||||
executionTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
console.error(`❌ Error executing MCP tool ${mcpId}.${toolName}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
toolName,
|
||||
mcpId,
|
||||
error: error.message || "Unknown error",
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawna processo do MCP e executa JSON-RPC
|
||||
* @param command Comando executável
|
||||
* @param args Argumentos do comando
|
||||
* @param toolName Nome do tool
|
||||
* @param parameters Parâmetros do tool
|
||||
* @param environmentPath Caminho do ambiente (opcional)
|
||||
* @returns Resultado do tool
|
||||
*/
|
||||
private static async spawnMCPProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
toolName: string,
|
||||
parameters: Record<string, any>,
|
||||
environmentPath?: string
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env = {
|
||||
...globalThis.process.env,
|
||||
...(environmentPath && {
|
||||
PATH: `${environmentPath}/node_modules/.bin:${globalThis.process.env.PATH}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd: environmentPath || globalThis.process.cwd(),
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let responseReceived = false;
|
||||
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => {
|
||||
if (!responseReceived) {
|
||||
childProcess.kill();
|
||||
reject(new Error(`Execution timeout after ${EXECUTION_TIMEOUT}ms`));
|
||||
}
|
||||
}, EXECUTION_TIMEOUT);
|
||||
|
||||
// Capturar stdout
|
||||
childProcess.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
// Loga por linhas para diagnóstico
|
||||
const printedLines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim().length > 0);
|
||||
for (const l of printedLines) {
|
||||
console.log(`📥 [MCP stdout] ${l}`);
|
||||
}
|
||||
|
||||
// Tentar parsear respostas JSON-RPC
|
||||
const parsedLines = stdout.split("\n");
|
||||
for (const line of parsedLines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
console.log("🔎 [MCP stdout parsed]", parsed);
|
||||
if (parsed.id === 2 && parsed.result) {
|
||||
// Resposta do tools/call
|
||||
clearTimeout(timeout);
|
||||
responseReceived = true;
|
||||
childProcess.kill();
|
||||
resolve(parsed.result);
|
||||
}
|
||||
} catch (e) {
|
||||
// Linha não é JSON válido, continuar
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Capturar stderr
|
||||
childProcess.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
const errLines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim().length > 0);
|
||||
for (const l of errLines) {
|
||||
console.warn(`⚠️ [MCP stderr] ${l}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Erro de processo
|
||||
childProcess.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Process error: ${error.message}`));
|
||||
});
|
||||
|
||||
// Processo encerrou
|
||||
childProcess.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (!responseReceived) {
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(`Process exited with code ${code}. stderr: ${stderr}`)
|
||||
);
|
||||
} else {
|
||||
reject(new Error("No valid response received from MCP"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enviar requisições JSON-RPC
|
||||
try {
|
||||
// 1. Initialize
|
||||
const initRequest = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "ollahub", version: "1.0.0" },
|
||||
},
|
||||
id: 1,
|
||||
};
|
||||
console.log("📤 [MCP send] initialize", initRequest);
|
||||
childProcess.stdin?.write(JSON.stringify(initRequest) + "\n");
|
||||
|
||||
// Aguardar um pouco antes de enviar tool call
|
||||
setTimeout(() => {
|
||||
// 2. Call tool
|
||||
const toolCallRequest = {
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: parameters,
|
||||
},
|
||||
id: 2,
|
||||
};
|
||||
console.log("📤 [MCP send] tools/call", toolCallRequest);
|
||||
childProcess.stdin?.write(JSON.stringify(toolCallRequest) + "\n");
|
||||
childProcess.stdin?.end();
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeout);
|
||||
childProcess.kill();
|
||||
reject(new Error(`Failed to send JSON-RPC request: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tools disponíveis de um MCP
|
||||
* @param mcpId ID do MCP
|
||||
* @returns Array de tools
|
||||
*/
|
||||
static getAvailableTools(mcpId: string): MCPTool[] {
|
||||
try {
|
||||
const mcpData = MCPRepository.getInstalledMCP(mcpId);
|
||||
if (!mcpData || !mcpData.tools) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Se já é um array (já foi parseado), retornar diretamente
|
||||
if (Array.isArray(mcpData.tools)) {
|
||||
return mcpData.tools;
|
||||
}
|
||||
|
||||
// Se é string, parsear
|
||||
if (typeof mcpData.tools === "string") {
|
||||
const tools = JSON.parse(mcpData.tools);
|
||||
return Array.isArray(tools) ? tools : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error getting available tools for ${mcpId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar parâmetros contra schema do tool
|
||||
* @param parameters Parâmetros fornecidos
|
||||
* @param tool Definição do tool
|
||||
* @returns true se válido, false caso contrário
|
||||
*/
|
||||
static validateParameters(
|
||||
parameters: Record<string, any>,
|
||||
tool: MCPTool
|
||||
): { valid: boolean; error?: string } {
|
||||
try {
|
||||
const schema = tool.inputSchema;
|
||||
|
||||
// Verificar campos obrigatórios
|
||||
if (schema.required) {
|
||||
for (const required of schema.required) {
|
||||
if (!(required in parameters)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Missing required parameter: ${required}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validação básica de tipos
|
||||
for (const [key, value] of Object.entries(parameters)) {
|
||||
if (schema.properties && schema.properties[key]) {
|
||||
const propSchema = schema.properties[key];
|
||||
const expectedType = propSchema.type;
|
||||
|
||||
if (expectedType) {
|
||||
const actualType = typeof value;
|
||||
// Tratar objeto vazio como "ausente" quando tipo esperado não é object
|
||||
if (
|
||||
value &&
|
||||
actualType === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 0 &&
|
||||
expectedType !== "object"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const valid =
|
||||
(expectedType === "string" && actualType === "string") ||
|
||||
(expectedType === "number" && actualType === "number") ||
|
||||
(expectedType === "boolean" && actualType === "boolean") ||
|
||||
(expectedType === "object" && actualType === "object") ||
|
||||
(expectedType === "array" && Array.isArray(value));
|
||||
|
||||
if (!valid) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Parameter ${key} should be ${expectedType}, got ${actualType}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
/**
|
||||
* Serviço de instalação de servidores MCP
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import { MCP_CONFIG } from "@/lib/config/mcp";
|
||||
import { MCPEnvironmentService } from "./mcp-environment";
|
||||
import { sanitizePackageName, spawnWithProgress } from "@/lib/utils/system";
|
||||
import type {
|
||||
MCPEnvironment,
|
||||
InstallationProgress,
|
||||
MCPInstallationStatus,
|
||||
InstallationConfig,
|
||||
} from "@/lib/types/mcp-installer";
|
||||
|
||||
export class MCPInstallerService {
|
||||
/**
|
||||
* Instala um servidor MCP baseado na configuração
|
||||
*/
|
||||
static async install(
|
||||
config: InstallationConfig,
|
||||
onProgress: (progress: InstallationProgress) => void
|
||||
): Promise<MCPEnvironment> {
|
||||
// Verificar dependências do sistema
|
||||
onProgress({
|
||||
status: "checking_dependencies" as MCPInstallationStatus,
|
||||
message: "Checking system dependencies...",
|
||||
percentage: 5,
|
||||
});
|
||||
|
||||
const dependencies = await MCPEnvironmentService.checkSystemDependencies();
|
||||
|
||||
// Determinar tipo de instalação
|
||||
const installType = this.determineInstallType(config, dependencies);
|
||||
|
||||
onProgress({
|
||||
status: "downloading" as MCPInstallationStatus,
|
||||
message: `Preparing ${installType} installation...`,
|
||||
percentage: 10,
|
||||
});
|
||||
|
||||
// Delegar para instalador específico
|
||||
switch (installType) {
|
||||
case "npm":
|
||||
return await this.installNPM(config, onProgress, dependencies);
|
||||
case "python":
|
||||
return await this.installPython(config, onProgress, dependencies);
|
||||
case "rust":
|
||||
return await this.installRust(config, onProgress, dependencies);
|
||||
default:
|
||||
throw new Error(`Unsupported installation type: ${installType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina o tipo de instalação baseado na configuração
|
||||
*/
|
||||
private static determineInstallType(
|
||||
config: InstallationConfig,
|
||||
dependencies: any
|
||||
): "npm" | "python" | "rust" {
|
||||
// Preferir package_registry se disponível
|
||||
if (config.packageRegistry === "npm" && dependencies.npm) {
|
||||
return "npm";
|
||||
}
|
||||
if (config.packageRegistry === "pypi" && dependencies.python) {
|
||||
return "python";
|
||||
}
|
||||
if (config.packageRegistry === "cargo" && dependencies.cargo) {
|
||||
return "rust";
|
||||
}
|
||||
|
||||
// Tentar detectar pelo nome do pacote
|
||||
if (config.packageName) {
|
||||
if (
|
||||
config.packageName.startsWith("@") ||
|
||||
config.packageName.includes("/")
|
||||
) {
|
||||
if (dependencies.npm) return "npm";
|
||||
}
|
||||
if (
|
||||
config.packageName.includes("mcp-server") ||
|
||||
config.packageName.includes("mcp_server")
|
||||
) {
|
||||
if (dependencies.python) return "python";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback para npm se disponível
|
||||
if (dependencies.npm) return "npm";
|
||||
if (dependencies.python) return "python";
|
||||
if (dependencies.cargo) return "rust";
|
||||
|
||||
throw new Error("No suitable package manager found");
|
||||
}
|
||||
|
||||
/**
|
||||
* Instala pacote NPM
|
||||
*/
|
||||
private static async installNPM(
|
||||
config: InstallationConfig,
|
||||
onProgress: (progress: InstallationProgress) => void,
|
||||
dependencies: any
|
||||
): Promise<MCPEnvironment> {
|
||||
if (!dependencies.npm) {
|
||||
throw new Error("npm is not installed on this system");
|
||||
}
|
||||
|
||||
// Sanitizar nome do pacote
|
||||
const packageName = sanitizePackageName(config.packageName);
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: `Installing npm package: ${packageName}`,
|
||||
percentage: 20,
|
||||
logs: config.enableLogs
|
||||
? [`Installing ${packageName} via npm...`]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Garantir diretório npm existe
|
||||
const npmDir = await MCPEnvironmentService.ensureEnvironmentDirectory(
|
||||
"npm"
|
||||
);
|
||||
|
||||
// Instalar pacote localmente no diretório npm
|
||||
const logs: string[] = [];
|
||||
|
||||
try {
|
||||
const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const result = await spawnWithProgress(
|
||||
npmBin,
|
||||
["install", packageName, "--prefix", npmDir],
|
||||
{
|
||||
cwd: npmDir,
|
||||
timeout: MCP_CONFIG.installTimeout,
|
||||
onOutput: (data, isError) => {
|
||||
if (config.enableLogs) {
|
||||
logs.push(data);
|
||||
}
|
||||
|
||||
// Atualizar progresso baseado na saída
|
||||
let percentage = 40;
|
||||
if (data.includes("fetchMetadata")) percentage = 30;
|
||||
if (data.includes("extract")) percentage = 50;
|
||||
if (data.includes("finalize")) percentage = 70;
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: isError
|
||||
? `npm: ${data.substring(0, 100)}`
|
||||
: `Installing: ${data.substring(0, 100)}`,
|
||||
percentage,
|
||||
logs: config.enableLogs ? [data] : undefined,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`npm install failed: ${result.stderr || result.stdout}`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "npm package installed successfully",
|
||||
percentage: 80,
|
||||
logs: config.enableLogs ? ["✓ Installation completed"] : undefined,
|
||||
});
|
||||
|
||||
// Construir ambiente
|
||||
// Para NPM, executar npx do diretório onde foi instalado (não usa --prefix que não funciona)
|
||||
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
const environment: MCPEnvironment = {
|
||||
type: "npm",
|
||||
path: npmDir,
|
||||
executable: npxBin,
|
||||
args: ["-y", packageName],
|
||||
};
|
||||
|
||||
return environment;
|
||||
} catch (error: any) {
|
||||
const msg = String(error?.message || "");
|
||||
if (msg.includes("spawn EINVAL") || msg.includes("Failed to spawn process") || msg.includes("ENOENT")) {
|
||||
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Falling back to npx runtime execution (no preinstall)",
|
||||
percentage: 60,
|
||||
logs: config.enableLogs ? [msg] : undefined,
|
||||
});
|
||||
|
||||
const environment: MCPEnvironment = {
|
||||
type: "npm",
|
||||
path: npmDir,
|
||||
executable: npxBin,
|
||||
args: ["-y", packageName],
|
||||
};
|
||||
return environment;
|
||||
}
|
||||
throw new Error(`NPM installation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instala pacote Python
|
||||
*/
|
||||
private static async installPython(
|
||||
config: InstallationConfig,
|
||||
onProgress: (progress: InstallationProgress) => void,
|
||||
dependencies: any
|
||||
): Promise<MCPEnvironment> {
|
||||
if (!dependencies.python || !dependencies.pip) {
|
||||
throw new Error("Python3 and pip3 are not installed on this system");
|
||||
}
|
||||
|
||||
const packageName = sanitizePackageName(config.packageName);
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Creating Python virtual environment...",
|
||||
percentage: 20,
|
||||
logs: config.enableLogs ? ["Creating venv..."] : undefined,
|
||||
});
|
||||
|
||||
// Garantir diretório python existe
|
||||
await MCPEnvironmentService.ensureEnvironmentDirectory("python");
|
||||
|
||||
// Criar venv para este MCP
|
||||
const venvPath = await MCPEnvironmentService.createPythonVenv(
|
||||
config.mcpId,
|
||||
(msg) => {
|
||||
if (config.enableLogs) {
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: msg,
|
||||
percentage: 30,
|
||||
logs: [msg],
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: `Installing Python package: ${packageName}`,
|
||||
percentage: 40,
|
||||
logs: config.enableLogs
|
||||
? [`Installing ${packageName} via pip...`]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Caminho para pip no venv
|
||||
const pipBin =
|
||||
process.platform === "win32"
|
||||
? path.join(venvPath, "Scripts", "pip.exe")
|
||||
: path.join(venvPath, "bin", "pip");
|
||||
|
||||
const pythonBin =
|
||||
process.platform === "win32"
|
||||
? path.join(venvPath, "Scripts", "python.exe")
|
||||
: path.join(venvPath, "bin", "python");
|
||||
|
||||
try {
|
||||
const result = await spawnWithProgress(pipBin, ["install", packageName], {
|
||||
cwd: venvPath,
|
||||
timeout: MCP_CONFIG.installTimeout,
|
||||
onOutput: (data, isError) => {
|
||||
if (config.enableLogs) {
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: `pip: ${data.substring(0, 100)}`,
|
||||
percentage: 60,
|
||||
logs: [data],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`pip install failed: ${result.stderr || result.stdout}`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Python package installed successfully",
|
||||
percentage: 80,
|
||||
logs: config.enableLogs ? ["✓ Installation completed"] : undefined,
|
||||
});
|
||||
|
||||
// Extrair módulo Python do nome do pacote (ex: mcp-server-git -> mcp_server_git)
|
||||
const moduleName = packageName.replace(/-/g, "_");
|
||||
|
||||
const environment: MCPEnvironment = {
|
||||
type: "python",
|
||||
path: venvPath,
|
||||
executable: pythonBin,
|
||||
args: ["-m", moduleName],
|
||||
};
|
||||
|
||||
return environment;
|
||||
} catch (error: any) {
|
||||
throw new Error(`Python installation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instala servidor Rust (via git clone + cargo build)
|
||||
*/
|
||||
private static async installRust(
|
||||
config: InstallationConfig,
|
||||
onProgress: (progress: InstallationProgress) => void,
|
||||
dependencies: any
|
||||
): Promise<MCPEnvironment> {
|
||||
if (!dependencies.cargo || !dependencies.git) {
|
||||
throw new Error("Cargo and Git are not installed on this system");
|
||||
}
|
||||
|
||||
if (!config.repositoryUrl) {
|
||||
throw new Error("Repository URL is required for Rust installation");
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "downloading" as MCPInstallationStatus,
|
||||
message: "Cloning Rust repository...",
|
||||
percentage: 20,
|
||||
logs: config.enableLogs
|
||||
? [`Cloning ${config.repositoryUrl}...`]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Garantir diretório rust existe
|
||||
await MCPEnvironmentService.ensureEnvironmentDirectory("rust");
|
||||
|
||||
const projectPath = path.join(MCP_CONFIG.rustDir, config.mcpId);
|
||||
|
||||
try {
|
||||
// Clonar repositório
|
||||
const cloneResult = await spawnWithProgress(
|
||||
"git",
|
||||
["clone", config.repositoryUrl, projectPath],
|
||||
{
|
||||
cwd: MCP_CONFIG.rustDir,
|
||||
timeout: MCP_CONFIG.installTimeout,
|
||||
onOutput: (data) => {
|
||||
if (config.enableLogs) {
|
||||
onProgress({
|
||||
status: "downloading" as MCPInstallationStatus,
|
||||
message: `git: ${data.substring(0, 100)}`,
|
||||
percentage: 30,
|
||||
logs: [data],
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (cloneResult.exitCode !== 0) {
|
||||
throw new Error(`git clone failed: ${cloneResult.stderr}`);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Building Rust project...",
|
||||
percentage: 40,
|
||||
logs: config.enableLogs ? ["Building with cargo..."] : undefined,
|
||||
});
|
||||
|
||||
// Compilar com cargo
|
||||
const buildResult = await spawnWithProgress(
|
||||
"cargo",
|
||||
["build", "--release"],
|
||||
{
|
||||
cwd: projectPath,
|
||||
timeout: MCP_CONFIG.installTimeout,
|
||||
onOutput: (data, isError) => {
|
||||
if (config.enableLogs) {
|
||||
let percentage = 50;
|
||||
if (data.includes("Compiling")) percentage = 60;
|
||||
if (data.includes("Finished")) percentage = 80;
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: `cargo: ${data.substring(0, 100)}`,
|
||||
percentage,
|
||||
logs: [data],
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (buildResult.exitCode !== 0) {
|
||||
throw new Error(`cargo build failed: ${buildResult.stderr}`);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
status: "installing" as MCPInstallationStatus,
|
||||
message: "Rust project built successfully",
|
||||
percentage: 80,
|
||||
logs: config.enableLogs ? ["✓ Build completed"] : undefined,
|
||||
});
|
||||
|
||||
// Determinar nome do binário
|
||||
const binaryName = config.packageName || config.mcpId;
|
||||
const binaryPath = path.join(
|
||||
projectPath,
|
||||
"target",
|
||||
"release",
|
||||
process.platform === "win32" ? `${binaryName}.exe` : binaryName
|
||||
);
|
||||
|
||||
const environment: MCPEnvironment = {
|
||||
type: "rust",
|
||||
path: projectPath,
|
||||
executable: binaryPath,
|
||||
args: [],
|
||||
};
|
||||
|
||||
return environment;
|
||||
} catch (error: any) {
|
||||
throw new Error(`Rust installation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Serviço de validação de protocolo MCP (JSON-RPC)
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import type {
|
||||
MCPEnvironment,
|
||||
ValidationResult,
|
||||
} from "@/lib/types/mcp-installer";
|
||||
import { MCP_CONFIG } from "@/lib/config/mcp";
|
||||
|
||||
export class MCPValidatorService {
|
||||
/**
|
||||
* Valida se um servidor MCP está funcionando corretamente
|
||||
* Testa o protocolo JSON-RPC do MCP
|
||||
*/
|
||||
static async validateMCPServer(
|
||||
environment: MCPEnvironment,
|
||||
config: any,
|
||||
timeoutMs: number = MCP_CONFIG.testTimeout
|
||||
): Promise<ValidationResult> {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
let hasResolved = false;
|
||||
|
||||
// Preparar comando e argumentos
|
||||
const command = environment.executable;
|
||||
const args = environment.args || [];
|
||||
const env = {
|
||||
...process.env,
|
||||
...environment.env,
|
||||
...(config.env || {}),
|
||||
};
|
||||
|
||||
// Para NPM, adicionar node_modules/.bin ao PATH para encontrar binários locais
|
||||
if (environment.type === "npm" && environment.path) {
|
||||
const nodeBinPath = `${environment.path}/node_modules/.bin`;
|
||||
env.PATH = `${nodeBinPath}:${env.PATH || process.env.PATH}`;
|
||||
}
|
||||
|
||||
console.log(`🧪 Testing MCP server: ${command} ${args.join(" ")}`);
|
||||
|
||||
try {
|
||||
// Spawnar processo do servidor MCP
|
||||
const serverProcess = spawn(command, args, {
|
||||
cwd: environment.path, // Executar do diretório de instalação
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32", // Workaround para *.cmd no Windows
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const responses: any[] = [];
|
||||
let currentBuffer = "";
|
||||
|
||||
// Timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
serverProcess.kill();
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Timeout after ${timeoutMs}ms. Server did not respond.`,
|
||||
});
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Capturar stdout (respostas JSON-RPC)
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
currentBuffer += text;
|
||||
|
||||
// Processar respostas JSON-RPC linha por linha
|
||||
const lines = currentBuffer.split("\n");
|
||||
currentBuffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const response = JSON.parse(trimmed);
|
||||
responses.push(response);
|
||||
console.log(
|
||||
"📨 MCP Response:",
|
||||
JSON.stringify(response).substring(0, 200)
|
||||
);
|
||||
|
||||
// Verificar se recebemos resposta válida de initialize
|
||||
if (
|
||||
response.id === 1 &&
|
||||
response.result &&
|
||||
response.result.capabilities
|
||||
) {
|
||||
// Protocolo MCP detectado, agora listar ferramentas
|
||||
this.sendRequest(serverProcess, {
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/list",
|
||||
id: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Verificar se recebemos lista de ferramentas
|
||||
if (response.id === 2 && response.result) {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
serverProcess.kill();
|
||||
|
||||
const tools = Array.isArray(response.result.tools)
|
||||
? response.result.tools.map((tool: any) => ({
|
||||
name: tool.name || "unnamed",
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}))
|
||||
: [];
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
protocol: "mcp",
|
||||
tools,
|
||||
capabilities: responses[0]?.result?.capabilities,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Não é JSON válido, ignorar
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Capturar stderr
|
||||
serverProcess.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
// Error handler
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Failed to spawn server: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Exit handler
|
||||
serverProcess.on("exit", (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
|
||||
// Se saiu muito rápido sem responder, é um erro
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < 1000) {
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Server exited quickly with code ${code}. stderr: ${stderr}`,
|
||||
});
|
||||
} else {
|
||||
// Servidor pode ter processado e saído normalmente
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Server exited with code ${code} before completing validation`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Aguardar servidor estar pronto (pequeno delay)
|
||||
setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
// Enviar requisição de initialize
|
||||
this.sendRequest(serverProcess, {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: "ollahub",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
id: 1,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
resolve({
|
||||
success: false,
|
||||
protocol: "unknown",
|
||||
tools: [],
|
||||
error: `Validation error: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envia requisição JSON-RPC para o servidor
|
||||
*/
|
||||
private static sendRequest(process: any, request: any): void {
|
||||
try {
|
||||
const requestStr = JSON.stringify(request) + "\n";
|
||||
console.log("📤 Sending MCP request:", requestStr.trim());
|
||||
process.stdin?.write(requestStr);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to send request:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validação simples de servidor (apenas verifica se inicia)
|
||||
* Usado como fallback se validação JSON-RPC falhar
|
||||
*/
|
||||
static async simpleValidation(
|
||||
environment: MCPEnvironment,
|
||||
config: any,
|
||||
timeoutMs: number = 5000
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const command = environment.executable;
|
||||
const args = environment.args || [];
|
||||
const env = {
|
||||
...process.env,
|
||||
...environment.env,
|
||||
...(config.env || {}),
|
||||
};
|
||||
|
||||
// Para NPM, adicionar node_modules/.bin ao PATH para encontrar binários locais
|
||||
if (environment.type === "npm" && environment.path) {
|
||||
const nodeBinPath = `${environment.path}/node_modules/.bin`;
|
||||
env.PATH = `${nodeBinPath}:${env.PATH || process.env.PATH}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverProcess = spawn(command, args, {
|
||||
cwd: environment.path, // Executar do diretório de instalação
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let hasResolved = false;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
serverProcess.kill();
|
||||
// Se chegou ao timeout sem errar, provavelmente está rodando
|
||||
resolve({ success: true });
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Failed to start: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Exited with code ${code}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Validation error: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Serviço para detectar capabilities de function calling dos modelos
|
||||
*/
|
||||
|
||||
import type { ModelCapabilities } from "@/lib/types/mcp-chat";
|
||||
|
||||
/**
|
||||
* Lista de modelos com suporte nativo a function calling
|
||||
* Baseado na documentação do Ollama (atualizado em nov 2024)
|
||||
*/
|
||||
const MODELS_WITH_NATIVE_TOOLS = [
|
||||
// Llama 3.x
|
||||
/^llama3\.1/i,
|
||||
/^llama3\.2/i,
|
||||
/^llama3-/i,
|
||||
|
||||
// Mistral
|
||||
/^mistral/i,
|
||||
/^mistral-nemo/i,
|
||||
/^mixtral/i,
|
||||
|
||||
// Gemma
|
||||
/^gemma2/i,
|
||||
|
||||
// Qwen
|
||||
/^qwen2\.5/i,
|
||||
|
||||
// Command-R
|
||||
/^command-r/i,
|
||||
|
||||
// Firefunction
|
||||
/^firefunction/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Detecta capabilities de function calling de um modelo
|
||||
* @param modelName Nome do modelo (ex: "llama3.1:8b", "qwen2.5:7b")
|
||||
* @returns Capabilities do modelo
|
||||
*/
|
||||
export function detectModelCapabilities(modelName: string): ModelCapabilities {
|
||||
const supportsNativeTools = MODELS_WITH_NATIVE_TOOLS.some((pattern) =>
|
||||
pattern.test(modelName)
|
||||
);
|
||||
|
||||
return {
|
||||
modelName,
|
||||
supportsNativeTools,
|
||||
requiresPromptEngineering: !supportsNativeTools,
|
||||
toolCallFormat: supportsNativeTools ? "openai" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um modelo específico suporta function calling nativo
|
||||
* @param modelName Nome do modelo
|
||||
* @returns true se suporta, false caso contrário
|
||||
*/
|
||||
export function supportsNativeTools(modelName: string): boolean {
|
||||
return detectModelCapabilities(modelName).supportsNativeTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os modelos conhecidos com suporte a function calling
|
||||
* @returns Array de padrões regex em string
|
||||
*/
|
||||
export function getSupportedModelsPatterns(): string[] {
|
||||
return [
|
||||
"llama3.1:*",
|
||||
"llama3.2:*",
|
||||
"mistral:*",
|
||||
"mixtral:*",
|
||||
"gemma2:*",
|
||||
"qwen2.5:*",
|
||||
"command-r:*",
|
||||
"firefunction:*",
|
||||
];
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
export const DEFAULT_SYSTEM_PROMPT = `Você é um assistente de IA especializado em desenvolvimento de software e análise técnica.
|
||||
|
||||
FORMATAÇÃO OBRIGATÓRIA:
|
||||
- Sempre use blocos de código markdown com especificação de linguagem
|
||||
- Para JavaScript/TypeScript: \`\`\`javascript ou \`\`\`typescript
|
||||
- Para Python: \`\`\`python
|
||||
- Para Bash/Shell: \`\`\`bash
|
||||
- Para SQL: \`\`\`sql
|
||||
- Para JSON: \`\`\`json
|
||||
- Para YAML: \`\`\`yaml
|
||||
- Para Docker: \`\`\`dockerfile
|
||||
- Para configurações: \`\`\`toml, \`\`\`ini, \`\`\`conf
|
||||
|
||||
ESTRUTURA DE RESPOSTAS:
|
||||
1. Explicação conceitual clara
|
||||
2. Exemplo prático em bloco de código
|
||||
3. Explicação do código quando necessário
|
||||
4. Boas práticas
|
||||
5. Possíveis problemas e soluções
|
||||
|
||||
CONTEXTO OLLAHUB:
|
||||
- Execução local via Ollama
|
||||
- Stack frequente: Next.js, React, Node.js
|
||||
- Priorize exemplos executáveis e explique comandos e configurações.`;
|
||||
|
||||
export function mergeSystemPrompt(userPrompt?: string) {
|
||||
if (!userPrompt || userPrompt.trim().length === 0)
|
||||
return DEFAULT_SYSTEM_PROMPT;
|
||||
return `${DEFAULT_SYSTEM_PROMPT}\n\nDiretivas do usuário:\n${userPrompt}`;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Tipos e interfaces para integração de MCPs no chat
|
||||
*/
|
||||
|
||||
/**
|
||||
* Representa um tool MCP com schema JSON
|
||||
*/
|
||||
export interface MCPTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: "object";
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contexto de MCPs ativos para a conversa
|
||||
*/
|
||||
export interface MCPChatContext {
|
||||
mcpId: string;
|
||||
enabled: boolean;
|
||||
tools: MCPTool[];
|
||||
config: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resultado da execução de um MCP
|
||||
*/
|
||||
export interface MCPExecutionResult {
|
||||
success: boolean;
|
||||
toolName: string;
|
||||
mcpId: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
executionTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capabilities de function calling do modelo
|
||||
*/
|
||||
export interface ModelCapabilities {
|
||||
supportsNativeTools: boolean;
|
||||
requiresPromptEngineering: boolean;
|
||||
modelName: string;
|
||||
toolCallFormat?: "openai" | "anthropic" | "custom";
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuração de MCPs para chat
|
||||
*/
|
||||
export interface MCPChatConfig {
|
||||
alwaysActive: string[]; // MCPs sempre ativos
|
||||
neverUse: string[]; // MCPs nunca usar
|
||||
activationMode: "automatic" | "keywords" | "manual";
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent detectado na mensagem do usuário
|
||||
*/
|
||||
export interface DetectedIntent {
|
||||
type:
|
||||
| "web_search"
|
||||
| "time"
|
||||
| "weather"
|
||||
| "file"
|
||||
| "code"
|
||||
| "database"
|
||||
| "other";
|
||||
confidence: number;
|
||||
keywords: string[];
|
||||
suggestedTools: string[];
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Tipos e interfaces para o sistema de instalação MCP
|
||||
*/
|
||||
|
||||
export type PackageRegistry = "npm" | "pypi" | "cargo" | "other" | null;
|
||||
|
||||
export enum MCPInstallationStatus {
|
||||
PENDING = "pending",
|
||||
CHECKING_DEPENDENCIES = "checking_dependencies",
|
||||
DOWNLOADING = "downloading",
|
||||
INSTALLING = "installing",
|
||||
TESTING = "testing",
|
||||
READY = "ready",
|
||||
FAILED = "failed",
|
||||
}
|
||||
|
||||
export interface InstallationProgress {
|
||||
status: MCPInstallationStatus;
|
||||
message: string;
|
||||
percentage: number;
|
||||
logs?: string[];
|
||||
}
|
||||
|
||||
export interface MCPEnvironment {
|
||||
type: "npm" | "python" | "rust";
|
||||
path: string;
|
||||
executable: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
success: boolean;
|
||||
protocol: "mcp" | "unknown";
|
||||
tools: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: any;
|
||||
}>;
|
||||
resources?: Array<{
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
error?: string;
|
||||
capabilities?: any;
|
||||
}
|
||||
|
||||
export interface SystemDependencies {
|
||||
node: boolean;
|
||||
npm: boolean;
|
||||
python: boolean;
|
||||
pip: boolean;
|
||||
cargo: boolean;
|
||||
git: boolean;
|
||||
}
|
||||
|
||||
export interface InstallationConfig {
|
||||
mcpId: string;
|
||||
packageName: string;
|
||||
packageRegistry: PackageRegistry;
|
||||
repositoryUrl?: string;
|
||||
config: any;
|
||||
enableLogs: boolean;
|
||||
}
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user